Files
stickman/specs/phase_9_prop_editor.md
T
ryan b66ded5f60 Add headless regression tests for Prop Editor functionality
- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality.
- Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management.
- Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens.
- Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
2026-09-19 21:26:03 -04:00

555 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_VERSION>` (`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.<preset>.fill/outline (valid color string)
│ missing / invalid
▼
PropTheme.DEFAULT_CONFIG material_tints.<preset>.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.<name>.<key> → 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_<mtime>.png
5. (headless / blank) tex == null → skip silently (placeholder on load side)
```
`ThumbnailCache` extension:
```gdscript
func prop_file_key(path: String) -> String: # "<basename>_<mtime>"
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 `<basename>_<mtime>.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.*