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)
- 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.
- A created point sometimes appears on a different segment; it must appear where the cursor was when created.
- 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.
- In "Edit Points" mode, do NOT allow adding shapes (empty-canvas RMB shows nothing).
- 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). - Rename modes: "Move & Resize" → "Arrange", "Edit Points" → "Reshape" (enum values stay TRANSFORM/POINTS — only labels/emoji change).
- Mode toggle becomes a segmented [ Arrange | Reshape ] pair centered in the ModeBar, with a bigger font and a related emoji on each button.
- "View → Reset Views" becomes "View → Reset View" (singular).
- File/Edit/View menu bar + items get a bigger font (theme-driven) plus kid-friendly emoji in labels.
- Window title =
[Prop name][filename.prop][*]: name (default "Untitled"), filename (basename with.prop, only when saved),*when modified. - Exit dialog: intercept window close while modified; Discard / Save / Cancel with emoji; async save-before-quit.
- 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_pressedalready routesMENU_CREATE_POINT→create_point_on_edge( _get_context_world_pos()); thecontext_shapemeta is set on the menu so the selected shape is the onecreate_point_on_edgeoperates 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_edgealready asserts projection +vertex_created; the Round 3 fix (stored click position) is verified by a new direct_get_context_world_posassertion 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
- Emoji sets. Approved as proposed: buttons 🖐️ Arrange / ✏️ Reshape; menus 📁💾📄📂🗑️🔧📏👁️🔍; exit dialog 💾 Save / 🗑️ Discard / ❌ Cancel; restore 🔄 Restore.
- "Untitled" vs "Unnamed Prop". Approved: window title default "Untitled"; status bar keeps "Unnamed Prop".
- Filename with extension. Approved: with
.prop(get_file()), extension enforced on save (.prptreated as a typo for.prop). - Autosave debounce. Approved: 1.0 s.
- Restore flow. Approved: prompt with 🔄 Restore / 🗑️ Discard (mirrors the sandbox).
- Autosave clear semantics. Approved: per §2.7 table (clear on save/load/clear/discard-quit; keep after Restore; drop stale autosave at startup).
- Exit-save thumbnail. Approved: skip the thumbnail on exit-save.
- Mode button height. Approved: 44 px (
mode_button_min_height) withfont_mode20. - 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.