Files
stickman/docs/specs/phase_9a_prop_attach.md
T

878 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 9a — Role-Based Prop Anchors and IK Authoring Preview
Status: **proposed — awaiting user sign-off** (2026-09-20).
Source: `docs/archive/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/props/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/editors/prop/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/props/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/editors/prop/prop_attach_preview.gd` and `scenes/editors/prop/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://content/stickmen/test.stk` through `StickmanFactory.load_stk()`.
2. Instantiate `res://scenes/characters/stickman/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/characters/stickman/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/editors/prop/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.