- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation. - Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold. - Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness. - Added corresponding .uid files for each test. - Created sync_sources.py script to manage markdown file synchronization with the notebook. - Added .gitignore for virtual environment files.
652 lines
30 KiB
Markdown
652 lines
30 KiB
Markdown
# 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/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/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/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/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/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/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/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.
|