# 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).