# Phase 9 — Prop Editor (Vector Prop Authoring Tool) Status: **approved** (2026-09-18; all seven decisions and the five open choices resolved per user approval). Source plan: `plans/PHASE_9_PROP_EDITOR.md` (four phases: theme/tokens → vector geometry & vertex editing → physics inspector → thumbnail caching). --- ## 1. Scope and proposed implementation decisions This phase adds a **standalone Prop Editor** — the authoring counterpart to the Stickman Editor — for creating, editing, and saving vector prop assets (`.prop` files). It mirrors the existing `stickman_editor.gd` / `body_part_panel.gd` conventions (menu bar, save/load/clear, JSON serialization, canvas with zoom/pan/vertex-edit/gizmos, status bar) but adapts the canvas shape model to prop geometry (polygon / circle / line primitives, per-shape fill + outline + width, material presets) and adds the missing transform gizmos (translate / rotate / scale). The seven design decisions below are **approved** (2026-09-18). Each is stated with its rationale and its approved resolution. ### Decision 1 — Standalone editor scene (recommended) **Recommendation:** Add a new standalone `scenes/prop_editor.tscn` + `scripts/prop_editor.gd` (`class_name PropEditor extends Control`), launched via **F6**, **not** the configured `run/main_scene`. **Rationale:** - Matches the established pattern: the stickman editor is a standalone `Control` scene; the sandbox stage is a separate scene/tool. The prop editor is an *authoring* tool, not a sandbox concern. - Keeps `sandbox_stage.gd` (already a large controller owning EDIT/DIRECT/PLAY, asset selector, rule builder, terrain, director) decoupled from authoring UI. - Nothing in the plan (Tasks 1.1–4.2) references sandbox placement; `.prop` files only need to round-trip into `PropUtils` *payloads* so placement can come later (see Decision 2). **Node layout** (minimal `.tscn` shell, `%UniqueName` markers, UI built in code — mirrors `scenes/stickman_editor.tscn` and `scenes/asset_selector.tscn`): ```text PropEditor (Control) [script = scripts/prop_editor.gd] ├── %MenuBar (MenuBar) File / Edit / View ├── %TopBar (HBoxContainer) y = 32..64 │ ├── NameLabel (Label "Prop Name:") │ └── %PropNameEdit (LineEdit, placeholder "Unnamed Prop") ├── %MainLayout (HBoxContainer) y = 68..-28 │ ├── %CanvasPanel (PanelContainer, stretch 2) styled dark bg (StyleBoxFlat) │ │ └── %PropCanvas (Control) [script = scripts/prop_canvas.gd] │ └── %InspectorColumn (VBoxContainer, stretch 1, ~300 px) │ └── %InspectorList (VBoxContainer) controls built in code (Task 1.3 / 3.x) ├── %StatusBar (HBoxContainer) bottom 28 px │ ├── %CursorCoords (Label) "X: ### Y: ###" │ └── %SaveStatus (Label) material preset summary / dirty flag ├── %SaveDialog (FileDialog, filters "*.prop ; Prop Files") ├── %LoadDialog (FileDialog, filters "*.prop ; Prop Files") ├── %ClearConfirmDialog (ConfirmationDialog) └── %ErrorDialog (AcceptDialog) ``` ### Decision 2 — `.prop` file format: versioned JSON, multi-shape, PropUtils-compatible **Recommendation:** A versioned JSON asset at `res://props/*.prop` (new `props/` directory), version `"1.0"`, storing a **multi-shape stack** for full editor fidelity **plus** a **pure flattening converter** so any saved prop can later be placed via the existing `PropUtils.spawn_prop(container, pos, payload, material_preset)`. **Rationale:** - The plan (Tasks 2.1, 2.4) is unambiguous: props are built from a **shape stack** with z-order ("Bring Forward"/"Send Back"), mirror, and clipboard — exactly like `BodyPartPanel`. - `PropBlock`/`PropUtils` are single-shape (`type` + `points|radius` + colors). So the file stores the editor's full `shapes[]` and a **deterministic static converter** `PropFile.flatten_to_payload(shapes)` produces the single payload the runtime already consumes. - Backward/forward compatible: the file is versioned and unknown/missing keys fall back gracefully (AGENTS.md rule: never change the meaning of an existing key). The exact schema and the flattening rule are in Section 3. ### Decision 3 — `prop_theme.json` schema + fallback hierarchy **Recommendation:** A committed `res://prop_theme.json` (top-level `res://`, matching the existing `res://sandbox_theme.json`), loaded once at editor startup by a new `scripts/prop_theme.gd` (`class_name PropTheme extends RefCounted`) modeled on `SandboxTheme`. Fallback hierarchy: **custom file → `PropTheme.DEFAULT_CONFIG` → hardcoded constants** (see Section 3 diagram). **Rationale:** - `SandboxTheme` already implements the exact loader mechanics this needs (deep-merge, `value()`/`color()`/`number()` accessors, warn-once, clamp, invalid-input fallback). A parallel `PropTheme` keeps the two theme surfaces independently versioned and avoids entangling the sandbox theme with prop-editor tokens. - Physics values are deliberately **not** themeable (Decision 5) — only visual tokens and primitive defaults come from the theme. ### Decision 4 — Dedicated `PropCanvas` (recommended) vs reusing `BodyPartPanel` **Recommendation:** A new `scripts/prop_canvas.gd` (`class_name PropCanvas extends Control`) that mirrors `BodyPartPanel`'s zoom/pan/vertex-edit/z-order behavior but adds prop-specific needs. **Rationale:** - `BodyPartPanel` is a `PanelContainer` with an embedded title label + single-`Color` picker and a stickman shape schema (`shape_type` ∈ `"line"|"rectangle"|"circle"`, one `color` hex per shape). Props need **per-shape `fill_color` + `outline_color` + `outline_width`**, a `"polygon"` primitive, and **translate/rotate/scale gizmos** (Task 2.5) that `BodyPartPanel` does not implement (it only drags vertices/shape position). - `BodyPartPanel` delegates copy/paste to the editor; the prop canvas owns a local clipboard (Task 2.7). Reusing it would require invasive branching. Full public API in Section 3. ### Decision 5 — Reuse `PropBlock` static preset tables as the single source of truth **Recommendation:** `PropBlock.mass_for()`, `PropBlock.physics_material()`, and `PropBlock.tint_for()` remain the single source of truth for Task 1.2 / 3.1. `prop_theme.json` provides only **visual overrides / fallbacks** (material tint colors, outline width, primitive dimensions) and **never** duplicates mass/friction/bounce. **Rationale:** - Avoids a second copy of preset physics that could drift from the runtime `PropBlock`. - Keeps "what does a WOOD prop weigh/feel like" in one place; the theme is presentation-only. ### Decision 6 — Reuse `PropThumbnail` + `ThumbnailCache`; mtime-keyed custom-prop PNGs **Recommendation:** Reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail`) and `scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) for Tasks 4.1/4.2. The editor calls `PropThumbnail.render(payload, material_preset)` then `ThumbnailCache.save_png(...)` on every save. Add **one small extension** to `ThumbnailCache`: mtime-keyed paths for custom `.prop` files (`prop_file_key(path)` / `prop_file_png(path)`), mirroring the existing `stickman_key()`/`stickman_png()`, so re-edited custom props invalidate their cached PNG. **Rationale:** - `PropThumbnail` already renders the exact payload + preset a saved prop produces, into a 200×200 transparent texture. No second renderer is needed. - Built-in template thumbnails stay keyed by `id_v` (`prop_png(id)`); custom `.prop` files are keyed by basename + mtime (they can be edited and re-saved, so version alone is insufficient). Save-side flow in Section 3. ### Decision 7 — Headless tests for all non-drag behavior; manual verification for gizmo drags **Recommendation:** Four headless suites under `tests/` (schema round-trip, theme fallback, canvas math incl. sanitize/clipboard/mirror/z-order, thumbnail cache keying). Interactive vertex/gizmo dragging (Tasks 2.2, 2.5) is manual verification only, because pointer-drag geometry cannot be asserted headlessly and matches existing practice (`test_phase3b_library.gd` etc.). Details in Section 4 and the per-suite coverage in Section 2. --- ## 2. Targets and ownership | Target | Kind | Change | | --- | --- | --- | | `scenes/prop_editor.tscn` | **new** | Minimal shell: `%MenuBar`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%PropCanvas`/`%InspectorColumn`/`%InspectorList`, `%StatusBar`/`%CursorCoords`/`%SaveStatus`, `%SaveDialog`, `%LoadDialog`, `%ClearConfirmDialog`, `%ErrorDialog`. | | `scripts/prop_editor.gd` (`PropEditor`) | **new** | Main controller: menu bar, save/load/clear, JSON (de)serialization, inspector build + signal fan-out, material-preset application, thumbnail save. | | `scripts/prop_canvas.gd` (`PropCanvas`) | **new** | Multi-shape canvas: zoom/pan, vertex drag, insert/remove vertex, z-order, mirror, clipboard, translate/rotate/scale gizmos, grid/snap, sanitize-on-commit, cursor hit-testing. | | `scripts/prop_theme.gd` (`PropTheme`) | **new** | Theme loader (deep-merge, validated accessors, warn-once), mirrors `SandboxTheme`. | | `scripts/prop_file.gd` (`PropFile`) | **new** | Static `.prop` schema: `to_file_dict`/`from_file_dict`, `save`/`load`, preset string⇄enum, `sanitize_shape`, `flatten_to_payload`. | | `res://prop_theme.json` | **new** | Committed theme file (schema `"1.0"`); editable presentation tokens. | | `scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) | **modified** | Add `prop_file_key(path)` / `prop_file_png(path)` (mtime-keyed custom-prop PNGs). No change to existing `prop_png`/`stickman_*`. | | `scripts/prop_block.gd` (`PropBlock`) | **unchanged** | `mass_for`/`physics_material`/`tint_for`, `ShapeType`, `MaterialPreset`, color/width constants reused as-is. | | `scripts/prop_utils.gd` (`PropUtils`) | **unchanged** | `spawn_prop`/`_apply_shape_payload`/`create_*` consumed by `PropFile.flatten_to_payload`. | | `tests/test_prop_editor_file.gd` | **new** | `.prop` schema: `from_file_dict`/`to_file_dict` round-trip, version rejection, missing-key defaults, preset string⇄enum, `flatten_to_payload` (polygon/circle/line, empty, z-order preference), `sanitize_shape`. | | `tests/test_prop_editor_theme.gd` | **new** | `PropTheme` fallback hierarchy (file → `DEFAULT_CONFIG` → hardcoded), invalid color/number fallback, physics-not-themeable assertion. | | `tests/test_prop_editor_canvas.gd` | **new** | `PropCanvas` math: insert/remove vertex + vertex-flags, mirror X/Y about bbox center, `bring_forward`/`send_back`, clipboard deep-copy + `CLIPBOARD_PASTE_OFFSET`, sanitize round-trip via `TerrainUtils.sanitize_points`, scale/rotate transform math. | | `tests/test_prop_editor_thumb.gd` | **new** | `ThumbnailCache.prop_file_key`/`prop_file_png` formatting, `prop_png` unchanged, `save_png`→`load_png` round-trip, missing-file null. | | `docs/tech_debt_and_optimizations.md` | **modified** | Log the multi-shape→single-`PropBlock` flattening trade-off and the mtime-keyed custom-prop thumbnail cache note. | **Layout/theme note:** the inspector column width, canvas default size, and dialog sizes are open choices (Section 5) — recorded as recommended defaults in Section 3, not baked into the shell. --- ## 3. Static contracts ### 3.1 `.prop` file schema (version `"1.0"`) ```json { "version": "1.0", "prop_name": "My Crate", "material_preset": "wood", "shapes": [ { "shape_type": "polygon", "points": [ { "x": -24.0, "y": -24.0 }, { "x": 24.0, "y": -24.0 }, { "x": 24.0, "y": 24.0 }, { "x": -24.0, "y": 24.0 } ], "fill_color": "#8C6133", "outline_color": "#40290F", "outline_width": 2.0, "closed": true, "vertex_flags": [0, 0, 0, 0] }, { "shape_type": "circle", "radius": 24.0, "fill_color": "#E63333", "outline_color": "#590D0D", "outline_width": 2.0, "closed": true, "vertex_flags": [] } ], "metadata": { "created_at": "2026-09-18T10:00:00", "modified_at": "2026-09-18T10:00:00" } } ``` Field contract: | Key | Type | Notes | | --- | --- | --- | | `version` | string | `"1.0"`. Unknown → load error (see `SUPPORTED_VERSIONS`). | | `prop_name` | string | Display name; empty falls back to filename basename. | | `material_preset` | string | `"none"\|"wood"\|"rubber"\|"cardboard"\|"metal"`; missing/invalid → `"none"`. | | `shapes` | array of objects | Z-order = array order (index 0 = back, last = front). | | `shapes[i].shape_type` | string | `"polygon"\|"circle"\|"line"`. Missing → `"polygon"`. | | `shapes[i].points` | array of `{x,y}` | Polygon/line vertex list. Missing → `[]`. | | `shapes[i].radius` | float | Circle only; default `24.0`. | | `shapes[i].fill_color` / `outline_color` | `#RRGGBB` / `#RRGGBBAA` string | Defaults from material tint (Section 3.3). | | `shapes[i].outline_width` | float | Default `PropBlock.DEFAULT_OUTLINE_WIDTH` (2.0). | | `shapes[i].closed` | bool | Polygon/circle `true`; line `false`. | | `shapes[i].vertex_flags` | array of int | `0` = original, `1` = user-created (mirrors stickman editor). Length == `points.size()` (circles: empty). | | `metadata` | object | Write-only provenance; ignored on load. | ### 3.2 `prop_theme.json` schema (version `"1.0"`) ```json { "version": "1.0", "outline_widths": { "default": 2.0, "min": 0.5, "max": 32.0 }, "material_tints": { "wood": { "fill": "#8C6133", "outline": "#40290F" }, "rubber": { "fill": "#E63333", "outline": "#590D0D" }, "cardboard": { "fill": "#D9B873", "outline": "#66521D" }, "metal": { "fill": "#808C99", "outline": "#262E38" }, "none": { "fill": "#8C6133", "outline": "#26201E" } }, "primitive_defaults": { "box": { "size_x": 48.0, "size_y": 48.0 }, "ball": { "radius": 24.0 }, "plank": { "length": 160.0, "thickness": 16.0 }, "triangle": { "base": 56.0, "height": 48.0 }, "line": { "length": 120.0 }, "circle": { "radius": 40.0, "segments": 12 } }, "canvas": { "grid_size": 15, "background_color": "#1F1F1F", "grid_color": "#FFFFFF26" } } ``` **Fallback hierarchy** (only visual + defaults are themed; physics is fixed): ```text material fill/outline tint: prop_theme.json material_tints..fill/outline (valid color string) │ missing / invalid ▼ PropTheme.DEFAULT_CONFIG material_tints..fill/outline │ ▼ PropBlock.tint_for(preset) ← hardcoded single source of truth (never overridden by theme) outline width default: prop_theme.json outline_widths.default → DEFAULT_CONFIG → PropBlock.DEFAULT_OUTLINE_WIDTH (2.0) primitive default dimension (box/ball/plank/triangle/line/circle): prop_theme.json primitive_defaults.. → DEFAULT_CONFIG → PropUtils.create_* defaults physics (mass / friction / bounce): PropBlock.mass_for(preset) / PropBlock.physics_material(preset) — NEVER themeable ``` ### 3.3 Shared constants and enums ```gdscript # prop_canvas.gd — editor shape type superset (superset of PropBlock.ShapeType) enum ShapeType { POLYGON, CIRCLE, LINE } enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 } # mirrors stickman editor # gizmo / canvas constants (mirror BodyPartPanel where applicable) const HANDLE_RADIUS := 3.0 const HANDLE_RADIUS_SELECTION := 8.0 const HANDLE_HOLLOW_SIZE := 6.0 const EDGE_HIT_THRESHOLD := 12.0 const ROTATE_RING_RADIUS := 40.0 # selection rotate ring (screen-constant / zoom) const SCALE_HANDLE_DISTANCE := 8.0 # corner scale-handle reach const ROTATION_SNAP_DEG := 15.0 # Ctrl-snap (deg_to_rad(15.0)) const CLIPBOARD_PASTE_OFFSET := Vector2(16, 16) const DEFAULT_CIRCLE_SEGMENTS := 12 # editor circle primitives const MIN_ZOOM := 0.3 const MAX_ZOOM := 3.0 const ZOOM_STEP := 1.10 const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15) # prop_block.gd — existing authority, reused unchanged # enum ShapeType { POLYGON, CIRCLE } # enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL } # const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0) # const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0) # const DEFAULT_OUTLINE_WIDTH := 2.0 # static func physics_material(preset) -> PhysicsMaterial # static func mass_for(preset) -> float # static func tint_for(preset) -> Color ``` ### 3.4 `PropFile` — static schema + converters ```gdscript class_name PropFile extends RefCounted const FILE_VERSION := "1.0" const FILE_EXT := ".prop" const DEFAULT_DIR := "res://props" const SUPPORTED_VERSIONS: Array[String] = ["1.0"] # index == PropBlock.MaterialPreset const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"] const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"] static func preset_to_string(preset: int) -> String # clamp into range; NONE fallback static func preset_from_string(s: String) -> int # unknown -> MaterialPreset.NONE static func shape_type_to_payload_type(s: String) -> int # "circle"->CIRCLE else POLYGON static func sanitize_shape(shape: Dictionary) -> Dictionary # sanitize polygon/line points via TerrainUtils static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary static func from_file_dict(data: Variant) -> Dictionary # returns {} on error; else { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] } static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary # returns a single PropUtils.spawn_prop payload {type, points|radius, fill_color, outline_color, outline_width} static func save(path: String, data: Dictionary) -> bool # FileAccess WRITE + JSON.stringify static func load(path: String) -> Dictionary # {} on read/parse failure (push_warning) ``` **`flatten_to_payload` rule (deterministic, testable):** 1. Prefer the **frontmost closed polygon** — scan `shapes` from last to first, pick the first with `shape_type == "polygon"` and `closed == true`. Its sanitized `points` become `{ "type": POLYGON, "points": ... }`. 2. Else prefer a `circle` shape (last→first) → `{ "type": CIRCLE, "radius": ... }`. 3. Else fall back to the **first shape's** points as a polygon (even if a line). 4. `fill_color`/`outline_color`/`outline_width` come from the chosen shape; a missing color falls back to the material tint (`PropBlock.tint_for`) / `DEFAULT_OUTLINE_COLOR`. 5. Empty `shapes` → `{}` (caller skips spawn, with one warning). Lines are editor-only (open, unfilled) and never become collision geometry (see Section 5). ### 3.5 `PropTheme` — validated loader (mirrors `SandboxTheme`) ```gdscript class_name PropTheme extends RefCounted const SCHEMA_VERSION := "1.0" const DEFAULT_PATH := "res://prop_theme.json" const DEFAULT_CONFIG: Dictionary = { /* mirrors committed prop_theme.json — see 3.2 */ } func load_from(path: String = DEFAULT_PATH) -> bool func value(path: String, fallback: Variant = null) -> Variant # dotted-path access func color(path: String, fallback: Color) -> Color # #RRGGBB[AA] validation + warn-once func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float func material_tint(preset: int, channel: String) -> Color # channel "fill"|"outline" func outline_width_default() -> float func primitive_default(primitive: String, key: String) -> float # e.g. ("box","size_x") func grid_size() -> int ``` `material_tint(preset, channel)` implements the hierarchy in 3.2: theme file → `DEFAULT_CONFIG` → `PropBlock.tint_for(preset)` (and `.darkened(0.55)` for the outline channel's final fallback, matching `PropBlock._apply_material_preset()`). ### 3.6 `PropCanvas` — multi-shape canvas + gizmos ```gdscript class_name PropCanvas extends Control signal shape_changed(shapes: Array[Dictionary]) signal shape_selected(idx: int) # public API func set_shape_data(shapes: Array[Dictionary]) -> void # import; resets zoom/pan/selection func get_shape_data() -> Array[Dictionary] # export (plain dicts, {x,y} points) func clear() -> void # no shape_changed emit (matches clear_shape) func select(idx: int) -> void func deselect() -> void func get_selected_index() -> int func set_grid_size(size: int) -> void func set_snap_enabled(enabled: bool) -> void func reset_view() -> void func add_primitive(primitive: String, center: Vector2, params: Dictionary = {}) -> Dictionary # primitive ∈ "box"|"ball"|"plank"|"triangle"|"line"|"circle"; appends; returns the new shape func insert_vertex(shape_idx: int, vertex_idx: int, world_pos: Vector2) -> void # flags it USER_CREATED func remove_vertex(shape_idx: int, vertex_idx: int) -> void # <2 vertices -> drop shape func bring_forward(idx: int) -> void func send_back(idx: int) -> void func mirror_x(idx: int) -> void # invert X about bbox center func mirror_y(idx: int) -> void # invert Y about bbox center func copy_shape(idx: int) -> Dictionary # deep duplicate (duplicate(true)) func paste_shape(source: Dictionary, target_pos: Vector2) -> void # offset by CLIPBOARD_PASTE_OFFSET func translate_selected(delta: Vector2) -> void func rotate_selected(angle_rad: float) -> void # Ctrl snap applied by the drag handler func scale_selected(factor: Vector2) -> void # Ctrl → uniform (locked aspect) func is_cursor_over_drawing(global_pos: Vector2) -> bool func global_to_world(global_pos: Vector2) -> Vector2 func set_has_clipboard(state: bool) -> void # enables "Paste" in context menu ``` Internal shape dictionary (editor-space, mirrors `BodyPartPanel`): ```gdscript { "shape_type": "polygon", # "polygon"|"circle"|"line" "points": PackedVector2Array(), # polygon/line only "radius": 24.0, # circle only "fill_color": "#8C6133", # hex string "outline_color": "#40290F", # hex string "outline_width": 2.0, "closed": true, "vertex_flags": PackedInt32Array(), # 0=original, 1=user-created } ``` Input handling order (mirrors `BodyPartPanel._on_drawing_area_gui_input`): magnify/pinch zoom → pan gesture → middle-mouse pan → wheel zoom (cursor-centered) → **gizmo hit-test** (rotate ring / scale corner on the selected shape) → right-click context menu (vertex = "Remove Point"; outline = "Create Point"; else "Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back"; empty canvas = "Add Box/Ball/Plank/Triangle/Line/Circle") → left-click vertex drag / shape drag. ### 3.7 `PropEditor` — controller ```gdscript class_name PropEditor extends Control const PROP_FILE := preload("res://scripts/prop_file.gd") const PROP_THEME := preload("res://scripts/prop_theme.gd") const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd") const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd") @onready var _menu_bar: MenuBar = %MenuBar @onready var _prop_name_edit: LineEdit = %PropNameEdit @onready var _canvas: PropCanvas = %PropCanvas @onready var _save_dialog: FileDialog = %SaveDialog @onready var _load_dialog: FileDialog = %LoadDialog @onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog @onready var _error_dialog: AcceptDialog = %ErrorDialog @onready var _cursor_coords: Label = %CursorCoords @onready var _save_status: Label = %SaveStatus func _ready() -> void func _setup_menu_bar() -> void # File (Save/Load/Clear), Edit (Configure Grid/Snap), View (Reset Views) func _build_inspector() -> void # builds %InspectorList children (Task 1.3 / 3.1 / 3.2) func _on_save_file_selected(path: String) -> void func _on_load_file_selected(path: String) -> void func _on_clear_confirmed() -> void func _build_json_data() -> Dictionary # PropFile.to_file_dict(...) func _apply_json_data(json: Variant) -> String # "" on success, else error text func _refresh_inspector_from_selection() -> void func _apply_material_preset(preset: int) -> void func _save_prop_thumbnail() -> void # async (await render) ``` **Save-side thumbnail flow** (Task 4.2): ```text Save "res://props/mycrate.prop" 1. PropFile.save(path, data) # write .prop 2. payload = PropFile.flatten_to_payload(shapes) 3. tex = await _prop_thumbnail.render(payload, material_preset) 4. if tex != null: _thumb_cache.save_png(tex, _thumb_cache.prop_file_png(path)) # 200x200, user://thumbnails/props/mycrate_.png 5. (headless / blank) tex == null → skip silently (placeholder on load side) ``` `ThumbnailCache` extension: ```gdscript func prop_file_key(path: String) -> String: # "_" func prop_file_png(path: String) -> String: # PROP_DIR + "/" + key + ".png" ``` --- ## 4. Task-to-implementation mapping | Plan task | Fulfilled by | Acceptance criteria | | --- | --- | --- | | **1.1** Prop design-token schema | `scripts/prop_theme.gd` + `res://prop_theme.json` | File parses and loads on `PropEditor._ready()`; missing keys fall back (file → `DEFAULT_CONFIG` → hardcoded) with no runtime error. | | **1.2** Material preset visual utility | `PropEditor._apply_material_preset()` reading `PropBlock.mass_for`/`physics_material`/`tint_for` (via `PropTheme.material_tint` override) | Selecting a preset populates default mass/friction/bounce (from `PropBlock`) and fill/outline tint. | | **1.3** Inspector theme | `PropEditor._build_inspector()` applying a local `Theme` (from `PropTheme`) to `OptionButton`/`SpinBox`/`ColorPicker`/`Button` | Controls match styling; no global GUI theme or sandbox scene mutation. | | **2.1** Multi-shape creation & primitive controls | `PropCanvas.add_primitive()` + inspector mode-specific params (radius for circle; vertex list for polygon) | Adding a shape appends to the stack; switching primitive modes exposes `radius` vs `points` params. | | **2.2** Vertex drag & reshape | `PropCanvas` vertex hit-test + `InputEventMouseMotion` drag (mirrors `BodyPartPanel._handle_left_press`/`_on_drawing_area_gui_input`) | Dragging updates geometry live; release commits (emits `shape_changed`). | | **2.3** Contextual vertex insert/delete | `PropCanvas.insert_vertex()` / `remove_vertex()` via right-click "Create Point"/"Remove Point" | Insert adds a `USER_CREATED` vertex at edge midpoint; delete closes the segment; <2 vertices drops the shape. | | **2.4** Selection, context menu, z-order | `PropCanvas` hit-testing + context menu + `bring_forward()`/`send_back()` | Left-click highlights bbox/handles; Delete removes; z-order alters draw order. | | **2.5** Translate/rotate/scale gizmos | `PropCanvas` gizmo hit-test + drag handlers (`translate_selected`/`rotate_selected`/`scale_selected`); Ctrl = 15° rotate snap + uniform scale | Drag translates all vertices; Ctrl snaps rotation to 15°; Ctrl locks aspect ratio on scale. | | **2.6** Mirror engine | `PropCanvas.mirror_x()`/`mirror_y()` (invert about bbox center) | Mirror X/Y invert coordinates about the shape center. | | **2.7** Clipboard | `PropCanvas.copy_shape()` (deep dup) + `paste_shape()` (offset `CLIPBOARD_PASTE_OFFSET`) + `set_has_clipboard()` | Copy stores a deep duplicate; paste inserts an offset duplicate; "Paste" disabled when empty. | | **2.8** Live vector sync | `PropCanvas` → `shape_changed` → `PropEditor` inspector writes fill/outline/width back into the shape dict + `queue_redraw` | Modifying fill/outline/width reflects instantly on canvas. | | **2.9** Sanitization | `PropCanvas` commit path + `PropFile.sanitize_shape()` calling `TerrainUtils.sanitize_points()` | Committed polygons are grid-snapped, redundant/collinear-stripped, clockwise. | | **3.1** Material preset picker | Inspector `OptionButton` → `PropEditor._apply_material_preset()` | Changing preset updates displayed mass/friction/restitution (from `PropBlock`). | | **3.2** Color swatches | Inspector `ColorPickerButton`s for fill + outline (override preset colors) | Custom fill/outline override preset tint and update stroke/fill live. | | **4.1** Thumbnail generator | reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail.render`) | Renders off-screen 200×200 transparent PNG without affecting main editor. | | **4.2** Disk cache pipeline | `PropEditor._save_prop_thumbnail()` + `ThumbnailCache.prop_file_png`/`save_png`/`load_png` | Save exports `_.png`; load reads cached PNG if source unchanged (mtime key). | --- ## 5. Design choices — user-approved resolutions 1. **Multi-shape → single `PropBlock` flattening.** **Approved rule:** frontmost closed polygon → else circle → else first shape (spec 3.4, `flatten_to_payload`). 2. **Line primitives and physics.** **Approved:** lines are editor-only (drawn, never flattened into collision geometry). 3. **Sandbox asset-selector integration.** **Approved direction:** `.prop` files will later appear in the sandbox "Prop" selector; **not** wired this phase. 4. **Menu-bar entry in the main editor.** **Approved:** F6-standalone only this phase (no "Prop Editor…" item in the stickman editor menu bar). 5. **Default canvas / window size.** **Approved:** canvas 1200×800 with a ~300 px inspector column; window resizable, no size persistence. --- ## 6. Architectural alternatives and deferred work - **`Control`-based editor vs an in-world Node2D tool:** an in-world (`Node2D`) prop editor would share the sandbox camera/zoom, but props are authored assets, not stage objects; the `Control` canvas reuses the proven `BodyPartPanel` zoom/pan/input model and keeps the file format independent of stage transforms. - **Reuse `BodyPartPanel` directly:** rejected (Decision 4) — the prop shape model (fill + outline + width, polygon primitives, transform gizmos) diverges enough that a dedicated `PropCanvas` is cheaper than branching. - **Physics theme tokens:** deliberately excluded from `prop_theme.json` (Decision 5). If per-project physics tuning is ever wanted, a separate, explicit physics-override file is preferable to silently changing runtime feel via a visual theme. - **Multi-shape `PropBlock`:** a future phase could compose multiple shapes into one prop (multiple `Polygon2D`/`Line2D` children + merged collision). Until then the single-shape `PropBlock` + flattening converter is the contract; recorded in the tech-debt log. --- *Validation will follow the existing pattern: per-script `--check-only`, then each `tests/test_prop_editor_*.gd` headless suite printing PASS/FAIL and `quit(0/1)`. Interactive vertex/gizmo drags are manual F6 verification.*