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.
This commit is contained in:
2026-08-21 12:39:31 -04:00
parent 6b273c049c
commit ab5c79ab6a
14 changed files with 1459 additions and 174 deletions
+191
View File
@@ -0,0 +1,191 @@
# 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 <= 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.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`).