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

13 KiB
Raw Blame History

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).