Files
stickman/docs/specs/phase_9b_prop_centering.md

30 KiB
Raw Permalink Blame History

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:

    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:

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:

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:

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:

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

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:

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:

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:

bounds = Rect2(Vector2(400, 200), Vector2(200, 100))
center = Vector2(500, 250)
delta = Vector2(-500, -250)

The command applies:

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:

(emitter - primary_grip) before == (emitter - primary_grip) after
(shape point - primary_grip) before == (shape point - primary_grip) after

5.2 Controller sequence

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:

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:

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

┌─────────────────────────────────────────────────────────────────────────────┐
│ 📁 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:

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.

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:

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

  1. Polygon, line, and circle points all contribute to one expected union AABB.
  2. Empty/malformed point sets do not merge an artificial (0,0) into distant bounds.
  3. Center delta is the exact negative bounds center.
  4. Every exported point value changes by exactly delta.
  5. Vertex flags, radius, reshaped, colors, closed state, and shape order remain unchanged.
  6. Shape selection, mode, and zoom remain unchanged by translation.
  7. One successful translation emits exactly one shape_changed; empty/zero translations emit none and return false.
  8. Grid snap does not alter the center delta.

11.3 Editor integration

  1. Anchor ItemList excludes origin and its first empty state has no selection.
  2. Add/preset/load/delete transitions follow the selection table in §6.2.
  3. Overlay hit-testing at (0,0) cannot select origin, even with inactive gizmos shown.
  4. Centering updates shapes and non-origin anchors together while origin stays zero.
  5. Centering marks dirty once, arms autosave, refreshes preview document data, and keeps a valid editable anchor selected.
  6. Empty/already-centered commands do not mark dirty.
  7. Centering focuses origin while preserving zoom.
  8. startup/New/load/reset place origin at canvas center; Reset View uses zoom 1.0.
  9. Center button is present in all modes and disabled only for empty/already-centered geometry.

11.4 Save preflight

  1. should_warn_off_origin(false, Rect2()) is false.
  2. Boundary equality does not warn; values just beyond the computed threshold do.
  3. The 1024 floor and 2 × diagonal relative branch are both exercised.
  4. Save Anyway does not alter points or anchors.
  5. Center & Save applies one center transaction and does not re-open the warning.
  6. Cancel clears save/quit/New pending state without writing.
  7. Autosave remains warning-free and preserves off-origin coordinates.
  8. 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.