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
+213
View File
@@ -0,0 +1,213 @@
# Phase 9 Round 3 — Bugfix: Overlay Head Bone, One Node Per Shape, Part Rotation/Scale in the Harness
## Overview
Three defects remain after Round 2 (user report, testing `break.stk` in the harness):
1. **"The head bone has another bone extending out of it"** — the harness bone overlay draws the
Head leaf bone as a segment to `IK_Targets/Head` (0, 624), the `SkeletonModification2DLookAt`
target — a long line sticking out of the head. It should draw the head bone along its own
direction instead.
2. **Duplicate nodes per closed shape**`_mount_shape()` mounts closed shapes as a `Polygon2D`
fill **plus** a `Line2D` outline (e.g. `Body/Head/@Polygon2D@165` + `@Line2D@166`). Per the
user, closed shapes should mount as **just the Polygon2D** and open shapes as **just one
Line2D**.
3. **Part rotation & scale not applied** — the adapter ignores the per-part `rotation` (degrees)
and `scale` the user applied in the Whole Stickman preview. A torso rotated 180° looks
identical to an unrotated one; a head scaled ~2× in the editor (≈160×160 px) renders ~80×80
in the harness.
---
## 1. Root-cause analysis
### 1a. Overlay head segment (`scripts/test_harness.gd`)
`_draw_bones()` treats every leaf bone identically: draw to its IK target. For the four limbs
the targets ARE the wrist/ankle joints (correct). For the Head the target is a LookAt aim point
232 px above the neck — drawn as a "bone" it looks broken. The Head bone is authored with
`length = 90`, `bone_angle = 90` (points up), so its true tip is
`origin + Vector2(0, -length).rotated(global_rotation)` — a 90 px segment inside the head.
**Fix:** special-case the Head leaf — draw along the bone's own direction; keep the limb leaf
bones drawing to their IK targets.
### 1b. Duplicate nodes (`scripts/stk_rig_adapter.gd` `_mount_shape`)
Closed shapes mount `Polygon2D` + closed `Line2D` outline (Round 1 mirrored the editor's
fill+outline rendering). The user wants one node per shape: closed → `Polygon2D` only; open →
`Line2D` only. `DEFAULT_LINE_WIDTH = 2.0` still applies to open lines.
### 1c. Part rotation/scale (`scripts/stk_rig_adapter.gd` mount pipeline)
The `.stk` part dict carries `rotation` (degrees, about the part bbox center) and `scale`
(about the bbox center) from the Whole Stickman preview
(`whole_stickman_preview.gd:500-506`: `world = C + R(rot)·S·(pt C)`, `C =` bbox center). The
adapter currently mounts the raw drawn geometry, ignoring both. The user's requirement: the
shapes must appear in the harness exactly as rotated/scaled in the editor (the guide in the
editor IS the rig's rest pose).
**Full application is possible without detaching limbs** — the earlier "flip-only" concern was
based on computing the alignment on the *raw* geometry and then rotating on top, which
double-rotates. The correct composition (per user direction) is:
1. Apply the part transform **first**: `Q = E(P) = C + R(rot)·S·(P C)` for every point
(scale then rotate about the bbox center — the editor's exact transform).
2. Compute the anchor and alignment **on the transformed geometry**:
- `J' = E(J_raw)` (the drawn joint end, transformed).
- `F' = E(J_raw + F_raw) J' = R(rot)·S·F_raw` (the transformed far vector).
3. Alignment `θ = F'.normalized().angle_to(Vector2.DOWN)` — rotates the fitted long axis onto
the bone. Because the user aligned the shape to the guide (the bone direction), θ is the
residual between their drawn orientation and the bone; the shape appears in the harness as
in the editor, attached at the joint. 90° rotations (break.stk's lower limbs — sideways
drawn bars turned vertical) work: `F'` points along the fitted limb, `θ` keeps it hanging,
and the limb stays connected to the elbow/knee.
4. **180° flips** (rotations whose wrapped value is within ±45° of 180°): a flip about the
center swaps the ends of the shape. In the editor the flipped shape shows its drawn far end
at the joint region, so the harness attaches the **drawn far end** at the joint:
- `flipped = |wrapf(rot_rad, -PI, PI)| > PI * 0.75`
- if flipped: anchor `A = E(far_raw_end)`, far vector `V = F'`; else `A = J'`, `V = F'`.
The flipped part then hangs the other way along the bone with its content turned around —
the rotation is visibly applied (e.g. the 180° torso shows its drawn neck end at the hip
joint and the drawn hip end up at the neck — matching the editor's flipped torso).
5. Bone fit scale `s = bone_length / V.length()` measured on the transformed extent (so a
user-scaled part is not double-fitted; `s ≈ 1` when the user already scaled to the bone
length — verified numerically for every `break.stk` part). Guards: `|V| <= 0.0001 → s = 1,
θ = 0`.
6. Mounted points: `v = R(θ)·(Q A)`; `v.y *= s`.
7. **Head special case (unchanged semantics):** `θ = 0`, `s = 1` (the head mounts upright,
chin at the origin, extending `Y`; the head driver has no rotation). The flip anchor rule
still applies (a 180° head attaches at its cap top — upside-down, chin up — matching the
editor). Part scale applies through `E` (head face ≈160 px: drawn 80 × 1.986).
---
## 2. Fix specification
### 2a. `scripts/test_harness.gd` — head leaf segment
In `_draw_bones()`, for the leaf bone `Head` (no Bone2D child): draw
`origin → origin + Vector2(0, -length).rotated(global_rotation)` instead of the IK-target
segment. Limb leaf bones (LeftLowerArm, RightLowerArm, LeftLowerLeg, RightLowerLeg) keep the
IK-target segments. Implement via a per-bone check (`bone.name == "Head"`) or by removing
`Head` from `LEAF_BONE_IK_PATHS` and handling it in the no-target fallback (the fallback
already draws `origin + R(global_rotation)·(length, 0)` — change it to use the bone's
`bone_angle` direction: `Vector2(length, 0).rotated(deg_to_rad(bone.bone_angle)).rotated(global_rotation)`;
for the Head (`bone_angle = 90`) that yields `(0, 90)` rotated by the pose — the desired
segment; for other bones `bone_angle` is 0 → `(length, 0)` — same as today).
### 2b. `scripts/stk_rig_adapter.gd` — one node per shape
`_mount_shape()`: closed → `Polygon2D` only (polygon + color; **no** outline Line2D); open →
`Line2D` (width `DEFAULT_LINE_WIDTH`). Delete the outline branch.
### 2c. `scripts/stk_rig_adapter.gd` — part transform composition
Read per-part `rotation` (float, **degrees**, default 0.0) and `scale` (`{x, y}`, default
`(1, 1)`) from the part dict. Apply in the mount pipeline:
1. Raw bbox `C` (center), raw anchor `J_raw`, raw far point `F_pt_raw = J_raw + F_raw`
(direction from the joint end to the far end, per the Round 2 family rules), as today.
2. `E(P) = C + R(rot_rad)·S_part·(P C)` — the preview's part transform, applied to every
point and to the anchor/far point: `J' = E(J_raw)`, `F_pt' = E(F_pt_raw)`,
`F' = F_pt' J'`.
3. `flipped = |wrapf(rot_rad, -PI, PI)| > PI * 0.75`.
4. Anchor `A` and far vector `V`: `A = F_pt'`, `V = F'` if flipped, else `A = J'`,
`V = F'`.
5. `θ = V.normalized().angle_to(Vector2.DOWN)` (0 if `|V| <= 0.0001`).
6. `s = bone_length / |V|` (head → 1.0; guards `|V| <= 0.0001 → 1.0`).
7. Mounted points: `v = R(θ)·(Q A)`; `v.y *= s`. Head: `θ = 0`, `s = 1` (upright mount,
flip anchor rule still applies).
**Example (torso, rot 180°, scale (0.857, 3.99)):** `E` flips the drawn torso; `J'` = the
transformed hip end (now the fitted top), `F_pt'` = the transformed neck end (fitted bottom);
flipped → `A` = the neck end; `V` points up → `θ = π`; `s ≈ 391.5/395`; the torso extends up
from the hip joint with the drawn **neck end attached at the hip** and the drawn hip end up at
the neck — the 180° rotation visibly applied (unrotated: hip end at the hip, neck at the
neck).
**Example (lower arm, rot 90°):** not flipped; `J'` = the elbow (fitted bottom); `F'` points
up (the editor's bent forearm) → `θ = π`; the forearm hangs along the bone from the elbow with
its content turned 90° — exactly as assembled in the editor.
**Scale check:** part scale composes into `E` and the fit `s` is measured on the transformed
extent → no double-fitting. Head: `s = 1` but `E` applies `1.986` → the face mounts at ≈160 px
(drawn 80 × 1.986).
### 2d. Keep (unchanged)
`_reset_node_transform`, head `set_script(null)`, head-driver zeroing, `_fit_bones`,
`_recalibrate_ik`, `_bone_length_for`, `_compute_part_bbox`, width 2.0, all node-path
constants, null guards.
---
## 3. Files modified
| File | Changes |
|---|---|
| `scripts/stk_rig_adapter.gd` | `_mount_shapes()` reads part `rotation`/`scale`; mount pipeline composes `E_full` + fitted anchor/θ/fit-scale + flip heuristic; `_mount_shape()` mounts one node per shape (closed → Polygon2D only). |
| `scripts/test_harness.gd` | `_draw_bones()` leaf fallback uses the bone's `bone_angle` direction (Head draws a 90 px segment inside the head; limbs unchanged). |
| `docs/phase9_round3_bugfix_spec.md` | This file. |
## 4. Open question — RESOLVED (user directive)
1. **Rotation semantics.** User directive: *the shapes must appear in the harness exactly as
rotated/scaled in the editor — that is the point of the guide.* ✅ **Full application**: the
part transform `E` is applied to the geometry first, and the mount (anchor + alignment +
fit) is computed on the transformed geometry, so every rotation is rendered. Rotations
align the drawn long axis onto the bone (a 90° rotation turns a sideways-drawn bar into the
limb as assembled — the limb stays attached to its joint; this does **not** detach limbs
because the anchor is the transformed joint end, not a raw-geometry point). 180° flips
additionally swap the attachment to the drawn far end (the end the user rotated into the
joint position), making flips visible.
## 5. Edge cases
- Rotation absent (old files) → 0.0; scale absent → (1, 1) — Round 2 behavior unchanged.
- Negative part scale (mirroring) → `E_full` mirrors the points; `θ` adapts; fine.
- `F'` ≈ 0 (degenerate) → `s = 1`, `θ = 0`, no flip effect.
- Flip + IK: the flip is baked into the mounted points, so IK flexing still follows the bones.
- Multi-shape parts: bbox/transform over all shapes (unchanged).
- `-360`-style rotations normalize via `wrapf` ✓.
## 6. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless smoke test (spawn `break.stk`, stack enabled, 2 frames):
- Every `Body/*` node's children: closed shapes → exactly one Polygon2D and zero Line2D
for that shape (count Polygon2D == number of closed shapes; Line2D count == number of
open shapes; no node has both for the same shape).
- Head: mounted face width ≈ 159160 px (bbox width 108 × 1.986 ≈ 215 incl. cap; the face
circle x-span ≈ 160), chin still at local origin.
- Legs/arms still hang along bones: `Body/LeftUpperLeg` far end ≈ (0, +~200) local after
`θ`+fit; thickness ≈ 24 (drawn 28 × part-scale 0.853).
- Flip: temporarily modify the stk dict — set `torso.rotation = 180.0` — re-apply to a
fresh rig, assert the drawn torso **neck end** now lands ≈ (0, 0) (hip joint) and the
drawn **hip end** ≈ (0, 391.5) (neck) — the ends swapped vs. rotation 0 (hip end at the
hip). Restore rotation 0 → hip end back at (0, 0).
- Overlay: `test_harness.gd` head-leaf drawing path exists (code review; the overlay draws
cannot be asserted headlessly — verified by review + manual F6).
- Regression: driver rotations enabled; IK-following still works (move Left_Hand target,
assert Body/LeftUpperArm rotation tracks bone + π/2).
3. Manual harness check (F6): no bone sticking out of the head; single nodes in the Remote
Inspector; torso flip visible after rotating 180° in the editor and saving.
## 7. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Head leaf overlay segment along the bone's own direction (`bone_angle`-aware fallback) | The LookAt target is an aim point, not a joint; the bone itself is neck→head-top. |
| D2 | One node per shape (closed → Polygon2D only) | User request; removes node duplication in the Body tree. |
| D3 | Apply part scale fully (via `E`) + measure bone-fit scale on the transformed extent | Reproduces the editor scale (head 160 px) without double-fitting bone-scaled parts. |
| D4 | Apply part rotation fully: `E` first, then anchor/alignment/fit on the transformed geometry; 180° flips swap the attachment to the drawn far end | User directive (editor fidelity); the transformed joint anchor keeps 90°-rotated limbs attached to their bones. |
| D5 | Flip shown via the anchor swap (no separate content rotation) | Matches the editor: the flipped shape's far end sits at the joint region; content turns around on the bone. |
## 8. Implementation order
1. `stk_rig_adapter.gd` — part `rotation`/`scale` read + `E_full`/`J'`/`F'`/`θ`/fit/flip in
`_mount_shapes()` + `_compute_mount_transform()` restructure.
2. `stk_rig_adapter.gd``_mount_shape()` single-node-per-shape.
3. `test_harness.gd` — head-leaf overlay segment.
4. Headless smoke test + parse check.
5. Docs (`BUGS.md` Round 3 note, `AGENTS.md`, `README.md`).