Files
stickman/specs/phase_9a_prop_attach.md
ryan 00abacc7b2 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.
2026-09-21 16:48:24 -04:00

41 KiB
Raw Permalink Blame History

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

{
  "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:

{
    "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:

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:

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

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:

{
    "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:

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:

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:

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:

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:

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

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:

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:

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:

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

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.