From b66ded5f605ff2dba23dba46ae49d741afe7cf5a Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sat, 19 Sep 2026 21:26:03 -0400 Subject: [PATCH] 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. --- AGENTS.md | 259 +++++ README.md | 86 +- docs/tech_debt_and_optimizations.md | 7 + plans/PHASE_9_PROP_EDITOR.md | 151 +++ prop_theme.json | 47 + props/box.prop | 116 ++ props/eye.prop | 277 +++++ scenes/prop_editor.tscn | 157 +++ scripts/prop_canvas.gd | 1401 +++++++++++++++++++++++++ scripts/prop_canvas.gd.uid | 1 + scripts/prop_editor.gd | 1061 +++++++++++++++++++ scripts/prop_editor.gd.uid | 1 + scripts/prop_file.gd | 334 ++++++ scripts/prop_file.gd.uid | 1 + scripts/prop_theme.gd | 238 +++++ scripts/prop_theme.gd.uid | 1 + scripts/thumbnails/thumbnail_cache.gd | 8 + specs/phase_9_prop_editor.md | 554 ++++++++++ specs/phase_9_prop_editor_round2.md | 447 ++++++++ specs/phase_9_prop_editor_round3.md | 694 ++++++++++++ specs/phase_9_prop_editor_round4.md | 52 + specs/phase_9_prop_editor_round5.md | 49 + specs/phase_9_prop_editor_round6.md | 51 + specs/phase_9_prop_editor_round7.md | 47 + tests/test_prop_editor_canvas.gd | 387 +++++++ tests/test_prop_editor_canvas.gd.uid | 1 + tests/test_prop_editor_file.gd | 252 +++++ tests/test_prop_editor_file.gd.uid | 1 + tests/test_prop_editor_round3.gd | 89 ++ tests/test_prop_editor_round3.gd.uid | 1 + tests/test_prop_editor_theme.gd | 170 +++ tests/test_prop_editor_theme.gd.uid | 1 + tests/test_prop_editor_thumb.gd | 80 ++ tests/test_prop_editor_thumb.gd.uid | 1 + 34 files changed, 7021 insertions(+), 2 deletions(-) create mode 100644 plans/PHASE_9_PROP_EDITOR.md create mode 100644 prop_theme.json create mode 100644 props/box.prop create mode 100644 props/eye.prop create mode 100644 scenes/prop_editor.tscn create mode 100644 scripts/prop_canvas.gd create mode 100644 scripts/prop_canvas.gd.uid create mode 100644 scripts/prop_editor.gd create mode 100644 scripts/prop_editor.gd.uid create mode 100644 scripts/prop_file.gd create mode 100644 scripts/prop_file.gd.uid create mode 100644 scripts/prop_theme.gd create mode 100644 scripts/prop_theme.gd.uid create mode 100644 specs/phase_9_prop_editor.md create mode 100644 specs/phase_9_prop_editor_round2.md create mode 100644 specs/phase_9_prop_editor_round3.md create mode 100644 specs/phase_9_prop_editor_round4.md create mode 100644 specs/phase_9_prop_editor_round5.md create mode 100644 specs/phase_9_prop_editor_round6.md create mode 100644 specs/phase_9_prop_editor_round7.md create mode 100644 tests/test_prop_editor_canvas.gd create mode 100644 tests/test_prop_editor_canvas.gd.uid create mode 100644 tests/test_prop_editor_file.gd create mode 100644 tests/test_prop_editor_file.gd.uid create mode 100644 tests/test_prop_editor_round3.gd create mode 100644 tests/test_prop_editor_round3.gd.uid create mode 100644 tests/test_prop_editor_theme.gd create mode 100644 tests/test_prop_editor_theme.gd.uid create mode 100644 tests/test_prop_editor_thumb.gd create mode 100644 tests/test_prop_editor_thumb.gd.uid diff --git a/AGENTS.md b/AGENTS.md index 0d48b43..546732a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,8 @@ - `` = 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)`): ` --headless --path --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): ` --path res://scenes/sandbox_stage.tscn -- --play --scene .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/_.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)` → `"_"` 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)` → + `"_v.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 diff --git a/README.md b/README.md index 11cc4c9..1dd2389 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index fc3e632..addec26 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -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`, `_.png`), so re-edits invalidate the cached PNG; built-in template PNGs stay `id_v`-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) | --- diff --git a/plans/PHASE_9_PROP_EDITOR.md b/plans/PHASE_9_PROP_EDITOR.md new file mode 100644 index 0000000..bd63ee2 --- /dev/null +++ b/plans/PHASE_9_PROP_EDITOR.md @@ -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/.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/.png`. + +- The editor loads cached thumbnails from disk if source files are unchanged. diff --git a/prop_theme.json b/prop_theme.json new file mode 100644 index 0000000..50b59c9 --- /dev/null +++ b/prop_theme.json @@ -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" + } + } +} diff --git a/props/box.prop b/props/box.prop new file mode 100644 index 0000000..ce6e13d --- /dev/null +++ b/props/box.prop @@ -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" +} \ No newline at end of file diff --git a/props/eye.prop b/props/eye.prop new file mode 100644 index 0000000..36f0732 --- /dev/null +++ b/props/eye.prop @@ -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" +} \ No newline at end of file diff --git a/scenes/prop_editor.tscn b/scenes/prop_editor.tscn new file mode 100644 index 0000000..4354e78 --- /dev/null +++ b/scenes/prop_editor.tscn @@ -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 diff --git a/scripts/prop_canvas.gd b/scripts/prop_canvas.gd new file mode 100644 index 0000000..c7c8599 --- /dev/null +++ b/scripts/prop_canvas.gd @@ -0,0 +1,1401 @@ +class_name PropCanvas +extends Control +## PropCanvas - Multi-shape vector canvas for the Prop Editor. +## +## Mirrors BodyPartPanel's proven patterns (cursor-centered zoom, pinch/pan +## gestures, middle-mouse pan, vertex drag with live reshape, right-click +## context menu, z-order) and adds prop-specific needs: per-shape fill/outline +## colors + outline width, polygon/circle/line primitives, translate/rotate/ +## scale gizmos (Ctrl = 15 degree rotation snap, Ctrl = aspect-locked scale), +## mirror X/Y, an in-memory clipboard, and grid-snap sanitization on commit. + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +signal shape_changed(shapes: Array[Dictionary]) +signal shape_selected(idx: int) + +## Emitted when the user copies a shape via the context menu; the editor owns +## the clipboard slot and calls set_has_clipboard / paste_shape. +signal shape_copy_requested(shape: Dictionary) + +## Emitted when Paste is chosen; the editor replies via paste_shape(). +signal paste_from_context_requested(world_pos: Vector2) + +## Emitted when a vertex is created via Create Point (shape index, vertex index). +signal vertex_created(shape_idx: int, vertex_idx: int) + +## Emitted only when the edit mode actually changes. +signal mode_changed(mode: int) + +# --------------------------------------------------------------------------- +# Enums / constants +# --------------------------------------------------------------------------- + +enum ShapeType { POLYGON, CIRCLE, LINE } +enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 } + +enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE } + +## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points" +## (vertex handles + Create/Remove Point). Gates input and drawing only — the +## public math APIs stay mode-agnostic. +enum EditMode { TRANSFORM, POINTS } + +const HANDLE_RADIUS: float = 3.0 +const HANDLE_RADIUS_SELECTION: float = 8.0 +const HANDLE_HOLLOW_SIZE: float = 6.0 +const EDGE_HIT_THRESHOLD: float = 12.0 +# Rotation dot below the bbox bottom-center (mirrors whole_stickman_preview). +const ROTATION_CIRCLE_RADIUS: float = 5.0 +const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0 +const ROTATION_CIRCLE_OFFSET: float = 26.0 +const SCALE_CROSS_SIZE: float = 5.0 +const SCALE_CROSS_HIT_RADIUS: float = 12.0 +const ROTATION_SNAP_DEG: float = 15.0 +const FLASH_DURATION: float = 0.6 +const FLASH_RING_RADIUS: float = 12.0 +const CLIPBOARD_PASTE_OFFSET := Vector2(16.0, 16.0) +const DEFAULT_CIRCLE_SEGMENTS: int = 12 +const MIN_ZOOM: float = 0.3 +const MAX_ZOOM: float = 3.0 +const ZOOM_STEP: float = 1.10 +const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15) + +const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd") +const PropFileScript := preload("res://scripts/prop_file.gd") + +const SELECTION_CORE_COLOR := Color.WHITE +const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85) +const FLASH_COLOR := Color.YELLOW + +# Primitive defaults mirror PropUtils.create_* generator defaults. +const DEFAULT_FILL_HEX: String = "#8C6133" +const DEFAULT_OUTLINE_HEX: String = "#40290F" +const DEFAULT_OUTLINE_WIDTH: float = 2.0 +const DEFAULT_BOX_SIZE := Vector2(48.0, 48.0) +const DEFAULT_BALL_RADIUS: float = 24.0 +const DEFAULT_PLANK_LENGTH: float = 160.0 +const DEFAULT_PLANK_THICKNESS: float = 16.0 +const DEFAULT_TRIANGLE_BASE: float = 56.0 +const DEFAULT_TRIANGLE_HEIGHT: float = 48.0 +const DEFAULT_LINE_LENGTH: float = 120.0 +const DEFAULT_CIRCLE_RADIUS: float = 40.0 + +# Context menu item ids. +const MENU_REMOVE_POINT := 1 +const MENU_CREATE_POINT := 2 +const MENU_COPY := 3 +const MENU_PASTE := 4 +const MENU_DELETE := 5 +const MENU_MIRROR_X := 6 +const MENU_MIRROR_Y := 7 +const MENU_BRING_FORWARD := 8 +const MENU_SEND_BACK := 9 +const MENU_ADD_BOX := 10 +const MENU_ADD_PLANK := 12 +const MENU_ADD_TRIANGLE := 13 +const MENU_ADD_LINE := 14 +const MENU_ADD_CIRCLE := 15 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var shapes: Array[Dictionary] = [] +var _selected_shape_idx: int = -1 +var _zoom: float = 1.0 +var _pan_offset: Vector2 = Vector2.ZERO +var _grid_size: int = 15 +var _snap_enabled: bool = false +var _has_clipboard: bool = false +var _context_menu: PopupMenu +var _edit_mode: EditMode = EditMode.TRANSFORM + +# Drag state +var _drag_mode: DragMode = DragMode.NONE +var _dragging_vertex: int = -1 +var _drag_offset: Vector2 = Vector2.ZERO +var _shape_drag_start: Vector2 = Vector2.ZERO +var _shape_drag_original_points: PackedVector2Array = PackedVector2Array() +var _rotate_center: Vector2 = Vector2.ZERO +var _rotate_total_delta: float = 0.0 +var _rotate_prev_angle: float = 0.0 +var _scale_start_world: Vector2 = Vector2.ZERO +var _scale_corner: int = -1 +var _gizmo_base_points: PackedVector2Array = PackedVector2Array() +var _is_panning: bool = false +var _pan_start: Vector2 = Vector2.ZERO + +# Create Point flash feedback +var _flash_vertex: int = -1 +var _flash_remaining: float = 0.0 + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + if not draw.is_connected(_on_canvas_draw): + draw.connect(_on_canvas_draw) + if not gui_input.is_connected(_on_canvas_gui_input): + gui_input.connect(_on_canvas_gui_input) + _context_menu = PopupMenu.new() + _context_menu.name = "ContextMenu" + add_child(_context_menu) + if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed): + _context_menu.id_pressed.connect(_on_context_menu_id_pressed) + + +func _process(delta: float) -> void: + if _flash_remaining > 0.0: + _flash_remaining = maxf(_flash_remaining - delta, 0.0) + queue_redraw() + if _flash_remaining <= 0.0: + _flash_vertex = -1 + + +# --------------------------------------------------------------------------- +# Public API — edit mode (Round 2) +# --------------------------------------------------------------------------- + +func set_edit_mode(mode: int) -> void: + var m: int = EditMode.TRANSFORM if mode != EditMode.POINTS else EditMode.POINTS + if m == _edit_mode: + return + _edit_mode = m + _drag_mode = DragMode.NONE + _dragging_vertex = -1 + queue_redraw() + mode_changed.emit(_edit_mode) + + +func get_edit_mode() -> int: + return _edit_mode + + +# --------------------------------------------------------------------------- +# Public API — shape data +# --------------------------------------------------------------------------- + +func set_shape_data(data: Variant) -> void: + ## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y} + ## format) or a single Dictionary. + shapes.clear() + if data is Array: + for item: Variant in data as Array: + if item is Dictionary: + shapes.append(_coerce_shape(item as Dictionary)) + elif data is Dictionary: + shapes.append(_coerce_shape(data as Dictionary)) + _selected_shape_idx = -1 + _drag_mode = DragMode.NONE + _dragging_vertex = -1 + _zoom = 1.0 + _pan_offset = Vector2.ZERO + queue_redraw() + + +func get_shape_data() -> Array[Dictionary]: + ## Export all shapes as plain dictionaries ({x,y} points, flag arrays). + var result: Array[Dictionary] = [] + for sd: Dictionary in shapes: + result.append(_export_shape(sd)) + return result + + +func clear() -> void: + ## Remove all shapes without emitting shape_changed. + shapes.clear() + _selected_shape_idx = -1 + _drag_mode = DragMode.NONE + _dragging_vertex = -1 + _zoom = 1.0 + _pan_offset = Vector2.ZERO + queue_redraw() + + +func select(idx: int) -> void: + _selected_shape_idx = clampi(idx, -1, shapes.size() - 1) + queue_redraw() + + +func deselect() -> void: + _selected_shape_idx = -1 + _drag_mode = DragMode.NONE + _dragging_vertex = -1 + queue_redraw() + + +func get_selected_index() -> int: + return _selected_shape_idx + + +func delete_selected() -> void: + if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size(): + return + shapes.remove_at(_selected_shape_idx) + _selected_shape_idx = clampi(_selected_shape_idx - 1, -1, shapes.size() - 1) + queue_redraw() + _emit_changed() + + +# --------------------------------------------------------------------------- +# Public API — primitives +# --------------------------------------------------------------------------- + +func add_primitive(primitive: String, center: Vector2, params: Dictionary = {}) -> Dictionary: + ## Append a primitive ("box"|"ball"|"plank"|"triangle"|"line"|"circle") at + ## `center`. Optional params: size_x/size_y (box), radius (ball/circle), + ## length/thickness (plank), base/height (triangle), length (line), + ## segments (circle), fill_color/outline_color (hex), outline_width. + ## Returns the new shape in export format. + var sd := _make_shape_dict() + var fill_hex: String = str(params.get("fill_color", DEFAULT_FILL_HEX)) + var outline_hex: String = str(params.get("outline_color", DEFAULT_OUTLINE_HEX)) + var outline_width: float = float(params.get("outline_width", DEFAULT_OUTLINE_WIDTH)) + sd["fill_color"] = fill_hex + sd["outline_color"] = outline_hex + sd["outline_width"] = outline_width + + match primitive: + "ball", "circle": + sd["shape_type"] = "circle" + var radius: float = float(params.get("radius", DEFAULT_CIRCLE_RADIUS if primitive == "circle" else DEFAULT_BALL_RADIUS)) + var segments: int = int(params.get("segments", DEFAULT_CIRCLE_SEGMENTS)) + sd["radius"] = radius + sd["points"] = _circle_points(center, radius, segments) + sd["closed"] = true + sd["vertex_flags"] = _zero_flags(sd["points"].size()) + "plank": + sd["shape_type"] = "polygon" + var length: float = float(params.get("length", DEFAULT_PLANK_LENGTH)) + var thickness: float = float(params.get("thickness", DEFAULT_PLANK_THICKNESS)) + var hl: float = length * 0.5 + var ht: float = thickness * 0.5 + sd["points"] = PackedVector2Array([ + center + Vector2(-hl, -ht), + center + Vector2(hl, -ht), + center + Vector2(hl, ht), + center + Vector2(-hl, ht), + ]) + sd["closed"] = true + sd["vertex_flags"] = _zero_flags(4) + "triangle": + sd["shape_type"] = "polygon" + var base: float = float(params.get("base", DEFAULT_TRIANGLE_BASE)) + var height: float = float(params.get("height", DEFAULT_TRIANGLE_HEIGHT)) + var hb: float = base * 0.5 + var hh: float = height * 0.5 + sd["points"] = PackedVector2Array([ + center + Vector2(-hb, hh), + center + Vector2(hb, hh), + center + Vector2(0.0, -hh), + ]) + sd["closed"] = true + sd["vertex_flags"] = _zero_flags(3) + "line": + sd["shape_type"] = "line" + var line_length: float = float(params.get("length", DEFAULT_LINE_LENGTH)) + var hl2: float = line_length * 0.5 + sd["points"] = PackedVector2Array([ + center + Vector2(-hl2, 0.0), + center + Vector2(hl2, 0.0), + ]) + sd["closed"] = false + sd["vertex_flags"] = _zero_flags(2) + _: + sd["shape_type"] = "polygon" + var sx: float = float(params.get("size_x", DEFAULT_BOX_SIZE.x)) + var sy: float = float(params.get("size_y", DEFAULT_BOX_SIZE.y)) + var hx: float = sx * 0.5 + var hy: float = sy * 0.5 + sd["points"] = PackedVector2Array([ + center + Vector2(-hx, -hy), + center + Vector2(hx, -hy), + center + Vector2(hx, hy), + center + Vector2(-hx, hy), + ]) + sd["closed"] = true + sd["vertex_flags"] = _zero_flags(4) + + shapes.append(sd) + _selected_shape_idx = shapes.size() - 1 + queue_redraw() + _emit_changed() + return _export_shape(sd) + + +# --------------------------------------------------------------------------- +# Public API — vertex editing +# --------------------------------------------------------------------------- + +func insert_vertex(shape_idx: int, vertex_idx: int, world_pos: Vector2) -> void: + ## Insert a USER_CREATED vertex at `world_pos` before `vertex_idx`. + if shape_idx < 0 or shape_idx >= shapes.size(): + return + var sd: Dictionary = shapes[shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if vertex_idx < 0 or vertex_idx > pts.size(): + return + pts.insert(vertex_idx, world_pos) + sd["points"] = pts + sd["reshaped"] = true + var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + if vf.size() == pts.size() - 1: + vf.insert(vertex_idx, VertexFlag.USER_CREATED) + sd["vertex_flags"] = vf + queue_redraw() + _emit_changed() + + +func remove_vertex(shape_idx: int, vertex_idx: int) -> void: + ## Remove a vertex. Shapes reduced below 2 vertices are removed. + if shape_idx < 0 or shape_idx >= shapes.size(): + return + var sd: Dictionary = shapes[shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if vertex_idx < 0 or vertex_idx >= pts.size(): + return + pts.remove_at(vertex_idx) + sd["points"] = pts + sd["reshaped"] = true + var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + if vf.size() > 0: + vf.remove_at(clampi(vertex_idx, 0, vf.size() - 1)) + sd["vertex_flags"] = vf + if pts.size() == 2: + sd["closed"] = false + if pts.size() < 2: + shapes.remove_at(shape_idx) + _selected_shape_idx = clampi(shape_idx - 1, -1, shapes.size() - 1) + _dragging_vertex = -1 + queue_redraw() + _emit_changed() + + +# --------------------------------------------------------------------------- +# Public API — z-order / mirror / clipboard / transforms +# --------------------------------------------------------------------------- + +func bring_forward(idx: int) -> void: + if idx < 0 or idx >= shapes.size() - 1: + return + var sd: Dictionary = shapes[idx] + shapes.remove_at(idx) + shapes.insert(idx + 1, sd) + _selected_shape_idx = idx + 1 + queue_redraw() + _emit_changed() + + +func send_back(idx: int) -> void: + if idx <= 0 or idx >= shapes.size(): + return + var sd: Dictionary = shapes[idx] + shapes.remove_at(idx) + shapes.insert(idx - 1, sd) + _selected_shape_idx = idx - 1 + queue_redraw() + _emit_changed() + + +func mirror_x(idx: int) -> void: + if idx < 0 or idx >= shapes.size(): + return + var sd: Dictionary = shapes[idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return + var bbox: Rect2 = _shape_bbox(sd) + var center_x: float = bbox.get_center().x + for i: int in pts.size(): + pts[i] = Vector2(center_x * 2.0 - pts[i].x, pts[i].y) + sd["points"] = pts + queue_redraw() + _emit_changed() + + +func mirror_y(idx: int) -> void: + if idx < 0 or idx >= shapes.size(): + return + var sd: Dictionary = shapes[idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return + var bbox: Rect2 = _shape_bbox(sd) + var center_y: float = bbox.get_center().y + for i: int in pts.size(): + pts[i] = Vector2(pts[i].x, center_y * 2.0 - pts[i].y) + sd["points"] = pts + queue_redraw() + _emit_changed() + + +func copy_shape(idx: int) -> Dictionary: + ## Return a deep duplicate of the internal shape dictionary, {} if invalid. + if idx < 0 or idx >= shapes.size(): + return {} + return shapes[idx].duplicate(true) + + +func paste_shape(source: Dictionary, target_pos: Vector2) -> void: + ## Insert a deep copy of `source` with its bbox center at + ## target_pos + CLIPBOARD_PASTE_OFFSET. + if source.is_empty(): + return + var new_sd: Dictionary = source.duplicate(true) + var pts: PackedVector2Array = new_sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return + var bbox: Rect2 = _shape_bbox(new_sd) + var offset: Vector2 = (target_pos + CLIPBOARD_PASTE_OFFSET) - bbox.get_center() + var new_pts := PackedVector2Array() + for pt: Vector2 in pts: + new_pts.append(pt + offset) + new_sd["points"] = new_pts + shapes.append(new_sd) + _selected_shape_idx = shapes.size() - 1 + queue_redraw() + _emit_changed() + + +func translate_selected(delta: Vector2) -> void: + _apply_point_transform(delta, 0.0, Vector2.ONE, Vector2.ZERO) + + +func rotate_selected(angle_rad: float) -> void: + _apply_point_transform(Vector2.ZERO, angle_rad, Vector2.ONE, Vector2.ZERO) + + +func scale_selected(factor: Vector2) -> void: + _apply_point_transform(Vector2.ZERO, 0.0, factor, Vector2.ZERO) + + +func sanitize_shape(idx: int) -> void: + ## Grid-snap + redundancy/collinear removal + clockwise enforcement on a + ## polygon/line shape, keeping vertex flags aligned. Circles are skipped + ## (radius-based geometry must not be grid-distorted). + if idx < 0 or idx >= shapes.size(): + return + var sd: Dictionary = shapes[idx] + if str(sd.get("shape_type", "polygon")) == "circle": + return + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + var flags: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + var gs: float = float(_grid_size) if _snap_enabled else 0.0 + var was_clockwise: bool = pts.size() >= 3 and Geometry2D.is_polygon_clockwise(pts) + var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, gs) + var is_clockwise: bool = cleaned.size() >= 3 and Geometry2D.is_polygon_clockwise(cleaned) + var reversed: bool = cleaned.size() >= 3 and not was_clockwise and is_clockwise + var kept_flags := PackedInt32Array() + kept_flags.resize(cleaned.size()) + for i: int in mini(cleaned.size(), flags.size()): + kept_flags[i] = flags[i] + if reversed: + kept_flags.reverse() + sd["points"] = cleaned + sd["vertex_flags"] = kept_flags + if cleaned.size() == 2 and sd.get("shape_type", "") == "polygon": + sd["closed"] = false + queue_redraw() + _emit_changed() + + +# --------------------------------------------------------------------------- +# Public API — inspector sync (Task 2.8 live updates) +# --------------------------------------------------------------------------- + +func set_shape_fill_color(idx: int, hex: String) -> void: + if idx < 0 or idx >= shapes.size(): + return + shapes[idx]["fill_color"] = hex + queue_redraw() + _emit_changed() + + +func set_shape_outline_color(idx: int, hex: String) -> void: + if idx < 0 or idx >= shapes.size(): + return + shapes[idx]["outline_color"] = hex + queue_redraw() + _emit_changed() + + +func set_shape_outline_width(idx: int, width: float) -> void: + if idx < 0 or idx >= shapes.size(): + return + shapes[idx]["outline_width"] = width + queue_redraw() + _emit_changed() + + +func set_shape_radius(idx: int, radius: float) -> void: + if idx < 0 or idx >= shapes.size(): + return + var sd: Dictionary = shapes[idx] + if str(sd.get("shape_type", "")) != "circle": + return + var old_center: Vector2 = _shape_bbox(sd).get_center() + sd["radius"] = maxf(radius, 0.001) + sd["points"] = _circle_points(old_center, float(sd["radius"]), DEFAULT_CIRCLE_SEGMENTS) + sd["vertex_flags"] = _zero_flags(sd["points"].size()) + sd["reshaped"] = false + queue_redraw() + _emit_changed() + + +# --------------------------------------------------------------------------- +# Public API — view / input +# --------------------------------------------------------------------------- + +func set_grid_size(size: int) -> void: + _grid_size = maxi(size, 1) + queue_redraw() + + +func set_snap_enabled(enabled: bool) -> void: + _snap_enabled = enabled + + +func get_snap_enabled() -> bool: + return _snap_enabled + + +func reset_view() -> void: + _zoom = 1.0 + _pan_offset = Vector2.ZERO + queue_redraw() + + +func set_has_clipboard(state: bool) -> void: + _has_clipboard = state + + +## Sets the font size of the right-click context menu (Round 4, theme-driven). +func set_context_menu_font_size(size: int) -> void: + if _context_menu != null: + _context_menu.add_theme_font_size_override("font_size", size) + + +func is_cursor_over_drawing(global_pos: Vector2) -> bool: + return get_global_rect().has_point(global_pos) + + +func global_to_world(global_pos: Vector2) -> Vector2: + return _screen_to_world(global_pos - global_position) + + +# --------------------------------------------------------------------------- +# Drawing +# --------------------------------------------------------------------------- + +func _on_canvas_draw() -> void: + draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom)) + _draw_grid() + + if shapes.is_empty(): + var rect := get_rect() + var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5)) + var font := get_theme_default_font() + if font != null: + draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6)) + return + + for i: int in shapes.size(): + _draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1) + + if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size(): + _draw_selection_gizmos(shapes[_selected_shape_idx]) + + +func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return + var fill: Color = Color.from_string(str(sd.get("fill_color", DEFAULT_FILL_HEX)), Color.GRAY) + var outline: Color = Color.from_string(str(sd.get("outline_color", DEFAULT_OUTLINE_HEX)), Color.WHITE) + var width: float = float(sd.get("outline_width", DEFAULT_OUTLINE_WIDTH)) + var closed: bool = bool(sd.get("closed", false)) + + if closed: + draw_colored_polygon(pts, fill) + _draw_polyline(pts, outline, closed, width) + + if is_selected: + # Two-tone halo selection: a dark backing pass + a bright core pass, so + # the selection reads on any background (white shapes, dark shapes, or + # a differently-colored shape behind). + _draw_polyline(pts, SELECTION_HALO_COLOR, closed, 4.5) + _draw_polyline(pts, SELECTION_CORE_COLOR, closed, 2.0) + if _edit_mode == EditMode.POINTS: + var flags: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + for j: int in pts.size(): + var flag: int = flags[j] if flags.size() > j else 0 + # Color-matched handles (stickman editor convention); yellow while dragging. + var handle_color: Color = Color.YELLOW if j == dragging_vertex else fill + draw_circle(pts[j], (HANDLE_RADIUS + 1.5) / _zoom, SELECTION_HALO_COLOR) + if flag == VertexFlag.ORIGINAL: + draw_circle(pts[j], HANDLE_RADIUS / _zoom, handle_color) + else: + var hs: float = HANDLE_HOLLOW_SIZE / _zoom * 0.5 + draw_rect(Rect2(pts[j] - Vector2(hs, hs), Vector2(hs * 2.0, hs * 2.0)), handle_color, false) + 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, FLASH_COLOR, 2.0 / _zoom) + + +func _draw_selection_gizmos(sd: Dictionary) -> void: + var bbox: Rect2 = _shape_bbox(sd) + if bbox.size == Vector2.ZERO: + return + # Two-tone halo drawing (dark backing + bright core) so gizmos stay visible + # over any background, including a white shape behind the selected one. + var halo: Color = SELECTION_HALO_COLOR + var core: Color = SELECTION_CORE_COLOR + # Bounding box + draw_rect(bbox, halo, false, 3.5 / _zoom) + draw_rect(bbox, core, false, 1.5 / _zoom) + # Rotation dot BELOW the bbox bottom-center (stickman editor pattern). + var rot_center := Vector2(bbox.position.x + bbox.size.x * 0.5, bbox.end.y + ROTATION_CIRCLE_OFFSET) + draw_circle(rot_center, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, halo) + draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, core) + # Corner scale crosses. + var cross_half := SCALE_CROSS_SIZE / _zoom + for corner: Vector2 in [bbox.position, Vector2(bbox.end.x, bbox.position.y), bbox.end, Vector2(bbox.position.x, bbox.end.y)]: + draw_line(corner + Vector2(-cross_half, 0.0), corner + Vector2(cross_half, 0.0), halo, 3.5 / _zoom) + draw_line(corner + Vector2(0.0, -cross_half), corner + Vector2(0.0, cross_half), halo, 3.5 / _zoom) + draw_line(corner + Vector2(-cross_half, 0.0), corner + Vector2(cross_half, 0.0), core, 1.5 / _zoom) + draw_line(corner + Vector2(0.0, -cross_half), corner + Vector2(0.0, cross_half), core, 1.5 / _zoom) + + +func _draw_grid() -> void: + var gs := float(_grid_size) + if gs <= 0.0: + return + var area_size := size + var world_origin := -_pan_offset / _zoom + var world_size := area_size / _zoom + var world_end := world_origin + world_size + var start_x: float = floor(world_origin.x / gs) * gs + var start_y: float = floor(world_origin.y / gs) * gs + var line_width: float = 1.0 / _zoom + var x: float = start_x + while x <= world_end.x: + draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width) + x += gs + var y: float = start_y + while y <= world_end.y: + draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width) + y += gs + + +func _draw_polyline(pts: PackedVector2Array, color: Color, closed: bool, width: float) -> void: + for i: int in pts.size() - 1: + draw_line(pts[i], pts[i + 1], color, width) + if closed and pts.size() >= 2: + draw_line(pts[pts.size() - 1], pts[0], color, width) + + +# --------------------------------------------------------------------------- +# Input +# --------------------------------------------------------------------------- + +func _on_canvas_gui_input(event: InputEvent) -> void: + if event is InputEventMagnifyGesture: + var mag := event as InputEventMagnifyGesture + var cursor_local := get_local_mouse_position() + var world := _screen_to_world(cursor_local) + _zoom = clampf(_zoom * mag.factor, MIN_ZOOM, MAX_ZOOM) + _pan_offset = cursor_local - world * _zoom + queue_redraw() + return + + if event is InputEventPanGesture: + var pan := event as InputEventPanGesture + _pan_offset -= pan.delta * 3.0 + queue_redraw() + return + + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_MIDDLE: + if mb.pressed: + _is_panning = true + _pan_start = mb.position + else: + _is_panning = false + return + if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + _zoom_at(mb.position, ZOOM_STEP) + return + if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + _zoom_at(mb.position, 1.0 / ZOOM_STEP) + return + + var world_pos := _screen_to_world(mb.position) + if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: + _handle_right_click(mb, world_pos) + return + if mb.button_index == MOUSE_BUTTON_LEFT: + if mb.pressed: + _handle_left_press(world_pos) + else: + _handle_left_release() + + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + if _is_panning: + _pan_offset += (mm.position - _pan_start) + _pan_start = mm.position + queue_redraw() + return + _handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed) + + +func _zoom_at(local_pos: Vector2, factor: float) -> void: + var world := _screen_to_world(local_pos) + _zoom = clampf(_zoom * factor, MIN_ZOOM, MAX_ZOOM) + _pan_offset = local_pos - world * _zoom + queue_redraw() + + +func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: + if _edit_mode == EditMode.POINTS: + _handle_points_right_click(mb, world_pos) + return + _handle_transform_right_click(mb, world_pos) + + +func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: + # POINTS mode = point editing only: Remove Point on a vertex, Create Point + # near a segment, nothing anywhere else (no Copy/Erase/Flip/z-order, no + # Add-Shape). + var context_shape: int = -1 + var context_vertex: int = -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 + + # Segment proximity -> Create Point (selects the shape first). + 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 + + # Interior body: select only, no menu. + var body_shape: int = -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) + # Empty canvas -> no menu at all (adding shapes is Arrange-mode work). + + +func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: + var context_shape: int = -1 + + # Shape hit (front to back). + var hit_shape: int = -1 + for i: int in range(shapes.size() - 1, -1, -1): + if _is_point_in_shape(i, world_pos): + hit_shape = i + break + + _context_menu.clear() + if hit_shape >= 0: + _selected_shape_idx = hit_shape + _context_menu.set_meta("context_shape", hit_shape) + _context_menu.set_meta("click_position", world_pos) + queue_redraw() + shape_selected.emit(hit_shape) + _context_menu.add_item("Copy", MENU_COPY) + if _has_clipboard: + _context_menu.add_item("Paste", MENU_PASTE) + _context_menu.add_item("Erase", MENU_DELETE) + _context_menu.add_separator() + _context_menu.add_item("Flip Left-Right", MENU_MIRROR_X) + _context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y) + _context_menu.add_separator() + _context_menu.add_item("Bring Forward", MENU_BRING_FORWARD) + _context_menu.add_item("Send Back", MENU_SEND_BACK) + _context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) + return + + # Empty canvas -> add primitives. + _context_menu.set_meta("click_position", world_pos) + _context_menu.add_item("Add Box", MENU_ADD_BOX) + _context_menu.add_item("Add Plank", MENU_ADD_PLANK) + _context_menu.add_item("Add Triangle", MENU_ADD_TRIANGLE) + _context_menu.add_item("Add Line", MENU_ADD_LINE) + _context_menu.add_item("Add Circle", MENU_ADD_CIRCLE) + if _has_clipboard: + _context_menu.add_separator() + _context_menu.add_item("Paste", MENU_PASTE) + _context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) + + +func _handle_left_press(world_pos: Vector2) -> void: + if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size(): + var sd: Dictionary = shapes[_selected_shape_idx] + # 1. Rotation dot (below the bbox) + if _hit_test_rotation_dot(sd, world_pos): + var bbox: Rect2 = _shape_bbox(sd) + _drag_mode = DragMode.ROTATE + _rotate_center = bbox.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())) + queue_redraw() + return + # 2. Scale corner + var corner_idx := _hit_test_scale_corner(sd, world_pos) + if corner_idx >= 0: + var bbox: Rect2 = _shape_bbox(sd) + _drag_mode = DragMode.SCALE + _scale_corner = corner_idx + _scale_start_world = world_pos + _rotate_center = bbox.get_center() + _gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array())) + queue_redraw() + return + + if _edit_mode == EditMode.POINTS: + # 3. Vertex (selected shape first, then others front to back) + var hit_shape_v: int = -1 + var hit_idx: int = -1 + if _selected_shape_idx >= 0: + hit_idx = _hit_test_vertex(_selected_shape_idx, world_pos) + if hit_idx >= 0: + hit_shape_v = _selected_shape_idx + if hit_shape_v < 0: + for i: int in range(shapes.size() - 1, -1, -1): + if i == _selected_shape_idx: + continue + hit_idx = _hit_test_vertex(i, world_pos) + if hit_idx >= 0: + hit_shape_v = i + break + if hit_shape_v >= 0: + if hit_shape_v != _selected_shape_idx: + _selected_shape_idx = hit_shape_v + shape_selected.emit(hit_shape_v) + var sd: Dictionary = shapes[hit_shape_v] + _drag_mode = DragMode.VERTEX + _dragging_vertex = hit_idx + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + _drag_offset = world_pos - pts[hit_idx] + queue_redraw() + return + + # Shape body hit (front to back) + var hit_shape: int = -1 + for i: int in range(shapes.size() - 1, -1, -1): + if _is_point_in_shape(i, world_pos): + hit_shape = i + break + if hit_shape >= 0: + if hit_shape != _selected_shape_idx: + _selected_shape_idx = hit_shape + shape_selected.emit(hit_shape) + if _edit_mode == EditMode.TRANSFORM: + # Drag to move (translate) + _drag_mode = DragMode.TRANSLATE + _shape_drag_start = world_pos + _shape_drag_original_points = PackedVector2Array(shapes[hit_shape].get("points", PackedVector2Array())) + # POINTS mode: select-only (no translate) — Round 2 decision. + queue_redraw() + return + + _selected_shape_idx = -1 + _drag_mode = DragMode.NONE + queue_redraw() + shape_selected.emit(-1) + + +func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void: + match _drag_mode: + DragMode.VERTEX: + if _selected_shape_idx >= 0: + var sd: Dictionary = shapes[_selected_shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if _dragging_vertex >= 0 and _dragging_vertex < pts.size(): + var target := world_pos - _drag_offset + if _snap_enabled: + target = _snap_to_grid(target) + pts[_dragging_vertex] = target + sd["points"] = pts + sd["reshaped"] = true + queue_redraw() + DragMode.TRANSLATE: + if _selected_shape_idx >= 0: + var target := world_pos + if _snap_enabled: + target = _snap_to_grid(target) + var delta := target - _shape_drag_start + var sd: Dictionary = shapes[_selected_shape_idx] + var new_pts := PackedVector2Array() + for pt: Vector2 in _shape_drag_original_points: + new_pts.append(pt + delta) + sd["points"] = new_pts + queue_redraw() + DragMode.ROTATE: + if not _gizmo_base_points.is_empty(): + 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)) + var sd: Dictionary = shapes[_selected_shape_idx] + var new_pts := PackedVector2Array() + for pt: Vector2 in _gizmo_base_points: + new_pts.append(_rotate_center + (pt - _rotate_center).rotated(delta)) + sd["points"] = new_pts + queue_redraw() + DragMode.SCALE: + if not _gizmo_base_points.is_empty(): + var rel_start := _scale_start_world - _rotate_center + var rel_cur := world_pos - _rotate_center + var fx: float = rel_cur.x / rel_start.x if absf(rel_start.x) > 0.001 else 1.0 + var fy: float = rel_cur.y / rel_start.y if absf(rel_start.y) > 0.001 else 1.0 + if ctrl_pressed: + var uniform: float = fx if absf(fx) > absf(fy) else fy + fx = uniform + fy = uniform + var sd: Dictionary = shapes[_selected_shape_idx] + if str(sd.get("shape_type", "")) == "circle" and absf(fx - fy) > 0.0001: + sd["reshaped"] = true + var new_pts := PackedVector2Array() + for pt: Vector2 in _gizmo_base_points: + var rel := pt - _rotate_center + new_pts.append(_rotate_center + Vector2(rel.x * fx, rel.y * fy)) + sd["points"] = new_pts + queue_redraw() + _: + pass + + +func _handle_left_release() -> void: + match _drag_mode: + DragMode.VERTEX: + _drag_mode = DragMode.NONE + _dragging_vertex = -1 + _drag_offset = Vector2.ZERO + if _selected_shape_idx >= 0: + sanitize_shape(_selected_shape_idx) + else: + queue_redraw() + DragMode.TRANSLATE: + _drag_mode = DragMode.NONE + _shape_drag_original_points = PackedVector2Array() + queue_redraw() + _emit_changed() + DragMode.ROTATE, DragMode.SCALE: + _drag_mode = DragMode.NONE + _gizmo_base_points = PackedVector2Array() + if _selected_shape_idx >= 0: + sanitize_shape(_selected_shape_idx) + else: + queue_redraw() + _: + pass + + +func _on_context_menu_id_pressed(id: int) -> void: + var context_shape: Variant = _context_menu.get_meta("context_shape", -1) + var shape_idx: int = int(context_shape) if context_shape is int else -1 + match id: + MENU_REMOVE_POINT: + var context_vertex: Variant = _context_menu.get_meta("context_vertex", -1) + remove_vertex(shape_idx, int(context_vertex) if context_vertex is int else -1) + MENU_CREATE_POINT: + create_point_on_edge(_get_context_world_pos()) + MENU_COPY: + var copied: Dictionary = copy_shape(shape_idx) + if not copied.is_empty(): + shape_copy_requested.emit(copied) + MENU_PASTE: + _paste_from_context() + MENU_DELETE: + if shape_idx >= 0 and shape_idx < shapes.size(): + shapes.remove_at(shape_idx) + _selected_shape_idx = clampi(shape_idx - 1, -1, shapes.size() - 1) + queue_redraw() + _emit_changed() + MENU_MIRROR_X: + mirror_x(shape_idx) + MENU_MIRROR_Y: + mirror_y(shape_idx) + MENU_BRING_FORWARD: + bring_forward(shape_idx) + MENU_SEND_BACK: + send_back(shape_idx) + MENU_ADD_BOX: + add_primitive("box", _get_context_world_pos(), _add_params()) + MENU_ADD_PLANK: + add_primitive("plank", _get_context_world_pos(), _add_params()) + MENU_ADD_TRIANGLE: + add_primitive("triangle", _get_context_world_pos(), _add_params()) + MENU_ADD_LINE: + add_primitive("line", _get_context_world_pos(), _add_params()) + MENU_ADD_CIRCLE: + add_primitive("circle", _get_context_world_pos(), _add_params()) + + +## Overridable hook so a subclass can inject default colors and dimensions +## into context-menu additions. +func _add_params() -> Dictionary: + return {} + + +func _paste_from_context() -> void: + paste_from_context_requested.emit(_get_context_world_pos()) + + +## Insert a new USER_CREATED vertex at the cursor's projection onto the nearest +## segment of the selected shape. Emits vertex_created and flashes the new +## vertex (FLASH_DURATION). Returns the new vertex index, or -1 on failure. +func create_point_on_edge(world_pos: Vector2) -> int: + if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size(): + return -1 + var sd: Dictionary = shapes[_selected_shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return -1 + var closed: bool = bool(sd.get("closed", false)) + var best_edge: int = 0 + var best_dist: float = INF + for i: int in pts.size() - 1: + var seg_dist: float = _point_to_segment_distance(world_pos, pts[i], pts[i + 1]) + if seg_dist < best_dist: + best_dist = seg_dist + best_edge = i + if closed and pts.size() >= 2: + var seg_dist: float = _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0]) + if seg_dist < best_dist: + best_dist = seg_dist + best_edge = pts.size() - 1 + var projection: Vector2 = _project_point_onto_segment(world_pos, pts[best_edge], pts[(best_edge + 1) % pts.size()]) + var insert_idx: int = best_edge + 1 + if closed and best_edge == pts.size() - 1: + insert_idx = 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 + queue_redraw() + return insert_idx + + +func _project_point_onto_segment(point: Vector2, a: Vector2, b: Vector2) -> Vector2: + var ab: Vector2 = b - a + var ap: Vector2 = point - a + var ab_len_sq: float = ab.length_squared() + if ab_len_sq < 0.0001: + return a + var t: float = clampf(ap.dot(ab) / ab_len_sq, 0.0, 1.0) + return a + ab * t + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +func _emit_changed() -> void: + shape_changed.emit(get_shape_data()) + + +func _screen_to_world(pos: Vector2) -> Vector2: + return (pos - _pan_offset) / _zoom + + +func _snap_to_grid(pos: Vector2) -> Vector2: + var gs := float(_grid_size) + if gs <= 0.0: + return pos + return Vector2(round(pos.x / gs) * gs, round(pos.y / gs) * gs) + + +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, + # so there is 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 + + +func _make_shape_dict() -> Dictionary: + return { + "shape_type": "polygon", + "points": PackedVector2Array(), + "radius": DEFAULT_CIRCLE_RADIUS, + "fill_color": DEFAULT_FILL_HEX, + "outline_color": DEFAULT_OUTLINE_HEX, + "outline_width": DEFAULT_OUTLINE_WIDTH, + "closed": true, + "vertex_flags": PackedInt32Array(), + "reshaped": false, + } + + +func _zero_flags(count: int) -> PackedInt32Array: + var flags := PackedInt32Array() + flags.resize(count) + return flags + + +func _circle_points(center: Vector2, 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(center + Vector2(cos(angle), sin(angle)) * radius) + return pts + + +func _shape_bbox(sd: Dictionary) -> Rect2: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return Rect2() + var bbox := Rect2(pts[0], Vector2.ZERO) + for pt: Vector2 in pts: + bbox = bbox.expand(pt) + return bbox + + +func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int: + if shape_idx < 0 or shape_idx >= shapes.size(): + return -1 + var pts: PackedVector2Array = shapes[shape_idx].get("points", PackedVector2Array()) + var threshold: float = HANDLE_RADIUS_SELECTION / _zoom + for i: int in pts.size(): + if world_pos.distance_to(pts[i]) <= threshold: + return i + return -1 + + +## Returns the frontmost shape whose segment is within EDGE_HIT_THRESHOLD / +## _zoom of world_pos, else -1. Used by the POINTS-mode right-click menu. +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 + + +func _is_point_in_shape(shape_idx: int, world_pos: Vector2) -> bool: + if shape_idx < 0 or shape_idx >= shapes.size(): + return false + var sd: Dictionary = shapes[shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return false + var closed: bool = bool(sd.get("closed", false)) + if closed: + return Geometry2D.is_point_in_polygon(world_pos, pts) + return _distance_to_shape_pts(pts, closed, world_pos) <= EDGE_HIT_THRESHOLD / _zoom + + +func _is_point_near_outline(shape_idx: int, world_pos: Vector2) -> bool: + if shape_idx < 0 or shape_idx >= shapes.size(): + return false + var sd: Dictionary = shapes[shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.size() < 2: + return false + return _distance_to_shape_pts(pts, bool(sd.get("closed", false)), world_pos) <= EDGE_HIT_THRESHOLD / _zoom + + +func _distance_to_shape_pts(pts: PackedVector2Array, closed: bool, world_pos: Vector2) -> float: + var min_dist: float = INF + for i: int in pts.size() - 1: + min_dist = minf(min_dist, _point_to_segment_distance(world_pos, pts[i], pts[i + 1])) + if closed and pts.size() >= 2: + min_dist = minf(min_dist, _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0])) + return min_dist + + +func _point_to_segment_distance(point: Vector2, a: Vector2, b: Vector2) -> float: + var ab: Vector2 = b - a + var ap: Vector2 = point - a + var ab_len_sq: float = ab.length_squared() + if ab_len_sq < 0.0001: + return ap.length() + var t: float = clampf(ap.dot(ab) / ab_len_sq, 0.0, 1.0) + return point.distance_to(a + ab * t) + + +func _hit_test_rotation_dot(sd: Dictionary, world_pos: Vector2) -> bool: + var bbox: Rect2 = _shape_bbox(sd) + if bbox.size == Vector2.ZERO: + return false + var rot_center := Vector2(bbox.position.x + bbox.size.x * 0.5, bbox.end.y + ROTATION_CIRCLE_OFFSET) + return world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom + + +func _hit_test_scale_corner(sd: Dictionary, world_pos: Vector2) -> int: + var bbox: Rect2 = _shape_bbox(sd) + if bbox.size == Vector2.ZERO: + return -1 + var corners: Array[Vector2] = [ + bbox.position, + Vector2(bbox.end.x, bbox.position.y), + bbox.end, + Vector2(bbox.position.x, bbox.end.y), + ] + var threshold: float = SCALE_CROSS_HIT_RADIUS / _zoom + for i: int in corners.size(): + if world_pos.distance_to(corners[i]) <= threshold: + return i + return -1 + + +func _apply_point_transform(translate: Vector2, angle: float, factor: Vector2, pivot: Vector2) -> void: + if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size(): + return + var sd: Dictionary = shapes[_selected_shape_idx] + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return + # Non-uniform scaling deforms a circle; its radius field no longer describes + # the shape, so mark it reshaped (Round 6). + if str(sd.get("shape_type", "")) == "circle" and absf(factor.x - factor.y) > 0.0001: + sd["reshaped"] = true + var center: Vector2 = pivot if pivot != Vector2.ZERO else _shape_bbox(sd).get_center() + var new_pts := PackedVector2Array() + for pt: Vector2 in pts: + var p: Vector2 = center + (pt - center).rotated(angle) + p = center + Vector2((p.x - center.x) * factor.x, (p.y - center.y) * factor.y) + new_pts.append(p + translate) + sd["points"] = new_pts + queue_redraw() + _emit_changed() + + +func _coerce_shape(item: Dictionary) -> Dictionary: + var shape_type: String = str(item.get("shape_type", "polygon")) + if not PropFileScript.SHAPE_STRINGS.has(shape_type): + shape_type = "polygon" + var pts := PackedVector2Array() + var pts_variant: Variant = item.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 radius: float = float(item.get("radius", DEFAULT_CIRCLE_RADIUS)) + if shape_type == "circle" and pts.is_empty(): + pts = _circle_points(Vector2.ZERO, radius, DEFAULT_CIRCLE_SEGMENTS) + var closed: bool + if item.has("closed"): + closed = bool(item.get("closed", false)) + else: + closed = shape_type != "line" + var vf := PackedInt32Array() + var vf_variant: Variant = item.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 = _zero_flags(pts.size()) + return { + "shape_type": shape_type, + "points": pts, + "radius": radius, + "fill_color": str(item.get("fill_color", DEFAULT_FILL_HEX)), + "outline_color": str(item.get("outline_color", DEFAULT_OUTLINE_HEX)), + "outline_width": float(item.get("outline_width", DEFAULT_OUTLINE_WIDTH)), + "closed": closed, + "vertex_flags": vf, + "reshaped": bool(item.get("reshaped", false)), + } + + +func _export_shape(sd: Dictionary) -> Dictionary: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + var pts_arr: Array = [] + for pt: Vector2 in pts: + pts_arr.append({"x": pt.x, "y": pt.y}) + var flags: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) + var flags_arr: Array = [] + for i: int in flags.size(): + flags_arr.append(flags[i]) + var result := { + "shape_type": str(sd.get("shape_type", "polygon")), + "points": pts_arr, + "fill_color": str(sd.get("fill_color", DEFAULT_FILL_HEX)), + "outline_color": str(sd.get("outline_color", DEFAULT_OUTLINE_HEX)), + "outline_width": float(sd.get("outline_width", DEFAULT_OUTLINE_WIDTH)), + "closed": bool(sd.get("closed", false)), + "vertex_flags": flags_arr, + "reshaped": bool(sd.get("reshaped", false)), + } + if str(sd.get("shape_type", "")) == "circle": + var radius: float = float(sd.get("radius", DEFAULT_CIRCLE_RADIUS)) + if not pts.is_empty(): + radius = _recompute_radius(sd) + result["radius"] = radius + return result + + +func _recompute_radius(sd: Dictionary) -> float: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + if pts.is_empty(): + return float(sd.get("radius", DEFAULT_CIRCLE_RADIUS)) + var centroid := Vector2.ZERO + for pt: Vector2 in pts: + centroid += pt + centroid /= float(pts.size()) + var total: float = 0.0 + for pt: Vector2 in pts: + total += centroid.distance_to(pt) + return maxf(total / float(pts.size()), 0.001) diff --git a/scripts/prop_canvas.gd.uid b/scripts/prop_canvas.gd.uid new file mode 100644 index 0000000..84aa6e9 --- /dev/null +++ b/scripts/prop_canvas.gd.uid @@ -0,0 +1 @@ +uid://lophq30fv72p diff --git a/scripts/prop_editor.gd b/scripts/prop_editor.gd new file mode 100644 index 0000000..2716ad6 --- /dev/null +++ b/scripts/prop_editor.gd @@ -0,0 +1,1061 @@ +class_name PropEditor +extends Control +## PropEditor - Standalone vector prop authoring tool (Phase 9). +## +## Mirrors the stickman editor's controller conventions (menu bar, +## save/load/clear, JSON (de)serialization, status bar) around a PropCanvas. +## Owns the material-preset inspector (physics readouts from PropBlock's +## static preset tables), per-shape fill/outline/width controls (live sync), +## primitive creation with theme-driven defaults, a local inspector Theme +## (never mutates global GUI settings), the shape clipboard, and the +## save-side thumbnail pipeline (PropThumbnail + ThumbnailCache). + +const PropFileScript := preload("res://scripts/prop_file.gd") +const PropThemeScript := preload("res://scripts/prop_theme.gd") +const PropBlockScript := preload("res://scripts/prop_block.gd") +const PropCanvasScript := preload("res://scripts/prop_canvas.gd") +const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd") +const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.gd") + +const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"] +const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"] +const PRIMITIVE_LABELS: PackedStringArray = ["Box", "Plank", "Triangle", "Line", "Circle"] + +# Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode. +const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"] +const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"] + +const UNTITLED_NAME: String = "Untitled" +const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop" +const AUTOSAVE_DEBOUNCE: float = 1.0 +const SETTINGS_PATH: String = "user://prop_editor_settings.json" +const RECENT_MAX: int = 5 + +const MENU_NEW := 0 +const MENU_LOAD := 2 +const MENU_SAVE := 3 +const MENU_SAVE_AS := 4 +const MENU_RESET_VIEWS := 20 + + +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]] + + +## Pure window-title builder: [Prop name][filename.prop][*]. +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() + if dirty: + title += " *" + return title + + +## Most-recent-first dedupe + front-insert + trim (headless-testable). +static func update_recent_list(list: Array, path: String, max_count: int = RECENT_MAX) -> Array: + var result: Array = [] + result.append(path) + for entry: Variant in list: + if str(entry) != path: + result.append(str(entry)) + if result.size() > max_count: + result.resize(max_count) + return result + +# --------------------------------------------------------------------------- +# On-ready node references +# --------------------------------------------------------------------------- + +@onready var _menu_bar: MenuBar = %MenuBar +@onready var _top_right: HBoxContainer = %TopRightControls +@onready var _prop_name_edit: LineEdit = %PropNameEdit +@onready var _canvas: PropCanvas = %PropCanvas +@onready var _mode_bar: HBoxContainer = %ModeBar +@onready var _inspector_list: VBoxContainer = %InspectorList +@onready var _save_dialog: FileDialog = %SaveDialog +@onready var _load_dialog: FileDialog = %LoadDialog +@onready var _error_dialog: AcceptDialog = %ErrorDialog +@onready var _cursor_coords: Label = %CursorCoords +@onready var _save_status: Label = %SaveStatus + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var _theme: PropTheme +var _material_preset: int = PropBlockScript.MaterialPreset.NONE +var _current_path: String = "" +var _dirty: bool = false +var _clipboard: Dictionary = {} +var _thumb: PropThumbnail +var _thumb_cache: ThumbnailCache +var _ui_theme: Theme + +# Inspector controls +var _preset_option: OptionButton +var _mass_label: Label +var _friction_label: Label +var _bounce_label: Label +var _fill_button: ColorPickerButton +var _outline_button: ColorPickerButton +var _width_spin: SpinBox +var _radius_spin: SpinBox +var _radius_row: Control +var _reshaped_note: Label +var _vertex_label: Label +var _delete_button: Button +var _shape_section: VBoxContainer +var _primitive_option: OptionButton +var _params_box: VBoxContainer +var _param_spins: Dictionary = {} +var _add_button: Button +var _updating_inspector: bool = false + +# Grid settings dialog (removed Round 4 - replaced by top-right controls) +var _grid_spin: SpinBox +var _snap_check: CheckBox + +# Edit-mode toolbar (Round 2/3) +var _mode_group: ButtonGroup +var _move_button: Button +var _points_button: Button + +# Add-shape inspector section (hidden in Reshape mode, Round 3) +var _add_section: VBoxContainer + +# Exit/new dialog + autosave (Round 3/4) +var _exit_dialog: ConfirmationDialog +var _pending_quit: bool = false +var _pending_new: bool = false +var _autosave_remaining: float = 0.0 + +# Recent props (Round 5) +var _recent_props: Array[String] = [] + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + get_tree().auto_accept_quit = false + _theme = PropThemeScript.new() + _theme.load_from(PropThemeScript.DEFAULT_PATH) + _thumb_cache = ThumbnailCacheScript.new() + _load_settings() + + _thumb = PropThumbnailScript.new() + add_child(_thumb) + + _build_control_theme() + _setup_menu_bar() + _build_mode_toolbar() + _build_top_right_controls() + _build_inspector() + _setup_dialogs() + + _canvas.shape_changed.connect(_on_canvas_shape_changed) + _canvas.shape_selected.connect(_on_canvas_shape_selected) + _canvas.shape_copy_requested.connect(_on_canvas_copy_requested) + _canvas.paste_from_context_requested.connect(_on_canvas_paste_requested) + _canvas.mode_changed.connect(_on_canvas_mode_changed) + _canvas.set_grid_size(_theme.grid_size()) + _canvas.set_snap_enabled(false) + _canvas.set_has_clipboard(false) + _canvas.set_context_menu_font_size(_theme.font_context_menu()) + + _prop_name_edit.text_changed.connect(_on_name_changed) + _prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name()) + _cursor_coords.add_theme_font_size_override("font_size", _theme.font_status()) + _save_status.add_theme_font_size_override("font_size", _theme.font_status()) + + _mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER + + _refresh_inspector_from_selection() + _sync_mode_buttons(_canvas.get_edit_mode()) + _sync_mode_controls(_canvas.get_edit_mode()) + _update_save_status() + _update_cursor_coords() + _check_autosave_recovery() + + +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 _unhandled_key_input(event: InputEvent) -> void: + if event is InputEventKey: + var key := event as InputEventKey + if key.pressed and not key.echo and (key.keycode == KEY_DELETE or key.keycode == KEY_BACKSPACE): + var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null + if focus_owner == null or focus_owner is Label: + if _canvas.get_selected_index() >= 0: + _canvas.delete_selected() + + +func _update_cursor_coords() -> void: + if _canvas != null and _canvas.is_cursor_over_drawing(get_global_mouse_position()): + var world_pos := _canvas.global_to_world(get_global_mouse_position()) + _cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)] + else: + _cursor_coords.text = "X: - Y: -" + + +func _update_save_status() -> void: + var name: String = _prop_name_edit.text if _prop_name_edit != null else "" + if name.is_empty(): + name = "Unnamed Prop" + var suffix: String = " • Modified" if _dirty else "" + _save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix] + _update_window_title() + + +func _update_window_title() -> void: + if not is_inside_tree(): + return + get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty) + + +# --------------------------------------------------------------------------- +# Edit-mode toolbar (Round 2) +# --------------------------------------------------------------------------- + +func _build_mode_toolbar() -> void: + _mode_group = ButtonGroup.new() + _move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM) + _points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS) + _move_button.pressed.connect(_on_move_mode_pressed) + _points_button.pressed.connect(_on_points_mode_pressed) + _move_button.theme = _ui_theme + _points_button.theme = _ui_theme + + +func _make_mode_button(mode: int) -> Button: + var button := Button.new() + button.text = mode_button_text(mode) + button.toggle_mode = true + button.button_group = _mode_group + button.custom_minimum_size.y = float(_theme.mode_button_min_height()) + button.add_theme_font_size_override("font_size", _theme.font_mode()) + # No keyboard focus: the focus stylebox otherwise draws over the pressed + # stylebox, hiding the selected (accent-bordered) state until the cursor + # moves away (Round 4 fix). + button.focus_mode = Control.FOCUS_NONE + _mode_bar.add_child(button) + return button + + +func _on_move_mode_pressed() -> void: + _canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM) + + +func _on_points_mode_pressed() -> void: + _canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS) + + +func _on_canvas_mode_changed(mode: int) -> void: + _sync_mode_buttons(mode) + _sync_mode_controls(mode) + + +func _sync_mode_buttons(mode: int) -> void: + if _move_button == null or _points_button == null: + return + _move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM) + _points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS) + var accent: Color = _theme.accent_color() + _move_button.add_theme_color_override("font_pressed_color", accent) + _points_button.add_theme_color_override("font_pressed_color", accent) + + +func _sync_mode_controls(mode: int) -> void: + if _add_section != null: + _add_section.visible = mode != PropCanvasScript.EditMode.POINTS + if _delete_button != null: + _delete_button.visible = mode != PropCanvasScript.EditMode.POINTS + + +# --------------------------------------------------------------------------- +# Theme (Task 1.3 - local inspector theme, no global mutation) +# --------------------------------------------------------------------------- + +func _build_control_theme() -> void: + _ui_theme = Theme.new() + var bg: Color = _theme.canvas_background_color() + var panel := StyleBoxFlat.new() + panel.bg_color = Color(0.12, 0.12, 0.12, 1.0) + panel.set_corner_radius_all(6) + panel.content_margin_left = 8 + panel.content_margin_right = 8 + panel.content_margin_top = 6 + panel.content_margin_bottom = 6 + panel.border_color = Color(1.0, 1.0, 1.0, 0.12) + panel.set_border_width_all(1) + var panel_dark := StyleBoxFlat.new() + panel_dark.bg_color = bg.darkened(0.25) + panel_dark.set_corner_radius_all(6) + panel_dark.content_margin_left = 8 + panel_dark.content_margin_right = 8 + panel_dark.content_margin_top = 6 + panel_dark.content_margin_bottom = 6 + panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12) + panel_dark.set_border_width_all(1) + var panel_pressed := StyleBoxFlat.new() + panel_pressed.bg_color = bg.darkened(0.25) + panel_pressed.set_corner_radius_all(6) + panel_pressed.content_margin_left = 8 + panel_pressed.content_margin_right = 8 + panel_pressed.content_margin_top = 6 + panel_pressed.content_margin_bottom = 6 + panel_pressed.border_color = _theme.accent_color() + panel_pressed.set_border_width_all(2) + _ui_theme.set_stylebox("normal", "Button", panel) + _ui_theme.set_stylebox("hover", "Button", panel_dark) + _ui_theme.set_stylebox("pressed", "Button", panel_pressed) + _ui_theme.set_stylebox("focus", "Button", panel) + _ui_theme.set_stylebox("normal", "OptionButton", panel) + _ui_theme.set_stylebox("hover", "OptionButton", panel_dark) + _ui_theme.set_stylebox("pressed", "OptionButton", panel_dark) + _ui_theme.set_stylebox("normal", "SpinBox", panel_dark) + _ui_theme.set_stylebox("normal", "LineEdit", panel_dark) + _ui_theme.set_stylebox("normal", "ColorPickerButton", panel) + _ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark) + _ui_theme.set_stylebox("normal", "PopupMenu", panel_dark) + _ui_theme.set_stylebox("panel", "MenuBar", panel_dark) + _ui_theme.set_stylebox("normal", "CheckBox", panel) + _ui_theme.set_stylebox("panel", "PanelContainer", panel_dark) + _ui_theme.set_color("font_color", "Label", _theme.text_color()) + _ui_theme.set_color("font_color", "Button", _theme.text_color()) + _ui_theme.set_color("font_hover_color", "Button", Color.WHITE) + _ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color()) + _ui_theme.set_font_size("font_size", "Label", _theme.font_body()) + _ui_theme.set_font_size("font_size", "Button", _theme.font_body()) + _ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body()) + _ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body()) + _ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body()) + _ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu()) + _ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar()) + _menu_bar.theme = _ui_theme + _top_right.theme = _ui_theme + _prop_name_edit.theme = _ui_theme + _save_dialog.theme = _ui_theme + _load_dialog.theme = _ui_theme + _error_dialog.theme = _ui_theme + + +# --------------------------------------------------------------------------- +# Menu & dialogs +# --------------------------------------------------------------------------- + +func _setup_menu_bar() -> void: + var file_menu := PopupMenu.new() + file_menu.name = "FileMenu" + file_menu.add_item("🆕 New", MENU_NEW) + 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() + var recent_popup := PopupMenu.new() + recent_popup.name = "RecentProps" + recent_popup.id_pressed.connect(_on_recent_prop_selected) + file_menu.add_child(recent_popup) + file_menu.add_submenu_item("🕑 Recent", "RecentProps") + file_menu.id_pressed.connect(_on_menu_id_pressed) + file_menu.about_to_popup.connect(_update_recent_menu) + _menu_bar.add_child(file_menu) + _menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "📁 File") + + var view_menu := PopupMenu.new() + view_menu.name = "ViewMenu" + view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS) + view_menu.id_pressed.connect(_on_menu_id_pressed) + _menu_bar.add_child(view_menu) + _menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "👁️ View") + + +# --------------------------------------------------------------------------- +# Recent props (Round 5, mirrors the sandbox recent-scenes menu) +# --------------------------------------------------------------------------- + +func _update_recent_menu() -> void: + var recent: PopupMenu = _menu_bar.get_node_or_null(NodePath("FileMenu/RecentProps")) as PopupMenu + if recent == null: + return + recent.clear() + var existing: Array[String] = [] + for path: String in _recent_props: + if FileAccess.file_exists(path): + existing.append(path) + if existing.size() != _recent_props.size(): + _recent_props = existing + _save_settings() + if _recent_props.is_empty(): + recent.add_item("(No recent props)", -1) + recent.set_item_disabled(0, true) + else: + for index: int in _recent_props.size(): + recent.add_item(_recent_props[index], index) + + +func _on_recent_prop_selected(index: int) -> void: + if index < 0 or index >= _recent_props.size(): + return + var path := _recent_props[index] + if not FileAccess.file_exists(path): + _recent_props.remove_at(index) + _save_settings() + _show_error("File not found: %s" % path) + return + _load_from(path) + + +func _push_recent(path: String) -> void: + var updated: Array = update_recent_list(_recent_props, path, RECENT_MAX) + _recent_props.clear() + for entry: Variant in updated: + _recent_props.append(str(entry)) + _save_settings() + + +func _load_settings() -> void: + _recent_props.clear() + if not FileAccess.file_exists(SETTINGS_PATH): + return + var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ) + if file == null: + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not parsed is Dictionary: + return + var raw_list: Variant = (parsed as Dictionary).get("recent_props", []) + if raw_list is Array: + for entry: Variant in raw_list as Array: + if entry is String and not (entry as String).is_empty(): + _recent_props.append(entry as String) + + +func _save_settings() -> void: + var data := {"version": 1, "recent_props": _recent_props} + var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) + if file == null: + return + file.store_string(JSON.stringify(data)) + file.close() + + +func _setup_dialogs() -> void: + _save_dialog.file_selected.connect(_on_save_file_selected) + _load_dialog.file_selected.connect(_on_load_file_selected) + _build_exit_dialog() + + +func _build_top_right_controls() -> void: + # Grid size + snap, right side of the menu bar row (sandbox-style). + var grid_label := Label.new() + grid_label.text = "Grid:" + grid_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _top_right.add_child(grid_label) + _grid_spin = SpinBox.new() + _grid_spin.min_value = 1 + _grid_spin.max_value = 512 + _grid_spin.value = _theme.grid_size() + _grid_spin.custom_minimum_size.x = 90.0 + _grid_spin.value_changed.connect(_on_top_grid_changed) + _top_right.add_child(_grid_spin) + _snap_check = CheckBox.new() + _snap_check.text = "Snap" + _snap_check.button_pressed = false + _snap_check.toggled.connect(_on_top_snap_toggled) + _top_right.add_child(_snap_check) + + +func _on_top_grid_changed(value: float) -> void: + _canvas.set_grid_size(int(value)) + + +func _on_top_snap_toggled(pressed: bool) -> void: + _canvas.set_snap_enabled(pressed) + + +func _build_exit_dialog() -> void: + _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" + _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 + _pending_new = false + _exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?" + _exit_dialog.popup_centered() + + +func _request_new() -> void: + if not _dirty: + _do_new() + return + _pending_new = true + _pending_quit = false + _exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?" + _exit_dialog.popup_centered() + + +func _on_exit_save() -> void: + _exit_dialog.hide() + if _current_path.is_empty(): + _save_dialog.popup_centered() + return + await _save_and_finish(_current_path) + + +func _save_and_finish(path: String) -> void: + var ok: bool = await _save_to(path, false) + if not ok: + return + if _pending_quit: + _pending_quit = false + get_tree().quit() + elif _pending_new: + _pending_new = false + _do_new() + + +func _on_exit_custom_action(action: StringName) -> void: + if action == &"discard": + _exit_dialog.hide() + _clear_autosave() + if _pending_quit: + _pending_quit = false + get_tree().quit() + elif _pending_new: + _pending_new = false + _do_new() + + +func _on_exit_cancel() -> void: + _pending_quit = false + _pending_new = false + + +func _do_new() -> void: + _prop_name_edit.text = "" + _material_preset = PropBlockScript.MaterialPreset.NONE + _preset_option.selected = _material_preset + _update_physics_labels() + _canvas.clear() + _clipboard = {} + _canvas.set_has_clipboard(false) + _current_path = "" + _dirty = false + _clear_autosave() + _refresh_inspector_from_selection() + _update_save_status() + + +func _on_menu_id_pressed(id: int) -> void: + match id: + MENU_NEW: + _request_new() + MENU_LOAD: + _load_dialog.popup_centered() + MENU_SAVE: + if _current_path.is_empty(): + _save_dialog.popup_centered() + else: + await _save_to(_current_path) + MENU_SAVE_AS: + _save_dialog.popup_centered() + MENU_RESET_VIEWS: + _canvas.reset_view() + + +# --------------------------------------------------------------------------- +# Inspector (Tasks 1.3 / 3.1 / 3.2 / 2.1) +# --------------------------------------------------------------------------- + +func _build_inspector() -> void: + var header_size: int = _theme.font_header() + var row_height: int = _theme.row_min_height() + + # Material preset section + var material_title := Label.new() + material_title.text = "Material" + material_title.add_theme_font_size_override("font_size", header_size) + _inspector_list.add_child(material_title) + + _preset_option = OptionButton.new() + for label: String in PRESET_LABELS: + _preset_option.add_item(label) + _preset_option.selected = _material_preset + _preset_option.custom_minimum_size.y = float(_theme.button_min_height()) + _preset_option.item_selected.connect(_on_preset_selected) + _inspector_list.add_child(_preset_option) + + _mass_label = Label.new() + _friction_label = Label.new() + _bounce_label = Label.new() + _inspector_list.add_child(_mass_label) + _inspector_list.add_child(_friction_label) + _inspector_list.add_child(_bounce_label) + _update_physics_labels() + + # Selected-shape section + _shape_section = VBoxContainer.new() + _shape_section.add_theme_constant_override("separation", 6) + _inspector_list.add_child(_shape_section) + + var shape_title := Label.new() + shape_title.text = "Selected Shape" + shape_title.add_theme_font_size_override("font_size", header_size) + _shape_section.add_child(shape_title) + + var fill_row := HBoxContainer.new() + fill_row.custom_minimum_size.y = float(row_height) + _shape_section.add_child(fill_row) + var fill_label := Label.new() + fill_label.text = "Fill:" + fill_label.custom_minimum_size = Vector2(90.0, 0.0) + fill_row.add_child(fill_label) + _fill_button = ColorPickerButton.new() + _fill_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _fill_button.custom_minimum_size.y = float(_theme.button_min_height()) + _fill_button.color_changed.connect(_on_fill_color_changed) + fill_row.add_child(_fill_button) + + var outline_row := HBoxContainer.new() + outline_row.custom_minimum_size.y = float(row_height) + _shape_section.add_child(outline_row) + var outline_label := Label.new() + outline_label.text = "Outline:" + outline_label.custom_minimum_size = Vector2(90.0, 0.0) + outline_row.add_child(outline_label) + _outline_button = ColorPickerButton.new() + _outline_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _outline_button.custom_minimum_size.y = float(_theme.button_min_height()) + _outline_button.color_changed.connect(_on_outline_color_changed) + outline_row.add_child(_outline_button) + + var width_row := HBoxContainer.new() + width_row.custom_minimum_size.y = float(row_height) + _shape_section.add_child(width_row) + var width_label := Label.new() + width_label.text = "Width:" + width_label.custom_minimum_size = Vector2(90.0, 0.0) + width_row.add_child(width_label) + _width_spin = SpinBox.new() + _width_spin.min_value = _theme.outline_width_min() + _width_spin.max_value = _theme.outline_width_max() + _width_spin.step = 0.5 + _width_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _width_spin.custom_minimum_size.y = float(_theme.button_min_height()) + _width_spin.value_changed.connect(_on_width_changed) + width_row.add_child(_width_spin) + + _radius_row = HBoxContainer.new() + _radius_row.custom_minimum_size.y = float(row_height) + _shape_section.add_child(_radius_row) + var radius_label := Label.new() + radius_label.text = "Radius:" + radius_label.custom_minimum_size = Vector2(90.0, 0.0) + _radius_row.add_child(radius_label) + _radius_spin = SpinBox.new() + _radius_spin.min_value = 1.0 + _radius_spin.max_value = 1000.0 + _radius_spin.step = 1.0 + _radius_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _radius_spin.custom_minimum_size.y = float(_theme.button_min_height()) + _radius_spin.value_changed.connect(_on_radius_changed) + _radius_row.add_child(_radius_spin) + + _reshaped_note = Label.new() + _reshaped_note.text = "✏️ This shape was reshaped" + _reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color()) + _shape_section.add_child(_reshaped_note) + + _vertex_label = Label.new() + _shape_section.add_child(_vertex_label) + + _delete_button = Button.new() + _delete_button.text = "Erase Shape" + _delete_button.custom_minimum_size.y = float(_theme.button_min_height()) + _delete_button.pressed.connect(_on_delete_shape_pressed) + _shape_section.add_child(_delete_button) + + # Add-shape section (hidden in Reshape mode, Round 3) + _add_section = VBoxContainer.new() + _add_section.add_theme_constant_override("separation", 8) + _inspector_list.add_child(_add_section) + + var add_title := Label.new() + add_title.text = "Add Shape" + add_title.add_theme_font_size_override("font_size", header_size) + _add_section.add_child(add_title) + + _primitive_option = OptionButton.new() + for label: String in PRIMITIVE_LABELS: + _primitive_option.add_item(label) + _primitive_option.custom_minimum_size.y = float(_theme.button_min_height()) + _primitive_option.item_selected.connect(_on_primitive_selected) + _add_section.add_child(_primitive_option) + + _params_box = VBoxContainer.new() + _params_box.add_theme_constant_override("separation", 4) + _add_section.add_child(_params_box) + + _add_button = Button.new() + _add_button.text = "Add a Shape" + _add_button.custom_minimum_size.y = float(_theme.button_min_height()) + _add_button.pressed.connect(_on_add_pressed) + _add_section.add_child(_add_button) + + _apply_control_theme(_inspector_list) + _rebuild_primitive_params() + + +func _apply_control_theme(node: Node) -> void: + if node is Control: + (node as Control).theme = _ui_theme + for child: Node in node.get_children(): + _apply_control_theme(child) + + +func _update_physics_labels() -> void: + var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset) + _mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset) + _friction_label.text = "Friction: %.2f" % mat.friction + _bounce_label.text = "Bounce: %.2f" % mat.bounce + + +func _on_preset_selected(index: int) -> void: + _material_preset = clampi(index, 0, PRESET_LABELS.size() - 1) + _update_physics_labels() + _update_save_status() + + +func _rebuild_primitive_params() -> void: + for child: Node in _params_box.get_children(): + child.queue_free() + _param_spins.clear() + var primitive: String = PRIMITIVE_IDS[_primitive_option.selected] + match primitive: + "box": + _add_param_spin("Size X", _theme.primitive_default("box", "size_x"), 1.0, 2000.0, "size_x") + _add_param_spin("Size Y", _theme.primitive_default("box", "size_y"), 1.0, 2000.0, "size_y") + "ball": + _add_param_spin("Radius", _theme.primitive_default("ball", "radius"), 1.0, 1000.0, "radius") + "plank": + _add_param_spin("Length", _theme.primitive_default("plank", "length"), 1.0, 4000.0, "length") + _add_param_spin("Thickness", _theme.primitive_default("plank", "thickness"), 1.0, 1000.0, "thickness") + "triangle": + _add_param_spin("Base", _theme.primitive_default("triangle", "base"), 1.0, 2000.0, "base") + _add_param_spin("Height", _theme.primitive_default("triangle", "height"), 1.0, 2000.0, "height") + "line": + _add_param_spin("Length", _theme.primitive_default("line", "length"), 1.0, 4000.0, "length") + "circle": + _add_param_spin("Radius", _theme.primitive_default("circle", "radius"), 1.0, 1000.0, "radius") + + +func _add_param_spin(label_text: String, default_value: float, minimum: float, maximum: float, key: String) -> void: + var row := HBoxContainer.new() + row.custom_minimum_size.y = float(_theme.row_min_height()) + _params_box.add_child(row) + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(90.0, 0.0) + row.add_child(label) + var spin := SpinBox.new() + spin.min_value = minimum + spin.max_value = maximum + spin.value = default_value + spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spin.custom_minimum_size.y = float(_theme.button_min_height()) + row.add_child(spin) + _param_spins[key] = spin + + +func _on_primitive_selected(_index: int) -> void: + _rebuild_primitive_params() + + +func _on_add_pressed() -> void: + var primitive: String = PRIMITIVE_IDS[_primitive_option.selected] + var params: Dictionary = {} + for key: String in _param_spins: + params[key] = (_param_spins[key] as SpinBox).value + params["fill_color"] = _theme.material_tint(_material_preset, "fill").to_html() + params["outline_color"] = _theme.material_tint(_material_preset, "outline").to_html() + params["outline_width"] = _theme.outline_width_default() + var center: Vector2 = _canvas.global_to_world(_canvas.get_global_rect().get_center()) + _canvas.add_primitive(primitive, center, params) + _mark_dirty() + + +func _on_fill_color_changed(color: Color) -> void: + if _updating_inspector: + return + var idx := _canvas.get_selected_index() + if idx >= 0: + _canvas.set_shape_fill_color(idx, color.to_html()) + + +func _on_outline_color_changed(color: Color) -> void: + if _updating_inspector: + return + var idx := _canvas.get_selected_index() + if idx >= 0: + _canvas.set_shape_outline_color(idx, color.to_html()) + + +func _on_width_changed(value: float) -> void: + if _updating_inspector: + return + var idx := _canvas.get_selected_index() + if idx >= 0: + _canvas.set_shape_outline_width(idx, value) + + +func _on_radius_changed(value: float) -> void: + if _updating_inspector: + return + var idx := _canvas.get_selected_index() + if idx >= 0: + _canvas.set_shape_radius(idx, value) + + +func _on_delete_shape_pressed() -> void: + _canvas.delete_selected() + + +func _refresh_inspector_from_selection() -> void: + _updating_inspector = true + var idx := _canvas.get_selected_index() + _shape_section.visible = idx >= 0 + if idx >= 0 and idx < _canvas.shapes.size(): + var sd: Dictionary = _canvas.shapes[idx] + _fill_button.color = Color.from_string(str(sd.get("fill_color", "")), Color.GRAY) + _outline_button.color = Color.from_string(str(sd.get("outline_color", "")), Color.WHITE) + _width_spin.value = float(sd.get("outline_width", 2.0)) + var is_circle: bool = str(sd.get("shape_type", "")) == "circle" + var reshaped: bool = bool(sd.get("reshaped", false)) + _radius_row.visible = is_circle and not reshaped + _reshaped_note.visible = reshaped + if is_circle: + var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) + var radius: float = float(sd.get("radius", 24.0)) + if not pts.is_empty(): + var centroid := Vector2.ZERO + for pt: Vector2 in pts: + centroid += pt + centroid /= float(pts.size()) + var total: float = 0.0 + for pt: Vector2 in pts: + total += centroid.distance_to(pt) + radius = maxf(total / float(pts.size()), 0.001) + _radius_spin.value = radius + _vertex_label.text = "Vertices: %d" % (sd.get("points", PackedVector2Array()) as PackedVector2Array).size() + _updating_inspector = false + + +func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void: + _mark_dirty() + _refresh_inspector_from_selection() + + +func _on_canvas_shape_selected(_idx: int) -> void: + _refresh_inspector_from_selection() + + +func _on_canvas_copy_requested(shape: Dictionary) -> void: + _clipboard = shape.duplicate(true) + _canvas.set_has_clipboard(true) + + +func _on_canvas_paste_requested(world_pos: Vector2) -> void: + if _clipboard.is_empty(): + return + _canvas.paste_shape(_clipboard, world_pos) + + +func _on_name_changed(_text: String) -> void: + _mark_dirty() + + +# --------------------------------------------------------------------------- +# Dirty tracking + autosave (Round 3) +# --------------------------------------------------------------------------- + +func _mark_dirty() -> void: + _dirty = true + _update_save_status() + _autosave_remaining = AUTOSAVE_DEBOUNCE + + +func _write_autosave() -> void: + if not _dirty: + return + var data: Dictionary = _build_json_data() + var meta: Dictionary = data.get("metadata", {}) + meta["autosave"] = true + meta["source_path"] = _current_path + meta["modified_unix"] = Time.get_unix_time_from_system() + data["metadata"] = meta + PropFileScript.save_safe(AUTOSAVE_PATH, data) + + +func _clear_autosave() -> void: + _autosave_remaining = 0.0 + if FileAccess.file_exists(AUTOSAVE_PATH): + DirAccess.remove_absolute(AUTOSAVE_PATH) + + +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() + + +# --------------------------------------------------------------------------- +# Save / load / clear +# --------------------------------------------------------------------------- + +func _build_json_data() -> Dictionary: + return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data()) + + +func _apply_json_data(json: Variant, is_recovery: bool = false) -> String: + var model: Dictionary = PropFileScript.from_file_dict(json) + if model.is_empty(): + return "The file could not be read: unsupported version or malformed data." + _prop_name_edit.text = str(model.get("prop_name", "")) + _material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE)) + if _preset_option != null: + _preset_option.selected = _material_preset + _update_physics_labels() + var shapes: Array = model.get("shapes", []) + _canvas.set_shape_data(shapes) + _dirty = is_recovery + _autosave_remaining = 0.0 + _refresh_inspector_from_selection() + _update_save_status() + return "" + + +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() + _push_recent(path) + _update_save_status() + if with_thumbnail: + await _save_prop_thumbnail() + return true + + +func _save_prop_thumbnail() -> void: + if _thumb == null or _current_path.is_empty(): + return + var payload: Dictionary = PropFileScript.flatten_to_payload(_canvas.get_shape_data()) + if payload.is_empty(): + return + var tex: Texture2D = await _thumb.render(payload, _material_preset) + if tex == null: + return + _thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path)) + + +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 or _pending_new)) + if ok and _pending_quit: + _pending_quit = false + get_tree().quit() + elif ok and _pending_new: + _pending_new = false + _do_new() + + +func _load_from(path: String) -> void: + var raw: Dictionary = PropFileScript.load(path) + if raw.is_empty(): + _show_error("Could not load '%s'. The file is missing or not valid JSON." % path) + return + var error_text: String = _apply_json_data(raw) + if not error_text.is_empty(): + _show_error(error_text) + return + _current_path = path + _clear_autosave() + _push_recent(path) + _update_save_status() + + +func _on_load_file_selected(path: String) -> void: + _load_from(path) + + +func _show_error(message: String) -> void: + _error_dialog.dialog_text = message + _error_dialog.popup_centered() diff --git a/scripts/prop_editor.gd.uid b/scripts/prop_editor.gd.uid new file mode 100644 index 0000000..45e7920 --- /dev/null +++ b/scripts/prop_editor.gd.uid @@ -0,0 +1 @@ +uid://v8gaqlxoei3a diff --git a/scripts/prop_file.gd b/scripts/prop_file.gd new file mode 100644 index 0000000..4933023 --- /dev/null +++ b/scripts/prop_file.gd @@ -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 diff --git a/scripts/prop_file.gd.uid b/scripts/prop_file.gd.uid new file mode 100644 index 0000000..b865540 --- /dev/null +++ b/scripts/prop_file.gd.uid @@ -0,0 +1 @@ +uid://rbk8by20x7g4 diff --git a/scripts/prop_theme.gd b/scripts/prop_theme.gd new file mode 100644 index 0000000..28669f8 --- /dev/null +++ b/scripts/prop_theme.gd @@ -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) diff --git a/scripts/prop_theme.gd.uid b/scripts/prop_theme.gd.uid new file mode 100644 index 0000000..465e742 --- /dev/null +++ b/scripts/prop_theme.gd.uid @@ -0,0 +1 @@ +uid://qxwnxe3u1o33 diff --git a/scripts/thumbnails/thumbnail_cache.gd b/scripts/thumbnails/thumbnail_cache.gd index a88a538..4d680b8 100644 --- a/scripts/thumbnails/thumbnail_cache.gd +++ b/scripts/thumbnails/thumbnail_cache.gd @@ -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 diff --git a/specs/phase_9_prop_editor.md b/specs/phase_9_prop_editor.md new file mode 100644 index 0000000..f16404d --- /dev/null +++ b/specs/phase_9_prop_editor.md @@ -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_png(id)`); custom + `.prop` files are keyed by basename + mtime (they can be edited and re-saved, so version + alone is insufficient). + +Save-side flow in Section 3. + +### Decision 7 — Headless tests for all non-drag behavior; manual verification for gizmo drags + +**Recommendation:** Four headless suites under `tests/` (schema round-trip, theme fallback, +canvas math incl. sanitize/clipboard/mirror/z-order, thumbnail cache keying). Interactive +vertex/gizmo dragging (Tasks 2.2, 2.5) is manual verification only, because pointer-drag +geometry cannot be asserted headlessly and matches existing practice (`test_phase3b_library.gd` +etc.). + +Details in Section 4 and the per-suite coverage in Section 2. + +--- + +## 2. Targets and ownership + +| Target | Kind | Change | +| --- | --- | --- | +| `scenes/prop_editor.tscn` | **new** | Minimal shell: `%MenuBar`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%PropCanvas`/`%InspectorColumn`/`%InspectorList`, `%StatusBar`/`%CursorCoords`/`%SaveStatus`, `%SaveDialog`, `%LoadDialog`, `%ClearConfirmDialog`, `%ErrorDialog`. | +| `scripts/prop_editor.gd` (`PropEditor`) | **new** | Main controller: menu bar, save/load/clear, JSON (de)serialization, inspector build + signal fan-out, material-preset application, thumbnail save. | +| `scripts/prop_canvas.gd` (`PropCanvas`) | **new** | Multi-shape canvas: zoom/pan, vertex drag, insert/remove vertex, z-order, mirror, clipboard, translate/rotate/scale gizmos, grid/snap, sanitize-on-commit, cursor hit-testing. | +| `scripts/prop_theme.gd` (`PropTheme`) | **new** | Theme loader (deep-merge, validated accessors, warn-once), mirrors `SandboxTheme`. | +| `scripts/prop_file.gd` (`PropFile`) | **new** | Static `.prop` schema: `to_file_dict`/`from_file_dict`, `save`/`load`, preset string⇄enum, `sanitize_shape`, `flatten_to_payload`. | +| `res://prop_theme.json` | **new** | Committed theme file (schema `"1.0"`); editable presentation tokens. | +| `scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) | **modified** | Add `prop_file_key(path)` / `prop_file_png(path)` (mtime-keyed custom-prop PNGs). No change to existing `prop_png`/`stickman_*`. | +| `scripts/prop_block.gd` (`PropBlock`) | **unchanged** | `mass_for`/`physics_material`/`tint_for`, `ShapeType`, `MaterialPreset`, color/width constants reused as-is. | +| `scripts/prop_utils.gd` (`PropUtils`) | **unchanged** | `spawn_prop`/`_apply_shape_payload`/`create_*` consumed by `PropFile.flatten_to_payload`. | +| `tests/test_prop_editor_file.gd` | **new** | `.prop` schema: `from_file_dict`/`to_file_dict` round-trip, version rejection, missing-key defaults, preset string⇄enum, `flatten_to_payload` (polygon/circle/line, empty, z-order preference), `sanitize_shape`. | +| `tests/test_prop_editor_theme.gd` | **new** | `PropTheme` fallback hierarchy (file → `DEFAULT_CONFIG` → hardcoded), invalid color/number fallback, physics-not-themeable assertion. | +| `tests/test_prop_editor_canvas.gd` | **new** | `PropCanvas` math: insert/remove vertex + vertex-flags, mirror X/Y about bbox center, `bring_forward`/`send_back`, clipboard deep-copy + `CLIPBOARD_PASTE_OFFSET`, sanitize round-trip via `TerrainUtils.sanitize_points`, scale/rotate transform math. | +| `tests/test_prop_editor_thumb.gd` | **new** | `ThumbnailCache.prop_file_key`/`prop_file_png` formatting, `prop_png` unchanged, `save_png`→`load_png` round-trip, missing-file null. | +| `docs/tech_debt_and_optimizations.md` | **modified** | Log the multi-shape→single-`PropBlock` flattening trade-off and the mtime-keyed custom-prop thumbnail cache note. | + +**Layout/theme note:** the inspector column width, canvas default size, and dialog sizes are +open choices (Section 5) — recorded as recommended defaults in Section 3, not baked into the +shell. + +--- + +## 3. Static contracts + +### 3.1 `.prop` file schema (version `"1.0"`) + +```json +{ + "version": "1.0", + "prop_name": "My Crate", + "material_preset": "wood", + "shapes": [ + { + "shape_type": "polygon", + "points": [ + { "x": -24.0, "y": -24.0 }, + { "x": 24.0, "y": -24.0 }, + { "x": 24.0, "y": 24.0 }, + { "x": -24.0, "y": 24.0 } + ], + "fill_color": "#8C6133", + "outline_color": "#40290F", + "outline_width": 2.0, + "closed": true, + "vertex_flags": [0, 0, 0, 0] + }, + { + "shape_type": "circle", + "radius": 24.0, + "fill_color": "#E63333", + "outline_color": "#590D0D", + "outline_width": 2.0, + "closed": true, + "vertex_flags": [] + } + ], + "metadata": { + "created_at": "2026-09-18T10:00:00", + "modified_at": "2026-09-18T10:00:00" + } +} +``` + +Field contract: + +| Key | Type | Notes | +| --- | --- | --- | +| `version` | string | `"1.0"`. Unknown → load error (see `SUPPORTED_VERSIONS`). | +| `prop_name` | string | Display name; empty falls back to filename basename. | +| `material_preset` | string | `"none"\|"wood"\|"rubber"\|"cardboard"\|"metal"`; missing/invalid → `"none"`. | +| `shapes` | array of objects | Z-order = array order (index 0 = back, last = front). | +| `shapes[i].shape_type` | string | `"polygon"\|"circle"\|"line"`. Missing → `"polygon"`. | +| `shapes[i].points` | array of `{x,y}` | Polygon/line vertex list. Missing → `[]`. | +| `shapes[i].radius` | float | Circle only; default `24.0`. | +| `shapes[i].fill_color` / `outline_color` | `#RRGGBB` / `#RRGGBBAA` string | Defaults from material tint (Section 3.3). | +| `shapes[i].outline_width` | float | Default `PropBlock.DEFAULT_OUTLINE_WIDTH` (2.0). | +| `shapes[i].closed` | bool | Polygon/circle `true`; line `false`. | +| `shapes[i].vertex_flags` | array of int | `0` = original, `1` = user-created (mirrors stickman editor). Length == `points.size()` (circles: empty). | +| `metadata` | object | Write-only provenance; ignored on load. | + +### 3.2 `prop_theme.json` schema (version `"1.0"`) + +```json +{ + "version": "1.0", + "outline_widths": { "default": 2.0, "min": 0.5, "max": 32.0 }, + "material_tints": { + "wood": { "fill": "#8C6133", "outline": "#40290F" }, + "rubber": { "fill": "#E63333", "outline": "#590D0D" }, + "cardboard": { "fill": "#D9B873", "outline": "#66521D" }, + "metal": { "fill": "#808C99", "outline": "#262E38" }, + "none": { "fill": "#8C6133", "outline": "#26201E" } + }, + "primitive_defaults": { + "box": { "size_x": 48.0, "size_y": 48.0 }, + "ball": { "radius": 24.0 }, + "plank": { "length": 160.0, "thickness": 16.0 }, + "triangle": { "base": 56.0, "height": 48.0 }, + "line": { "length": 120.0 }, + "circle": { "radius": 40.0, "segments": 12 } + }, + "canvas": { + "grid_size": 15, + "background_color": "#1F1F1F", + "grid_color": "#FFFFFF26" + } +} +``` + +**Fallback hierarchy** (only visual + defaults are themed; physics is fixed): + +```text +material fill/outline tint: + prop_theme.json material_tints..fill/outline (valid color string) + │ missing / invalid + ▼ + PropTheme.DEFAULT_CONFIG material_tints..fill/outline + │ + ▼ + PropBlock.tint_for(preset) ← hardcoded single source of truth (never overridden by theme) + +outline width default: + prop_theme.json outline_widths.default → DEFAULT_CONFIG → PropBlock.DEFAULT_OUTLINE_WIDTH (2.0) + +primitive default dimension (box/ball/plank/triangle/line/circle): + prop_theme.json primitive_defaults.. → DEFAULT_CONFIG → PropUtils.create_* defaults + +physics (mass / friction / bounce): + PropBlock.mass_for(preset) / PropBlock.physics_material(preset) — NEVER themeable +``` + +### 3.3 Shared constants and enums + +```gdscript +# prop_canvas.gd — editor shape type superset (superset of PropBlock.ShapeType) +enum ShapeType { POLYGON, CIRCLE, LINE } +enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 } # mirrors stickman editor + +# gizmo / canvas constants (mirror BodyPartPanel where applicable) +const HANDLE_RADIUS := 3.0 +const HANDLE_RADIUS_SELECTION := 8.0 +const HANDLE_HOLLOW_SIZE := 6.0 +const EDGE_HIT_THRESHOLD := 12.0 +const ROTATE_RING_RADIUS := 40.0 # selection rotate ring (screen-constant / zoom) +const SCALE_HANDLE_DISTANCE := 8.0 # corner scale-handle reach +const ROTATION_SNAP_DEG := 15.0 # Ctrl-snap (deg_to_rad(15.0)) +const CLIPBOARD_PASTE_OFFSET := Vector2(16, 16) +const DEFAULT_CIRCLE_SEGMENTS := 12 # editor circle primitives +const MIN_ZOOM := 0.3 +const MAX_ZOOM := 3.0 +const ZOOM_STEP := 1.10 +const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15) + +# prop_block.gd — existing authority, reused unchanged +# enum ShapeType { POLYGON, CIRCLE } +# enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL } +# const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0) +# const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0) +# const DEFAULT_OUTLINE_WIDTH := 2.0 +# static func physics_material(preset) -> PhysicsMaterial +# static func mass_for(preset) -> float +# static func tint_for(preset) -> Color +``` + +### 3.4 `PropFile` — static schema + converters + +```gdscript +class_name PropFile +extends RefCounted + +const FILE_VERSION := "1.0" +const FILE_EXT := ".prop" +const DEFAULT_DIR := "res://props" +const SUPPORTED_VERSIONS: Array[String] = ["1.0"] +# index == PropBlock.MaterialPreset +const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"] +const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"] + +static func preset_to_string(preset: int) -> String # clamp into range; NONE fallback +static func preset_from_string(s: String) -> int # unknown -> MaterialPreset.NONE +static func shape_type_to_payload_type(s: String) -> int # "circle"->CIRCLE else POLYGON +static func sanitize_shape(shape: Dictionary) -> Dictionary # sanitize polygon/line points via TerrainUtils +static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary +static func from_file_dict(data: Variant) -> Dictionary +# returns {} on error; else { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] } +static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary +# returns a single PropUtils.spawn_prop payload {type, points|radius, fill_color, outline_color, outline_width} +static func save(path: String, data: Dictionary) -> bool # FileAccess WRITE + JSON.stringify +static func load(path: String) -> Dictionary # {} on read/parse failure (push_warning) +``` + +**`flatten_to_payload` rule (deterministic, testable):** + +1. Prefer the **frontmost closed polygon** — scan `shapes` from last to first, pick the first + with `shape_type == "polygon"` and `closed == true`. Its sanitized `points` become + `{ "type": POLYGON, "points": ... }`. +2. Else prefer a `circle` shape (last→first) → `{ "type": CIRCLE, "radius": ... }`. +3. Else fall back to the **first shape's** points as a polygon (even if a line). +4. `fill_color`/`outline_color`/`outline_width` come from the chosen shape; a missing color + falls back to the material tint (`PropBlock.tint_for`) / `DEFAULT_OUTLINE_COLOR`. +5. Empty `shapes` → `{}` (caller skips spawn, with one warning). + +Lines are editor-only (open, unfilled) and never become collision geometry (see Section 5). + +### 3.5 `PropTheme` — validated loader (mirrors `SandboxTheme`) + +```gdscript +class_name PropTheme +extends RefCounted + +const SCHEMA_VERSION := "1.0" +const DEFAULT_PATH := "res://prop_theme.json" +const DEFAULT_CONFIG: Dictionary = { /* mirrors committed prop_theme.json — see 3.2 */ } + +func load_from(path: String = DEFAULT_PATH) -> bool +func value(path: String, fallback: Variant = null) -> Variant # dotted-path access +func color(path: String, fallback: Color) -> Color # #RRGGBB[AA] validation + warn-once +func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float +func material_tint(preset: int, channel: String) -> Color # channel "fill"|"outline" +func outline_width_default() -> float +func primitive_default(primitive: String, key: String) -> float # e.g. ("box","size_x") +func grid_size() -> int +``` + +`material_tint(preset, channel)` implements the hierarchy in 3.2: theme file → +`DEFAULT_CONFIG` → `PropBlock.tint_for(preset)` (and `.darkened(0.55)` for the outline +channel's final fallback, matching `PropBlock._apply_material_preset()`). + +### 3.6 `PropCanvas` — multi-shape canvas + gizmos + +```gdscript +class_name PropCanvas +extends Control + +signal shape_changed(shapes: Array[Dictionary]) +signal shape_selected(idx: int) + +# public API +func set_shape_data(shapes: Array[Dictionary]) -> void # import; resets zoom/pan/selection +func get_shape_data() -> Array[Dictionary] # export (plain dicts, {x,y} points) +func clear() -> void # no shape_changed emit (matches clear_shape) +func select(idx: int) -> void +func deselect() -> void +func get_selected_index() -> int +func set_grid_size(size: int) -> void +func set_snap_enabled(enabled: bool) -> void +func reset_view() -> void +func add_primitive(primitive: String, center: Vector2, params: Dictionary = {}) -> Dictionary +# primitive ∈ "box"|"ball"|"plank"|"triangle"|"line"|"circle"; appends; returns the new shape +func insert_vertex(shape_idx: int, vertex_idx: int, world_pos: Vector2) -> void # flags it USER_CREATED +func remove_vertex(shape_idx: int, vertex_idx: int) -> void # <2 vertices -> drop shape +func bring_forward(idx: int) -> void +func send_back(idx: int) -> void +func mirror_x(idx: int) -> void # invert X about bbox center +func mirror_y(idx: int) -> void # invert Y about bbox center +func copy_shape(idx: int) -> Dictionary # deep duplicate (duplicate(true)) +func paste_shape(source: Dictionary, target_pos: Vector2) -> void # offset by CLIPBOARD_PASTE_OFFSET +func translate_selected(delta: Vector2) -> void +func rotate_selected(angle_rad: float) -> void # Ctrl snap applied by the drag handler +func scale_selected(factor: Vector2) -> void # Ctrl → uniform (locked aspect) +func is_cursor_over_drawing(global_pos: Vector2) -> bool +func global_to_world(global_pos: Vector2) -> Vector2 +func set_has_clipboard(state: bool) -> void # enables "Paste" in context menu +``` + +Internal shape dictionary (editor-space, mirrors `BodyPartPanel`): + +```gdscript +{ + "shape_type": "polygon", # "polygon"|"circle"|"line" + "points": PackedVector2Array(), # polygon/line only + "radius": 24.0, # circle only + "fill_color": "#8C6133", # hex string + "outline_color": "#40290F", # hex string + "outline_width": 2.0, + "closed": true, + "vertex_flags": PackedInt32Array(), # 0=original, 1=user-created +} +``` + +Input handling order (mirrors `BodyPartPanel._on_drawing_area_gui_input`): magnify/pinch zoom +→ pan gesture → middle-mouse pan → wheel zoom (cursor-centered) → **gizmo hit-test** (rotate +ring / scale corner on the selected shape) → right-click context menu (vertex = "Remove +Point"; outline = "Create Point"; else "Copy/Paste/Delete/Mirror X/Mirror Y/Bring +Forward/Send Back"; empty canvas = "Add Box/Ball/Plank/Triangle/Line/Circle") → left-click +vertex drag / shape drag. + +### 3.7 `PropEditor` — controller + +```gdscript +class_name PropEditor +extends Control + +const PROP_FILE := preload("res://scripts/prop_file.gd") +const PROP_THEME := preload("res://scripts/prop_theme.gd") +const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd") +const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd") + +@onready var _menu_bar: MenuBar = %MenuBar +@onready var _prop_name_edit: LineEdit = %PropNameEdit +@onready var _canvas: PropCanvas = %PropCanvas +@onready var _save_dialog: FileDialog = %SaveDialog +@onready var _load_dialog: FileDialog = %LoadDialog +@onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog +@onready var _error_dialog: AcceptDialog = %ErrorDialog +@onready var _cursor_coords: Label = %CursorCoords +@onready var _save_status: Label = %SaveStatus + +func _ready() -> void +func _setup_menu_bar() -> void # File (Save/Load/Clear), Edit (Configure Grid/Snap), View (Reset Views) +func _build_inspector() -> void # builds %InspectorList children (Task 1.3 / 3.1 / 3.2) +func _on_save_file_selected(path: String) -> void +func _on_load_file_selected(path: String) -> void +func _on_clear_confirmed() -> void +func _build_json_data() -> Dictionary # PropFile.to_file_dict(...) +func _apply_json_data(json: Variant) -> String # "" on success, else error text +func _refresh_inspector_from_selection() -> void +func _apply_material_preset(preset: int) -> void +func _save_prop_thumbnail() -> void # async (await render) +``` + +**Save-side thumbnail flow** (Task 4.2): + +```text +Save "res://props/mycrate.prop" + 1. PropFile.save(path, data) # write .prop + 2. payload = PropFile.flatten_to_payload(shapes) + 3. tex = await _prop_thumbnail.render(payload, material_preset) + 4. if tex != null: + _thumb_cache.save_png(tex, _thumb_cache.prop_file_png(path)) # 200x200, user://thumbnails/props/mycrate_.png + 5. (headless / blank) tex == null → skip silently (placeholder on load side) +``` + +`ThumbnailCache` extension: + +```gdscript +func prop_file_key(path: String) -> String: # "_" +func prop_file_png(path: String) -> String: # PROP_DIR + "/" + key + ".png" +``` + +--- + +## 4. Task-to-implementation mapping + +| Plan task | Fulfilled by | Acceptance criteria | +| --- | --- | --- | +| **1.1** Prop design-token schema | `scripts/prop_theme.gd` + `res://prop_theme.json` | File parses and loads on `PropEditor._ready()`; missing keys fall back (file → `DEFAULT_CONFIG` → hardcoded) with no runtime error. | +| **1.2** Material preset visual utility | `PropEditor._apply_material_preset()` reading `PropBlock.mass_for`/`physics_material`/`tint_for` (via `PropTheme.material_tint` override) | Selecting a preset populates default mass/friction/bounce (from `PropBlock`) and fill/outline tint. | +| **1.3** Inspector theme | `PropEditor._build_inspector()` applying a local `Theme` (from `PropTheme`) to `OptionButton`/`SpinBox`/`ColorPicker`/`Button` | Controls match styling; no global GUI theme or sandbox scene mutation. | +| **2.1** Multi-shape creation & primitive controls | `PropCanvas.add_primitive()` + inspector mode-specific params (radius for circle; vertex list for polygon) | Adding a shape appends to the stack; switching primitive modes exposes `radius` vs `points` params. | +| **2.2** Vertex drag & reshape | `PropCanvas` vertex hit-test + `InputEventMouseMotion` drag (mirrors `BodyPartPanel._handle_left_press`/`_on_drawing_area_gui_input`) | Dragging updates geometry live; release commits (emits `shape_changed`). | +| **2.3** Contextual vertex insert/delete | `PropCanvas.insert_vertex()` / `remove_vertex()` via right-click "Create Point"/"Remove Point" | Insert adds a `USER_CREATED` vertex at edge midpoint; delete closes the segment; <2 vertices drops the shape. | +| **2.4** Selection, context menu, z-order | `PropCanvas` hit-testing + context menu + `bring_forward()`/`send_back()` | Left-click highlights bbox/handles; Delete removes; z-order alters draw order. | +| **2.5** Translate/rotate/scale gizmos | `PropCanvas` gizmo hit-test + drag handlers (`translate_selected`/`rotate_selected`/`scale_selected`); Ctrl = 15° rotate snap + uniform scale | Drag translates all vertices; Ctrl snaps rotation to 15°; Ctrl locks aspect ratio on scale. | +| **2.6** Mirror engine | `PropCanvas.mirror_x()`/`mirror_y()` (invert about bbox center) | Mirror X/Y invert coordinates about the shape center. | +| **2.7** Clipboard | `PropCanvas.copy_shape()` (deep dup) + `paste_shape()` (offset `CLIPBOARD_PASTE_OFFSET`) + `set_has_clipboard()` | Copy stores a deep duplicate; paste inserts an offset duplicate; "Paste" disabled when empty. | +| **2.8** Live vector sync | `PropCanvas` → `shape_changed` → `PropEditor` inspector writes fill/outline/width back into the shape dict + `queue_redraw` | Modifying fill/outline/width reflects instantly on canvas. | +| **2.9** Sanitization | `PropCanvas` commit path + `PropFile.sanitize_shape()` calling `TerrainUtils.sanitize_points()` | Committed polygons are grid-snapped, redundant/collinear-stripped, clockwise. | +| **3.1** Material preset picker | Inspector `OptionButton` → `PropEditor._apply_material_preset()` | Changing preset updates displayed mass/friction/restitution (from `PropBlock`). | +| **3.2** Color swatches | Inspector `ColorPickerButton`s for fill + outline (override preset colors) | Custom fill/outline override preset tint and update stroke/fill live. | +| **4.1** Thumbnail generator | reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail.render`) | Renders off-screen 200×200 transparent PNG without affecting main editor. | +| **4.2** Disk cache pipeline | `PropEditor._save_prop_thumbnail()` + `ThumbnailCache.prop_file_png`/`save_png`/`load_png` | Save exports `_.png`; load reads cached PNG if source unchanged (mtime key). | + +--- + +## 5. Design choices — user-approved resolutions + +1. **Multi-shape → single `PropBlock` flattening.** **Approved rule:** frontmost closed polygon + → else circle → else first shape (spec 3.4, `flatten_to_payload`). +2. **Line primitives and physics.** **Approved:** lines are editor-only (drawn, never flattened + into collision geometry). +3. **Sandbox asset-selector integration.** **Approved direction:** `.prop` files will later + appear in the sandbox "Prop" selector; **not** wired this phase. +4. **Menu-bar entry in the main editor.** **Approved:** F6-standalone only this phase (no + "Prop Editor…" item in the stickman editor menu bar). +5. **Default canvas / window size.** **Approved:** canvas 1200×800 with a ~300 px inspector + column; window resizable, no size persistence. + +--- + +## 6. Architectural alternatives and deferred work + +- **`Control`-based editor vs an in-world Node2D tool:** an in-world (`Node2D`) prop editor + would share the sandbox camera/zoom, but props are authored assets, not stage objects; the + `Control` canvas reuses the proven `BodyPartPanel` zoom/pan/input model and keeps the file + format independent of stage transforms. +- **Reuse `BodyPartPanel` directly:** rejected (Decision 4) — the prop shape model (fill + + outline + width, polygon primitives, transform gizmos) diverges enough that a dedicated + `PropCanvas` is cheaper than branching. +- **Physics theme tokens:** deliberately excluded from `prop_theme.json` (Decision 5). If + per-project physics tuning is ever wanted, a separate, explicit physics-override file is + preferable to silently changing runtime feel via a visual theme. +- **Multi-shape `PropBlock`:** a future phase could compose multiple shapes into one prop + (multiple `Polygon2D`/`Line2D` children + merged collision). Until then the single-shape + `PropBlock` + flattening converter is the contract; recorded in the tech-debt log. + +--- + +*Validation will follow the existing pattern: per-script `--check-only`, then each +`tests/test_prop_editor_*.gd` headless suite printing PASS/FAIL and `quit(0/1)`. Interactive +vertex/gizmo drags are manual F6 verification.* diff --git a/specs/phase_9_prop_editor_round2.md b/specs/phase_9_prop_editor_round2.md new file mode 100644 index 0000000..44d4a7d --- /dev/null +++ b/specs/phase_9_prop_editor_round2.md @@ -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. diff --git a/specs/phase_9_prop_editor_round3.md b/specs/phase_9_prop_editor_round3.md new file mode 100644 index 0000000..7f47c43 --- /dev/null +++ b/specs/phase_9_prop_editor_round3.md @@ -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.* diff --git a/specs/phase_9_prop_editor_round4.md b/specs/phase_9_prop_editor_round4.md new file mode 100644 index 0000000..b534a58 --- /dev/null +++ b/specs/phase_9_prop_editor_round4.md @@ -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. diff --git a/specs/phase_9_prop_editor_round5.md b/specs/phase_9_prop_editor_round5.md new file mode 100644 index 0000000..86ebc6b --- /dev/null +++ b/specs/phase_9_prop_editor_round5.md @@ -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. diff --git a/specs/phase_9_prop_editor_round6.md b/specs/phase_9_prop_editor_round6.md new file mode 100644 index 0000000..cfa6bce --- /dev/null +++ b/specs/phase_9_prop_editor_round6.md @@ -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. diff --git a/specs/phase_9_prop_editor_round7.md b/specs/phase_9_prop_editor_round7.md new file mode 100644 index 0000000..891091b --- /dev/null +++ b/specs/phase_9_prop_editor_round7.md @@ -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. diff --git a/tests/test_prop_editor_canvas.gd b/tests/test_prop_editor_canvas.gd new file mode 100644 index 0000000..a63af95 --- /dev/null +++ b/tests/test_prop_editor_canvas.gd @@ -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) diff --git a/tests/test_prop_editor_canvas.gd.uid b/tests/test_prop_editor_canvas.gd.uid new file mode 100644 index 0000000..77704e0 --- /dev/null +++ b/tests/test_prop_editor_canvas.gd.uid @@ -0,0 +1 @@ +uid://yr00a8ban8vp diff --git a/tests/test_prop_editor_file.gd b/tests/test_prop_editor_file.gd new file mode 100644 index 0000000..7951476 --- /dev/null +++ b/tests/test_prop_editor_file.gd @@ -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) diff --git a/tests/test_prop_editor_file.gd.uid b/tests/test_prop_editor_file.gd.uid new file mode 100644 index 0000000..0e1cf4d --- /dev/null +++ b/tests/test_prop_editor_file.gd.uid @@ -0,0 +1 @@ +uid://drq2g06sh58xs diff --git a/tests/test_prop_editor_round3.gd b/tests/test_prop_editor_round3.gd new file mode 100644 index 0000000..594e883 --- /dev/null +++ b/tests/test_prop_editor_round3.gd @@ -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") diff --git a/tests/test_prop_editor_round3.gd.uid b/tests/test_prop_editor_round3.gd.uid new file mode 100644 index 0000000..c95878d --- /dev/null +++ b/tests/test_prop_editor_round3.gd.uid @@ -0,0 +1 @@ +uid://bc82lccw6jtf diff --git a/tests/test_prop_editor_theme.gd b/tests/test_prop_editor_theme.gd new file mode 100644 index 0000000..e8a0534 --- /dev/null +++ b/tests/test_prop_editor_theme.gd @@ -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) diff --git a/tests/test_prop_editor_theme.gd.uid b/tests/test_prop_editor_theme.gd.uid new file mode 100644 index 0000000..7989a74 --- /dev/null +++ b/tests/test_prop_editor_theme.gd.uid @@ -0,0 +1 @@ +uid://bo7bwalcfi8x7 diff --git a/tests/test_prop_editor_thumb.gd b/tests/test_prop_editor_thumb.gd new file mode 100644 index 0000000..d2cdcba --- /dev/null +++ b/tests/test_prop_editor_thumb.gd @@ -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) diff --git a/tests/test_prop_editor_thumb.gd.uid b/tests/test_prop_editor_thumb.gd.uid new file mode 100644 index 0000000..81ed3e2 --- /dev/null +++ b/tests/test_prop_editor_thumb.gd.uid @@ -0,0 +1 @@ +uid://bkmhs37lfr7em