Files
stickman/docs/phase9_task4_refactor_spec.md
T
ryan 07bef66703 Implement rig animation controls in the test harness
- Added a new animation specification document for Phase 9 Task 5 detailing the requirements for rig animation controls.
- Introduced a new `StickmanRig` script to manage the facing direction and joint bending for the rig.
- Implemented UI elements in the test harness for selecting animations, controlling playback (play/pause/resume/stop), and toggling loop mode.
- Enhanced the `test_harness.gd` script to handle animation playback state and UI interactions.
- Updated documentation in `AGENTS.md`, `README.md`, and `RIGGING.md` to reflect the new animation features.
2026-08-24 23:41:23 -04:00

26 KiB
Raw Blame History

Phase 9 Task 4 — Refactor: Facing Direction & Bone Bend into the Rig

Overview

RIGGING.md Task 4: facing direction and per-joint bone bend must become part of master_rig.tscn, not the test harness. A rigged stickman instance should carry its own facing profile and bend flags so different instances can face different directions / bend differently at the same time. These must be exported controls on the rig so they are easy to access from the inspector and from runtime code.

Today the harness owns all of this:

  • enum FacingProfile { LEFT, RIGHT, FORWARD } (Task 1),
  • PROFILE_FLAGS — per-profile flip_bend_direction sets for the 4 TwoBoneIK joints,
  • Z_ORDER_BY_PROFILE — per-profile Body/* draw-order tables (Task 2),
  • BEND_JOINTS / BEND_JOINT_BONE_PATHS — the 4 upper↔lower limb connectors,
  • _facing_profile, _bend_joint_bones, _bend_modifications, _body_container state,
  • _apply_facing_profile(), _apply_body_z_order(), _resolve_bend_joints(), and _ensure_modification_stack_enabled().

Task 4 moves that state + logic into a new script attached to the master_rig.tscn root, and the harness becomes a thin driver that reads/writes the rig's exported properties.

1. New rig script — scripts/stickman_rig.gd

A new class_name StickmanRig script, extends Node2D, attached to the Master root node of master_rig.tscn (the root currently has no script). It is the single owner of facing profile

  • per-joint bend flags + Body/* z-order.

1a. Class declaration & enums

class_name StickmanRig
extends Node2D

## Facing profiles. Values are used directly as facing-menu item ids in the
## harness (0/1/2), so they must stay stable.
enum FacingProfile { LEFT, RIGHT, FORWARD }

## Per-joint bend direction. INVERTED == flip_bend_direction = true.
enum BendDirection { NORMAL, INVERTED }

The enums live in the rig script (not a shared script). The harness references them as StickmanRig.FacingProfile.LEFT / StickmanRig.BendDirection.INVERTED (idiomatic class_name enum access — no autoload or singleton needed).

1b. Constants (moved verbatim from test_harness.gd)

const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"

## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
## Direction" flag is user-controllable.
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]

## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
## each joint's world position for hit-testing.
const BEND_JOINT_BONE_PATHS: Dictionary = {
	"LeftArm":  "Torso/LeftUpperArm/LeftLowerArm",
	"RightArm": "Torso/RightUpperArm/RightLowerArm",
	"LeftLeg":  "Torso/LeftUpperLeg/LeftLowerLeg",
	"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
}

## flip_bend_direction value per facing profile, keyed by bend-joint name.
const PROFILE_FLAGS: Dictionary = {
	FacingProfile.LEFT:    { "LeftArm": false, "RightArm": false, "LeftLeg": true,  "RightLeg": true  },
	FacingProfile.RIGHT:   { "LeftArm": true,  "RightArm": true,  "LeftLeg": false, "RightLeg": false },
	FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true,  "LeftLeg": true,  "RightLeg": false },
}

## Body/* visual part node names in draw order (back-to-front) per profile.
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
## side the arm pair draws in front; head always frontmost.
const Z_ORDER_BY_PROFILE: Dictionary = {
	FacingProfile.FORWARD: [
		"Body",
		"LeftUpperLeg", "RightUpperLeg",
		"LeftLowerLeg", "RightLowerLeg",
		"LeftUpperArm", "RightUpperArm",
		"LeftLowerArm", "RightLowerArm",
		"Head",
	],
	FacingProfile.LEFT: [
		"LeftUpperArm", "LeftLowerArm",
		"LeftUpperLeg", "LeftLowerLeg",
		"Body",
		"RightUpperLeg", "RightLowerLeg",
		"RightUpperArm", "RightLowerArm",
		"Head",
	],
	FacingProfile.RIGHT: [
		"RightUpperArm", "RightLowerArm",
		"RightUpperLeg", "RightLowerLeg",
		"Body",
		"LeftUpperLeg", "LeftLowerLeg",
		"LeftUpperArm", "LeftLowerArm",
		"Head",
	],
}

1c. Exported properties (the "exported controls")

## Facing preset. Setting it overwrites the four per-joint bend values from
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
	set(value):
		if facing_profile == value:
			return
		facing_profile = value
		_apply_profile()

## Per-joint bend direction (the actual flip_bend_direction source of truth).
## Defaults match the FORWARD profile. These are individually overridable after
## a profile is applied (drifting away from the preset, exactly like the current
## harness right-click behavior).
@export_group("Bend Direction")
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
	set(value):
		_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
	set(value):
		_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
	set(value):
		_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
	set(value):
		_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)

Semantics:

  • facing_profile is a preset. Its setter writes the four *_bend vars (through their own setters, so mod flags stay in sync) and then reorders Body/* children, then emits facing_profile_changed. It early-outs if the value is unchanged (prevents redundant re-applies, e.g. re-spawning with the default profile).
  • The four *_bend vars are the per-joint source of truth. Each setter stores the value, updates the resolved SkeletonModification2DTwoBoneIK.flip_bend_direction, and emits bend_flag_changed. BendDirection.INVERTEDflip_bend_direction = true.
  • All setters are guarded by a _nodes_ready flag (set at the end of _ready()): before the rig has resolved its children (e.g. while PackedScene.instantiate() is still hydrating the serialized exports), the setter only stores the value; _ready() then applies the full state once. This makes the apply path order-independent and robust against setter timing during instantiation.

1d. Signals

## Emitted when the facing preset changes (after flags + z-order are applied).
signal facing_profile_changed(profile: int)

## Emitted when a single joint's bend direction changes. `flipped` is the new
## flip_bend_direction value (true == inverted).
signal bend_flag_changed(joint: String, flipped: bool)

The harness primarily refreshes menu labels on about_to_popup (the established pattern used by the editor's snap/guide menus), so the signals are not strictly required for label sync — but they are emitted for programmatic consumers and match the repo's signal-driven data-flow convention.

1e. Public methods

## Facing preset accessors.
func set_facing_profile(profile: int) -> void
func get_facing_profile() -> int

## Per-joint bend accessors (bool form mirrors flip_bend_direction directly).
func set_joint_bend_flipped(joint: String, flipped: bool) -> void
func get_joint_bend_flipped(joint: String) -> bool

## The list of bend-joint names (["LeftArm", "RightArm", "LeftLeg", "RightLeg"]).
func get_bend_joints() -> Array[String]

## World position of a bend joint (its lower bone's global_position) for
## right-click hit-testing. Unknown joint → push_warning + Vector2.ZERO.
func get_bend_joint_global_position(joint: String) -> Vector2

set_facing_profile / set_joint_bend_flipped are the runtime API; the exported var setters funnel into the same internal apply functions, so there is exactly one mutation path (no drift between the inspector values, the internal state, and the live mod flags).

1f. Internal state & resolution

var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _bend_joint_bones: Dictionary = {}        # { String : Bone2D }  (lower bones)
var _bend_modifications: Dictionary = {}      # { String : SkeletonModification2DTwoBoneIK }
  • _ready(): resolves _skeleton (get_node_or_null(SKELETON_PATH)), _body_container (get_node_or_null(BODY_CONTAINER_PATH)), the 4 lower Bone2Ds via BEND_JOINT_BONE_PATHS, and the 4 TwoBoneIK modifications; enables the modification stack (stack.enabled = true); then applies the current facing_profile once (_apply_profile()) and sets _nodes_ready = true.
  • _resolve_bend_modifications() mirrors the current harness _resolve_bend_joints() matching: walk _skeleton.modification_stack, and for each SkeletonModification2DTwoBoneIK match by joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]) (no hardcoded stack index). Missing bone/mod → push_warning + skip (never crash).
  • _apply_profile(): writes the four *_bend vars from PROFILE_FLAGS[profile] (through their setters), then _apply_body_z_order().
  • _apply_body_z_order(): preserves the Task 2 algorithm — walk the profile's ordered part names back-to-front and move_child(part, get_child_count() - 1) for each existing child; unknown/ extra children stay at the back; missing parts skipped silently.

1g. Editor-time vs runtime

  • The script is NOT @tool. Exported properties still appear in the editor inspector (they are serialized into master_rig.tscn when the scene is saved), but the script body — node resolution, flip_bend_direction writes, Body/* reordering, mod-stack enabling — runs only at runtime (_ready() + setters on a live instance).
  • Rationale: SkeletonModificationStack2D only solves at runtime, so an in-editor live preview of bend direction would not be meaningful, and mutating Body/* child order / mod sub-resources in the editor would dirty the scene and fight the editor's undo/ownership. Runtime-only application is the safe minimum the task explicitly permits ("at minimum runtime"). Making it @tool later (gated behind Engine.is_editor_hint()) is a possible future enhancement, not part of this task.

1h. Failure handling

Matches repo style throughout (get_node_or_null, push_warning, skip):

  • Missing Skeleton2D, Body, a bend-joint bone, or a TwoBoneIK mod → push_warning (prefixed "StickmanRig: ...") and skip that piece; the rig never crashes.
  • Unknown joint name in set_joint_bend_flipped / get_joint_bend_flipped / get_bend_joint_global_positionpush_warning and no-op / false / Vector2.ZERO respectively.
  • Empty Body container or missing part nodes → z-order reorder silently skips (as today).

2. master_rig.tscn changes

  1. Attach the script to the root Master node: script = ExtResource("…") for res://scripts/stickman_rig.gd. The exported defaults get serialized (facing_profile = 2, left_arm_bend = 0, right_arm_bend = 1, left_leg_bend = 1, right_leg_bend = 0).

  2. Align authored state to the FORWARD default (Q1 resolved: yes). So the scene's as-authored state == the runtime default (and the editor view of the raw scene is coherent):

    • TwoBoneIK flip_bend_direction (currently authored = the RIGHT profile, see §6):
      • LeftArm (…TwoBoneIK_f0s26, target_nodepath = ../IK_Targets/Left_Hand): trueremove the flag / false.
      • RightArm (…TwoBoneIK_yvxej): trueunchanged.
      • RightLeg (…TwoBoneIK_ylko5): absent → unchanged (stays false).
      • LeftLeg (…TwoBoneIK_2leu7): absent → add flip_bend_direction = true.
    • Body/* child order: the authored order is Head, Body, LeftUpperLeg, RightUpperLeg, LeftLowerLeg, RightLowerLeg, LeftUpperArm, RightUpperArm, LeftLowerArm, RightLowerArm. Move the Body/Head node to the end → order becomes exactly the FORWARD table. (Safe: the RemoteTransform2D drivers and the adapter reference Body/* by name, never by index.)

    Both edits are pure scene text edits. Runtime behavior is unchanged either way (the rig applies FORWARD on _ready), so this only affects the as-authored appearance and makes the "default leaves the scene as-authored" verification true.

3. Harness changes — scripts/test_harness.gd

The harness stops owning bend/facing state and drives the rig script. It keeps a thin UI mirror for menu labels + re-application across respawns (the rig remains the authority over the actual flags).

3a. Removed

Harness element Disposition
enum FacingProfile Delete — use StickmanRig.FacingProfile.
PROFILE_FLAGS, Z_ORDER_BY_PROFILE Delete — moved to StickmanRig.
BEND_JOINTS, BEND_JOINT_BONE_PATHS Delete — the rig exposes get_bend_joints() + get_bend_joint_global_position().
BODY_CONTAINER_PATH Delete — only the rig needs it.
_body_container Delete (resolve + clear).
_bend_joint_bones, _bend_modifications Delete (resolve + clear).
_apply_facing_profile() Delete — call _rig_script.set_facing_profile().
_apply_body_z_order() Delete — moved to the rig.
_resolve_bend_joints() Delete — moved to the rig's _resolve_bend_modifications().
_ensure_modification_stack_enabled() Delete — the rig enables its own stack in _ready().

Note: SKELETON_PATH stays in the harness (still used to resolve _skeleton for the bone overlay and the coordinates panel). _facing_profile stays but is repurposed as a UI mirror (§3b).

3b. State (changed / added)

var _rig_script: StickmanRig = null
var _facing_profile: int = StickmanRig.FacingProfile.FORWARD   # UI mirror only
var _context_joint: String = ""
  • _facing_profile is now only "the user's last-selected facing profile", used for the [√] menu prefix and to re-apply after each spawn. The rig's exported facing_profile is the actual state.
  • _context_joint is unchanged (which joint the right-click menu targets).

3c. Spawn flow — _load_and_spawn()

  1. _free_current_rig() (unchanged except it clears _rig_script instead of the deleted state).
  2. var rig := StickmanFactory.spawn(path); _rig_script = rig as StickmanRig (null-guarded: foreign rig without the script → push_warning and disable facing/bend UI).
  3. Connect signals before add_child: _rig_script.facing_profile_changed.connect(_on_facing_profile_changed), _rig_script.bend_flag_changed.connect(_on_bend_flag_changed) (both may be no-ops; label sync is done on about_to_popup, but connecting keeps future consumers working).
  4. _world.add_child(_rig) (rig _ready runs here, applies its default FORWARD and enables its mod stack).
  5. _resolve_rig_nodes() — now only resolves _skeleton, _ik_handles, _coord_bones (drops _body_container and _resolve_bend_joints()).
  6. _rig_script.set_facing_profile(_facing_profile) — re-applies the remembered selection (no-op when it equals the rig's FORWARD default).

3d. Facing menu

  • Menu item ids stay StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD.
  • _on_facing_menu_id_pressed(id): _facing_profile = id; if _rig_script valid → _rig_script.set_facing_profile(id); then _update_facing_menu_labels().
  • _facing_menu_label(profile): unchanged ([√] prefix based on _facing_profile).

3e. Right-click bend toggle

  • _hit_test_bend_joint(world_pos): iterate _rig_script.get_bend_joints(); position from _rig_script.get_bend_joint_global_position(joint); same JOINT_HIT_RADIUS_PX / _camera.zoom.x nearest-joint selection.
  • _handle_right_click(): unchanged flow (set _context_joint, set label, popup).
  • _context_menu_label(joint): "Normal Bend" if _rig_script.get_joint_bend_flipped(joint) else "Invert Bend" (fallback "Invert Bend" when the rig script is missing).
  • _on_context_menu_id_pressed(_id): if _context_joint non-empty and _rig_script valid → _rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint)).

3f. Signal handlers (optional but included)

func _on_facing_profile_changed(_profile: int) -> void:
	_facing_profile = _rig_script.get_facing_profile() if _rig_script else _facing_profile
	_update_facing_menu_labels()
	_debug_overlay.queue_redraw()

func _on_bend_flag_changed(_joint: String, _flipped: bool) -> void:
	_debug_overlay.queue_redraw()

(These keep the [√] prefix in sync if the profile is ever changed from outside the menu; the menu still refreshes on about_to_popup as the primary mechanism.)

4. Factory / adapter impact

  • stickman_factory.gd: no functional change. spawn_from_data / spawn still return the rig root (now carrying the StickmanRig script); narrow the return type to StickmanRig (return RIG_SCENE.instantiate() as StickmanRig) for stronger typing (Q6 resolved: yes).
  • stk_rig_adapter.gd: no change. The adapter mounts shapes onto Body/* by node path and does not read or write bend/facing state. The rig's _ready (z-order + flags + stack-enable) runs after StkRigAdapter.apply() (which happens inside spawn_from_data, before the rig enters the tree), so mounted shape children are already present when the rig reorders Body/* — moving a part node moves its whole shape group, exactly as today.

5. master_rig_builder.gd, master_rig2.tscn

Out of scope and untouched (per docs/phase9_round1_bugfix_spec.md): master_rig_builder.gd builds a different rig (Sticky/Stickman/.../Hip naming), and master_rig2.tscn is a pose-override variant not referenced by the factory. The new StickmanRig script targets master_rig.tscn only.

6. Defaults & backward compatibility

  • Default profile = FORWARD (facing_profile = FacingProfile.FORWARD), matching the current harness default _facing_profile = FacingProfile.FORWARD. On a fresh spawn the rig applies FORWARD flags {LeftArm:false, RightArm:true, LeftLeg:true, RightLeg:false} and the FORWARD z-order — identical to today's runtime behavior.
  • Authored master_rig.tscn flags are NOT currently FORWARD — they are the RIGHT profile (LeftArm:true, RightArm:true, LeftLeg:false, RightLeg:false, see master_rig.tscn lines 23/31 and the absent flags on the two leg mods). The harness already overwrites these at runtime, so behavior is unchanged before/after this refactor; the mismatch only matters for the "as-authored == default" goal (§2 / Q1).
  • No .stk format change. FILE_VERSION stays "1.5". Bend/facing state is rig-instance state, not figure data; it is never serialized into .stk.
  • No settings.json change. The harness facing selection stays non-persistent (as today).
  • Old .stk files, foreign rigs, and partial figures load unchanged (null-guarded resolution).

7. Files modified

File Changes
scripts/stickman_rig.gd New. class_name StickmanRig extends Node2D — enums, constants, exported properties, signals, methods, runtime resolution/apply.
master_rig.tscn Attach StickmanRig to root Master; (recommended) align authored TwoBoneIK flags + Body/* order to FORWARD (§2).
scripts/test_harness.gd Delete bend/facing ownership (§3a); add _rig_script + repurposed _facing_profile; rewire spawn, facing menu, right-click toggle (§3c3f).
scripts/stickman_factory.gd Narrow spawn_from_data/spawn return type to StickmanRig (Q6).
docs/phase9_task4_refactor_spec.md This file.
AGENTS.md Add scripts/stickman_rig.gd bullet; update test_harness.gd bullet (facing/bend now drive the rig); note master_rig.tscn root script.
README.md Project-structure table row for stickman_rig.gd; update test-harness + factory bullets.
RIGGING.md Mark Task 4 implemented.

8. Edge cases

  • No rig script / foreign rig (rig as StickmanRig == null): push_warning; facing menu and right-click bend toggle become no-ops; bone overlay/coords still work.
  • Missing Skeleton2D / Body / bones / mods: push_warning + skip per section; rig never crashes.
  • Setters before _ready (during instantiation): guarded by _nodes_ready; _ready applies the full state once — no ordering bug.
  • Re-spawn: the rig is freed and a fresh one spawns; the harness re-applies the remembered _facing_profile (§3c). _free_current_rig() clears _rig_script (not the deleted state).
  • Manual bend override then profile change: setting a profile overwrites all four per-joint flags (the preset wins), exactly like the current _apply_facing_profile().
  • Unknown Body/* children: stay at the back during reorder (unchanged Task 2 semantics).
  • Head always frontmost: preserved in all three Z_ORDER_BY_PROFILE tables.

9. Design decisions

# Decision Justification
D1 New class_name StickmanRig extends Node2D on the master_rig.tscn root The rig is the natural owner of per-instance facing/bend; a class_name script makes it a typed, reusable API for the harness and future consumers.
D2 Enums live in the rig script (StickmanRig.FacingProfile / StickmanRig.BendDirection) No autoload/singleton; idiomatic class_name enum access; single source of truth both sides can reference.
D3 facing_profile is a preset; the four *_bend enum exports are the per-joint source of truth Matches the harness's existing preset-then-override behavior (RIGGING.md: "user is free to change bend manually"); the enum dropdowns give readable "Normal"/"Inverted" inspector labels (the task's "per-joint bend enums" hint).
D4 Public bool-form methods (set_joint_bend_flipped/get_joint_bend_flipped) alongside the enum exports flip_bend_direction is a bool; the bool form is the engine-accurate contract and keeps the harness context-menu label logic unchanged.
D5 TwoBoneIK resolved by joint_two_bone2d_node NodePath matching, not stack index Preserves the existing (Task 1) resolution approach; robust to stack reordering.
D6 Non-@tool script; apply only at runtime IK doesn't solve in-editor; mutating Body/* order / mod flags in-editor would dirty the scene; runtime-only is the task's permitted minimum.
D7 Harness keeps a _facing_profile UI mirror but not the bend authority Preserves current UX (selection persists across respawns, [√] label) without the harness owning flags/z-order.
D8 Rig enables its own mod stack in _ready() Facing/bend are meaningless until the stack is live; the rig should self-enable at runtime (single consumer today always enables it).
D9 Rig exposes get_bend_joint_global_position() Lets the harness drop BEND_JOINT_BONE_PATHS/_bend_joint_bones entirely; the rig owns the whole bend domain.

10. Test plan

  1. Parse check (same as prior tasks): ..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit — no errors.
  2. Headless SceneTree verification (temporary script, deleted after; pattern from Round 7):
    • StickmanFactory.spawn("res://stickmen/basic.stk") → cast root to StickmanRig (non-null); add_child it; assert get_facing_profile() == FacingProfile.FORWARD and the 4 mods' flags equal the FORWARD set; assert Body child order == Z_ORDER_BY_PROFILE[FORWARD] and Body/Head is last.
    • set_facing_profile(FacingProfile.LEFT) → assert flags + Body order match LEFT (left pairs before torso, head last); repeat RIGHT.
    • set_joint_bend_flipped("LeftArm", true) → assert get_joint_bend_flipped("LeftArm") == true, the matched TwoBoneIK mod flip_bend_direction == true, and the other three unchanged.
    • Instantiate a rig without adding it to a tree, set facing_profile before add_child, then add_child → assert the pre-set profile was honored by _ready (guards D-setters).
  3. Harness code review: facing menu ids use StickmanRig.FacingProfile; spawn connects signals before add_child and re-applies _facing_profile; right-click toggle reads/writes the rig script, not a mod directly; _resolve_rig_nodes() no longer touches bend/z-order state.
  4. Manual F6 (res://scenes/test_harness.tscn):
    • Load basic.stk; figure starts FORWARD (unchanged from before).
    • Facing menu → Left/Right: limbs tuck behind the torso correctly, head stays frontmost; [√] moves; context menu labels reflect the new per-joint flags.
    • Right-click an elbow/knee → "Invert Bend"/"Normal Bend" toggles the rig property and the label flips; the limb visibly bends the other way.
    • Load another .stk → the remembered facing profile re-applies to the fresh rig.
  5. Cleanup temp verification files.

11. Implementation order

  1. scripts/stickman_rig.gd — enums, constants, exports, signals, methods, runtime apply.
  2. master_rig.tscn — attach script; align authored flags + Body/* order to FORWARD (Q1).
  3. scripts/test_harness.gd — remove ownership, add _rig_script, rewire spawn/menus/toggle.
  4. scripts/stickman_factory.gd — (optional) narrow return type.
  5. Parse check + headless verification (temp, then removed).
  6. Docs: AGENTS.md, README.md, RIGGING.md, this spec.

12. Open questions — RESOLVED (user approval)

  • Q1 — Authored scene ≠ FORWARD default. Resolved: YES — edit master_rig.tscn to align the authored TwoBoneIK flags and Body/* order to FORWARD (§2).
  • Q2 — Per-joint export representation. Resolved: enum formBendDirection with @export_enum("Normal","Inverted") dropdowns.
  • Q3 — @tool vs runtime-only. Resolved: runtime-only (non-@tool; exports still inspector-editable).
  • Q4 — Mod-stack enabling. Resolved: YES — move stack.enabled = true into the rig's _ready(); harness drops _ensure_modification_stack_enabled().
  • Q5 — Harness _facing_profile UI mirror. Resolved: YES — keep the lightweight mirror for the [√] label + respawn re-application; the rig is the authority.
  • Q6 — Factory return type. Resolved: YES — narrow spawn_from_data/spawn return type to StickmanRig.