- 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.
12 KiB
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():
- For each bone (all 10): draw a joint dot at
bone.global_position. - For each Bone2D child of the bone: draw a line
origin → child.global_position(true bone segment; works for nested lower bones). - 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 toorigin + R(global_rotation) · (length, 0)when the target node is missing. - 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 <= 0→s = 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.stkhas 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
- Parse check:
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit. - Headless smoke test (SceneTree, spawn
break.stkviaStickmanFactory, enable the modification stack, wait 2 frames), asserting:- No
update_rotation == falseon 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/LeftUpperLegshape points start at the node origin and extend toward+Ylocal, with Y extent ≈ 200 (bone length) and X extent ≈ 28 (thickness). Body/Body(torso) shape Y extent ≈ 391.5, X extent ≈ 35.Body/Headshape 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 assertBody/LeftUpperArm.global_rotationchanged with the bone (upper arm follows) andBody/LeftLowerArm.global_positionmoved with the elbow.
- No
- 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.
- 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)
- 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".
- 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
stk_rig_adapter.gd— remove driver neutralization; new anchor/scale/θ helpers.stk_rig_adapter.gd— rewrite_mount_shapes()/_mount_shape()/_transform_points();DEFAULT_LINE_WIDTH = 2.0; zero Head driver position.test_harness.gd— rewrite_draw_bones().- Headless smoke test + parse check.
- Docs (
BUGS.mdRound 2 note,AGENTS.md,README.md).