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,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).
|
||||
Reference in New Issue
Block a user