feat: Implement draggable torso and head IK targets in the test harness

- Added draggable handles for the torso and head to the test harness.
- Updated `IK_HANDLE_PATHS` to include new entries for "Head" and "Torso".
- Implemented distinct colors for the torso (magenta) and head (yellow) markers.
- Added a visual aid (aim line) to indicate the head's LookAt target direction.
- Ensured that dragging the torso moves only the torso marker, allowing for limb stretching towards stationary targets.
This commit is contained in:
2026-08-21 12:39:31 -04:00
parent 6b273c049c
commit ab5c79ab6a
14 changed files with 1459 additions and 174 deletions
+159
View File
@@ -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.0v1.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).