Files
stickman/docs/phase9_round3_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

214 lines
13 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 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`).