- 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.
21 KiB
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).
{
"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:
- The guide needs the rest pose, not the posed figure.
master_rig.tscn'sSkeletonModificationStack2Dis enabled and contains 4×TwoBoneIK+ 1×LookAtmodifications. Instantiating the scene would apply IK, moving bones away from their rest transforms (hands towardIK_Targets, head toward the look-at target). The requirement explicitly asks for rest-pose dimensions. - Zero runtime cost & no scene dependency. Hardcoded constants are deterministic, editor-safe, and add no node/scene instantiation or potential side effects (no
RemoteTransform2Dwrites, no@toolscript instantiation). - Traceable. The source node + transform for every constant is documented in §3, so the values can be regenerated if
master_rig.tscnchanges.
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
# 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_JOINTSare exactly the key pivot locations required by PROJECT.md.GUIDE_SCALEandGUIDE_OFFSETare the only tunables mapping master-rig coordinates into preview world space (see §3 for the chosen values).
2b. New state + public API
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()
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; theHeadjoint dot marks the head center.- Joint dots use
GUIDE_JOINT_RADIUS / _zoomso 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 ofGUIDE_SCALEand zoom. - The guide is pure drawing — no
_gui_inputhit-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 =
Headbone origin(-0.12882307, -391.5079). This is the head/spine pivot. (The visual torsoLine2Drenders 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(Node2Dwith the embedded@toolscript,radius = 100) is driven byRemoteTransform2Dunder theHeadbone at local offset(0.0503, -72.0): center ≈(0, -391.5) + (0.05, -72) = (0, -463.5). - Elbows =
LeftLowerArm/RightLowerArmbone origins. World = parent arm origin(0,-248)rotated by the arm rest rotation0.0477 radapplied to the local offset(±168, 0)→(±167.8, -256.0). - Wrists/Ankles = the
TwoBoneIKtargets (Left_Hand,Right_Hand,Left_Leg,Right_LeginIK_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.
# _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
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():
# _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 use6.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 —
PreviewAreaalready hasclip_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_guidefalls back totrue(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:
- Load check — run the project; no parse errors; main scene opens.
- 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.
- 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).
- Draw order — draw a part; confirm it renders over the guide; confirm the guide renders over the grid.
- Pan/zoom — middle-drag and wheel-zoom the preview; the guide tracks the grid and parts exactly; joint dots stay ~6 px.
- Non-interference — with the guide visible, left-click-drag a part and right-click for the context menu; behavior is unchanged.
- Persistence — the guide is on by default; disable it, close and reopen the project; it stays disabled (settings.json contains
"show_pose_guide": false). - Clear/Load — clearing or loading a figure leaves the guide setting unchanged.
Note:
..\Godot_v4.7.1-stable_win64_console.exe . --check-onlycould not be run — the executable is not present underC:\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
Skeleton2Drig pivots frommaster_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_guidekey (bool, defaulttrue). - Menu bar structure diagram — add the View → Show Pose Guide entry.
9. Recommended Implementation Order
whole_stickman_preview.gd— constants,_show_guide,set_show_guide(), draw helpers, wire into_on_preview_draw().stickman_editor.gd—_show_guidestate + View menu item + handlers.- Settings persistence (
_load_settings/_save_settings/_broadcast_settings). - Manual verification (list in §7) +
--check-only. - README update.