Files
stickman/docs/phase9_round2_bugfix_spec.md
T
ryan ab5c79ab6a feat: Implement draggable torso and head IK targets in the test harness
- Added draggable handles for the torso and head to the test harness.
- Updated `IK_HANDLE_PATHS` to include new entries for "Head" and "Torso".
- Implemented distinct colors for the torso (magenta) and head (yellow) markers.
- Added a visual aid (aim line) to indicate the head's LookAt target direction.
- Ensured that dragging the torso moves only the torso marker, allowing for limb stretching towards stationary targets.
2026-08-21 12:39:31 -04:00

12 KiB
Raw Blame History

Phase 9 Round 2 — Bugfix: Bone Visibility, IK Shape Following, Torso/Head Mount in the Harness

Overview

Testing break.stk in the test harness revealed four defects. This round fixes all four with changes confined to scripts/stk_rig_adapter.gd (mount pipeline) and scripts/test_harness.gd (debug bone overlay). Root causes were established with a headless probe of the live rig (spawned via StickmanFactory with the modification stack enabled, 2 frames).

Root causes (verified)

# Symptom Root cause (probe-verified)
1 Lower-limb bones/joints invisible in "Show Bones" test_harness.gd _draw_bones() draws parent-origin → bone-origin lines. For lower bones the parent origin is the hip/shoulder (upper bone origin), so the lower segment duplicates the upper segment; wrist/ankle joints are never drawn (they are bone ends, not origins).
2 IK only moves lower-arm shapes; upper-arm shapes static Round 1's _neutralize_driver_rotations() sets update_rotation = false on the 10 RemoteTransform2D drivers. Probe: every Body/* node shows rot_deg = 0.00 while its bone rotates. Upper-limb visuals sit at fixed joints (shoulder/hip origins don't move) → static; lower-limb visuals translate with the elbow/knee but never rotate → appear "attached to the upper bones".
3 Torso top connects at the hip joint With driver rotation neutralized, the torso (anchor top-center, driver rotation π suppressed) hangs with its top at the hip-driven Body/Body origin. The user's torso is drawn hip-at-bottom (break.stk torso rect y∈[209,308], hip end at 308) and must mount with the hip end at the origin, extending up.
4 Head features wrong scale / too thick / nose not triangular DEFAULT_LINE_WIDTH = 16.0 vs the editor's 2 px outline (body_part_panel.gd:435). Small eye/nose/mouth shapes get 16 px outlines that dominate their geometry. Additionally the Head RemoteTransform2D has a local position offset (0.05, 72) designed for the old 100-px-radius circle, so the mounted head floats 72 px above the neck joint.

1. The rig's visual convention (verified numerically)

The RemoteTransform2D drivers push global position + rotation onto the Body/* nodes. With rotation pushes enabled, each Body/* node's world rotation = bone_pose_rotation + RT_local_rotation. The authored RT local rotations are:

Part RT local rotation Bone pose → visual direction at rest/IK (probe-verified)
Body/Body (torso) π (3.1415927) hanging geometry (0,+Y) → world Y (up) ✓
Body/LeftUpperArm +π/2 hanging → world X (left arm outward) ✓
Body/RightUpperArm −π/2 hanging → world +X (right arm outward) ✓
Body/LeftLowerArm π hanging → world Y (forearm direction) ✓
Body/RightLowerArm −π/2 hanging → world Y (forearm direction) ✓
Body/LeftUpperLeg / RightUpperLeg 0 hanging → world toward the knee ✓
Body/LeftLowerLeg / RightLowerLeg −π/2 hanging → world toward the ankle ✓
Body/Head 0 upright (no rotation needed) ✓

Consequence: mounted geometry must be authored in the rig's "hanging" convention — the joint end at the local origin, the far end along local +Y — and the drivers rotate it correctly in any pose, so IK flexing follows the bones exactly. (This replaces the Round 1 "clean unrotated frame + rotation neutralization" approach, which is what broke IK following.)

2. Fix 1 — StkRigAdapter mount pipeline (rotation compensation by hanging convention)

2a. Remove driver neutralization

Delete _neutralize_driver_rotations() and its call in apply(). The drivers keep their defaults (update_position/rotation/scale = true).

2b. Mount transform (per part)

For each part compute the bbox over all shape points. Let cx = (min_x + max_x) / 2, cy = (min_y + max_y) / 2, width = max_x min_x, height = max_y min_y, long_axis_is_x = width >= height, extent = max(width, height).

Anchor J, scale S (drawn space), and alignment rotation θ:

Part family Anchor J Scale S θ (maps far end to local +Y)
Head (cx, max_y) (bottom-center = chin) (1, 1) 0 (upright, extends Y)
Torso (cx, max_y) (bottom-center = hip end) (1, s) π (drawn extends up → rotated to hang down; driver π flips it back up)
Left limbs (arms + legs), long axis X (max_x, cy) (joint at right end) (s, 1) −π/2 (far end X+Y)
Right limbs, long axis X (min_x, cy) (joint at left end) (s, 1) +π/2 (far end +X+Y)
Legs drawn vertically (long axis Y) (cx, min_y) (hip at top) (1, s) 0 (already hanging)
Arms drawn vertically (long axis Y) (cx, min_y) (shoulder at top) (1, s) 0 (already hanging)

where s = bone_length / extent with bone_length from _bone_length_for() (unchanged), guarded: extent <= 0.0001 → s = 1.0. Head: no scale, no rotation.

Point transform (replaces _transform_points math):

q = (P  J) ⋅ S            # scale along the drawn long axis only (cross axis 1:1)
pt_local = q.rotated(θ)   # rotate into the hanging frame

Notes:

  • This preserves the Round 1 anisotropic-scaling requirement in effect: only the long axis is scaled (arms/legs = the drawn segment axis), cross-axis thickness stays 1:1. For horizontally-drawn arms it is mathematically identical to Round 1's x·(bone/part_length).
  • break.stk's legs are drawn horizontally (long axis X, extent ≈101), so under Round 1's literal "legs scale Y" they mounted as 101-px-thick bars. The long-axis rule fixes this: legs become ~200 long × 28 thick — matching the drawn/assembled proportions 1:1.
  • The torso pre-rotation π + the driver's π cancel at rest, so the mounted torso appears with the hip end at the hip joint and the top at torso_length (the neck) — exactly where the user assembled it (their preview scale 3.99 ≈ 391.5/99 = 3.955).

2c. Head joint placement

The Head driver (Skeleton2D/Torso/Head/RemoteTransform2D) has local position (0.0503, 72.0) — authored to center the old 100-px circle 72 px above the neck. In _fit_bones() (head section), zero the driver's local position so Body/Head sits on the neck joint. The mounted head (chin at local origin) then lands with the chin on the neck and rotates about the neck with the head bone's LookAt.

2d. Line width

DEFAULT_LINE_WIDTH := 2.0 (was 16.0) — matches the editor's outline width (body_part_panel.gd:435 width: float = 2.0), so eyes/nose/mouth/cap render 1:1.

2e. Keep

_reset_node_transform() (scale (1,1), rotation 0 before mounting — drivers overwrite each frame anyway), head set_script(null), _fit_bones bone fitting, _recalibrate_ik, _bone_length_for, node-path constants, bbox recompute (file pivot/length still ignored).

3. Fix 2 — test_harness.gd bone overlay

Rewrite _draw_bones():

  1. For each bone (all 10): draw a joint dot at bone.global_position.
  2. For each Bone2D child of the bone: draw a line origin → child.global_position (true bone segment; works for nested lower bones).
  3. For leaf bones (no Bone2D child): draw a line from the origin to the bone's corresponding IK target (map: LeftLowerArm → IK_Targets/Left_Hand, RightLowerArm → IK_Targets/Right_Hand, LeftLowerLeg → IK_Targets/Left_Leg, RightLowerLeg → IK_Targets/Right_Leg, Head → IK_Targets/Head) — shows the forearm/shin segments and the wrist/ankle joints; falls back to origin + R(global_rotation) · (length, 0) when the target node is missing.
  4. Keep the existing colors/widths (_bone_color).

Result: 9 distinct segments (spine, neck-head, both arms ×2, both legs ×2) + 10 bone-origin dots + target joints — lower limbs clearly visible.

4. Files modified

File Changes
scripts/stk_rig_adapter.gd Remove _neutralize_driver_rotations() + call; new anchor/scale/rotation table in _mount_shapes() + _compute_anchor/_compute_scale/rotation helpers; DEFAULT_LINE_WIDTH = 2.0; zero the Head driver's local position in _fit_bones(); update doc comments.
scripts/test_harness.gd Rewrite _draw_bones() (child-direction segments + leaf→IK-target segments + dots).
docs/phase9_round2_bugfix_spec.md This file.

5. Edge cases

  • Empty part → skip (unchanged). extent <= 0s = 1.
  • Vertically-drawn legs/arms → θ = 0 (hanging already); horizontally-drawn → ±π/2.
  • Torso drawn with hip at top (not bottom) would mount flipped — accepted assumption (mirrors Round 1's shoulder-inward assumption); break.stk has hip at bottom.
  • Multi-shape parts: bbox over all shapes (unchanged).
  • Old files without pivot/length: unaffected (bbox recomputed).
  • Missing driver/target nodes: null-guarded warnings (unchanged pattern).

6. Test plan

  1. Parse check: ..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit.
  2. Headless smoke test (SceneTree, spawn break.stk via StickmanFactory, enable the modification stack, wait 2 frames), asserting:
    • No update_rotation == false on any of the 10 drivers.
    • Body/* world rotations ≈ their bone's world rotation + RT local rotation (e.g. Body/LeftUpperArm.global_rotation ≈ LeftUpperArm.global_rotation + π/2).
    • Mounted geometry hangs: Body/LeftUpperLeg shape points start at the node origin and extend toward +Y local, with Y extent ≈ 200 (bone length) and X extent ≈ 28 (thickness).
    • Body/Body (torso) shape Y extent ≈ 391.5, X extent ≈ 35.
    • Body/Head shape chin point ≈ local origin; the Head driver position == (0,0); mounted Line2D width == 2.0.
    • Bones overlay data: 10 bones; each lower bone's parent is the upper bone (sanity).
    • Then SET a pose override or drag an IK target (move IK_Targets/Left_Hand), await a frame, and assert Body/LeftUpperArm.global_rotation changed with the bone (upper arm follows) and Body/LeftLowerArm.global_position moved with the elbow.
  3. Harness manual check (F6): lower limb bones + wrist/ankle joints visible; drag hand IK → upper arm shape rotates at the shoulder and lower arm shape flexes at the elbow.
  4. Cleanup: delete the temporary test script.

7. Design decisions

# Decision Justification
D1 Restore driver rotation pushes; drop _neutralize_driver_rotations() Probe-verified root cause of the IK-following bug; the hanging convention + driver rotations track bones in every pose.
D2 Mount geometry in the rig's hanging frame (joint at origin, far end +Y) with a per-part alignment rotation Makes the mount pose-agnostic; no per-part hardcoded compensation table needed.
D3 Scale only the drawn long axis (auto-detected); cross axis 1:1 Fixes break.stk's horizontally-drawn legs (101-px bars → 200×28) and keeps Round 1's anisotropic-scaling intent.
D4 Torso: anchor bottom-center + θ = π Hip end at the hip joint; top lands at the neck, matching the user's assembled torso (preview scale ≈ 391.5/99).
D5 Head: zero the Head driver's local position Chin lands on the neck joint; head rotates about the neck with LookAt.
D6 DEFAULT_LINE_WIDTH = 2.0 Matches the editor's 2 px outline — features render 1:1.
D7 Harness overlay: parent→child segments + leaf→IK-target segments Shows true lower-limb bones and wrist/ankle joints.

8. Open questions — RESOLVED (user-approved)

  1. Legs drawn horizontally (break.stk). Auto-detect the long axis (rotate to hang, scale the long axis) — replaces Round 1's fixed "legs = Y axis".
  2. Figure pose = rig rest/IK pose. Rig pose, shapes 1:1 — part transforms remain v1-ignored; the drawn shapes are fitted 1:1 onto the rig's bones.

9. Implementation order

  1. stk_rig_adapter.gd — remove driver neutralization; new anchor/scale/θ helpers.
  2. stk_rig_adapter.gd — rewrite _mount_shapes()/_mount_shape()/_transform_points(); DEFAULT_LINE_WIDTH = 2.0; zero Head driver position.
  3. test_harness.gd — rewrite _draw_bones().
  4. Headless smoke test + parse check.
  5. Docs (BUGS.md Round 2 note, AGENTS.md, README.md).