29 KiB
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-profileflip_bend_directionsets for the 4 TwoBoneIK joints,Z_ORDER_BY_PROFILE— per-profileBody/*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_containerstate,_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_profileis a preset. Its setter writes the four*_bendvars (through their own setters, so mod flags stay in sync) and then reordersBody/*children, then emitsfacing_profile_changed. It early-outs if the value is unchanged (prevents redundant re-applies, e.g. re-spawning with the default profile).- The four
*_bendvars are the per-joint source of truth. Each setter stores the value, updates the resolvedSkeletonModification2DTwoBoneIK.flip_bend_direction, and emitsbend_flag_changed.BendDirection.INVERTED→flip_bend_direction = true. - All setters are guarded by a
_nodes_readyflag (set at the end of_ready()): before the rig has resolved its children (e.g. whilePackedScene.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 lowerBone2Ds viaBEND_JOINT_BONE_PATHS, and the 4 TwoBoneIK modifications; enables the modification stack (stack.enabled = true); then applies the currentfacing_profileonce (_apply_profile()) and sets_nodes_ready = true._resolve_bend_modifications()mirrors the current harness_resolve_bend_joints()matching: walk_skeleton.modification_stack, and for eachSkeletonModification2DTwoBoneIKmatch byjoint_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*_bendvars fromPROFILE_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 andmove_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 intomaster_rig.tscnwhen the scene is saved), but the script body — node resolution,flip_bend_directionwrites,Body/*reordering, mod-stack enabling — runs only at runtime (_ready()+ setters on a live instance). - Rationale:
SkeletonModificationStack2Donly solves at runtime, so an in-editor live preview of bend direction would not be meaningful, and mutatingBody/*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@toollater (gated behindEngine.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_position→push_warningand no-op /false/Vector2.ZEROrespectively. - Empty
Bodycontainer or missing part nodes → z-order reorder silently skips (as today).
2. master_rig.tscn changes
-
Attach the script to the root
Masternode:script = ExtResource("…")forres://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). -
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):true→ remove the flag /false.RightArm(…TwoBoneIK_yvxej):true→ unchanged.RightLeg(…TwoBoneIK_ylko5): absent → unchanged (staysfalse).LeftLeg(…TwoBoneIK_2leu7): absent → addflip_bend_direction = true.
Body/*child order: the authored order isHead, Body, LeftUpperLeg, RightUpperLeg, LeftLowerLeg, RightLowerLeg, LeftUpperArm, RightUpperArm, LeftLowerArm, RightLowerArm. Move theBody/Headnode to the end → order becomes exactly the FORWARD table. (Safe: theRemoteTransform2Ddrivers and the adapter referenceBody/*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. - TwoBoneIK
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_profileis now only "the user's last-selected facing profile", used for the[√]menu prefix and to re-apply after each spawn. The rig's exportedfacing_profileis the actual state._context_jointis unchanged (which joint the right-click menu targets).
3c. Spawn flow — _load_and_spawn()
_free_current_rig()(unchanged except it clears_rig_scriptinstead of the deleted state).var rig := StickmanFactory.spawn(path);_rig_script = rig as StickmanRig(null-guarded: foreign rig without the script →push_warningand disable facing/bend UI).- 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 onabout_to_popup, but connecting keeps future consumers working). _world.add_child(_rig)(rig_readyruns here, applies its default FORWARD and enables its mod stack)._resolve_rig_nodes()— now only resolves_skeleton,_ik_handles,_coord_bones(drops_body_containerand_resolve_bend_joints())._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_scriptvalid →_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); sameJOINT_HIT_RADIUS_PX / _camera.zoom.xnearest-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_jointnon-empty and_rig_scriptvalid →_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/spawnstill return the rig root (now carrying theStickmanRigscript); narrow the return type toStickmanRig(return RIG_SCENE.instantiate() as StickmanRig) for stronger typing (Q6 resolved: yes).stk_rig_adapter.gd: no change. The adapter mounts shapes ontoBody/*by node path and does not read or write bend/facing state. The rig's_ready(z-order + flags + stack-enable) runs afterStkRigAdapter.apply()(which happens insidespawn_from_data, before the rig enters the tree), so mounted shape children are already present when the rig reordersBody/*— 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.tscnflags are NOT currently FORWARD — they are the RIGHT profile (LeftArm:true, RightArm:true, LeftLeg:false, RightLeg:false, seemaster_rig.tscnlines 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
.stkformat change.FILE_VERSIONstays"1.5". Bend/facing state is rig-instance state, not figure data; it is never serialized into.stk. - No
settings.jsonchange. The harness facing selection stays non-persistent (as today). - Old
.stkfiles, 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 (§3c–3f). |
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;_readyapplies 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_PROFILEtables.
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. |
9a. Round N — whole-rig Y-axis mirror (2026-09-05 design change)
Decision (user-approved): facing LEFT is now rendered as a whole-rig Y-axis mirror —
Master.scale.x = -1 (RIGHT/FORWARD → (1,1)) — replacing the per-part/head mirroring. This flips
the head and body together so the figure faces the correct direction.
_apply_head_flip()and theBody/Head.scale.xmirror are removed (the Head Pivot node's driver transform is untouched).PROFILE_FLAGS(per-jointflip_bend_direction) andZ_ORDER_BY_PROFILEare kept provisionally (unchanged). The mirror reflects the whole skeleton +IK_Targets+ mountedBody/*geometry, but does not affect depth (draw order); whether the bend flags can be collapsed to a single canonical set must still be verified empirically (a root mirror is not provably reflection-invariant for TwoBoneIK'sflip_bend_directionsign).- Walk-clip mapping — Option A (single canonical clip):
walk_rightis the canonical walk. ForFacingProfile.LEFTthe rig root is X-mirrored and the samewalk_rightclip plays mirrored;walk_leftis no longer used at runtime. The animation.:facing_profiletracks are neutralized/removed — facing is set explicitly byset_facing_profile()/walk_to().
Follow-up (implemented, tested): two head-related fixes were required to make the LEFT root
mirror render the head correctly. (1) The head RemoteTransform2D (Skeleton2D/Torso/Head/Pivot)
no longer sets update_scale = false — it pushes the full transform like every other Body
driver, so Body/Head.scale stays identity under the mirrored root (the old partial-channel push
re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps). (2) The SkeletonModification2DLookAt
that aims the Head bone is not mirror-invariant: under the LEFT root mirror it writes a bone
rotation 180° off the FORWARD aim, flipping the head to hang below the neck. New
_apply_head_lookat_mirror_mode() (called from _apply_profile()) disables the LookAt and pins the
head bone to the FORWARD canonical aim (π) when facing LEFT; _pin_mirrored_head_rotation()
re-asserts the pin each _physics_process frame while ANIMATED/RECOVERING so recovery's stack
re-arm can't let LookAt flip the bone. RIGHT/FORWARD re-enable the LookAt. Consequence: interactive
head-aiming while facing LEFT is intentionally static.
10. Test plan
- Parse check (same as prior tasks):
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit— no errors. - Headless SceneTree verification (temporary script, deleted after; pattern from Round 7):
StickmanFactory.spawn("res://stickmen/basic.stk")→ cast root toStickmanRig(non-null);add_childit; assertget_facing_profile() == FacingProfile.FORWARDand the 4 mods' flags equal the FORWARD set; assertBodychild order ==Z_ORDER_BY_PROFILE[FORWARD]andBody/Headis last.set_facing_profile(FacingProfile.LEFT)→ assert flags +Bodyorder match LEFT (left pairs before torso, head last); repeatRIGHT.set_joint_bend_flipped("LeftArm", true)→ assertget_joint_bend_flipped("LeftArm") == true, the matched TwoBoneIK modflip_bend_direction == true, and the other three unchanged.- Instantiate a rig without adding it to a tree, set
facing_profilebeforeadd_child, thenadd_child→ assert the pre-set profile was honored by_ready(guards D-setters).
- Harness code review: facing menu ids use
StickmanRig.FacingProfile; spawn connects signals beforeadd_childand 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. - 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.
- Load
- Cleanup temp verification files.
11. Implementation order
scripts/stickman_rig.gd— enums, constants, exports, signals, methods, runtime apply.master_rig.tscn— attach script; align authored flags +Body/*order to FORWARD (Q1).scripts/test_harness.gd— remove ownership, add_rig_script, rewire spawn/menus/toggle.scripts/stickman_factory.gd— (optional) narrow return type.- Parse check + headless verification (temp, then removed).
- 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.tscnto align the authored TwoBoneIK flags andBody/*order to FORWARD (§2). - Q2 — Per-joint export representation. Resolved: enum form —
BendDirectionwith@export_enum("Normal","Inverted")dropdowns. - Q3 —
@toolvs runtime-only. Resolved: runtime-only (non-@tool; exports still inspector-editable). - Q4 — Mod-stack enabling. Resolved: YES — move
stack.enabled = trueinto the rig's_ready(); harness drops_ensure_modification_stack_enabled(). - Q5 — Harness
_facing_profileUI 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/spawnreturn type toStickmanRig.