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