# 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.