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:
@@ -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 468–469):
|
||||
|
||||
```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:271–272`) always returns `"Show Pose Guide"`, and `_update_guide_menu_item()` (`:266–268`) 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 271–272):
|
||||
|
||||
```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 266–268):
|
||||
|
||||
```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()` (`:540–571`) already re-syncs the Edit menu text after load (`_edit_menu.set_item_text(1, _snap_menu_label())` at `:570–571`), 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:318–408`), `_draw_silhouette_guide()` is called at line 322, **before** the `for part_name in _part_order` loop (`:325–384`). 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 325–384)
|
||||
5. _draw_silhouette_guide() ← MOVED here (ghost over parts)
|
||||
6. dragged-part highlight (lines 386–390)
|
||||
7. selection gizmos (lines 392–408)
|
||||
```
|
||||
|
||||
**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`).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,443 @@
|
||||
# Phase 8 — Architectural Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Phase 8 has two deliverables:
|
||||
|
||||
1. **Save-side `.stk` export** — when saving/exporting, the editor appends a top-level `proportions` object (5 rig bone lengths) and, inside each `body_parts` entry, a `pivot` (`{x, y}`) and `length` (float). This is the **only** editor change; the editor does **not** instantiate the rig.
|
||||
2. **A standalone runtime adapter `StkRigAdapter.gd`** — a GDScript utility that takes a loaded `.stk` dictionary + an instantiated `master_rig.tscn` node and re-fits the skeleton (bone lengths), recalibrates the IK targets, and mounts the `.stk` vector shapes onto the rig's `Body/` visual nodes. This script is consumed by a **future runtime pipeline**, not by the editor.
|
||||
|
||||
Scope is intentionally tight:
|
||||
|
||||
- The editor is **save-side only** — it computes and writes `proportions` / `pivot` / `length`. It never loads `StkRigAdapter.gd` and never imports `master_rig.tscn` (see §5f / §8).
|
||||
- `StkRigAdapter.gd` is a **standalone script** with no hard dependency back into the editor. It is not referenced by any existing scene or autoload.
|
||||
|
||||
Key facts established from exploration (cited throughout):
|
||||
|
||||
- The Phase 7 silhouette guide already hardcodes the 13 rest-pose joint coordinates of `master_rig.tscn` as `GUIDE_JOINTS` in `scripts/whole_stickman_preview.gd:59-73`. These are the **same pivots** the adapter's bone-fitting targets.
|
||||
- `master_rig.tscn`'s bone names/paths match the Phase 8 requirement text exactly (`Skeleton2D/Torso/LeftUpperArm`, `LeftLowerArm`, `RightUpperArm`, `RightLowerArm`, `LeftUpperLeg`, `LeftLowerLeg`, `RightUpperLeg`, `RightLowerLeg`, `IK_Targets/Left_Leg`, `IK_Targets/Right_Leg`, `IK_Targets/Left_Hand`, `IK_Targets/Right_Hand`).
|
||||
- The editor already serializes per-part `shapes`/`position`/`rotation`/`scale` in `scripts/stickman_editor.gd:363-394`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model Changes (`.stk`)
|
||||
|
||||
### 1a. Version bump → `"1.4"`
|
||||
|
||||
`FILE_VERSION` (`stickman_editor.gd:51`) becomes `"1.4"`. `SUPPORTED_VERSIONS` (`:54`) gains `"1.4"` (so loading `["1.0".."1.4"]`). No other load-side migration is required (see 1d).
|
||||
|
||||
### 1b. Top-level `proportions` object
|
||||
|
||||
A new top-level key, sibling to `version` / `stickman_name` / `part_order` / `body_parts` / `metadata`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.4",
|
||||
"stickman_name": "Bob",
|
||||
"part_order": [ "..." ],
|
||||
"proportions": {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5
|
||||
},
|
||||
"body_parts": { "..." },
|
||||
"metadata": { "..." }
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `upper_arm_length` | `float` | Distance shoulder → elbow (== `LeftUpperArm.length`). |
|
||||
| `lower_arm_length` | `float` | Distance elbow → wrist (== `LeftLowerArm.length`). |
|
||||
| `upper_leg_length` | `float` | Distance hip → knee (== `LeftUpperLeg.length`). |
|
||||
| `lower_leg_length` | `float` | Distance knee → ankle (== `LeftLowerLeg.length`). |
|
||||
| `torso_length` | `float` | Height from hip base to neck (`Hips` → `Neck`). |
|
||||
|
||||
**Source = master-rig rest-pose constants (hardcoded), not the user's shapes.** Full derivation in §2.
|
||||
|
||||
### 1c. Per-part `pivot` and `length`
|
||||
|
||||
Inside **each** of the 10 `body_parts[part_name]` objects (siblings of `shapes`/`position`/`rotation`/`scale`):
|
||||
|
||||
```json
|
||||
"torso": {
|
||||
"shapes": [ "..." ],
|
||||
"position": { "x": 150.0, "y": 100.0 },
|
||||
"rotation": 0.0,
|
||||
"scale": { "x": 1.0, "y": 1.0 },
|
||||
"pivot": { "x": 300.5, "y": 250.0 },
|
||||
"length": 99.0
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `pivot` | `object` | `{ "x": float, "y": float }` — the **local origin point of rotation**, i.e. the bounding-box center of all shape points in the part's **local drawing space** (before `position` is applied). |
|
||||
| `length` | `float` | The part's bounding-box extent along its **segment axis** (see §3), in local pixels. |
|
||||
|
||||
Both are **always written for all 10 parts** (empty part → `pivot {0,0}` / `length 0.0`) so the schema is uniform.
|
||||
|
||||
### 1d. Backward compatibility / migration
|
||||
|
||||
- `pivot` / `length` / `proportions` are **write-only** metadata. The editor never reads them back (they are recomputed from live shapes at every save). Therefore the load path (`_apply_json_data`, `stickman_editor.gd:397-476`) requires **no changes** — old v1.0–v1.3 files simply lack these keys and load identically to today; they gain the keys on their next save.
|
||||
- v1.0/v1.1/v1.2/v1.3 files remain loadable (already handled by `SUPPORTED_VERSIONS` + the existing wrap/migrate logic at `:426-439`).
|
||||
- No `settings.json` change (proportions/pivot/length are figure data, not editor preferences).
|
||||
|
||||
---
|
||||
|
||||
## 2. Proportions derivation (master-rig rest-pose constants)
|
||||
|
||||
**Recommendation: hardcode the 5 proportions as `const` values** in `stickman_editor.gd`, derived once from `master_rig.tscn` (the same approach Phase 7 took for `GUIDE_JOINTS`). **Do not** derive them from the user's part bounding boxes.
|
||||
|
||||
Justification:
|
||||
|
||||
1. The proportions describe the **target rig's** joint distances, and the adapter writes them directly into `Bone2D.length` / `position.x/y`. Those values are the authored `master_rig.tscn` bone lengths, not whatever arbitrary size the user happened to draw.
|
||||
2. The requirement says "after silhouette alignment" — the user aligns their parts **to** the guide (the rig rest pose). The guide *is* the source of truth for joint distances; the user's parts are the thing being aligned, so measuring them would be circular and fragile.
|
||||
3. Deterministic, editor-safe, no scene instantiation (mirrors the Phase 7 rationale for hardcoding `GUIDE_JOINTS`).
|
||||
|
||||
### Derivation table
|
||||
|
||||
| Proportion | Source node / joint | Value |
|
||||
|---|---|---|
|
||||
| `upper_arm_length` | `Skeleton2D/Torso/LeftUpperArm.length` (`master_rig.tscn:173`); guide `LeftShoulder`→`LeftElbow` | **168.0** |
|
||||
| `lower_arm_length` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm.length` (`:181`); guide `LeftElbow`→`LeftWrist` | **200.0** |
|
||||
| `upper_leg_length` | `Skeleton2D/Torso/LeftUpperLeg.length` (`:222`); guide `Hips`→`LeftKnee` | **200.0** |
|
||||
| `lower_leg_length` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg.length` (`:230`); guide `LeftKnee`→`LeftAnkle` | **200.0** |
|
||||
| `torso_length` | `Hips (0,0)` → `Neck (0,-391.5)` (`Skeleton2D/Torso/Head.position.y`, `:152`) | **391.5** |
|
||||
|
||||
Notes (reproducible):
|
||||
|
||||
- **Bone length vs. joint distance.** `upper_arm_length` is the *bone length* `168.0`, not the literal `|shoulder - elbow| = sqrt(168² + 8²) ≈ 168.19`. The 8 px vertical offset comes from the arm bone's `rotation = 0.0477` (`master_rig.tscn:170`), which the adapter does **not** touch. Writing `168.0` reproduces the rest pose exactly when the adapter sets `LeftUpperArm.length = 168.0` and leaves `rotation` alone. (Writing `168.19` would over-extend the bone.) The requirement's "distance between shoulder and elbow" is treated as the bone length.
|
||||
- **`torso_length`** has no corresponding single bone (the spine is the `Head` bone authored at `y = -391.5`); it is informational in `proportions` (no adapter bone op consumes it).
|
||||
- **`RightUpperLeg.length = 90.0` inconsistency.** `master_rig.tscn:245` authors the right upper leg bone at `90.0` while the left is `200.0` (`:222`) and `master_rig2.tscn:262` fixes the right to `200.0`. Use **200.0** (the symmetric/corrected value). The adapter overwrites both legs anyway, so the discrepancy is moot after fitting — but it is flagged in §5f.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-part `pivot` and `length` computation
|
||||
|
||||
Computed in the editor from each panel's local shape points (via `BodyPartPanel.get_shape_data()`, `body_part_panel.gd:195-218`, which returns points as `{x, y}` dicts in **local drawing space**).
|
||||
|
||||
### 3a. `pivot` — local bounding-box center
|
||||
|
||||
```
|
||||
min_x, min_y, max_x, max_y = bounds over ALL points of ALL shapes in the part
|
||||
pivot = { x: (min_x + max_x) / 2, y: (min_y + max_y) / 2 }
|
||||
```
|
||||
|
||||
This is the **local** analogue of `WholeStickmanPreview._compute_center()` (`whole_stickman_preview.gd:509-519`), which computes the world-space center from `pt + position`. Local pivot = world center − `position` = bbox center of local points. Matches the editor's actual rotation pivot.
|
||||
|
||||
### 3b. `length` — extent along the segment axis
|
||||
|
||||
```
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
length = width (for arm parts)
|
||||
= height (for torso, legs, head)
|
||||
```
|
||||
|
||||
**Axis mapping** (matches the adapter's bone convention — arms use `position.x`, legs/torso use `position.y`, §5c):
|
||||
|
||||
| Part family | Parts | Axis |
|
||||
|---|---|---|
|
||||
| Arms | `left_upper_arm`, `left_lower_arm`, `right_upper_arm`, `right_lower_arm` | **X** (`width`) |
|
||||
| Legs + torso + head | `torso`, `left_upper_leg`, `left_lower_leg`, `right_upper_leg`, `right_lower_leg`, `head` | **Y** (`height`) |
|
||||
|
||||
Empty part (no shapes, or all shapes with <2 points) → `pivot {0,0}`, `length 0.0`.
|
||||
|
||||
> **Alternative considered (not chosen):** `length = max(width, height)`. Simpler and axis-agnostic, but loses the arm/leg axis distinction that the adapter's `position.x` vs `position.y` convention encodes. See §10 Q1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Editor changes (`scripts/stickman_editor.gd`)
|
||||
|
||||
### 4a. New constants
|
||||
|
||||
```gdscript
|
||||
const FILE_VERSION := "1.4" # was "1.3" (line 51)
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"] # line 54
|
||||
|
||||
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2)
|
||||
const PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5,
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. New compute helper
|
||||
|
||||
```gdscript
|
||||
const X_AXIS_PARTS: PackedStringArray = [
|
||||
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
|
||||
]
|
||||
|
||||
func _compute_part_pivot_length(shapes_arr: Array, part_name: String) -> Dictionary:
|
||||
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
|
||||
for sd in shapes_arr:
|
||||
if not sd is Dictionary:
|
||||
continue
|
||||
for p in (sd as Dictionary).get("points", []):
|
||||
if not p is Dictionary:
|
||||
continue
|
||||
var px: float = float((p as Dictionary).get("x", 0.0))
|
||||
var py: float = float((p as Dictionary).get("y", 0.0))
|
||||
min_x = min(min_x, px); min_y = min(min_y, py)
|
||||
max_x = max(max_x, px); max_y = max(max_y, py)
|
||||
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return { "pivot": { "x": 0.0, "y": 0.0 }, "length": 0.0 }
|
||||
|
||||
var pivot := { "x": (min_x + max_x) * 0.5, "y": (min_y + max_y) * 0.5 }
|
||||
var length: float
|
||||
if X_AXIS_PARTS.has(part_name):
|
||||
length = max_x - min_x
|
||||
else:
|
||||
length = max_y - min_y
|
||||
return { "pivot": pivot, "length": length }
|
||||
```
|
||||
|
||||
### 4c. Wire into serialization
|
||||
|
||||
**`_collect_all_shape_data()`** (`stickman_editor.gd:363-378`) — extend the per-part dict with `pivot`/`length`:
|
||||
|
||||
```gdscript
|
||||
all_data[part_name] = {
|
||||
"shapes": shapes_arr,
|
||||
"position": {"x": pos.x, "y": pos.y},
|
||||
"rotation": rot,
|
||||
"scale": {"x": scl.x, "y": scl.y},
|
||||
"pivot": pivot_length["pivot"],
|
||||
"length": pivot_length["length"],
|
||||
}
|
||||
```
|
||||
|
||||
**`_build_json_data()`** (`:381-394`) — add the top-level `proportions` key:
|
||||
|
||||
```gdscript
|
||||
return {
|
||||
"version": FILE_VERSION,
|
||||
"stickman_name": _stickman_name_edit.text.strip_edges(),
|
||||
"part_order": _whole_preview.get_part_order(),
|
||||
"proportions": PROPORTIONS.duplicate(),
|
||||
"body_parts": body_parts,
|
||||
"metadata": { "created_at": time_str, "modified_at": time_str },
|
||||
}
|
||||
```
|
||||
|
||||
No change to `_on_save_file_selected` (`:287-300`) beyond what the above touches.
|
||||
|
||||
### 4d. Load path — no change
|
||||
|
||||
`_apply_json_data` (`:397-476`) ignores unknown keys; `proportions`/`pivot`/`length` are never read. Version `"1.4"` is now accepted by the extended `SUPPORTED_VERSIONS`.
|
||||
|
||||
---
|
||||
|
||||
## 5. `StkRigAdapter.gd` (runtime adapter)
|
||||
|
||||
### 5a. Class shape & API
|
||||
|
||||
New file `res://scripts/stk_rig_adapter.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name StkRigAdapter
|
||||
extends RefCounted
|
||||
## Standalone runtime adapter: fits an instantiated master_rig.tscn to a
|
||||
## loaded .stk dictionary (proportions + shapes). Not referenced by the editor.
|
||||
|
||||
static func apply(stk_data: Dictionary, rig: Node2D) -> void
|
||||
```
|
||||
|
||||
- `rig` is the instantiated `master_rig.tscn` root (`Master`, a `Node2D`).
|
||||
- `stk_data` is the parsed `.stk` dictionary (the adapter reads `proportions` and `body_parts`).
|
||||
- All node access goes through `rig.get_node_or_null(NodePath)` with the fixed paths in §5b; **every lookup is null-guarded** so a malformed/foreign scene fails gracefully (push a warning, skip that op) rather than hard-erroring.
|
||||
- `apply()` calls three private helpers in order: `_fit_bones` → `_recalibrate_ik` → `_mount_shapes`.
|
||||
|
||||
### 5b. Part-key → node-path mapping table
|
||||
|
||||
Paths are **relative to the `rig` root** (`Master`). Bone paths are used by `_fit_bones`; `Body` visual paths by `_mount_shapes`.
|
||||
|
||||
| `part_key` | Bone node (fitting) | `Body` visual node (mount) |
|
||||
|---|---|---|
|
||||
| `head` | `Skeleton2D/Torso/Head` (not length-fitted) | `Body/Head` (circle `Node2D`) |
|
||||
| `torso` | — (no torso bone) | `Body/Body` (`Line2D`) |
|
||||
| `left_upper_arm` | `Skeleton2D/Torso/LeftUpperArm` | `Body/LeftUpperArm` |
|
||||
| `left_lower_arm` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm` | `Body/LeftLowerArm` |
|
||||
| `right_upper_arm` | `Skeleton2D/Torso/RightUpperArm` | `Body/RightUpperArm` |
|
||||
| `right_lower_arm` | `Skeleton2D/Torso/RightUpperArm/RightLowerArm` | `Body/RightLowerArm` |
|
||||
| `left_upper_leg` | `Skeleton2D/Torso/LeftUpperLeg` | `Body/LeftUpperLeg` |
|
||||
| `left_lower_leg` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg` | `Body/LeftLowerLeg` |
|
||||
| `right_upper_leg` | `Skeleton2D/Torso/RightUpperLeg` | `Body/RightUpperLeg` |
|
||||
| `right_lower_leg` | `Skeleton2D/Torso/RightUpperLeg/RightLowerLeg` | `Body/RightLowerLeg` |
|
||||
|
||||
The 10 `Body/*` visual nodes and their `RemoteTransform2D` drivers are enumerated in `master_rig.tscn:71-141` (visuals) and `:160-262` (bone `RemoteTransform2D`). Every visual node is already driven by a `RemoteTransform2D` under the matching bone, so mounting geometry **into** these nodes inherits the skeleton's pose for free.
|
||||
|
||||
### 5c. Bone fitting (`_fit_bones`)
|
||||
|
||||
Reads `proportions` (with defaults = §2 values when the key is absent, for robustness). Applies the Phase 8 requirement **verbatim**, plus a marked completion:
|
||||
|
||||
```
|
||||
proportions = stk_data.get("proportions", DEFAULTS)
|
||||
ua = proportions.upper_arm_length
|
||||
la = proportions.lower_arm_length
|
||||
ul = proportions.upper_leg_length
|
||||
ll = proportions.lower_leg_length
|
||||
|
||||
# Arms — upper length + lower-bone origin on X (requirement verbatim)
|
||||
LeftUpperArm.length = ua ; LeftLowerArm.position.x = -ua
|
||||
RightUpperArm.length = ua ; RightLowerArm.position.x = ua
|
||||
|
||||
# Legs — upper length + lower-bone origin on Y (requirement verbatim)
|
||||
LeftUpperLeg.length = ul ; LeftLowerLeg.position.y = ul
|
||||
RightUpperLeg.length = ul ; RightLowerLeg.position.y = ul
|
||||
|
||||
# RECOMMENDED COMPLETION (see note): also fit the lower-bone lengths
|
||||
LeftLowerArm.length = la ; RightLowerArm.length = la
|
||||
LeftLowerLeg.length = ll ; RightLowerLeg.length = ll
|
||||
```
|
||||
|
||||
- The four `*.length = ua/ul` and four `position.x/y` assignments are exactly the requirement's "Bone Fitting Logic".
|
||||
- **Recommended completion:** the requirement omits setting `LeftLowerArm.length` / `RightLowerArm.length` / `LeftLowerLeg.length` / `RightLowerLeg.length` (= `lower_arm_length` / `lower_leg_length`). Without it, lower limbs keep their authored `200.0` and won't scale if proportions differ from defaults. This completion is flagged in §10 Q2; it is safe (a no-op for the default proportions) and makes the adapter actually "fit" all 8 limb bones.
|
||||
- `auto_calculate_length_and_angle` is already `false` on all these bones (`master_rig.tscn:156/172/180/197/205/221/229/245/252`), so direct `.length` writes are authoritative.
|
||||
- Do **not** touch `bone_angle`, `rotation`, or `rest` — those encode the rest-pose orientation and must be preserved.
|
||||
|
||||
### 5d. IK target recalibration (`_recalibrate_ik`)
|
||||
|
||||
```
|
||||
ul = upper_leg_length ; ll = lower_leg_length
|
||||
ua = upper_arm_length ; la = lower_arm_length
|
||||
|
||||
# Leg IK targets (requirement verbatim)
|
||||
IK_Targets/Left_Leg.position.y = ul + ll
|
||||
IK_Targets/Right_Leg.position.y = ul + ll
|
||||
|
||||
# Hand IK targets — "default rests to match total arm length"
|
||||
IK_Targets/Left_Hand.position.x = -ua
|
||||
IK_Targets/Right_Hand.position.x = ua
|
||||
IK_Targets/Left_Hand.position.y = ELBOW_REST_Y - la
|
||||
IK_Targets/Right_Hand.position.y = ELBOW_REST_Y - la
|
||||
```
|
||||
|
||||
- `ELBOW_REST_Y = -256.0` is the authored elbow height (`Body/LeftUpperArm.position.y`, `master_rig.tscn:116`; also the `LeftElbow` guide joint). Keeping `hand.y = elbow_y − lower_arm_length` preserves the elbow→wrist vertical span while the x-position tracks the (possibly re-fitted) upper-arm length. With default proportions this reproduces the authored hand rest `(±168, −456)` exactly.
|
||||
- `IK_Targets/Head` and `IK_Targets/Torso` are **not** touched (no proportion governs them).
|
||||
- The `TwoBoneIK` `target_nodepath`s already point at `../IK_Targets/{Left,Right}_{Hand,Leg}` (`master_rig.tscn:21-49`), so moving the `Marker2D`s is sufficient — no modification-stack edits required.
|
||||
- The leg value `ul + ll = 400.0` differs from the authored `Left_Leg.y = 376.0` (`master_rig.tscn:283`); the authored pose has a slight knee bend (knee world y ≈ 176). Setting `400.0` is the requirement's intent ("stand straight" default reach). Noted, not overridden.
|
||||
|
||||
### 5e. Visual shape mount (`_mount_shapes`)
|
||||
|
||||
For each of the 10 part keys, mount the part's `.stk` shapes into the corresponding `Body/*` node (§5b). Two equivalent strategies are permitted by the requirement ("replace default Line2D nodes **or** instantiate new nodes"); the spec recommends **updating the existing `Body/*` nodes in place**, which preserves the `RemoteTransform2D` driving and keeps node names stable:
|
||||
|
||||
1. **Coordinate transform.** `.stk` shape points are in arbitrary panel-local space (e.g. `test.stk` has points around x∈[150,600]). Transform each point into the `Body` node's local convention:
|
||||
```
|
||||
pt_local = (pt - pivot) * scale_factor
|
||||
scale_factor = bone_length / part_length # per part, per axis (see below)
|
||||
```
|
||||
where `pivot` and `length` come from `body_parts[part_name]`, and `bone_length` is the corresponding proportion (arms → `upper_arm_length`/`lower_arm_length`; legs → `upper_leg_length`/`lower_leg_length`; torso → `torso_length`; head → leave at 1.0 scale, translate only). This maps the part's `pivot` → the node's origin and stretches the drawn segment to the bone length.
|
||||
2. **Node construction per shape** (`.stk` shape dict → Godot nodes):
|
||||
- `color = Color.from_string(shape["color"], Color.WHITE)`
|
||||
- **Open** (`closed == false`) → one `Line2D` with `points = transformed`, `width = DEFAULT_LINE_WIDTH`, `default_color = color`.
|
||||
- **Closed** (`closed == true`) → one `Polygon2D` (`polygon = transformed`, `color = color`) for the fill **plus** one `Line2D` (`closed = true`) for the outline — mirroring the editor's fill+outline rendering (`body_part_panel.gd:380-385`).
|
||||
- `DEFAULT_LINE_WIDTH := 16.0` (matches `master_rig.tscn`'s `width = 16.0`; `.stk` stores no width).
|
||||
3. **Head special case.** `Body/Head` is a `Node2D` with the embedded circle `@tool` script (`master_rig.tscn:73-77`, `radius = 100`). For a `head` part whose shapes are circle-like, set its `radius`/`color` exports from the head bbox; otherwise (or as a uniform v1), replace it with the same `Polygon2D`/`Line2D` treatment as other parts.
|
||||
4. **Empty part** → remove/clear the corresponding `Body` node's geometry (freeze or hide), so a part the user never drew doesn't render the default `Line2D`.
|
||||
|
||||
> The coordinate mapping here is the most under-specified piece of the requirement. §5e defines a concrete, deterministic v1 (translate `pivot`→origin, scale along the segment axis to the bone length). The fine-grained fidelity (matching the user's in-preview `rotation`/`scale`/`position` exactly) is intentionally left to the runtime consumer — see §10 Q3.
|
||||
|
||||
### 5f. `master_rig.tscn` vs `master_rig2.tscn`
|
||||
|
||||
- The adapter targets **`master_rig.tscn`** (the clean scene Phase 7 derived `GUIDE_JOINTS` from). It has **no** `metadata/_local_pose_override_enabled_` on its bones.
|
||||
- `master_rig2.tscn` is a variant that (a) adds `metadata/_local_pose_override_enabled_ = true` to every bone, (b) fixes `RightUpperLeg.length` from `90.0` → `200.0` (`master_rig2.tscn:262`), and (c) explicitly sets `enabled = true` on the modification stack. `clear_pose.gd` (an `EditorScript`) exists to strip the pose-override metadata and reset bone scales to `Vector2.ONE`/`rest`.
|
||||
- **Implication:** the adapter is written against `master_rig.tscn` node names (identical in both) and overwrites lengths anyway, so it works with either — but the `90.0` right-leg value in `master_rig.tscn` is a latent bug the adapter must not rely on (it writes `RightUpperLeg.length` from `proportions`, §5c).
|
||||
|
||||
### 5g. Editor does **not** verify/import the adapter
|
||||
|
||||
The editor only *produces* data the adapter *consumes*. There is no editor→adapter reference, no `preload("res://scripts/stk_rig_adapter.gd")`, no scene that imports it. "Verification" is a standalone concern: the adapter is syntax-checked with the project parse check and (optionally) a future headless smoke test (§8). This keeps the editor decoupled from the runtime pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/stickman_editor.gd` | `FILE_VERSION` → `"1.4"`; add `"1.4"` to `SUPPORTED_VERSIONS`; add `PROPORTIONS` const + `X_AXIS_PARTS` const; add `_compute_part_pivot_length()`; extend `_collect_all_shape_data()` with `pivot`/`length`; add `proportions` to `_build_json_data()`. |
|
||||
| `scripts/stk_rig_adapter.gd` | **New.** `StkRigAdapter` (`RefCounted`) with `static func apply(stk_data, rig)` + `_fit_bones` / `_recalibrate_ik` / `_mount_shapes` + node-path consts (§5). |
|
||||
| `docs/phase8_spec.md` | This file. |
|
||||
| `README.md` | Document the `.stk` v1.4 `proportions`/`pivot`/`length` keys, the `StkRigAdapter.gd` script, and the runtime-pipeline note (§11). |
|
||||
| `AGENTS.md` | Add a Phase 8 note (v1.4 export + `StkRigAdapter.gd`). |
|
||||
|
||||
No `.tscn` changes. No `settings.json` change. No load-path change.
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases & Constraints
|
||||
|
||||
- **Empty part** — no shapes → `pivot {0,0}`, `length 0.0`; the adapter clears/hides that `Body` node's geometry. Never divide by zero in the mount transform (`scale_factor` guards `length <= 0` → 1.0 or skip).
|
||||
- **Multi-shape parts** — `pivot`/`length` are computed over **all** shapes in the part (the part is treated as one object, matching the Whole Stickman preview semantics).
|
||||
- **`part_length == 0` or `bone_length == 0`** — guard the mount scale; fall back to translation-only.
|
||||
- **Negative scale parts** (Phase 5 mirroring sets `scale.x/y` negative) — `pivot`/`length` are computed from the **unscaled local points** (the panel geometry), not the preview transform, so mirroring does not affect them. (The adapter does not consume part `scale`/`rotation`/`position` in v1 — see Q3.)
|
||||
- **Old files loaded then saved** — v1.0–v1.3 files load unchanged and are written out as `"1.4"` with computed `proportions`/`pivot`/`length` on the next save.
|
||||
- **Adapter with a foreign/malformed scene** — every `get_node_or_null` is null-guarded; missing nodes → push a warning and skip, never crash.
|
||||
- **Adapter with a `.stk` lacking `proportions`** — falls back to the §2 default constants, so the rig still fits to the standard rest pose.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing / Verification
|
||||
|
||||
There is no automated test suite in the repo (no `test/` directory, no GUT addon installed — the `tester` agent's GUT template is aspirational; `glob test*.gd` returns nothing). Verification is manual + the project parse check.
|
||||
|
||||
1. **Parse check** — run from `C:\Godot4\stickman` (engine binary per the project: `config/features = "4.7"`, matching the user's 4.7.1 binary):
|
||||
```
|
||||
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit
|
||||
```
|
||||
> The plain `--check-only` form hangs on renderer init in 4.7.x; use `--headless --check-only --quit` (as established in `docs/phase7_round1_spec.md` §6). The user's stated command `..\Godot_v4.7.1-stable_win64_console.exe . --check-only` is equivalent in intent but should include `--headless --quit`.
|
||||
2. **Save emits v1.4** — File → Save; inspect the `.stk`: `version == "1.4"`, a top-level `proportions` with the 5 values (`168.0/200.0/200.0/200.0/391.5`), and every `body_parts.*` entry has `pivot {x,y}` + `length` (arm parts' `length` = bbox width; leg/torso/head = bbox height).
|
||||
3. **Empty part** — a part with no shapes writes `pivot {0,0}` / `length 0.0`; no crash.
|
||||
4. **Backward-compat load** — load `stickmen/basic.stk` (v1.0) and `stickmen/test.stk` (v1.1); no error; save; result is v1.4 with computed pivot/length/proportions.
|
||||
5. **Adapter smoke test (standalone, optional/headless)** — a small `--headless` SceneTree script that instantiates `master_rig.tscn`, calls `StkRigAdapter.apply(sample_stk, rig)`, and asserts: `LeftUpperArm.length == 168.0`, `LeftLowerArm.position.x == -168.0`, `LeftUpperLeg.length == 200.0`, `IK_Targets/Left_Leg.position.y == 400.0`, `IK_Targets/Left_Hand.position == (-168.0, -456.0)`. (Add under `test/` later if GUT is introduced; out of scope for this phase.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Design Decisions (summary)
|
||||
|
||||
| # | Decision | One-line justification |
|
||||
|---|---|---|
|
||||
| D1 | `proportions` = hardcoded master-rig rest-pose constants (§2) | They describe the rig's joints and are written verbatim into bones; user shapes are the aligned thing, not the measure. |
|
||||
| D2 | `pivot` = local bbox center; `length` = axis-specific extent (arms→Δx, legs/torso/head→Δy) (§3) | Matches the editor's actual rotation pivot and the adapter's `position.x` vs `position.y` bone convention. |
|
||||
| D3 | Version `"1.3" → "1.4"`, write-only metadata, no load migration (§1) | `pivot`/`length`/`proportions` are recomputed on save; old files load unchanged and gain keys on next save. |
|
||||
| D4 | `StkRigAdapter` = `RefCounted` static `apply()`, standalone script (§5a) | No scene/autoload dependency; consumed by a future runtime pipeline, never by the editor. |
|
||||
| D5 | Adapter targets `master_rig.tscn` (not `master_rig2.tscn`) (§5f) | It's the clean scene Phase 7 derived `GUIDE_JOINTS` from; `master_rig2` is a pose-override variant with a fixed right-leg length. |
|
||||
| D6 | Visual mount = update existing `Body/*` nodes in place (§5e) | Preserves `RemoteTransform2D` driving and node names; `.stk` shape → `Line2D`/`Polygon2D` with pivot→origin + length-normalization. |
|
||||
| D7 | Editor never imports/verifies the adapter (§5g) | Keeps the editor decoupled; the adapter is verified standalone (parse check + optional headless smoke test). |
|
||||
| D8 | Bone fitting adds lower-bone `.length` writes beyond the requirement's literal text (§5c) | Without it the lower limbs don't scale; the addition is a safe no-op at default proportions (flagged Q2). |
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions — RESOLVED (user-approved)
|
||||
|
||||
1. **`length` axis convention (D2).** ✅ **Axis-specific**: arms→X / legs+torso+head→Y (§3b).
|
||||
2. **Lower-bone length fitting (D8).** ✅ **Include** the completion `LeftLowerArm.length = lower_arm_length` etc. (§5c).
|
||||
3. **Visual-mount fidelity (§5e).** ✅ **v1 as specified**: pivot→bone origin + scale to bone length; part `position`/`rotation`/`scale` ignored (noted in adapter docs as a v2 concern).
|
||||
4. **`proportions` arms (§2).** ✅ **168.0** (bone length, reproduces rest pose).
|
||||
5. **Head `length` (§3b).** ✅ Vertical bbox height (circle diameter).
|
||||
|
||||
---
|
||||
|
||||
## 11. README / AGENTS Updates
|
||||
|
||||
- **README §"File format (`.stk`)"** — bump the example to `"1.4"`; add the top-level `proportions` table and the per-part `pivot`/`length` rows to the Part object table; update the migration note (`v1.0–v1.3` auto-migrate; pivot/length/proportions recomputed on save).
|
||||
- **README new subsection (or §"Project structure")** — document `scripts/stk_rig_adapter.gd`: its `static apply()` API, that it fits an instantiated `master_rig.tscn` to a loaded `.stk`, and that it is a runtime-pipeline utility (not used by the editor).
|
||||
- **AGENTS.md** — add a Phase 8 note: `FILE_VERSION "1.4"`; top-level `proportions` + per-part `pivot`/`length` computed on save in `stickman_editor.gd`; `scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) as a standalone runtime adapter targeting `master_rig.tscn`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Recommended Implementation Order
|
||||
|
||||
1. `scripts/stickman_editor.gd` — constants (`FILE_VERSION`, `SUPPORTED_VERSIONS`, `PROPORTIONS`, `X_AXIS_PARTS`) + `_compute_part_pivot_length()`.
|
||||
2. `scripts/stickman_editor.gd` — wire `pivot`/`length` into `_collect_all_shape_data()` and `proportions` into `_build_json_data()`.
|
||||
3. `scripts/stk_rig_adapter.gd` — `apply()` + `_fit_bones()` + `_recalibrate_ik()` + `_mount_shapes()` with §5b paths.
|
||||
4. Manual verification (§8) + `--headless --check-only --quit`.
|
||||
5. Optional headless adapter smoke test.
|
||||
6. Doc updates (`README.md`, `AGENTS.md`).
|
||||
Reference in New Issue
Block a user