Files
ryan 48d98ce0fe 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.
2026-08-18 17:12:54 -04:00

410 lines
21 KiB
Markdown
Raw Permalink 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 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.