- 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.
160 lines
8.9 KiB
Markdown
160 lines
8.9 KiB
Markdown
# 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).
|