Files
stickman/docs/specs/phase_9_prop_editor.md
T

31 KiB
Raw Blame History

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: docs/archive/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.

Recommendation: Add a new standalone scenes/editors/prop/prop_editor.tscn + scripts/editors/prop/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/editors/stickman/stickman_editor.tscn and scenes/stage/asset_selector.tscn):

PropEditor (Control)                              [script = scripts/editors/prop/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/editors/prop/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://content/props/*.prop (new content/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/editors/prop/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.

Recommendation: A new scripts/editors/prop/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/shared/thumbnails/prop_thumbnail.gd (PropThumbnail) and scripts/shared/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/editors/prop/prop_editor.tscn new Minimal shell: %MenuBar, %TopBar/%PropNameEdit, %MainLayout/%CanvasPanel/%PropCanvas/%InspectorColumn/%InspectorList, %StatusBar/%CursorCoords/%SaveStatus, %SaveDialog, %LoadDialog, %ClearConfirmDialog, %ErrorDialog.
scripts/editors/prop/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/editors/prop/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/editors/prop/prop_theme.gd (PropTheme) new Theme loader (deep-merge, validated accessors, warn-once), mirrors SandboxTheme.
scripts/editors/prop/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/shared/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/props/prop_block.gd (PropBlock) unchanged mass_for/physics_material/tint_for, ShapeType, MaterialPreset, color/width constants reused as-is.
scripts/props/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/architecture/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")

{
  "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")

{
  "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):

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

# 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

class_name PropFile
extends RefCounted

const FILE_VERSION := "1.0"
const FILE_EXT := ".prop"
const DEFAULT_DIR := "res://content/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)

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

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):

{
  "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

class_name PropEditor
extends Control

const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
const PROP_THEME := preload("res://scripts/editors/prop/prop_theme.gd")
const PROP_THUMBNAIL := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
const THUMBNAIL_CACHE := preload("res://scripts/shared/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):

Save "res://content/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:

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/editors/prop/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 ColorPickerButtons for fill + outline (override preset colors) Custom fill/outline override preset tint and update stroke/fill live.
4.1 Thumbnail generator reuse scripts/shared/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.