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:
@@ -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`).
|
||||
Reference in New Issue
Block a user