Files
stickman/docs/phase9_round5_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

160 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).