2 Commits
Author SHA1 Message Date
ryan ed9a2d0d18 Merge remote-tracking branch 'origin/feature/prop-editor' 2026-09-19 23:57:12 -04:00
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).
- **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`
- 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):
`<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()`.
@@ -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
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
- **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
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
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
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`
@@ -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
dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop**
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
@@ -1093,6 +1329,29 @@ rotation, scale}`.
- The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible;
`"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)
- 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.
## 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`)
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.
- **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
| 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/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://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_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_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_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/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. |
@@ -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`.
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) |
| 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) |
| 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"
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:
if _memory.has(png_path):
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