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
+651
View File
@@ -0,0 +1,651 @@
# Phase 9b — Prop Origin, Centering, and Off-Origin Save Guard
## 1. Status and scope
This specification is the approved design for a focused Prop Editor follow-up to Phase 9a. It
does not implement Sandbox Stage or runtime prop behavior.
Phase 9b makes the canonical prop origin useful without presenting it as a fake-editable anchor:
- hide `origin` from the editable anchor list and all anchor interaction;
- retain and serialize the canonical `origin` at `(0,0)` for schema compatibility;
- draw a subtle, non-interactive `(0,0)` marker in Arrange, Reshape, and Attach;
- add an always-visible `🎯 Center Prop` command beside Grid/Snap;
- center the complete authored document by translating every shape and every non-origin anchor by
the negative union-bounds center;
- focus the authoring camera on `(0,0)` after centering without changing zoom;
- center `(0,0)` in the authoring view on startup, New, load/recovery view reset, and explicit
`Reset View`;
- warn during explicit saves when a non-empty prop is unusually far from the origin;
- never recenter automatically after ordinary shape or anchor edits.
The existing `.prop` file version remains `1.0`. No file migration, Sandbox integration, runtime
attachment, physics-center calculation, or undo/redo system is part of Phase 9b.
## 2. Approved decisions
1. The command is an always-visible `🎯 Center Prop` button in `TopRightControls`, immediately
after the existing Snap control. Its tooltip is `Move the whole prop and its anchors to (0,0).`
2. Centering focuses `(0,0)` at the canvas center while preserving the current zoom. Startup,
New, all existing document-load view resets, and `View → Reset View` put `(0,0)` at the canvas
center; Reset View also restores zoom to `1.0`.
3. An explicit save warns when:
```text
distance(bounds center, origin) > max(1024 px, 2 × bounds diagonal)
```
The warning offers `🎯 Center & Save`, `💾 Save Anyway`, and `❌ Cancel`. Autosave is silent.
4. Centering is opt-in only. Adding, moving, reshaping, loading, and saving never silently alter
document coordinates.
5. The centering point is the axis-aligned union-bounds center of displayed shape geometry, not a
polygon centroid, visual-stroke center, center of mass, or physics pivot.
6. There is no Phase 9b-only undo stack. The command is one explicit document mutation and uses
the existing dirty/autosave/recovery pipeline.
## 3. Existing contracts preserved
- `PropEditor` remains the document coordinator and owns `_anchors`, dirty state, autosave, save
dialogs, and the holding-preview synchronization.
- `PropCanvas` continues to own internal typed shape data, selection, pan/zoom, shape drawing, and
shape mutation.
- `PropAnchorOverlay` remains responsible only for editable non-origin anchor markers and input.
- `PropAnchorData` remains the normalization and JSON-safe anchor helper.
- `PropFile` continues to serialize normalized anchors, including canonical `origin`, and to load
legacy files without an `anchors` section by synthesizing it.
- Arrange, Reshape, and Attach mode behavior is otherwise unchanged.
- Existing grid/snap state does not affect centering. Centering uses the exact bounds-center delta.
- The holding preview stays editor-only. No Sandbox/runtime file or class may be modified.
## 4. Target files and typed contracts
### 4.1 `scripts/props/prop_anchor_data.gd` — `class_name PropAnchorData`
Add pure static helpers so list filtering, selection fallback, and document translation share one
normalization boundary:
```gdscript
static func editable_ids(anchors: Dictionary) -> Array[String]
static func preferred_editable_id(
anchors: Dictionary,
preferred_id: String = ""
) -> String
static func translated_non_origin(
anchors: Dictionary,
delta: Vector2
) -> Dictionary
```
Contract:
- `editable_ids()` returns normalized IDs except `ORIGIN_ID`, preserving normalized dictionary
iteration order.
- `preferred_editable_id()` returns a valid non-origin `preferred_id` first; otherwise
`primary_grip`, the first grip, the first remaining editable ID, or `""`.
- `translated_non_origin()` returns a deep normalized copy. It adds `delta` only to each
non-origin `position`; roles, rotation, Z offset, and hand compatibility are unchanged. The
returned `origin` is canonical and exactly `Vector2.ZERO`.
- Non-finite deltas are rejected by returning the normalized input unchanged and warning once per
call site or by an equivalent finite guard. UI paths must never supply such a delta.
`default_anchors()`, `normalize_anchors()`, and `serialize_anchors()` keep their existing public
behavior. The origin remains stored because it is the explicit local-coordinate contract; hiding
it is an editor presentation decision.
### 4.2 `scripts/props/prop_anchor_overlay.gd` — `class_name PropAnchorOverlay`
Change the overlay's selection contract:
```gdscript
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
```
- `_selected_id` defaults to `""`, not `origin`.
- `select_anchor("")` clears selection and emits `anchor_selected("")` only when the selection
changes.
- `select_anchor("origin")` must not make origin editable; it clears or ignores selection.
- `set_anchor_data()` preserves a valid selected non-origin ID. If it disappears, it clears the
selection rather than falling back to origin.
- `hit_test_anchor()`, `_visible()`, `begin_pointer()`, and `draw_gizmos()` always exclude origin.
- Existing marker drawing, translation, rotation, snapping, and
`anchors_changed(anchors: Dictionary)` payloads remain unchanged for editable anchors.
The universal origin marker does not move into this class because it is not an anchor tool.
### 4.3 `scripts/editors/prop/prop_canvas.gd` — `class_name PropCanvas`
Add or tighten these public APIs:
```gdscript
func has_shape_geometry() -> bool
func get_shapes_bounds() -> Rect2
func translate_all_shapes(delta: Vector2) -> bool
func focus_world_point(world_point: Vector2) -> void
func set_origin_marker_color(color: Color) -> void
func reset_view() -> void
```
#### Geometry presence and bounds
`has_shape_geometry()` is true when at least one shape has at least two finite internal points and
is therefore drawable by the current canvas.
`get_shapes_bounds()` returns the axis-aligned union of valid, drawable internal point geometry:
| Shape | Bounds source |
| --- | --- |
| Polygon | All stored vertices |
| Line | All stored endpoints/vertices |
| Circle | Its stored/generated perimeter points |
Every shape imported by `set_shape_data()` already passes through `_coerce_shape()`; a legacy or
external circle with radius but no points receives generated perimeter points there. Bounds must
skip empty/non-drawable point sets rather than treating `Rect2()` as geometry at the origin. The
union excludes outline width. This makes centering match the geometry the author manipulates and
prevents a malformed empty shape from pulling a distant prop's center toward zero.
For no drawable geometry, `get_shapes_bounds()` returns `Rect2()` and callers must consult
`has_shape_geometry()` before interpreting it.
#### Bulk shape translation
`translate_all_shapes(delta)`:
- returns `false` for no drawable geometry, non-finite delta, or an approximately zero delta;
- adds `delta` to every point in every shape with points;
- does not snap, sanitize, reverse winding, regenerate primitives, or change Z order;
- preserves `_selected_shape_idx`, canvas mode, view state, circle `radius`, `reshaped`,
`vertex_flags`, fill/outline fields, and `closed`;
- queues one redraw and emits exactly one `shape_changed(get_shape_data())` after success;
- returns `true` after a successful mutation.
The controller updates its anchors before calling this API. The existing synchronous shape signal
then becomes the one dirty/autosave notification for the complete document transaction.
#### Camera behavior
`focus_world_point(world_point)` preserves `_zoom` and derives:
```gdscript
_pan_offset = size * 0.5 - world_point * _zoom
```
`reset_view()` sets `_zoom = 1.0`, focuses `Vector2.ZERO`, and redraws. `clear()` and the existing
shape-data load reset route use this revised Reset View behavior. Startup calls Reset View after
the canvas has a valid arranged size (deferred by one frame only if its size is still zero).
Window resize does not continually recenter the camera. Once initialized, pan/zoom remains tool
state until New/load/reset/center or direct navigation changes it.
#### Origin marker
`_on_canvas_draw()` draws the marker in every `EditMode`, independent of shape or anchor state.
The existing empty-canvas hint must no longer return before marker drawing.
The marker is a small halo-backed cross/ring plus `(0,0)` label at world `Vector2.ZERO`:
- core radius/cross half-size: approximately `6` screen px;
- core width: `1–1.5` screen px;
- dark halo: approximately `2` screen px wider;
- label offset: approximately `(10, -8)` screen px;
- label size: approximately `12` screen px;
- all world dimensions, offsets, widths, and font size account for `_zoom`, keeping screen size
effectively constant over `[0.3, 3.0]`;
- core color comes from `set_origin_marker_color()`, initialized by `PropEditor` from
`PropTheme.text_muted_color()` with a subtle alpha;
- drawing occurs after ordinary shape geometry and before active transform/anchor gizmos;
- there is no marker hit test, `Control` child, signal, mouse filter, or drag path.
### 4.4 `scripts/editors/prop/prop_editor.gd` — `extends Control`
Add constants and state with explicit types:
```gdscript
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
var _center_prop_button: Button
var _off_origin_save_dialog: ConfirmationDialog
var _pending_save_path: String = ""
var _pending_save_with_thumbnail: bool = true
```
Continue using `_pending_quit` and `_pending_new` as the post-save completion intent. Do not add a
second conflicting completion enum unless implementation proves it makes those existing flows
simpler; there must still be one authoritative pending completion state.
Add focused helpers:
```gdscript
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool
func _center_prop_on_origin(focus_view: bool = true) -> bool
func _refresh_center_prop_button() -> void
func _begin_save_request(path: String, with_thumbnail: bool) -> void
func _execute_pending_save(center_first: bool) -> void
func _cancel_pending_save_request() -> void
```
`should_warn_off_origin()` uses a strict `>` comparison:
```gdscript
if not has_geometry:
return false
var center_distance: float = bounds.get_center().length()
var threshold: float = maxf(
OFF_ORIGIN_WARNING_MIN_DISTANCE,
OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length()
)
return center_distance > threshold
```
### 4.5 `scripts/editors/prop/prop_file.gd` — `class_name PropFile`
No schema or API change is required. Tests must prove that:
- `to_file_dict()` still serializes canonical origin plus every editable anchor;
- `from_file_dict()` still synthesizes origin for legacy `.prop` v1.0 files without anchors;
- centered coordinate values round-trip without changing radius, flags, or anchor metadata.
### 4.6 `scenes/editors/prop/prop_editor.tscn` and theme
No new image asset is needed. The button and warning dialog are built by `PropEditor`, matching
existing dynamic UI construction.
- `🎯 Center Prop` is added after Snap in `%TopRightControls`.
- The button uses the local `_ui_theme`, `button_min_height()`, full-rectangle mouse filtering,
and a concise tooltip. It stays visible in all modes.
- It is disabled when there is no drawable geometry or the bounds center is approximately zero;
it remains visible so the feature is discoverable.
- Refresh enabled state after New, load/recovery, shape change, and successful center.
- The save-warning dialog uses the same local theme. It must not mutate the global GUI theme.
- If minimum window width becomes tight, the existing adjustable Inspector remains the first
source of reclaimed space. Phase 9b must not let the new button force the Inspector offscreen.
## 5. Document centering transaction
### 5.1 Exact state transformation
Given:
```gdscript
bounds = Rect2(Vector2(400, 200), Vector2(200, 100))
center = Vector2(500, 250)
delta = Vector2(-500, -250)
```
The command applies:
```text
shape point (430, 215) → (-70, -35)
primary_grip (510, 245) → ( 10, -5)
emitter (590, 250) → ( 90, 0)
origin (0, 0) → ( 0, 0)
```
All relative vectors are preserved:
```text
(emitter - primary_grip) before == (emitter - primary_grip) after
(shape point - primary_grip) before == (shape point - primary_grip) after
```
### 5.2 Controller sequence
```mermaid
flowchart TD
Click[Center Prop / Center & Save] --> HasGeometry{Drawable geometry?}
HasGeometry -- No --> NoOp[Return false; no dirty state]
HasGeometry -- Yes --> Bounds[Read union bounds once]
Bounds --> Delta[delta = -bounds.center]
Delta --> Meaningful{Meaningful finite delta?}
Meaningful -- No --> Focus[Focus origin if requested]
Meaningful -- Yes --> Anchors[Translate normalized non-origin anchors]
Anchors --> SetAnchors[Set editor + canvas anchor state]
SetAnchors --> Shapes[Canvas translates all points; emits once]
Shapes --> Dirty[Existing handler marks dirty + arms autosave]
Dirty --> Refresh[Refresh inspector, button, and holding preview once]
Refresh --> Focus
```
`_center_prop_on_origin()` must read bounds once before any mutation. It must not calculate a new
center after translating shapes or anchors.
The operation preserves:
- shape and editable-anchor selection;
- shape/anchor ordering;
- active mode;
- current zoom;
- clipboard;
- grid/snap settings;
- material and prop name;
- preview hand/facing/animation preferences.
If the old selection is missing or is legacy `origin`, normalize it through
`preferred_editable_id()`. With no editable anchors, select `""` and let the holding preview show
its existing “select a grip” state.
### 5.3 Dirty, autosave, and preview behavior
- A successful non-zero center produces one logical dirty transition through the single
`shape_changed` emission.
- `_autosave_remaining` is armed once with the existing debounce.
- The editor refreshes the shape inspector and anchor inspector after both halves of the
transaction are in place.
- `_sync_attach_preview()` runs once after shapes and anchors match. In Attach mode, the held prop
must remain on the same grip and merely receive recentered local coordinates.
- A zero-delta or empty command does not mark dirty or write autosave.
- Centering does not immediately write the `.prop`; Save remains explicit.
## 6. Origin visibility and anchor-list behavior
### 6.1 Inspector population
`_refresh_anchor_inspector()` populates `%AnchorList` only from
`PropAnchorData.editable_ids(_anchors)`. It must never infer selection by reading item text for an
origin row because that row no longer exists.
When `_selected_anchor_id == ""`:
- no item is selected;
- Role, Allowed Hands, X, Y, Rotation, and Z Offset are disabled;
- Rename and Delete are disabled;
- a muted hint reads `Add or select an anchor.`;
- Add Anchor and preset buttons remain enabled.
The old disabled-origin tooltip and disabled-origin detail state are removed from the inspector.
The canvas `(0,0)` marker replaces their educational purpose.
### 6.2 Selection transitions
| Transition | Resulting editable selection |
| --- | --- |
| New/empty document | `""` |
| Load/recovery | Preserve valid preferred ID if supplied; otherwise `primary_grip`, first grip, first editable, or `""` |
| One-/Two-Handed preset | `primary_grip` |
| Tool/Nozzle or Melee preset | `primary_grip` |
| Add Anchor | New anchor ID |
| Rename | Renamed ID |
| Delete | Previous visible row, else next visible row, else `""` |
| Center Prop | Preserve current valid editable ID; otherwise preferred fallback |
The holding preview receives `""` when nothing editable is selected. It must never receive origin
as the active attachment anchor.
## 7. Save preflight and pending-state flow
### 7.1 Entry points
Every user-requested save routes through `_begin_save_request(path, with_thumbnail)`:
- File → Save with an existing path;
- Save As after the file dialog returns and the `.prop` extension is normalized;
- save-before-quit;
- save-before-New.
`_save_to()` remains the low-level writer and never opens the warning itself. `_write_autosave()`
continues calling `PropFile.save_safe()` directly and never warns or centers.
### 7.2 Warning dialog
The dialog text must state both the condition and the non-destructive options, for example:
```text
This prop is far from (0,0). Centering it keeps its shapes and anchors together.
What would you like to do?
```
Buttons:
- OK: `🎯 Center & Save`
- custom action `save_anyway`: `💾 Save Anyway`
- Cancel: `❌ Cancel`
Flow:
```mermaid
stateDiagram-v2
[*] --> SaveRequest
SaveRequest --> SaveDirect: empty or within threshold
SaveRequest --> Warning: unusually far
Warning --> Center: Center & Save
Center --> SaveDirect: one center transaction
Warning --> SaveDirect: Save Anyway
Warning --> Editor: Cancel
SaveDirect --> Editor: save failed
SaveDirect --> Editor: normal save succeeded
SaveDirect --> Quit: pending quit + success
SaveDirect --> NewDocument: pending New + success
```
Rules:
- `_pending_save_path` and `_pending_save_with_thumbnail` are set before the warning opens.
- Center & Save invokes `_center_prop_on_origin(true)` and then calls `_save_to()` directly so the
same request cannot warn a second time.
- Save Anyway calls `_save_to()` without coordinate mutation.
- Cancel clears the pending save payload and clears `_pending_quit`/`_pending_new`, returning to
the editor.
- A failed save reports the existing error and clears all pending completion state so a later
unrelated save cannot unexpectedly quit or create a new document.
- A successful ordinary save clears the pending save payload normally.
- A successful pending quit exits; a successful pending New invokes `_do_new()`.
- Warning evaluation uses the same corrected bounds helper as Center Prop and presets. Empty props
do not warn.
- Choosing Save Anyway is scoped to one request. A later explicit save evaluates the rule again.
- Save warning state is tool state and is not serialized.
## 8. Layout and drawing schematic
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 📁 File 👁 View Grid [15] Snap [🎯 Center Prop] │
├─────────────────────────────────────────────────────────────────────────────┤
│ Prop Name [...............................................................] │
├───────────────────────────────────────────────────────┬─────────────────────┤
│ [🖐 Arrange] [✏ Reshape] [📌 Attach] │ Inspector │
│ │ │
│ authoring canvas │ Attach list excludes│
│ │ canonical origin │
│ ──○── (0,0) ← passive marker │ │
│ │ │
│ shapes / selection / editable anchor gizmos │ │
└───────────────────────────────────────────────────────┴─────────────────────┘
```
Drawing order inside `PropCanvas`:
```text
grid
→ empty-canvas hint (when applicable)
→ shape geometry and existing selection rendering
→ passive origin marker
→ Arrange transform gizmos OR Attach editable-anchor gizmos
```
The origin marker is never part of the input-routing diagram.
## 9. Signal and state flow
No new public signal is required.
```mermaid
flowchart LR
Button[Center Prop] --> Editor[PropEditor transaction]
Editor --> AnchorData[PropAnchorData.translated_non_origin]
Editor -->|set_anchor_data| Canvas[PropCanvas]
Editor -->|translate_all_shapes| Canvas
Canvas -->|shape_changed: Array Dictionary| Editor
Editor --> Dirty[dirty + autosave debounce]
Editor --> Inspector[refresh inspector]
Editor --> Preview[set_document shapes + anchors + selection]
Editor --> Camera[focus_world_point 0,0]
```
Existing signal payloads remain:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal shape_selected(idx: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
```
`anchor_selected("")` is now a valid “no editable anchor selected” payload.
## 10. Edge cases
- **Empty prop:** marker is visible; Center Prop is disabled; save never warns; origin still saves.
- **Only malformed/empty shapes:** treated as no drawable geometry for centering and warning.
- **Single line:** endpoints participate in bounds; their midpoint becomes `(0,0)` after center.
- **Circle:** translate perimeter points only; preserve radius and `reshaped`; no regeneration.
- **Mixed shapes:** compute one union before translating anything, regardless of Z order.
- **Very large prop:** relative threshold prevents an automatic warning merely because its center
is moderately offset compared with its own diagonal.
- **Already centered:** command is disabled/no-op and does not dirty.
- **No custom anchors:** shapes center normally; origin stays fixed; selection remains empty.
- **Legacy file without anchors:** load synthesizes origin, hides it from the list, and exposes the
passive marker; no migration prompt.
- **Legacy selection of origin:** immediately resolves through editable fallback or `""`.
- **Anchor far outside geometry:** it receives the same delta as geometry; it is deliberately not
included in bounds calculation because shapes define the prop's authored center.
- **Snap enabled:** ignored by the bulk translation; exact centering wins over grid alignment.
- **Save during pending quit/New:** warning completion follows the original intent only after
successful save; Cancel returns safely to the current document.
- **Autosave:** writes current coordinates without warning or centering.
- **Pan/zoom:** normal edits never recenter. Center preserves zoom and focuses origin. Reset View
restores zoom `1.0` and focuses origin.
## 11. Automated validation
Add `tests/test_phase9b_prop_centering.gd` as a headless `SceneTree` suite. It must assert values,
not only counts.
### 11.1 Data helpers
1. `editable_ids()` never contains origin.
2. Preferred selection honors a valid current ID, then primary grip, first grip, first editable,
then empty.
3. `translated_non_origin()` preserves all non-position fields and fixes origin at exact zero.
4. Legacy missing-anchor data still normalizes to canonical origin.
5. Serialization still writes canonical origin and `.prop` version remains `1.0`.
### 11.2 Bounds and translation
6. Polygon, line, and circle points all contribute to one expected union AABB.
7. Empty/malformed point sets do not merge an artificial `(0,0)` into distant bounds.
8. Center delta is the exact negative bounds center.
9. Every exported point value changes by exactly delta.
10. Vertex flags, radius, `reshaped`, colors, closed state, and shape order remain unchanged.
11. Shape selection, mode, and zoom remain unchanged by translation.
12. One successful translation emits exactly one `shape_changed`; empty/zero translations emit
none and return false.
13. Grid snap does not alter the center delta.
### 11.3 Editor integration
14. Anchor ItemList excludes origin and its first empty state has no selection.
15. Add/preset/load/delete transitions follow the selection table in §6.2.
16. Overlay hit-testing at `(0,0)` cannot select origin, even with inactive gizmos shown.
17. Centering updates shapes and non-origin anchors together while origin stays zero.
18. Centering marks dirty once, arms autosave, refreshes preview document data, and keeps a valid
editable anchor selected.
19. Empty/already-centered commands do not mark dirty.
20. Centering focuses origin while preserving zoom.
21. startup/New/load/reset place origin at canvas center; Reset View uses zoom `1.0`.
22. Center button is present in all modes and disabled only for empty/already-centered geometry.
### 11.4 Save preflight
23. `should_warn_off_origin(false, Rect2())` is false.
24. Boundary equality does not warn; values just beyond the computed threshold do.
25. The `1024` floor and `2 × diagonal` relative branch are both exercised.
26. Save Anyway does not alter points or anchors.
27. Center & Save applies one center transaction and does not re-open the warning.
28. Cancel clears save/quit/New pending state without writing.
29. Autosave remains warning-free and preserves off-origin coordinates.
30. Successful and failed save completion cannot leave stale pending state.
### 11.5 Existing regressions
Run all Prop Editor suites listed in `AGENTS.md`, especially:
- `tests/test_prop_editor_file.gd`
- `tests/test_prop_editor_theme.gd`
- `tests/test_prop_editor_canvas.gd`
- `tests/test_prop_editor_thumb.gd`
- `tests/test_prop_editor_round3.gd`
- `tests/test_phase9a_prop_attach.gd`
Update Phase 9a expectations that currently select or expose origin in the inspector/overlay. Do
not weaken its schema assertions: origin remains canonical and serialized.
Run Godot 4.7.1 `--headless --check-only` for every changed script and `git diff --check`.
## 12. Manual acceptance checklist
Launch `res://scenes/editors/prop/prop_editor.tscn` with F6 and verify:
1. `(0,0)` starts centered and is shown as a subtle fixed-size marker.
2. The marker remains visible at its world location in Arrange, Reshape, and Attach.
3. It stays approximately the same screen size through min/max zoom and never highlights,
selects, or drags.
4. Reset View restores zoom and places origin in the canvas center.
5. `🎯 Center Prop` remains visible beside Grid/Snap in all modes and its complete rectangle is
clickable.
6. A prop authored far away moves so the union AABB center is exactly on the marker.
7. After centering, current zoom is unchanged and the marker/prop are centered in the view.
8. Mixed polygon, line, and circle props center together without deformation or Z-order change.
9. Every non-origin anchor moves with the prop; rotations, Z offsets, roles, and allowed hands do
not change.
10. A selected grip stays selected and the holding preview continues showing the same prop-to-hand
alignment before and after centering.
11. Origin is absent from the anchor list and cannot be clicked in Attach mode.
12. A new prop with no anchors shows the passive marker and `Add or select an anchor.` state.
13. Presets select `primary_grip`; deleting the final editable anchor leaves no selection.
14. Empty and already-centered documents keep the Center button visible but disabled.
15. Ordinary move/reshape/anchor edits never auto-center the document.
16. A moderately offset or large intentional prop inside the approved threshold saves normally.
17. A small prop beyond the threshold opens the three-choice warning.
18. Center & Save recenters, focuses, and saves; Save Anyway retains original coordinates;
Cancel writes nothing.
19. Save-before-quit and save-before-New honor the same warning and completion behavior.
20. Autosave/recovery never opens the warning and preserves the current coordinates.
21. Save/load round-trips centered points, flags, circle state, and anchors exactly.
22. A legacy `.prop` without anchors opens without migration UI, hides synthesized origin from the
list, and still displays the passive marker.
## 13. Architectural trade-offs and deferred items
| Decision | Benefit | Cost / future work |
| --- | --- | --- |
| Keep serialized fixed origin but hide it | Stable explicit file contract without fake editing UX | A future movable/custom pivot requires a separately versioned design |
| AABB center from internal points | Deterministic across polygon/line/circle and matches canvas geometry | Not a centroid, painted-outline center, or physical center of mass |
| Controller-coordinated transaction | Preserves current shape/anchor ownership and produces one dirty event | Requires disciplined ordering between anchor update and one bulk canvas mutation |
| Passive canvas drawing | Constant-size, allocation-free, and incapable of intercepting input | Visual behavior needs manual acceptance because headless pixel capture is unreliable |
| Heuristic explicit-save warning | Catches accidental remote authoring without blocking autosave | Intentional off-origin props are asked again on each explicit save |
| Focus origin after center | The centered prop remains visible and authoring around the pivot is natural | Center command intentionally changes camera pan, though not zoom |
| No center-specific undo | Avoids an inconsistent partial undo system | Users cannot reverse centering through Ctrl+Z until document-wide undo exists |
Deferred beyond Phase 9b:
- configurable/movable pivots or multiple origins;
- center of mass, collision-based bounds, or material-weighted centering;
- outline-width-aware visual centering;
- persistent “do not warn again” per asset;
- document-wide undo/redo;
- Sandbox/runtime consumption of origin or anchors;
- automatic recenter-on-edit behavior (explicitly rejected).
## 14. Implementation order and pipeline stop
1. Add/test pure anchor helpers and remove origin from overlay interaction.
2. Correct bounds, add bulk translation/focus APIs, and draw the universal marker.
3. Add editor selection fallback, inspector empty state, Center button, and atomic transaction.
4. Route explicit save entry points through the warning/pending-state flow.
5. Add Phase 9b tests, update Phase 9a expectations, run all regressions and static checks.
6. Hand the implementation to the user for Godot Editor verification.
Do not update the user guide or README during implementation handoff. Documentation changes happen
only after the user confirms Phase 9b is verified and working, per the spec-pipeline.