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
+409
View File
@@ -0,0 +1,409 @@
# Phase 7 — Architectural Specification
## Overview
Phase 7 adds a **silhouette guide** to the **Whole Stickman** preview window. The guide is a semi-transparent stick figure whose joint anchors match the **rest-pose dimensions of `master_rig.tscn`**, so the user can align their body-part shapes to the same pivots the future `Skeleton2D` rig will use. The guide is a **view aid only** — it is never part of the figure, never hit-testable, and never saved to `.stk`.
Key behaviors:
- Drawn **in world space** inside `WholeStickmanPreview`, so it **moves with pan/zoom** exactly like the grid and user parts.
- Drawn **above the grid, below user parts** (so it is visible but never obscures the user's shapes).
- **13 circular joint anchors** (radius 6.0 px) at: Head, Neck, LeftShoulder, LeftElbow, LeftWrist, RightShoulder, RightElbow, RightWrist, Hips, LeftKnee, LeftAnkle, RightKnee, RightAnkle.
- **Color-coded limb sides** to prevent misassigned limb axes:
- Left Side (Left Arm / Left Leg): **Cyan/Blue**
- Right Side (Right Arm / Right Leg): **Orange/Red**
- Central Axis (Spine / Head): **Neutral White**
- Toggleable via a new **View → Show Pose Guide** menu item (checkable), default **on**, persisted to `settings.json`.
- **User decisions (approved):** guide rendered at **1:1 master-rig scale** (dwarfs the default starter parts — intentional), **on by default**, Head joint at the **head-circle center**, state **persisted** to `settings.json`, joint dots **constant 6 px screen size**.
The joint positions are **hardcoded constants** derived once from `master_rig.tscn` (not read at runtime). Rationale in §2.
---
## 1. Data Model Changes
### 1a. `.stk` file — NO changes
The silhouette guide is a view aid. It is **not** written to, and **not** read from, `.stk` files. `FILE_VERSION` stays `"1.3"`. No migration is required.
### 1b. `settings.json` — one new key
Add `show_pose_guide` (bool) to the existing settings file. `SETTINGS_VERSION` stays `"1.0"` (the pattern established in Phase 6, where `recent_colors` was added without a version bump).
```json
{
"version": "1.0",
"grid_size": 15,
"snap_to_grid": false,
"recent_colors": ["#000000"],
"show_pose_guide": true
}
```
| Key | Type | Default | Description |
|---|---|---|---|
| `show_pose_guide` | `bool` | `true` | Whether the pose silhouette guide is visible in the Whole Stickman preview. |
Load/save follows the existing `_load_settings()` / `_save_settings()` pattern (missing key → default `false`, silent; corrupt file → defaults).
### 1c. Joint positions — hardcoded (recommended)
**Recommendation: hardcode** the 13 joint positions + the head circle radius as `const` data, derived once from `master_rig.tscn`. **Do not** instantiate `master_rig.tscn` at runtime or evaluate its `Skeleton2D`/IK stack. Justification:
1. **The guide needs the *rest* pose, not the *posed* figure.** `master_rig.tscn`'s `SkeletonModificationStack2D` is enabled and contains 4× `TwoBoneIK` + 1× `LookAt` modifications. Instantiating the scene would apply IK, moving bones *away* from their rest transforms (hands toward `IK_Targets`, head toward the look-at target). The requirement explicitly asks for **rest-pose** dimensions.
2. **Zero runtime cost & no scene dependency.** Hardcoded constants are deterministic, editor-safe, and add no node/scene instantiation or potential side effects (no `RemoteTransform2D` writes, no `@tool` script instantiation).
3. **Traceable.** The source node + transform for every constant is documented in §3, so the values can be regenerated if `master_rig.tscn` changes.
The rest transforms are static authored data; reading them at runtime buys nothing and only risks IK side effects.
---
## 2. WholeStickmanPreview — Rendering
All changes live in `scripts/whole_stickman_preview.gd`. No new scene/node is required (the guide is a `_draw()` overlay, mirroring how `_draw_grid()` is already inlined).
### 2a. New constants
```gdscript
# Phase 7: pose silhouette guide
const GUIDE_JOINT_RADIUS: float = 6.0 # screen-space joint dot radius (px)
const GUIDE_SCALE: float = 1.0 # master_rig -> preview world scale (1:1, per user decision)
const GUIDE_OFFSET: Vector2 = Vector2(170.0, 580.0) # master_rig (0,0) [hips] -> preview world
const GUIDE_HEAD_RADIUS: float = 100.0 # master-space head circle radius
const GUIDE_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.45) # cyan-blue, semi
const GUIDE_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.45) # orange-red, semi
const GUIDE_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.45) # white, semi
const GUIDE_JOINT_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.65)
const GUIDE_JOINT_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.65)
const GUIDE_JOINT_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.65)
const GUIDE_JOINTS: Dictionary = {
"Hips": Vector2(0.0, 0.0),
"Neck": Vector2(0.0, -391.5),
"Head": Vector2(0.0, -463.5),
"LeftShoulder": Vector2(0.0, -248.0),
"RightShoulder": Vector2(0.0, -248.0),
"LeftElbow": Vector2(-168.0, -256.0),
"RightElbow": Vector2(168.0, -256.0),
"LeftWrist": Vector2(-168.0, -456.0),
"RightWrist": Vector2(168.0, -456.0),
"LeftKnee": Vector2(-95.0, 176.0),
"RightKnee": Vector2(95.0, 176.0),
"LeftAnkle": Vector2(-96.0, 376.0),
"RightAnkle": Vector2(96.0, 376.0),
}
const GUIDE_LEFT_JOINTS: PackedStringArray = [
"LeftShoulder", "LeftElbow", "LeftWrist", "LeftKnee", "LeftAnkle"
]
const GUIDE_RIGHT_JOINTS: PackedStringArray = [
"RightShoulder", "RightElbow", "RightWrist", "RightKnee", "RightAnkle"
]
# any joint not in LEFT/RIGHT is central (Hips, Neck, Head)
const GUIDE_BONES: Array = [
["Hips", "Neck"], # spine (central)
["LeftShoulder", "LeftElbow"], # left upper arm
["LeftElbow", "LeftWrist"], # left forearm
["RightShoulder", "RightElbow"], # right upper arm
["RightElbow", "RightWrist"], # right forearm
["Hips", "LeftKnee"], # left thigh
["LeftKnee", "LeftAnkle"], # left shin
["Hips", "RightKnee"], # right thigh
["RightKnee", "RightAnkle"], # right shin
]
```
> The 13 joint names in `GUIDE_JOINTS` are exactly the key pivot locations required by PROJECT.md. `GUIDE_SCALE` and `GUIDE_OFFSET` are the only tunables mapping master-rig coordinates into preview world space (see §3 for the chosen values).
### 2b. New state + public API
```gdscript
var _show_guide: bool = true # Phase 7 (default ON per user decision)
func set_show_guide(enabled: bool) -> void:
_show_guide = enabled
preview_area.queue_redraw()
```
### 2c. Draw order (updated)
In `_on_preview_draw()` the order becomes:
```
1. draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
2. _draw_grid() (existing — behind everything)
3. _draw_silhouette_guide() (NEW — above grid, below parts)
4. part loop + labels (existing — user parts)
5. selection gizmos (existing — always on top)
```
The guide is inserted **after** `_draw_grid()` and **before** the `for part_name in _part_order` loop, so user parts render over it.
### 2d. `_draw_silhouette_guide()`
```gdscript
func _draw_silhouette_guide() -> void:
if not _show_guide:
return
var lw: float = 1.5 / _zoom # match _draw_polyline_preview stroke
var jr: float = GUIDE_JOINT_RADIUS / _zoom # constant 6px screen size
# 1. Limb / spine segments (semi-transparent)
for bone: Array in GUIDE_BONES:
var a: String = bone[0] as String
var b: String = bone[1] as String
if not (GUIDE_JOINTS.has(a) and GUIDE_JOINTS.has(b)):
continue
var pa: Vector2 = _guide_to_preview(GUIDE_JOINTS[a])
var pb: Vector2 = _guide_to_preview(GUIDE_JOINTS[b])
preview_area.draw_line(pa, pb, _guide_color(a), lw)
# 2. Head outline circle (central, semi-transparent)
var head_center: Vector2 = _guide_to_preview(GUIDE_JOINTS["Head"])
preview_area.draw_arc(
head_center,
GUIDE_HEAD_RADIUS * GUIDE_SCALE,
0.0, TAU, 48,
GUIDE_COLOR_CENTRAL, lw
)
# 3. Joint anchors (constant screen size)
for joint: String in GUIDE_JOINTS:
var p: Vector2 = _guide_to_preview(GUIDE_JOINTS[joint])
preview_area.draw_circle(p, jr, _guide_joint_color(joint))
func _guide_to_preview(master_pos: Vector2) -> Vector2:
return master_pos * GUIDE_SCALE + GUIDE_OFFSET
func _guide_color(joint_name: String) -> Color:
if GUIDE_LEFT_JOINTS.has(joint_name):
return GUIDE_COLOR_LEFT
if GUIDE_RIGHT_JOINTS.has(joint_name):
return GUIDE_COLOR_RIGHT
return GUIDE_COLOR_CENTRAL
func _guide_joint_color(joint_name: String) -> Color:
if GUIDE_LEFT_JOINTS.has(joint_name):
return GUIDE_JOINT_COLOR_LEFT
if GUIDE_RIGHT_JOINTS.has(joint_name):
return GUIDE_JOINT_COLOR_RIGHT
return GUIDE_JOINT_COLOR_CENTRAL
```
Notes:
- `draw_arc` (not a filled polygon) is used for the head so the head stays outline-only and semi-transparent; the `Head` joint dot marks the head center.
- Joint dots use `GUIDE_JOINT_RADIUS / _zoom` so they are always 6 px on screen, matching the existing vertex-handle convention (`HANDLE_RADIUS / _zoom`, `ROTATION_CIRCLE_RADIUS / _zoom`). This satisfies "radius 6.0 px" literally, independent of `GUIDE_SCALE` and zoom.
- The guide is pure drawing — **no `_gui_input` hit-testing is added**, so it can never intercept part dragging/selection.
### 2e. Color specification
| Group | Joints | RGB | Alpha (lines) | Alpha (joints) |
|---|---|---|---|---|
| Left (cyan/blue) | LeftShoulder, LeftElbow, LeftWrist, LeftKnee, LeftAnkle | `(0.35, 0.70, 1.00)` | `0.45` | `0.65` |
| Right (orange/red) | RightShoulder, RightElbow, RightWrist, RightKnee, RightAnkle | `(1.00, 0.50, 0.20)` | `0.45` | `0.65` |
| Central (white) | Hips, Neck, Head | `(1.00, 1.00, 1.00)` | `0.45` | `0.65` |
---
## 3. Master-rig joint derivation
`master_rig.tscn` is a `Skeleton2D` rooted at `Master` (Node2D at origin). The `Skeleton2D` node has `rotation = -0.0006991282` and its `Torso` bone has `rotation = 0.0006991282` — these cancel to identity at sub-pixel precision, so world-space rest positions are computed as a straightforward hierarchy traversal of the `Bone2D` rest transforms (with the visual `Body/…` `Line2D`/circle nodes and `IK_Targets/*` `Marker2D` used as cross-checks).
| # | Joint | Source node | Rest position (world) | Rounded |
|---|---|---|---|---|
| 1 | Hips | `Skeleton2D/Torso` origin | `(0, 0)` | `(0, 0)` |
| 2 | Neck | `Skeleton2D/Torso/Head` origin | `(-0.13, -391.51)` | `(0, -391.5)` |
| 3 | Head | head circle center (`Body/Head`, radius 100) | `(-0.04, -463.51)` | `(0, -463.5)` |
| 4 | LeftShoulder | `Torso/LeftUpperArm` origin | `(0, -248)` | `(0, -248)` |
| 5 | RightShoulder | `Torso/RightUpperArm` origin | `(0, -248)` | `(0, -248)` |
| 6 | LeftElbow | `Torso/LeftUpperArm/LeftLowerArm` origin | `(-167.8, -256.0)` | `(-168, -256)` |
| 7 | RightElbow | `Torso/RightUpperArm/RightLowerArm` origin | `(167.8, -256.0)` | `(168, -256)` |
| 8 | LeftWrist | `IK_Targets/Left_Hand` (== forearm bone tip) | `(-168, -456)` | `(-168, -456)` |
| 9 | RightWrist | `IK_Targets/Right_Hand` | `(168, -456)` | `(168, -456)` |
| 10 | LeftKnee | `Torso/LeftUpperLeg/LeftLowerLeg` origin | `(-94.96, 176.01)` | `(-95, 176)` |
| 11 | RightKnee | `Torso/RightUpperLeg/RightLowerLeg` origin | `(94.96, 176.01)` | `(95, 176)` |
| 12 | LeftAnkle | `IK_Targets/Left_Leg` (== shin bone tip) | `(-96, 376)` | `(-96, 376)` |
| 13 | RightAnkle | `IK_Targets/Right_Leg` | `(96, 376)` | `(96, 376)` |
Derivation notes (reproducible):
- **Neck** = `Head` bone origin `(-0.12882307, -391.5079)`. This is the head/spine pivot. (The visual torso `Line2D` renders from `(0,0)` to `(0,-400)`; the neck bone sits ~8.5 px inside that top end, i.e. inside the head circle.)
- **Head** = head circle center. The head circle `Body/Head` (`Node2D` with the embedded `@tool` script, `radius = 100`) is driven by `RemoteTransform2D` under the `Head` bone at local offset `(0.0503, -72.0)`: center ≈ `(0, -391.5) + (0.05, -72) = (0, -463.5)`.
- **Elbows** = `LeftLowerArm`/`RightLowerArm` bone origins. World = parent arm origin `(0,-248)` rotated by the arm rest rotation `0.0477 rad` applied to the local offset `(±168, 0)``(±167.8, -256.0)`.
- **Wrists/Ankles** = the `TwoBoneIK` targets (`Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` in `IK_Targets/`), which match the forearm/shin bone tips in rest pose. Forearm: elbow `(±168,-256)` → wrist `(±168,-456)` (200 px straight down). Shin: knee `(±95,176)` → ankle `(±96,376)` (200 px, ≈vertical).
### Coordinate mapping to preview space
Master-rig space is large (≈ 336 × 940 px: x ∈ [-168, 168], y ∈ [-563.5, 376]). Preview world space is the "panel pixel" space where default parts live (`DEFAULT_POSITIONS` around x ∈ [130, 170], y ∈ [40, 210]).
**User decision: 1:1 scale.** `GUIDE_SCALE = 1.0` + `GUIDE_OFFSET = (170, 580)` anchors the guide's hips at world `(170, 580)` so the head top lands just below the panel title at default zoom/pan (`y ≈ 16.5`), while the full figure (≈ 336 × 940 px, feet at `y ≈ 956`) extends past the panel bottom and must be reached by panning/zooming out — the accepted "dwarfs the default starter parts" trade-off:
| Guide joint | Preview-world position |
|---|---|
| Hips | `(170, 580)` |
| Neck | `(170, 188.5)` |
| Head center (r 100) | `(170, 116.5)` |
| Shoulders | `(170, 332)` |
| Elbows | `(2, 324)` / `(338, 324)` |
| Wrists | `(2, 124)` / `(338, 124)` |
| Knees | `(75, 756)` / `(265, 756)` |
| Ankles | `(74, 956)` / `(266, 956)` |
The user aligns their parts to the guide by dragging them onto it (or pans/zooms the guide to a convenient spot — it is a world-space fixture, so panning moves it with the grid). `GUIDE_SCALE`/`GUIDE_OFFSET` remain the two tunable constants if a different size is wanted later.
---
## 4. StickmanEditor — UI & persistence
### 4a. View menu
In `_setup_menu_bar()`, extend the existing View `PopupMenu` (currently only "Reset Views", id 0) with a **checkable** item:
```
View
──────────────
Reset Views (id 0)
──────────────
Show Pose Guide (✓) (id 1, checkable)
```
Mirror the existing Snap-to-Grid pattern: store the view menu as a member (`var _view_menu: PopupMenu`), connect `about_to_popup` to refresh the label, and toggle state in `_on_view_menu_id_pressed`.
```gdscript
# _setup_menu_bar()
var view_menu: PopupMenu = PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("Reset Views", 0)
view_menu.add_separator()
view_menu.add_item(_guide_menu_label(), 1)
view_menu.set_item_checked(1, _show_guide)
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
view_menu.about_to_popup.connect(_on_view_menu_about_to_popup)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
_view_menu = view_menu
```
```gdscript
func _on_view_menu_id_pressed(id: int) -> void:
match id:
0: # Reset Views
for panel in _body_part_panels.values():
if panel is BodyPartPanel:
(panel as BodyPartPanel).reset_view()
_whole_preview.reset_view()
1: # Show Pose Guide (checkable)
_show_guide = not _show_guide
_save_settings()
_broadcast_settings()
_update_guide_menu_item()
func _on_view_menu_about_to_popup() -> void:
_update_guide_menu_item()
func _update_guide_menu_item() -> void:
if _view_menu:
_view_menu.set_item_checked(1, _show_guide)
func _guide_menu_label() -> String:
return "Show Pose Guide"
```
(Use `set_item_checked()` + a label for the checkable item; the existing Snap-to-Grid item uses a text `[√]` prefix instead — either is acceptable, but `set_item_checked` is the cleaner Godot idiom. This spec uses `set_item_checked`.)
### 4b. Settings state + broadcast
Add `var _show_guide: bool = true` to the editor, extend `_load_settings()` / `_save_settings()` with the `show_pose_guide` key, and push the value to the preview in `_broadcast_settings()`:
```gdscript
# _load_settings()
_show_guide = bool(d.get("show_pose_guide", true))
# _save_settings()
var data := {
"version": SETTINGS_VERSION,
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
"recent_colors": _recent_colors,
"show_pose_guide": _show_guide,
}
# _broadcast_settings() (append)
_whole_preview.set_show_guide(_show_guide)
```
Because `_broadcast_settings()` is already called in `_ready()` (after `_load_settings()`) and whenever grid/snap changes, the guide visibility is correctly re-applied on startup and on every toggle.
---
## 5. Files Modified
| File | Changes |
|---|---|
| `scripts/whole_stickman_preview.gd` | Add guide constants, `_show_guide` state, `set_show_guide()`, `_draw_silhouette_guide()` + `_guide_to_preview()`/`_guide_color()`/`_guide_joint_color()` helpers; call guide draw between grid and parts in `_on_preview_draw()`. |
| `scripts/stickman_editor.gd` | Add `_show_guide` state, `_view_menu` member, "Show Pose Guide" checkable View menu item (id 1), `_on_view_menu_about_to_popup`/`_update_guide_menu_item`, settings key `show_pose_guide`, broadcast to preview. |
| `docs/phase7_spec.md` | This file. |
| `README.md` | Document the guide, the View menu item, and the settings key (see §7). |
No `.tscn` changes are required.
---
## 6. Edge Cases & Constraints
- **Guide vs. pan/zoom** — the guide is drawn under `draw_set_transform(_pan_offset, 0, Vector2(_zoom, _zoom))`, so it pans/zooms with everything else. Joint dots use `6.0 / _zoom`, staying a constant 6 px on screen (consistent with existing handles).
- **Guide is never interactive** — no hit-testing is added; left-click part selection and right-click context menus are unaffected. The guide cannot block a part the user is trying to grab (parts render over it and win hit-tests).
- **Clipping** — `PreviewArea` already has `clip_contents = true`, so the guide clips to the panel bounds like the grid and parts.
- **Empty preview** — the guide renders even when no parts exist (it is independent of `_part_shapes`), which is desirable: the user sees the pose before drawing anything.
- **Min zoom (0.3)** — the figure shrinks but joint dots stay 6 px; dots will appear relatively large vs. the figure at extreme zoom-out. This matches existing handle behavior and is acceptable for an anchor aid.
- **Head circle vs. neck** — the head circle (r 100 in preview space at 1:1) overlaps the neck joint, faithfully mirroring `master_rig.tscn` (head center sits 72 px below the neck in master space). This is intentional.
- **`Reset Views`** — resets zoom/pan only; it does **not** change guide visibility (the toggle is independent state).
- **Clear / Load** — do not touch `_show_guide`; the toggle is a persistent editor preference, not figure state.
- **Settings missing/corrupt** — `show_pose_guide` falls back to `true` (the new default), matching `_load_settings()`'s existing behavior.
---
## 7. Testing / Verification
There is no automated test suite in the repo (only a sample figure `stickmen/test.stk`). Verification is manual in the Godot editor, plus the project parse check:
1. **Load check** — run the project; no parse errors; main scene opens.
2. **Toggle** — View → Show Pose Guide is checked by default and the guide is visible; clicking it unchecks it and hides the guide; clicking again re-shows it.
3. **Visuals** — with the guide on: 13 joint dots; left limbs cyan/blue, right limbs orange/red, spine+head white; everything semi-transparent (see-through grid behind it).
4. **Draw order** — draw a part; confirm it renders **over** the guide; confirm the guide renders **over** the grid.
5. **Pan/zoom** — middle-drag and wheel-zoom the preview; the guide tracks the grid and parts exactly; joint dots stay ~6 px.
6. **Non-interference** — with the guide visible, left-click-drag a part and right-click for the context menu; behavior is unchanged.
7. **Persistence** — the guide is on by default; disable it, close and reopen the project; it stays disabled (settings.json contains `"show_pose_guide": false`).
8. **Clear/Load** — clearing or loading a figure leaves the guide setting unchanged.
> Note: `..\Godot_v4.7.1-stable_win64_console.exe . --check-only` could not be run — the executable is not present under `C:\Godot4\` and no shell tool is available to the architect. The developer should run it before implementation.
---
## 8. README Updates
- **Overview / menu section** — add "Show Pose Guide" under View; describe the color-coded, semi-transparent silhouette and its 13 joint anchors.
- **New subsection** (e.g. "Pose Silhouette Guide") — explain purpose (align parts to the future `Skeleton2D` rig pivots from `master_rig.tscn`), the color coding (left = cyan, right = orange, central = white), that it's drawn behind parts and tracks pan/zoom, and that it's a view aid not saved to `.stk`.
- **Settings table** — add `show_pose_guide` key (bool, default `true`).
- **Menu bar structure diagram** — add the View → Show Pose Guide entry.
---
## 9. Recommended Implementation Order
1. `whole_stickman_preview.gd` — constants, `_show_guide`, `set_show_guide()`, draw helpers, wire into `_on_preview_draw()`.
2. `stickman_editor.gd``_show_guide` state + View menu item + handlers.
3. Settings persistence (`_load_settings`/`_save_settings`/`_broadcast_settings`).
4. Manual verification (list in §7) + `--check-only`.
5. README update.