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`).
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 9 Round 4 — Bugfix: Head Position vs. the Editor's Silhouette Guide
|
||||
|
||||
## Overview
|
||||
|
||||
User report: the head is **too high** in the harness. Reference: the editor's silhouette guide
|
||||
head is a circle; the bottom of the mounted head shape must line up with the bottom of that
|
||||
circle relative to the torso bone, so the head **overlaps the torso** some.
|
||||
|
||||
### Guide geometry (source of truth, `scripts/whole_stickman_preview.gd`)
|
||||
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `GUIDE_JOINTS["Neck"]` (`:61`) | `(0, −391.5)` — the Head bone origin |
|
||||
| `GUIDE_JOINTS["Head"]` (`:62`) | `(0, −463.5)` — the head circle **center** |
|
||||
| `GUIDE_HEAD_RADIUS` (`:49`) | `100.0` |
|
||||
|
||||
→ The guide's head circle bottom = `−463.5 + 100 = −363.5` — **28 px below the neck**,
|
||||
overlapping the torso's top region (torso spans `0 → −391.5`) by 28 px.
|
||||
|
||||
### Current behavior (wrong)
|
||||
|
||||
Round 3 zeroes the Head driver's local position, so `Body/Head` sits on the Head bone origin
|
||||
(the neck, `y = −391.5`); the mounted chin is the local origin → the chin lands at the neck
|
||||
and the head floats above the torso with no overlap.
|
||||
|
||||
### Required behavior
|
||||
|
||||
The mounted head's bottom (chin) must land at `y = −363.5` — i.e. the mounted head geometry
|
||||
gets a `+28 px` local Y offset so the chin sits 28 px below the neck joint, matching the
|
||||
guide circle's bottom and overlapping the torso.
|
||||
|
||||
## Fix specification — `scripts/stk_rig_adapter.gd`
|
||||
|
||||
1. Add `const HEAD_CHIN_DROP := 28.0` with a doc comment deriving the value
|
||||
(guide head circle bottom `−363.5` − neck `−391.5` = 28; the guide circle has radius 100
|
||||
centered at the Head joint `(0, −463.5)`).
|
||||
2. In the head branch of the mount (where `θ = 0`, `s = 1`), after transforming the points,
|
||||
translate them by `Vector2(0.0, HEAD_CHIN_DROP)` — the chin (local origin) ends up 28 px
|
||||
below the node origin (the neck).
|
||||
3. The flip anchor rule from Round 3 is unchanged (a flipped head still attaches at its cap
|
||||
top; the drop applies identically).
|
||||
4. Nothing else changes (no driver/overlay/serialization changes).
|
||||
|
||||
Note: the offset is applied **after** the part scale/rotation composition (it is a rig-space
|
||||
fixture position, not part geometry), so a 180°-rotated head drops by the same 28 px.
|
||||
|
||||
## Files modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/stk_rig_adapter.gd` | `HEAD_CHIN_DROP` const + head-points translation. |
|
||||
| `docs/phase9_round4_bugfix_spec.md` | This file. |
|
||||
|
||||
## 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):
|
||||
- `Body/Head` world position ≈ `(0, −391.5)` (node still at the neck).
|
||||
- Mounted head chin point ≈ local `(0, +28)`; world chin ≈ `(0, −363.5)`.
|
||||
- Head top ≈ world `(0, −363.5 − 219)` ≈ `(0, −582)`; the head now overlaps the torso's
|
||||
top region (torso top at −391.5) by ≈ 28 px.
|
||||
- Regression: the head's other Round 3 properties (scale ≈160 face, one node per shape,
|
||||
IK following, flip behavior) unchanged.
|
||||
3. Manual harness check (F6): head bottom aligned with the guide circle's bottom; head
|
||||
overlaps the torso.
|
||||
|
||||
## Design decisions
|
||||
|
||||
| # | Decision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | Fixed `+28 px` rig-space offset (not relative to the head's drawn size) | The guide circle is a fixed rig fixture; the requirement is to align to its bottom. |
|
||||
| D2 | Offset applied to the mounted points (not the driver) | The Head driver's zeroing stays (rotation about the neck with the LookAt); a driver offset would swing with the bone rotation. |
|
||||
@@ -0,0 +1,159 @@
|
||||
# Phase 9 Round 5 — Feature: Guide-Relative Part Placement in the Harness
|
||||
|
||||
## Overview
|
||||
|
||||
User request: for the torso, legs, and arms, attach the shapes to the rig **relative to the
|
||||
silhouette guide** in the editor — the harness stickman should represent how each part was
|
||||
placed on the editor's guide **1:1** (the guide's joint positions are the reference).
|
||||
|
||||
### Current behavior
|
||||
|
||||
The adapter assumes perfect alignment: each part's joint end mounts exactly at the rig joint
|
||||
(offset 0), so any placement offset the user applied in the Whole Stickman preview is lost.
|
||||
|
||||
### Design
|
||||
|
||||
The editor computes, at save time, each part's placement offset from its guide joint (a
|
||||
size-independent delta in preview pixels = master pixels, since `GUIDE_SCALE = 1.0`) and
|
||||
writes it as per-part write-only metadata. The adapter converts it to the joint-end offset and
|
||||
translates the mounted geometry accordingly, in the bone's frame.
|
||||
|
||||
## 1. Guide reference geometry (`scripts/whole_stickman_preview.gd`)
|
||||
|
||||
`GUIDE_JOINTS` (`:59-73`) gives every joint in master space. The part→joint mapping:
|
||||
|
||||
| Part | Guide joint |
|
||||
|---|---|
|
||||
| `head` | `Neck` (the head bone origin / rig attachment, `(0, −391.5)` — NOT the circle center, which sits 72 px above the neck) |
|
||||
| `torso` | `Hips` |
|
||||
| `left_upper_arm` | `LeftShoulder` |
|
||||
| `left_lower_arm` | `LeftElbow` |
|
||||
| `right_upper_arm` | `RightShoulder` |
|
||||
| `right_lower_arm` | `RightElbow` |
|
||||
| `left_upper_leg` | `Hips` |
|
||||
| `left_lower_leg` | `LeftKnee` |
|
||||
| `right_upper_leg` | `Hips` |
|
||||
| `right_lower_leg` | `RightKnee` |
|
||||
|
||||
## 2. Editor changes
|
||||
|
||||
### 2a. `scripts/whole_stickman_preview.gd`
|
||||
|
||||
Add a public method:
|
||||
|
||||
```gdscript
|
||||
func get_guide_joint_preview(joint_name: String) -> Vector2:
|
||||
if not GUIDE_JOINTS.has(joint_name):
|
||||
push_warning("WholeStickmanPreview: unknown guide joint '%s'." % joint_name)
|
||||
return Vector2.ZERO
|
||||
return _guide_to_preview(GUIDE_JOINTS[joint_name])
|
||||
```
|
||||
|
||||
### 2b. `scripts/stickman_editor.gd`
|
||||
|
||||
1. Add `const GUIDE_JOINT_FOR_PART: Dictionary` (the 10-row mapping above).
|
||||
2. Bump `FILE_VERSION` `"1.4"` → `"1.5"`; add `"1.5"` to `SUPPORTED_VERSIONS`; update the
|
||||
unsupported-version error message (`'1.0'..'1.5'`).
|
||||
3. In `_collect_all_shape_data()`, for **each** part compute the guide offset:
|
||||
```
|
||||
var joint_preview := _whole_preview.get_guide_joint_preview(GUIDE_JOINT_FOR_PART[part_name])
|
||||
var center_preview := pos + Vector2(float(pl["pivot"].x), float(pl["pivot"].y))
|
||||
var guide_offset := center_preview - joint_preview
|
||||
```
|
||||
(the part's bbox **center** in preview space minus the guide joint in preview space — both
|
||||
are preview-world coordinates, so the panel-size terms cancel and the delta is pure
|
||||
master-space pixels) and store `"guide_offset": { "x": ..., "y": ... }` in the part dict
|
||||
(uniform for all 10 parts).
|
||||
4. `guide_offset` is **write-only** metadata like `pivot`/`length` — the load path ignores it
|
||||
(`_apply_json_data` unchanged); v1.0–v1.4 files load unchanged and gain the key on their
|
||||
next save.
|
||||
|
||||
## 3. Adapter changes (`scripts/stk_rig_adapter.gd`)
|
||||
|
||||
1. Read `guide_offset` (`{x, y}`) from the part dict (default `null`/absent).
|
||||
2. **Only when present** (old files keep the current offset-0 behavior), compute the anchor
|
||||
offset: `delta = guide_offset + (A − C)` where
|
||||
- `A` = the mount anchor already computed (the transformed joint end `J'`, or the
|
||||
transformed far end `F_pt'` when flipped — Round 3);
|
||||
- `C` = the raw bbox center (the editor measured `guide_offset` from the bbox center).
|
||||
3. Convert to the node frame: `t = delta.rotated(-c_node)` where `c_node` = the part's driver
|
||||
`RemoteTransform2D.global_rotation` at apply time (the rig is not yet in the tree — this is
|
||||
the authored/guide pose frame; the driver later maps local `+Y` onto the bone, so `t` is a
|
||||
bone-relative placement, preserved as the bone flexes).
|
||||
4. Apply `t` to the mounted points (final translation, after `E`/θ/scale/flip; independent of
|
||||
the flip logic).
|
||||
5. **Head:** when `guide_offset` is present, apply it exactly like the other parts (anchor
|
||||
`A` = the chin, or the cap top when flipped; `c_node` ≈ 0) — the stored offset then
|
||||
reproduces the head's placement vs. the guide circle (chin at the circle bottom
|
||||
`−363.5` when the user aligned it there). When absent (old files), keep the Round 4
|
||||
`HEAD_CHIN_DROP` translation as the fallback.
|
||||
6. All driver lookups null-guarded (existing pattern).
|
||||
|
||||
Worked example (break.stk torso, assembled with its bbox center ≈195.75 px above the guide's
|
||||
Hips): `guide_offset ≈ (0, −195.75)`; `A − C` ≈ `(0, ±197.5)` (hip end below center, or the
|
||||
neck end above when flipped) → `delta ≈ (0, ±2)` — the torso's joint end lands within ~2 px of
|
||||
the Hips joint, reproducing the editor placement.
|
||||
|
||||
## 4. Files modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/whole_stickman_preview.gd` | `get_guide_joint_preview()` public method. |
|
||||
| `scripts/stickman_editor.gd` | `GUIDE_JOINT_FOR_PART` const; `FILE_VERSION "1.5"` (+ `SUPPORTED_VERSIONS` + error text); `guide_offset` computed per non-head part in `_collect_all_shape_data()`. |
|
||||
| `scripts/stk_rig_adapter.gd` | Read + apply `guide_offset` (node-frame translation, only when present). |
|
||||
| `docs/phase9_round5_bugfix_spec.md` | This file. |
|
||||
|
||||
## 5. Edge cases
|
||||
|
||||
- **Old files without `guide_offset`** → no translation (exact current behavior; the head
|
||||
keeps the Round 4 chin drop).
|
||||
- **Empty part** → the adapter skips before reading the offset (unchanged).
|
||||
- **Flipped part** → `A` is the far end; the offset still reproduces the placement (the end
|
||||
the user placed at the joint).
|
||||
- **Head** → the guide joint is the **Neck** (the bone origin the head mounts at); a user
|
||||
aligned chin-at-circle-bottom yields `guide_offset = (0, −72 − half-height…)` →
|
||||
`delta ≈ (0, +28)` → chin at `−363.5` (the Round 4 chin drop is the fallback for files
|
||||
without the key). Using the circle center instead would land the chin 72 px too high.
|
||||
- **Guide moves on window resize** → offsets are computed at save time from the live preview;
|
||||
re-save after resizing to refresh (note to user).
|
||||
- **Rest vs IK pose frame** → `c_node` is the authored (guide) pose; at most ~15° pose
|
||||
difference for the legs — sub-pixel error for typical small offsets; documented.
|
||||
|
||||
## 6. Test plan
|
||||
|
||||
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
|
||||
2. Headless adapter smoke test (synthetic stk dicts):
|
||||
- Part WITHOUT `guide_offset`: mounted anchor at local (0,0) (regression).
|
||||
- Torso WITH `guide_offset = (0, −195.75)` (rotation 0, scale (0.857, 3.99) like
|
||||
break.stk): anchor `A` = hip end at `C + (0, +197.5)`; `delta = (0, −195.75) + (0, 197.5)
|
||||
= (0, 1.75)`; `t = delta.rotated(-π)` = `(0, −1.75)` → assert the mounted hip end lands
|
||||
at local ≈ (0, −1.75) instead of (0, 0).
|
||||
- Torso WITHOUT the key → hip end at local (0, 0) exactly.
|
||||
- Flipped torso (rotation 180) WITH `guide_offset = (0, −195.75)`: the 180° rotation
|
||||
moves the drawn neck end about `C` to `C + (0, +197.5)`, so `A − C = (0, +197.5)`;
|
||||
`delta = (0, −195.75) + (0, 197.5) = (0, 1.75)`; assert the anchor point (drawn neck
|
||||
end) lands at `delta.rotated(-π) = (0, −1.75)` local.
|
||||
- IK regression: with an offset applied, moving an IK target still rotates the Body nodes
|
||||
correctly.
|
||||
3. Editor-side verification: parse check + code review (the save computation); manual editor
|
||||
run (F5): load break.stk, Save, inspect the .stk → `version "1.5"` + per-part
|
||||
`guide_offset` for the 9 non-head parts; the harness then shows the placement 1:1.
|
||||
4. Cleanup temp test files.
|
||||
|
||||
## 7. Design decisions
|
||||
|
||||
| # | Decision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | Editor computes the offset as bbox-center − guide-joint (preview space) at save time | Both points live in the same preview world; the delta cancels the panel-size term, so the stored value is a pure master-space vector. |
|
||||
| D2 | Store as per-part `guide_offset` (write-only), version bump to `"1.5"` | Matches the `pivot`/`length` metadata precedent; load path untouched; old files default to offset 0. |
|
||||
| D3 | Adapter converts center-offset → anchor-offset (`+ (A − C)`) and applies it in the driver's pose frame | The anchor is the joint end the user placed; the driver frame keeps the placement bone-relative as the rig flexes. |
|
||||
| D4 | Head included, mapped to the guide **Neck** joint (not the circle center); Round 4 chin drop becomes the old-file fallback | The neck is the head's rig attachment point; the circle center would misplace the chin by 72 px. |
|
||||
| D5 | Offset applied only when the key is present | Old files keep exact current behavior (perfect-alignment assumption / chin drop). |
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
1. `whole_stickman_preview.gd` — `get_guide_joint_preview()`.
|
||||
2. `stickman_editor.gd` — const map + version bump + `guide_offset` in `_collect_all_shape_data()`.
|
||||
3. `stk_rig_adapter.gd` — read/apply `guide_offset`.
|
||||
4. Headless smoke test + parse check.
|
||||
5. Docs (BUGS.md Round 5 note, AGENTS.md, README.md).
|
||||
@@ -0,0 +1,103 @@
|
||||
# Phase 9 Round 6 — Bugfix: Joint-End Anchor Selection via the Guide Placement
|
||||
|
||||
## Overview
|
||||
|
||||
User report: the **lower left leg** and **lower right arm** are mounted 180° off their bones
|
||||
(the far end attaches at the joint). Root cause (verified against `break.stk` data): the
|
||||
adapter picks the joint end with fixed per-side family rules (left limbs → drawn `max_x` end,
|
||||
right limbs → drawn `min_x` end, plus a 180° flip heuristic), but the user's drawn-side
|
||||
conventions are **inconsistent across parts** — e.g. the left lower leg's knee is at the drawn
|
||||
`min_x` end while the rule assumes `max_x`. The only reliable signal for which drawn end is
|
||||
the joint is the **user's placement**, which Round 5 now stores as `guide_offset`.
|
||||
|
||||
## 1. Verified diagnosis (break.stk, real data)
|
||||
|
||||
`joint_in_part_space = C − guide_offset` (C = raw bbox center; both ends E-transformed):
|
||||
|
||||
| Part | rule anchor | dist to joint | other end | dist to joint | correct end |
|
||||
|---|---|---|---|---|---|
|
||||
| `left_lower_leg` (rot 90) | E(max_x) = ankle | ≈199 | E(min_x) = **knee** | ≈11 | min_x ✗ (bug) |
|
||||
| `right_lower_arm` (rot 90) | E(min_x) = wrist | ≈204 | E(max_x) = **elbow** | ≈2 | max_x ✗ (bug) |
|
||||
| `left_lower_arm` (rot 90) | E(max_x) = elbow | ≈4 | E(min_x) = wrist | ≈204 | max_x ✓ |
|
||||
| `right_lower_leg` (rot 90) | E(min_x) = knee | ≈6 | E(max_x) = ankle | ≈199 | min_x ✓ |
|
||||
| `right_upper_arm` (rot −180) | flip → E(max_x) = shoulder | ≈7 | E(min_x) = hand | ≈178 | max_x ✓ (flip heuristic) |
|
||||
| `torso` (rot 180) | flip → E(neck end) | ≈5 | E(hip end) | ≈400 | neck ✓ (flip heuristic) |
|
||||
| `head` | E(chin) | ≈43 | E(cap top) | ≈191 | chin ✓ |
|
||||
|
||||
**Rule:** when `guide_offset` is present, choose as the joint anchor whichever transformed end
|
||||
(`E(J_raw)` or `E(F_pt_raw)`) is **nearest to `C − guide_offset`** (the part's guide joint in
|
||||
part space). This fixes both buggy parts, preserves every correct case, and naturally
|
||||
reproduces the 180° flip behavior (a flipped part's far end lands near the joint).
|
||||
|
||||
## 2. Fix specification — `scripts/stk_rig_adapter.gd`
|
||||
|
||||
In `_compute_mount_transform()` (the anchor/flip section, ~lines 397–407):
|
||||
|
||||
1. When `has_guide_offset` is true:
|
||||
- `joint_pos := center - guide_offset` (the guide joint in the part's E-space; the editor
|
||||
stored `guide_offset = (pos + C) − joint_preview`, so `C − guide_offset = joint_preview −
|
||||
pos` — the joint position in the adapter's local frame, up to the preview translation
|
||||
which is distance-preserving).
|
||||
- `d_joint := j_prime.distance_to(joint_pos)`; `d_far := f_pt_prime.distance_to(joint_pos)`.
|
||||
- If `d_far < d_joint` (strict): `anchor = f_pt_prime`, `v = -f_prime` (the far end
|
||||
attaches — replaces the flip heuristic for this case).
|
||||
Else: `anchor = j_prime`, `v = f_prime`.
|
||||
2. When `has_guide_offset` is **false** (old files): keep the current family rules + 180°
|
||||
flip heuristic exactly as today.
|
||||
3. `theta`, `s`, the Round 5 offset `t = (guide_offset + (anchor − center)).rotated(−c_node)`,
|
||||
the head fallback (`HEAD_CHIN_DROP`), and everything downstream are **unchanged** — they
|
||||
already consume `anchor`/`v` generically.
|
||||
|
||||
Note: this answers the user's hypothesis directly — the pivot point was indeed on the wrong
|
||||
drawn side; instead of guessing per-side conventions (or baking rotation/scale, which does not
|
||||
record which end is the joint), the stored guide placement decides the pivot side.
|
||||
|
||||
## 3. Files modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/stk_rig_adapter.gd` | Nearest-end anchor selection when `guide_offset` is present (replaces the family-side + flip choice for that case). |
|
||||
| `docs/phase9_round6_bugfix_spec.md` | This file. |
|
||||
|
||||
## 4. Edge cases
|
||||
|
||||
- **Old files without `guide_offset`** → family rules + flip heuristic (current behavior).
|
||||
- **Tie** (strict `<`) → keeps the family-rule end.
|
||||
- **Flipped parts with `guide_offset`** → the far end is naturally nearest (the flip behavior
|
||||
is preserved without the heuristic).
|
||||
- **Misplaced parts** (placement error > half the part length) → the wrong end may win;
|
||||
degenerate input, acceptable.
|
||||
- **Head** → the chin is nearest the Neck (≈43 vs ≈191); the Round 5 offset and the
|
||||
`HEAD_CHIN_DROP` fallback are unchanged.
|
||||
|
||||
## 5. Test plan
|
||||
|
||||
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
|
||||
2. Headless smoke test (spawn the current `break.stk`):
|
||||
- `Body/LeftLowerLeg`: the mounted knee end ≈ local (0, 0) (was the ankle end) and the far
|
||||
end ≈ (0, +200) — shin hangs from the knee.
|
||||
- `Body/RightLowerArm`: the mounted elbow end ≈ local (0, 0), far end ≈ (0, +200).
|
||||
- Regressions: `Body/LeftLowerArm` elbow ≈ (0,0); `Body/RightLowerLeg` knee ≈ (0,0);
|
||||
`Body/RightUpperArm` shoulder ≈ (0,0) (flip case via nearest rule); torso flipped neck
|
||||
end ≈ (0,0); head chin ≈ (0, +28) (guide-offset present → no chin-drop, chin ≈ 28 from
|
||||
the neck per the stored offset); all parts' far ends along +Y local (bone-aligned).
|
||||
- No-guide-offset fallback (synthetic dict without the key): lower limbs use the family
|
||||
rules + flip heuristic (regression: flipped torso anchor = far end; 90° limbs anchored at
|
||||
the family side).
|
||||
- IK regression: moving a hand target still rotates the body nodes with the bones.
|
||||
3. Cleanup temp test files.
|
||||
|
||||
## 6. Design decisions
|
||||
|
||||
| # | Decision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | Joint end = the fitted end nearest the stored guide joint (`C − guide_offset`) | The drawn-side conventions are inconsistent per part; the placement is the ground truth. |
|
||||
| D2 | Nearest rule only when `guide_offset` is present | Old files keep exact current behavior. |
|
||||
| D3 | Keep θ/s/t and the head fallback unchanged | They consume `anchor`/`v` generically; the placement math is already correct. |
|
||||
| D4 | No baking of rotation/scale into the .stk | Baking alone cannot record which drawn end is the joint; the stored placement already encodes it. |
|
||||
|
||||
## 7. Implementation order
|
||||
|
||||
1. `stk_rig_adapter.gd` — nearest-end anchor selection (guarded by `has_guide_offset`).
|
||||
2. Headless smoke test + parse check.
|
||||
3. Docs (BUGS.md Round 6 note, AGENTS.md, README.md).
|
||||
@@ -0,0 +1,103 @@
|
||||
# Phase 9 Round 7 — Feature: Draggable Torso & Head IK Targets in the Test Harness
|
||||
|
||||
## Overview
|
||||
|
||||
User request (test harness):
|
||||
1. **Draggable torso target** — so the user can drag the whole stickman around.
|
||||
2. **Draggable head target** — so the user can test the LookAt IK for the head.
|
||||
|
||||
The rig already provides both targets:
|
||||
- `IK_Targets/Torso` (Marker2D at the hips) with a child `RemoteTransform2D` whose
|
||||
`remote_path` is `../../../Skeleton2D/Torso` — moving the marker moves the hip bone, and
|
||||
every other bone (Head/arms/legs) and `Body/*` visual follows.
|
||||
- `IK_Targets/Head` (Marker2D at `(0, −624)`) — the `SkeletonModification2DLookAt` aim point
|
||||
for the Head bone (`bone_index = 1`, constrained, `constraint_in_localspace = true`).
|
||||
|
||||
The harness (`scripts/test_harness.gd`) already has generic handle dragging
|
||||
(`IK_HANDLE_PATHS` → `_hit_test_handle` → `_dragging_handle` → `_handle_mouse_motion` sets
|
||||
`global_position`; `_draw_ik_handles` draws markers). Only the two new handles need wiring,
|
||||
plus torso-follow semantics.
|
||||
|
||||
## 1. Fix specification — `scripts/test_harness.gd`
|
||||
|
||||
### 1a. Register the new handles
|
||||
|
||||
Add to `IK_HANDLE_PATHS`:
|
||||
|
||||
```gdscript
|
||||
"Head": "IK_Targets/Head",
|
||||
"Torso": "IK_Targets/Torso",
|
||||
```
|
||||
|
||||
(Note: `LEAF_BONE_IK_PATHS` stays unchanged — the head's LookAt aim point is not a bone tip,
|
||||
and the Torso is not a leaf bone.)
|
||||
|
||||
### 1b. Torso drag (resolved: bones only)
|
||||
|
||||
The Torso handle behaves like every other handle: dragging it moves only the marker (the
|
||||
`IK_Targets/Torso` RemoteTransform2D then moves the hip bone, and the bone hierarchy follows).
|
||||
The other IK targets (hands/legs/head) **stay put**, so dragging the figure away from them
|
||||
stretches the limbs toward the stationary targets — per user decision.
|
||||
|
||||
### 1c. Marker colors
|
||||
|
||||
`_draw_ik_handles` currently colors hands green, everything else blue. Add distinct colors:
|
||||
- `HANDLE_COLOR_HEAD := Color(1.0, 1.0, 0.0)` (yellow) for `"Head"`.
|
||||
- `HANDLE_COLOR_TORSO := Color(1.0, 0.0, 1.0)` (magenta) for `"Torso"`.
|
||||
|
||||
### 1d. Head aim line (visual aid for the LookAt test)
|
||||
|
||||
In `_draw_ik_handles`, when both the head bone and the head marker exist, draw a thin
|
||||
semi-transparent line from the head bone origin (`Skeleton2D/Torso/Head` global position) to
|
||||
the head marker so the user can see what the head is aiming at. (Optional but helpful; use the
|
||||
existing overlay draw style, e.g. width 1.5/zoom, alpha ~0.5.)
|
||||
|
||||
## 2. Files modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/test_harness.gd` | `IK_HANDLE_PATHS` + 2 entries (Head, Torso); head/torso marker colors; head aim line. |
|
||||
| `docs/phase9_round7_feature_spec.md` | This file. |
|
||||
|
||||
## 3. Edge cases
|
||||
|
||||
- **Missing nodes** (foreign rig): `_ik_handles` lookups are already null-guarded; the aim
|
||||
line needs a null guard for the head bone.
|
||||
- **Dragging the Torso** moves the markers only (the `RayCast_*` helpers under `IK_Targets`
|
||||
stay put — they are not handles).
|
||||
- **Head LookAt constraints** — the head bone rotates within its authored constraint range;
|
||||
dragging the head marker far away clamps the rotation (expected rig behavior).
|
||||
- **Camera** — does not follow the dragged figure (unchanged).
|
||||
|
||||
## 4. Test plan
|
||||
|
||||
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
|
||||
2. Headless rig-level verification (SceneTree script, spawn `break.stk`):
|
||||
- Move `IK_Targets/Torso` by `(50, −30)` → await a frame → assert `Skeleton2D/Torso`
|
||||
global position moved by ≈ the delta, and every `Body/*` visual node's global position
|
||||
moved by ≈ the same delta (rigid translation via the Torso RemoteTransform2D).
|
||||
- Move `IK_Targets/Head` from `(0, −624)` to `(300, −624)` → await a frame → assert the
|
||||
Head bone `global_rotation` changed, and `Body/Head.global_rotation` changed with it
|
||||
(rotation push) — LookAt works.
|
||||
3. Harness code review: `IK_HANDLE_PATHS` has 6 entries (incl. Head, Torso); marker colors
|
||||
per §1c (yellow head, magenta torso); the head aim line is null-guarded and drawn in the
|
||||
IK overlay; the generic drag flow needs no changes (the Torso marker's RemoteTransform2D
|
||||
moves the hip bone on drag). (Mouse-drag flow is UI-side; the same `_handle_mouse_motion`
|
||||
math is covered by the review.)
|
||||
4. Manual F6 check: drag the magenta torso marker — whole figure moves; drag the yellow head
|
||||
marker — the head turns to look at it.
|
||||
5. Cleanup temp files.
|
||||
|
||||
## 5. Design decisions
|
||||
|
||||
| # | Decision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | Torso drag = bones only (no target following) | User decision: dragging the figure away from the stationary limb/head targets stretches the limbs — useful for testing IK reach. |
|
||||
| D2 | Head/torso get distinct marker colors | 6 markers need visual separation; matches the existing color-coded style. |
|
||||
| D3 | Head aim line drawn in the IK overlay | Makes the LookAt target relationship visible. |
|
||||
|
||||
## 6. Implementation order
|
||||
|
||||
1. `test_harness.gd` — new handles + colors + aim line + torso-follow.
|
||||
2. Headless verification + parse check.
|
||||
3. Docs (BUGS.md Round 7 note, AGENTS.md, README.md).
|
||||
Reference in New Issue
Block a user