Files
stickman/docs/phase7_spec.md
T
ryan 48d98ce0fe Add StkRigAdapter for runtime skeleton fitting and shape mounting
- Implemented StkRigAdapter class to adapt a master rig to a loaded .stk dictionary.
- Added methods for fitting bone lengths, recalibrating IK targets, and mounting vector shapes.
- Defined constants for default proportions and bone paths.
- Included error handling for missing nodes and invalid data structures.
2026-08-18 17:12:54 -04:00

21 KiB
Raw Blame History

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

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

# 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

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

# _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 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).
  • ClippingPreviewArea 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/corruptshow_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.

  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.