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,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 ≈ 159–160 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`).
|
||||
Reference in New Issue
Block a user