Add StkRigAdapter for runtime skeleton fitting and shape mounting

- Implemented StkRigAdapter class to adapt a master rig to a loaded .stk dictionary.
- Added methods for fitting bone lengths, recalibrating IK targets, and mounting vector shapes.
- Defined constants for default proportions and bone paths.
- Included error handling for missing nodes and invalid data structures.
This commit is contained in:
2026-08-18 17:12:54 -04:00
parent 1eeeb64b39
commit 48d98ce0fe
13 changed files with 1994 additions and 108 deletions
+271
View File
@@ -0,0 +1,271 @@
# Phase 7 Round 1 — Architectural Specification
## Overview
This round fixes three user-reported bugs in the **pose silhouette guide** introduced in Phase 7 (see `BUGS.md`, "Stickman editor (Phase 7 Round 1)"):
1. **Silhouette starting position** — on startup the guide is anchored at a fixed world position (`GUIDE_OFFSET = (170, 580)`) that puts the figure up-left of the preview, forcing the user to pan over to it. The guide should start **centered** in the Whole Stickman view.
2. **Menu label never changes** — View → "Show Pose Guide" toggles visibility but its text stays "Show Pose Guide" regardless of state. It must read **"Hide Pose Guide"** when visible and **"Show Pose Guide"** when hidden.
3. **Guide renders behind parts** — the guide is drawn *below* user parts, so it cannot be used to judge how well parts align to the joints/limbs. It should "ghost" *in front of* the parts.
All three are confined to two scripts (`scripts/whole_stickman_preview.gd`, `scripts/stickman_editor.gd`) and touch no `.stk` format and no `settings.json` schema (the `show_pose_guide` key already exists). **No `.tscn` changes are required.**
Key behavior that is **preserved** (must not regress):
- The guide is a **world-space fixture**: it pans/zooms with the grid and parts (drawn under `draw_set_transform(_pan_offset, 0, Vector2(_zoom, _zoom))`).
- The guide is **pure drawing** — no `_gui_input` hit-testing — so it never intercepts part dragging/selection or the right-click context menu.
- 1:1 master-rig scale (`GUIDE_SCALE = 1.0`), color-coded limbs (left cyan-blue, right orange-red, central white), 6 px constant joint dots.
- `Reset Views` restores zoom 1.0 / pan (0,0); Clear/Load do not touch guide visibility.
---
## 1. Bug 1 — Centering the guide at startup
### 1a. Problem
`GUIDE_OFFSET = Vector2(170.0, 580.0)` (a `const` at `whole_stickman_preview.gd:48`) anchors the figure's **hips** at preview-world `(170, 580)`. The 1:1 figure spans roughly world `x ∈ [2, 338]`, `y ∈ [16.5, 956]` (center ≈ `(170, 486)`). On a typical panel (~640 × 600 px) at the default view (zoom 1, pan 0), that places the head near the top-left and the feet far below the fold — the user must pan to reach a centered figure.
### 1b. Recommended approach — dynamic, draw-time centering
Replace the static `GUIDE_OFFSET` with a **figure-center constant** and compute the offset **at draw time** from the live `preview_area.size`. This centers the figure's bounding-box center at the preview-area center in **world space**, so at the default view (zoom 1, pan 0) the figure is centered on screen, and it **re-centers automatically on window resize** (because `preview_area.size` is re-read on every draw).
Why this approach:
- **Deterministic & resize-proof** — centering is a pure function of the current viewport, recomputed every frame; no reliance on a guessed window size or on `_ready()`-time layout (which is not yet settled when the script initializes).
- **Minimal** — a one-line change to `_guide_to_preview()` plus a renamed constant; no new state, no camera/timing machinery.
- **Preserves world-fixture semantics** — the guide is *not* screen-locked; it still moves with pan/zoom. "Centered" is true at the default view and after `Reset Views`, and the user can still pan/zoom the guide anywhere afterward.
### 1c. Code changes (`scripts/whole_stickman_preview.gd`)
Replace the constant (line 48):
```gdscript
const GUIDE_OFFSET: Vector2 = Vector2(170.0, 580.0) # master_rig (0,0) [hips] -> preview world
```
with:
```gdscript
const GUIDE_FIGURE_CENTER: Vector2 = Vector2(0.0, -93.75) # master-space bbox center of the 1:1 guide figure
```
Replace `_guide_to_preview()` (lines 468469):
```gdscript
func _guide_to_preview(master_pos: Vector2) -> Vector2:
return master_pos * GUIDE_SCALE + GUIDE_OFFSET
```
with:
```gdscript
func _guide_to_preview(master_pos: Vector2) -> Vector2:
# Center the guide figure's bounding box at the preview-area center.
# At zoom 1 / pan (0,0) — startup and Reset Views — this is the on-screen center.
return (master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5
```
`GUIDE_FIGURE_CENTER` derivation (mirrors `phase7_spec.md` §3 style):
| Bound | Master-space | Source |
|---|---|---|
| Top | `-563.5` | Head circle center `(0, -463.5)` minus `GUIDE_HEAD_RADIUS` (100) |
| Bottom | `+376.0` | `Ankle` joints (`LeftAnkle`/`RightAnkle`) |
| Left / Right | `±168.0` | `Elbow`/`Wrist` joints |
- Center X = `0` (figure is left/right symmetric).
- Center Y = `(-563.5 + 376.0) / 2 = -93.75`.
> `GUIDE_SCALE` stays `1.0` and `GUIDE_HEAD_RADIUS` stays `100.0` — the bug is about *position*, not *size*.
### 1d. Alternatives considered (not chosen)
| Option | Description | Rejected because |
|---|---|---|
| (b) Static offset tuned for a typical window | Pick a new fixed `GUIDE_OFFSET` that looks centered at a representative size. | Fragile: breaks on different window sizes / DPI; still not "centered" in general. |
| (c) Auto-fit camera at startup | Set `_zoom`/`_pan_offset` once so the *whole* figure (head→feet) fits centered. | Needs the panel's final size, which isn't ready in `_ready()` (requires deferred/`NOTIFICATION_RESIZED` handling); interacts badly with `Reset Views` (resets to zoom 1/pan 0, un-centering again) and shrinks the default starter parts. Adds timing complexity for a cosmetic fix. |
> **Open question (see §7 Q1):** centering alone makes head and feet clip *equally* above/below the fold at 1:1 zoom (the figure is ~940 px tall vs a ~600 px panel). If full visibility is required, option (c) must be layered on top.
---
## 2. Bug 2 — Dynamic menu label
### 2a. Problem
`_guide_menu_label()` (`stickman_editor.gd:271272`) always returns `"Show Pose Guide"`, and `_update_guide_menu_item()` (`:266268`) only refreshes the checkmark, never the text. So the label is static even though the toggle works.
### 2b. Recommended approach
Make the label reflect the **action** the click will perform, per the user's exact wording, and refresh the **text** whenever the menu is about to open or the toggle fires:
- Visible (`_show_guide == true`) → **"Hide Pose Guide"**
- Hidden (`_show_guide == false`) → **"Show Pose Guide"**
**User decision: drop the checkmark.** The item is text-only; the action wording itself communicates the current state. Remove all `set_item_checked` usage for this item (setup + refresh).
### 2c. Code changes (`scripts/stickman_editor.gd`)
Replace `_guide_menu_label()` (lines 271272):
```gdscript
func _guide_menu_label() -> String:
return "Show Pose Guide"
```
with:
```gdscript
func _guide_menu_label() -> String:
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
```
Replace `_update_guide_menu_item()` (lines 266268):
```gdscript
func _update_guide_menu_item() -> void:
if _view_menu:
_view_menu.set_item_checked(1, _show_guide)
```
with:
```gdscript
func _update_guide_menu_item() -> void:
if _view_menu:
_view_menu.set_item_text(1, _guide_menu_label())
```
Also remove the now-obsolete `view_menu.set_item_checked(1, _show_guide)` call from `_setup_menu_bar()` (the item is no longer checkable).
### 2d. Startup-sync fix (related robustness gap)
`_load_settings()` (`:540571`) already re-syncs the Edit menu text after load (`_edit_menu.set_item_text(1, _snap_menu_label())` at `:570571`), but it does **not** re-sync the View menu. If `settings.json` has `show_pose_guide: false`, the label is stale ("Hide Pose Guide") until the menu first opens. Add a sync call at the end of `_load_settings()`, right after `_show_guide` is assigned (line 558):
```gdscript
_show_guide = bool(d.get("show_pose_guide", true))
# ... existing grid/clamp/recent_colors logic ...
if _edit_menu:
_edit_menu.set_item_text(1, _snap_menu_label())
if _view_menu:
_update_guide_menu_item()
```
This mirrors the existing Edit-menu pattern and guarantees the label is correct immediately after load (the `about_to_popup` handler remains as a defensive re-sync).
> Note: `_setup_menu_bar()` runs *before* `_load_settings()` in `_ready()` (`:103` vs `:109`), so the initial label uses the `_show_guide` default (`true` → "Hide Pose Guide"), which is correct for the default state; the load-sync covers the non-default case.
---
## 3. Bug 3 — Draw the guide *in front of* parts
### 3a. Problem
In `_on_preview_draw()` (`whole_stickman_preview.gd:318408`), `_draw_silhouette_guide()` is called at line 322, **before** the `for part_name in _part_order` loop (`:325384`). User parts (opaque fills) therefore cover the guide, hiding the joints/limbs exactly where the user needs to check alignment.
### 3b. Recommended draw order
Move `_draw_silhouette_guide()` to draw **after** the part loop but **before** the selection gizmos (and before/after the drag highlight as specified below):
```
1. draw_set_transform(_pan_offset, 0, Vector2(_zoom, _zoom)) (line 319)
2. _draw_grid() (line 321)
3. _selected_gizmo_bounds = Rect2() (line 323)
4. part loop + labels (lines 325384)
5. _draw_silhouette_guide() ← MOVED here (ghost over parts)
6. dragged-part highlight (lines 386390)
7. selection gizmos (lines 392408)
```
**Z-order rationale:**
- **Guide over parts** — the semi-transparent guide (line alpha 0.45, joint alpha 0.65) drawn *after* parts "ghosts" over the opaque fills, so limbs/joints remain visible through/over a part. This is the whole point of Bug 3.
- **Selection gizmos above the guide** — the bounding box, rotation circle, and scale crosses are the user's manipulation affordances; they must never be obscured by the (now-front) guide. They stay last.
- **Drag highlight above the guide** — the yellow drag rect (drawn during `Interaction.TRANSLATE`) is also an affordance; keep it above the guide for the same reason. (Draw guide at step 5, before the highlight at step 6, so highlight + gizmos remain on top.)
- **Labels** — part labels (`H`, `T`, `LUA`, …) are drawn inside the part loop (step 4), so the guide may overlap them slightly. This is acceptable: labels are small, and the guide is semi-transparent. (Splitting labels into a later pass is not worth the complexity.)
### 3c. Code changes (`scripts/whole_stickman_preview.gd`)
Delete line 322 (`_draw_silhouette_guide()` from its current position) and insert it after the part loop, immediately before the "Highlight dragged part" block (i.e., between line 384 and line 386):
```gdscript
# Phase 7: ghost the silhouette guide IN FRONT of parts (below selection affordances)
_draw_silhouette_guide()
# Highlight dragged part
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
...
```
### 3d. Alpha values — no change recommended
The existing alphas (lines 0.45, joints 0.65) already produce the intended "ghost" effect; now that they render over parts instead of under them, the semi-transparency is exactly what keeps them reading as a ghost rather than a solid overlay. Keep the constants unchanged for consistency. (If, during manual review, the guide feels too heavy over large filled parts, a single-line tweak to `GUIDE_COLOR_*` / `GUIDE_JOINT_COLOR_*` is the fallback — noted here, not applied.)
### 3e. Hit-testing — unaffected
The guide is pure `draw_*` calls and participates in **no** input logic. Hit-testing runs entirely on `_part_bounds` (`_try_start_interaction` at `:659`, `_handle_right_click` at `:944`), which is unchanged. Drawing the guide over parts cannot intercept clicks or the context menu. `preview_area.clip_contents = true` still clips the guide to the panel bounds (as before).
---
## 4. Files Modified
| File | Changes |
|---|---|
| `scripts/whole_stickman_preview.gd` | Replace `GUIDE_OFFSET` const with `GUIDE_FIGURE_CENTER`; re-center in `_guide_to_preview()`; move `_draw_silhouette_guide()` call after the part loop (before the drag highlight). |
| `scripts/stickman_editor.gd` | Make `_guide_menu_label()` dynamic; refresh text in `_update_guide_menu_item()` and drop the checkmark (`set_item_checked` calls removed); add `_update_guide_menu_item()` call in `_load_settings()`. |
| `docs/phase7_round1_spec.md` | This file. |
| `README.md` | Update §5 "Pose Silhouette Guide" (centering behavior, "ghosts in front of parts", dynamic menu label) and the menu-bar structure diagram (§ "Menu bar structure"). |
| `AGENTS.md` | Update the Phase 7 notes: `GUIDE_OFFSET (170, 580)` → dynamic centering via `GUIDE_FIGURE_CENTER`; "renders above the grid but below user parts" → "above the grid and above user parts, below selection gizmos"; "Show Pose Guide" → dynamic "Hide/Show Pose Guide" label. |
No `.stk` / `settings.json` / `.tscn` changes.
---
## 5. Edge Cases & Constraints
- **Guide vs. pan/zoom** — the guide remains a world-space fixture. It is centered only at the default view (zoom 1, pan 0) and after `Reset Views`; panning/zooming moves it with the grid/parts exactly as before.
- **Window resize** — because `_guide_to_preview()` re-reads `preview_area.size` each draw, the guide re-centers on resize. This means the guide's world position shifts relative to already-placed parts when the window is resized; accepted as a cosmetic consequence for a *view aid* (and is the desired "handles resize for free" behavior). Parts/grid stay fixed; only the guide re-centers.
- **`preview_area.size` is zero before first layout** — `_on_preview_draw` is connected to `preview_area.draw`, which only fires after the control is laid out, so `size` is valid at draw time. No guard required, but note the figure-center maps to `(0,0)`-relative origin in the (theoretical) zero-size case.
- **Negative master coords** — centering produces negative world coords for the head/arms (e.g., head top at world y ≈ 170 on a 600 px panel); the grid already handles negative world space (it draws from `floor(world_origin / gs) * gs`), so no change is needed.
- **Vertical clipping at 1:1 zoom** — the figure (~940 px) is taller than a typical panel (~600 px), so head and feet clip equally above/below the fold when centered. This is the 1:1-scale decision from Phase 7, unchanged here; if full visibility is wanted, auto-fit (option (c)) must be added (see Q1).
- **Menu state after load** — with the `_load_settings()` sync added, a persisted `show_pose_guide: false` shows "Show Pose Guide" immediately at startup; the `about_to_popup` handler remains as a defensive re-sync.
- **Clear / Load / Reset Views** — none of them touch `_show_guide` or the label; `Reset Views` re-centers the guide (zoom 1/pan 0 + draw-time centering) but does not change its visibility.
---
## 6. Testing / Verification
There is no automated test suite; verification is manual in the editor plus the project parse check.
1. **Parse check** — run from `C:\Godot4\stickman`:
```
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit
```
(The plain `--check-only` form hangs on renderer init in 4.7.1; use the `--headless --check-only --quit` variant.)
2. **Bug 1 — centered** — launch the app; the guide's head/neck/torso/hips/knees should appear centered horizontally in the preview (head/feet may clip above/below at 1:1 zoom — expected). No panning needed to find it.
3. **Bug 1 — resize** — drag-resize the window; the guide stays centered in the preview area.
4. **Bug 1 — Reset Views** — after panning/zooming away, View → Reset Views returns zoom 1 / pan 0 with the guide centered again.
5. **Bug 2 — label** — View menu shows "Hide Pose Guide" by default; clicking toggles the guide off and the item becomes "Show Pose Guide"; clicking again restores both. Re-open the menu to confirm the text persists correctly across opens (no checkmark either way).
6. **Bug 2 — persistence** — set `show_pose_guide: false` in `settings.json`, relaunch; the menu reads "Show Pose Guide" and the guide is hidden.
7. **Bug 3 — in front** — draw a filled part over a joint; the guide line/joint dot should be visible *through/over* the part (ghosted), while the selected part's bounding box / rotation circle / scale crosses still render on top of the guide.
8. **Bug 3 — drag highlight** — during a translation drag, the yellow highlight stays visible above the guide.
9. **Non-regression** — with the guide on, left-click-drag a part and right-click for the context menu; behavior unchanged (guide is not hit-testable).
---
## 7. Clarifying Questions (resolved)
1. **Bug 1 — center vs. fit.** *Resolved:* **centering only** (no auto-zoom-to-fit). At 1:1 zoom the head/feet clip equally above/below the fold — accepted.
2. **Bug 1 — world anchoring.** *Resolved:* the guide stays a **world-space fixture** (moves with pan/zoom; centered at default view and after Reset Views).
3. **Bug 3 — z-order vs. affordances.** *Resolved:* **grid → parts → guide → drag highlight → selection gizmos** — gizmos and drag highlight stay on top of the ghosted guide.
4. **Bug 2 — checkmark.** *Resolved:* **drop the checkbox** — text-only dynamic action label ("Hide Pose Guide" / "Show Pose Guide"); all `set_item_checked` usage for this item is removed.
---
## 8. Recommended Implementation Order
1. `scripts/whole_stickman_preview.gd` — Bug 1 centering (`GUIDE_FIGURE_CENTER` + `_guide_to_preview`).
2. `scripts/whole_stickman_preview.gd` — Bug 3 reorder (`_draw_silhouette_guide()` move).
3. `scripts/stickman_editor.gd` — Bug 2 dynamic label + `_update_guide_menu_item()` + `_load_settings()` sync.
4. Manual verification (§6) + `--headless --check-only --quit`.
5. Doc updates (`README.md`, `AGENTS.md`).