# 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.*