Files
stickman/docs/specs/phase_9_prop_editor_round3.md

31 KiB

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:

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):

# 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):

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)

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.

# 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"):

"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):

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:

# _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:

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:

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:

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.

# 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:

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:

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):

# 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:

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:

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):

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):

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):

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):

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.