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

192 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`).