Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 deletions
+694
View File
@@ -0,0 +1,694 @@
# Phase 9 Prop Editor — Round 3 (Reshape-Mode Menu, Window Chrome, Exit & Autosave)
Status: **approved** (2026-09-18; all nine open choices resolved as proposed).
Source: twelve user feedback points against the shipped Round 2 Prop Editor
(`scripts/editors/prop/prop_canvas.gd`, `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_theme.gd`,
`prop_theme.json`, `scenes/editors/prop/prop_editor.tscn`). Autosave/exit reference:
`scripts/editors/stage/sandbox_stage.gd` + `scripts/stage/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/editors/prop/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/editors/prop/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://content/props/mycrate.prop` | false | `My Crate — mycrate.prop` |
| `""` | `res://content/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/editors/prop/prop_canvas.gd` | `hit_test_segment` (public); `_handle_right_click` POINTS matrix; `_get_context_world_pos` bounds-fix. |
| `scripts/editors/prop/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/editors/prop/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/editors/prop/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/architecture/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/editors/prop/prop_editor.gd")
const PF := preload("res://scripts/editors/prop/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://content/props/mycrate.prop", false) == "My Crate — mycrate.prop"
PE.build_window_title("", "res://content/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.*