Add regression tests for prop attachment, centering, and editor UX

- 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.
This commit is contained in:
2026-09-21 16:48:24 -04:00
parent e3689c86c8
commit 00abacc7b2
30 changed files with 4903 additions and 55 deletions
+877
View File
@@ -0,0 +1,877 @@
# Phase 9a — Role-Based Prop Anchors and IK Authoring Preview
Status: **proposed — awaiting user sign-off** (2026-09-20).
Source: `plans/PHASE_9a_PROP_ATTACH.md`, refined through the spec-pipeline architecture
clarification round.
## 1. Accepted decisions
The user approved these three architecture decisions:
1. **Prop Editor-only phase.** This phase implements anchor authoring, `.prop` round-trip,
role queries, and an isolated rig-backed IK holding preview. All Sandbox Stage integration,
runtime loading, attachment, gameplay, physics, and scene persistence are future work.
2. **Fixed origin pivot.** Every prop has exactly one reserved anchor named `origin`, with
role `pivot`, at `(0, 0)`. It cannot be deleted, renamed, moved, or assigned another role.
Legacy `.prop` files synthesize it on load.
3. **One-hand authoring preview.** The selected grip follows the solved wrist endpoint of an
isolated preview rig. Secondary grips remain serialized/queryable and may be previewed one at
a time, but never drive the opposite hand.
These are requirements, not implementation suggestions.
## 2. Scope
### 2.1 In scope
- Extend the existing `[ Arrange | Reshape ]` toolbar and mode enum with one new `Attach`
mode. Arrange and Reshape retain their current labels, behavior, and tests.
- Add a normalized role-based anchor dictionary to `.prop` v1.0.
- Preserve legacy `.prop` compatibility by synthesizing the fixed `origin` pivot.
- Add anchor list/property editing, add/delete/rename, role selection, quick presets, and
role-specific canvas gizmos.
- Add anchor translation, grid snapping, rotation handles, and an isolated rig-backed preview
where a stickman visibly holds the prop while its selected arm is posed through IK.
- Include anchors in ordinary save/load, Save As, autosave, recovery, and New.
- Add pure role/hand query and transform helpers that the editor preview consumes and future
gameplay systems may reuse.
- Add headless regression coverage plus manual Prop Editor checks.
### 2.2 Explicitly deferred
- **All Sandbox Stage/runtime changes:** custom `.prop` loading/spawning, selector integration,
stage provenance/persistence, hand sockets, `StickmanPropAttachment`, equip/release, held-prop
z ordering, collision/freeze handling, ragdoll/drop behavior, and runtime mirroring.
- Secondary-hand IK and simultaneous two-hand constraint solving in gameplay. The editor preview
remains one-hand-at-a-time.
- Automatic grip choice, reach-to-pickup animation, pickup menus, director actions, or action
queue schema changes.
- Gameplay behavior for `grip`, `emitter`, `impact`, `mount`, or `custom` anchors. This phase
authors and previews metadata only.
- Projectile, particle, light, melee, accessory, throw, or center-of-mass systems.
- Full multi-shape runtime rendering/collision. The existing runtime flattening contract is not
modified; the editor preview may show the full authoring stack.
- Document-wide undo/redo. The Prop Editor currently has no undo system; adding anchor-only
undo would make shape and anchor editing inconsistent.
- Any `.scene` schema or save/load change.
## 3. Existing constraints
- `PropCanvas.EditMode` currently has only `TRANSFORM` and `POINTS`, and
`set_edit_mode()` clamps every non-POINTS value to TRANSFORM.
- `PropEditor` derives every save/autosave from `_build_json_data()`, so anchor document state
must enter through that single path.
- `PropFile.FILE_VERSION` is `"1.0"`; additive fields such as `reshaped` already use
backward-compatible v1.0 evolution.
- `PropBlock` and `PropUtils.spawn_prop()` are single-shape runtime objects, but Phase 9a does not
change or consume them. The editor preview uses non-physics visuals built from all shapes.
- `StickmanRig` contains runtime locomotion/ragdoll/action components. The editor preview may
instantiate it only inside a private SubViewport and must disable gameplay processing and
collisions; no rig/runtime class or scene is modified by this phase.
- `StickmanRig` mirrors its root on X for LEFT, while Godot TwoBoneIK is not mirror-invariant.
The preview must isolate reflection outside the positive-scale solving skeleton.
- `IK_Targets/Left_Hand` and `Right_Hand` are solver goals. Preview attachment must sample the
solved lower-arm visual endpoint, not pretend the target marker itself is the wrist.
## 4. `.prop` schema and normalization
### 4.1 Version policy
Keep `PropFile.FILE_VERSION == "1.0"` and `SUPPORTED_VERSIONS == ["1.0"]`. `anchors` is an
additive field. A missing or malformed `anchors` value is normalized to the mandatory origin
rather than rejecting an otherwise valid legacy file.
`PropFile.to_file_dict()` always emits `anchors`, including the fixed origin. Loading an old
file does not mark the editor dirty merely because the in-memory default was synthesized; its
next explicit save writes the new field.
### 4.2 File representation
```json
{
"version": "1.0",
"prop_name": "Arcane Staff",
"material_preset": "wood",
"shapes": [],
"anchors": {
"origin": {
"role": "pivot",
"x": 0.0,
"y": 0.0,
"rotation_deg": 0.0,
"z_index_offset": 0,
"allowed_hands": "both"
},
"primary_grip": {
"role": "grip",
"x": 0.0,
"y": 18.0,
"rotation_deg": 90.0,
"z_index_offset": 1,
"allowed_hands": "both"
},
"crystal_head": {
"role": "emitter",
"x": 0.0,
"y": -48.0,
"rotation_deg": -90.0,
"z_index_offset": 0,
"allowed_hands": "both"
}
},
"metadata": {}
}
```
Every serialized anchor uses the same six fields. Uniform entries make validation, UI binding,
and future consumers deterministic even when a field is not currently meaningful for a role.
### 4.3 Internal representation
The normalized editor model remains a `Dictionary` keyed by anchor ID, matching the project's
JSON-first asset conventions:
```gdscript
{
"primary_grip": {
"role": "grip",
"position": Vector2(0.0, 18.0),
"rotation_deg": 90.0,
"z_index_offset": 1,
"allowed_hands": "both",
},
}
```
Do not assign JSON `{x, y}` dictionaries directly into typed vector arrays or vector fields.
Parse `x` and `y` explicitly, following the existing `_coerce_points()` serialization lesson.
### 4.4 Registry and validation rules
- Roles: `pivot`, `grip`, `emitter`, `impact`, `mount`, `custom`.
- Allowed hands: `both`, `left_only`, `right_only`.
- IDs use lowercase snake case: `^[a-z][a-z0-9_]{0,31}$`.
- UI input is normalized by trimming, lowercasing, replacing runs of non-alphanumeric
characters with `_`, trimming `_`, and adding `_2`, `_3`, ... until unique.
- Position values must be finite; invalid values become `0.0`.
- `rotation_deg` must be finite and is wrapped into `[-180.0, 180.0)`.
- `z_index_offset` is clamped to `[-5, 5]`.
- Unknown roles become `custom`; unknown hand values become `both`.
- The `origin` entry is overwritten with the canonical fixed pivot on every normalization.
- Any non-origin entry claiming role `pivot` is preserved but downgraded to `custom`, with one
warning for that normalization pass. No user data coordinates are discarded.
- Malformed non-dictionary entries are skipped with a warning.
Anchor position is always in canonical prop-local pixels. `rotation_deg` is the direction of
the anchor's local **+X axis**. For a grip, +X points from elbow toward wrist; for an emitter,
it is the output direction. It is orientation metadata, not an instruction to rotate the prop's
stored geometry.
### 4.5 `PropAnchorData` contract
Add `scripts/prop_anchor_data.gd`:
```gdscript
class_name PropAnchorData
extends RefCounted
const ORIGIN_ID: String = "origin"
const ROLE_STRINGS: PackedStringArray = [
"pivot", "grip", "emitter", "impact", "mount", "custom"
]
const ALLOWED_HAND_STRINGS: PackedStringArray = [
"both", "left_only", "right_only"
]
enum Hand { LEFT, RIGHT }
static func default_anchors() -> Dictionary
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary
static func serialize_anchors(anchors: Dictionary) -> Dictionary
static func normalize_id(candidate: String, anchors: Dictionary) -> String
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D
static func solve_attachment_transform(
target_transform: Transform2D,
anchor: Dictionary,
positive_scale: Vector2,
mirror_x: bool
) -> Transform2D
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary
```
All returned dictionaries/arrays that expose document state are deep duplicates. Callers must
not be able to mutate the codec's or canvas's state by retaining a reference.
When `mirror_x` is true, local position becomes `(-x, y)` and the +X direction angle becomes
`PI - angle`. The canonical stored anchor is never mutated.
`solve_attachment_transform()` returns a positive-determinant prop transform satisfying
`prop_transform * effective_anchor_transform == target_transform` for translation and +X
direction. Normalize `positive_scale` to finite absolute components greater than `0.0001`, compute
`prop_angle = target_transform.get_rotation() - effective_anchor_angle`, build the prop basis
from `prop_angle` and that positive scale, then set:
```gdscript
var prop_origin := (
target_transform.origin
- prop_basis.basis_xform(effective_anchor_position)
)
```
The helper ignores scale/skew on `target_transform`; callers supply a pure rotation/translation
target. Mirroring is represented only by the effective anchor plus separately mirrored preview
geometry, never by a negative prop-node scale.
### 4.6 `PropFile` API changes
```gdscript
static func to_file_dict(
prop_name: String,
material_preset: int,
shapes: Array[Dictionary],
anchors: Dictionary = {}
) -> Dictionary
static func from_file_dict(data: Variant) -> Dictionary
```
`from_file_dict()` returns:
```gdscript
{
"prop_name": String,
"material_preset": int,
"shapes": Array[Dictionary],
"anchors": Dictionary,
}
```
`flatten_to_payload()` remains shape-only and unchanged. Anchors are provenance/interaction
metadata, not collision geometry.
## 5. Prop Editor architecture
### 5.1 Node layout
Update `scenes/prop_editor.tscn` without changing unique-name lookup contracts:
```text
PropEditor
├── TopRow
│ ├── MenuBar
│ └── TopRightControls # existing Grid + global Snap source
├── TopBar
├── MainLayout
│ ├── CanvasPanel
│ │ └── CanvasArea
│ │ ├── ModeBar # existing Arrange/Reshape + new Attach
│ │ ├── AttachTools # visible only in Attach
│ │ └── CanvasWorkspace (HSplitContainer)
│ │ ├── PropCanvas # existing authoring canvas
│ │ └── PropAttachPreview # rig-backed holding preview; Attach only
│ └── InspectorColumn
│ └── InspectorScroll # NEW ScrollContainer
│ └── InspectorList # keep %InspectorList
├── SaveDialog / LoadDialog / ErrorDialog
└── StatusBar
```
`AttachTools` contains:
- `Show Stickman Preview` (`CheckBox`, default on, session-only tool state; collapses/opens the
preview side of `CanvasWorkspace`);
- `Snap Anchors` (`CheckBox`, bound bidirectionally to the same `_snap_enabled` value as the
existing top-right Snap checkbox);
- `Show Inactive Gizmos` (`CheckBox`, default on, session-only tool state).
The second Snap control is a synchronized view of one setting, not independent state. Changing
either checkbox updates both without signal recursion and calls `PropCanvas.set_snap_enabled()`.
### 5.2 Modes
Preserve the existing Arrange (`TRANSFORM`) and Reshape (`POINTS`) values and append Attach:
```gdscript
enum EditMode { TRANSFORM, POINTS, ATTACH }
```
`set_edit_mode()` clamps only values outside that enum. Do not renumber or reinterpret the first
two values. ATTACH persists across clear/load just like the existing modes. Append `Attach` /
`📌` to the controller-owned mode-label arrays; do not rebuild or rename Arrange/Reshape. Keep
all three buttons in the existing `ButtonGroup`, with `focus_mode = FOCUS_NONE` to preserve the
current selected styling.
Mode behavior:
| Behavior | Arrange | Reshape | Attach |
| --- | --- | --- | --- |
| Shape body translate | yes | select only | no |
| Shape transform gizmos | yes | no | no |
| Vertex handles/menu | no | yes | no |
| Shape right-click menu | yes | reshape matrix | no |
| Anchor gizmos/input | no | no | yes |
| Wheel/pinch/middle-pan | yes | yes | yes |
Shape selection may remain remembered while Attach is active, but no selection outline, shape
gizmo, or vertex handle is drawn in that mode. Switching back restores the remembered shape
selection.
### 5.3 Composed anchor interaction helper
Add `scripts/prop_anchor_overlay.gd`:
```gdscript
class_name PropAnchorOverlay
extends RefCounted
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
enum DragMode { NONE, TRANSLATE, ROTATE }
func set_anchor_data(data: Variant) -> void
func get_anchor_data() -> Dictionary
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
func set_show_inactive(enabled: bool) -> void
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String
func begin_pointer(world_pos: Vector2, zoom: float) -> bool
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void
func end_pointer() -> void
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void
```
This helper owns anchor selection/drag state and math, while `PropCanvas` remains the only owner
of pan, zoom, coordinate conversion, and `_gui_input`. The canvas routes left-button events to
the helper only in ATTACH and re-emits its two signals as public `PropCanvas` signals. The helper
draws only authoring gizmos; the isolated rig preview in §5.5 owns all pose visualization.
Add to `PropCanvas`:
```gdscript
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
func set_anchor_data(data: Variant) -> void
func get_anchor_data() -> Dictionary
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
func set_show_inactive_anchors(enabled: bool) -> void
func get_shapes_bounds() -> Rect2
```
An empty shape stack must not early-return before anchor drawing. Attach mode still shows the
origin and other anchors on an empty canvas.
### 5.4 Drawing and hit targets
Use code-drawn vector markers; do not add bitmap icon dependencies.
| Role | Marker | Core color |
| --- | --- | --- |
| pivot | crosshair | yellow |
| grip | hand-like circle + short palm bars | cyan |
| emitter | target reticle + direction arrow | red |
| impact | diamond + slash | bright pink |
| mount | ring + center dot | purple |
| custom | hexagon | light gray |
- Draw a dark halo behind every marker/line, matching the existing selection visibility rule.
- Marker radius and hit radius are constant screen sizes (`value / zoom`).
- Draw the selected marker last, with a yellow outer selection ring.
- When `Show Inactive Gizmos` is off, draw/hit-test only the selected anchor. If no anchor is
selected, continue showing the origin so the canvas is not visually empty.
- Pivot is selectable but never draggable and never receives a rotation handle.
- A selected `grip` or `emitter` shows a rotation ring and handle.
- Translation snaps each coordinate with `snappedf(value, grid_size)` only when the shared Snap
setting is enabled.
- Rotation is free by default; Ctrl snaps to 15-degree increments, consistent with Arrange.
- Emit `anchors_changed` during live drag for inspector feedback. Mouse release sanitizes the
final position/angle and emits once more only if sanitization changed it.
- Right click has no Attach-mode context menu; list/inspector buttons own add/delete/rename.
### 5.5 Rig-backed holding and IK preview
The preview must show a recognizable stickman holding the current prop, not a symbolic hand.
It is a separate authoring visualization with its own `SubViewport`, world, rig instance, camera,
pose state, and non-physics prop visual. It reuses the actual rig scene, `StkRigAdapter`, arm IK,
and the pure anchor-alignment math, while remaining isolated from Sandbox/gameplay state.
#### 5.5.1 Class and scene contract
Add `scripts/prop_attach_preview.gd` and `scenes/prop_attach_preview.tscn`:
```gdscript
class_name PropAttachPreview
extends PanelContainer
enum PreviewHand { LEFT, RIGHT }
enum FrameMode { UPPER_BODY, FULL_FIGURE }
func set_document(
shapes: Array[Dictionary],
anchors: Dictionary,
selected_anchor_id: String
) -> void
func set_selected_anchor(anchor_id: String) -> void
func set_preview_hand(hand: int) -> void
func set_facing_profile(profile: int) -> void
func set_arm_animation_enabled(enabled: bool) -> void
func reset_arm_pose() -> void
func frame_view(mode: int) -> void
func is_preview_available() -> bool
```
```text
PropAttachPreview (PanelContainer)
└── PreviewLayout (VBoxContainer)
├── PreviewHeader (HBoxContainer)
│ ├── Title: "Holding Preview"
│ ├── HandOption: Left / Right
│ ├── FacingOption: Forward / Left / Right
│ └── FrameOption: Upper Body / Full Figure
├── PreviewHint (Label)
├── PreviewViewportContainer (SubViewportContainer)
│ └── PreviewViewport (SubViewport)
│ └── PreviewWorld (Node2D)
│ ├── PreviewRigRoot (Node2D; preview-only mirror wrapper)
│ │ └── PreviewRig (dynamically instanced for this editor preview)
│ ├── PreviewProp (Node2D; generated visual only)
│ ├── PreviewOverlay (Node2D; target handle/instructions)
│ └── PreviewCamera (Camera2D)
└── PreviewFooter (HBoxContainer)
├── AnimateArm (CheckBox)
├── ResetArm (Button)
└── ResetView (Button)
```
The preview panel has a `360 px` minimum width and is the right pane of the resizable
`CanvasWorkspace` split. It is visible only in Attach mode and while `Show Stickman Preview` is
enabled. Hiding it must not change document data, selected anchor, or pose state outside this
private preview.
#### 5.5.2 Safe rig reuse and isolation
Create one private preview rig when the preview enters the tree:
1. Load `res://stickmen/test.stk` through `StickmanFactory.load_stk()`.
2. Instantiate `res://scenes/stickman_rig.tscn`, call `set_physics_process(false)` on the rig
before it enters the tree, and apply valid `.stk` data through `StkRigAdapter.apply()`. If the
file cannot be loaded, keep the scene's authored master visuals rather than failing the editor.
3. Add the rig only to `PreviewViewport`'s private `PreviewWorld`; after `_ready()`, stop its
`AnimationPlayer`, call `stop_queue()` then `clear_queue()`, retain the default `ANIMATED`
state, and set every descendant `CollisionObject2D` layer and mask to `0`.
4. Do not set the whole rig subtree to `PROCESS_MODE_DISABLED`, because that would also stop the
skeleton solve. The root physics callback is the gameplay tick boundary; authored component
children have no autonomous process callback.
5. Keep the `Skeleton2D` modification stack and `RemoteTransform2D` drivers active so moving an
IK target solves the real arm and updates the real Body visuals.
The preview instance is never added to Sandbox `World`, never registered with `StageSpawner`,
never serialized, and never connected to stage rules or signals. It must not create a
`PropBlock`, hand socket, or runtime attachment component. Preview pose/facing/hand settings are
session-only UI state. The preview receives deep copies from `PropEditor`, so no preview mutation
can alter the document except through the existing anchor inspector/canvas signals.
This boundary intentionally reuses visual rig/IK behavior but not gameplay ownership, physics,
ragdoll, locomotion, or action execution.
#### 5.5.3 Prop visual and shared alignment math
`PreviewProp` is rebuilt from the editor's complete `shapes[]` stack using one `Polygon2D` or
`Line2D` per shape, in document order. It has no collision node or physics body and therefore
shows all authored shapes even though the staged runtime still flattens collision/render payloads.
Coalesce repeated shape/anchor signals into at most one rebuild/alignment per rendered frame.
Add one pure helper so editor alignment is deterministic, directly testable, and reusable by a
future runtime design if that later specification adopts the same convention:
```gdscript
static func solve_attachment_transform(
target_transform: Transform2D,
anchor: Dictionary,
positive_scale: Vector2,
mirror_x: bool
) -> Transform2D
```
This belongs on `PropAnchorData`. Phase 9a has exactly one production caller
(`PropAttachPreview`). Keeping the transform pure and independent of preview nodes avoids
coupling the schema to the rig scene. A future runtime phase may reuse it only after separately
validating the convention. The preview may mirror its non-physics geometry directly, but
canonical document shapes/anchors remain unchanged.
Do not add HandSocket nodes to the runtime rig. After the skeleton/RemoteTransform update, derive
the preview wrist transform from existing nodes:
1. Resolve `Body/LeftLowerArm` or `Body/RightLowerArm` as the solved hanging-frame visual.
2. Resolve the matching lower `Bone2D` and read its fitted `length`.
3. Compute elbow = `lower_visual.to_global(Vector2.ZERO)` and wrist =
`lower_visual.to_global(Vector2(0, lower_length))`.
4. Direction = `(wrist - elbow).normalized()`; build a proper (positive determinant) socket
transform whose +X axis follows that direction and whose origin is wrist.
5. Pass that transform to `solve_attachment_transform()`.
`StkRigAdapter` already mounts lower-arm visuals into the +Y hanging frame and fits their extent
to the bone length, so this samples the visible endpoint without changing the rig or trusting the
IK goal marker as the solved wrist. If the nodes/direction are invalid, hide the preview prop and
show a non-fatal preview-unavailable hint.
When the selected entry is a grip, align it to this derived wrist transform. A one-frame
engine-order delay is acceptable while dragging; the following frame must be exact. When no grip
is selected, keep the rig visible, hide the prop visual, and show
`Select a grip anchor to preview holding.`
Preview `z_index_offset` relative to the rig root: negative behind, positive in front, and zero
placed immediately before the rig so the hand/body stays visible. This is an authoring convention
only; exact runtime layering is deliberately left to the future runtime specification.
#### 5.5.4 Hand and facing behavior
- Default preview hand is RIGHT and default facing is FORWARD.
- `left_only` forces LEFT and disables RIGHT in `HandOption`.
- `right_only` forces RIGHT and disables LEFT.
- `both` enables both choices and remembers the last session choice.
- Selecting any grip—including `secondary_grip`—previews that one grip as the active one-hand
attachment. It never moves the opposite hand or consumes another grip.
- Changing hand moves only the newly active hand target; first reset both hand targets to their
captured rest positions so poses do not leak between hand choices.
- Facing uses the preview rig's established `FacingProfile`, bend flags, and Body child ordering.
It does not write the document or affect a runtime rig.
- LEFT must exercise `PropAnchorData`'s effective-anchor mirroring so authors can evaluate the
intended facing behavior before a future runtime implementation exists.
The runtime rig deliberately disables idle limb IK under its negative-X LEFT root because Godot's
TwoBoneIK solver is not mirror-invariant. The preview must not bypass that known constraint by
forcing the solver under a reflected skeleton. For LEFT preview:
1. call `PreviewRig.set_facing_profile(LEFT)` to reuse bend flags and Body ordering;
2. restore `PreviewRig.scale = Vector2.ONE`;
3. mirror `PreviewRigRoot.scale.x = -1` so reflection occurs *after* the positive-scale skeleton
solves;
4. explicitly enable the two arm `SkeletonModification2DTwoBoneIK` entries for preview posing;
5. keep `PreviewProp` outside `PreviewRigRoot`, mirror its generated geometry, and align it to the
reflected socket with `solve_attachment_transform(..., mirror_x = true)`.
RIGHT/FORWARD use identity `PreviewRigRoot` and `mirror_x = false`. This establishes the editor's
facing convention while keeping the isolated preview solver stable. Tests must assert that LEFT
leaves `PreviewRig`'s local scale positive and puts the reflection only on the wrapper.
This preserves the approved one-hand authoring scope while allowing every authored grip to be
visually tested with either compatible anatomical hand.
#### 5.5.5 IK interaction
Draw a constant-screen-size cyan target handle at the active
`IK_Targets/Left_Hand` or `IK_Targets/Right_Hand` marker. The user may drag this handle inside the
preview viewport; the real TwoBoneIK modification bends the arm and the preview prop follows the
solved wrist socket.
Clamp manual and animated targets to the reachable annulus around the active upper-arm shoulder:
```text
min reach = abs(upper_length - lower_length) + 8 px
max reach = upper_length + lower_length - 8 px
```
If the drag vector is zero, use the captured rest-pose direction before clamping. This avoids
solver folding/jitter without inventing a second IK implementation.
`Animate Arm` provides a hands-off movement check:
- period: `3.0 s`, sine-eased ping-pong;
- endpoint A: the captured rest target;
- endpoint B: rest target plus `(outward * 120, -120)`, clamped to the same annulus;
- anatomical outward sign: LEFT `+1`, RIGHT `-1` in unmirrored rig-local space;
- starting a manual drag turns animation off;
- Reset Arm turns animation off and restores both hand targets to their captured rest values.
The inactive hand remains at rest. Head, torso, and leg targets remain untouched. No animation
clip plays while the arm target is manipulated.
#### 5.5.6 Camera and input
- `PreviewViewport.size` follows `PreviewViewportContainer.size`.
- Default frame is `UPPER_BODY`, fitting the union of head/torso/arms and current prop with a
`32 px` screen margin.
- `FULL_FIGURE` fits a preview-local union of the transformed `Body/*` visual bounds and current
prop AABB with the same margin. `PropAttachPreview` owns this bounds helper; it must not import
or call `StageSpawner`.
- The local bounds helper walks visible descendants recursively: transform each `Polygon2D.polygon`
point and each `Line2D.points` entry into `PreviewWorld` coordinates, expanding line bounds by
half the drawn width. `UPPER_BODY` filters to Head/Torso/arm branches; `FULL_FIGURE` includes
every `Body/*` branch.
- Camera framing is recalculated on preview resize, explicit Reset View, frame-mode change, or a
document geometry rebuild—not every arm-animation frame. This prevents zoom pumping.
- Mouse wheel performs cursor-centered preview zoom, clamped `[0.3, 3.0]` relative to the fitted
zoom. Middle mouse pans. These controls affect only the preview camera.
- Left drag moves only the active IK handle; clicks elsewhere do not select/edit anchors.
- The parent `PropCanvas` keeps its existing pan/zoom/input unchanged.
Convert pointer positions through the preview viewport rather than the authoring canvas:
```gdscript
var preview_world_pos := (
preview_viewport.get_canvas_transform().affine_inverse()
* preview_container_local_position
)
```
The viewport size must match the container's integer size (clamped to at least `1 × 1`), so this
mapping remains stable after split/window resize.
The preview overlay shows a short hint: `Drag the cyan hand target to test the grip.` It may show
the active anchor ID and hand, but it must not show gameplay/action controls.
### 5.6 Inspector
`PropEditor` owns normalized document state:
```gdscript
var _anchors: Dictionary = PropAnchorData.default_anchors()
var _selected_anchor_id: String = PropAnchorData.ORIGIN_ID
```
The existing Material section stays visible. In ATTACH, hide Selected Shape and Add Shape, and
show a new Anchor section containing:
1. `ItemList` of anchor IDs (the list itself scrolls);
2. `Add Anchor`, `Rename`, `Delete` buttons;
3. Role `OptionButton`;
4. X/Y `SpinBox` values (`-100000..100000`, step `1`);
5. Rotation `SpinBox` (`-180..<180`, step `1`) plus a matching slider if it can share the same
no-recursion update guard;
6. Z Offset `SpinBox` (`-5..5`, integer);
7. Allowed Hands `OptionButton`;
8. Quick Presets buttons: One-Handed, Two-Handed, Tool / Nozzle, Melee Edge.
For `origin`, Rename/Delete and every property control are disabled. Role-specific relevance is
communicated by muted labels, but the uniform data remains editable for non-origin anchors.
Add creates a uniquely named `anchor`, selects it, and defaults to role `custom` at the center of
the current shape bounds (or zero when empty). Delete selects the preceding list entry, falling
back to `origin`. Rename uses the ID normalizer and immediately updates selection.
Quick presets replace all non-origin anchors. If any exist, use a confirmation dialog before
replacement. Layout is derived from the union shape AABB:
- major axis = X when width >= height, else Y;
- direction angle = `0°` for X or `90°` for Y;
- center = bounds center, or zero for empty bounds;
- positive endpoint = right-center or bottom-center;
- quarter offset = `min(major_length * 0.2, 32)`.
Preset contents:
| Preset | Generated anchors |
| --- | --- |
| One-Handed | `primary_grip` at center |
| Two-Handed | `primary_grip` and `secondary_grip` at center ± quarter offset |
| Tool / Nozzle | `primary_grip` at center, `emitter` at positive endpoint |
| Melee Edge | `primary_grip` at center, `impact` at positive endpoint |
All generated anchors use the major-axis direction, `both`, and z offset `0`.
### 5.7 Dirty state, New, save, and recovery
- `_build_json_data()` passes `_anchors` to `PropFile.to_file_dict()`.
- `_apply_json_data()` assigns the normalized model anchors to both `_anchors` and canvas.
- `_do_new()` resets anchors to `default_anchors()` and selects `origin`.
- Canvas `anchors_changed` updates `_anchors`, calls `_mark_dirty()`, and refreshes inspector.
- After shape/anchor/selection changes, pass deep copies to
`PropAttachPreview.set_document()`/`set_selected_anchor()`; the preview coalesces visual work.
- Preview visibility, preview hand, facing, arm animation, camera, inactive-gizmo visibility,
and mode changes are tool state and do not mark the document dirty.
- Add/delete/rename/preset/property changes mark dirty through the same centralized path.
- Existing debounced autosave automatically includes anchors through `_build_json_data()`.
- Recovery restores anchors and keeps the document dirty, matching shape recovery.
- Thumbnails remain geometry-only; changing only anchors may update the `.prop` mtime/cache key
on save, but anchors are not drawn into the thumbnail.
## 6. Future Sandbox/runtime scope — not a Phase 9a deliverable
Phase 9a stops at the `.prop` anchor contract and editor-only preview. It must not modify
`PropBlock`, `PropUtils`, `StageSpawner`, `SandboxStage`, `StageSceneIO`, `StickmanRig`,
`StkRigAdapter`, `scenes/stickman_rig.tscn`, collision/physics behavior, director actions, or
runtime tests.
A later, separately approved specification may consume the saved anchor contract to design:
- custom `.prop` discovery, selector UI, spawning, provenance, missing-asset handling, and stage
scene persistence;
- a runtime held-prop visual/physics representation that addresses the existing multi-shape
flattening limitation;
- explicit wrist sockets or another stable solved-wrist API;
- one-hand and two-hand equip/release ownership;
- facing/mirroring and per-limb draw-order behavior;
- collision masks, freeze/reparent strategy, throwing, ragdoll/drop lifecycle, and save semantics;
- gameplay consumers for emitter/impact/mount/custom roles.
The future runtime spec should reuse the pure normalized anchor data and transform convention
where appropriate, but must validate them against runtime physics and animation rather than
treating the editor preview as authoritative gameplay behavior.
## 7. Signal and data flow
### 7.1 Editor
```mermaid
flowchart TD
Input[Canvas/Inspector input] --> Overlay[PropAnchorOverlay]
Overlay -->|anchors_changed deep copy| Canvas[PropCanvas]
Canvas -->|anchors_changed| Editor[PropEditor document state]
Editor --> Dirty[_mark_dirty + autosave debounce]
Editor --> Inspector[Anchor inspector refresh]
Editor -->|deep-copied shapes/anchors/selection| Preview[PropAttachPreview]
Preview --> PreviewRig[Private SubViewport rig + arm IK]
Preview --> PreviewProp[Non-physics full-shape visual]
Editor --> Save[_build_json_data]
Save --> Codec[PropFile.to_file_dict]
Codec --> Disk[.prop JSON]
```
The diagram ends at the `.prop` file and private preview. No arrow reaches Sandbox/runtime state.
## 8. Theme and UX requirements
- Reuse `PropTheme` font sizes, minimum row heights, text, muted text, border, and accent tokens.
- Do not mutate the global project theme.
- `InspectorScroll` fills the inspector column vertically and scrolls when the anchor controls do
not fit.
- `PropAttachPreview` uses the same local `_ui_theme` tokens as the editor; the viewport
background uses `canvas_background_color()` and the panel/header use existing border/text/
muted/accent tokens.
- The preview split is user-resizable, never shrinks below `360 px` while open, and collapses
completely when hidden so existing Arrange/Reshape canvas space is unchanged.
- Anchor list/buttons meet the existing theme's minimum hit height.
- Disabled origin controls remain readable using muted text, with tooltip: “Origin is the fixed
prop pivot.”
- Add/delete/rename/preset actions must keep keyboard focus from causing the mode-selected style
regression; mode buttons remain `FOCUS_NONE`.
- Status cursor coordinates continue to report prop world coordinates in Attach mode.
- No new image assets are required.
## 9. Tests and engine validation
### 9.1 New headless suite
Add `tests/test_phase9a_prop_attach.gd` (`extends SceneTree`, PASS/FAIL, `quit(0/1)`) covering:
1. default anchors contain only canonical origin;
2. legacy file without anchors synthesizes origin;
3. origin tampering is overwritten and extra pivot roles downgrade to custom;
4. finite/default/clamp/wrap validation;
5. ID normalization and collision suffixes;
6. serialize/parse round-trip asserts coordinate and rotation **values**, not only counts;
7. role and allowed-hand queries;
8. X-mirrored anchor position/direction math;
9. all four quick-preset layouts for horizontal, vertical, and empty bounds;
10. `PropCanvas.EditMode.ATTACH` persists through clear/load and blocks shape drag routing;
11. fixed origin cannot begin translate/rotate;
12. snapped and unsnapped anchor translation plus Ctrl rotation snapping;
13. preview rig lives under its private `SubViewport` world and adds nothing to a Sandbox world;
14. preview disables rig physics ticking/collisions and animation playback while leaving the
Skeleton2D modification stack enabled;
15. preview visual creates one node per authored shape and never creates `PropBlock` or collision;
16. `left_only`/`right_only` force the compatible preview hand while `both` permits either;
17. the derived wrist transform (lower-arm visual origin plus fitted lower-bone length) maps the
selected anchor to the solved wrist within tolerance;
18. manual preview target clamping respects derived arm reach and leaves the inactive target at
its captured rest position;
19. arm auto-motion changes only the active hand and Reset Arm restores both rest targets;
20. FORWARD/RIGHT/LEFT use the same editor transform solver; LEFT reflection lives on
`PreviewRigRoot`, the rig's local scale stays positive, and arm IK remains enabled;
21. selecting `secondary_grip` previews it with exactly one hand and never moves the other;
22. frame-mode/reset/resize recalculate camera framing, while arm-motion ticks do not change
fitted zoom.
### 9.2 Existing regressions
Run all Prop Editor suites listed in `AGENTS.md`. Because the editor-only preview instantiates
the existing rig without changing its runtime contracts, also run the rig modularization suite
as a read-only compatibility regression. At minimum:
- `tests/test_prop_editor_file.gd`
- `tests/test_prop_editor_canvas.gd`
- `tests/test_prop_editor_theme.gd`
- `tests/test_prop_editor_thumb.gd`
- `tests/test_prop_editor_round3.gd`
- `tests/test_phase8_rig_modularization.gd`
No Sandbox Stage suite is part of Phase 9a validation because this phase does not modify or
integrate with Sandbox/runtime systems.
### 9.3 Static checks
Run Godot 4.7.1 `--headless --check-only` for every changed/new script, especially:
- `prop_anchor_data.gd`
- `prop_anchor_overlay.gd`
- `prop_attach_preview.gd`
- `prop_file.gd`
- `prop_canvas.gd`
- `prop_editor.gd`
- the new test suite
If new `class_name` entries are not in the global script cache, run one headless editor rescan
before per-script checks, per technical-debt item #22.
## 10. Manual acceptance checklist
### Prop Editor (F6 `res://scenes/prop_editor.tscn`)
- Existing Arrange/Reshape behavior and labels are unchanged; the appended Attach button renders
and all three selected states remain visible.
- Attach mode shows anchors while hiding every shape selection/vertex/transform gizmo.
- Pan, wheel zoom, pinch, and Reset View remain correct.
- Add/rename/delete work; origin controls are disabled and origin cannot be dragged.
- Anchor translation stays under the cursor at multiple zoom levels and snaps from either Snap
checkbox.
- Grip/emitter rotation ring works; Ctrl snaps 15 degrees.
- Inactive toggle and role marker colors/icons are legible over light and dark props.
- Opening the Stickman Preview shows the real default rig visibly holding the complete multi-shape
prop at the selected grip; selecting a non-grip shows the instructional empty state.
- Left/Right hand choice obeys `allowed_hands`; any selected secondary grip is previewed as a
one-hand grip and the opposite hand stays at rest.
- Dragging the cyan target visibly bends the actual IK arm and keeps the prop locked to the solved
wrist; Animate Arm sweeps smoothly, manual drag cancels it, and Reset Arm restores the pose.
- Forward/Right/Left preview facing catches mirrored anchor offsets without modifying document or
any state outside the private preview.
- Upper Body / Full Figure framing, preview wheel zoom, middle pan, Reset View, split resize, and
show/hide work without affecting the authoring canvas camera.
- Preview z offset visibly swaps the prop behind/in front according to the editor preview
convention.
- Inspector scrolls at a small window size.
- Save/load and crash-recovery preserve anchors exactly; legacy props open with origin.
- Quick presets match horizontal and vertical prop bounds.
## 11. Architectural trade-offs
| Decision | Benefit | Cost / follow-up |
| --- | --- | --- |
| Additive `.prop` v1.0 anchors | Old files remain valid; no migration branch | Version alone cannot advertise anchor capability; consumers query presence/defaults |
| Fixed origin pivot | Stable coordinates and no geometry/COM migration | User cannot choose a custom physics pivot in this phase |
| JSON dictionary + static codec | Matches current project and is easy to inspect | GDScript cannot statically type dictionary fields; normalization boundary is essential |
| Composed anchor helper | Keeps anchor state/hit math out of the already large canvas | Canvas remains the input/coordinate owner and must route Attach events explicitly |
| Isolated rig-backed preview | Shows real IK bending, facing, custom stickman visuals, and the full authored prop | Adds a private SubViewport/rig instance and requires strict disabling of gameplay processing/collision |
| One-hand-at-a-time preview | Keeps anchor evaluation focused and matches the approved authoring scope | It does not solve or demonstrate two-hand constraints |
| Derived visual wrist, no sockets | Reuses the fitted rig while avoiding any rig scene/runtime contract change | Depends on the lower-arm visual's established hanging-frame convention and fitted lower-bone length |
| Editor-only facing convention | Exposes mirrored offset mistakes during authoring without touching gameplay state | A future runtime design must validate facing and draw order independently |
| Full multi-shape preview | Faithfully shows the authored prop inside the editor | It deliberately does not prove how a future runtime representation will render or collide |
| No anchor-only undo | Consistent editor expectations | Full shape + anchor document undo remains future work |
## 12. Implementation order
1. `PropAnchorData` and schema tests.
2. `PropFile` round-trip and legacy normalization.
3. Append Attach to the existing mode system; add overlay math/drawing, inspector, presets, and
save/autosave integration while preserving Arrange/Reshape.
4. Add the isolated rig-backed `PropAttachPreview`, shared editor transform solver, derived wrist
transform, IK interaction, and camera/layout behavior without changing rig/runtime files.
5. Complete save/autosave/recovery integration, the editor-only headless suite, static checks,
and manual hand-off.
Implementation must stop after engine validation and ask the user to verify in Godot. README or
other product documentation updates wait until the user confirms the feature works.
+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/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.
+638
View File
@@ -0,0 +1,638 @@
# Phase 9c — Prop Editor UX and Walking Hold Preview
Status: **approved specification — ready for implementation** (2026-09-21).
Phase 9c is a Prop Editor-only refinement discovered during Phase 9a/9b user verification. It
does not amend their saved-file contracts and does not add Sandbox Stage or gameplay behavior.
## 1. Approved decisions
The user approved these interaction choices:
1. Replace the preview's separate Facing/Frame controls with one **Preview Motion** selector:
`Front Pose`, `Walk Left`, and `Walk Right`, plus a dynamic `Pause Walk` / `Resume Walk`
button.
2. Rename anchors through a modal, focused text-entry dialog. The current automatic
`old_name_renamed` behavior is removed.
3. Rename `Show Inactive Gizmos` to **Show Other Markers**. When off, it hides the passive
origin marker and all unselected anchor markers while the selected editable anchor remains
visible.
This work is named Phase 9c because Phase 9a and 9b are already implemented and verified. It
changes preview motion ownership and established UI contracts rather than retroactively changing
those completed specifications.
## 2. Complete requested refinement set
| # | Requirement | Phase 9c contract |
| --- | --- | --- |
| 1 | Rename accepts a name | Open a modal LineEdit dialog, normalize the entered value, preserve the anchor payload, and select the renamed anchor. |
| 2 | Remove `(0,0)` copy | Keep only the small passive ring/cross at the canonical origin. |
| 3 | Inspector background `#262c39` | Add `ui.colors.inspector_background` to `prop_theme.json` and a validated accessor. |
| 4 | No add-shape hint in Reshape | The empty-canvas right-click hint is Arrange-only. |
| 5 | Clear hand names | Preview items read `Left Hand` and `Right Hand`. |
| 6 | Remove ineffective frame choice | Remove Upper Body/Full Figure and always frame the complete rig plus visible prop. |
| 7 | Replace synthetic arm animation | Preview Front Pose, Walk Left, and Walk Right; the old control becomes Pause/Resume Walk. |
| 8 | Preserve manual arm IK | The cyan target remains draggable; starting a drag pauses gait playback first. |
| 9 | Hide Material in Attach | Group the full Material UI and hide it only in Attach. |
| 10 | Remove duplicate Snap Anchors | Top-row Snap is the sole snap control and source of truth. |
| 11 | Preview checked on Attach entry | Every transition into Attach checks and shows the preview; the user may hide it for that Attach session. |
| 12 | Make marker visibility meaningful | Show Other Markers controls passive origin plus unselected anchor gizmos. |
| 13 | Fix clipped Grid/Snap | Use container-driven top rows, themed minimum heights, and vertical margins. |
| 14 | Remove preview blue border | Retain the dark preview surface but use no colored border. |
## 3. Scope and preserved contracts
### 3.1 In scope
- `PropEditor`, `PropCanvas`, `PropAttachPreview`, Prop Editor scene layout, and PropTheme UI
presentation.
- Preview-only use of the existing `stickman_rig.tscn` animation and IK nodes.
- Regression tests for all new UI state and preview ownership behavior.
### 3.2 Explicitly out of scope
- Any Sandbox Stage, `PropBlock`, spawner, runtime equip/release, physics, collision, terrain,
director, or action-queue change.
- Navigation or actual root translation in the holding preview. Walking is an in-place visual
gait check.
- A new animation asset, animation blending layer, or simultaneous animation/manual ownership
of the same hand IK target.
- `.prop` schema/version changes. Anchor IDs remain the persisted names; no separate display-name
field is added.
- Persistent UI preferences for preview motion, playback, marker visibility, or preview
visibility.
- Document-wide undo/redo, already tracked by debt item #37.
### 3.3 Saved-document behavior
Only a successful anchor rename mutates document data. It marks dirty once, arms the existing
autosave debounce once, refreshes the inspector/canvas/preview, and is included automatically in
ordinary save, Save As, autosave, recovery, and load round trips.
Preview motion, paused state, chosen preview hand, Show Stickman Preview, and Show Other Markers
are session-only tool state. Changing them must not mark dirty, arm autosave, or alter serialized
shape/anchor data. Hiding the Material section never changes the current material preset.
## 4. Existing rig and animation constraints
`stickman_rig.tscn` contains looped `walk_left` and `walk_right` clips. The actual
`StickmanLocomotion.walk_to()` path selects a LEFT/RIGHT facing profile and plays `walk_right`
for both directions. LEFT is represented through the rig's facing/mirroring policy, not by
playing `walk_left` during runtime locomotion.
Phase 9c must follow that proven runtime gait rule:
- `Walk Right`: RIGHT facing, positive preview wrapper, play `walk_right`.
- `Walk Left`: LEFT facing, keep the solving skeleton positive, mirror `PreviewRigRoot`, play
`walk_right`.
- `Front Pose`: FORWARD facing, player stopped, authored standing targets restored.
The existing `walk_left` asset is not deleted or modified. The preview must not call
`StickmanRig.walk_to()`: doing so would introduce NavigationServer/map dependencies and unwanted
root translation. It directly owns only its private AnimationPlayer.
The existing isolation contract remains mandatory:
- rig physics processing disabled;
- action queue stopped and cleared;
- no terrain following or navigation request;
- all collision layers/masks disabled;
- preview prop contains visual CanvasItems only;
- no reference to Sandbox Stage state.
## 5. Target files and typed contracts
| File | Required change |
| --- | --- |
| `scripts/prop_editor.gd` | Rename dialog; mode visibility; one snap control; Attach-entry preview default; marker toggle routing; theme/layout application. |
| `scripts/prop_canvas.gd` | Arrange-only empty hint; origin visibility API; remove origin text. |
| `scripts/prop_attach_preview.gd` | Replace synthetic motion and Frame/Facing UI with the approved motion state machine; preserve manual IK and wrist attachment. |
| `scripts/prop_theme.gd` | Add validated inspector background accessor/fallback. |
| `prop_theme.json` | Add `ui.colors.inspector_background: "#262C39"`. |
| `scenes/prop_editor.tscn` | Container-driven editor chrome and responsive top-row margins/minimum sizing. |
| `scenes/prop_attach_preview.tscn` | No structural requirement beyond retaining the scripted PanelContainer shell. |
| `tests/test_phase9c_prop_editor_ux.gd` | New Phase 9c unit/integration suite. |
| Existing Prop Editor tests | Replace assertions for removed controls and add token/layout regressions where they naturally belong. |
No change is required in `prop_file.gd`, `prop_anchor_data.gd`, `stickman_rig.gd`,
`stickman_locomotion.gd`, or `stickman_rig.tscn` unless implementation uncovers a verified defect
independent of this feature. Such a discovery must be reported rather than folded into Phase 9c.
## 6. Node and layout design
Keep dialogs as direct children of the editor root. Put visible editor chrome under one
full-rect `VBoxContainer` so font and hit-target tokens determine row height instead of fixed
32 px offsets:
```text
PropEditor (Control)
├── EditorChrome (VBoxContainer, full rect)
│ ├── TopRow (HBoxContainer, min height >= button_min_height + margins)
│ │ ├── MenuBar (expand)
│ │ └── TopRightMargin (MarginContainer)
│ │ └── TopRightControls (HBoxContainer)
│ │ ├── Grid label
│ │ ├── Grid SpinBox
│ │ ├── Snap CheckBox
│ │ └── Center Prop Button
│ ├── TopBar (HBoxContainer, themed minimum height)
│ │ ├── Prop Name label
│ │ └── PropNameEdit
│ ├── MainLayout (HSplitContainer, expand/fill)
│ │ ├── CanvasPanel
│ │ │ └── CanvasArea
│ │ │ ├── ModeBar
│ │ │ ├── AttachTools (FlowContainer)
│ │ │ │ ├── Show Stickman Preview
│ │ │ │ └── Show Other Markers
│ │ │ └── CanvasWorkspace (HSplitContainer)
│ │ │ ├── PropCanvas
│ │ │ └── PropAttachPreview
│ │ └── InspectorColumn
│ │ └── InspectorScroll
│ │ └── InspectorList
│ │ ├── MaterialSection
│ │ ├── ShapeSection
│ │ ├── AddSection
│ │ └── AnchorSection
│ └── StatusBar
├── SaveDialog / LoadDialog / ErrorDialog
├── ExitDialog / RecoveryDialog / OffOriginSaveDialog
└── RenameAnchorDialog
```
Existing unique names remain unique so controller lookup continues through `%NodeName`.
Path-based tests must migrate to unique lookup or the revised hierarchy instead of forcing the
old fixed layout to remain.
`TopRightMargin` uses small vertical margins (nominally 4 px top and bottom). Grid SpinBox,
Snap CheckBox, Center Prop, and both top rows use at least `PropTheme.button_min_height()`. The
layout must remain unclipped at the committed 18–22 px font tokens and when a custom theme raises
those font sizes. Attach tools use a `FlowContainer` so the two longer kid-facing labels wrap
instead of extending outside a narrow canvas pane.
## 7. Theme and surface styling
Extend both `PropTheme.DEFAULT_CONFIG` and the committed JSON:
```json
{
"ui": {
"colors": {
"inspector_background": "#262C39"
}
}
}
```
Add:
```gdscript
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
```
The InspectorColumn panel style derives its background only from this accessor. Keep the subtle
theme border and split grabber so the adjustable inspector boundary remains legible.
`PropAttachPreview` retains its dark surface but sets all border widths to zero. It must not use
the accent color as a frame border. Pane separation is supplied by the split grabber and the
contrast between preview and inspector backgrounds.
## 8. Anchor rename workflow
### 8.1 Dialog contract
Add typed state:
```gdscript
var _rename_anchor_dialog: ConfirmationDialog
var _rename_anchor_edit: LineEdit
var _rename_anchor_source_id: String = ""
```
`Rename` is enabled only for a selected editable non-origin anchor. Pressing it:
1. stores the selected ID in `_rename_anchor_source_id`;
2. fills the LineEdit with that ID;
3. opens the dialog;
4. defers focus and selects all text so typing replaces the old name.
The dialog title is `Rename Anchor`, confirmation text is `Rename`, and cancel closes without
mutation. The confirm button is disabled when the trimmed entry is empty.
### 8.2 Commit algorithm
On confirm:
```gdscript
var remaining := _anchors.duplicate(true)
var value := remaining[_rename_anchor_source_id].duplicate(true)
remaining.erase(_rename_anchor_source_id)
var new_id := PropAnchorData.normalize_id(_rename_anchor_edit.text, remaining)
```
- Human input is normalized with the existing lowercase-snake-case rules.
- A collision receives the existing deterministic `_2`, `_3`, ... suffix.
- Renaming to the same normalized ID is a no-op: do not dirty or autosave.
- Otherwise insert the unchanged payload at `new_id`, normalize anchors once, replace selection,
update canvas selection, mark dirty once, refresh the inspector, and sync the preview once.
- Position, role, allowed hands, rotation, and Z offset must remain byte/value equivalent.
- Clear `_rename_anchor_source_id` on confirmed commit, cancel, hide, New, and load so a stale
dialog cannot rename another document's anchor.
No `display_name` field is introduced. The normalized ID displayed in the list is the saved ID.
## 9. Marker and canvas behavior
### 9.1 Passive origin marker
Add to `PropCanvas`:
```gdscript
var _show_origin_marker: bool = true
func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
```
`_draw_origin_marker()` retains its constant-screen-size dark halo, ring, and cross, but removes
the font lookup and `(0,0)` `draw_string()` call. It remains non-interactive and is drawn at
world `Vector2.ZERO` in Arrange, Reshape, and Attach whenever `_show_origin_marker` is true.
### 9.2 Show Other Markers
The Attach toolbar contains one default-on `Show Other Markers` CheckBox. Its callback routes
the same value to:
```gdscript
_canvas.set_show_inactive_anchors(enabled)
_canvas.set_show_origin_marker(enabled)
```
The anchor overlay continues to draw and hit-test the selected editable anchor when false while
hiding every unselected editable anchor. The origin marker follows the setting in all modes,
including after leaving Attach. The setting is session-only and is not reset by switching modes,
New, load, or save.
### 9.3 Empty-canvas hint
Draw `Right-click to add a shape` only when all are true:
- `shapes.is_empty()`;
- mode is `EditMode.TRANSFORM` (Arrange);
- the canvas font is available.
Reshape and Attach show no add-shape hint. The origin marker is evaluated independently afterward.
## 10. Inspector and mode synchronization
Wrap the existing Material title, preset OptionButton, mass, friction, and bounce labels in
`_material_section: VBoxContainer`.
Mode visibility is authoritative in `_sync_mode_controls(mode)`:
| Section/control | Arrange | Reshape | Attach |
| --- | --- | --- | --- |
| MaterialSection | visible | visible | hidden |
| ShapeSection | selected shape only | selected shape only | hidden |
| AddSection / Erase Shape | visible | hidden | hidden |
| AnchorSection / AttachTools | hidden | hidden | visible |
| Holding Preview | hidden | hidden | visible when checked |
Delete `_snap_anchors_check`, `_on_snap_anchors_toggled()`, and the reverse synchronization from
`_on_top_snap_toggled()`. The existing top Snap CheckBox remains the only writer of
`PropCanvas.set_snap_enabled()`. Anchor drag already consumes the canvas's shared snap state and
grid size; this behavior must not be duplicated in the controller.
Track the last canvas mode. On a real transition from Arrange/Reshape into Attach:
```gdscript
_show_preview_check.set_pressed_no_signal(true)
```
then show/sync the preview. Toggling it off while already in Attach stays off until the user
leaves and re-enters Attach. Repeated inspector refreshes or document updates while remaining in
Attach must not force it back on.
## 11. Holding-preview UI
The dynamic preview hierarchy becomes:
```text
PropAttachPreview
└── VBoxContainer
├── Header (FlowContainer)
│ ├── Holding Preview title
│ ├── HandOption: Left Hand / Right Hand
│ └── MotionOption: Front Pose / Walk Left / Walk Right
├── HintLabel
├── SubViewportContainer
└── Footer (FlowContainer)
├── WalkPlaybackButton: Pause Walk / Resume Walk
├── Reset Arm
└── Reset View
```
Remove `_facing_option`, `_frame_option`, `FrameMode`, and `_animate_check`. The initial hand
remains Right Hand. The initial motion is Front Pose. The control hit rectangles continue to use
the full themed minimum height and `MOUSE_FILTER_STOP`.
The motion choice is a preview tool preference: `set_document()` does not reset it, so authors
can compare multiple props in the same walk. Opening a fresh editor instance begins at Front
Pose. New/load do not serialize or dirty this state.
## 12. Preview motion and IK ownership state machine
### 12.1 Typed state/API
```gdscript
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
var _walk_paused: bool = true
var _manual_arm_override: bool = false
var _hand_target_rests_local: Dictionary = {}
func set_preview_motion(motion: int) -> void
func set_walk_paused(paused: bool) -> void
func toggle_walk_playback() -> void
func reset_arm_pose() -> void
```
Standing hand target rest values are captured as each Marker2D's **local `position`**, never as
global positions. Animation tracks also write these local positions, so reset remains correct
under the LEFT preview-wrapper mirror.
### 12.2 State transitions
```mermaid
stateDiagram-v2
[*] --> FrontPose
FrontPose --> WalkPlaying: choose Walk Left / Walk Right
WalkPlaying --> WalkPaused: Pause Walk
WalkPlaying --> WalkPaused: begin cyan-target drag
WalkPaused --> WalkPlaying: Resume Walk
WalkPaused --> FrontPose: choose Front Pose
WalkPlaying --> FrontPose: choose Front Pose
WalkPlaying --> WalkPlaying: switch walk direction
WalkPaused --> WalkPlaying: switch walk direction (restart selected gait)
```
Exact transition behavior:
- **Front Pose entry:** stop AnimationPlayer with reset, set FORWARD profile/wrapper, restore all
standing target local positions, enable arm IK, set `_walk_paused = true`, clear manual
override, update marker/prop, and reframe.
- **Walk Left/Right entry:** clear manual override, apply the requested profile/wrapper, enable
arm IK, start `walk_right` from time `0.0`, set `_walk_paused = false`, update button copy,
and reframe. Changing between walk directions restarts at `0.0` for deterministic pose and
mirror state.
- **Pause:** call AnimationPlayer pause without resetting targets or playhead. The currently
solved pose freezes; button becomes `Resume Walk`.
- **Resume:** resume the current walk clip from its saved playhead, clear manual override, and
change the button to `Pause Walk`.
- **Manual drag begin:** if walking, pause first; then set manual override true and allow the
selected hand target to be clamped/dragged. Mouse release ends pointer capture but intentionally
leaves walking paused and preserves the authored inspection pose.
- **Reset Arm in Front:** restore both hand targets from local standing rests, clear manual
override, remain stopped.
- **Reset Arm during a paused walk:** re-seek/reapply the current animation playhead so the
dragged target returns to the frozen gait pose, pause again, clear manual override, and keep
the same direction/playhead.
- **Reset Arm during a playing walk:** first pause, then apply the paused-walk rule.
- **Hand selection/forced compatibility change:** keep the current motion direction and playback
state, clear any manual override by reapplying the current front/gait pose, switch the cyan
marker and prop binding, and do not restart a playing clip unless reapplication requires it.
`Pause Walk` is disabled in Front Pose and carries a tooltip such as `Choose Walk Left or Walk
Right to preview walking.` `Reset Arm` remains enabled when a compatible grip is visible.
### 12.3 Frame ownership and prop following
```mermaid
flowchart LR
Motion[Preview Motion selector] --> Player[Private AnimationPlayer]
Player --> Targets[IK target Marker2D positions]
Drag[Cyan target drag] -->|pauses player, then owns selected target| Targets
Targets --> IK[TwoBoneIK solve]
IK --> Wrist[Solved lower-arm visual endpoint]
Wrist --> Solve[PropAnchorData.solve_attachment_transform]
Solve --> Prop[Full multi-shape preview prop]
```
There is exactly one writer for the selected hand target at a time. Phase 9c deliberately does
not blend manual offsets additively over a moving animation. This avoids frame-order fights
between AnimationPlayer and pointer input.
Every preview frame still updates the cyan marker and derives the held prop transform from
`_solved_wrist_transform()`, never directly from the IK target. It therefore follows arm swing,
paused gait, and manual IK poses. Direction changes retain the positive-scale solver/wrapper
mirror strategy and never mutate stored anchor coordinates.
## 13. Camera behavior
Delete the upper-body filtering branch. `_reframe_camera()` always unions:
- every visible rig polygon/line, including legs;
- every visible authored prop polygon/line/circle visualization.
Grow the union by the existing safe margin, center the Camera2D, and fit it to the viewport.
`Reset View` calls this one function. Wheel zoom and middle-mouse pan remain unchanged. Resizing
the preview pane may refit as it does today. The removed frame choice carries no persisted state.
## 14. Signals and update flow
No public document signal payload changes:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
```
Rename flow:
```text
Rename button
-> RenameAnchorDialog + focused LineEdit
-> normalize entered ID against anchors excluding old ID
-> replace dictionary key, retain payload
-> canvas.set_anchor_data + canvas.select_anchor
-> mark dirty once
-> refresh anchor inspector
-> preview.set_document
```
Preview controls communicate internally through direct typed methods because the preview owns
the complete private visualization. No global signal bus or runtime component is introduced.
## 15. Edge cases
- No selected editable anchor: Rename remains disabled and cannot open a stale source ID.
- Rename entry empty/whitespace: confirmation disabled.
- Rename normalizes to current ID: no-op and no dirty event.
- Rename collides after normalization: deterministic numeric suffix; no existing anchor replaced.
- Rename while autosave countdown exists: one ordinary document mutation resets the debounce.
- Origin: never appears in the editable list and cannot be renamed.
- No shapes in Reshape: blank grid plus optional origin marker; no add-shape hint.
- Show Other Markers off with no selection: all editable markers and origin are hidden.
- Show Other Markers off with selection: selected marker and its rotation dot remain visible and
hit-testable; all others and origin are hidden.
- Preview hidden, then Attach exited/re-entered: re-entry checks and shows it again.
- Attach document has no compatible grip: preview shows its existing selection guidance; gait
controls may animate the rig, but prop and cyan hold target remain unavailable as appropriate.
- Hand restriction forces another hand during a paused/manual pose: current preview pose is
reapplied before binding the new hand, preventing stale global target placement.
- Walk paused at loop boundary: resuming remains looped and does not restart unless direction
changes.
- Prop extends far beyond the figure: full framing includes it; no separate frame selector.
- Custom large UI fonts: top controls grow through containers instead of clipping.
- Invalid/missing new theme token: accessor returns exact `#262C39` fallback and warns only under
the existing PropTheme rules.
## 16. Automated validation
Add `tests/test_phase9c_prop_editor_ux.gd` as a headless SceneTree suite and update affected
existing suites. Assertions must verify state and values, not only node counts.
### 16.1 Rename
1. Rename opens a ConfirmationDialog with the current ID selected in a focused LineEdit.
2. Empty/whitespace input disables confirmation.
3. `Main Handle` becomes `main_handle` and preserves every anchor payload field.
4. A collision becomes `main_handle_2` without replacing the existing anchor.
5. Same-normalized-name commit emits no dirty mutation.
6. Successful rename updates list selection, canvas selection, preview selected ID, save output,
and load round trip.
7. Cancel/New/load clears pending rename source and does not mutate the document.
### 16.2 Canvas, modes, and markers
8. Origin drawing has no `(0,0)` string path and remains present by default in all three modes.
9. Show Other Markers false hides origin and unselected anchors in draw/hit-test while preserving
selected anchor interaction.
10. Arrange alone shows the empty-canvas right-click hint.
11. Material is visible in Arrange/Reshape and hidden in Attach without changing preset.
12. Attach toolbar contains no Snap Anchors control; top Snap still snaps anchor dragging by the
configured Grid value.
13. A real transition into Attach checks/shows preview; ordinary refresh while hidden does not
re-check it; leaving and re-entering does.
### 16.3 Theme/layout
14. Default and committed theme return exact inspector background `#262C39`.
15. Custom valid override works; invalid value falls back under current validation.
16. Inspector surface consumes the accessor.
17. Preview panel border widths are all zero.
18. Top rows and Grid/Snap controls meet themed minimum height after layout; their visible rects
are not clipped by parents.
19. Inspector and preview remain adjustable through the existing split panes.
### 16.4 Preview motion and IK
20. Hand items are exactly `Left Hand` and `Right Hand`.
21. Motion items are exactly `Front Pose`, `Walk Left`, and `Walk Right`; no FrameOption or
FacingOption remains.
22. Initial state is FORWARD Front Pose with stopped player and local standing targets.
23. Walk Right selects RIGHT and loop-plays the proven gait clip in place.
24. Walk Left selects LEFT, keeps the solving rig positive, mirrors only PreviewRigRoot, and
loop-plays the proven gait clip in place.
25. Pause preserves current animation position and target values; Resume advances from it.
26. Direction change restarts deterministically at `0.0`.
27. Starting cyan-target drag pauses gait before writing the selected target and leaves it paused
after release.
28. Reset Arm restores standing local target in Front and the frozen animation sample in Walk.
29. Switching/forcing hand keeps motion/play state but clears stale manual override.
30. Held prop continues to map the selected grip exactly to the solved wrist over several walk
frames and after a manual drag.
31. Preview root does not translate during gait; queues stay empty, physics stays disabled,
collision layers/masks stay zero, and preview prop children are non-physics CanvasItems.
32. Reset View frames the complete figure and intersects the visible held-prop bounds.
### 16.5 Regression commands
Run all Prop Editor suites from `AGENTS.md`, particularly:
- `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`
- `tests/test_phase9b_prop_centering.gd`
Update old assertions that explicitly expect Animate Arm, FrameOption, FacingOption, Snap Anchors,
the blue preview border, or `(0,0)` label. Do not weaken anchor round-trip, origin
canonicalization, wrist alignment, preview isolation, or centering tests.
Run Godot 4.7.1 `--headless --check-only` for every changed script and `git diff --check`.
## 17. Manual acceptance checklist
Launch `res://scenes/prop_editor.tscn` with F6 and verify:
1. Select an editable anchor, click Rename, type a new name, and confirm the entered normalized
name appears; it no longer appends `_renamed` automatically.
2. Rename to a name already in use and confirm a safe numeric suffix is applied.
3. Cancel Rename and confirm nothing changes; rename/save/reload and confirm persistence.
4. The passive center marker has no `(0,0)` text in Arrange, Reshape, or Attach.
5. `Show Other Markers` off hides that passive marker and unselected anchors but retains the
selected anchor and rotation dot; turn it on and confirm they return.
6. Inspector background is visibly `#262C39` and distinct from canvas/preview.
7. Empty Arrange shows `Right-click to add a shape`; empty Reshape does not.
8. Material controls are hidden in Attach and return unchanged in Arrange/Reshape.
9. Attach contains only Show Stickman Preview and Show Other Markers; no Snap Anchors duplicate.
10. Top Snap with a non-default Grid value still snaps an anchor drag.
11. Hide the preview, leave Attach, and re-enter; it is checked and shown again.
12. Grid and Snap have comfortable vertical padding and are not clipped at top or bottom.
13. Resize the outer inspector split and inner preview split in both directions.
14. Preview has a dark surface without a blue border.
15. Hand choices read Left Hand and Right Hand.
16. There is no Upper Body/Full Figure or separate Facing control.
17. Initial preview is a static Front Pose.
18. Choose Walk Right and confirm an in-place full-body walking gait while the complete prop
follows the holding wrist.
19. Choose Walk Left and confirm the stickman faces/walks left without inverted limbs and the
complete prop still follows the wrist.
20. Pause Walk freezes the exact pose and changes to Resume Walk; Resume continues rather than
jumping to the beginning.
21. While walking, drag the cyan hand target. Walking pauses first, the arm follows the pointer,
the prop follows the solved wrist, and the pose remains paused on release.
22. Reset Arm restores the appropriate front/frozen-gait arm pose without changing anchor data.
23. Switch Left Hand/Right Hand and test a `both` grip plus left-only/right-only restrictions.
24. Reset View includes the entire figure and visible prop; wheel zoom and middle-pan still work.
25. Change preview controls and verify the document does not become dirty.
26. Confirm save/load/autosave/recovery retain shapes, material, and renamed anchors exactly.
## 18. Architectural trade-offs
| Decision | Benefit | Cost / future work |
| --- | --- | --- |
| Separate Phase 9c | Preserves completed 9a/9b history and gives behavior changes focused regressions | User guide spans another verified phase before its next update |
| One Preview Motion selector | Removes ineffective/ambiguous combinations and is kid-readable | Facing is no longer independently previewable without gait |
| Runtime gait rule, preview in place | Matches current gameplay direction/mirror semantics without navigation dependencies | Not a terrain, speed, arrival, or root-motion simulation |
| Pause before manual IK | One deterministic owner writes the hand target | No live additive hand posing while gait continues |
| Local rest targets | Stable under wrapper mirroring and matches animation track coordinates | Existing global-rest code must be replaced carefully |
| Full figure only | One reliable frame and fewer controls | Authors cannot request a tighter automatic upper-body crop; wheel zoom remains available |
| Modal rename | Focused, touch-friendly, and hard to trigger accidentally | One additional dialog lifecycle must be cleared on document changes |
| One snap source | Eliminates contradictory UI state | Attach toolbar no longer exposes snap next to anchors; top control must remain visible |
| Show Other Markers controls origin too | Toggle has visible meaning even with one anchor | A setting changed in Attach also affects the passive marker in other modes |
| Container-driven chrome | Adapts to theme/font changes and prevents clipping | Scene hierarchy and path-based tests require a targeted update |
## 19. Implementation order and pipeline handoff
1. Add theme token/accessor and convert editor chrome to responsive containers.
2. Group mode sections; remove duplicate/obsolete controls; implement marker visibility and hint
rules.
3. Add the rename dialog and document transaction tests.
4. Replace preview synthetic motion with the exact PreviewMotion/playback/IK state machine.
5. Add Phase 9c tests, update affected expectations, run all regressions/static checks.
6. Hand the implementation to the user for Godot Editor verification.
Do not update the user guide or README during implementation handoff. After the user confirms
Phase 9c is verified and working, the writer must revise `docs/prop_editor_user_guide.md` to
remove the old automatic Rename, Snap Anchors, Facing, Frame, and Animate Arm instructions and
document the approved Phase 9c controls.