Author SHA1 Message Date
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
34 changed files with 7021 additions and 2 deletions
+259
View File
@@ -14,6 +14,8 @@
- `<project>` = the repo root (e.g. `/Users/ryan/Projects/stickman`, or `C:\Godot4\stickman` on Windows). - `<project>` = the repo root (e.g. `/Users/ryan/Projects/stickman`, or `C:\Godot4\stickman` on Windows).
- **Run a headless test suite** (`tests/*.gd` extend `SceneTree`, print PASS/FAIL, `quit(0/1)`): - **Run a headless test suite** (`tests/*.gd` extend `SceneTree`, print PASS/FAIL, `quit(0/1)`):
`<godot> --headless --path <project> --script res://tests/test_cli_scene.gd` `<godot> --headless --path <project> --script res://tests/test_cli_scene.gd`
- Prop Editor suites (same pattern): `tests/test_prop_editor_file.gd`, `tests/test_prop_editor_theme.gd`,
`tests/test_prop_editor_canvas.gd`, `tests/test_prop_editor_thumb.gd`, `tests/test_prop_editor_round3.gd`.
- **Launch the Sandbox Stage and auto-PLAY a `.scene`** (visual/runtime debugging — `print()` output streams to the launching terminal): - **Launch the Sandbox Stage and auto-PLAY a `.scene`** (visual/runtime debugging — `print()` output streams to the launching terminal):
`<godot> --path <project> res://scenes/sandbox_stage.tscn -- --play --scene <path>.scene` `<godot> --path <project> res://scenes/sandbox_stage.tscn -- --play --scene <path>.scene`
- Args after `--` are parsed by `scripts/cli_scene_launcher.gd` (`CliSceneLauncher.parse`) via `OS.get_cmdline_user_args()`. - Args after `--` are parsed by `scripts/cli_scene_launcher.gd` (`CliSceneLauncher.parse`) via `OS.get_cmdline_user_args()`.
@@ -37,6 +39,11 @@ props, live stickman rigs, action queues, and events. The editor remains the con
`run/main_scene`; the Sandbox Stage and its runtime systems are separate scenes and tools `run/main_scene`; the Sandbox Stage and its runtime systems are separate scenes and tools
that form the foundation of the broader moviemaking experience. that form the foundation of the broader moviemaking experience.
The **Prop Editor** (Phase 9) is a standalone, kid-friendly vector-prop authoring tool — the
authoring counterpart to the stickman editor — for creating, editing, and saving `.prop`
vector prop assets. It is launched via **F6** on `res://scenes/prop_editor.tscn` and is **not**
the configured `run/main_scene`.
## Required addon ## Required addon
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/` - **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
@@ -147,6 +154,228 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
dots at alpha `0.65`. Joint dots use `GUIDE_JOINT_RADIUS / _zoom` (6 px constant screen dots at alpha `0.65`. Joint dots use `GUIDE_JOINT_RADIUS / _zoom` (6 px constant screen
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
part dragging/selection. part dragging/selection.
- `scripts/prop_editor.gd` — `class_name PropEditor`, `extends Control`; the **Prop Editor**
controller (Phase 9, **not wired into the editor**; launched via **F6** on
`res://scenes/prop_editor.tscn`). Owns the menu bar, save/load/new flow, `.prop`
(de)serialization, the material-preset + per-shape inspector, the mode toolbar, the status
bar, the window title, the exit dialog, and the autosave/recovery pipeline. State:
`_theme: PropTheme`, `_material_preset: int` (`PropBlock.MaterialPreset`), `_current_path`,
`_dirty`, `_clipboard`, `_thumb: PropThumbnail`, `_thumb_cache: ThumbnailCache`, `_ui_theme:
Theme` (local only — never mutates the global GUI theme), plus `_recent_props`.
- **Menu bar:** **📁 File** = 🆕 New / 💾 Save / 📄 Save As... / 📂 Load... / separator /
🕑 **Recent** submenu; **👁️ View** = 🔍 Reset View. There is **no Edit menu** (Round 4
removed it — grid/snap moved to the top-right controls). Menu labels carry kid-friendly
emoji.
- **Recent props (Round 5/6):** `SETTINGS_PATH := "user://prop_editor_settings.json"` (schema
`{ "version": 1, "recent_props": [...] }`), `RECENT_MAX := 5`; `_load_settings()` /
`_save_settings()` read/write the list. `static func update_recent_list(list, path,
max_count = RECENT_MAX) -> Array` is a pure helper (dedupe + front-insert + trim). The
**🕑 Recent** submenu (`FileMenu/RecentProps` child `PopupMenu`) is rebuilt on
`about_to_popup` (`_update_recent_menu`): missing files are pruned (and settings re-saved)
and an empty list shows a disabled "(No recent props)" placeholder. `_push_recent(path)` is
called on every successful **load AND save**; selecting a recents entry calls `_load_from()`,
and a missing file shows the error dialog + removes the entry. `RECENT_MAX` is 5,
most-recent-first.
- **Top-right controls (Round 4):** built in `_build_top_right_controls()` into `%TopRightControls`
(the right side of `%TopRow`, beside `%MenuBar`): a `Grid:` `Label` + `_grid_spin` `SpinBox`
(1–512, initialized from `_theme.grid_size()`) + a `Snap` `CheckBox` (`_snap_check`). This
replaces the old `Edit → Configure Grid / Snap...` dialog/menu entirely.
- **Mode toolbar (Round 2/3):** a centered `%ModeBar` `HBoxContainer` above the canvas
(`_build_mode_toolbar()`) holding two `toggle_mode` `Button`s sharing a `ButtonGroup`:
🖐️ **Arrange** (`EditMode.TRANSFORM`) and ✏️ **Reshape** (`EditMode.POINTS`).
`static func mode_button_text(mode: int) -> String` is the pure, headless-testable
label builder (`MODE_EMOJI`/`MODE_LABELS`, clamps to the last entry). Each button applies
`_theme.font_mode()` + `_theme.mode_button_min_height()` and sets
`focus_mode = FOCUS_NONE` (Round 4 fix — the `focus` stylebox draws after `pressed`, which
otherwise masked the selected/accent state until the cursor moved). `_sync_mode_buttons()`
re-presses the right button on `mode_changed` (via `set_pressed_no_signal`) and colors the
label with `accent_color()`.
- **Inspector (`_build_inspector()`, built into `%InspectorList`):** a material-preset
`OptionButton` (`PRESET_LABELS` None/Wood/Rubber/Cardboard/Metal) + read-only mass/friction/
bounce `Label`s sourced from `PropBlock.mass_for()` / `PropBlock.physics_material()`
(single source of truth — never themed), then per-shape fill/outline `ColorPickerButton`s +
an outline-width `SpinBox`, a vertex-count label, and an **Erase Shape** button, then the
**Add Shape** section (primitive `OptionButton` + per-primitive param `SpinBox`es +
**Add a Shape** button). `PRIMITIVE_IDS`/`PRIMITIVE_LABELS` = Box/Plank/Triangle/Line/Circle
(**Ball removed, Round 4** — circle covers it; `"ball"` stays available programmatically).
`_refresh_inspector_from_selection()` syncs the controls from the selected shape and gates
the **circle Radius row**: when the shape's `reshaped` flag is set the `_radius_row` is
hidden and a muted "✏️ This shape was reshaped" note (`_reshaped_note`) is shown instead.
`_sync_mode_controls(mode)` hides the Add-Shape section (`_add_section`) and the Erase
button in POINTS (Reshape) mode, called from `_on_canvas_mode_changed` and `_ready()`.
- **Window title (Round 3):** `static func build_window_title(prop_name, file_path, dirty) ->
String` is a pure, headless-testable builder producing `[name][ — filename.prop ][ *]`
(empty name → `UNTITLED_NAME` = "Untitled"; `file_path.get_file()` keeps the `.prop`
extension; ` *` when dirty). `_update_window_title()` is the single re-derivation site,
called from `_update_save_status()` (which is already invoked on every relevant transition).
`_on_save_file_selected()` normalizes the path to `.prop` so `get_file()` always carries the
extension.
- **Exit dialog (Round 3):** `get_tree().auto_accept_quit = false` in `_ready()` +
`_notification(NOTIFICATION_WM_CLOSE_REQUEST)` → `_request_close()`. Clean tree quits
immediately; dirty shows the themed `_exit_dialog` ("⚠️ Unsaved Changes") with **💾 Save** /
**🗑️ Discard** (`add_button`, action `&"discard"`) / **❌ Cancel**. Save with no
`_current_path` opens the Save dialog (the pending flag stays set); otherwise
`_save_and_finish()`. `_save_to(path, with_thumbnail)` returns success and **skips the
thumbnail render on exit-save** (the PNG is a cache nicety — a fast, deterministic quit). A
successful save-then-quit clears the autosave via `_save_to`. The **same dialog** is reused
by **File → New** (`_request_new()` sets `_pending_new`, different prompt text; Save →
`_do_new()`, Discard → `_do_new()`, Cancel → stay).
- **Autosave / recovery (Round 3):** `AUTOSAVE_PATH := "user://prop_editor_autosave.prop"`,
`AUTOSAVE_DEBOUNCE := 1.0`. Dirty setpoints route through `_mark_dirty()` (sets `_dirty`,
refreshes the status bar/title, arms `_autosave_remaining`); `_process(delta)` counts down
and calls `_write_autosave()`, which stamps `metadata.autosave = true` /
`metadata.source_path` / `metadata.modified_unix` and writes atomically via
`PropFile.save_safe()` (no re-trigger: it does not touch `_dirty` or emit `shape_changed`).
`_clear_autosave()` removes the file (also resetting the countdown). `_check_autosave_recovery()`
(called from `_ready()` after dialogs are built) prompts 🔄 **Restore** / 🗑️ **Discard** on a
stale-checked autosave (if the saved source exists and is newer-or-equal, the autosave is
dropped silently); Restore calls `_apply_json_data(raw, true)` (the `is_recovery` flag keeps
the prop **dirty**) and restores `_current_path`. Clear semantics: clear on successful
save/load/new/discard-quit; **kept after Restore**; stale autosaves are dropped at startup.
- **Save flow:** `_save_to(path, with_thumbnail = true) -> bool` writes `PropFile.save(path,
_build_json_data())`, updates `_current_path`/`_dirty`, clears the autosave, refreshes
status/title, and (when `with_thumbnail`) `await`s `_save_prop_thumbnail()`. The thumbnail
path reuses `PropThumbnailScript.render(PropFile.flatten_to_payload(shapes),
_material_preset)` then `ThumbnailCacheScript.save_png(tex, _thumb_cache.prop_file_png(path))`
(200×200 `user://thumbnails/props/<basename>_<mtime>.png`); a blank/headless capture
(`null`) is skipped silently. The editor preloads `prop_file.gd`, `prop_theme.gd`,
`prop_block.gd`, `prop_canvas.gd`, `thumbnails/prop_thumbnail.gd`, and
`thumbnails/thumbnail_cache.gd` via `preload` consts. `_apply_json_data(json, is_recovery =
false) -> String` returns `""` on success or an error string (dialog on failure).
- `scripts/prop_canvas.gd` — `class_name PropCanvas`, `extends Control`; the Prop Editor's
**multi-shape vector canvas** (Phase 9, **not used by the editor**), mirroring
`BodyPartPanel`'s proven input model (cursor-centered zoom, pinch/pan gestures, middle-mouse
pan, vertex drag, right-click context menu, z-order) while adding prop-specific needs
(per-shape fill/outline/width, polygon/circle/line primitives, transform gizmos, an in-memory
clipboard, grid-snap sanitize-on-commit). Enums `ShapeType { POLYGON, CIRCLE, LINE }`,
`VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }`, `DragMode { NONE, VERTEX, TRANSLATE, ROTATE,
SCALE }`, and `EditMode { TRANSFORM, POINTS }`. Signals `shape_changed(shapes)`,
`shape_selected(idx)`, `shape_copy_requested(shape)`, `paste_from_context_requested(world_pos)`,
`vertex_created(shape_idx, vertex_idx)`, `mode_changed(mode)`. State `shapes:
Array[Dictionary]`, `_selected_shape_idx`, `_zoom`/`_pan_offset`, `_grid_size`/`_snap_enabled`,
`_edit_mode` (default TRANSFORM), `_has_clipboard`, and the drag/rotate/scale state. Public
API mirrors the Phase 9 spec: `set_shape_data`/`get_shape_data`, `clear`, `select`/`deselect`/
`get_selected_index`, `set_grid_size`/`set_snap_enabled`/`reset_view`, `add_primitive`,
`insert_vertex`/`remove_vertex`, `bring_forward`/`send_back`, `mirror_x`/`mirror_y`,
`copy_shape`/`paste_shape`/`set_has_clipboard`, `translate_selected`/`rotate_selected`/
`scale_selected`, `is_cursor_over_drawing`/`global_to_world`, `set_shape_radius`,
`set_context_menu_font_size`, `create_point_on_edge`, `hit_test_segment`, and
`set_edit_mode`/`get_edit_mode`.
- **Zoom / pan:** wheel zoom is cursor-centered (mouse wheel and pinch), clamped to
`[MIN_ZOOM 0.3, MAX_ZOOM 3.0]` with `ZOOM_STEP 1.10`; middle-mouse drag and
`InputEventPanGesture` pan (delta × 3 for parity), matching `BodyPartPanel`/preview.
- **Edit modes (Round 2/3):** `EditMode { TRANSFORM, POINTS }` with kid-facing labels
("Arrange" / "Reshape") owned by the controller. `set_edit_mode(mode)` clamps unknown values
to TRANSFORM, cancels any in-progress drag, redraws, and emits `mode_changed` **only on a
real change**; `get_edit_mode()` reads it. The mode gates **input and drawing only** — every
math API (`insert_vertex`, `translate_selected`, `sanitize_shape`, clipboard, mirror, z-order,
`select`/`deselect`, `set_shape_data`/`clear`) stays **mode-agnostic**, and the mode
**persists across clear/load** (a tool preference).
- **Arrange (TRANSFORM) gizmos:** selection draws a bounding box, a **rotation DOT below the
bbox bottom-center** (`ROTATION_CIRCLE_RADIUS 5.0` / `ROTATION_CIRCLE_HIT_RADIUS 12.0` /
`ROTATION_CIRCLE_OFFSET 26.0`, mirroring `whole_stickman_preview`), and four corner **scale
CROSSES** (`SCALE_CROSS_SIZE 5.0` / `SCALE_CROSS_HIT_RADIUS 12.0`) — the Round 1
ring/scale-circles were replaced. Rotation uses **incremental angle accumulation**
(`_rotate_total_delta` + `_rotate_prev_angle`, wrapping-safely) so dragging a full circle
never jumps at ±180°; **Ctrl** snaps to **15°** (`ROTATION_SNAP_DEG`). Scale is free-form,
**Ctrl** locks the aspect ratio (uniform). Dragging the shape body translates it.
- **Reshape (POINTS) vertex editing (Round 2):** color-matched **3 px** vertex handles
(`HANDLE_RADIUS 3.0`, yellow while dragging; user-created vertices are hollow rects) with a
dark halo backing; vertex drag reshapes live. **Create Point** inserts at the **cursor's
projection** onto the nearest segment (clamped, closed-aware) via `create_point_on_edge()`,
which auto-selects, flags the vertex USER_CREATED, emits `vertex_created`, and **flashes the
new vertex with a 0.6 s yellow ring** (`FLASH_DURATION`/`FLASH_RING_RADIUS`). **Remove Point**
deletes a vertex; **< 2 vertices drops the shape**. In POINTS mode the shape body is
**select-only** (no translate).
- **POINTS-mode right-click matrix (Round 3):** on a vertex (selected first, then front→back)
→ **Remove Point** only; near a **segment** (within `EDGE_HIT_THRESHOLD / _zoom`, via the
public `hit_test_segment(world_pos) -> int` segment-proximity helper) → **Create Point** only
(shape is selected first); the interior body of a closed shape → **select only, no menu**;
empty canvas → **no menu** (the Add-Shape menu is disabled in POINTS mode).
- **TRANSFORM-mode right-click menu:** on a shape → **Copy / Paste / Erase / Flip Left-Right /
Flip Up-Down / Bring Forward / Send Back**; on empty canvas → **Add Box / Plank / Triangle /
Line / Circle** (**Ball removed**, Round 4) (+ Paste when the clipboard is populated). Copy
emits `shape_copy_requested` (the editor owns the clipboard slot); Paste emits
`paste_from_context_requested`; `copy_shape(idx)` returns a deep duplicate
(`duplicate(true)`) and `paste_shape(source, target_pos)` inserts it offset by
`CLIPBOARD_PASTE_OFFSET` (16, 16). `set_context_menu_font_size(size)` applies the theme's
`font_context_menu` to the RMB menu.
- **`reshaped` flag (Round 6):** every shape dict carries `reshaped: bool` (default `false`;
internal + export + `.prop` schema). It is set `true` by vertex insert/remove/drag,
`create_point_on_edge` (via insert), and **non-uniform scaling of a circle** (both the gizmo
drag and `scale_selected`); it is cleared by `set_shape_radius()` (which regenerates the
points from the radius). Translate/rotate/mirror/uniform-scale do **not** set it (they
preserve primitive-ness). The editor's inspector gates primitive-specific rows on it (today:
the circle Radius row, replaced by the muted "reshaped" note).
- **Sanitize on commit:** vertex-drag, rotate, and scale commits run the points through
`TerrainUtils.sanitize_points` (grid-snapped, clockwise, collinear-stripped), with the
`vertex_flags` winding reversed to match. `sanitize_shape` is also exposed statically on
`PropFile`.
- **Two-tone halo selection (Round 7):** every selection element — the selection polyline,
the bounding box, the rotation dot, the four scale crosses, and the POINTS-mode vertex
handles — is drawn as a **dark backing pass** (`SELECTION_HALO_COLOR`, black at 0.85 alpha,
~2 px wider) + a **bright core pass** (`SELECTION_CORE_COLOR`, white, original width). The
pair is visible on **any** background (the Round 6 fill-adaptive `contrast_color_for` was
removed because it could not know what sits *behind* a selected shape).
- `scripts/prop_file.gd` — `class_name PropFile`, `extends RefCounted`; the static `.prop`
schema + converters (Phase 9, **not used by the editor**). Consts `FILE_VERSION := "1.0"`,
`FILE_EXT := ".prop"`, `DEFAULT_DIR := "res://props"`, `SUPPORTED_VERSIONS: ["1.0"]`,
`PRESET_STRINGS` (index == `PropBlock.MaterialPreset`: none/wood/rubber/cardboard/metal),
`SHAPE_STRINGS` (polygon/circle/line), `DEFAULT_RADIUS`, `DEFAULT_CIRCLE_SEGMENTS`.
- **Schema (v1.0):** top-level `version`, `prop_name`, `material_preset`, `shapes[]`, and
write-only `metadata` (`created_at`/`modified_at`). Each shape carries `shape_type`
(`polygon`/`circle`/`line`), `points` (`[{x,y}]`, polygon/line), `radius` (circle),
`fill_color`/`outline_color` (hex), `outline_width`, `closed`, `vertex_flags` (0/1), and
**`reshaped`** (Round 6, default `false`; additive/backward-compatible). `metadata` is
ignored on load.
- `preset_to_string` / `preset_from_string` (unknown → `MaterialPreset.NONE`),
`shape_type_to_payload_type` (circle → CIRCLE, else POLYGON), `sanitize_shape(shape,
grid_size)` (circles pass through; polygon/line points run through
`TerrainUtils.sanitize_points`, keeping `vertex_flags` aligned).
- `to_file_dict(prop_name, material_preset, shapes)` / `from_file_dict(data)` (accepts a dict
or JSON string; returns `{}` + `push_warning` on any schema/version error; missing keys fall
back to defaults; a non-`"1.0"` `version` is rejected).
- `flatten_to_payload(shapes) -> Dictionary` — the deterministic single-shape payload
`PropUtils.spawn_prop()` consumes: **frontmost closed polygon** (scan last→first) → else
**circle** (last→first) → else the **first shape's** points as a polygon; colors come from
the chosen shape (material tint fallback); empty `shapes` → `{}` (caller skips). Lines are
editor-only and never become collision geometry.
- `save(path, data)` (FileAccess WRITE + `JSON.stringify`) and `load(path)` (`{}` +
`push_warning` on read/parse failure), plus **`save_safe(path, data)`** — an atomic
tmp/`.bak` swap used by the autosave (and later the editor's normal save path may use it).
- **Round 5 serialization lesson (critical):** `_serialize_shape()` must route `points`
through the **`_coerce_points()` helper**. Assigning an export-format `Array` of `{x, y}`
dictionaries directly to a typed `PackedVector2Array` **silently zeroes every coordinate**
(Godot cannot construct `Vector2` from a dictionary; the count is preserved but every
element becomes `Vector2.ZERO`). Round 1–4 files saved this way are unrecoverable. Any typed
`Packed*Array` assignment from dictionary/JSON data must go through a Variant-coercion
helper, and regression tests must assert point **values**, not just counts.
- `scripts/prop_theme.gd` — `class_name PropTheme`, `extends RefCounted`; the **validated theme
loader** for `res://prop_theme.json` (Phase 9, **not used by the editor**), modeled on
`SandboxTheme`. Consts `SCHEMA_VERSION := "1.0"`, `DEFAULT_PATH := "res://prop_theme.json"`,
and `DEFAULT_CONFIG` (mirrors the committed file). `load_from(path)` deep-merges the file over
`DEFAULT_CONFIG` (missing/malformed → defaults; warn-once via `_warn_once`), so an older file
without newer sections still loads. Accessors: `value` (dotted path), `color` (`#RRGGBB[AA]`
validation + warn-once), `number`/`integer` (clamped, finite-guarded).
`material_tint(preset, channel)` implements the fallback hierarchy **theme file →
`DEFAULT_CONFIG` → `PropBlock.tint_for(preset)`** (outline's final fallback is the tint
darkened 0.55). `outline_width_default/min/max`, `primitive_default(primitive, key)`,
`grid_size`, `canvas_background_color`, `canvas_grid_color`.
- **Physics is never themeable** — mass/friction/bounce come only from `PropBlock`'s static
preset tables.
- **`ui` tokens (Rounds 2–4):** fonts `body` **18**, `header` **24**, `menu` **22**,
`menu_bar` **22**, `mode` **26**, `status` **18**, `prop_name` **20**, `context_menu` **22**;
hit targets `button_min_height` **36**, `row_min_height` **36**, `mode_button_min_height`
**56**; colors `text` `#F2F2F2`, `text_muted` `#9A9A9A`, `border` `#FFFFFF33`, `accent`
`#4FA3FF`. Accessors `font_body/header/menu/status/mode/menu_bar/prop_name/context_menu`,
`button_min_height`, `row_min_height`, `mode_button_min_height`, `text_color`,
`text_muted_color`, `border_color`, `accent_color`. All are clamped with `DEFAULT_CONFIG`
fallbacks and applied through a **local** `Theme` (no global GUI mutation).
- `scripts/thumbnails/thumbnail_cache.gd` — see its existing section; the Phase 9 extension
adds `prop_file_key(path)` → `"<basename>_<mtime>"` and `prop_file_png(path)` →
`PROP_DIR + "/" + key + ".png"` for **custom `.prop`** files (mtime-keyed so a re-edited prop
invalidates its cached PNG). Built-in template thumbnails keep `prop_png(id)` →
`"<id>_v<PROP_VERSION>.png"`; `stickman_key`/`stickman_png` are unchanged.
- `scripts/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone - `scripts/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone
runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9). runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9).
`static func apply(stk_data, rig)` fits an instantiated `master_rig.tscn` to a loaded `.stk` `static func apply(stk_data, rig)` fits an instantiated `master_rig.tscn` to a loaded `.stk`
@@ -1066,6 +1295,13 @@ page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect
(thumbnail + name + prop material badge) is built in code at runtime because the entry set is (thumbnail + name + prop material badge) is built in code at runtime because the entry set is
dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop** dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop**
palette-button selector grids. palette-button selector grids.
- `scenes/prop_editor.tscn` — **standalone Prop Editor scene** (Phase 9, not wired into the
editor; launched via **F6**). Backed by `scripts/prop_editor.gd`, minimal shell with
`%MenuBar` inside `%TopRow` (beside `%TopRightControls` — the grid spinbox + snap checkbox),
`%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%CanvasArea`/`%ModeBar`/`%PropCanvas`,
`%InspectorColumn`/`%InspectorList`, a bottom status bar with `%SaveStatus` left +
`%CursorCoords` right, and `%SaveDialog`/`%LoadDialog`/`%ErrorDialog`. The menu, inspector,
mode toolbar, and dialogs are built in code; the `.tscn` is only the layout skeleton.
### Body-part data model ### Body-part data model
@@ -1093,6 +1329,29 @@ rotation, scale}`.
- The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible; - The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible;
`"1.0"`–`"1.4"` files auto-migrate on load). `"1.0"`–`"1.4"` files auto-migrate on load).
### `.prop` file format (Phase 9)
- The Prop Editor's `.prop` vector-prop assets are versioned JSON (`version "1.0"`),
defined in `README.md`, saved under `res://props/*.prop`, and converted to a runtime
payload by `PropFile.flatten_to_payload()`.
- Shape dictionary (per entry in `shapes[]`): `{ "shape_type": "polygon"|"circle"|"line",
"points": Array[{x,y}] (polygon/line), "radius": float (circle), "fill_color": "#hex",
"outline_color": "#hex", "outline_width": float, "closed": bool,
"vertex_flags": Array[int], "reshaped": bool }`.
- `vertex_flags`: same length as `points`; `0` = original vertex, `1` = user-created via
"Create Point" (circles: empty). `reshaped` defaults to `false`; set by vertex
insert/remove/drag and non-uniform circle scaling, cleared by `set_shape_radius()`.
- **Backward compatibility rule:** unknown/missing keys fall back to defaults (material
preset → `"none"`, `shape_type` → `"polygon"`, missing colors → material tint, missing
`reshaped` → `false`); only **additive** keys may be introduced — never change the
meaning of an existing key. A non-`"1.0"` `version` is rejected on load.
- Prop Editor settings files:
- `user://prop_editor_settings.json` — schema `{ "version": 1, "recent_props": [...] }`,
the last up-to-5 opened/saved prop paths, most-recent-first.
- `user://prop_editor_autosave.prop` — the debounced (1.0 s) autosave, written atomically
via `PropFile.save_safe()` with `metadata.autosave`/`source_path`/`modified_unix`;
cleared on save/load/new/discard, kept after Restore, dropped when stale at startup.
### settings.json (Phase 6) ### settings.json (Phase 6)
- Persisted editor preferences written to `settings.json` via `_save_settings()` and - Persisted editor preferences written to `settings.json` via `_save_settings()` and
+84 -2
View File
@@ -883,6 +883,79 @@ Each entry in the `shapes` array:
> On load, parts missing from the file fall back to built-in default preview positions. v1.0 files have `closed` inferred from `shape_type` and `vertex_flags` default to all zeros. v1.0/v1.1 files have their single shape wrapped in a `shapes` array and default `rotation`/`scale` applied. Files without `part_order` (v1.0–v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions. > On load, parts missing from the file fall back to built-in default preview positions. v1.0 files have `closed` inferred from `shape_type` and `vertex_flags` default to all zeros. v1.0/v1.1 files have their single shape wrapped in a `shapes` array and default `rotation`/`scale` applied. Files without `part_order` (v1.0–v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions.
## File format (`.prop`)
The **Prop Editor** (Phase 9) authors vector prop assets saved as `.prop` files, stored under `res://props/*.prop`. Files are UTF-8 JSON, pretty-printed with tab indentation, versioned, and designed to remain **backward compatible** — the format stores the editor's full multi-shape stack, while a deterministic converter (`PropFile.flatten_to_payload()`) collapses it into the single-shape payload `PropUtils.spawn_prop()` consumes at runtime (frontmost closed polygon → else circle → else the first shape; lines are editor-only and never become collision geometry).
```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],
"reshaped": false
},
{
"shape_type": "circle",
"radius": 24.0,
"fill_color": "#E63333",
"outline_color": "#590D0D",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
"reshaped": false
}
],
"metadata": {
"created_at": "2026-09-18T10:00:00",
"modified_at": "2026-09-18T10:00:00"
}
}
```
### Root object
| Key | Type | Description |
|---|---|---|
| `version` | `string` | Format version. Currently `"1.0"`. A non-`"1.0"` version is rejected on load. |
| `prop_name` | `string` | Optional display name; an empty name falls back to the filename basename. |
| `material_preset` | `string` | `"none"` \| `"wood"` \| `"rubber"` \| `"cardboard"` \| `"metal"`. Missing/invalid → `"none"`. Sets `mass` + `physics_material_override` (from `PropBlock`'s static preset tables). |
| `shapes` | `array` | Array of shape objects (see below). Draw/Z-order = array order (first = back, last = front). |
| `metadata.created_at` | `string` | Timestamp written on save. Write-only — ignored on load. |
| `metadata.modified_at` | `string` | Timestamp written on save. Write-only — ignored on load. |
### Shape object
Each entry in the `shapes` array:
| Key | Type | Description |
|---|---|---|
| `shape_type` | `string` | `"polygon"`, `"circle"`, or `"line"`. Missing → `"polygon"`. |
| `points` | `array` | Array of `{ "x": float, "y": float }` vertices — polygon/line only. Missing → `[]`. |
| `radius` | `float` | Circle only; default `24.0`. |
| `fill_color` / `outline_color` | `string` | Hex colour (`#RRGGBB` / `#RRGGBBAA`). Missing → the material tint (from `PropBlock.tint_for`). |
| `outline_width` | `float` | Vector outline thickness. Default `2.0` (`PropBlock.DEFAULT_OUTLINE_WIDTH`). |
| `closed` | `bool` | Polygon/circle `true` (filled); line `false` (outline-only). |
| `vertex_flags` | `array[int]` | Same length as `points`. `0` = original vertex, `1` = user-created via "Create Point" (circles: empty). |
| `reshaped` | `bool` | **Round 6.** `false` by default. Set `true` when the shape's vertices were edited (insert/remove/drag) or a circle was scaled non-uniformly; cleared by `set_shape_radius()`. The inspector hides primitive-specific fields (today: the circle Radius row) when set. Additive/backward-compatible — old files load as `false`. |
> **Backward compatibility rule:** unknown/missing keys fall back to defaults, and only **additive** keys may be introduced — never change the meaning of an existing key. The `reshaped` flag is the Round 6 addition; a `material_preset` of `"none"` and a missing `shape_type` both fall back gracefully.
> **Save-serialization note (Round 5):** point coordinates must be written through a Variant-coercion helper (`_coerce_points`). Assigning an `Array` of `{x,y}` dictionaries directly to a typed `PackedVector2Array` silently zeroes every coordinate in Godot — files saved by the Round 1–4 build were unrecoverable.
## Settings file (`user://settings.json`) ## Settings file (`user://settings.json`)
Phase 3 introduces a global settings file stored in Godot's **user data** directory (`user://`), separate from the `.stk` figure files. It persists the grid configuration and is versioned for future extensibility: Phase 3 introduces a global settings file stored in Godot's **user data** directory (`user://`), separate from the `.stk` figure files. It persists the grid configuration and is versioned for future extensibility:
@@ -911,6 +984,8 @@ Behavior:
- **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting. - **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting.
- **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views". - **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views".
> **Prop Editor settings (Phase 9, separate files):** the standalone Prop Editor uses its own user-data files — `user://prop_editor_settings.json` (schema `{ "version": 1, "recent_props": [...] }`, the last up-to-5 opened/saved `.prop` paths, most-recent-first) and `user://prop_editor_autosave.prop` (the debounced 1.0 s autosave, written atomically via `PropFile.save_safe` with `metadata.autosave`/`source_path`/`modified_unix`; cleared on save/load/new/discard, kept after Restore, and dropped when stale at startup). The Prop Editor does **not** read or write `user://settings.json`.
## Project structure ## Project structure
| Path | Purpose | | Path | Purpose |
@@ -929,8 +1004,13 @@ Behavior:
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. | | `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. | | `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). | | `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. | | `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. Also the single source of truth for the Prop Editor's inspector physics readouts. |
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). | | `res://scripts/prop_editor.gd` | **Prop Editor (Phase 9).** `class_name PropEditor`, `extends Control` — standalone vector-prop authoring controller (run via **F6** on `res://scenes/prop_editor.tscn`; **not** the main scene). Owns the 📁 File (🆕 New / 💾 Save / 📄 Save As… / 📂 Load… / 🕑 Recent) and 👁️ View (🔍 Reset View) menus, the top-right Grid spinbox + Snap checkbox, the centered Arrange/Reshape mode toolbar, the material-preset + per-shape inspector (physics readouts from `PropBlock`; hides the circle Radius row + shows a "reshaped" note when a shape's `reshaped` flag is set), the status bar, the window title builder, the exit dialog (💾 Save / 🗑️ Discard / ❌ Cancel), recent-props persistence (`user://prop_editor_settings.json`, max 5), and the debounced autosave/recovery pipeline (`user://prop_editor_autosave.prop` via `PropFile.save_safe`). Saves export a cached 200×200 thumbnail. |
| `res://scripts/prop_canvas.gd` | **Prop Editor (Phase 9).** `class_name PropCanvas`, `extends Control` — multi-shape vector canvas: cursor-centered zoom/pan (wheel, pinch, middle-mouse), `EditMode { TRANSFORM, POINTS }` ("Arrange"/"Reshape") gating input + drawing only, rotation dot + corner scale crosses (Ctrl = 15° snap / aspect lock), color-matched vertex handles with Create/Remove Point (0.6 s flash), per-mode right-click menus, a local clipboard, mirror + z-order, the per-shape `reshaped` flag, sanitize-on-commit via `TerrainUtils`, and two-tone halo selection. |
| `res://scripts/prop_file.gd` | **Prop Editor (Phase 9).** `class_name PropFile`, `extends RefCounted` — static `.prop` v1.0 schema + converters: `to_file_dict`/`from_file_dict` (round-trip, version rejection, missing-key defaults), `preset_to_string`/`from_string`, `sanitize_shape`, `flatten_to_payload` (frontmost closed polygon → circle → first shape), `save`/`load`, and the atomic `save_safe` (tmp/`.bak` swap). Points must be coerced via `_coerce_points` when serializing (Round 5). |
| `res://scripts/prop_theme.gd` | **Prop Editor (Phase 9).** `class_name PropTheme`, `extends RefCounted` — validated loader for `res://prop_theme.json` (deep-merge, warn-once, clamped accessors); hierarchy theme file → `DEFAULT_CONFIG` → `PropBlock` constants. Physics is never themeable. Holds the Round 2–4 `ui` tokens (fonts, hit targets, colors). Applied through a **local** Theme only. |
| `res://scenes/prop_editor.tscn` | **Prop Editor (Phase 9).** Standalone scene backing `scripts/prop_editor.gd` (run via **F6**; not the main scene): minimal shell (`%MenuBar` in `%TopRow` beside `%TopRightControls`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%CanvasArea`/`%ModeBar`/`%PropCanvas`, `%InspectorColumn`/`%InspectorList`, status bar, dialogs); UI built in code. |
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). `PropFile.flatten_to_payload()` produces the payload it consumes. |
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder / Phase 4.5.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). **Phase 4.5** routes `PropBlock` physics contacts through proxy metadata, tracks per-part body IDs for pair debouncing, preserves rig→prop rule ordering, purges stale pairs on separation/deletion/ragdoll, and restores authored rig state when returning PLAY→DIRECT. Standalone staging scene run via **F6**; not wired into the editor. | | `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder / Phase 4.5.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). **Phase 4.5** routes `PropBlock` physics contacts through proxy metadata, tracks per-part body IDs for pair debouncing, preserves rig→prop rule ordering, purges stale pairs on separation/deletion/ragdoll, and restores authored rig state when returning PLAY→DIRECT. Standalone staging scene run via **F6**; not wired into the editor. |
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). | | `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. | | `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
@@ -1057,3 +1137,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery first calls `_capture_pre_ragdoll_state()` (6 IK markers, 8 limb + head bone rotations, facing profile, 4 bend flags, head LookAt state, per-leg `leg_sides`, and entry chain offsets `torso_chain_offset`/`head_chain_offset`) as the END target, then `_capture_ragdoll_pose()` records the 10 bodies' rig-local pose **and** probes the terrain via `_probe_ground_y()` (a 7-ray fan with a tolerance band, excluding the ragdoll RIDs), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`, with a mirror-aware local shift `local_shift = to_local(shift) − to_local(Vector2.ZERO)`), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves via `_snap_skeleton_to_pose()` — the 6 IK targets plus direct arm/head bone rotations (`rot_u = chain(u) − θ_t − bone_angle_u`, `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`, head via `head_chain_offset`), all normalized into `(−π, π]`, with the ankles clamped onto the terrain and the two leg IK mods enabled with the captured knee side — and finally `_play_stand_up()` runs a **2.0 s** sine-in-out tween of the markers → their pre-ragdoll values and the arm/head bones to the snap-computed `_recovery_bones` targets (elbows via `_natural_joint_target`), legs IK-driven. `_on_stand_up_finished()` restores the facing profile + bend flags + LookAt state, re-enables the arm mods, and returns to `ANIMATED`. The per-joint bend decisions live in three editable tables — `RAGDOLL_JOINT_BAND_LIMITS`, `RECOVERY_LEG_SIDE_FORCE`, `RECOVERY_JOINT_PATH` (Joint bend configuration (EDIT ME)); diagnostics are gated behind `DEBUG_RECOVERY := false`. > **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery first calls `_capture_pre_ragdoll_state()` (6 IK markers, 8 limb + head bone rotations, facing profile, 4 bend flags, head LookAt state, per-leg `leg_sides`, and entry chain offsets `torso_chain_offset`/`head_chain_offset`) as the END target, then `_capture_ragdoll_pose()` records the 10 bodies' rig-local pose **and** probes the terrain via `_probe_ground_y()` (a 7-ray fan with a tolerance band, excluding the ragdoll RIDs), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`, with a mirror-aware local shift `local_shift = to_local(shift) − to_local(Vector2.ZERO)`), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves via `_snap_skeleton_to_pose()` — the 6 IK targets plus direct arm/head bone rotations (`rot_u = chain(u) − θ_t − bone_angle_u`, `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`, head via `head_chain_offset`), all normalized into `(−π, π]`, with the ankles clamped onto the terrain and the two leg IK mods enabled with the captured knee side — and finally `_play_stand_up()` runs a **2.0 s** sine-in-out tween of the markers → their pre-ragdoll values and the arm/head bones to the snap-computed `_recovery_bones` targets (elbows via `_natural_joint_target`), legs IK-driven. `_on_stand_up_finished()` restores the facing profile + bend flags + LookAt state, re-enables the arm mods, and returns to `ANIMATED`. The per-joint bend decisions live in three editable tables — `RAGDOLL_JOINT_BAND_LIMITS`, `RECOVERY_LEG_SIDE_FORCE`, `RECOVERY_JOINT_PATH` (Joint bend configuration (EDIT ME)); diagnostics are gated behind `DEBUG_RECOVERY := false`.
Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat). `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat). `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
> **Phase 9 Prop Editor (Rounds 1–7):** a standalone, kid-friendly vector-prop authoring tool — the authoring counterpart to the stickman editor — run via **F6** on `res://scenes/prop_editor.tscn` (not the main scene). It adds a new `.prop` v1.0 JSON format (`res://props/*.prop`, multi-shape stack + deterministic `PropFile.flatten_to_payload()` conversion), a dedicated `PropCanvas` multi-shape canvas, a validated `PropTheme` (`res://prop_theme.json`) with a Round 2–4 `ui` token section, and a save-side 200×200 thumbnail pipeline reusing `PropThumbnail` + a mtime-keyed `ThumbnailCache.prop_file_png`. Round highlights: **R2** — two explicit canvas modes ("Arrange"/"Reshape"), the stickman-style bottom rotation dot + corner scale crosses, color-matched 3 px vertex handles, and cursor-projection "Create Point" with a 0.6 s flash; **R3** — POINTS-mode right-click is point-editing only (segment proximity via `hit_test_segment`), centered mode toolbar with emoji, window title `Untitled — file.prop *`, exit dialog (💾/🗑️/❌), and a debounced autosave/recovery prompt; **R4** — selected-mode is clearly outlined (`FOCUS_NONE` + accent-border `pressed` stylebox), bigger theme-driven defaults, the Grid spinbox + Snap checkbox moved to the menu row (Edit menu removed), File → New with a save prompt, and **Ball removed** from the add menus; **R5** — File → 🕑 Recent (last 5, persisted) and a critical serialization fix (typed `PackedVector2Array` assignments from `{x,y}` dicts silently zeroed every coordinate — `_serialize_shape` now routes through `_coerce_points`); **R6** — Recent updates on save, a per-shape **`reshaped`** flag gates the circle Radius row, and fill-adaptive selection contrast; **R7** — two-tone halo selection (dark backing + bright core) so outlines/boxes/dots/crosses/handles read on any background. Several Prop Editor rows are logged in `docs/tech_debt_and_optimizations.md` (#29–#35).
+7
View File
@@ -38,6 +38,13 @@ This document tracks known technical debt, optimization opportunities, and minor
| 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) | | 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) |
| 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) | | 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) |
| 28 | **Airborne collision sweep cost scales per proxy** — Phase 6a tests the horizontal movement against each of the rig's ten synchronized collision proxies every airborne physics frame. | Low | Open | The per-proxy `PhysicsShapeQueryParameters2D.cast_motion()` checks reuse the existing proxy shapes and avoid a second body-size approximation; this is acceptable for the current sandbox scale. With many simultaneously falling rigs, the cost grows with `rigs × 10` casts per physics frame. Consider a combined query shape or broadphase candidate filter if airborne crowds become a target. (2026-09-17) | | 28 | **Airborne collision sweep cost scales per proxy** — Phase 6a tests the horizontal movement against each of the rig's ten synchronized collision proxies every airborne physics frame. | Low | Open | The per-proxy `PhysicsShapeQueryParameters2D.cast_motion()` checks reuse the existing proxy shapes and avoid a second body-size approximation; this is acceptable for the current sandbox scale. With many simultaneously falling rigs, the cost grows with `rigs × 10` casts per physics frame. Consider a combined query shape or broadphase candidate filter if airborne crowds become a target. (2026-09-17) |
| 29 | **Prop Editor multi-shape → single-shape `PropBlock` flattening** — Phase 9 (`.prop` v1.0) stores a full multi-shape `shapes[]` stack, but `PropFile.flatten_to_payload()` collapses it into the one payload `PropUtils.spawn_prop()` consumes (frontmost closed polygon → circle → first shape). Also: a circle whose vertices were hand-edited still flattens by an average-distance radius, and a non-NONE preset thumbnail ignores payload colors because `PropThumbnail` re-tints from the preset. | Low | Open | Deliberate contract until a multi-shape `PropBlock` composition (multiple `Polygon2D`/`Line2D` children + merged collision) exists; recorded per `specs/phase_9_prop_editor.md` §6. Custom `.prop` thumbnails are mtime-keyed (`ThumbnailCache.prop_file_key`/`prop_file_png`, `<basename>_<mtime>.png`), so re-edits invalidate the cached PNG; built-in template PNGs stay `id_v<PROP_VERSION>`-keyed. (2026-09-18) |
| 30 | **Prop Editor Round 2 interaction model** — vertex editing and shape transform are separated into two explicit canvas modes (`EditMode { TRANSFORM, POINTS }`, kid-facing "Move & Resize" / "Edit Points") driven by an always-visible toolbar toggle; "Create Point" inserts at the cursor's segment projection and flashes the new vertex (0.6 s) instead of inserting an invisible collinear midpoint. | Low | Open | Deliberate, user-approved design (`specs/phase_9_prop_editor_round2.md` §5): modes gate *input* only — all public math APIs stay mode-agnostic. "Create Point" stays outline-only per user decision; auto-begin-drag was rejected as modal/surprising. Revisit only if kids find the two modes confusing in practice. (2026-09-18) |
| 31 | **Prop Editor Round 3 chrome/autosave decisions** — exit-save skips the thumbnail render (fast, deterministic quit; the PNG is a cache nicety), autosave is debounced 1.0 s to `user://prop_editor_autosave.prop` via `PropFile.save_safe` (atomic tmp/bak swap), and all human-facing strings (mode labels/emoji, menu copy) live in the `prop_editor.gd` controller while the canvas stays string-free. | Low | Open | User-approved (`specs/phase_9_prop_editor_round3.md` §6). `save_safe` leaves a `.bak` window during the rename — acceptable on desktop, revisit if autosaves ever run on a filesystem without atomic rename. (2026-09-18) |
| 32 | **Prop Editor mode buttons drop keyboard focus** — `_make_mode_button` sets `focus_mode = FOCUS_NONE` because the Button `focus` stylebox draws after `pressed` and (with the themed `focus` = normal panel) masked the selected/accent state until the cursor moved away. | Low | Open | Round 4 fix (`specs/phase_9_prop_editor_round4.md`). Trade-off: the Arrange/Reshape toggle is mouse-only. Revisit if keyboard accessibility for the mode toggle becomes a requirement — a focus stylebox matching the pressed state (or a focus-visible-only border) would restore it. (2026-09-19) |
| 33 | **Prop File save-serialization zeroed every point (fixed, Round 5)** — `PropFile._serialize_shape` assigned an export-format `Array` of `{x,y}` dicts directly to a typed `PackedVector2Array`; Godot's implicit conversion cannot build `Vector2` from a dictionary and silently yielded `Vector2.ZERO` per element, so every `.prop` saved by Round 1-4 wrote all points as `(0,0)` (loading as invisible/degenerate geometry). | High | ✅ Resolved | Round 5: `_serialize_shape` now routes points through `_coerce_points`; regression assertions check point *values* through `to_file_dict` and the full round-trip, plus an end-to-end canvas→save→load test. The previously saved `props/eye.prop` and `props/box.prop` are unrecoverable (coordinates were zeroed at save time) and were re-authored. Lesson: any typed `Packed*Array` assignment from dictionary/JSON data needs a Variant-coercion helper. (2026-09-19) |
| 34 | **Prop Editor per-shape `reshaped` flag** — every shape carries `reshaped` (false by default; set by vertex insert/remove/drag and non-uniform circle scaling, cleared by `set_shape_radius`), which gates primitive-specific inspector fields (today: the circle Radius row, replaced by a muted "reshaped" note). | Low | Open | Round 6 (`specs/phase_9_prop_editor_round6.md`). Only the circle consumes the flag today; future primitive params (box W/H, plank length/thickness) should reuse it instead of adding per-type detection. An "un-reshape" affordance (restoring a clean primitive from its params) is a possible future feature. (2026-09-19) |
| 35 | **Prop Editor selection uses a two-tone halo** — after Round 6's fill-based contrast failed for the reported "shape in front of a white shape" case (the selection extends beyond the shape onto the background), every selection element (outline polyline, bbox, rotation dot, scale crosses, vertex handles) is drawn as a dark backing pass + bright core pass. | Low | Open | Round 7 (`specs/phase_9_prop_editor_round7.md`). The pair is visible on any background but doubles the selection draw calls; negligible at sandbox scale. If a custom selection color is ever wanted (e.g. per-user theme), it should replace the core pass while keeping the halo. (2026-09-19) |
--- ---
+151
View File
@@ -0,0 +1,151 @@
**Phase 1: Prop Theme & Material Token Integration**
- **Task 1.1: Prop Design Token Schema**
- Define a standalone JSON theme configuration (`prop_theme.json`) structuring design tokens specifically for prop editing (e.g., `outline_widths`, `material_tints`, `primitive_defaults`).
- Implement fallback hierarchy for default colors, line widths, and physics values.
- _Acceptance Criteria:_
- `prop_theme.json` parses successfully and loads default tokens on editor startup.
- Missing keys in custom prop files fall back gracefully without raising runtime errors.
- **Task 1.2: Material Preset Visual Utility**
- Create helper logic mapping physics material presets (Wood, Rubber, Cardboard, Metal) directly to default mass, friction, bounce, and fill/outline color tints.
- _Acceptance Criteria:_
- Selecting a material preset populates default mass, friction, restitution, and visual tint values.
- **Task 1.3: Prop Control Inspector Theme**
- Apply standardized `.theme` styling to Prop Editor control widgets (`OptionButton`, `SpinBox`, `ColorPicker`, `Button`).
- _Acceptance Criteria:_
- Prop Editor controls match visual styling without mutating global GUI settings or sandbox scenes.
---
**Phase 2: Vector Geometry, Vertex Editing & Shape Manipulation**
- **Task 2.1: Multi-Shape Creation & Primitive Geometry Controls**
- Implement UI controls to instantiate primitive shapes (Polygons, Circles, Lines) onto the active drawing surface.
- Support an internal shape stack array for multiple vector shapes per prop asset.
- _Acceptance Criteria:_
- Adding a shape appends a new vector item to the shape stack array.
- Switching primitive modes exposes mode-specific geometry parameters (e.g., radius for circles, vertex lists for polygons).
- **Task 2.2: Direct Vertex Dragging & Reshaping**
- Render interactive vertex handles on selected shapes.
- Implement left-click drag on vertex handles to reshape polygon geometry in real time.
- _Acceptance Criteria:_
- Dragging a vertex handle updates the shape mesh visually during input.
- Releasing the mouse commits the updated vertex coordinates.
- **Task 2.3: Contextual Vertex Insertion & Deletion**
- Right-clicking an outline segment displays a **"Create Point"** menu option to insert a vertex at the edge midpoint.
- Right-clicking an existing vertex handle displays a **"Remove Point"** option to delete the vertex.
- Handle degenerate edges gracefully.
- _Acceptance Criteria:_
- Inserting a vertex adds a new point to the shape's point list at the targeted midpoint.
- Deleting a vertex removes the handle and closes the surrounding line segment.
- Shapes reduced below 2 vertices are removed from the canvas.
- **Task 2.4: Shape Selection, Context Menu & Z-Ordering**
- Build a canvas hit-testing system to select shapes via left-click.
- Implement a right-click context menu offering Copy, Paste, Delete, Mirror X, Mirror Y, Bring Forward, and Send Back.
- _Acceptance Criteria:_
- Left-clicking a shape highlights its bounding box and handles.
- Pressing `Delete`/`Backspace` or selecting "Delete" removes the targeted shape.
- "Bring Forward" and "Send Back" alter the target shape's draw order in the rendering stack.
- **Task 2.5: Translation, Rotation & Scaling Gizmos**
- Implement direct click-and-drag translation handles on selected shape bounding boxes.
- Provide a rotation gizmo that snaps to **15-degree increments** when holding **Ctrl**.
- Provide corner scaling handles that lock **aspect ratio** uniform scaling while holding **Ctrl**.
- _Acceptance Criteria:_
- Dragging the translation handle translates all vertices in unison.
- Holding **Ctrl** during rotation snaps rotation strictly to 15-degree steps.
- Holding **Ctrl** during scaling locks aspect ratio; releasing **Ctrl** allows non-uniform scaling.
- **Task 2.6: Shape Mirroring Engine**
- Implement **Mirror X** and **Mirror Y** shape transformations relative to the shape's bounding-box center.
- _Acceptance Criteria:_
- Triggering "Mirror X" inverts X-coordinates across the shape center; "Mirror Y" inverts Y-coordinates.
- **Task 2.7: In-Memory Shape Clipboard System**
- Implement a dedicated shape clipboard module storing deep duplicates of shape property dictionaries.
- _Acceptance Criteria:_
- Copying a shape stores its full parameter dictionary in memory.
- Pasting creates a duplicate shape offset from the original.
- The "Paste" context menu option is disabled when the clipboard is empty.
- **Task 2.8: Live Vector Canvas Synchronizer**
- Wire UI input signals directly to `PropBlock` setters for real-time visual updates.
- _Acceptance Criteria:_
- Modifying `fill_color`, `outline_color`, or `outline_width` reflects instantly on the active canvas.
- **Task 2.9: Point Sanitization Pipeline**
- Pass edited polygon coordinates through `TerrainUtils.sanitize_points()`.
- _Acceptance Criteria:_
- Committed geometry maintains clockwise vertex ordering and strips redundant collinear points.
---
**Phase 3: Physical Property Inspector & Styling Tools**
- **Task 3.1: Material Preset Picker**
- Implement UI selectors for assigning `material_preset` values and displaying physics properties.
- _Acceptance Criteria:_
- Changing material presets updates displayed physics values (mass, friction, restitution).
- **Task 3.2: Custom Color Swatch & Palette Pickers**
- Build reusable visual color swatches for custom interior fill and outline stroke colors.
- _Acceptance Criteria:_
- Selecting custom colors overrides preset colors and updates visual stroke/fill values.
---
**Phase 4: Prop Preview Rendering & Disk Caching**
- **Task 4.1: Prop SubViewport Thumbnail Generator**
- Build an isolated `SubViewport` preview pipeline (`prop_thumbnail.gd`) to capture 2D textures.
- _Acceptance Criteria:_
- `prop_thumbnail.gd` renders off-screen prop geometry into a transparent 2D texture without interfering with main editor performance.
- **Task 4.2: Disk Caching Pipeline**
- Implement local thumbnail export writing textures to `user://thumbnails/props/<id>.png`.
- Validate file timestamps to reload cached textures when up-to-date.
- _Acceptance Criteria:_
- Saving a prop exports a 200x200 PNG thumbnail to `user://thumbnails/props/<id>.png`.
- The editor loads cached thumbnails from disk if source files are unchanged.
+47
View File
@@ -0,0 +1,47 @@
{
"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"
},
"ui": {
"fonts": {
"body": 18,
"header": 24,
"menu": 22,
"menu_bar": 22,
"mode": 26,
"status": 18,
"prop_name": 20,
"context_menu": 22
},
"hit_targets": {
"button_min_height": 36,
"row_min_height": 36,
"mode_button_min_height": 56
},
"colors": {
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF"
}
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"material_preset": "none",
"metadata": {
"created_at": "2026-09-19T16:03:21",
"modified_at": "2026-09-19T16:03:21"
},
"prop_name": "",
"shapes": [
{
"closed": true,
"fill_color": "ffffffff",
"outline_color": "#40290F",
"outline_width": 2.0,
"points": [
{
"x": 1258.0,
"y": 499.732421875
},
{
"x": 1230.26721191406,
"y": 603.232421875
},
{
"x": 1154.5,
"y": 678.999694824219
},
{
"x": 1051.0,
"y": 706.732421875
},
{
"x": 947.5,
"y": 678.999694824219
},
{
"x": 871.732727050781,
"y": 603.232421875
},
{
"x": 844.0,
"y": 499.732421875
},
{
"x": 871.732727050781,
"y": 396.232421875
},
{
"x": 947.5,
"y": 371.465148925781
},
{
"x": 1044.0,
"y": 507.732421875
},
{
"x": 1152.5,
"y": 367.465148925781
},
{
"x": 1230.26721191406,
"y": 396.232421875
}
],
"radius": 182.826955159505,
"reshaped": true,
"shape_type": "circle",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#8C6133",
"outline_color": "#40290F",
"outline_width": 2.0,
"points": [
{
"x": 1146.23413085938,
"y": 564.821594238281
},
{
"x": 1194.23413085938,
"y": 564.821594238281
},
{
"x": 1194.23413085938,
"y": 612.821594238281
},
{
"x": 1146.23413085938,
"y": 612.821594238281
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"version": "1.0"
}
+277
View File
@@ -0,0 +1,277 @@
{
"material_preset": "none",
"metadata": {
"created_at": "2026-09-19T21:11:33",
"modified_at": "2026-09-19T21:11:33"
},
"prop_name": "",
"shapes": [
{
"closed": true,
"fill_color": "ffffffff",
"outline_color": "#40290F",
"outline_width": 4.0,
"points": [
{
"x": 1294.0,
"y": 556.848999023438
},
{
"x": 1274.30578613281,
"y": 630.348999023438
},
{
"x": 1220.5,
"y": 684.154724121094
},
{
"x": 1147.0,
"y": 703.848999023438
},
{
"x": 1073.5,
"y": 684.154724121094
},
{
"x": 1019.69427490234,
"y": 630.348999023438
},
{
"x": 1000.0,
"y": 556.848999023438
},
{
"x": 1019.69427490234,
"y": 483.348999023438
},
{
"x": 1079.5,
"y": 453.543273925781
},
{
"x": 1152.0,
"y": 455.848999023438
},
{
"x": 1223.5,
"y": 454.543273925781
},
{
"x": 1274.30578613281,
"y": 483.348999023438
}
],
"radius": 139.647333145142,
"reshaped": false,
"shape_type": "circle",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "3b52bbff",
"outline_color": "#40290F",
"outline_width": 2.0,
"points": [
{
"x": 1207.81555175781,
"y": 569.486694335938
},
{
"x": 1199.5625,
"y": 600.287231445313
},
{
"x": 1177.01501464844,
"y": 622.834716796875
},
{
"x": 1146.21447753906,
"y": 631.087768554688
},
{
"x": 1115.41394042969,
"y": 622.834716796875
},
{
"x": 1092.86645507813,
"y": 600.287231445313
},
{
"x": 1084.61340332031,
"y": 569.486694335938
},
{
"x": 1092.86645507813,
"y": 538.686157226563
},
{
"x": 1115.41394042969,
"y": 516.138671875
},
{
"x": 1146.21447753906,
"y": 507.885620117188
},
{
"x": 1177.01501464844,
"y": 516.138671875
},
{
"x": 1199.5625,
"y": 538.686157226563
}
],
"radius": 61.6010309855143,
"reshaped": false,
"shape_type": "circle",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "000000ff",
"outline_color": "#40290F",
"outline_width": 2.0,
"points": [
{
"x": 1147.79870605469,
"y": 526.655395507813
},
{
"x": 1160.27172851563,
"y": 532.014404296875
},
{
"x": 1169.40283203125,
"y": 546.655395507813
},
{
"x": 1172.74487304688,
"y": 566.655395507813
},
{
"x": 1169.40283203125,
"y": 586.655395507813
},
{
"x": 1160.27185058594,
"y": 601.29638671875
},
{
"x": 1147.79870605469,
"y": 606.655395507813
},
{
"x": 1135.32568359375,
"y": 601.29638671875
},
{
"x": 1126.19470214844,
"y": 586.655395507813
},
{
"x": 1122.8525390625,
"y": 566.655395507813
},
{
"x": 1126.19470214844,
"y": 546.655395507813
},
{
"x": 1135.32556152344,
"y": 532.014404296875
}
],
"radius": 32.9105350176493,
"reshaped": false,
"shape_type": "circle",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#8C6133",
"outline_color": "#40290F",
"outline_width": 2.0,
"points": [
{
"x": 966.386901855469,
"y": 478.505645751953
},
{
"x": 1051.41577148438,
"y": 447.389190673828
},
{
"x": 1146.44836425781,
"y": 434.252014160156
},
{
"x": 1260.01403808594,
"y": 452.928924560547
},
{
"x": 1342.70739746094,
"y": 480.989318847656
},
{
"x": 1286.82446289063,
"y": 375.060638427734
},
{
"x": 989.98193359375,
"y": 375.060638427734
}
],
"reshaped": true,
"shape_type": "polygon",
"vertex_flags": [
0,
1,
1,
1,
0,
0,
0
]
}
],
"version": "1.0"
}
+157
View File
@@ -0,0 +1,157 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/prop_editor.gd" id="1_editor_script"]
[ext_resource type="Script" path="res://scripts/prop_canvas.gd" id="2_canvas_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_canvas"]
bg_color = Color(0.121569, 0.121569, 0.121569, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[node name="PropEditor" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_editor_script")
[node name="TopRow" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_bottom = 32.0
grow_horizontal = 2
[node name="MenuBar" type="MenuBar" parent="TopRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="TopRightControls" type="HBoxContainer" parent="TopRow"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 32.0
offset_bottom = 64.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
text = "Prop Name:"
vertical_alignment = 1
[node name="PropNameEdit" type="LineEdit" parent="TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Prop"
[node name="MainLayout" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 68.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 6
[node name="CanvasPanel" type="PanelContainer" parent="MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
theme_override_styles/panel = SubResource("StyleBox_canvas")
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="ModeBar" type="HBoxContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
mouse_filter = 0
clip_contents = true
script = ExtResource("2_canvas_script")
[node name="InspectorColumn" type="VBoxContainer" parent="MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
custom_minimum_size = Vector2(300, 0)
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="SaveDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Save Prop"
size = Vector2i(600, 450)
access = 2
filters = PackedStringArray("*.prop ; Prop Files")
[node name="LoadDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Open a Prop"
size = Vector2i(600, 450)
ok_button_text = "Open"
file_mode = 0
access = 2
filters = PackedStringArray("*.prop ; Prop Files")
[node name="ErrorDialog" type="AcceptDialog" parent="."]
unique_name_in_owner = true
title = "Error"
[node name="StatusBar" type="HBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -28.0
grow_horizontal = 2
grow_vertical = 0
[node name="SaveStatus" type="Label" parent="StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="CursorCoords" type="Label" parent="StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = ""
horizontal_alignment = 2
vertical_alignment = 1
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://lophq30fv72p
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://v8gaqlxoei3a
+334
View File
@@ -0,0 +1,334 @@
class_name PropFile
extends RefCounted
## PropFile - Static schema + converters for .prop vector prop assets.
##
## The file stores the editor's full multi-shape stack (version "1.0") plus
## enough provenance for later sandbox placement. flatten_to_payload() converts
## the stack into the single-shape payload PropUtils.spawn_prop() already
## consumes: frontmost closed polygon -> else circle -> else first shape.
const PropBlockScript := preload("res://scripts/prop_block.gd")
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
const FILE_VERSION: String = "1.0"
const FILE_EXT: String = ".prop"
const DEFAULT_DIR: String = "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"]
const DEFAULT_RADIUS: float = 24.0
const DEFAULT_CIRCLE_SEGMENTS: int = 12
static func preset_to_string(preset: int) -> String:
return PRESET_STRINGS[clampi(preset, 0, PRESET_STRINGS.size() - 1)]
static func preset_from_string(s: String) -> int:
var idx: int = PRESET_STRINGS.find(s.to_lower())
if idx < 0:
return PropBlockScript.MaterialPreset.NONE
return idx
static func shape_type_to_payload_type(s: String) -> int:
if s.to_lower() == "circle":
return PropBlockScript.ShapeType.CIRCLE
return PropBlockScript.ShapeType.POLYGON
## Sanitize a shape dictionary: polygon/line points run through
## TerrainUtils.sanitize_points (grid snap, redundancy/collinear removal,
## clockwise enforcement). Circles pass through unchanged (radius-based).
static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScript.DEFAULT_GRID_SIZE) -> Dictionary:
var result: Dictionary = shape.duplicate(true)
var shape_type: String = str(result.get("shape_type", "polygon"))
if shape_type == "circle":
return result
var pts: PackedVector2Array = _coerce_points(result.get("points", PackedVector2Array()))
var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, grid_size)
var flags: PackedInt32Array = result.get("vertex_flags", PackedInt32Array())
var kept_flags := PackedInt32Array()
# Keep vertex flags aligned: sanitize only ever drops points, so walk the
# cleaned list and retain a flag for each surviving vertex (match by index
# order; approximate alignment is acceptable for write-only metadata).
kept_flags.resize(cleaned.size())
for i: int in mini(cleaned.size(), flags.size()):
kept_flags[i] = flags[i]
result["points"] = cleaned
result["vertex_flags"] = kept_flags
return result
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary:
var serialized: Array = []
for shape: Dictionary in shapes:
serialized.append(_serialize_shape(shape))
var now: String = Time.get_datetime_string_from_system()
return {
"version": FILE_VERSION,
"prop_name": prop_name,
"material_preset": preset_to_string(material_preset),
"shapes": serialized,
"metadata": {"created_at": now, "modified_at": now},
}
## Parse a file-level dictionary (or JSON string) into the editor model.
## Returns {} on any schema error (with a push_warning), otherwise
## { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }.
static func from_file_dict(data: Variant) -> Dictionary:
if data is String:
var parsed: Variant = JSON.parse_string(data as String)
if not parsed is Dictionary:
push_warning("PropFile: file data is not a valid JSON object.")
return {}
data = parsed
if not data is Dictionary:
push_warning("PropFile: file data is not a dictionary.")
return {}
var dict: Dictionary = data as Dictionary
var version: String = str(dict.get("version", ""))
if not SUPPORTED_VERSIONS.has(version):
push_warning("PropFile: unsupported file version '%s'." % version)
return {}
var preset: int = preset_from_string(str(dict.get("material_preset", "none")))
var shapes: Array[Dictionary] = []
var raw_shapes: Variant = dict.get("shapes", [])
if raw_shapes is Array:
for item: Variant in raw_shapes as Array:
if item is Dictionary:
shapes.append(_parse_shape(item as Dictionary, preset))
return {
"prop_name": str(dict.get("prop_name", "")),
"material_preset": preset,
"shapes": shapes,
}
## Deterministic conversion of the multi-shape stack into a single
## PropUtils.spawn_prop payload. Rule: frontmost closed polygon -> else circle
## -> else first shape. Returns {} when shapes is empty.
static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary:
if shapes.is_empty():
push_warning("PropFile: cannot flatten an empty shape stack.")
return {}
var chosen: Dictionary = {}
var kind: String = "polygon"
# 1. Frontmost closed polygon (scan back to front).
for i: int in range(shapes.size() - 1, -1, -1):
var sd: Dictionary = shapes[i]
if str(sd.get("shape_type", "polygon")) == "polygon" and bool(sd.get("closed", true)):
chosen = sd
kind = "polygon"
break
# 2. Else frontmost circle.
if chosen.is_empty():
for i: int in range(shapes.size() - 1, -1, -1):
var sd: Dictionary = shapes[i]
if str(sd.get("shape_type", "polygon")) == "circle":
chosen = sd
kind = "circle"
break
# 3. Else the first shape (even a line) as a polygon.
if chosen.is_empty() and not shapes.is_empty():
chosen = shapes[0]
kind = "polygon"
var payload: Dictionary = {}
if kind == "circle":
payload = {
"type": PropBlockScript.ShapeType.CIRCLE,
"radius": float(chosen.get("radius", DEFAULT_RADIUS)),
}
else:
var pts: PackedVector2Array = _coerce_points(chosen.get("points", PackedVector2Array()))
payload = {
"type": PropBlockScript.ShapeType.POLYGON,
"points": TerrainUtilsScript.sanitize_points(pts),
}
payload["fill_color"] = _color_from_hex(str(chosen.get("fill_color", "")), PropBlockScript.tint_for(PropBlockScript.MaterialPreset.NONE))
payload["outline_color"] = _color_from_hex(str(chosen.get("outline_color", "")), PropBlockScript.DEFAULT_OUTLINE_COLOR)
payload["outline_width"] = float(chosen.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH))
return payload
## Write `data` (a to_file_dict result) as pretty-printed JSON to `path`.
static func save(path: String, data: Dictionary) -> bool:
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
if file == null:
push_warning("PropFile: could not open '%s' for writing." % path)
return false
file.store_string(JSON.stringify(data, "\t"))
file.close()
return true
## Atomic write mirroring StageSceneIO.write_file_safe: write a temp file,
## swap the previous file to .bak, rename the temp into place, then remove the
## backup. Used by the editor autosave so a crash mid-write cannot corrupt it.
static func save_safe(path: String, data: Dictionary) -> bool:
var tmp := path + ".tmp"
var bak := path + ".bak"
var file := FileAccess.open(tmp, FileAccess.WRITE)
if file == null:
push_warning("PropFile: could not write temporary file '%s'." % tmp)
return false
file.store_string(JSON.stringify(data, "\t"))
file.flush()
file.close()
if FileAccess.file_exists(bak):
DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
DirAccess.remove_absolute(tmp)
return false
if DirAccess.rename_absolute(tmp, path) != OK:
if FileAccess.file_exists(bak):
DirAccess.rename_absolute(bak, path)
return false
if FileAccess.file_exists(bak):
DirAccess.remove_absolute(bak)
return true
## Read and parse a .prop file. Returns the raw dictionary on success, {}
## on read/parse failure (with a push_warning). Schema validation (version,
## shapes) happens in from_file_dict.
static func load(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
push_warning("PropFile: '%s' does not exist." % path)
return {}
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("PropFile: could not open '%s' for reading." % path)
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
push_warning("PropFile: '%s' is not a valid JSON object." % path)
return {}
return parsed as Dictionary
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _serialize_shape(shape: Dictionary) -> Dictionary:
# Accept both internal (PackedVector2Array) and export (Array of {x,y})
# point formats; a typed assignment would silently zero an Array of dicts.
var points: PackedVector2Array = _coerce_points(shape.get("points", PackedVector2Array()))
var points_arr: Array = []
for pt: Vector2 in points:
points_arr.append({"x": pt.x, "y": pt.y})
var flags: PackedInt32Array = shape.get("vertex_flags", PackedInt32Array())
var flags_arr: Array = []
for i: int in flags.size():
flags_arr.append(flags[i])
var result := {
"shape_type": str(shape.get("shape_type", "polygon")),
"points": points_arr,
"fill_color": str(shape.get("fill_color", "")),
"outline_color": str(shape.get("outline_color", "")),
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
"closed": bool(shape.get("closed", false)),
"vertex_flags": flags_arr,
"reshaped": bool(shape.get("reshaped", false)),
}
if str(shape.get("shape_type", "")) == "circle":
result["radius"] = float(shape.get("radius", DEFAULT_RADIUS))
return result
static func _parse_shape(shape: Dictionary, preset: int) -> Dictionary:
var shape_type: String = str(shape.get("shape_type", "polygon"))
if not SHAPE_STRINGS.has(shape_type):
shape_type = "polygon"
var pts := PackedVector2Array()
var pts_variant: Variant = shape.get("points", [])
if pts_variant is Array:
for p: Variant in pts_variant as Array:
if p is Dictionary:
var d: Dictionary = p as Dictionary
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
elif p is Vector2:
pts.append(p as Vector2)
elif pts_variant is PackedVector2Array:
pts = pts_variant as PackedVector2Array
var closed: bool
if shape.has("closed"):
closed = bool(shape.get("closed", false))
else:
closed = shape_type != "line"
var radius: float = float(shape.get("radius", DEFAULT_RADIUS))
if shape_type == "circle" and pts.is_empty():
pts = _circle_points(radius, DEFAULT_CIRCLE_SEGMENTS)
var vf := PackedInt32Array()
var vf_variant: Variant = shape.get("vertex_flags", [])
if vf_variant is Array:
var vf_arr: Array = vf_variant as Array
vf.resize(vf_arr.size())
for i: int in vf_arr.size():
vf[i] = int(vf_arr[i])
elif vf_variant is PackedInt32Array:
vf = vf_variant as PackedInt32Array
if vf.size() != pts.size():
vf.resize(pts.size())
var tint: Color = PropBlockScript.tint_for(preset)
var fill_hex: String = str(shape.get("fill_color", ""))
if fill_hex.is_empty():
fill_hex = tint.to_html()
var outline_hex: String = str(shape.get("outline_color", ""))
if outline_hex.is_empty():
outline_hex = tint.darkened(0.55).to_html()
var result := {
"shape_type": shape_type,
"points": pts,
"radius": radius,
"fill_color": fill_hex,
"outline_color": outline_hex,
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
"closed": closed,
"vertex_flags": vf,
"reshaped": bool(shape.get("reshaped", false)),
}
return result
static func _circle_points(radius: float, segments: int) -> PackedVector2Array:
var pts := PackedVector2Array()
var seg: int = maxi(segments, 3)
for i: int in seg:
var angle: float = TAU * float(i) / float(seg)
pts.append(Vector2(cos(angle), sin(angle)) * radius)
return pts
static func _color_from_hex(hex: String, fallback: Color) -> Color:
if hex.is_empty():
return fallback
return Color.from_string(hex, fallback)
## Accept either internal (PackedVector2Array) or export (Array of {x,y})
## point formats.
static func _coerce_points(variant: Variant) -> PackedVector2Array:
var pts := PackedVector2Array()
if variant is PackedVector2Array:
pts = variant as PackedVector2Array
elif variant is Array:
for p: Variant in variant as Array:
if p is Dictionary:
var d: Dictionary = p as Dictionary
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
elif p is Vector2:
pts.append(p as Vector2)
return pts
+1
View File
@@ -0,0 +1 @@
uid://rbk8by20x7g4
+238
View File
@@ -0,0 +1,238 @@
class_name PropTheme
extends RefCounted
## Validated, cached presentation data for the Prop Editor.
##
## Mirrors SandboxTheme's loader mechanics (deep-merge, dotted-path accessors,
## warn-once, clamp, invalid-input fallback) but scoped to prop-editor tokens.
## Fallback hierarchy: prop_theme.json -> DEFAULT_CONFIG -> hardcoded
## PropBlock constants. Physics values (mass/friction/bounce) are deliberately
## NOT themeable; PropBlock's static preset tables remain the single source of
## truth.
const PropBlockScript := preload("res://scripts/prop_block.gd")
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://prop_theme.json"
const PRESET_KEYS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"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",
},
"ui": {
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF"},
},
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "PropTheme: '%s' could not be read; using defaults." % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
_warn_once(path, "PropTheme: '%s' is malformed; using defaults." % path)
return false
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func value(path: String, fallback: Variant = null) -> Variant:
var cursor: Variant = _config
for piece: String in path.split("."):
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
return fallback
cursor = (cursor as Dictionary)[piece]
return cursor
func color(path: String, fallback: Color) -> Color:
var raw: Variant = value(path, null)
if not raw is String:
return fallback
var text: String = String(raw)
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
_warn_once(path, "PropTheme: invalid color at '%s'." % path)
return fallback
return Color.from_string(text, fallback)
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
var raw: Variant = value(path, fallback)
if not raw is int and not raw is float:
_warn_once(path, "PropTheme: invalid number at '%s'." % path)
return fallback
var n: float = float(raw)
if not is_finite(n):
return fallback
return clampf(n, minimum, maximum)
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
## Material tint for a PropBlock.MaterialPreset value and channel ("fill" or
## "outline"). Hierarchy: theme file -> DEFAULT_CONFIG -> PropBlock.tint_for
## (outline's final fallback is the tint darkened 0.55).
func material_tint(preset: int, channel: String) -> Color:
var key: String = PRESET_KEYS[preset] if preset >= 0 and preset < PRESET_KEYS.size() else "none"
var tint: Color = PropBlockScript.tint_for(preset)
var fallback: Color = tint if channel == "fill" else tint.darkened(0.55)
return color("material_tints.%s.%s" % [key, channel], fallback)
func outline_width_default() -> float:
return number("outline_widths.default", PropBlockScript.DEFAULT_OUTLINE_WIDTH, 0.0, 256.0)
func outline_width_min() -> float:
return number("outline_widths.min", 0.5, 0.0, 256.0)
func outline_width_max() -> float:
return number("outline_widths.max", 32.0, 0.0, 1024.0)
## Primitive default dimension, e.g. primitive_default("box", "size_x").
## Falls back to the DEFAULT_CONFIG primitive table (which mirrors the
## PropUtils.create_* generator defaults).
func primitive_default(primitive: String, key: String, fallback: float = 0.0) -> float:
var table: Dictionary = value("primitive_defaults.%s" % primitive, {})
if table is Dictionary and (table as Dictionary).has(key):
return number("primitive_defaults.%s.%s" % [primitive, key], fallback)
var defaults: Dictionary = DEFAULT_CONFIG.get("primitive_defaults", {})
var entry: Dictionary = defaults.get(primitive, {})
return float(entry.get(key, fallback))
func grid_size() -> int:
return integer("canvas.grid_size", 15, 1, 512)
func canvas_background_color() -> Color:
return color("canvas.background_color", Color("#1F1F1F"))
func canvas_grid_color() -> Color:
return color("canvas.grid_color", Color(1.0, 1.0, 1.0, 0.15))
# ---------------------------------------------------------------------------
# Kid-friendly UI tokens (Round 2)
# ---------------------------------------------------------------------------
func font_body() -> int:
return integer("ui.fonts.body", 18, 8, 72)
func font_header() -> int:
return integer("ui.fonts.header", 24, 8, 96)
func font_menu() -> int:
return integer("ui.fonts.menu", 22, 8, 72)
func font_status() -> int:
return integer("ui.fonts.status", 18, 8, 72)
func font_mode() -> int:
return integer("ui.fonts.mode", 26, 8, 96)
func font_menu_bar() -> int:
return integer("ui.fonts.menu_bar", 22, 8, 72)
func font_prop_name() -> int:
return integer("ui.fonts.prop_name", 20, 8, 96)
func font_context_menu() -> int:
return integer("ui.fonts.context_menu", 22, 8, 96)
func button_min_height() -> int:
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
func row_min_height() -> int:
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
func mode_button_min_height() -> int:
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
func text_color() -> Color:
return color("ui.colors.text", Color("#F2F2F2"))
func text_muted_color() -> Color:
return color("ui.colors.text_muted", Color("#9A9A9A"))
func border_color() -> Color:
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
func accent_color() -> Color:
return color("ui.colors.accent", Color("#4FA3FF"))
func invalidate_caches() -> void:
_warned.clear()
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
for key: Variant in overrides:
if merged.get(key) is Dictionary and overrides[key] is Dictionary:
merged[key] = _deep_merge(merged[key], overrides[key])
else:
merged[key] = overrides[key]
return merged
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key):
_warned[key] = true
push_warning(message)
+1
View File
@@ -0,0 +1 @@
uid://qxwnxe3u1o33
+8
View File
@@ -39,6 +39,14 @@ func prop_png(id: String) -> String:
return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png" return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
func prop_file_key(path: String) -> String:
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
func prop_file_png(path: String) -> String:
return PROP_DIR + "/" + prop_file_key(path) + ".png"
func load_png(png_path: String) -> Texture2D: func load_png(png_path: String) -> Texture2D:
if _memory.has(png_path): if _memory.has(png_path):
return _memory[png_path] as Texture2D return _memory[png_path] as Texture2D
+554
View File
@@ -0,0 +1,554 @@
# 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.*
+447
View File
@@ -0,0 +1,447 @@
# Phase 9 Prop Editor — Round 2 (Usability & Interaction Fixes)
Status: **approved** (2026-09-18; all open choices resolved — two deviations: "Create Point"
stays **outline-only**, and `prop_theme.json` keeps `version` **"1.0"**).
Source: five user feedback points against the shipped Round 1 Prop Editor
(`scripts/prop_canvas.gd`, `scripts/prop_editor.gd`, `prop_theme.json`).
---
## 0. Feedback summary (verbatim intent)
1. **Rotation is very hard** — replace the ring-above-bbox with the stickman editor's
bottom rotation dot (small dot below the bbox bottom-center, generous hit radius,
incremental-angle drag, keep Ctrl 15° snap).
2. **Vertex node circles are too large and should stay constant screen-size** — diagnose
what is actually read as "too large" and shrink the offending gizmos; keep vertex handles
at the stickman editor's 3 px.
3. **"Create Point" along a segment does nothing** — diagnose the true root cause and give
immediate visible feedback; decide whether it is available anywhere on the shape.
4. **Mode separation: vertex editing vs transform editing** — design an explicit interaction
model (evaluate ≥2 models, recommend one), with a canvas-owned mode + public API.
5. **Kid-friendly UI** — bigger text, friendlier plain-language copy, larger hit targets,
higher-contrast widget styling, via a `ui` section in `prop_theme.json` (local-theme-only).
---
## 1. Diagnoses
### 1.1 Point 3 — "Create Point" appears to do nothing (root cause + evidence)
The wiring is correct (`MENU_CREATE_POINT` → `_create_point_on_edge` → `insert_vertex`), so the
failure is perceptual, not mechanical. Evidence from `scripts/prop_canvas.gd`:
1. **Collinear insertion (primary cause).** `_create_point_on_edge` (lines 951–975) inserts the
new vertex at the **exact segment midpoint**:
```gdscript
var midpoint: Vector2 = (pts[best_edge] + pts[(best_edge + 1) % pts.size()]) * 0.5
insert_vertex(_selected_shape_idx, insert_idx, midpoint)
```
On any straight edge (box, plank, triangle side, line) the midpoint lies **on the same line**
as its two neighbours. The polygon fill and outline are therefore geometrically identical
before/after the insert — nothing changes visually. (The inserted vertex is collinear and
would also be stripped by `sanitize_shape()` on the next vertex/rotate/scale commit, since
`TerrainUtils.sanitize_points` drops collinear vertices.)
2. **The new handle is small and low-contrast.** `insert_vertex` flags the vertex
`USER_CREATED` (line 298), and `_draw_shape` renders it as a hollow rectangle of
`HANDLE_HOLLOW_SIZE / _zoom * 0.5` (≈3 px half-size) in **white** (`HANDLE_COLOR`, line
578). Over a light fill or at small zoom this is nearly invisible, so even the handle is not
noticed. (Note: the stickman editor color-matches these to the shape — see 1.2.)
3. **Discoverability.** "Create Point" only appears when `_is_point_near_outline` is true
(line 730), i.e. a right-click within `EDGE_HIT_THRESHOLD / _zoom` (12 px) of an outline
edge. Right-clicking the body of a filled shape shows Copy/Paste/Delete instead — a kid
right-clicks the *middle* of the shape and never sees the option.
**Recommendation (see §2.4 / §4.3):** insert at the **cursor's projection onto the nearest
segment** (clamped), **auto-select the shape**, **emit a `vertex_created` signal**, and flash
the new vertex with a short pulsing highlight. Auto-begin-drag is evaluated as an alternative
in §5. **User-approved resolution:** "Create Point" stays **outline-only** (the current
`EDGE_HIT_THRESHOLD / _zoom` gating is kept).
### 1.2 Point 2 — what is actually read as "too large" (evidence)
The vertex handles are **not** the problem: they already divide by `_zoom` (3 px constant,
lines 35, 576). The two things a user actually reads as "giant vertex circles" are the other
two gizmos drawn on selection (`_draw_selection_gizmos`, lines 582–595):
| Gizmo | Current draw | Effective on-screen size | Read as |
| --- | --- | --- | --- |
| Corner **scale handles** | `draw_circle(corner, SCALE_HANDLE_DISTANCE / _zoom, …)` (line 590) | **8 px radius filled circles** (16 px diameter) at the four bbox corners | four "giant vertex handles" |
| **Rotate ring** | `draw_arc(ring_center, ROTATE_RING_RADIUS / _zoom, …)` (line 595) + connector line | **40 px-radius ring** floating above the bbox | another "giant handle" |
| Vertex handles | `draw_circle(pts[j], HANDLE_RADIUS / _zoom, …)` (line 576) | 3 px — already correct | — |
Two further divergences from the stickman editor worth fixing while here:
- Vertex handles are drawn **white** (`HANDLE_COLOR`, line 574) instead of **color-matched**
to the shape (`body_part_panel.gd` lines 399–407: `handle_color := color`, yellow while
dragging). Color-matching makes 3 px handles legible and distinct from gizmos.
- The rotate ring's hit test (line 1097–1103) is a fuzzy `maxf(12/zoom, ring*0.3)` circle —
generous but at an awkward location (above the shape), which is why rotation is hard (point 1).
---
## 2. Static contracts
### 2.1 Constants — rotation dot + scale crosses (mirror `whole_stickman_preview.gd`)
Replace the ring/scale-circle constants. New values are **exactly** the stickman editor's
(`whole_stickman_preview.gd` lines 31–35):
```gdscript
# prop_canvas.gd — REMOVE:
# const ROTATE_RING_RADIUS: float = 40.0
# const SCALE_HANDLE_DISTANCE: float = 8.0
# (keep HANDLE_RADIUS / HANDLE_RADIUS_SELECTION / HANDLE_HOLLOW_SIZE / EDGE_HIT_THRESHOLD)
# prop_canvas.gd — ADD (mirror whole_stickman_preview exactly):
const ROTATION_CIRCLE_RADIUS: float = 5.0 # draw radius, divided by _zoom
const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0 # hit radius, divided by _zoom
const ROTATION_CIRCLE_OFFSET: float = 26.0 # bbox bottom-center -> dot center (world)
const SCALE_CROSS_SIZE: float = 5.0 # cross arm length, divided by _zoom
const SCALE_CROSS_HIT_RADIUS: float = 12.0 # hit radius, divided by _zoom
```
**Drawing** (`_draw_selection_gizmos`):
```gdscript
var b: Rect2 = _shape_bbox(sd)
if b.size == Vector2.ZERO:
return
# Bounding box (unchanged)
draw_rect(b, GIZMO_COLOR, false, 1.5 / _zoom)
# Rotation dot BELOW the bbox bottom-center (was the ring above)
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, GIZMO_COLOR)
# Corner scale CROSSES (was filled circles)
var cross_half := SCALE_CROSS_SIZE / _zoom
for corner: Vector2 in [b.position, Vector2(b.end.x, b.position.y), b.end, Vector2(b.position.x, b.end.y)]:
draw_line(corner + Vector2(-cross_half, 0), corner + Vector2(cross_half, 0), GIZMO_COLOR, 1.5 / _zoom)
draw_line(corner + Vector2(0, -cross_half), corner + Vector2(0, cross_half), GIZMO_COLOR, 1.5 / _zoom)
```
**Rotation dot hit-test** (replaces `_hit_test_rotate_ring`):
```gdscript
func _hit_test_rotation_dot(sd: Dictionary, world_pos: Vector2) -> bool:
var b: Rect2 = _shape_bbox(sd)
if b.size == Vector2.ZERO:
return false
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
return world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom
```
**Scale cross hit-test** (replaces `_hit_test_scale_corner` threshold):
```gdscript
var threshold: float = SCALE_CROSS_HIT_RADIUS / _zoom # was SCALE_HANDLE_DISTANCE / _zoom
```
### 2.2 Rotation drag — incremental angle accumulation (point 1, hard-to-rotate fix)
The current drag is **absolute-from-start** (`_rotate_start_angle` set at press, `delta =
current_angle − start` each frame, lines 765–770, 841–843) — it jumps when the cursor crosses
±180°. Adopt the stickman editor's incremental accumulation
(`whole_stickman_preview.gd` lines 767–794):
```gdscript
# State (replace _rotate_start_angle):
var _rotate_total_delta: float = 0.0
var _rotate_prev_angle: float = 0.0
# On press (_handle_left_press, rotation branch):
_drag_mode = DragMode.ROTATE
_rotate_center = _shape_bbox(sd).get_center()
_rotate_total_delta = 0.0
_rotate_prev_angle = rad_to_deg(_rotate_center.angle_to_point(world_pos))
_gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array()))
# On motion (_handle_drag_motion, DragMode.ROTATE):
var current_angle := rad_to_deg(_rotate_center.angle_to_point(world_pos))
var frame_delta := current_angle - _rotate_prev_angle
if frame_delta > 180.0:
frame_delta -= 360.0
elif frame_delta < -180.0:
frame_delta += 360.0
_rotate_total_delta += frame_delta
_rotate_prev_angle = current_angle
var delta := deg_to_rad(_rotate_total_delta)
if ctrl_pressed:
delta = snappedf(delta, deg_to_rad(ROTATION_SNAP_DEG))
# apply delta to _gizmo_base_points about _rotate_center (unchanged math)
```
### 2.3 Mode separation — enum, state, signals, public API (point 4)
**Recommended model: (a) two explicit modes** with a kid-friendly segmented toggle. Rationale
and rejected alternatives in §2.3.1 and §5.
```gdscript
# prop_canvas.gd
enum EditMode { TRANSFORM, POINTS } # kid-facing labels: "Move & Resize" / "Edit Points"
signal mode_changed(mode: int) # emitted only when the mode actually changes
var _edit_mode: EditMode = EditMode.TRANSFORM # default on load
func set_edit_mode(mode: int) -> void:
var m: int = TRANSFORM if mode != POINTS else POINTS # clamp anything unknown to TRANSFORM
if m == _edit_mode:
return
_edit_mode = m
_drag_mode = DragMode.NONE # cancel any in-progress drag
_dragging_vertex = -1
queue_redraw()
mode_changed.emit(_edit_mode)
func get_edit_mode() -> int:
return _edit_mode
```
**Mode ↔ existing public API mapping** (the mode gates *input*, not the math APIs — tests and
programmatic callers keep working in either mode):
| API | Mode behaviour |
| --- | --- |
| `insert_vertex` / `remove_vertex` | mode-agnostic (programmatic); only the **context menu** hides "Create/Remove Point" outside POINTS mode |
| `translate_selected` / `rotate_selected` / `scale_selected` | mode-agnostic (programmatic; tests) |
| `sanitize_shape`, `set_shape_data`, `clear`, `copy_shape`, `paste_shape`, `mirror_x/y`, `bring_forward/send_back` | mode-agnostic |
| `select` / `deselect` / `get_selected_index` | mode-agnostic; do **not** change the mode |
| `set_shape_data` / `clear` | do **not** reset the mode (tool preference persists, like snap) — see §5 |
**Drawing rules per mode** (`_draw_shape` + `_draw_selection_gizmos`):
| Element | TRANSFORM | POINTS |
| --- | --- | --- |
| Bounding box (`draw_rect`) | ✔ | — (replaced by a light selection outline) |
| Rotation dot | ✔ | — |
| Scale crosses | ✔ | — |
| Vertex handles (color-matched circles / hollow rects) | — | ✔ |
| Selection outline polyline (white, `2.5 / _zoom`) | ✔ (light) | ✔ |
**Hit-test priority per mode** (`_handle_left_press`):
```text
TRANSFORM (default):
1. rotation dot -> DragMode.ROTATE
2. scale cross corner -> DragMode.SCALE
3. shape body (front→back, fill/outline) -> DragMode.TRANSLATE + select
4. empty -> deselect
POINTS:
1. vertex (selected shape first, then others front→back) -> DragMode.VERTEX
2. shape body -> select only (NO translate) # see §5 open choice
3. empty -> deselect
```
**Right-click context menu per mode** (`_handle_right_click`):
```text
POINTS:
on a vertex -> "Remove Point" (only item)
on a shape -> "Create Point" + Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back
empty canvas -> Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste if clipboard)
TRANSFORM:
on a shape -> Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back (no Create/Remove Point)
empty canvas -> Add primitives (+ Paste if clipboard)
```
### 2.3.1 Model evaluation (point 4)
- **(a) Two explicit modes + segmented toggle — RECOMMENDED.** Predictable, no keyboard, no
ambiguity between "grab a vertex" and "move the whole shape". Mode state is canvas-owned with
a public API (`set_edit_mode`/`get_edit_mode` + `mode_changed`), so the editor's toggle drives
it and the headless suite can assert it. Cost: the kid must learn two modes and switch; the
toggle lives next to the canvas (always visible) and the default (`TRANSFORM`) matches the
most common first action ("move my shape").
- **(b) Automatic context switching — rejected.** Clicking a vertex enters points mode, clicking
the body transforms. A vertex sits *on* the body, so the two hit targets overlap; accidental
vertex drags while trying to move a shape (and vice-versa) are exactly the conflict the user
reported. Harder to assert headlessly, and the "current mode" is invisible until you miss.
- **(c) Per-selection mode + inspector "Edit Points" button — rejected as primary.** Clean, but
the affordance is hidden in the inspector column and changes per selection, which is less
discoverable for 7–12 year-olds than an always-visible toggle. (Its "Edit Points" button is
kept as an optional secondary affordance — see §5.)
### 2.4 Create Point — new public API + feedback (point 3)
```gdscript
signal vertex_created(shape_idx: int, vertex_idx: int)
# Returns the new vertex index, or -1 if no segment/point could be inserted.
func create_point_on_edge(world_pos: Vector2) -> int:
# find nearest segment (existing _point_to_segment_distance loop, closed-aware)
# projection := a + ab * clampf(ap.dot(ab)/ab_len_sq, 0, 1) # cursor projected onto segment
# insert_idx := best_edge + 1 (closed wrap: pts.size())
insert_vertex(_selected_shape_idx, insert_idx, projection)
vertex_created.emit(_selected_shape_idx, insert_idx)
_flash_vertex = insert_idx
_flash_remaining = FLASH_DURATION # §2.4.1
queue_redraw()
return insert_idx
```
`_create_point_on_edge` (private) is refactored to delegate to `create_point_on_edge` (public),
so the projection + signal + flash path is directly testable.
```gdscript
const FLASH_DURATION: float = 0.6 # seconds
const FLASH_RING_RADIUS: float = 12.0 # max ring radius, divided by _zoom
var _flash_vertex: int = -1
var _flash_remaining: float = 0.0
```
**2.4.1 Flash feedback (recommended over auto-begin-drag).** `_process(delta)` decrements
`_flash_remaining`; `_draw_shape` draws a pulsing ring around the flashing vertex:
```gdscript
if j == _flash_vertex and _flash_remaining > 0.0:
var t := 1.0 - _flash_remaining / FLASH_DURATION
var ring_r := FLASH_RING_RADIUS * (0.5 + 0.5 * t) / _zoom
draw_arc(pts[j], ring_r, 0.0, TAU, 24, Color.YELLOW, 2.0 / _zoom)
```
Rationale: a flash + visible (color-matched, §2.1) hollow handle is non-modal and
unmistakeable; auto-begin-drag after a right-click menu choice can feel like the vertex "ran
away" or the tool got stuck. (Auto-drag remains an open choice — §5.)
### 2.5 Kid-friendly UI — theme additions, accessors, copy (point 5)
**Schema version: user-approved to stay `"1.0"`** (the new top-level `ui` section is additive;
no existing-key meaning changes). `PropTheme.load_from` is already version-agnostic (it
deep-merges and never rejects on `version`), so an older file without `ui` still loads with
fallbacks.
New section (committed `res://prop_theme.json` and `PropTheme.DEFAULT_CONFIG`):
```json
"ui": {
"fonts": {
"body": 16,
"header": 20,
"menu": 16,
"status": 14
},
"hit_targets": {
"button_min_height": 36,
"row_min_height": 36
},
"colors": {
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF"
}
}
```
New `PropTheme` accessors (clamped, fallback → `DEFAULT_CONFIG`):
```gdscript
func font_body() -> int: return integer("ui.fonts.body", 16, 8, 72)
func font_header() -> int: return integer("ui.fonts.header", 20, 8, 96)
func font_menu() -> int: return integer("ui.fonts.menu", 16, 8, 72)
func font_status() -> int: return integer("ui.fonts.status", 14, 8, 72)
func button_min_height() -> int: return integer("ui.hit_targets.button_min_height", 36, 16, 200)
func row_min_height() -> int: return integer("ui.hit_targets.row_min_height", 36, 16, 200)
func text_color() -> Color: return color("ui.colors.text", Color("#F2F2F2"))
func text_muted_color() -> Color: return color("ui.colors.text_muted", Color("#9A9A9A"))
func border_color() -> Color: return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
func accent_color() -> Color: return color("ui.colors.accent", Color("#4FA3FF"))
```
`prop_editor.gd` `_build_control_theme()` replaces the hardcoded `14`/`16` font sizes and the
hardcoded text/border colors with these tokens; `_build_inspector()` applies `button_min_height`
to buttons and `row_min_height` to inspector rows (`custom_minimum_size.y`). Local `Theme`
only — **no global GUI mutation**.
**Copy changes (plain-language, no emojis):**
| Current | Proposed |
| --- | --- |
| "Add" (button) | "Add a Shape" |
| "Delete Shape" (inspector button) | "Erase Shape" |
| "Delete" (context menu) | "Erase" |
| "Mirror X" / "Mirror Y" (context menu) | "Flip Left-Right" / "Flip Up-Down" |
| "Create Point" / "Remove Point" | keep ("Create Point" / "Remove Point") |
| "Bring Forward" / "Send Back" | keep ("Bring Forward" / "Send Back") |
| "Selected Shape" / "Add Shape" (section headers) | keep, but rendered at `font_header` |
("Erase" vs "Delete", and whether to also relabel Mirror as "Flip", are open choices — §5.)
---
## 3. Task-to-implementation mapping & acceptance criteria
| # | Feedback point | Target | Acceptance criteria |
| --- | --- | --- | --- |
| 1 | Hard rotation | `scripts/prop_canvas.gd` | `ROTATE_RING_RADIUS` removed; `ROTATION_CIRCLE_RADIUS/HIT_RADIUS/OFFSET` = `5.0/12.0/26.0`; dot drawn below bbox bottom-center at `ROTATION_CIRCLE_RADIUS / _zoom`; hit radius `ROTATION_CIRCLE_HIT_RADIUS / _zoom`; drag uses incremental accumulation (no ±180° jump); Ctrl snaps 15°. |
| 2 | Giant circles | `scripts/prop_canvas.gd` | `SCALE_HANDLE_DISTANCE` removed; corner handles drawn as `SCALE_CROSS_SIZE / _zoom` crosses; vertex handles stay `HANDLE_RADIUS / _zoom` (3 px) and become **color-matched** (yellow while dragging), user-created = hollow rect. |
| 3 | Create Point | `scripts/prop_canvas.gd` | `create_point_on_edge(world_pos) -> int` inserts at the **cursor projection** (clamped), emits `vertex_created`, flashes the vertex 0.6 s; "Create Point" remains available **near the outline only** (`EDGE_HIT_THRESHOLD / _zoom`, unchanged — user decision); `_create_point_on_edge` delegates to it. |
| 4 | Mode separation | `scripts/prop_canvas.gd` + `scripts/prop_editor.gd` | `enum EditMode { TRANSFORM, POINTS }`; default `TRANSFORM`; `set_edit_mode`/`get_edit_mode`/`mode_changed`; drawing + hit-test + context menu obey §2.3; editor renders a segmented toggle ("Move & Resize" / "Edit Points") that drives `set_edit_mode` and syncs from `mode_changed`; shape-editor controls unaffected in both modes. |
| 5 | Kid-friendly UI | `scripts/prop_editor.gd` + `scripts/prop_theme.gd` + `prop_theme.json` | `ui` section + accessors per §2.5; font sizes/tokens drive `_build_control_theme`/`_build_inspector` (no hardcoded 14/16); min-height hit targets applied; copy table applied; local theme only. |
**Test updates — `tests/test_prop_editor_canvas.gd` (headless; public API under test):**
- **New `_test_edit_mode()`**: default `get_edit_mode() == TRANSFORM`; `set_edit_mode(POINTS)`
→ `get_edit_mode() == POINTS` and `mode_changed` fires exactly once; re-setting the same mode
fires **no** extra `mode_changed`; unknown value clamps to `TRANSFORM`; `select`/`deselect`/
`clear` leave the mode unchanged.
- **New `_test_gizmo_constants()`**: assert `ROTATION_CIRCLE_RADIUS == 5.0`,
`ROTATION_CIRCLE_HIT_RADIUS == 12.0`, `ROTATION_CIRCLE_OFFSET == 26.0`,
`SCALE_CROSS_SIZE == 5.0`, `SCALE_CROSS_HIT_RADIUS == 12.0`, `HANDLE_RADIUS == 3.0`; assert
`ROTATE_RING_RADIUS` and `SCALE_HANDLE_DISTANCE` are absent from
`PROP_CANVAS.get_script().get_script_constant_map()`.
- **New `_test_create_point_on_edge()`**: box at `(100,100)`; `create_point_on_edge(Vector2
(124, 76))` (off-midpoint, above the top edge) → returned index ≥ 0, inserted point == the
cursor's projection onto the top edge (not the midpoint `(124,76)` itself and not
`(124,100)`), `vertex_flags[idx] == USER_CREATED`, and `vertex_created` emitted with the
right `(shape_idx, vertex_idx)`.
- Existing suites unchanged (all existing APIs remain mode-agnostic).
**Manual F6 verification:** drag the bottom dot in a full circle (no ±180° flip); scale
crosses; create a point then drag it to kink a rectangle edge; toggle modes and confirm
gizmos/handles swap and body-drag is inert in POINTS mode; confirm fonts/targets.
---
## 4. Files modified
| File | Changes |
| --- | --- |
| `scripts/prop_canvas.gd` | Rotation dot + incremental drag; scale crosses; color-matched vertex handles; `EditMode` enum + `set_edit_mode`/`get_edit_mode`/`mode_changed`; mode-gated drawing/hit-test/context-menu; `create_point_on_edge` + `vertex_created` + flash state; "Create Point" anywhere on shape. |
| `scripts/prop_editor.gd` | Segmented mode toggle + `mode_changed` wiring; theme-driven fonts/colors/hit-targets (replace hardcoded 14/16); copy changes. |
| `scripts/prop_theme.gd` | `ui` section in `DEFAULT_CONFIG` + accessors (§2.5). |
| `prop_theme.json` | `version` stays `"1.0"` (user decision); add `ui` section. |
| `tests/test_prop_editor_canvas.gd` | Add `_test_edit_mode`, `_test_gizmo_constants`, `_test_create_point_on_edge`. |
| `docs/tech_debt_and_optimizations.md` | Log the explicit-mode interaction decision and the "Create Point" feedback design. |
---
## 5. Design choices — user-approved resolutions
1. **Mode names / labels.** **Approved:** `"Move & Resize"` (TRANSFORM) and `"Edit Points"` (POINTS).
2. **Default mode on load.** **Approved:** `TRANSFORM` ("Move & Resize").
3. **Mode persistence across Clear/Load.** **Approved:** mode persists (tool preference, like snap).
4. **Shape-body drag in POINTS mode.** **Approved:** select-only (no translate).
5. **Create Point feedback.** **Approved:** insert at cursor projection + auto-select + flash ring (0.6 s).
6. **Create Point availability.** **Approved (deviation):** keep **outline-only** (`EDGE_HIT_THRESHOLD / _zoom` gating unchanged).
7. **Theme schema version.** **Approved (deviation):** stay `"1.0"` (additive `ui` section only).
8. **"Erase" vs "Delete", Mirror→"Flip" relabel.** **Approved:** "Erase Shape"/"Erase", "Flip Left-Right"/"Flip Up-Down"; "Create Point"/"Remove Point"/"Bring Forward"/"Send Back" unchanged.
9. **Mode toggle placement.** **Approved:** a segmented two-button toolbar row above the canvas (always visible), synced to `mode_changed`.
---
## 6. Implementation order
1. `prop_theme.gd` + `prop_theme.json` (theme tokens + accessors) — independent of canvas work.
2. `prop_canvas.gd` — constants, rotation dot + incremental drag, scale crosses, color-matched
handles (points 1 & 2).
3. `prop_canvas.gd` — `create_point_on_edge` + `vertex_created` + flash + "anywhere on shape"
(point 3).
4. `prop_canvas.gd` — `EditMode` + gating (point 4).
5. `prop_editor.gd` — mode toggle + theme-driven fonts/targets + copy (points 4 & 5).
6. `tests/test_prop_editor_canvas.gd` — three new suites.
7. Parse check + headless run + manual F6 verification; docs/tech-debt note.
+694
View File
@@ -0,0 +1,694 @@
# Phase 9 Prop Editor — Round 3 (Reshape-Mode Menu, Window Chrome, Exit & Autosave)
Status: **approved** (2026-09-18; all nine open choices resolved as proposed).
Source: twelve user feedback points against the shipped Round 2 Prop Editor
(`scripts/prop_canvas.gd`, `scripts/prop_editor.gd`, `scripts/prop_theme.gd`,
`prop_theme.json`, `scenes/prop_editor.tscn`). Autosave/exit reference:
`scripts/sandbox_stage.gd` + `scripts/stage_scene_io.gd`.
---
## 0. Feedback summary (verbatim intent)
1. In "Edit Points" mode, right-clicking some segments shows the add-shape menu
instead of Create Point — a segment right-click should always offer creating a point.
2. A created point sometimes appears on a different segment; it must appear where the
cursor was when created.
3. In "Edit Points" mode, the RMB menu must ONLY offer point editing: "Create Point"
(segment) / "Remove Point" (vertex). No Copy/Erase/Flip/z-order items.
4. In "Edit Points" mode, do NOT allow adding shapes (empty-canvas RMB shows nothing).
5. In "Edit Points" mode, the inspector must NOT allow adding or erasing a shape
(hide the Add-Shape section and the Erase Shape button; sync on `mode_changed`).
6. Rename modes: "Move & Resize" → **"Arrange"**, "Edit Points" → **"Reshape"**
(enum values stay TRANSFORM/POINTS — only labels/emoji change).
7. Mode toggle becomes a segmented **[ Arrange | Reshape ]** pair **centered** in the
ModeBar, with a **bigger font** and a **related emoji** on each button.
8. "View → Reset Views" becomes **"View → Reset View"** (singular).
9. File/Edit/View menu bar + items get a **bigger font** (theme-driven) plus
**kid-friendly emoji** in labels.
10. **Window title** = `[Prop name][filename.prop][*]`: name (default **"Untitled"**),
filename (basename with `.prop`, only when saved), `*` when modified.
11. **Exit dialog**: intercept window close while modified; **Discard / Save / Cancel**
with emoji; async save-before-quit.
12. **Autosave/recovery**: temp prop file on modification, debounced; startup restore
prompt; explicit clear semantics.
---
## 1. Diagnoses (verified root causes)
### 1.1 Point 2 — created point lands on a different segment
`PropCanvas._get_context_world_pos()` (`scripts/prop_canvas.gd:1102-1108`) discards any
stored click position whose world coordinates fall outside the visible `[0,0 .. size/zoom]`
rect and replaces it with the **canvas center**:
```gdscript
func _get_context_world_pos() -> Vector2:
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2(100.0, 60.0))
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2(100.0, 60.0)
var world_size := size / _zoom
if world_pos.x < 0.0 or world_pos.y < 0.0 or world_pos.x > world_size.x or world_pos.y > world_size.y:
return _screen_to_world(size * 0.5)
return world_pos
```
Negative world coordinates are **normal after panning** (shapes live around the world
origin). Right-clicking a segment at e.g. `(-40, 120)` stores a valid world position that is
then thrown away, so `create_point_on_edge` (called from `_on_context_menu_id_pressed`,
`MENU_CREATE_POINT`, line 993) projects the **canvas center** onto whatever segment is nearest
*it* — a different segment. **Fix:** remove the bounds fallback entirely (the stored position
is always valid world space); only guard non-finite values.
### 1.2 Point 1 — right-clicking some segments shows the Add-Shape menu
In POINTS mode, `_handle_right_click` (lines 744-813) targets a shape only through
`_is_point_in_shape` (lines 774-778). For **closed** shapes `_is_point_in_shape` is
**fill-only** (`Geometry2D.is_point_in_polygon`, lines 1168-1169); for open lines it is a
distance-to-outline test. A right-click **on a segment** that lies slightly outside the fill
(concave corners, a 12-gon circle's edge chord, just beyond a rectangle edge) therefore falls
through to the empty-canvas **Add-Shape** menu (lines 802-813). The existing "Create Point"
branch (line 786) is only reachable **after** `_is_point_in_shape` already returned true, so
it cannot rescue the case. For open lines, a click beyond `EDGE_HIT_THRESHOLD / _zoom` also
falls through. **Fix:** in POINTS mode, replace the fill-only shape gate with
**segment-proximity targeting** (see §2.1). Combined with points 3/4 (the empty-canvas Add
menu disappears in POINTS mode), the entire fall-through disappears.
---
## 2. Static contracts
### 2.1 POINTS-mode right-click menu matrix + segment-proximity targeting
New public helper (directly testable):
```gdscript
# prop_canvas.gd — returns the frontmost shape whose segment is within
# EDGE_HIT_THRESHOLD / _zoom of world_pos, else -1.
func hit_test_segment(world_pos: Vector2) -> int:
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_near_outline(i, world_pos):
return i
return -1
```
`_is_point_near_outline` already measures distance to the **segments only**
(`_distance_to_shape_pts`, lines 1183-1189), so this is the segment-proximity test the fix
needs.
Revised `_handle_right_click` right-click decision table:
| Mode | Click target | Menu shown |
| --- | --- | --- |
| POINTS | on a vertex (selected first, then others front→back) | **Remove Point** (only) |
| POINTS | on/near a **segment** (within `EDGE_HIT_THRESHOLD/zoom`, front→back) | **Create Point** (only); shape is selected first |
| POINTS | interior body of a closed shape (inside fill, *not* near any segment) | **nothing**; shape is selected (no menu pops) |
| POINTS | empty canvas | **nothing** (no Add-Shape menu) |
| TRANSFORM | on a shape body (fill/outline, front→back) | Copy / Paste / Erase / Flip L-R / Flip U-D / Bring Forward / Send Back (unchanged) |
| TRANSFORM | empty canvas | Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste) (unchanged) |
Revised `_handle_right_click` shape (POINTS branch only; TRANSFORM branch is unchanged):
```gdscript
func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
# --- POINTS mode: point editing only ---
if _edit_mode == EditMode.POINTS:
# 1. Vertex (selected first, then others front->back) -> Remove Point
var context_shape := -1
var context_vertex := -1
if _selected_shape_idx >= 0:
var hit_idx := _hit_test_vertex(_selected_shape_idx, world_pos)
if hit_idx >= 0:
context_shape = _selected_shape_idx
context_vertex = hit_idx
for i: int in range(shapes.size() - 1, -1, -1):
if context_shape >= 0:
break
if i == _selected_shape_idx:
continue
var hit_idx := _hit_test_vertex(i, world_pos)
if hit_idx >= 0:
context_shape = i
context_vertex = hit_idx
_context_menu.clear()
if context_shape >= 0 and context_vertex >= 0:
_context_menu.set_meta("context_shape", context_shape)
_context_menu.set_meta("context_vertex", context_vertex)
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
return
# 2. Segment proximity -> Create Point (selects the shape)
var seg_shape := hit_test_segment(world_pos)
if seg_shape >= 0:
_selected_shape_idx = seg_shape
_context_menu.set_meta("context_shape", seg_shape)
_context_menu.set_meta("click_position", world_pos)
queue_redraw()
shape_selected.emit(seg_shape)
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
return
# 3. Interior body: select only, NO menu
var body_shape := -1
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_in_shape(i, world_pos):
body_shape = i
break
if body_shape >= 0:
_selected_shape_idx = body_shape
queue_redraw()
shape_selected.emit(body_shape)
return # empty canvas -> no menu either
# --- TRANSFORM mode: existing shape-hit / empty-canvas-add logic (unchanged) ---
...
```
Notes:
- The vertex branch keeps precedence over the segment branch (a vertex sits at a segment end).
- `_on_context_menu_id_pressed` already routes `MENU_CREATE_POINT` → `create_point_on_edge(
_get_context_world_pos())`; the `context_shape` meta is set on the menu so the selected shape
is the one `create_point_on_edge` operates on.
- Programmatic APIs (`insert_vertex`, `remove_vertex`, `create_point_on_edge`,
`hit_test_segment`, `set_edit_mode`/`get_edit_mode`) remain mode-agnostic; only the context
menu gates them.
### 2.2 `_get_context_world_pos` fix (Point 2)
```gdscript
func _get_context_world_pos() -> Vector2:
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2.ZERO)
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2.ZERO
# The stored click position is always a valid world-space coordinate
# (from _screen_to_world at click time). Negative coords are normal after
# panning — no bounds clamp. Only guard non-finite values.
if not is_finite(world_pos.x) or not is_finite(world_pos.y):
return _screen_to_world(size * 0.5)
return world_pos
```
This also fixes TRANSFORM-mode empty-canvas Add placement (a click far off the origin now
places the primitive there instead of silently snapping to center).
### 2.3 Mode labels/emoji — location, centering, font token (Points 6, 7)
**Location decision:** the canvas keeps only the enum (`enum EditMode { TRANSFORM, POINTS }`).
All human-facing strings (mode labels/emoji, menu labels/emoji, dialog copy) live in
`prop_editor.gd` — the UI controller. The canvas stays a string-free widget; the label mapping
is a pure static function for headless testing.
```gdscript
# prop_editor.gd
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"] # index == EditMode
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
static func mode_button_text(mode: int) -> String:
var idx: int = clampi(mode, 0, MODE_LABELS.size() - 1)
return "%s %s" % [MODE_EMOJI[idx], MODE_LABELS[idx]]
```
Emoji choice (kid-friendly, distinct, justified):
| Mode | Emoji | Label | Rationale |
| --- | --- | --- | --- |
| TRANSFORM | 🖐️ | Arrange | open hand = "grab and move things around" (translate/rotate/scale gizmos) |
| POINTS | ✏️ | Reshape | pencil = "draw / edit points" (vertex handles, Create/Remove Point) |
**Toolbar centering.** The `%ModeBar` `HBoxContainer` (`scenes/prop_editor.tscn` line 78) is
the row above the canvas. Set `_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER` in
`_ready()` so the segmented pair sits centered in the canvas width (interpreting "centered
vertically" as "centered in the bar" — see §6 open choices). `_make_mode_button` keeps
`toggle_mode` + shared `ButtonGroup` (the segmented affordance), drops `SIZE_SHRINK_BEGIN` in
favor of the container alignment, and gains a bigger font + min height.
**Font/hit-target tokens.** Add to `PropTheme.DEFAULT_CONFIG` + committed `prop_theme.json`
(additive; `version` stays `"1.0"`):
```json
"ui": {
"fonts": { "body": 16, "header": 20, "menu": 18, "menu_bar": 18, "mode": 20, "status": 14 },
"hit_targets": { "button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 44 }
}
```
New `PropTheme` accessors (clamped, fallback to `DEFAULT_CONFIG`):
```gdscript
func font_mode() -> int: return integer("ui.fonts.mode", 20, 8, 96)
func font_menu_bar() -> int: return integer("ui.fonts.menu_bar", 18, 8, 72)
func mode_button_min_height() -> int: return integer("ui.hit_targets.mode_button_min_height", 44, 16, 200)
```
`_make_mode_button` applies `add_theme_font_size_override("font_size", _theme.font_mode())`
and `custom_minimum_size.y = float(_theme.mode_button_min_height())`, and sets
`text = mode_button_text(mode)`.
### 2.4 Menu bar emoji + font tokens (Points 8, 9)
`_build_control_theme()` adds a `MenuBar` font size; `_setup_menu_bar()` rewrites titles/items:
```gdscript
# _build_control_theme() addition:
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
# (PopupMenu items already use font_menu, now default 18.)
# _setup_menu_bar() rewrite (emoji + singular "Reset View"):
_menu_bar.set_menu_title(idx_file, "📁 File")
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
file_menu.add_item("📂 Load...", MENU_LOAD)
file_menu.add_separator()
file_menu.add_item("🗑️ Clear", MENU_CLEAR)
_menu_bar.set_menu_title(idx_edit, "🔧 Edit")
edit_menu.add_item("📏 Configure Grid / Snap...", MENU_GRID_SETTINGS)
_menu_bar.set_menu_title(idx_view, "👁️ View")
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS) # was "Reset Views"
```
Proposed menu emoji set (unique within the menu bar; 💾/🗑️ reappear in the exit dialog by
design — same meaning):
| Label | Emoji | Justification |
| --- | --- | --- |
| File | 📁 | folder container = file operations |
| Save | 💾 | floppy disk = classic save (matches exit-dialog Save) |
| Save As… | 📄 | page = "write to a new document" |
| Load… | 📂 | open folder = open existing |
| Clear | 🗑️ | trash = discard content (matches exit-dialog Discard) |
| Edit | 🔧 | wrench = tools/settings |
| Configure Grid / Snap… | 📏 | ruler = measurement/grid |
| View | 👁️ | eye = visibility |
| Reset View | 🔍 | magnifier = look again / reset |
Emoji-in-labels is an established pattern in this codebase (sandbox uses `💾 Save`,
`🗑️ Discard`, `📋 Edit Queue…`, `⚡ Edit Rules…`), so it renders via the project's fallback
font.
### 2.5 Window title builder + update points (Point 10)
Pure, headless-testable builder in `prop_editor.gd`:
```gdscript
const UNTITLED_NAME := "Untitled"
static func build_window_title(prop_name: String, file_path: String, dirty: bool) -> String:
var name := prop_name.strip_edges()
if name.is_empty():
name = UNTITLED_NAME
var title := name
if not file_path.is_empty():
title += " — " + file_path.get_file() # get_file() keeps the ".prop" extension
if dirty:
title += " *"
return title
```
Examples:
| name | file_path | dirty | title |
| --- | --- | --- | --- |
| `""` | `""` | false | `Untitled` |
| `"My Crate"` | `""` | true | `My Crate *` |
| `"My Crate"` | `res://props/mycrate.prop` | false | `My Crate — mycrate.prop` |
| `""` | `res://props/mycrate.prop` | true | `Untitled — mycrate.prop *` |
Update points — one choke point:
```gdscript
func _update_window_title() -> void:
if not is_inside_tree():
return
get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty)
func _update_save_status() -> void:
... existing status-bar text ...
_update_window_title() # single re-derivation site
```
`_update_save_status()` is already invoked on every relevant transition (`_on_name_changed`,
`_on_canvas_shape_changed`, `_on_add_pressed`, `_save_to`, `_load_from`,
`_on_clear_confirmed`, `_apply_json_data`), and `_ready()` calls it once — so the title stays
in sync for free. **Extension enforcement:** `_on_save_file_selected` normalizes the path to
`.prop` (mirroring sandbox `_on_scene_save_path_selected`) so `get_file()` always includes the
extension:
```gdscript
func _on_save_file_selected(path: String) -> void:
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
...
```
### 2.6 Exit dialog flow (Point 11)
Mirrors sandbox (`auto_accept_quit = false` + `NOTIFICATION_WM_CLOSE_REQUEST` +
save-then-quit). The save path is **async only because of the thumbnail render**, so the
exit-save path **skips the thumbnail** (cache nicety, not data) — see §6.
```gdscript
# state
var _exit_dialog: ConfirmationDialog
var _pending_quit: bool = false
# _ready(): add
get_tree().auto_accept_quit = false
# _setup_dialogs(): build (same _ui_theme as _clear_confirm)
_exit_dialog = ConfirmationDialog.new()
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
_exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel" # or add_button(..., true, "cancel")
_exit_dialog.theme = _ui_theme
add_child(_exit_dialog)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_close()
func _request_close() -> void:
if not _dirty:
get_tree().quit()
return
_pending_quit = true
_exit_dialog.popup_centered()
func _on_exit_save() -> void:
_exit_dialog.hide()
if _current_path.is_empty():
_save_dialog.popup_centered() # _pending_quit stays true
return
await _save_and_maybe_quit(_current_path)
func _save_and_maybe_quit(path: String) -> void:
var ok: bool = await _save_to(path, false) # skip thumbnail on exit-save
if ok and _pending_quit:
_pending_quit = false
get_tree().quit()
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
_clear_autosave()
_pending_quit = false
get_tree().quit()
func _on_exit_cancel() -> void:
_pending_quit = false # stay in the editor
# _on_save_file_selected (save dialog path) — compose with pending-quit:
func _on_save_file_selected(path: String) -> void:
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
var ok: bool = await _save_to(path, not _pending_quit) # thumbnail only on normal save
if _pending_quit and ok:
_pending_quit = false
get_tree().quit()
```
`_save_to` changes signature to return success and accept a thumbnail flag:
```gdscript
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
var ok: bool = PropFileScript.save(path, _build_json_data())
if not ok:
_show_error("Could not save the prop to '%s'." % path)
return false
_current_path = path
_dirty = false
_clear_autosave()
_update_save_status()
if with_thumbnail:
await _save_prop_thumbnail()
return true
```
Flow summary:
```text
Window close (NOTIFICATION_WM_CLOSE_REQUEST)
├─ clean -> quit()
└─ dirty -> _exit_dialog
├─ ❌ Cancel -> _pending_quit=false, stay
├─ 🗑️ Discard -> clear autosave, quit()
└─ 💾 Save
├─ no _current_path -> Save dialog; on select -> save(skip thumb) -> quit
└─ has _current_path -> save(skip thumb) -> quit (or show error, no quit)
```
`_ask_confirm`-style theming already exists: the exit dialog uses the same `_ui_theme` and
`ConfirmationDialog` shape as `_clear_confirm` (built in `_setup_dialogs()`), and gets the
same `⚠️` title convention as the sandbox's unsaved-changes dialog.
### 2.7 Autosave / recovery (Point 12)
Mirrors `sandbox_stage.gd` (`AUTOSAVE_PATH`, debounce-free write-on-modify, `write_file_safe`,
`_check_autosave_recovery`, stale-check, clear-on-save/load). A small atomic writer is added to
`PropFile` (the scene IO's `write_file_safe` is sandbox-specific):
```gdscript
# prop_file.gd — atomic write mirroring StageSceneIO.write_file_safe.
static func save_safe(path: String, data: Dictionary) -> bool:
var tmp := path + ".tmp"
var bak := path + ".bak"
var file := FileAccess.open(tmp, FileAccess.WRITE)
if file == null:
push_warning("PropFile: could not write temporary file '%s'." % tmp)
return false
file.store_string(JSON.stringify(data, "\t"))
file.flush()
file.close()
if FileAccess.file_exists(bak):
DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
DirAccess.remove_absolute(tmp)
return false
if DirAccess.rename_absolute(tmp, path) != OK:
if FileAccess.file_exists(bak):
DirAccess.rename_absolute(bak, path)
return false
if FileAccess.file_exists(bak):
DirAccess.remove_absolute(bak)
return true
```
Editor constants + state:
```gdscript
const AUTOSAVE_PATH := "user://prop_editor_autosave.prop"
const AUTOSAVE_DEBOUNCE := 1.0
var _autosave_remaining: float = 0.0 # > 0 = counting down to a write
```
Dirty setpoints go through a single helper that schedules the autosave:
```gdscript
func _mark_dirty() -> void:
_dirty = true
_update_save_status()
_autosave_remaining = AUTOSAVE_DEBOUNCE
# _on_canvas_shape_changed / _on_name_changed / _on_add_pressed -> _mark_dirty()
```
Debounce tick (appended to the existing `_process`):
```gdscript
func _process(delta: float) -> void:
_update_cursor_coords()
if _autosave_remaining > 0.0:
_autosave_remaining -= delta
if _autosave_remaining <= 0.0:
_autosave_remaining = 0.0
_write_autosave()
func _write_autosave() -> void:
if not _dirty:
return
var data := _build_json_data()
data["metadata"]["autosave"] = true
data["metadata"]["source_path"] = _current_path
data["metadata"]["modified_unix"] = Time.get_unix_time_from_system()
PropFileScript.save_safe(AUTOSAVE_PATH, data)
func _clear_autosave() -> void:
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
```
The autosave never triggers another autosave (it writes the file, does not touch `_dirty` or
emit `shape_changed`); `_apply_json_data`/`clear` set `_dirty = false` and do not schedule.
Startup recovery (called from `_ready()` after dialogs are built; mirrors
`sandbox._check_autosave_recovery`, lines 1482-1500):
```gdscript
func _check_autosave_recovery() -> void:
if not FileAccess.file_exists(AUTOSAVE_PATH):
return
var raw: Dictionary = PropFileScript.load(AUTOSAVE_PATH)
if raw.is_empty():
_clear_autosave(); return
var raw_meta: Variant = raw.get("metadata", {})
var metadata: Dictionary = raw_meta if raw_meta is Dictionary else {}
if not bool(metadata.get("autosave", false)):
_clear_autosave(); return
var source_path := String(metadata.get("source_path", ""))
# Stale if the saved source exists and is newer-or-equal than the autosave.
if not source_path.is_empty() and FileAccess.file_exists(source_path) \
and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
_clear_autosave(); return
var dialog := ConfirmationDialog.new()
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
add_child(dialog)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
_current_path = source_path
_update_save_status()
dialog.queue_free())
dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"discard":
_clear_autosave()
dialog.queue_free())
dialog.popup_centered()
```
`_apply_json_data` gains an `is_recovery` flag so a restored prop stays **dirty** (its content
is newer than any saved source):
```gdscript
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
...
_dirty = is_recovery # recovery keeps dirty; normal load clears
...
```
**Autosave clear semantics (explicit table):**
| Event | Autosave cleared? |
| --- | --- |
| Successful manual save (`_save_to`, non-autosave) | **yes** |
| Successful load (`_load_from`) | **yes** |
| File → Clear confirmed (`_on_clear_confirmed`) | **yes** |
| Exit dialog Discard → quit | **yes** |
| Exit dialog Save → successful save → quit | **yes** (via `_save_to`) |
| Recovery prompt Discard | **yes** |
| Recovery prompt Restore | **no** (kept until a manual save/load/clear) |
| Autosave is stale (source newer) at startup | **yes** |
---
## 3. Task-to-implementation mapping & acceptance criteria
| # | Feedback point | Target | Acceptance criteria |
| --- | --- | --- | --- |
| 1 | Segment right-click → Create Point | `prop_canvas.gd` | `hit_test_segment(world_pos) -> int` public; `_handle_right_click` POINTS branch uses segment proximity (not fill-only) per §2.1; Create Point offered for any segment within `EDGE_HIT_THRESHOLD/zoom`; vertex still takes precedence (Remove Point). |
| 2 | Created point on correct segment | `prop_canvas.gd` | `_get_context_world_pos` no longer rejects negative/out-of-bounds coords; only non-finite guard; created vertex == cursor projection onto nearest segment. |
| 3 | POINTS menu = point editing only | `prop_canvas.gd` | POINTS right-click matrix per §2.1: vertex→Remove Point, segment→Create Point, interior/empty→no menu; no Copy/Erase/Flip/z-order items in POINTS mode. |
| 4 | No add menu in POINTS | `prop_canvas.gd` | POINTS empty-canvas right-click pops nothing (early return before the Add-Shape block). |
| 5 | Inspector hides Add/Erase in POINTS | `prop_editor.gd` | `_add_section` VBoxContainer wraps the Add-Shape section; `_sync_mode_controls(mode)` hides `_add_section` + `_delete_button` in POINTS and shows them in TRANSFORM; called from `_on_canvas_mode_changed` and `_ready()`. |
| 6 | Rename modes | `prop_editor.gd` | `MODE_LABELS = ["Arrange","Reshape"]`; `mode_button_text()` returns emoji + label; enum values unchanged; no canvas change. |
| 7 | Segmented centered pair, bigger font, emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | `_mode_bar.alignment = ALIGNMENT_CENTER`; `_make_mode_button` uses `font_mode()` + `mode_button_min_height()` + `mode_button_text(mode)`; tokens `ui.fonts.mode` (20), `ui.hit_targets.mode_button_min_height` (44) present in `DEFAULT_CONFIG` + JSON. |
| 8 | "Reset View" singular | `prop_editor.gd` | `view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)` (label only). |
| 9 | Menu font + emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | MenuBar font token `ui.fonts.menu_bar` (18) applied in `_build_control_theme`; `font_menu` bumped to 18; titles/items per §2.4 emoji table. |
| 10 | Window title | `prop_editor.gd` | `build_window_title(name, path, dirty)` static pure; `_update_window_title()` called from `_update_save_status()`; `get_window().title` set; `.prop` extension enforced in `_on_save_file_selected`. |
| 11 | Exit dialog | `prop_editor.gd` | `auto_accept_quit = false` in `_ready()`; `_notification(NOTIFICATION_WM_CLOSE_REQUEST)`; themed exit dialog (💾/🗑️/❌); `_save_to(path, with_thumbnail) -> bool`; save-then-quit sequencing; Cancel stays; save failure aborts quit. |
| 12 | Autosave/recovery | `prop_editor.gd` + `prop_file.gd` | `AUTOSAVE_PATH`, `AUTOSAVE_DEBOUNCE = 1.0`, `_mark_dirty()` schedules, `_write_autosave()` (metadata.autosave + source_path + modified_unix), `PropFile.save_safe` atomic write, `_check_autosave_recovery()` prompt, `_apply_json_data(is_recovery)`, clear-semantics table. |
---
## 4. Files modified
| File | Changes |
| --- | --- |
| `scripts/prop_canvas.gd` | `hit_test_segment` (public); `_handle_right_click` POINTS matrix; `_get_context_world_pos` bounds-fix. |
| `scripts/prop_editor.gd` | Mode labels/emoji + `mode_button_text`; toolbar centering + font; menu emoji + "Reset View"; window title builder + update points; exit dialog + `auto_accept_quit` + `_notification`; autosave (path/debounce/write/recovery/clear); `_save_to(path, with_thumbnail) -> bool`; `_apply_json_data(is_recovery)`; `_add_section` wrapper + `_sync_mode_controls`. |
| `scripts/prop_theme.gd` | `font_mode()`, `font_menu_bar()`, `mode_button_min_height()`; `DEFAULT_CONFIG` `ui` additions; `font_menu` default 16→18. |
| `prop_theme.json` | Additive `ui` keys (`fonts.mode`, `fonts.menu_bar`, `fonts.menu` 18, `hit_targets.mode_button_min_height`); `version` stays `"1.0"`. |
| `scripts/prop_file.gd` | `save_safe(path, data) -> bool` atomic write. |
| `tests/test_prop_editor_canvas.gd` | Add `_test_segment_hit_test` (and optionally `_test_context_world_pos` if the helper is exposed). |
| `tests/test_prop_editor_round3.gd` | **new** — title builder, `mode_button_text`, `PropFile.save_safe` round-trip, autosave metadata. |
| `docs/tech_debt_and_optimizations.md` | Log exit-save thumbnail skip + autosave debounce + mode-label location decisions. |
---
## 5. Test coverage
**Headless — `tests/test_prop_editor_canvas.gd` (existing suite, public API):**
- New `_test_segment_hit_test()`: box at `(100,100)` (bbox `(76,76)-(124,124)`);
`hit_test_segment(Vector2(110, 70))` (6 px above top edge, within threshold) → `0`;
`hit_test_segment(Vector2(100, 100))` (interior, ~24 px from every edge) → `-1`;
`hit_test_segment(Vector2(400, 400))` → `-1`.
- Existing `_test_create_point_on_edge` already asserts projection + `vertex_created`; the
Round 3 fix (stored click position) is verified by a new direct `_get_context_world_pos`
assertion only if that helper is exposed (optional — otherwise manual F6 covers it).
**Headless — `tests/test_prop_editor_round3.gd` (new; static funcs only, no scene instantiation):**
```gdscript
const PE := preload("res://scripts/prop_editor.gd")
const PF := preload("res://scripts/prop_file.gd")
# Title builder (pure static)
PE.build_window_title("", "", false) == "Untitled"
PE.build_window_title("My Crate", "", true) == "My Crate *"
PE.build_window_title("My Crate", "res://props/mycrate.prop", false) == "My Crate — mycrate.prop"
PE.build_window_title("", "res://props/mycrate.prop", true) == "Untitled — mycrate.prop *"
# Mode labels/emoji (pure static, clamping)
PE.mode_button_text(0) == "🖐️ Arrange"
PE.mode_button_text(1) == "✏️ Reshape"
PE.mode_button_text(99) == "✏️ Reshape" # clamps to last
# save_safe atomic round-trip (PropFile)
var p := "user://round3_autosave_test.prop"
var d := {"version":"1.0","prop_name":"X","material_preset":"none","shapes":[],
"metadata":{"autosave":true,"source_path":"","modified_unix":123}}
PF.save_safe(p, d) == true
PF.load(p) deep-equals d (JSON round-trip)
FileAccess.file_exists(p + ".tmp") == false and FileAccess.file_exists(p + ".bak") == false
# overwrite path works, still no leftover
```
**Manual F6 verification (not headless-assertable):** emoji glyph rendering; toolbar centering
+ font/height visually; POINTS-mode right-click feel (segment→Create Point, vertex→Remove
Point, interior/empty→nothing); Create Point at a **negative** world coordinate after panning
(regression for Point 2); inspector Add/Erase hide on mode switch; window title updates across
name/save/load/clear/dirty; exit dialog Save/Discard/Cancel incl. save-then-quit; autosave
recovery prompt on a simulated unclean shutdown (kill the process with edits, relaunch).
---
## 6. Design choices — user-approved resolutions
1. **Emoji sets.** **Approved as proposed:** buttons 🖐️ Arrange / ✏️ Reshape; menus 📁💾📄📂🗑️🔧📏👁️🔍; exit dialog 💾 Save / 🗑️ Discard / ❌ Cancel; restore 🔄 Restore.
2. **"Untitled" vs "Unnamed Prop".** **Approved:** window title default **"Untitled"**; status bar keeps **"Unnamed Prop"**.
3. **Filename with extension.** **Approved:** with `.prop` (`get_file()`), extension enforced on save (`.prp` treated as a typo for `.prop`).
4. **Autosave debounce.** **Approved:** **1.0 s**.
5. **Restore flow.** **Approved:** **prompt** with 🔄 Restore / 🗑️ Discard (mirrors the sandbox).
6. **Autosave clear semantics.** **Approved:** per §2.7 table (clear on save/load/clear/discard-quit; keep after Restore; drop stale autosave at startup).
7. **Exit-save thumbnail.** **Approved:** **skip** the thumbnail on exit-save.
8. **Mode button height.** **Approved:** **44 px** (`mode_button_min_height`) with `font_mode` 20.
9. **Centering.** **Approved:** horizontally center the segmented pair in the ModeBar row (`ALIGNMENT_CENTER`).
---
*Validation follows the existing pattern: per-script `--check-only`, then
`tests/test_prop_editor_canvas.gd` and `tests/test_prop_editor_round3.gd` headless suites
printing PASS/FAIL and `quit(0/1)`. Interactive feel (emoji, centering, exit dialog, autosave
recovery) is manual F6 verification.*
+52
View File
@@ -0,0 +1,52 @@
# Phase 9 Prop Editor — Round 4 (Selection Feedback, Bigger Defaults, Status/Menus, New Flow)
Status: **implemented** (2026-09-19; direct UI round — no open design choices; all nine
feedback points applied).
Source: nine user feedback points against the shipped Round 3 Prop Editor.
## Feedback summary (verbatim intent)
1. The Arrange/Reshape selected state was invisible right after clicking until the cursor
moved away; make the selected button clearly outlined.
2. Mode buttons, File/View menu bar, and menu items need bigger defaults — theme-adjustable.
3. Inspector fonts and the Prop Name input need bigger defaults — theme-adjustable.
4. Swap the status bar: title left, (X, Y) coordinates right; bigger default text.
5. Remove the "Ball" shape — circle covers it.
6. RMB context menus need a bigger default font — theme-adjustable.
7. When the cursor leaves the canvas frame, coordinates read "X: -, Y: -" instead of blank.
8. Replace Edit → Configure Grid / Snap with a grid-size spinbox + snap toggle on the right
side of the menu bar row (sandbox-stage pattern).
9. Replace File → Clear with File → New at the top of the File menu, prompting to save edited
props first.
## Diagnoses
- **Point 1 (selection invisibility):** the Button `focus` stylebox draws after `pressed`, and
the theme set `focus` = same style as `normal`, masking the pressed look until focus left
(cursor moved away). Fix: mode buttons use `focus_mode = FOCUS_NONE`, and the theme's Button
`pressed` stylebox now has an accent-colored 2 px border (`panel_pressed`) so the selected
mode reads as an outlined box.
## Changes
| Target | Change |
| --- | --- |
| `prop_theme.json` + `scripts/prop_theme.gd` | Defaults bumped: body 18, header 24, menu 22, menu_bar 22, mode 26, status 18; new tokens `ui.fonts.prop_name` (20) and `ui.fonts.context_menu` (22); `mode_button_min_height` 44 → 56. All remain theme-adjustable. |
| `scripts/prop_canvas.gd` | Removed the "Add Ball" context-menu item (the `"ball"` primitive stays programmatically available); new `set_context_menu_font_size(size)` applies a font-size override to the RMB menu. |
| `scripts/prop_editor.gd` | `panel_pressed` accent-border stylebox; mode buttons `FOCUS_NONE`; `LineEdit` theme font + `font_prop_name` override on the name edit; status bar text swapped (title left via `%SaveStatus`, coords right via `%CursorCoords`) with "X: - Y: -" fallback; `PRIMITIVE_IDS/LABELS` drop "ball"; context-menu font wired from `font_context_menu`; top-right `Grid:` label + SpinBox + `Snap` CheckBox built in `%TopRightControls` (grid dialog + Edit menu removed); File menu is now 🆕 New / 💾 Save / 📄 Save As… / 📂 Load…; `_request_new()` reuses the unsaved-changes dialog (`_pending_new` flag; Save → save-then-new, Discard → new, Cancel → stay); `_do_new()` replaces the old clear flow. |
| `scenes/prop_editor.tscn` | New `%TopRow` HBox (y 0..32) containing `%MenuBar` (expand) + `%TopRightControls`; status bar children swapped (`%SaveStatus` left, `%CursorCoords` right); `%ClearConfirmDialog` removed. |
| `tests/test_prop_editor_theme.gd` | Updated to the new font/height defaults + new tokens. |
## Verification
- `--check-only` clean on all four scripts; all five headless suites green
(file 64, theme 53, canvas 87, thumb 10, round3 17); scene smoke (instantiate + title) passes.
- Manual F6: selected-mode outline; font sizes; swapped status bar with dashes; grid/snap
controls on the menu row; File → New save prompt; no Ball in any Add menu.
## Notes / tech debt
- Recorded in `docs/tech_debt_and_optimizations.md` (row 32): mode buttons drop keyboard focus
(`FOCUS_NONE`) so the focus stylebox can't mask the pressed state — revisit if keyboard
accessibility for the mode toggle is ever required.
+49
View File
@@ -0,0 +1,49 @@
# Phase 9 Prop Editor — Round 5 (File → Recent + save-serialization fix)
Status: **implemented** (2026-09-19; direct bugfix + feature round).
Source: two user feedback points against the shipped Round 4 Prop Editor.
## Feedback summary
1. File → Recent: show the last 5 opened props (sandbox-stage pattern).
2. Loading `props/eye.prop` draws nothing (the prop name shows, geometry is blank).
## Diagnosis (point 2 — root cause)
`PropFile._serialize_shape()` declared
```gdscript
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
```
but the editor always serializes **export-format** shapes whose `points` is an `Array` of
`{x, y}` dictionaries. The implicit `Array → PackedVector2Array` conversion cannot construct
`Vector2` from a dictionary and **silently yields `Vector2.ZERO` per element**, preserving the
count but zeroing every coordinate. Every save therefore wrote all points as `(0, 0)` —
verified in `props/eye.prop` and `props/box.prop` (all `x: 0.0, y: 0.0`), which load as a
degenerate, invisible shape. The round-1 tests only asserted point *counts* and dict *shape*,
so the corruption slipped through.
**Fix:** `_serialize_shape()` now uses the existing `_coerce_points()` helper (which handles
both `PackedVector2Array` and `Array`-of-`{x,y}` inputs). Regression assertions were added so
point **values** are verified through `to_file_dict` and the full round-trip.
The two already-saved user files (`props/eye.prop`, `props/box.prop`) are unrecoverable
(every coordinate was zeroed at save time) and must be re-authored.
## Changes
| Target | Change |
| --- | --- |
| `scripts/prop_file.gd` | `_serialize_shape` uses `_coerce_points` (bug fix). |
| `scripts/prop_editor.gd` | `SETTINGS_PATH := "user://prop_editor_settings.json"`, `RECENT_MAX := 5`, `_recent_props`; `update_recent_list(list, path, max)` static (dedupe + front-insert + trim); 🕑 Recent submenu in the File menu (`RecentProps` child popup, updated on `about_to_popup`, missing files pruned, "(No recent props)" placeholder); `_load_settings`/`_save_settings`; `_push_recent(path)` on every successful load; missing recent → error dialog + removal. |
| `tests/test_prop_editor_file.gd` | New value-preservation assertions (serialized `x == 64`, round-trip `(64,0)`/`(64,64)`). |
| `tests/test_prop_editor_round3.gd` | New `_test_recent_list` (dedupe, front-insert, trim). |
## Verification
- `--check-only` clean; all five suites green: file 66, theme 53, canvas 87, thumb 10, round3 21.
- Manual F6: save a prop with shapes at distinct coordinates, reload — geometry must match;
File → 🕑 Recent lists the last 5 opens and opens them; missing recent file shows the error
and prunes itself.
+51
View File
@@ -0,0 +1,51 @@
# Phase 9 Prop Editor — Round 6 (Recent-on-save, Reshaped Flag, Selection Contrast)
Status: **implemented** (2026-09-19; direct round — no open design choices).
Source: three user feedback points against the shipped Round 5 Prop Editor.
## Feedback summary
1. Saving a file should add it to File → Recent when it isn't there yet.
2. A circle's inspector Radius field becomes meaningless once the circle's vertices are
reshaped; every primitive needs a `reshaped` flag so primitive-specific inspector fields
hide/disable after reshaping.
3. A white shape in front of a white shape makes the white selection outline/box invisible;
the selection color should contrast with the shape's fill.
## Design decisions
- **Recent-on-save:** `_save_to()` calls `_push_recent(path)` on success (dedupe +
front-insert + trim, same helper as load — a save is recent activity).
- **`reshaped` flag:** new boolean on every shape dict (internal + export + `.prop` schema,
default `false`; additive and backward compatible — old files load as `false`). Set to
`true` by: `insert_vertex`, `remove_vertex`, vertex-drag motion, `create_point_on_edge`
(via insert), and **non-uniform scaling of a circle** (both the gizmo drag and
`scale_selected`); cleared by `set_shape_radius` (which regenerates points from the radius).
Translate/rotate/mirror/uniform-scale do **not** set it — they preserve primitive-ness.
The inspector hides the Radius row and shows a muted "✏️ This shape was reshaped" note when
the selected shape's flag is set (the flag exists on every primitive so future
primitive-specific fields can gate on it the same way).
- **Selection contrast:** new `PropCanvas.contrast_color_for(fill_hex) -> Color` — dark
(`#1F1F1F`) for light fills (luminance > 0.5), white otherwise. Applied to the selection
polyline and all gizmo drawing (bbox, rotation dot, scale crosses), which were previously
hard white and vanished on white shapes. Vertex handles stay fill-colored (stickman-editor
parity, Round 2 decision); the Create-Point flash stays yellow.
## Changes
| Target | Change |
| --- | --- |
| `scripts/prop_editor.gd` | `_save_to` pushes the path to recents on success; `_reshaped_note` label; `_refresh_inspector_from_selection` gates `_radius_row.visible` on `not reshaped` and shows the note when reshaped. |
| `scripts/prop_canvas.gd` | `"reshaped"` in `_make_shape_dict`/`_coerce_shape`/`_export_shape`; mutation sites set/clear it; `contrast_color_for` static; selection polyline + gizmos use the contrast color. |
| `scripts/prop_file.gd` | `"reshaped"` serialized/parsed (default `false`). |
| `tests/test_prop_editor_canvas.gd` | New `_test_reshaped_flag` (insert/remove/radius-reset/scale/mirror/translate/rotate matrix) and `_test_selection_contrast`. |
| `tests/test_prop_editor_file.gd` | Round-trip `reshaped` preservation + default-false assertions. |
## Verification
- `--check-only` clean; all five suites green (file 68, theme 53, canvas 100, thumb 10,
round3 21).
- Manual F6: save a new prop → appears in File → 🕑 Recent; reshape a circle (drag a vertex,
create a point, or stretch non-uniformly) → Radius row disappears + muted note; select a
white shape → dark outline/box/dot/crosses.
+47
View File
@@ -0,0 +1,47 @@
# Phase 9 Prop Editor — Round 7 (Two-Tone Halo Selection)
Status: **implemented** (2026-09-19; direct follow-up on the Round 6 selection-contrast fix).
Source: one user feedback point — the Round 6 contrast approach did not fix the reported
case.
## Diagnosis
Round 6 made the selection color adaptive to the *selected shape's fill* (dark on light
fills, white on dark fills). That solves "white shape selected on a dark canvas" but not the
reported case: a shape sitting **in front of a white shape** — the selection outline, bounding
box, rotation dot, and scale crosses extend beyond the selected shape onto the white shape
behind it, so a white (or fill-mismatched) selection still blends into the white background.
Fill-based contrast fundamentally cannot know what is behind the shape.
## Fix
Every selection element now uses a **two-tone halo**: a dark backing pass (`SELECTION_HALO_COLOR`,
black at 0.85 alpha, ~2 px wider) drawn first, then a bright core pass (`SELECTION_CORE_COLOR`,
white, the original width) on top. The pair is visible on any background — the dark halo
separates the bright core from light/white surroundings, and the bright core separates it from
dark surroundings. Applied to:
- the selection polyline over the shape outline,
- the bounding box (dark 3.5/zoom pass + white 1.5/zoom pass),
- the rotation dot (dark ring at radius + 2, white dot at radius),
- the four scale crosses (dark 3.5 pass + white 1.5 pass),
- vertex handles in Reshape mode (a 1.5 px dark ring behind each handle).
The Round 6 `contrast_color_for` helper was removed. `SELECTION_COLOR`/`GIZMO_COLOR` were
replaced by `SELECTION_CORE_COLOR`/`SELECTION_HALO_COLOR`.
## Changes
| Target | Change |
| --- | --- |
| `scripts/prop_canvas.gd` | Halo constants; two-pass drawing for selection polyline, bbox, rotation dot, scale crosses, vertex handles; `contrast_color_for` removed. |
| `tests/test_prop_editor_canvas.gd` | `_test_selection_contrast` replaced by `_test_selection_halo` (halo is dark, core is bright). |
## Verification
- `--check-only` clean; all five suites green (file 68, theme 53, canvas 100, thumb 10,
round3 21).
- Manual F6: select a dark shape in front of a white shape, and a white shape in front of a
white shape — the outline/box/dot/crosses must be visible in both; repeat in Reshape mode
for the vertex handles.
+387
View File
@@ -0,0 +1,387 @@
# Phase 9 Prop Editor - headless regression checks for PropCanvas math
# (vertex insert/remove, mirror, z-order, clipboard, transforms, sanitize).
# No GUI event injection; the public math APIs are driven directly.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_canvas.gd"
extends SceneTree
const PROP_CANVAS := preload("res://scripts/prop_canvas.gd")
var _checks: int = 0
var _failures: int = 0
var _changed_count: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP CANVAS TEST ===")
await _test_primitives()
await _test_vertex_insert_remove()
await _test_mirror()
await _test_z_order()
await _test_clipboard()
await _test_transforms()
await _test_sanitize_and_radius()
await _test_import_export_and_clear()
await _test_edit_mode()
await _test_gizmo_constants()
await _test_create_point_on_edge()
await _test_segment_hit_test()
await _test_reshaped_flag()
await _test_selection_halo()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _new_canvas() -> PropCanvas:
var canvas: PropCanvas = PROP_CANVAS.new()
root.add_child(canvas)
canvas.size = Vector2(800, 600)
_changed_count = 0
canvas.shape_changed.connect(func(_shapes: Array[Dictionary]) -> void: _changed_count += 1)
return canvas
func _free_canvas(canvas: PropCanvas) -> void:
canvas.queue_free()
await process_frame
func _bbox(pts: Array) -> Rect2:
var first: Dictionary = pts[0]
var bbox := Rect2(Vector2(float(first.get("x", 0.0)), float(first.get("y", 0.0))), Vector2.ZERO)
for p: Dictionary in pts:
bbox = bbox.expand(Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))))
return bbox
func _export_points(sd: Dictionary) -> Array:
return sd.get("points", []) as Array
func _test_primitives() -> void:
var canvas: PropCanvas = _new_canvas()
var box: Dictionary = canvas.add_primitive("box", Vector2(100, 100))
check("box appended", canvas.shapes.size() == 1)
check("box type", str(box.get("shape_type", "")) == "polygon")
check("box 4 points", _export_points(box).size() == 4)
check("box closed", bool(box.get("closed", false)))
check("add emits shape_changed", _changed_count == 1)
check("box selected", canvas.get_selected_index() == 0)
var big: Dictionary = canvas.add_primitive("box", Vector2(200, 200), {"size_x": 100.0, "size_y": 100.0})
var big_bbox: Rect2 = _bbox(_export_points(big))
check("box params respected", is_equal_approx(big_bbox.size.x, 100.0) and is_equal_approx(big_bbox.size.y, 100.0))
var ball: Dictionary = canvas.add_primitive("ball", Vector2(300, 300))
check("ball type", str(ball.get("shape_type", "")) == "circle")
check("ball radius default", is_equal_approx(float(ball.get("radius", 0.0)), 24.0))
check("ball points 12", _export_points(ball).size() == 12)
var line: Dictionary = canvas.add_primitive("line", Vector2(400, 400))
check("line type", str(line.get("shape_type", "")) == "line")
check("line open", not bool(line.get("closed", true)))
check("line 2 points", _export_points(line).size() == 2)
var plank: Dictionary = canvas.add_primitive("plank", Vector2(500, 500))
check("plank polygon", str(plank.get("shape_type", "")) == "polygon")
var triangle: Dictionary = canvas.add_primitive("triangle", Vector2(600, 600))
check("triangle 3 points", _export_points(triangle).size() == 3)
check("triangle closed", bool(triangle.get("closed", false)))
var circle: Dictionary = canvas.add_primitive("circle", Vector2(700, 700), {"radius": 50.0})
check("circle radius param", is_equal_approx(float(circle.get("radius", 0.0)), 50.0))
await _free_canvas(canvas)
func _test_vertex_insert_remove() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
canvas.insert_vertex(0, 2, Vector2(124, 100))
check("insert grows point list", _export_points(canvas.get_shape_data()[0]).size() == 5)
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("inserted vertex flagged USER_CREATED", int(flags[2]) == 1)
check("original vertices stay 0", int(flags[0]) == 0 and int(flags[4]) == 0)
canvas.remove_vertex(0, 2)
check("remove shrinks point list", _export_points(canvas.get_shape_data()[0]).size() == 4)
check("flags aligned after remove", (canvas.get_shape_data()[0].get("vertex_flags", []) as Array).size() == 4)
canvas.remove_vertex(0, 0)
canvas.remove_vertex(0, 0)
check("polygon with 2 points becomes open", bool(canvas.shapes[0].get("closed", true)) == false)
canvas.remove_vertex(0, 0)
check("below 2 vertices drops shape", canvas.shapes.size() == 0)
check("selection cleared after drop", canvas.get_selected_index() == -1)
await _free_canvas(canvas)
func _test_mirror() -> void:
var canvas: PropCanvas = _new_canvas()
var tri: Dictionary = canvas.add_primitive("triangle", Vector2(100, 100))
var before: Array = _export_points(tri)
var bbox: Rect2 = _bbox(before)
canvas.mirror_x(0)
var after_x: Array = _export_points(canvas.get_shape_data()[0])
check("mirror_x flips x about center", is_equal_approx(float(after_x[0].get("x", 0.0)), 2.0 * bbox.get_center().x - float(before[0].get("x", 0.0))))
check("mirror_x keeps y", is_equal_approx(float(after_x[0].get("y", 0.0)), float(before[0].get("y", 0.0))))
check("mirror_x keeps bbox center", _bbox(after_x).get_center().is_equal_approx(bbox.get_center()))
canvas.mirror_y(0)
var after_y: Array = _export_points(canvas.get_shape_data()[0])
check("mirror_y flips y about center", is_equal_approx(float(after_y[0].get("y", 0.0)), 2.0 * bbox.get_center().y - float(after_x[0].get("y", 0.0))))
check("mirror_y keeps bbox center", _bbox(after_y).get_center().is_equal_approx(bbox.get_center()))
await _free_canvas(canvas)
func _test_z_order() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(0, 0), {"fill_color": "#FF0000", "outline_color": "#000000"})
canvas.add_primitive("ball", Vector2(100, 0), {"fill_color": "#00FF00", "outline_color": "#000000"})
canvas.add_primitive("line", Vector2(200, 0), {"fill_color": "#0000FF", "outline_color": "#000000"})
canvas.bring_forward(1)
check("bring_forward swaps up", str(canvas.get_shape_data()[1].get("shape_type", "")) == "line")
check("bring_forward swaps down", str(canvas.get_shape_data()[2].get("shape_type", "")) == "circle")
canvas.send_back(2)
check("send_back swaps down", str(canvas.get_shape_data()[1].get("shape_type", "")) == "circle")
canvas.bring_forward(2)
check("bring_forward at top is no-op", str(canvas.get_shape_data()[2].get("shape_type", "")) == "line")
canvas.send_back(0)
check("send_back at bottom is no-op", str(canvas.get_shape_data()[0].get("shape_type", "")) == "polygon")
await _free_canvas(canvas)
func _test_clipboard() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(50, 50), {"fill_color": "#123456", "outline_color": "#000000"})
var copied: Dictionary = canvas.copy_shape(0)
check("copy returns deep dict", not copied.is_empty() and copied.has("points"))
canvas.set_shape_fill_color(0, "#ABCDEF")
check("copy is independent", str(copied.get("fill_color", "")) == "#123456")
canvas.paste_shape(copied, Vector2(100, 100))
check("paste appends", canvas.shapes.size() == 2)
var pasted_pts: Array = _export_points(canvas.get_shape_data()[1])
var pasted_bbox: Rect2 = _bbox(pasted_pts)
check("paste offsets by (16,16)", pasted_bbox.get_center().is_equal_approx(Vector2(116, 116)))
check("paste preserves color", str(canvas.get_shape_data()[1].get("fill_color", "")) == "#123456")
check("paste selects new shape", canvas.get_selected_index() == 1)
await _free_canvas(canvas)
func _test_transforms() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
canvas.translate_selected(Vector2(10, -5))
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("translate moves all vertices", _bbox(pts).get_center().is_equal_approx(Vector2(110, 95)))
canvas.add_primitive("box", Vector2(200, 200))
canvas.select(1)
canvas.rotate_selected(PI / 2.0)
var rotated_pts: Array = _export_points(canvas.get_shape_data()[1])
# Box corners at (224,224) rotate 90 degrees ccw about (200,200) -> (176,224).
var found: bool = false
for p: Dictionary in rotated_pts:
if Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))).is_equal_approx(Vector2(176, 224)):
found = true
check("rotate 90 degrees about bbox center", found)
check("rotate keeps bbox center", _bbox(rotated_pts).get_center().is_equal_approx(Vector2(200, 200)))
canvas.scale_selected(Vector2(2.0, 1.0))
var scaled_pts: Array = _export_points(canvas.get_shape_data()[1])
var scaled_bbox: Rect2 = _bbox(scaled_pts)
check("scale stretches width", is_equal_approx(scaled_bbox.size.x, 96.0))
check("scale keeps height", is_equal_approx(scaled_bbox.size.y, 48.0))
await _free_canvas(canvas)
func _test_sanitize_and_radius() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.set_grid_size(16)
canvas.set_snap_enabled(true)
# Rectangle with one collinear point and one off-grid point; sanitize must
# grid-snap, drop the collinear vertex, and enforce clockwise winding.
canvas.set_shape_data([{
"shape_type": "polygon",
"points": [
{"x": 0.0, "y": 0.0},
{"x": 16.0, "y": 0.0},
{"x": 32.0, "y": 0.0},
{"x": 32.0, "y": 32.0},
{"x": 0.0, "y": 33.0},
],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0, 0, 0],
}])
canvas.sanitize_shape(0)
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("sanitize drops collinear vertex", pts.size() == 4)
var packed := PackedVector2Array()
for p: Dictionary in pts:
packed.append(Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))))
check("sanitize enforces clockwise", Geometry2D.is_polygon_clockwise(packed))
var min_y: float = INF
var max_y: float = -INF
for v: Vector2 in packed:
min_y = minf(min_y, v.y)
max_y = maxf(max_y, v.y)
check("sanitize grid-snaps", is_equal_approx(min_y, 0.0) and is_equal_approx(max_y, 32.0))
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("sanitize keeps flags aligned", flags.size() == 4)
var circle: Dictionary = canvas.add_primitive("circle", Vector2(300, 300), {"radius": 40.0})
var before_count: int = _export_points(circle).size()
canvas.sanitize_shape(1)
check("sanitize skips circles", _export_points(canvas.get_shape_data()[1]).size() == before_count)
canvas.set_shape_radius(1, 80.0)
var radius_pts: Array = _export_points(canvas.get_shape_data()[1])
check("set_shape_radius resizes points", is_equal_approx(float(canvas.get_shape_data()[1].get("radius", 0.0)), 80.0))
var radius_bbox: Rect2 = _bbox(radius_pts)
check("radius bbox matches", is_equal_approx(radius_bbox.size.x, 160.0) and is_equal_approx(radius_bbox.size.y, 160.0))
await _free_canvas(canvas)
func _test_import_export_and_clear() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.set_shape_data([{
"shape_type": "polygon",
"points": [{"x": 0.0, "y": 0.0}, {"x": 10.0, "y": 0.0}, {"x": 10.0, "y": 10.0}],
"fill_color": "#111111",
"outline_color": "#222222",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0],
}])
check("import plain {x,y} data", canvas.shapes.size() == 1)
check("import preserves flags", (canvas.shapes[0].get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 3)
var exported: Array[Dictionary] = canvas.get_shape_data()
check("export plain format", exported.size() == 1 and (exported[0].get("points", []) as Array)[0] is Dictionary)
var before_changed: int = _changed_count
canvas.clear()
check("clear empties shapes", canvas.shapes.is_empty())
check("clear emits no shape_changed", _changed_count == before_changed)
check("export after clear empty", canvas.get_shape_data().is_empty())
await _free_canvas(canvas)
func _test_edit_mode() -> void:
var canvas: PropCanvas = _new_canvas()
check("default mode is TRANSFORM", canvas.get_edit_mode() == PROP_CANVAS.EditMode.TRANSFORM)
var mode_fires: Array = [0]
canvas.mode_changed.connect(func(_mode: int) -> void: mode_fires[0] += 1)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
check("set_edit_mode(POINTS) applies", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
check("mode_changed fired once on change", int(mode_fires[0]) == 1)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
check("re-setting same mode does not fire", int(mode_fires[0]) == 1)
canvas.set_edit_mode(99)
check("unknown mode clamps to TRANSFORM", canvas.get_edit_mode() == PROP_CANVAS.EditMode.TRANSFORM)
check("clamp fires mode_changed", int(mode_fires[0]) == 2)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
canvas.add_primitive("box", Vector2(100, 100))
canvas.clear()
check("clear keeps the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
canvas.set_shape_data([])
check("set_shape_data keeps the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
canvas.select(0)
canvas.deselect()
check("select/deselect keep the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
await _free_canvas(canvas)
func _test_gizmo_constants() -> void:
check("rotation dot radius constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_RADIUS, 5.0))
check("rotation dot hit radius constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_HIT_RADIUS, 12.0))
check("rotation dot offset constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_OFFSET, 26.0))
check("scale cross size constant", is_equal_approx(PROP_CANVAS.SCALE_CROSS_SIZE, 5.0))
check("scale cross hit radius constant", is_equal_approx(PROP_CANVAS.SCALE_CROSS_HIT_RADIUS, 12.0))
check("vertex handle radius constant", is_equal_approx(PROP_CANVAS.HANDLE_RADIUS, 3.0))
var constant_map: Dictionary = (PROP_CANVAS as Script).get_script_constant_map()
check("rotate ring constant removed", not constant_map.has("ROTATE_RING_RADIUS"))
check("scale circle constant removed", not constant_map.has("SCALE_HANDLE_DISTANCE"))
func _test_create_point_on_edge() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
var created: Array = []
canvas.vertex_created.connect(func(s_idx: int, v_idx: int) -> void: created.append([s_idx, v_idx]))
# Cursor at (110, 60): the nearest segment is the top edge (76,76)-(124,76);
# the projection onto it is (110, 76) - NOT the edge midpoint (100, 76).
var idx: int = canvas.create_point_on_edge(Vector2(110, 60))
check("create_point_on_edge returns valid index", idx >= 0)
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("create point grows the vertex list", pts.size() == 5)
var inserted: Dictionary = pts[idx]
check("inserted point is the cursor projection", Vector2(float(inserted.get("x", 0.0)), float(inserted.get("y", 0.0))).is_equal_approx(Vector2(110, 76)))
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("inserted vertex flagged USER_CREATED", int(flags[idx]) == 1)
check("vertex_created emitted with shape and vertex index", created.size() == 1 and int(created[0][0]) == 0 and int(created[0][1]) == idx)
await _free_canvas(canvas)
func _test_segment_hit_test() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
check("segment hit above top edge", canvas.hit_test_segment(Vector2(110, 70)) == 0)
check("segment hit left of left edge", canvas.hit_test_segment(Vector2(70, 90)) == 0)
check("segment hit misses interior", canvas.hit_test_segment(Vector2(100, 100)) == -1)
check("segment hit misses empty canvas", canvas.hit_test_segment(Vector2(400, 400)) == -1)
canvas.add_primitive("line", Vector2(300, 300))
check("segment hit on open line", canvas.hit_test_segment(Vector2(300, 306)) == 1)
canvas.select(1)
check("segment hit frontmost wins", canvas.hit_test_segment(Vector2(300, 306)) == 1)
await _free_canvas(canvas)
func _test_reshaped_flag() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("circle", Vector2(100, 100), {"radius": 40.0})
check("new circle not reshaped", not bool(canvas.shapes[0].get("reshaped", true)))
check("export carries reshaped false", not bool(canvas.get_shape_data()[0].get("reshaped", true)))
canvas.insert_vertex(0, 0, Vector2(60, 60))
check("insert vertex reshapes", bool(canvas.shapes[0].get("reshaped", false)))
canvas.set_shape_radius(0, 60.0)
check("set_shape_radius clears reshaped", not bool(canvas.shapes[0].get("reshaped", true)))
canvas.remove_vertex(0, 0)
check("remove vertex reshapes", bool(canvas.shapes[0].get("reshaped", false)))
canvas.add_primitive("circle", Vector2(300, 300), {"radius": 40.0})
canvas.select(1)
canvas.scale_selected(Vector2(2.0, 2.0))
check("uniform scale keeps circle primitive", not bool(canvas.shapes[1].get("reshaped", true)))
canvas.scale_selected(Vector2(2.0, 1.0))
check("non-uniform scale reshapes circle", bool(canvas.shapes[1].get("reshaped", false)))
canvas.add_primitive("box", Vector2(500, 500))
canvas.select(2)
canvas.translate_selected(Vector2(10, 0))
check("translate does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.rotate_selected(0.5)
check("rotate does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.mirror_x(2)
check("mirror does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.insert_vertex(2, 0, Vector2(400, 400))
check("vertex insert reshapes polygon", bool(canvas.shapes[2].get("reshaped", false)))
await _free_canvas(canvas)
func _test_selection_halo() -> void:
check("selection halo is dark", PROP_CANVAS.SELECTION_HALO_COLOR.get_luminance() < 0.3)
check("selection core is bright", PROP_CANVAS.SELECTION_CORE_COLOR.get_luminance() > 0.7)
+1
View File
@@ -0,0 +1 @@
uid://yr00a8ban8vp
+252
View File
@@ -0,0 +1,252 @@
# Phase 9 Prop Editor - headless regression checks for the .prop schema
# (PropFile: round-trip, version rejection, defaults, flattening, sanitize).
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_file.gd"
extends SceneTree
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP FILE TEST ===")
await _test_preset_conversion()
await _test_to_file_dict()
await _test_round_trip()
await _test_version_rejection()
await _test_missing_key_defaults()
await _test_flattening()
await _test_sanitize_shape()
await _test_save_load()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _hex(v: Vector2) -> Dictionary:
return {"x": v.x, "y": v.y}
func _box_shape(points: Array, fill: String = "#112233", outline: String = "#445566") -> Dictionary:
return {
"shape_type": "polygon",
"points": points,
"fill_color": fill,
"outline_color": outline,
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 0, 0, 0],
}
func _test_preset_conversion() -> void:
for i: int in PROP_FILE.PRESET_STRINGS.size():
var s: String = PROP_FILE.preset_to_string(i)
check("preset_to_string(%d) == PRESET_STRINGS[%d]" % [i, i], s == PROP_FILE.PRESET_STRINGS[i])
check("preset_from_string round trip %s" % s, PROP_FILE.preset_from_string(s) == i)
check("preset_from_string unknown -> NONE", PROP_FILE.preset_from_string("banana") == PROP_BLOCK.MaterialPreset.NONE)
check("preset_from_string case-insensitive", PROP_FILE.preset_from_string("WOOD") == PROP_BLOCK.MaterialPreset.WOOD)
check("preset_to_string clamps low", PROP_FILE.preset_to_string(-5) == PROP_FILE.PRESET_STRINGS[0])
check("preset_to_string clamps high", PROP_FILE.preset_to_string(99) == PROP_FILE.PRESET_STRINGS[PROP_FILE.PRESET_STRINGS.size() - 1])
check("shape_type_to_payload_type circle", PROP_FILE.shape_type_to_payload_type("circle") == PROP_BLOCK.ShapeType.CIRCLE)
check("shape_type_to_payload_type polygon", PROP_FILE.shape_type_to_payload_type("polygon") == PROP_BLOCK.ShapeType.POLYGON)
check("shape_type_to_payload_type unknown", PROP_FILE.shape_type_to_payload_type("line") == PROP_BLOCK.ShapeType.POLYGON)
func _test_to_file_dict() -> void:
var shapes: Array[Dictionary] = [_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))])]
var data: Dictionary = PROP_FILE.to_file_dict("My Crate", PROP_BLOCK.MaterialPreset.WOOD, shapes)
check("to_file_dict version", str(data.get("version", "")) == PROP_FILE.FILE_VERSION)
check("to_file_dict prop_name", str(data.get("prop_name", "")) == "My Crate")
check("to_file_dict preset string", str(data.get("material_preset", "")) == "wood")
check("to_file_dict shapes count", (data.get("shapes", []) as Array).size() == 1)
var meta: Dictionary = data.get("metadata", {})
check("to_file_dict metadata present", not meta.is_empty())
var serialized: Dictionary = (data.get("shapes", []) as Array)[0]
check("serialized points are {x,y} dicts", serialized.get("points", []) is Array and ((serialized.get("points", []) as Array)[0] as Dictionary).has("x"))
check("serialized point values preserved", is_equal_approx(float(((serialized.get("points", []) as Array)[1] as Dictionary).get("x", -1.0)), 64.0))
check("serialized flags array", (serialized.get("vertex_flags", []) as Array).size() == 4)
func _test_round_trip() -> void:
var shapes: Array[Dictionary] = []
shapes.append(_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))]))
shapes.append({
"shape_type": "circle",
"points": [],
"radius": 40.0,
"fill_color": "#FF0000",
"outline_color": "#000000",
"outline_width": 3.0,
"closed": true,
"vertex_flags": [],
})
shapes.append({
"shape_type": "line",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(64, 0))],
"fill_color": "#00FF00",
"outline_color": "#0000FF",
"outline_width": 1.5,
"closed": false,
"vertex_flags": [0, 0],
"reshaped": true,
})
var data: Dictionary = PROP_FILE.to_file_dict("Round Trip", PROP_BLOCK.MaterialPreset.METAL, shapes)
var model: Dictionary = PROP_FILE.from_file_dict(data)
check("round trip non-empty", not model.is_empty())
check("round trip name", str(model.get("prop_name", "")) == "Round Trip")
check("round trip preset", int(model.get("material_preset", -1)) == PROP_BLOCK.MaterialPreset.METAL)
var parsed: Array = model.get("shapes", [])
check("round trip shapes count", parsed.size() == 3)
var poly: Dictionary = parsed[0]
check("round trip polygon points", (poly.get("points", PackedVector2Array()) as PackedVector2Array).size() == 4)
check("round trip polygon point values", ((poly.get("points", PackedVector2Array()) as PackedVector2Array)[1]).is_equal_approx(Vector2(64, 0)) and ((poly.get("points", PackedVector2Array()) as PackedVector2Array)[2]).is_equal_approx(Vector2(64, 64)))
check("round trip polygon closed", bool(poly.get("closed", false)) == true)
check("round trip polygon fill", str(poly.get("fill_color", "")) == "#112233")
var circ: Dictionary = parsed[1]
check("round trip circle type", str(circ.get("shape_type", "")) == "circle")
check("round trip circle radius", is_equal_approx(float(circ.get("radius", 0.0)), 40.0))
check("round trip circle points rebuilt", (circ.get("points", PackedVector2Array()) as PackedVector2Array).size() == PROP_FILE.DEFAULT_CIRCLE_SEGMENTS)
var line: Dictionary = parsed[2]
check("round trip line closed", bool(line.get("closed", true)) == false)
check("round trip line width", is_equal_approx(float(line.get("outline_width", 0.0)), 1.5))
check("round trip reshaped preserved", bool(line.get("reshaped", false)) == true)
check("round trip reshaped defaults false", bool(poly.get("reshaped", true)) == false)
func _test_version_rejection() -> void:
check("reject missing version", PROP_FILE.from_file_dict({"prop_name": "x"}).is_empty())
check("reject future version", PROP_FILE.from_file_dict({"version": "9.9", "shapes": []}).is_empty())
check("reject non-dict data", PROP_FILE.from_file_dict([1, 2, 3]).is_empty())
check("accept json string", not PROP_FILE.from_file_dict(JSON.stringify({"version": "1.0", "shapes": []})).is_empty())
func _test_missing_key_defaults() -> void:
var data := {
"version": "1.0",
"shapes": [
{"shape_type": "polygon", "points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0)), _hex(Vector2(16, 16))]},
{"points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0))]},
],
}
var model: Dictionary = PROP_FILE.from_file_dict(data)
var shapes: Array = model.get("shapes", [])
check("defaults shapes parsed", shapes.size() == 2)
check("default preset NONE", int(model.get("material_preset", -1)) == PROP_BLOCK.MaterialPreset.NONE)
var s0: Dictionary = shapes[0]
check("default closed for polygon", bool(s0.get("closed", false)) == true)
check("default fill from tint", str(s0.get("fill_color", "")).to_lower() == PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.NONE).to_html().to_lower())
check("default outline darkened", str(s0.get("outline_color", "")) != "")
check("default width", is_equal_approx(float(s0.get("outline_width", 0.0)), 2.0))
check("default flags sized", (s0.get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 3)
var s1: Dictionary = shapes[1]
check("missing shape_type -> polygon", str(s1.get("shape_type", "")) == "polygon")
func _test_flattening() -> void:
check("flatten empty -> {}", PROP_FILE.flatten_to_payload([]).is_empty())
var line_shape := {
"shape_type": "line",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(64, 0))],
"fill_color": "#AABBCC",
"outline_color": "#112233",
"outline_width": 2.0,
"closed": false,
"vertex_flags": [0, 0],
}
var poly_back := _box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))], "#101010", "#202020")
var poly_front := _box_shape([_hex(Vector2(0, 0)), _hex(Vector2(32, 0)), _hex(Vector2(32, 32)), _hex(Vector2(0, 32))], "#FEFEFE", "#0F0F0F")
var circle_shape := {
"shape_type": "circle",
"points": [],
"radius": 40.0,
"fill_color": "#FF0000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
}
var payload: Dictionary = PROP_FILE.flatten_to_payload([line_shape, poly_back, poly_front])
check("flatten prefers frontmost closed polygon", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.POLYGON and (payload.get("fill_color", Color.BLACK) as Color).is_equal_approx(Color("#FEFEFE")))
payload = PROP_FILE.flatten_to_payload([line_shape, circle_shape])
check("flatten falls back to circle", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.CIRCLE)
check("flatten circle radius", is_equal_approx(float(payload.get("radius", 0.0)), 40.0))
payload = PROP_FILE.flatten_to_payload([line_shape])
check("flatten line -> polygon payload", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.POLYGON)
var pts: PackedVector2Array = payload.get("points", PackedVector2Array())
check("flatten line points sanitized to grid", pts.size() == 2 and pts[0].x == 0.0 and pts[1].x == 64.0)
var z_payload: Dictionary = PROP_FILE.flatten_to_payload([poly_front, poly_back])
check("flatten z-order: last closed polygon wins", (z_payload.get("fill_color", Color.BLACK) as Color).is_equal_approx(Color("#101010")))
func _test_sanitize_shape() -> void:
var dirty := {
"shape_type": "polygon",
"points": [_hex(Vector2(16, 16)), _hex(Vector2(16, 0)), _hex(Vector2(16, 0)), _hex(Vector2(0, 0)), _hex(Vector2(0, 16))],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 0, 1, 0, 0],
}
var cleaned: Dictionary = PROP_FILE.sanitize_shape(dirty)
var pts: PackedVector2Array = cleaned.get("points", PackedVector2Array())
check("sanitize drops consecutive duplicate", pts.size() == 4)
check("sanitize enforces clockwise", Geometry2D.is_polygon_clockwise(pts))
var collinear := {
"shape_type": "polygon",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0)), _hex(Vector2(32, 0)), _hex(Vector2(32, 32)), _hex(Vector2(0, 32))],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0, 0, 0],
}
var cleaned2: Dictionary = PROP_FILE.sanitize_shape(collinear)
check("sanitize drops collinear middle vertex", (cleaned2.get("points", PackedVector2Array()) as PackedVector2Array).size() == 4)
check("sanitize flags aligned", (cleaned2.get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 4)
var circle := {
"shape_type": "circle",
"points": [],
"radius": 24.0,
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
}
check("sanitize passes circles through", is_equal_approx(float(PROP_FILE.sanitize_shape(circle).get("radius", 0.0)), 24.0))
func _test_save_load() -> void:
var shapes: Array[Dictionary] = [_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))])]
var data: Dictionary = PROP_FILE.to_file_dict("Save Test", PROP_BLOCK.MaterialPreset.RUBBER, shapes)
var path := "user://prop_editor_test_save.prop"
check("save returns true", PROP_FILE.save(path, data))
var loaded: Dictionary = PROP_FILE.load(path)
check("load returns dict", not loaded.is_empty())
check("load matches", loaded.get("prop_name", "") == "Save Test" and loaded.get("material_preset", "") == "rubber")
check("load missing -> {}", PROP_FILE.load("user://definitely_missing.prop").is_empty())
DirAccess.remove_absolute(path)
+1
View File
@@ -0,0 +1 @@
uid://drq2g06sh58xs
+89
View File
@@ -0,0 +1,89 @@
# Phase 9 Prop Editor Round 3 - headless regression checks for the pure static
# helpers (window title, mode button text) and PropFile.save_safe atomic writes.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_round3.gd"
extends SceneTree
const PROP_EDITOR := preload("res://scripts/prop_editor.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP EDITOR ROUND 3 TEST ===")
await _test_window_title()
await _test_mode_button_text()
await _test_save_safe()
await _test_recent_list()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_window_title() -> void:
check("untitled no path no dirty", PROP_EDITOR.build_window_title("", "", false) == "Untitled")
check("name dirty", PROP_EDITOR.build_window_title("My Crate", "", true) == "My Crate *")
check("name path clean", PROP_EDITOR.build_window_title("My Crate", "res://props/mycrate.prop", false) == "My Crate — mycrate.prop")
check("untitled path dirty", PROP_EDITOR.build_window_title("", "res://props/mycrate.prop", true) == "Untitled — mycrate.prop *")
check("whitespace name falls back", PROP_EDITOR.build_window_title(" ", "", false) == "Untitled")
check("extension kept in title", PROP_EDITOR.build_window_title("X", "res://props/ball.prop", false).ends_with("ball.prop"))
func _test_mode_button_text() -> void:
check("arrange label", PROP_EDITOR.mode_button_text(0) == "🖐️ Arrange")
check("reshape label", PROP_EDITOR.mode_button_text(1) == "✏️ Reshape")
check("out-of-range clamps to last", PROP_EDITOR.mode_button_text(99) == "✏️ Reshape")
check("negative clamps to first", PROP_EDITOR.mode_button_text(-5) == "🖐️ Arrange")
func _test_save_safe() -> void:
var path := "user://round3_autosave_test.prop"
var data := {
"version": "1.0",
"prop_name": "X",
"material_preset": "none",
"shapes": [],
"metadata": {"autosave": true, "source_path": "", "modified_unix": 123},
}
check("save_safe first write", PROP_FILE.save_safe(path, data))
var loaded: Dictionary = PROP_FILE.load(path)
check("save_safe round trip", not loaded.is_empty() and loaded.get("prop_name", "") == "X" and bool((loaded.get("metadata", {}) as Dictionary).get("autosave", false)))
check("no tmp leftover", not FileAccess.file_exists(path + ".tmp"))
check("no bak leftover", not FileAccess.file_exists(path + ".bak"))
data["prop_name"] = "Y"
check("save_safe overwrite", PROP_FILE.save_safe(path, data))
var loaded2: Dictionary = PROP_FILE.load(path)
check("overwrite persisted", loaded2.get("prop_name", "") == "Y")
check("no leftovers after overwrite", not FileAccess.file_exists(path + ".tmp") and not FileAccess.file_exists(path + ".bak"))
DirAccess.remove_absolute(path)
func _test_recent_list() -> void:
var list: Array = PROP_EDITOR.update_recent_list([], "res://props/a.prop", 5)
check("recent empty list inserts", list == ["res://props/a.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/a.prop"], "res://props/a.prop", 5)
check("recent dedupe moves to front", list == ["res://props/a.prop", "res://props/b.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/c.prop"], "res://props/a.prop", 5)
check("recent insert keeps most-recent-first", list == ["res://props/a.prop", "res://props/b.prop", "res://props/c.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/c.prop", "res://props/d.prop", "res://props/e.prop", "res://props/f.prop"], "res://props/a.prop", 5)
check("recent trims to max", list.size() == 5 and list[0] == "res://props/a.prop" and list[4] == "res://props/e.prop")
+1
View File
@@ -0,0 +1 @@
uid://bc82lccw6jtf
+170
View File
@@ -0,0 +1,170 @@
# Phase 9 Prop Editor - headless regression checks for PropTheme
# (fallback hierarchy, invalid-input fallback, physics-not-themeable).
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_theme.gd"
extends SceneTree
const PROP_THEME := preload("res://scripts/prop_theme.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP THEME TEST ===")
await _test_default_file()
await _test_missing_file_fallback()
await _test_custom_override_and_fallback()
await _test_invalid_color_fallback()
await _test_invalid_number_fallback()
await _test_physics_not_themeable()
await _test_primitive_defaults()
await _test_preset_clamp()
await _test_ui_tokens()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_default_file() -> void:
var theme: PropTheme = PROP_THEME.new()
var loaded: bool = theme.load_from(PROP_THEME.DEFAULT_PATH)
check("committed theme loads", loaded)
check("grid_size default 15", theme.grid_size() == 15)
check("outline width default 2.0", is_equal_approx(theme.outline_width_default(), 2.0))
check("outline width min 0.5", is_equal_approx(theme.outline_width_min(), 0.5))
check("outline width max 32.0", is_equal_approx(theme.outline_width_max(), 32.0))
check("wood fill from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "fill").is_equal_approx(Color.from_string("#8C6133", Color.BLACK)))
check("wood outline from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "outline").is_equal_approx(Color.from_string("#40290F", Color.BLACK)))
check("none fill from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "fill").is_equal_approx(Color.from_string("#8C6133", Color.BLACK)))
check("canvas bg color", theme.canvas_background_color().is_equal_approx(Color.from_string("#1F1F1F", Color.BLACK)))
func _test_missing_file_fallback() -> void:
var theme: PropTheme = PROP_THEME.new()
var loaded: bool = theme.load_from("res://definitely_missing_theme.json")
check("missing file reports false", not loaded)
check("missing file falls back to defaults", theme.grid_size() == 15 and is_equal_approx(theme.outline_width_default(), 2.0))
check("missing file keeps material tints", theme.material_tint(PROP_BLOCK.MaterialPreset.RUBBER, "fill").is_equal_approx(Color.from_string("#E63333", Color.BLACK)))
func _write_user_theme(text: String, path: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(text)
f.close()
func _test_custom_override_and_fallback() -> void:
var path := "user://prop_theme_test_custom.json"
_write_user_theme('{"version":"1.0","canvas":{"grid_size":33},"outline_widths":{"default":5.0}}', path)
var theme: PropTheme = PROP_THEME.new()
check("custom theme loads", theme.load_from(path))
check("custom grid override", theme.grid_size() == 33)
check("custom width override", is_equal_approx(theme.outline_width_default(), 5.0))
check("unoverridden key falls back", theme.material_tint(PROP_BLOCK.MaterialPreset.METAL, "fill").is_equal_approx(Color.from_string("#808C99", Color.BLACK)))
check("unoverridden primitive falls back", is_equal_approx(theme.primitive_default("box", "size_x"), 48.0))
DirAccess.remove_absolute(path)
func _test_invalid_color_fallback() -> void:
var path := "user://prop_theme_test_badcolor.json"
_write_user_theme('{"version":"1.0","material_tints":{"wood":{"fill":"not-a-color","outline":"also bad"}}}', path)
var theme: PropTheme = PROP_THEME.new()
theme.load_from(path)
check("invalid fill falls back to PropBlock tint", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "fill").is_equal_approx(PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.WOOD)))
check("invalid outline falls back to darkened tint", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "outline").is_equal_approx(PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.WOOD).darkened(0.55)))
DirAccess.remove_absolute(path)
func _test_invalid_number_fallback() -> void:
var path := "user://prop_theme_test_badnum.json"
_write_user_theme('{"version":"1.0","outline_widths":{"default":"wide"}}', path)
var theme: PropTheme = PROP_THEME.new()
theme.load_from(path)
check("invalid number falls back", is_equal_approx(theme.outline_width_default(), 2.0))
check("number clamp low", is_equal_approx(theme.number("outline_widths.default", 2.0, 1.0, 10.0), 2.0))
var path2 := "user://prop_theme_test_clamp.json"
_write_user_theme('{"version":"1.0","outline_widths":{"default":500}}', path2)
var theme2: PropTheme = PROP_THEME.new()
theme2.load_from(path2)
check("number clamp high", is_equal_approx(theme2.number("outline_widths.default", 2.0, -INF, 32.0), 32.0))
DirAccess.remove_absolute(path)
DirAccess.remove_absolute(path2)
func _test_physics_not_themeable() -> void:
check("mass lives in PropBlock", is_equal_approx(PROP_BLOCK.mass_for(PROP_BLOCK.MaterialPreset.WOOD), 3.0))
check("bounce lives in PropBlock", is_equal_approx(PROP_BLOCK.physics_material(PROP_BLOCK.MaterialPreset.RUBBER).bounce, 0.85))
var theme: PropTheme = PROP_THEME.new()
check("theme default config has no physics section", not theme.value("physics", null) is Dictionary)
var path := "user://prop_theme_test_physics.json"
_write_user_theme('{"version":"1.0","physics":{"wood":{"mass":99.0}}}', path)
theme.load_from(path)
check("attempted physics override has no effect on accessors", is_equal_approx(theme.outline_width_default(), 2.0) and theme.grid_size() == 15)
DirAccess.remove_absolute(path)
func _test_primitive_defaults() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("box size_x default", is_equal_approx(theme.primitive_default("box", "size_x"), 48.0))
check("ball radius default", is_equal_approx(theme.primitive_default("ball", "radius"), 24.0))
check("plank length default", is_equal_approx(theme.primitive_default("plank", "length"), 160.0))
check("triangle height default", is_equal_approx(theme.primitive_default("triangle", "height"), 48.0))
check("circle segments default", is_equal_approx(theme.primitive_default("circle", "segments"), 12.0))
check("unknown key uses caller fallback", is_equal_approx(theme.primitive_default("box", "radius", 7.5), 7.5))
check("unknown primitive uses caller fallback", is_equal_approx(theme.primitive_default("blob", "size_x", 3.0), 3.0))
func _test_preset_clamp() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("out-of-range preset clamps to NONE", theme.material_tint(999, "fill").is_equal_approx(theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "fill")))
check("negative preset clamps to NONE", theme.material_tint(-3, "outline").is_equal_approx(theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "outline")))
func _test_ui_tokens() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("font_body 18", theme.font_body() == 18)
check("font_header 24", theme.font_header() == 24)
check("font_menu 22", theme.font_menu() == 22)
check("font_menu_bar 22", theme.font_menu_bar() == 22)
check("font_mode 26", theme.font_mode() == 26)
check("font_status 18", theme.font_status() == 18)
check("font_prop_name 20", theme.font_prop_name() == 20)
check("font_context_menu 22", theme.font_context_menu() == 22)
check("button_min_height 36", theme.button_min_height() == 36)
check("row_min_height 36", theme.row_min_height() == 36)
check("mode_button_min_height 56", theme.mode_button_min_height() == 56)
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F2F2F2", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#9A9A9A", Color.WHITE)))
check("border token", theme.border_color().is_equal_approx(Color.from_string("#FFFFFF33", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#4FA3FF", Color.WHITE)))
var path := "user://prop_theme_test_ui.json"
_write_user_theme('{"version":"1.0","ui":{"fonts":{"body":24},"colors":{"accent":"#FF8800"}}}', path)
var custom: PropTheme = PROP_THEME.new()
custom.load_from(path)
check("ui font override", custom.font_body() == 24)
check("unoverridden ui token falls back", custom.font_header() == 24)
check("ui accent override", custom.accent_color().is_equal_approx(Color.from_string("#FF8800", Color.WHITE)))
DirAccess.remove_absolute(path)
+1
View File
@@ -0,0 +1 @@
uid://bo7bwalcfi8x7
+80
View File
@@ -0,0 +1,80 @@
# Phase 9 Prop Editor - headless regression checks for the ThumbnailCache
# extension (prop_file_key / prop_file_png) and PNG round-trip.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_thumb.gd"
extends SceneTree
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP THUMB TEST ===")
await _test_prop_file_keys()
await _test_builtin_prop_png_unchanged()
await _test_png_round_trip()
await _test_missing_file()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_prop_file_keys() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
var path := "user://prop_thumb_key_test.prop"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("{}")
f.close()
var key: String = cache.prop_file_key(path)
var mtime: int = FileAccess.get_modified_time(path)
check("prop_file_key format", key == "prop_thumb_key_test_%d" % mtime)
check("prop_file_png uses PROP_DIR", cache.prop_file_png(path) == "user://thumbnails/props/%s.png" % key)
check("prop_file_key missing file still formats", cache.prop_file_key("res://nope.prop") == "nope_0")
DirAccess.remove_absolute(path)
func _test_builtin_prop_png_unchanged() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
check("prop_png keeps version key", cache.prop_png("crate") == "user://thumbnails/props/crate_v%d.png" % THUMBNAIL_CACHE.PROP_VERSION)
check("stickman_png unchanged", cache.stickman_png("abc_123") == "user://thumbnails/stickmen/abc_123.png")
func _test_png_round_trip() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
var img := Image.create_empty(4, 4, false, Image.FORMAT_RGBA8)
img.fill(Color(1.0, 0.0, 0.0, 1.0))
var tex := ImageTexture.create_from_image(img)
var png_path := "user://thumbnails/props/test_roundtrip_v9.png"
var err: Error = cache.save_png(tex, png_path)
check("save_png succeeds", err == OK)
check("png exists on disk", FileAccess.file_exists(png_path))
cache.clear_memory()
var loaded: Texture2D = cache.load_png(png_path)
check("load_png round trip", loaded != null and loaded.get_size() == Vector2(4, 4))
check("save_png null texture rejected", cache.save_png(null, "user://thumbnails/props/null_v9.png") != OK)
DirAccess.remove_absolute(png_path)
func _test_missing_file() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
check("load_png missing -> null", cache.load_png("user://thumbnails/props/definitely_missing.png") == null)
+1
View File
@@ -0,0 +1 @@
uid://bkmhs37lfr7em