feat: Implement draggable torso and head IK targets in the test harness

- Added draggable handles for the torso and head to the test harness.
- Updated `IK_HANDLE_PATHS` to include new entries for "Head" and "Torso".
- Implemented distinct colors for the torso (magenta) and head (yellow) markers.
- Added a visual aid (aim line) to indicate the head's LookAt target direction.
- Ensured that dragging the torso moves only the torso marker, allowing for limb stretching towards stationary targets.
This commit is contained in:
2026-08-21 12:39:31 -04:00
parent 6b273c049c
commit ab5c79ab6a
14 changed files with 1459 additions and 174 deletions
+131 -29
View File
@@ -19,15 +19,24 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
## Architecture
- `scripts/stickman_editor.gd``extends Control`; the main controller. Owns the menu bar,
save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels.
Writes `FILE_VERSION "1.4"`; auto-migrates `"1.0"``"1.3"` files on load. Coordinates cross-panel
Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"``"1.4"` files on load. Coordinates cross-panel
selection so only one shape is selected at a time (`shape_selected` → deselect others).
Collects per-part `{shapes[], position, rotation, scale, pivot, length}` for save/load.
Collects per-part `{shapes[], position, rotation, scale, pivot, length, guide_offset}` for save/load.
- **Phase 8 save export:** writes top-level `proportions` (hardcoded master-rig rest-pose
constants 168/200/200/200/391.5 via the `PROPORTIONS` const) and per-part `pivot`/`length`
computed from the panel's local shape bounding box (`_compute_part_pivot_length()`): `pivot` =
bbox center, `length` = bbox width for the 4 arm parts (`X_AXIS_PARTS`) and bbox height
otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load,
recomputed on every save.
- **Phase 9 Round 5 guide-offset export:** each part's save dict also gains `"guide_offset":
{x, y}` = (part bbox center in preview space) (guide joint in preview space), computed in
`_collect_all_shape_data()` as `(pos + pivot) - _whole_preview.get_guide_joint_preview(...)` —
a **pure master-space delta** (both points are preview-world coordinates, so panel-size terms
cancel). The part→joint map is `const GUIDE_JOINT_FOR_PART` (head→"Neck" — the head bone's
rig attachment origin, NOT the circle center — torso→"Hips", upper arms→Shoulders, lower
arms→Elbows, upper legs→"Hips", lower legs→Knees). Write-only metadata like `pivot`/`length`;
the load path (`_apply_json_data`) ignores it, so v1.0v1.4 files load unchanged and gain the
key on their next save.
- **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8,
most-recent-first), loads from `settings.json` (`recent_colors` key) in
`_load_settings()`, saves on each color selection via `_save_settings()`, and
@@ -89,7 +98,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
- `scripts/whole_stickman_preview.gd` — `class_name WholeStickmanPreview`, `extends Control`;
the assembly preview. Owns per-part position/rotation/scale, Z-order (`_part_order`),
selection + gizmos, grid drawing, and pan/zoom. Public API includes `set_body_parts()`,
`set_show_guide(enabled: bool)`, `reset_view()`, and `is_cursor_over_preview(global_pos)`.
`set_show_guide(enabled: bool)`, `reset_view()`, `is_cursor_over_preview(global_pos)`, and
(Phase 9 Round 5) `get_guide_joint_preview(joint_name) -> Vector2` — returns the preview-space
position of a `GUIDE_JOINTS` entry via `_guide_to_preview()`, guarded against unknown names
(`push_warning` + `Vector2.ZERO`).
- **Phase 7 pose silhouette guide:** `set_show_guide()` stores `_show_guide: bool`
(default `true`) and redraws; `_draw_silhouette_guide()` is called in `_on_preview_draw()`
after the part loop and before the drag highlight/selection gizmos, so it renders above
@@ -110,29 +122,95 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
- `scripts/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone
runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9).
`static func apply(stk_data, rig)` fits an instantiated `master_rig.tscn` to a loaded `.stk`
dictionary, calling four private helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D`
lengths + lower-bone origins), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand`
and `Left|Right_Leg` targets), `_neutralize_driver_rotations` (sets `update_rotation = false`
on the 10 `Body/*` `RemoteTransform2D` drivers so the `Body/*` nodes stay in the clean
unrotated frame the mount math assumes; position/scale pushes are retained), and
`_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual nodes — open → `Line2D`,
closed → `Polygon2D` fill + `Line2D` outline, width 16).
dictionary, calling three private helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D`
lengths + lower-bone origins, and zeroes the Head driver's local position so the chin sits on
the neck joint), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand` and
`Left|Right_Leg` targets), and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual
nodes — one node per shape: closed → single `Polygon2D`, open → single `Line2D` width 2). The
`RemoteTransform2D` drivers keep their defaults (`update_rotation = true`), so mounted shapes
follow their bones in every pose.
- **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
-proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every
other part — it clears the `Body/Head` node's inline `@tool` circle script via
`set_script(null)` and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. Dead helpers
`_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` were removed.
- **Phase 9 Round 1 bugfix (mount math):** `_mount_shapes()` no longer reads the file's
`pivot`/`length` fields — it recomputes a per-part bounding box at mount time via
`_compute_part_bbox()` (empty bbox → the part is skipped). `_compute_anchor()` derives a
**joint-based anchor** per part family: head → bottom-center `(cx, max_y)`; torso + legs →
top-center `(cx, min_y)`; left arms → `(max_x, cy)`; right arms → `(min_x, cy)`. Scaling is
**anisotropic** via `_compute_scale()`, applied as a `Vector2`: arms scale X only
`(bone_length/part_length, 1.0)`, legs/torso scale Y only `(1.0, bone_length/part_length)`,
head unscaled `(1.0, 1.0)`, with a `part_length <= 0` guard → `1.0`. `_bone_length_for()`
maps each part to its bone length (upper/lower arm/leg, torso); `X_AXIS_PARTS` const
identifies the four arm keys. `_reset_node_transform()` resets each `Body/*` container's
scale to `(1, 1)` and rotation to `0` (position untouched, owned by the driver).
- **Phase 9 Round 2 bugfix (hanging-convention mount):** driver rotation neutralization was
**removed** — the `RemoteTransform2D` drivers keep `update_rotation = true`, so each mounted
part rotates to follow its bone in every pose (IK flexing included). `_mount_shapes()` no
longer reads the file's `pivot`/`length` fields — it recomputes a per-part bounding box at
mount time via `_compute_part_bbox()` (empty bbox → the part is skipped) and derives the
mount transform via `_compute_mount_transform()`, which emits `{anchor, scale, theta}`:
geometry is mounted in the rig's **hanging convention** (joint anchor at the local origin,
far end along local `+Y`). Anchors: head and torso → bottom-center `(cx, max_y)` (chin / hip
end at the origin); left limbs drawn horizontally → `(max_x, cy)`; right limbs drawn
horizontally → `(min_x, cy)`; vertically drawn limbs → top-center `(cx, min_y)`. Alignment
rotation θ maps the far end onto local `+Y`: head `0`, torso `π` (driver π cancels it at
rest), left horizontal limbs `−π/2`, right horizontal limbs `+π/2`, vertical limbs `0`.
Scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`)
scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`); the
cross axis stays 1:1 (so horizontally-drawn legs become ~200×28, not 101-px bars).
`_bone_length_for()` maps each part to its bone length (upper/lower arm/leg, torso).
`_map_point()` applies `(P J) ⋅ S` then `q.rotated(θ)`. The **head driver's** local
position (`Skeleton2D/Torso/Head/RemoteTransform2D`) is zeroed in `_fit_bones()` so the
mounted head's chin lands on the neck joint. `DEFAULT_LINE_WIDTH := 2.0` (was 16.0) matches
the editor's 2 px outline. `_reset_node_transform()` still resets each `Body/*` container's
scale to `(1, 1)` and rotation to `0` before mounting (position untouched, owned by the
driver).
- **Phase 9 Round 3 bugfix (part preview transform + one node per shape):** the mount
pipeline now **composes the part's preview transform** `E(P) = C + R(rot)·S·(P C)`
(scale-then-rotate about the raw bbox center — the editor's exact Whole-Stickman-preview
transform) **before** the hanging-convention mount. `_mount_shapes()` reads the per-part
`rotation` (degrees, default `0.0`) and `scale` (`{x,y}`, default `(1,1)`) from the part
dict and applies `E` to the raw joint end `J_raw` and far point `F_pt_raw`
(`J' = E(J_raw)`, `F' = E(F_pt_raw) J'`). The anchor, alignment θ, and bone-fit scale `s`
are then computed on the **transformed geometry**: rotations near ±180°
(`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end (`A = F_pt'`, `V = F'`)
so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint and its
hip end at the neck); other rotations keep limbs attached along their bones (a 90° forearm
hangs from the elbow with its content turned, exactly as assembled). The bone-fit scale
`s = bone_length / |V|` is measured on the transformed extent so user-scaled parts are not
double-fitted. The **head** mounts upright with `θ = 0`, `s = 1` (a bone-fit scale would
double-scale the face), but still applies the part scale through `E` (face ≈160 px) with
the chin at the neck joint and the flip anchor rule still applying. `_mount_shape()` mounts
**one node per shape**: closed → single `Polygon2D` (fill only, no paired `Line2D` outline);
open → single `Line2D` (width 2).
- **Phase 9 Round 4 bugfix (head chin drop):** adds `const HEAD_CHIN_DROP := 28.0`, derived
from the editor's pose guide — the head circle (radius 100) is centered at the Head joint
`(0, 463.5)`, so its bottom is `363.5`; the neck (Head bone origin) is at `391.5`, so the
chin drops 28 px below the neck. The mounted head points get a `Vector2(0.0, 28.0)`
rig-space translation (`offset` in `_compute_mount_transform()` / `_map_point()`, applied
**after** the part transform and the `(θ = 0, s = 1)` transform; flip-agnostic — only the
head branch sets a non-zero `offset`). Result: the head's chin lands at world ≈ `(0, 363.5)`,
overlapping the torso's top (which ends at `391.5`) by 28 px — matching the silhouette
guide in the editor.
- **Phase 9 Round 5 guide-offset application:** `_mount_shapes()` reads each part's
`guide_offset` (`{x, y}`, default absent) and, **only when the key is present** (old files
keep the previous offset-0 behavior and the head falls back to the Round 4
`HEAD_CHIN_DROP`), applies a node-frame translation
`t = (guide_offset + (A C)).rotated(c_node)` where A = the mount anchor already computed
(the transformed joint end `J'`, or the transformed far end `F_pt'` when flipped — Round 3),
C = the raw bbox center, and `c_node` = the part's driver `RemoteTransform2D.global_rotation`
at apply time (read via the reintroduced `DRIVER_PATHS` const; null-guarded, fallback 0.0).
This converts the editor's master-space guide offset into a bone-relative placement so the
harness reproduces the guide placement 1:1 (and, for the head — mapped to the guide **Neck**
joint — subsumes the `HEAD_CHIN_DROP` fallback). `t` is applied in `_map_point()` as the
final rig-space translation, after `E`/θ/scale/flip and independent of the flip logic.
Re-saving a `.stk` from the editor populates the offsets.
- **Phase 9 Round 6 bugfix (guide-driven anchor selection):** when `guide_offset` is present,
the joint anchor in `_compute_mount_transform()` is now whichever transformed end
(`j_prime = E(J_raw)` or `f_pt_prime = E(F_pt_raw)`) is **nearest the part's stored guide
joint** (`center guide_offset`): if `d_far < d_joint` (strict) the far end attaches
(`anchor = f_pt_prime`, `v = f_prime`), else the family end (`anchor = j_prime`, `v =
f_prime`). This replaces the per-side family choice **and** the 180° flip heuristic for the
`guide_offset` case, fixing the **lower left leg** (knee now at the joint, was the ankle)
and **lower right arm** (elbow now at the joint, was the wrist), both 180° off their bones
because the user's drawn-side conventions are inconsistent per part. The nearest-end rule
preserves every previously-correct case and naturally reproduces the 180° flip (a flipped
part's far end lands nearest the joint — e.g. the flipped right upper arm shoulder and the
flipped torso neck end), plus the head chin (nearest the guide Neck). Old files **without**
the key keep the previous family rules + flip heuristic exactly as before. `theta`, `s`, the
Round 5 offset `t`, and the `HEAD_CHIN_DROP` fallback are unchanged — they consume
`anchor`/`v` generically.
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
@@ -151,13 +229,33 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
Bones" / "Show IK Handles" checkboxes; a status label showing the loaded filename. Viewport:
`SubViewportContainer` → `SubViewport` → world `Node2D` + enabled `Camera2D`; middle-mouse pan,
mouse-wheel zoom, camera recenters on each spawn. Each load frees the previous rig and spawns a
fresh one via `StickmanFactory.spawn()`. Debug overlay (a world-space `Node2D` `_draw()`): bone
lines between each `Bone2D` global origin and its parent's (color-coded left cyan / right orange /
central white + joint dots) when "Show Bones" is on; colored markers at
fresh one via `StickmanFactory.spawn()`. Debug overlay (a world-space `Node2D` `_draw()`): true
bone **segments** (a joint dot at each `Bone2D` origin + a parent→child line to each `Bone2D`
child, color-coded left cyan / right orange / central white) with leaf bones drawn out to their
IK targets (`LeftLowerArm→Left_Hand`, `RightLowerArm→Right_Hand`, `LeftLowerLeg→Left_Leg`,
`RightLowerLeg→Right_Leg`) so wrist/ankle joints are visible (Phase 9 Round 2; previously only
origin→parent-origin lines were drawn). The **Head** leaf is the exception (Phase 9 Round 3):
its IK target is a `SkeletonModification2DLookAt` aim point, not a joint, so it is **not** in
`LEAF_BONE_IK_PATHS` and the no-target fallback draws a ~90 px segment along the bone's own
direction (`Vector2(length, 0)` rotated by `bone_angle` then `global_rotation`) instead of a
line to the aim point; colored markers at
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
IK: click-drag the 4 limb `Marker2D` IK targets; the scene's `SkeletonModificationStack2D`
TwoBoneIK flexes limbs live. The harness enables the modification stack (`enabled = true`) after
each spawn.
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
flexes limbs live. The harness enables the modification stack (`enabled = true`) after each
spawn.
- **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries**
— the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim
point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone).
Dragging the **Torso** handle translates **bones only** (no target following) — the marker's
`RemoteTransform2D` moves the hip bone and the whole skeleton + `Body/*` visuals follow
rigidly, while the limb/head targets stay put (dragging the figure away from them stretches
the limbs toward the stationary targets, per user decision). Dragging the **Head** handle
drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint);
`Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`)
and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK
overlay also draws a **null-guarded semi-transparent yellow aim line** from the Head bone
origin to the head marker (`_draw_ik_handles`, width `1.5/zoom`, alpha `0.5`) — a visual aid
for the LookAt test.
- Scenes:
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
@@ -193,8 +291,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
origin) and `length` (float, bbox extent along the segment axis) for format v1.4; the
`.stk` root also gains a top-level `proportions` object (5 rig bone lengths). All three
are write-only metadata recomputed on every save — never read back on load.
- The JSON `.stk` format is defined in `README.md` (versioned `"1.4"`, extensible;
`"1.0"``"1.3"` files auto-migrate on load).
- **Phase 9 Round 5:** per-part data adds `guide_offset` `{x, y}` (bbox center guide joint,
preview space; pure master-space delta) for format v1.5. Write-only metadata like
`pivot`/`length`; the load path ignores it, so v1.0v1.4 files load unchanged and gain the
key on their next save.
- The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible;
`"1.0"``"1.4"` files auto-migrate on load).
### settings.json (Phase 6)
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
+40 -1
View File
@@ -71,7 +71,46 @@ The guide and joints should slightly 'ghost' in front of the objects so that the
> FIXED — 2026 Round 1: implemented per docs/phase9_round1_bugfix_spec.md; anisotropic scaling, joint-based anchors, and node/driver transform neutralization; verified with a 37-assertion headless smoke test.
## Stickman editor (Phase 9 Round 2)
> FIXED — 2026 Round 2: implemented per docs/phase9_round2_bugfix_spec.md; restored the `RemoteTransform2D` driver rotations (`update_rotation = true` again) so mounted shapes follow their bones under IK flexing, switched the mount to the rig's hanging convention (joint anchor at the local origin, far end along +Y, per-family alignment rotation), auto-detected the drawn long axis so only that axis scales to the bone length (cross-axis thickness 1:1), zeroed the Head driver's local position so the chin lands on the neck joint, dropped `DEFAULT_LINE_WIDTH` to 2.0 (editor's outline width), and rewrote the harness bone overlay (true parent→child segments + leaf bones drawn to their IK targets). Verified with a 46-assertion headless smoke test.
1. **Lower-limb bones/joints invisible in "Show Bones".** The harness `_draw_bones()` drew parent-origin → bone-origin lines, so for lower bones the segment duplicated the upper segment and wrist/ankle joints (bone ends, not origins) were never drawn. Fixed: the overlay now draws a joint dot per bone, parent→child bone segments, and leaf bones out to their IK targets.
2. **IK only moves lower-arm shapes; upper-arm shapes static.** Round 1's `_neutralize_driver_rotations()` set `update_rotation = false` on the 10 `RemoteTransform2D` drivers, so `Body/*` visuals sat unrotated at fixed joints. Fixed: driver rotation neutralization removed — the drivers keep their defaults and the hanging-convention mount rotates every part to follow its bone in any pose.
3. **Torso top connects at the hip joint.** With driver rotation suppressed, the torso (top-center anchor) hung with its top at the hip-driven origin. Fixed: the torso now mounts with the hip end (bottom-center anchor, θ = π) at the origin, so the top lands at the neck.
4. **Head features wrong scale / too thick / nose not triangular.** `DEFAULT_LINE_WIDTH = 16.0` dominated small face features, and the Head driver's local position `(0.05, 72)` floated the head above the neck. Fixed: line width is 2.0 (editor's 2 px outline) and the Head driver position is zeroed so the mounted head's chin sits on the neck joint.
## Stickman editor (Phase 9 Round 3)
> FIXED — 2026 Round 3: implemented per docs/phase9_round3_bugfix_spec.md; one node per shape (closed → single Polygon2D, open → single Line2D), the part's preview rotation/scale composed into the mount before the hanging-convention fit (anchor/θ/bone-fit scale computed on the transformed geometry; ±180° flips swap the attachment to the drawn far end so flips render), and the harness head-leaf overlay drawn along the bone's own direction instead of to the LookAt aim point. Verified with a 26-assertion headless smoke test.
1. **"The head bone has another bone extending out of it."** The harness bone overlay drew the Head leaf bone as a segment to `IK_Targets/Head` — the `SkeletonModification2DLookAt` aim point 232 px above the neck — a long line sticking out of the head. Fixed: the Head leaf now draws a ~90 px segment along the bone's own direction (`Vector2(length, 0)` rotated by `bone_angle` then `global_rotation`), so the head bone runs neck→head-top. Limb leaf bones (LeftLowerArm, RightLowerArm, LeftLowerLeg, RightLowerLeg) still draw to their IK targets.
2. **Duplicate Polygon2D + Line2D pairs per closed shape.** Closed shapes mounted as a `Polygon2D` fill **plus** a `Line2D` outline, producing two nodes per shape (e.g. `@Polygon2D@165` + `@Line2D@166`). Fixed: one node per shape — closed shapes mount as a single `Polygon2D`, open shapes as a single `Line2D` (width 2).
3. **Part rotation not applied.** The adapter ignored the per-part `rotation` (degrees) the user applied in the Whole Stickman preview — a torso rotated 180° looked identical to an unrotated one. Fixed: the mount now applies the preview's exact transform `E(P) = C + R(rot)·S·(P C)` (scale-then-rotate about the bbox center) **before** the hanging-convention mount, and the anchor/alignment θ/bone-fit scale are computed on the transformed geometry; rotations near ±180° (`|wrapf(rot)| > 0.75π`) additionally swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint).
4. **Part scale not applied / head too small.** The adapter ignored the per-part `scale`, so a head scaled ~2× in the editor (≈160 px face) rendered ~80×80 in the harness. Fixed: part scale composes into `E` (the head mounts upright with θ = 0, s = 1 but still applies the part scale via `E`, so the face mounts at ≈160 px and the chin sits on the neck joint); the bone-fit scale `s` is measured on the transformed extent, so user-scaled parts are not double-fitted.
### Fix Shape Mount Math & Point Scaling in StkRigAdapter.gd
Problem Summary:
When applying .stk v1.4 data to master*rig.tscn, shapes are severely distorted:Giant Polygon Explosions (Legs): Uniformly scaling points using scale_factor = bone_length / part_length on both $X$ and $Y$ multiplies the shape's thickness, turning thin leg segments into screen-filling blocks.Misaligned Joint Rotations (Head/Torso): Using (min + max) / 2 sets the pivot to the geometric center of the shape instead of its joint connection (e.g., neck base or hip joint).Required Fixes in StkRigAdapter.gd (or ActorFactory.gd):Please refactor \_mount_shapes() / point calculation logic using the following rules:1. Anisotropic Scaling (Primary Axis Only)Do not apply scale_factor to both axes. Only scale points along the bone's primary directional axis; leave cross-axis thickness at a $1.0$ scale multiplier:Arms (Primary Axis: $X$):$$\text{point}*{\text{local}}.x = (P*x - \text{anchor}\_x) \times \left(\frac{\text{upper_arm_length}}{\text{part_length}}\right)$$$$\text{point}*{\text{local}}.y = P*y - \text{anchor}\_y$$Legs / Torso (Primary Axis: $Y$):$$\text{point}*{\text{local}}.x = P*x - \text{anchor}\_x$$$$\text{point}*{\text{local}}.y = (P*y - \text{anchor}\_y) \times \left(\frac{\text{upper_leg_length}}{\text{part_length}}\right)$$Head: Keep unscaled ($1.0$ factor) on both axes:$$\text{point}*{\text{local}} = P - \text{anchor}$$(Guard against division by zero if part_length <= 0 by defaulting the scale factor to 1.0.)2. Joint-Based Anchor Alignment (Not BBox Center)Replace bounding-box midpoint anchors with joint origins so shapes rotate correctly around the bone joints:Head Anchor: Bottom-center of bounding box (x = (min_x + max_x) / 2, y = max_y).Torso & Legs Anchor: Top-center of bounding box (x = (min_x + max_x) / 2, y = min_y).Arms Anchor: Joint-end connection (x = min_x for right arms, x = max_x for left arms, y = (min_y + max_y) / 2).3. Node Transform ResetEnsure target Body/\* container nodes have their local scale reset to Vector2(1, 1) and rotation = 0 so Godot's node hierarchy does not multiply the geometry scaling a second time.Deliverable:Please update StkRigAdapter.gd to implement these corrected coordinate transform calculations and return the full updated GDScript.
When applying .stk v1.4 data to master*rig.tscn, shapes are severely distorted:Giant Polygon Explosions (Legs): Uniformly scaling points using scale_factor = bone_length / part_length on both $X$ and $Y$ multiplies the shape's thickness, turning thin leg segments into screen-filling blocks.Misaligned Joint Rotations (Head/Torso): Using (min + max) / 2 sets the pivot to the geometric center of the shape instead of its joint connection (e.g., neck base or hip joint).Required Fixes in StkRigAdapter.gd (or ActorFactory.gd):Please refactor \_mount_shapes() / point calculation logic using the following rules:1. Anisotropic Scaling (Primary Axis Only)Do not apply scale_factor to both axes. Only scale points along the bone's primary directional axis; leave cross-axis thickness at a $1.0$ scale multiplier:Arms (Primary Axis: $X$):$$\text{point}*{\text{local}}.x = (P*x - \text{anchor}\_x) \times \left(\frac{\text{upper_arm_length}}{\text{part_length}}\right)$$$$\text{point}*{\text{local}}.y = P*y - \text{anchor}\_y$$Legs / Torso (Primary Axis: $Y$):$$\text{point}*{\text{local}}.x = P*x - \text{anchor}\_x$$$$\text{point}*{\text{local}}.y = (P*y - \text{anchor}\_y) \times \left(\frac{\text{upper_leg_length}}{\text{part_length}}\right)$$Head: Keep unscaled ($1.0$ factor) on both axes:$$\text{point}*{\text{local}} = P - \text{anchor}$$(Guard against division by zero if part_length <= 0 by defaulting the scale factor to 1.0.)2. Joint-Based Anchor Alignment (Not BBox Center)Replace bounding-box midpoint anchors with joint origins so shapes rotate correctly around the bone joints:Head Anchor: Bottom-center of bounding box (x = (min_x + max_x) / 2, y = max_y).Torso & Legs Anchor: Top-center of bounding box (x = (min_x + max_x) / 2, y = min_y).Arms Anchor: Joint-end connection (x = min_x for right arms, x = max_x for left arms, y = (min_y + max_y) / 2).3. Node Transform ResetEnsure target Body/\* container nodes have their local scale reset to Vector2(1, 1) and rotation = 0 so Godot's
## Stickman editor (Phase 9 Round 4)
> FIXED — 2026 Round 4: implemented per docs/phase9_round4_bugfix_spec.md; the mounted head geometry is dropped +28 px (`HEAD_CHIN_DROP`) in rig space so the chin lands 28 px below the neck, aligned with the editor silhouette guide's head circle bottom and overlapping the torso. Verified with a 15-assertion headless smoke test.
1. **Head too high in the harness.** The head bone origin (neck) is at `y = 391.5`, and the mounted head's chin (its local origin) previously landed on that neck joint, so the head floated above the torso with no overlap. The editor's silhouette guide draws the head as a radius-100 circle centered at the Head joint `(0, 463.5)`, so the circle's bottom is `363.5` — 28 px below the neck, overlapping the torso's top region. Fixed: the mounted head points now receive a `Vector2(0.0, 28.0)` rig-space translation after the part transform and the (θ = 0, s = 1) transform, so the chin lands at world ≈ `(0, 363.5)` — the guide circle's bottom — overlapping the torso by 28 px, matching the editor guide. The drop is a rig-space fixture position (applied after part scale/rotation), so a flipped/rotated head drops identically. node hierarchy does not multiply the geometry scaling a second time.Deliverable:Please update StkRigAdapter.gd to implement these corrected coordinate transform calculations and return the full updated GDScript.
## Stickman editor (Phase 9 Round 5)
> ADDED — 2026 Round 5: implemented per docs/phase9_round5_bugfix_spec.md; the harness now reproduces the editor's guide-relative part placement **1:1**. The editor exports per-part `guide_offset` (bbox center guide joint, preview space; a pure master-space delta) as **write-only** metadata on save (`FILE_VERSION` bumped to `"1.5"`; the load path ignores the key). The head maps to the guide **Neck** joint — the head bone's rig attachment origin `(0, 391.5)` — **not** the circle center, which sits 72 px above the neck. `StkRigAdapter` applies the offset as a node-frame translation `t = (guide_offset + (A C)).rotated(c_node)` only when the key is present (A = the mount anchor incl. the 180° flip rule, C = the raw bbox center, `c_node` = the part's driver `RemoteTransform2D.global_rotation` at apply time); old files without the key keep the previous offset-0 behavior and the head falls back to the Round 4 `HEAD_CHIN_DROP`. **Note:** `.stk` files must be re-saved from the editor to populate the offsets — only files carrying `guide_offset` pick up the guide-relative placement in the harness.
## Stickman editor (Phase 9 Round 6)
> FIXED — 2026 Round 6: implemented per docs/phase9_round6_bugfix_spec.md; the mount anchor is now the transformed end nearest the part's stored guide joint when `guide_offset` is present. Verified with a 32-assertion headless smoke test.
1. **Lower-left-leg mounted 180° off its bone.** The leg's drawn knee was at the `min_x` end, but the per-side family rule anchored it at the `max_x` end, so the ankle attached at the joint and the shin hung backward. Root cause: the adapter picked the joint end with fixed per-side family rules (left limbs → `max_x`, right limbs → `min_x`) plus a 180° flip heuristic, but the user's drawn-side conventions are **inconsistent across parts** — no fixed rule can know which drawn end is the knee. Fix: when a part carries `guide_offset`, the anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is **nearest `center guide_offset`** (the part's guide joint) — the knee is now at the joint. Old files without the key keep the previous family rules.
2. **Lower-right-arm mounted 180° off its bone.** Same root cause mirrored on the right: the drawn elbow was at the `max_x` end, but the family rule anchored the arm at the `min_x` end, so the wrist attached at the joint and the forearm hung backward. Fix: the same nearest-end-to-guide-joint anchor selection puts the elbow at the joint. Old files without the key keep the previous rules.
## Stickman editor (Phase 9 Round 7)
> ADDED — 2026 Round 7: implemented per docs/phase9_round7_feature_spec.md; the test harness now exposes **six** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** — the marker's `RemoteTransform2D` translates the hip bone and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put (dragging the figure away from them stretches the limbs toward the stationary targets, per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). Verified with a 17-assertion headless test (Torso moved by (60, 40) → `Skeleton2D/Torso` and `Body/*` translate by exactly (60, 40); Head marker moved → Head bone + `Body/Head` rotate).
+25 -13
View File
@@ -231,7 +231,7 @@ The Whole Stickman preview treats all shapes in a body-part panel as **one combi
2. Select a `.stk` file.
3. On success, all body-part panels and the Whole Stickman preview are populated. On failure, an error dialog reports the problem (missing file, parse error, or unsupported version).
> v1.0, v1.1, v1.2, and v1.3 files are automatically migrated to v1.4 on load (v1.0/v1.1 single shapes wrapped in a `shapes` array, rotation defaults to 0, scale defaults to (1,1); files without `part_order` fall back to the default part order). `pivot`, `length`, and `proportions` are **write-only** metadata recomputed from live shapes on every save — never read back — so old files load unchanged and gain these keys on their next save.
> v1.0, v1.1, v1.2, v1.3, and v1.4 files are automatically migrated to v1.5 on load (v1.0/v1.1 single shapes wrapped in a `shapes` array, rotation defaults to 0, scale defaults to (1,1); files without `part_order` fall back to the default part order). `pivot`, `length`, `proportions`, and `guide_offset` are **write-only** metadata recomputed from live shapes on every save — never read back — so old files load unchanged and gain these keys on their next save.
### 13. Clear
@@ -251,7 +251,7 @@ Phase 9 adds the **runtime pipeline** that turns a saved `.stk` file into a live
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()``spawn_from_data()`. Returns `null` on empty data. |
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `Node2D` ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script).
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `Node2D` ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script); the mounted head is dropped `HEAD_CHIN_DROP` (28 px) below the neck so its chin aligns with the pose-guide circle's bottom and overlaps the torso (Phase 9 Round 4).
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
@@ -259,9 +259,9 @@ The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
- **Loaded filename** — status label showing the currently loaded file.
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
- **IK drag** — click and drag any of the 4 limb `Marker2D` IK targets (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg`); the scene's `SkeletonModificationStack2D` TwoBoneIK flexes the limb live. The harness enables the modification stack after each spawn.
- **IK drag** — click and drag any of the **6** `Marker2D` IK handles (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` flex the limb via TwoBoneIK; `IK_Targets/Torso` translates the whole rig rigidly via its `RemoteTransform2D`; `IK_Targets/Head` drives the head's `SkeletonModification2DLookAt` aim rotation) (Phase 9 Round 7). The harness enables the modification stack after each spawn.
Debug overlay (a world-space `Node2D` `_draw()`): bone lines drawn between each `Bone2D` global origin and its parent's (color-coded left cyan / right orange / central white, with joint dots) when **Show Bones** is on; colored markers at the four IK targets when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
## File format (`.stk`)
@@ -269,7 +269,7 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
```json
{
"version": "1.4",
"version": "1.5",
"stickman_name": "Bob",
"part_order": [
"head",
@@ -308,7 +308,8 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
"rotation": 0.0,
"scale": { "x": 1.0, "y": 1.0 },
"pivot": { "x": 122.0, "y": 39.5 },
"length": 10.0
"length": 10.0,
"guide_offset": { "x": 122.0, "y": 431.0 }
}
},
"metadata": {
@@ -322,7 +323,7 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
| Key | Type | Description |
|---|---|---|
| `version` | `string` | Format version. Currently `"1.4"`. Loading supports `"1.0"``"1.4"` (auto-migrated). |
| `version` | `string` | Format version. Currently `"1.5"`. Loading supports `"1.0"``"1.5"` (auto-migrated). |
| `stickman_name` | `string` | Optional display name for the figure. |
| `part_order` | `array[string]` | **Phase 5.** Render/Z-order of parts in the Whole Stickman preview, front-to-back semantics per array position (first = back, last = front). Absent on v1.0v1.2 files; defaults to the internal part-key order when missing. |
| `proportions` | `object` | **Phase 8.** Rig bone lengths used by the runtime `StkRigAdapter`. Object with 5 float keys: `upper_arm_length` (168.0), `lower_arm_length` (200.0), `upper_leg_length` (200.0), `lower_leg_length` (200.0), `torso_length` (391.5). Hardcoded **master-rig rest-pose constants** (from `master_rig.tscn`), not measured from the user's shapes. Write-only metadata — never read back on load. |
@@ -346,6 +347,7 @@ Each body part is an object with the following keys:
| `scale` | `object` | `{ "x": float, "y": float }` scale factors relative to created size. `1.0` = original size. May be **negative** (Phase 5) to represent mirroring along an axis. Defaults to `{ "x": 1.0, "y": 1.0 }` for older files. |
| `pivot` | `object` | **Phase 8.** `{ "x": float, "y": float }` — the **local rotation origin**, i.e. the bounding-box center of all shape points in the part's local drawing space (before `position` is applied). Always written for all 10 parts; `{ "x": 0.0, "y": 0.0 }` for an empty part. Write-only metadata — never read back on load. |
| `length` | `float` | **Phase 8.** The part's bounding-box extent along its **segment axis**, in local pixels: **width** (`max_x - min_x`) for the 4 arm parts, **height** (`max_y - min_y`) for torso, legs, and head. `0.0` for an empty part. Write-only metadata — never read back on load. |
| `guide_offset` | `object` | **Phase 9 Round 5.** `{ "x": float, "y": float }` — the part's **bbox center in preview space minus its silhouette-guide joint in preview space** (a pure master-space delta; the panel-size terms cancel). The adapter uses it to reproduce the editor's guide-relative placement in the harness. Write-only metadata — never read back on load; v1.0v1.4 files load unchanged and gain the key on their next save. |
### Shape object
@@ -396,13 +398,13 @@ Behavior:
| `res://project.godot` | Engine config; sets main scene to the editor and enabled features. |
| `res://scenes/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.4 serialization with multi-shape/rotation/scale, `part_order`, and Phase 8 `proportions`/`pivot`/`length`, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Round 1 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), neutralizing the `RemoteTransform2D` driver rotations (`update_rotation = false` on the 10 `Body/*` drivers), and mounting the `.stk` shapes onto the `Body/*` visual nodes (open shapes → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted), derives **joint-based anchors** per part family (head bottom-center, torso/legs top-center, arms shoulder-end), applies **anisotropic scaling** along the bone's primary axis only (arms X, legs/torso Y; head unscaled; divide-by-zero guard → `1.0`), and resets each `Body/*` container's scale to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. **Not used by the editor** — consumed by the runtime pipeline. |
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 46 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001``1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A C)).rotated(c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. |
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn` and calls `StkRigAdapter.apply(stk_data, rig)`; `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws bone lines and IK-target markers; the 4 limb `Marker2D` IK targets are click-draggable, flexing limbs live via `SkeletonModificationStack2D` TwoBoneIK (enabled after each spawn). |
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (enabled after each spawn), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels. |
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
| `res://stick.tscn` | **Legacy** rigged/animated stick figure scene (Skeleton2D + IK). Not used by the editor. |
| `res://AGENTS.md` | Guidance for AI agents working in this codebase. |
@@ -484,6 +486,16 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
> **Phase 9:** Adds the **runtime pipeline** for turning a `.stk` file into a live, rigged `master_rig.tscn` instance, plus a standalone staging scene to debug it. A new `res://scripts/stickman_factory.gd` (`class_name StickmanFactory`) provides the runtime entry point: `load_stk(path)` reads/parses a `.stk` (`FileAccess` + `JSON.parse_string`, `{}` + `push_warning` on failure), `spawn_from_data(stk_data)` instantiates `master_rig.tscn` and applies `StkRigAdapter.apply(stk_data, rig)`, and `spawn(path)` chains them (`null` on empty data). `StkRigAdapter` is extended to also fit the head bone (`Head.position.y = -proportions.torso_length`) and to mount the head as **full geometry** like every other part — clearing its inline `@tool` circle script and mounting `.stk` head shapes as `Line2D`/`Polygon2D` (removed the dead `_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` helpers). A new standalone scene/resource pair, `res://scenes/test_harness.tscn` + `res://scripts/test_harness.gd` (run via **F6**), loads `.stk` files (open dialog + quick-select for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), toggles a world-space debug overlay (bone lines + IK-target markers), pans/zooms a `Camera2D`, and lets you click-drag the 4 limb IK targets with live TwoBoneIK flexing (`SkeletonModificationStack2D` enabled on spawn). Neither the factory nor the harness is wired into the editor. **No `.stk` format change** — `FILE_VERSION` stays `"1.4"`.
> **Phase 9:** Adds the **runtime pipeline** for turning a `.stk` file into a live, rigged `master_rig.tscn` instance, plus a standalone staging scene to debug it. A new `res://scripts/stickman_factory.gd` (`class_name StickmanFactory`) provides the runtime entry point: `load_stk(path)` reads/parses a `.stk` (`FileAccess` + `JSON.parse_string`, `{}` + `push_warning` on failure), `spawn_from_data(stk_data)` instantiates `master_rig.tscn` and applies `StkRigAdapter.apply(stk_data, rig)`, and `spawn(path)` chains them (`null` on empty data). `StkRigAdapter` is extended to also fit the head bone (`Head.position.y = -proportions.torso_length`) and to mount the head as **full geometry** like every other part — clearing its inline `@tool` circle script and mounting `.stk` head shapes as `Line2D`/`Polygon2D` (removed the dead `_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` helpers). A new standalone scene/resource pair, `res://scenes/test_harness.tscn` + `res://scripts/test_harness.gd` (run via **F6**), loads `.stk` files (open dialog + quick-select for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), toggles a world-space debug overlay (true bone segments — parent→child lines with joint dots, limb leaf bones drawn to their IK targets and the Head leaf along its own direction — plus IK-target markers), pans/zooms a `Camera2D`, and lets you click-drag the 4 limb IK targets with live TwoBoneIK flexing (`SkeletonModificationStack2D` enabled on spawn). Neither the factory nor the harness is wired into the editor. **No `.stk` format change** — `FILE_VERSION` stays `"1.4"`.
> **Phase 9 Round 1 bugfix:** `StkRigAdapter._mount_shapes()` no longer trusts the file's `pivot`/`length`; it recomputes each part's bounding box at mount time and applies **joint-based anchors** (head bottom-center, torso/legs top-center, arms shoulder-end) with **anisotropic scaling** along the bone's primary axis only (arms X, legs/torso Y, head unscaled, divide-by-zero guard → `1.0`). `Body/*` container transforms are reset (scale `(1,1)`, rotation `0`, position untouched) and `_neutralize_driver_rotations()` sets `update_rotation = false` on the 10 `RemoteTransform2D` drivers before mounting. Per docs/phase9_round1_bugfix_spec.md; verified with a 37-assertion headless smoke test.
> **Phase 9 Round 2 bugfix:** `StkRigAdapter._mount_shapes()` no longer trusts the file's `pivot`/`length`; it recomputes each part's bounding box at mount time and mounts geometry in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) with a per-family alignment rotation (head `0`, torso `π`, left limbs `−π/2`, right limbs `+π/2`, vertical-drawn limbs `0`). Anchors: head/torso bottom-center, left horizontal limbs `max_x`, right horizontal limbs `min_x`, vertical limbs top-center. Scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. Driver rotation neutralization was **removed** — the 10 `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. `Body/*` container transforms are reset (scale `(1,1)`, rotation `0`, position untouched), the Head driver's local position is zeroed so the chin lands on the neck joint, and `DEFAULT_LINE_WIDTH` is 2.0 (editor's 2 px outline). The harness `_draw_bones()` was rewritten to draw true parent→child bone segments with leaf bones out to their IK targets (wrist/ankle joints now visible). Per docs/phase9_round2_bugfix_spec.md; verified with a 46-assertion headless smoke test.
> **Phase 9 Round 3 bugfix:** `StkRigAdapter._mount_shapes()` now reads each part's `rotation` (degrees, default 0.0) and `scale` (`{x,y}`, default `(1,1)`) and composes the preview's part transform `E(P) = C + R(rot)·S·(P C)` (scale-then-rotate about the bbox center) **before** the hanging-convention mount — the anchor, alignment θ, and bone-fit scale are computed on the transformed geometry, so every rotation/scale the user applied in the Whole Stickman preview renders in the harness. Rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (the 180° torso shows its drawn neck end at the hip joint); the bone-fit scale `s = bone_length/|V|` is measured on the transformed extent so user-scaled parts are not double-fitted. The head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px), chin at the neck joint. `_mount_shape()` mounts **one node per shape** — closed → single `Polygon2D` (fill only), open → single `Line2D` (width 2) — removing the previous Polygon2D + Line2D outline duplication. The harness head-leaf overlay now draws along the bone's own direction (the LookAt aim point is not a joint) instead of a line to `IK_Targets/Head`, eliminating the bone sticking out of the head. Per docs/phase9_round3_bugfix_spec.md; verified with a 26-assertion headless smoke test.
> **Phase 9 Round 4 bugfix:** the mounted **head** was too high in the harness — its chin (the local origin) landed on the neck joint at `y = 391.5`, floating above the torso. The editor's silhouette guide draws the head as a radius-100 circle centered at the Head joint `(0, 463.5)` (bottom at `363.5`), so `StkRigAdapter` now applies a `+28 px` rig-space translation (`const HEAD_CHIN_DROP := 28.0`) to the mounted head points after the part transform and the `(θ = 0, s = 1)` transform. The chin lands at world ≈ `(0, 363.5)` — the guide circle's bottom — overlapping the torso (top at `391.5`) by 28 px, matching the editor guide. The drop is flip-agnostic (a rig-space fixture applied after part scale/rotation). Per docs/phase9_round4_bugfix_spec.md; verified with a 15-assertion headless smoke test.
> **Phase 9 Round 5:** the `.stk` export evolved to **v1.5** with per-part **`guide_offset`** — the part's bbox center minus its silhouette-guide joint (both in preview space, a pure master-space delta). `StkRigAdapter` applies it only when present as a node-frame translation `t = (guide_offset + (A C)).rotated(c_node)` (A = the mount anchor incl. the 180° flip rule, C = the raw bbox center, `c_node` = the part's driver `RemoteTransform2D.global_rotation` at apply time), so the harness reproduces the editor's guide-relative placement **1:1**; old files without the key keep the previous offset-0 behavior and the head falls back to the Round 4 `HEAD_CHIN_DROP`. The head maps to the guide **Neck** joint — the head bone's rig attachment origin, **not** the circle center (which sits 72 px above the neck). `guide_offset` is **write-only** metadata like `pivot`/`length` — the load path ignores it, so v1.0v1.4 files load unchanged and gain the key on their next save. **Note:** `.stk` files must be re-saved from the editor to populate the offsets. Per docs/phase9_round5_bugfix_spec.md.
> **Phase 9 Round 6 bugfix:** `StkRigAdapter._compute_mount_transform()` now selects the joint anchor as whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is **nearest the part's stored guide joint** (`center guide_offset`) when a part carries `guide_offset`. This replaces the per-side family choice and the 180° flip heuristic for that case, fixing the **lower left leg** and **lower right arm**, which were mounted 180° off their bones (the far end attached at the joint) because the user's drawn-side conventions are inconsistent across parts — the stored guide placement is the ground truth for which drawn end is the joint. The nearest-end rule naturally preserves the 180° flip behavior (a flipped part's far end lands nearest the joint), the head chin, and every previously-correct case. Old files without the key keep the family rules + flip heuristic exactly as before. `theta`, `s`, the Round 5 offset `t`, and the head `HEAD_CHIN_DROP` fallback are unchanged. Per docs/phase9_round6_bugfix_spec.md; verified with a 32-assertion headless smoke test.
> **Phase 9 Round 7:** the test harness (`scripts/test_harness.gd`) now exposes **6** draggable IK handles. `IK_HANDLE_PATHS` gains `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone). Dragging the **Torso** handle moves **bones only** (no target following) — the marker's `RemoteTransform2D` translates the hip bone, and the whole skeleton + `Body/*` visuals follow rigidly, while the limb/head targets stay put so dragging the figure away from them stretches the limbs toward the stationary targets (per user decision). Dragging the **Head** handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint); `Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`) and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK overlay additionally draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (visual aid for the LookAt test). **No `.stk` format change.** Per docs/phase9_round7_feature_spec.md; verified with a 17-assertion headless test.
+191
View File
@@ -0,0 +1,191 @@
# Phase 9 Round 2 — Bugfix: Bone Visibility, IK Shape Following, Torso/Head Mount in the Harness
## Overview
Testing `break.stk` in the test harness revealed four defects. This round fixes all four with
changes confined to `scripts/stk_rig_adapter.gd` (mount pipeline) and `scripts/test_harness.gd`
(debug bone overlay). Root causes were established with a headless probe of the live rig
(spawned via `StickmanFactory` with the modification stack enabled, 2 frames).
### Root causes (verified)
| # | Symptom | Root cause (probe-verified) |
|---|---|---|
| 1 | Lower-limb bones/joints invisible in "Show Bones" | `test_harness.gd` `_draw_bones()` draws parent-**origin** → bone-origin lines. For lower bones the parent origin is the *hip/shoulder* (upper bone origin), so the lower segment duplicates the upper segment; wrist/ankle joints are never drawn (they are bone *ends*, not origins). |
| 2 | IK only moves lower-arm shapes; upper-arm shapes static | Round 1's `_neutralize_driver_rotations()` sets `update_rotation = false` on the 10 `RemoteTransform2D` drivers. Probe: every `Body/*` node shows `rot_deg = 0.00` while its bone rotates. Upper-limb visuals sit at fixed joints (shoulder/hip origins don't move) → static; lower-limb visuals *translate* with the elbow/knee but never rotate → appear "attached to the upper bones". |
| 3 | Torso top connects at the hip joint | With driver rotation neutralized, the torso (anchor top-center, driver rotation π suppressed) hangs with its top at the hip-driven `Body/Body` origin. The user's torso is drawn hip-at-bottom (`break.stk` torso rect y∈[209,308], hip end at 308) and must mount with the **hip end at the origin**, extending up. |
| 4 | Head features wrong scale / too thick / nose not triangular | `DEFAULT_LINE_WIDTH = 16.0` vs the editor's 2 px outline (`body_part_panel.gd:435`). Small eye/nose/mouth shapes get 16 px outlines that dominate their geometry. Additionally the `Head` `RemoteTransform2D` has a local position offset `(0.05, 72)` designed for the old 100-px-radius circle, so the mounted head floats 72 px above the neck joint. |
---
## 1. The rig's visual convention (verified numerically)
The `RemoteTransform2D` drivers push **global** position + rotation onto the `Body/*` nodes.
With rotation pushes enabled, each `Body/*` node's world rotation = `bone_pose_rotation +
RT_local_rotation`. The authored RT local rotations are:
| Part | RT local rotation | Bone pose → visual direction at rest/IK (probe-verified) |
|---|---|---|
| `Body/Body` (torso) | `π` (3.1415927) | hanging geometry `(0,+Y)` → world `Y` (up) ✓ |
| `Body/LeftUpperArm` | `+π/2` | hanging → world `X` (left arm outward) ✓ |
| `Body/RightUpperArm` | `−π/2` | hanging → world `+X` (right arm outward) ✓ |
| `Body/LeftLowerArm` | `π` | hanging → world `Y` (forearm direction) ✓ |
| `Body/RightLowerArm` | `−π/2` | hanging → world `Y` (forearm direction) ✓ |
| `Body/LeftUpperLeg` / `RightUpperLeg` | `0` | hanging → world toward the knee ✓ |
| `Body/LeftLowerLeg` / `RightLowerLeg` | `−π/2` | hanging → world toward the ankle ✓ |
| `Body/Head` | `0` | upright (no rotation needed) ✓ |
**Consequence:** mounted geometry must be authored in the rig's "hanging" convention —
the joint end at the local origin, the far end along local `+Y` — and the drivers rotate it
correctly **in any pose**, so IK flexing follows the bones exactly. (This replaces the Round 1
"clean unrotated frame + rotation neutralization" approach, which is what broke IK following.)
## 2. Fix 1 — `StkRigAdapter` mount pipeline (rotation compensation by hanging convention)
### 2a. Remove driver neutralization
Delete `_neutralize_driver_rotations()` and its call in `apply()`. The drivers keep their
defaults (`update_position/rotation/scale = true`).
### 2b. Mount transform (per part)
For each part compute the bbox over all shape points. Let `cx = (min_x + max_x) / 2`,
`cy = (min_y + max_y) / 2`, `width = max_x min_x`, `height = max_y min_y`,
`long_axis_is_x = width >= height`, `extent = max(width, height)`.
**Anchor `J`, scale `S` (drawn space), and alignment rotation `θ`:**
| Part family | Anchor J | Scale S | θ (maps far end to local +Y) |
|---|---|---|---|
| Head | `(cx, max_y)` (bottom-center = chin) | `(1, 1)` | `0` (upright, extends `Y`) |
| Torso | `(cx, max_y)` (bottom-center = hip end) | `(1, s)` | `π` (drawn extends up → rotated to hang down; driver π flips it back up) |
| Left limbs (arms + legs), long axis X | `(max_x, cy)` (joint at right end) | `(s, 1)` | `−π/2` (far end `X``+Y`) |
| Right limbs, long axis X | `(min_x, cy)` (joint at left end) | `(s, 1)` | `+π/2` (far end `+X``+Y`) |
| Legs drawn vertically (long axis Y) | `(cx, min_y)` (hip at top) | `(1, s)` | `0` (already hanging) |
| Arms drawn vertically (long axis Y) | `(cx, min_y)` (shoulder at top) | `(1, s)` | `0` (already hanging) |
where `s = bone_length / extent` with `bone_length` from `_bone_length_for()` (unchanged),
guarded: `extent <= 0.0001 → s = 1.0`. Head: no scale, no rotation.
**Point transform** (replaces `_transform_points` math):
```
q = (P J) ⋅ S # scale along the drawn long axis only (cross axis 1:1)
pt_local = q.rotated(θ) # rotate into the hanging frame
```
Notes:
- This preserves the Round 1 anisotropic-scaling requirement in effect: **only the long axis is
scaled** (arms/legs = the drawn segment axis), cross-axis thickness stays 1:1. For
horizontally-drawn arms it is mathematically identical to Round 1's `x·(bone/part_length)`.
- `break.stk`'s legs are drawn **horizontally** (long axis X, extent ≈101), so under Round 1's
literal "legs scale Y" they mounted as 101-px-thick bars. The long-axis rule fixes this:
legs become ~200 long × 28 thick — matching the drawn/assembled proportions 1:1.
- The torso pre-rotation π + the driver's π cancel at rest, so the mounted torso appears with
the hip end at the hip joint and the top at `torso_length` (the neck) — exactly where the
user assembled it (their preview scale 3.99 ≈ 391.5/99 = 3.955).
### 2c. Head joint placement
The `Head` driver (`Skeleton2D/Torso/Head/RemoteTransform2D`) has local position
`(0.0503, 72.0)` — authored to center the old 100-px circle 72 px above the neck. In
`_fit_bones()` (head section), **zero the driver's local position** so `Body/Head` sits on the
neck joint. The mounted head (chin at local origin) then lands with the chin on the neck and
rotates about the neck with the head bone's LookAt.
### 2d. Line width
`DEFAULT_LINE_WIDTH := 2.0` (was 16.0) — matches the editor's outline width
(`body_part_panel.gd:435` `width: float = 2.0`), so eyes/nose/mouth/cap render 1:1.
### 2e. Keep
`_reset_node_transform()` (scale `(1,1)`, rotation `0` before mounting — drivers overwrite each
frame anyway), head `set_script(null)`, `_fit_bones` bone fitting, `_recalibrate_ik`,
`_bone_length_for`, node-path constants, bbox recompute (file `pivot`/`length` still ignored).
## 3. Fix 2 — `test_harness.gd` bone overlay
Rewrite `_draw_bones()`:
1. For each bone (all 10): draw a joint dot at `bone.global_position`.
2. For each **Bone2D child** of the bone: draw a line `origin → child.global_position`
(true bone segment; works for nested lower bones).
3. For **leaf bones** (no Bone2D child): draw a line from the origin to the bone's
corresponding IK target (map: `LeftLowerArm → IK_Targets/Left_Hand`,
`RightLowerArm → IK_Targets/Right_Hand`, `LeftLowerLeg → IK_Targets/Left_Leg`,
`RightLowerLeg → IK_Targets/Right_Leg`, `Head → IK_Targets/Head`) — shows the forearm/shin
segments and the wrist/ankle joints; falls back to
`origin + R(global_rotation) · (length, 0)` when the target node is missing.
4. Keep the existing colors/widths (`_bone_color`).
Result: 9 distinct segments (spine, neck-head, both arms ×2, both legs ×2) + 10 bone-origin
dots + target joints — lower limbs clearly visible.
## 4. Files modified
| File | Changes |
|---|---|
| `scripts/stk_rig_adapter.gd` | Remove `_neutralize_driver_rotations()` + call; new anchor/scale/rotation table in `_mount_shapes()` + `_compute_anchor`/`_compute_scale`/rotation helpers; `DEFAULT_LINE_WIDTH = 2.0`; zero the Head driver's local position in `_fit_bones()`; update doc comments. |
| `scripts/test_harness.gd` | Rewrite `_draw_bones()` (child-direction segments + leaf→IK-target segments + dots). |
| `docs/phase9_round2_bugfix_spec.md` | This file. |
## 5. Edge cases
- Empty part → skip (unchanged). `extent <= 0``s = 1`.
- Vertically-drawn legs/arms → θ = 0 (hanging already); horizontally-drawn → ±π/2.
- Torso drawn with hip at top (not bottom) would mount flipped — accepted assumption
(mirrors Round 1's shoulder-inward assumption); `break.stk` has hip at bottom.
- Multi-shape parts: bbox over all shapes (unchanged).
- Old files without `pivot`/`length`: unaffected (bbox recomputed).
- Missing driver/target nodes: null-guarded warnings (unchanged pattern).
## 6. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless smoke test (SceneTree, spawn `break.stk` via `StickmanFactory`, enable the
modification stack, wait 2 frames), asserting:
- No `update_rotation == false` on any of the 10 drivers.
- `Body/*` world rotations ≈ their bone's world rotation + RT local rotation (e.g.
`Body/LeftUpperArm.global_rotation ≈ LeftUpperArm.global_rotation + π/2`).
- Mounted geometry hangs: `Body/LeftUpperLeg` shape points start at the node origin and
extend toward `+Y` local, with Y extent ≈ 200 (bone length) and X extent ≈ 28 (thickness).
- `Body/Body` (torso) shape Y extent ≈ 391.5, X extent ≈ 35.
- `Body/Head` shape chin point ≈ local origin; the Head driver position == (0,0);
mounted Line2D width == 2.0.
- Bones overlay data: 10 bones; each lower bone's parent is the upper bone (sanity).
- Then SET a pose override or drag an IK target (move `IK_Targets/Left_Hand`),
await a frame, and assert `Body/LeftUpperArm.global_rotation` **changed** with the bone
(upper arm follows) and `Body/LeftLowerArm.global_position` moved with the elbow.
3. Harness manual check (F6): lower limb bones + wrist/ankle joints visible; drag hand IK →
upper arm shape rotates at the shoulder and lower arm shape flexes at the elbow.
4. Cleanup: delete the temporary test script.
## 7. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Restore driver rotation pushes; drop `_neutralize_driver_rotations()` | Probe-verified root cause of the IK-following bug; the hanging convention + driver rotations track bones in every pose. |
| D2 | Mount geometry in the rig's hanging frame (joint at origin, far end `+Y`) with a per-part alignment rotation | Makes the mount pose-agnostic; no per-part hardcoded compensation table needed. |
| D3 | Scale only the drawn long axis (auto-detected); cross axis 1:1 | Fixes `break.stk`'s horizontally-drawn legs (101-px bars → 200×28) and keeps Round 1's anisotropic-scaling intent. |
| D4 | Torso: anchor bottom-center + θ = π | Hip end at the hip joint; top lands at the neck, matching the user's assembled torso (preview scale ≈ 391.5/99). |
| D5 | Head: zero the Head driver's local position | Chin lands on the neck joint; head rotates about the neck with LookAt. |
| D6 | `DEFAULT_LINE_WIDTH = 2.0` | Matches the editor's 2 px outline — features render 1:1. |
| D7 | Harness overlay: parent→child segments + leaf→IK-target segments | Shows true lower-limb bones and wrist/ankle joints. |
## 8. Open questions — RESOLVED (user-approved)
1. **Legs drawn horizontally (break.stk).****Auto-detect the long axis** (rotate to hang,
scale the long axis) — replaces Round 1's fixed "legs = Y axis".
2. **Figure pose = rig rest/IK pose.****Rig pose, shapes 1:1** — part transforms remain
v1-ignored; the drawn shapes are fitted 1:1 onto the rig's bones.
## 9. Implementation order
1. `stk_rig_adapter.gd` — remove driver neutralization; new anchor/scale/θ helpers.
2. `stk_rig_adapter.gd` — rewrite `_mount_shapes()`/`_mount_shape()`/`_transform_points()`;
`DEFAULT_LINE_WIDTH = 2.0`; zero Head driver position.
3. `test_harness.gd` — rewrite `_draw_bones()`.
4. Headless smoke test + parse check.
5. Docs (`BUGS.md` Round 2 note, `AGENTS.md`, `README.md`).
+213
View File
@@ -0,0 +1,213 @@
# Phase 9 Round 3 — Bugfix: Overlay Head Bone, One Node Per Shape, Part Rotation/Scale in the Harness
## Overview
Three defects remain after Round 2 (user report, testing `break.stk` in the harness):
1. **"The head bone has another bone extending out of it"** — the harness bone overlay draws the
Head leaf bone as a segment to `IK_Targets/Head` (0, 624), the `SkeletonModification2DLookAt`
target — a long line sticking out of the head. It should draw the head bone along its own
direction instead.
2. **Duplicate nodes per closed shape**`_mount_shape()` mounts closed shapes as a `Polygon2D`
fill **plus** a `Line2D` outline (e.g. `Body/Head/@Polygon2D@165` + `@Line2D@166`). Per the
user, closed shapes should mount as **just the Polygon2D** and open shapes as **just one
Line2D**.
3. **Part rotation & scale not applied** — the adapter ignores the per-part `rotation` (degrees)
and `scale` the user applied in the Whole Stickman preview. A torso rotated 180° looks
identical to an unrotated one; a head scaled ~2× in the editor (≈160×160 px) renders ~80×80
in the harness.
---
## 1. Root-cause analysis
### 1a. Overlay head segment (`scripts/test_harness.gd`)
`_draw_bones()` treats every leaf bone identically: draw to its IK target. For the four limbs
the targets ARE the wrist/ankle joints (correct). For the Head the target is a LookAt aim point
232 px above the neck — drawn as a "bone" it looks broken. The Head bone is authored with
`length = 90`, `bone_angle = 90` (points up), so its true tip is
`origin + Vector2(0, -length).rotated(global_rotation)` — a 90 px segment inside the head.
**Fix:** special-case the Head leaf — draw along the bone's own direction; keep the limb leaf
bones drawing to their IK targets.
### 1b. Duplicate nodes (`scripts/stk_rig_adapter.gd` `_mount_shape`)
Closed shapes mount `Polygon2D` + closed `Line2D` outline (Round 1 mirrored the editor's
fill+outline rendering). The user wants one node per shape: closed → `Polygon2D` only; open →
`Line2D` only. `DEFAULT_LINE_WIDTH = 2.0` still applies to open lines.
### 1c. Part rotation/scale (`scripts/stk_rig_adapter.gd` mount pipeline)
The `.stk` part dict carries `rotation` (degrees, about the part bbox center) and `scale`
(about the bbox center) from the Whole Stickman preview
(`whole_stickman_preview.gd:500-506`: `world = C + R(rot)·S·(pt C)`, `C =` bbox center). The
adapter currently mounts the raw drawn geometry, ignoring both. The user's requirement: the
shapes must appear in the harness exactly as rotated/scaled in the editor (the guide in the
editor IS the rig's rest pose).
**Full application is possible without detaching limbs** — the earlier "flip-only" concern was
based on computing the alignment on the *raw* geometry and then rotating on top, which
double-rotates. The correct composition (per user direction) is:
1. Apply the part transform **first**: `Q = E(P) = C + R(rot)·S·(P C)` for every point
(scale then rotate about the bbox center — the editor's exact transform).
2. Compute the anchor and alignment **on the transformed geometry**:
- `J' = E(J_raw)` (the drawn joint end, transformed).
- `F' = E(J_raw + F_raw) J' = R(rot)·S·F_raw` (the transformed far vector).
3. Alignment `θ = F'.normalized().angle_to(Vector2.DOWN)` — rotates the fitted long axis onto
the bone. Because the user aligned the shape to the guide (the bone direction), θ is the
residual between their drawn orientation and the bone; the shape appears in the harness as
in the editor, attached at the joint. 90° rotations (break.stk's lower limbs — sideways
drawn bars turned vertical) work: `F'` points along the fitted limb, `θ` keeps it hanging,
and the limb stays connected to the elbow/knee.
4. **180° flips** (rotations whose wrapped value is within ±45° of 180°): a flip about the
center swaps the ends of the shape. In the editor the flipped shape shows its drawn far end
at the joint region, so the harness attaches the **drawn far end** at the joint:
- `flipped = |wrapf(rot_rad, -PI, PI)| > PI * 0.75`
- if flipped: anchor `A = E(far_raw_end)`, far vector `V = F'`; else `A = J'`, `V = F'`.
The flipped part then hangs the other way along the bone with its content turned around —
the rotation is visibly applied (e.g. the 180° torso shows its drawn neck end at the hip
joint and the drawn hip end up at the neck — matching the editor's flipped torso).
5. Bone fit scale `s = bone_length / V.length()` measured on the transformed extent (so a
user-scaled part is not double-fitted; `s ≈ 1` when the user already scaled to the bone
length — verified numerically for every `break.stk` part). Guards: `|V| <= 0.0001 → s = 1,
θ = 0`.
6. Mounted points: `v = R(θ)·(Q A)`; `v.y *= s`.
7. **Head special case (unchanged semantics):** `θ = 0`, `s = 1` (the head mounts upright,
chin at the origin, extending `Y`; the head driver has no rotation). The flip anchor rule
still applies (a 180° head attaches at its cap top — upside-down, chin up — matching the
editor). Part scale applies through `E` (head face ≈160 px: drawn 80 × 1.986).
---
## 2. Fix specification
### 2a. `scripts/test_harness.gd` — head leaf segment
In `_draw_bones()`, for the leaf bone `Head` (no Bone2D child): draw
`origin → origin + Vector2(0, -length).rotated(global_rotation)` instead of the IK-target
segment. Limb leaf bones (LeftLowerArm, RightLowerArm, LeftLowerLeg, RightLowerLeg) keep the
IK-target segments. Implement via a per-bone check (`bone.name == "Head"`) or by removing
`Head` from `LEAF_BONE_IK_PATHS` and handling it in the no-target fallback (the fallback
already draws `origin + R(global_rotation)·(length, 0)` — change it to use the bone's
`bone_angle` direction: `Vector2(length, 0).rotated(deg_to_rad(bone.bone_angle)).rotated(global_rotation)`;
for the Head (`bone_angle = 90`) that yields `(0, 90)` rotated by the pose — the desired
segment; for other bones `bone_angle` is 0 → `(length, 0)` — same as today).
### 2b. `scripts/stk_rig_adapter.gd` — one node per shape
`_mount_shape()`: closed → `Polygon2D` only (polygon + color; **no** outline Line2D); open →
`Line2D` (width `DEFAULT_LINE_WIDTH`). Delete the outline branch.
### 2c. `scripts/stk_rig_adapter.gd` — part transform composition
Read per-part `rotation` (float, **degrees**, default 0.0) and `scale` (`{x, y}`, default
`(1, 1)`) from the part dict. Apply in the mount pipeline:
1. Raw bbox `C` (center), raw anchor `J_raw`, raw far point `F_pt_raw = J_raw + F_raw`
(direction from the joint end to the far end, per the Round 2 family rules), as today.
2. `E(P) = C + R(rot_rad)·S_part·(P C)` — the preview's part transform, applied to every
point and to the anchor/far point: `J' = E(J_raw)`, `F_pt' = E(F_pt_raw)`,
`F' = F_pt' J'`.
3. `flipped = |wrapf(rot_rad, -PI, PI)| > PI * 0.75`.
4. Anchor `A` and far vector `V`: `A = F_pt'`, `V = F'` if flipped, else `A = J'`,
`V = F'`.
5. `θ = V.normalized().angle_to(Vector2.DOWN)` (0 if `|V| <= 0.0001`).
6. `s = bone_length / |V|` (head → 1.0; guards `|V| <= 0.0001 → 1.0`).
7. Mounted points: `v = R(θ)·(Q A)`; `v.y *= s`. Head: `θ = 0`, `s = 1` (upright mount,
flip anchor rule still applies).
**Example (torso, rot 180°, scale (0.857, 3.99)):** `E` flips the drawn torso; `J'` = the
transformed hip end (now the fitted top), `F_pt'` = the transformed neck end (fitted bottom);
flipped → `A` = the neck end; `V` points up → `θ = π`; `s ≈ 391.5/395`; the torso extends up
from the hip joint with the drawn **neck end attached at the hip** and the drawn hip end up at
the neck — the 180° rotation visibly applied (unrotated: hip end at the hip, neck at the
neck).
**Example (lower arm, rot 90°):** not flipped; `J'` = the elbow (fitted bottom); `F'` points
up (the editor's bent forearm) → `θ = π`; the forearm hangs along the bone from the elbow with
its content turned 90° — exactly as assembled in the editor.
**Scale check:** part scale composes into `E` and the fit `s` is measured on the transformed
extent → no double-fitting. Head: `s = 1` but `E` applies `1.986` → the face mounts at ≈160 px
(drawn 80 × 1.986).
### 2d. Keep (unchanged)
`_reset_node_transform`, head `set_script(null)`, head-driver zeroing, `_fit_bones`,
`_recalibrate_ik`, `_bone_length_for`, `_compute_part_bbox`, width 2.0, all node-path
constants, null guards.
---
## 3. Files modified
| File | Changes |
|---|---|
| `scripts/stk_rig_adapter.gd` | `_mount_shapes()` reads part `rotation`/`scale`; mount pipeline composes `E_full` + fitted anchor/θ/fit-scale + flip heuristic; `_mount_shape()` mounts one node per shape (closed → Polygon2D only). |
| `scripts/test_harness.gd` | `_draw_bones()` leaf fallback uses the bone's `bone_angle` direction (Head draws a 90 px segment inside the head; limbs unchanged). |
| `docs/phase9_round3_bugfix_spec.md` | This file. |
## 4. Open question — RESOLVED (user directive)
1. **Rotation semantics.** User directive: *the shapes must appear in the harness exactly as
rotated/scaled in the editor — that is the point of the guide.* ✅ **Full application**: the
part transform `E` is applied to the geometry first, and the mount (anchor + alignment +
fit) is computed on the transformed geometry, so every rotation is rendered. Rotations
align the drawn long axis onto the bone (a 90° rotation turns a sideways-drawn bar into the
limb as assembled — the limb stays attached to its joint; this does **not** detach limbs
because the anchor is the transformed joint end, not a raw-geometry point). 180° flips
additionally swap the attachment to the drawn far end (the end the user rotated into the
joint position), making flips visible.
## 5. Edge cases
- Rotation absent (old files) → 0.0; scale absent → (1, 1) — Round 2 behavior unchanged.
- Negative part scale (mirroring) → `E_full` mirrors the points; `θ` adapts; fine.
- `F'` ≈ 0 (degenerate) → `s = 1`, `θ = 0`, no flip effect.
- Flip + IK: the flip is baked into the mounted points, so IK flexing still follows the bones.
- Multi-shape parts: bbox/transform over all shapes (unchanged).
- `-360`-style rotations normalize via `wrapf` ✓.
## 6. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless smoke test (spawn `break.stk`, stack enabled, 2 frames):
- Every `Body/*` node's children: closed shapes → exactly one Polygon2D and zero Line2D
for that shape (count Polygon2D == number of closed shapes; Line2D count == number of
open shapes; no node has both for the same shape).
- Head: mounted face width ≈ 159160 px (bbox width 108 × 1.986 ≈ 215 incl. cap; the face
circle x-span ≈ 160), chin still at local origin.
- Legs/arms still hang along bones: `Body/LeftUpperLeg` far end ≈ (0, +~200) local after
`θ`+fit; thickness ≈ 24 (drawn 28 × part-scale 0.853).
- Flip: temporarily modify the stk dict — set `torso.rotation = 180.0` — re-apply to a
fresh rig, assert the drawn torso **neck end** now lands ≈ (0, 0) (hip joint) and the
drawn **hip end** ≈ (0, 391.5) (neck) — the ends swapped vs. rotation 0 (hip end at the
hip). Restore rotation 0 → hip end back at (0, 0).
- Overlay: `test_harness.gd` head-leaf drawing path exists (code review; the overlay draws
cannot be asserted headlessly — verified by review + manual F6).
- Regression: driver rotations enabled; IK-following still works (move Left_Hand target,
assert Body/LeftUpperArm rotation tracks bone + π/2).
3. Manual harness check (F6): no bone sticking out of the head; single nodes in the Remote
Inspector; torso flip visible after rotating 180° in the editor and saving.
## 7. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Head leaf overlay segment along the bone's own direction (`bone_angle`-aware fallback) | The LookAt target is an aim point, not a joint; the bone itself is neck→head-top. |
| D2 | One node per shape (closed → Polygon2D only) | User request; removes node duplication in the Body tree. |
| D3 | Apply part scale fully (via `E`) + measure bone-fit scale on the transformed extent | Reproduces the editor scale (head 160 px) without double-fitting bone-scaled parts. |
| D4 | Apply part rotation fully: `E` first, then anchor/alignment/fit on the transformed geometry; 180° flips swap the attachment to the drawn far end | User directive (editor fidelity); the transformed joint anchor keeps 90°-rotated limbs attached to their bones. |
| D5 | Flip shown via the anchor swap (no separate content rotation) | Matches the editor: the flipped shape's far end sits at the joint region; content turns around on the bone. |
## 8. Implementation order
1. `stk_rig_adapter.gd` — part `rotation`/`scale` read + `E_full`/`J'`/`F'`/`θ`/fit/flip in
`_mount_shapes()` + `_compute_mount_transform()` restructure.
2. `stk_rig_adapter.gd``_mount_shape()` single-node-per-shape.
3. `test_harness.gd` — head-leaf overlay segment.
4. Headless smoke test + parse check.
5. Docs (`BUGS.md` Round 3 note, `AGENTS.md`, `README.md`).
+72
View File
@@ -0,0 +1,72 @@
# Phase 9 Round 4 — Bugfix: Head Position vs. the Editor's Silhouette Guide
## Overview
User report: the head is **too high** in the harness. Reference: the editor's silhouette guide
head is a circle; the bottom of the mounted head shape must line up with the bottom of that
circle relative to the torso bone, so the head **overlaps the torso** some.
### Guide geometry (source of truth, `scripts/whole_stickman_preview.gd`)
| Constant | Value |
|---|---|
| `GUIDE_JOINTS["Neck"]` (`:61`) | `(0, 391.5)` — the Head bone origin |
| `GUIDE_JOINTS["Head"]` (`:62`) | `(0, 463.5)` — the head circle **center** |
| `GUIDE_HEAD_RADIUS` (`:49`) | `100.0` |
→ The guide's head circle bottom = `463.5 + 100 = 363.5`**28 px below the neck**,
overlapping the torso's top region (torso spans `0 → 391.5`) by 28 px.
### Current behavior (wrong)
Round 3 zeroes the Head driver's local position, so `Body/Head` sits on the Head bone origin
(the neck, `y = 391.5`); the mounted chin is the local origin → the chin lands at the neck
and the head floats above the torso with no overlap.
### Required behavior
The mounted head's bottom (chin) must land at `y = 363.5` — i.e. the mounted head geometry
gets a `+28 px` local Y offset so the chin sits 28 px below the neck joint, matching the
guide circle's bottom and overlapping the torso.
## Fix specification — `scripts/stk_rig_adapter.gd`
1. Add `const HEAD_CHIN_DROP := 28.0` with a doc comment deriving the value
(guide head circle bottom `363.5` neck `391.5` = 28; the guide circle has radius 100
centered at the Head joint `(0, 463.5)`).
2. In the head branch of the mount (where `θ = 0`, `s = 1`), after transforming the points,
translate them by `Vector2(0.0, HEAD_CHIN_DROP)` — the chin (local origin) ends up 28 px
below the node origin (the neck).
3. The flip anchor rule from Round 3 is unchanged (a flipped head still attaches at its cap
top; the drop applies identically).
4. Nothing else changes (no driver/overlay/serialization changes).
Note: the offset is applied **after** the part scale/rotation composition (it is a rig-space
fixture position, not part geometry), so a 180°-rotated head drops by the same 28 px.
## Files modified
| File | Changes |
|---|---|
| `scripts/stk_rig_adapter.gd` | `HEAD_CHIN_DROP` const + head-points translation. |
| `docs/phase9_round4_bugfix_spec.md` | This file. |
## Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless smoke test (spawn `break.stk`, stack enabled, 2 frames):
- `Body/Head` world position ≈ `(0, 391.5)` (node still at the neck).
- Mounted head chin point ≈ local `(0, +28)`; world chin ≈ `(0, 363.5)`.
- Head top ≈ world `(0, 363.5 219)``(0, 582)`; the head now overlaps the torso's
top region (torso top at 391.5) by ≈ 28 px.
- Regression: the head's other Round 3 properties (scale ≈160 face, one node per shape,
IK following, flip behavior) unchanged.
3. Manual harness check (F6): head bottom aligned with the guide circle's bottom; head
overlaps the torso.
## Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Fixed `+28 px` rig-space offset (not relative to the head's drawn size) | The guide circle is a fixed rig fixture; the requirement is to align to its bottom. |
| D2 | Offset applied to the mounted points (not the driver) | The Head driver's zeroing stays (rotation about the neck with the LookAt); a driver offset would swing with the bone rotation. |
+159
View File
@@ -0,0 +1,159 @@
# Phase 9 Round 5 — Feature: Guide-Relative Part Placement in the Harness
## Overview
User request: for the torso, legs, and arms, attach the shapes to the rig **relative to the
silhouette guide** in the editor — the harness stickman should represent how each part was
placed on the editor's guide **1:1** (the guide's joint positions are the reference).
### Current behavior
The adapter assumes perfect alignment: each part's joint end mounts exactly at the rig joint
(offset 0), so any placement offset the user applied in the Whole Stickman preview is lost.
### Design
The editor computes, at save time, each part's placement offset from its guide joint (a
size-independent delta in preview pixels = master pixels, since `GUIDE_SCALE = 1.0`) and
writes it as per-part write-only metadata. The adapter converts it to the joint-end offset and
translates the mounted geometry accordingly, in the bone's frame.
## 1. Guide reference geometry (`scripts/whole_stickman_preview.gd`)
`GUIDE_JOINTS` (`:59-73`) gives every joint in master space. The part→joint mapping:
| Part | Guide joint |
|---|---|
| `head` | `Neck` (the head bone origin / rig attachment, `(0, 391.5)` — NOT the circle center, which sits 72 px above the neck) |
| `torso` | `Hips` |
| `left_upper_arm` | `LeftShoulder` |
| `left_lower_arm` | `LeftElbow` |
| `right_upper_arm` | `RightShoulder` |
| `right_lower_arm` | `RightElbow` |
| `left_upper_leg` | `Hips` |
| `left_lower_leg` | `LeftKnee` |
| `right_upper_leg` | `Hips` |
| `right_lower_leg` | `RightKnee` |
## 2. Editor changes
### 2a. `scripts/whole_stickman_preview.gd`
Add a public method:
```gdscript
func get_guide_joint_preview(joint_name: String) -> Vector2:
if not GUIDE_JOINTS.has(joint_name):
push_warning("WholeStickmanPreview: unknown guide joint '%s'." % joint_name)
return Vector2.ZERO
return _guide_to_preview(GUIDE_JOINTS[joint_name])
```
### 2b. `scripts/stickman_editor.gd`
1. Add `const GUIDE_JOINT_FOR_PART: Dictionary` (the 10-row mapping above).
2. Bump `FILE_VERSION` `"1.4"``"1.5"`; add `"1.5"` to `SUPPORTED_VERSIONS`; update the
unsupported-version error message (`'1.0'..'1.5'`).
3. In `_collect_all_shape_data()`, for **each** part compute the guide offset:
```
var joint_preview := _whole_preview.get_guide_joint_preview(GUIDE_JOINT_FOR_PART[part_name])
var center_preview := pos + Vector2(float(pl["pivot"].x), float(pl["pivot"].y))
var guide_offset := center_preview - joint_preview
```
(the part's bbox **center** in preview space minus the guide joint in preview space — both
are preview-world coordinates, so the panel-size terms cancel and the delta is pure
master-space pixels) and store `"guide_offset": { "x": ..., "y": ... }` in the part dict
(uniform for all 10 parts).
4. `guide_offset` is **write-only** metadata like `pivot`/`length` — the load path ignores it
(`_apply_json_data` unchanged); v1.0v1.4 files load unchanged and gain the key on their
next save.
## 3. Adapter changes (`scripts/stk_rig_adapter.gd`)
1. Read `guide_offset` (`{x, y}`) from the part dict (default `null`/absent).
2. **Only when present** (old files keep the current offset-0 behavior), compute the anchor
offset: `delta = guide_offset + (A C)` where
- `A` = the mount anchor already computed (the transformed joint end `J'`, or the
transformed far end `F_pt'` when flipped — Round 3);
- `C` = the raw bbox center (the editor measured `guide_offset` from the bbox center).
3. Convert to the node frame: `t = delta.rotated(-c_node)` where `c_node` = the part's driver
`RemoteTransform2D.global_rotation` at apply time (the rig is not yet in the tree — this is
the authored/guide pose frame; the driver later maps local `+Y` onto the bone, so `t` is a
bone-relative placement, preserved as the bone flexes).
4. Apply `t` to the mounted points (final translation, after `E`/θ/scale/flip; independent of
the flip logic).
5. **Head:** when `guide_offset` is present, apply it exactly like the other parts (anchor
`A` = the chin, or the cap top when flipped; `c_node` ≈ 0) — the stored offset then
reproduces the head's placement vs. the guide circle (chin at the circle bottom
`363.5` when the user aligned it there). When absent (old files), keep the Round 4
`HEAD_CHIN_DROP` translation as the fallback.
6. All driver lookups null-guarded (existing pattern).
Worked example (break.stk torso, assembled with its bbox center ≈195.75 px above the guide's
Hips): `guide_offset ≈ (0, 195.75)`; `A C` ≈ `(0, ±197.5)` (hip end below center, or the
neck end above when flipped) → `delta ≈ (0, ±2)` — the torso's joint end lands within ~2 px of
the Hips joint, reproducing the editor placement.
## 4. Files modified
| File | Changes |
|---|---|
| `scripts/whole_stickman_preview.gd` | `get_guide_joint_preview()` public method. |
| `scripts/stickman_editor.gd` | `GUIDE_JOINT_FOR_PART` const; `FILE_VERSION "1.5"` (+ `SUPPORTED_VERSIONS` + error text); `guide_offset` computed per non-head part in `_collect_all_shape_data()`. |
| `scripts/stk_rig_adapter.gd` | Read + apply `guide_offset` (node-frame translation, only when present). |
| `docs/phase9_round5_bugfix_spec.md` | This file. |
## 5. Edge cases
- **Old files without `guide_offset`** → no translation (exact current behavior; the head
keeps the Round 4 chin drop).
- **Empty part** → the adapter skips before reading the offset (unchanged).
- **Flipped part** → `A` is the far end; the offset still reproduces the placement (the end
the user placed at the joint).
- **Head** → the guide joint is the **Neck** (the bone origin the head mounts at); a user
aligned chin-at-circle-bottom yields `guide_offset = (0, 72 half-height…)` →
`delta ≈ (0, +28)` → chin at `363.5` (the Round 4 chin drop is the fallback for files
without the key). Using the circle center instead would land the chin 72 px too high.
- **Guide moves on window resize** → offsets are computed at save time from the live preview;
re-save after resizing to refresh (note to user).
- **Rest vs IK pose frame** → `c_node` is the authored (guide) pose; at most ~15° pose
difference for the legs — sub-pixel error for typical small offsets; documented.
## 6. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless adapter smoke test (synthetic stk dicts):
- Part WITHOUT `guide_offset`: mounted anchor at local (0,0) (regression).
- Torso WITH `guide_offset = (0, 195.75)` (rotation 0, scale (0.857, 3.99) like
break.stk): anchor `A` = hip end at `C + (0, +197.5)`; `delta = (0, 195.75) + (0, 197.5)
= (0, 1.75)`; `t = delta.rotated(-π)` = `(0, 1.75)` → assert the mounted hip end lands
at local ≈ (0, 1.75) instead of (0, 0).
- Torso WITHOUT the key → hip end at local (0, 0) exactly.
- Flipped torso (rotation 180) WITH `guide_offset = (0, 195.75)`: the 180° rotation
moves the drawn neck end about `C` to `C + (0, +197.5)`, so `A C = (0, +197.5)`;
`delta = (0, 195.75) + (0, 197.5) = (0, 1.75)`; assert the anchor point (drawn neck
end) lands at `delta.rotated(-π) = (0, 1.75)` local.
- IK regression: with an offset applied, moving an IK target still rotates the Body nodes
correctly.
3. Editor-side verification: parse check + code review (the save computation); manual editor
run (F5): load break.stk, Save, inspect the .stk → `version "1.5"` + per-part
`guide_offset` for the 9 non-head parts; the harness then shows the placement 1:1.
4. Cleanup temp test files.
## 7. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Editor computes the offset as bbox-center guide-joint (preview space) at save time | Both points live in the same preview world; the delta cancels the panel-size term, so the stored value is a pure master-space vector. |
| D2 | Store as per-part `guide_offset` (write-only), version bump to `"1.5"` | Matches the `pivot`/`length` metadata precedent; load path untouched; old files default to offset 0. |
| D3 | Adapter converts center-offset → anchor-offset (`+ (A C)`) and applies it in the driver's pose frame | The anchor is the joint end the user placed; the driver frame keeps the placement bone-relative as the rig flexes. |
| D4 | Head included, mapped to the guide **Neck** joint (not the circle center); Round 4 chin drop becomes the old-file fallback | The neck is the head's rig attachment point; the circle center would misplace the chin by 72 px. |
| D5 | Offset applied only when the key is present | Old files keep exact current behavior (perfect-alignment assumption / chin drop). |
## 8. Implementation order
1. `whole_stickman_preview.gd` — `get_guide_joint_preview()`.
2. `stickman_editor.gd` — const map + version bump + `guide_offset` in `_collect_all_shape_data()`.
3. `stk_rig_adapter.gd` — read/apply `guide_offset`.
4. Headless smoke test + parse check.
5. Docs (BUGS.md Round 5 note, AGENTS.md, README.md).
+103
View File
@@ -0,0 +1,103 @@
# Phase 9 Round 6 — Bugfix: Joint-End Anchor Selection via the Guide Placement
## Overview
User report: the **lower left leg** and **lower right arm** are mounted 180° off their bones
(the far end attaches at the joint). Root cause (verified against `break.stk` data): the
adapter picks the joint end with fixed per-side family rules (left limbs → drawn `max_x` end,
right limbs → drawn `min_x` end, plus a 180° flip heuristic), but the user's drawn-side
conventions are **inconsistent across parts** — e.g. the left lower leg's knee is at the drawn
`min_x` end while the rule assumes `max_x`. The only reliable signal for which drawn end is
the joint is the **user's placement**, which Round 5 now stores as `guide_offset`.
## 1. Verified diagnosis (break.stk, real data)
`joint_in_part_space = C guide_offset` (C = raw bbox center; both ends E-transformed):
| Part | rule anchor | dist to joint | other end | dist to joint | correct end |
|---|---|---|---|---|---|
| `left_lower_leg` (rot 90) | E(max_x) = ankle | ≈199 | E(min_x) = **knee** | ≈11 | min_x ✗ (bug) |
| `right_lower_arm` (rot 90) | E(min_x) = wrist | ≈204 | E(max_x) = **elbow** | ≈2 | max_x ✗ (bug) |
| `left_lower_arm` (rot 90) | E(max_x) = elbow | ≈4 | E(min_x) = wrist | ≈204 | max_x ✓ |
| `right_lower_leg` (rot 90) | E(min_x) = knee | ≈6 | E(max_x) = ankle | ≈199 | min_x ✓ |
| `right_upper_arm` (rot 180) | flip → E(max_x) = shoulder | ≈7 | E(min_x) = hand | ≈178 | max_x ✓ (flip heuristic) |
| `torso` (rot 180) | flip → E(neck end) | ≈5 | E(hip end) | ≈400 | neck ✓ (flip heuristic) |
| `head` | E(chin) | ≈43 | E(cap top) | ≈191 | chin ✓ |
**Rule:** when `guide_offset` is present, choose as the joint anchor whichever transformed end
(`E(J_raw)` or `E(F_pt_raw)`) is **nearest to `C guide_offset`** (the part's guide joint in
part space). This fixes both buggy parts, preserves every correct case, and naturally
reproduces the 180° flip behavior (a flipped part's far end lands near the joint).
## 2. Fix specification — `scripts/stk_rig_adapter.gd`
In `_compute_mount_transform()` (the anchor/flip section, ~lines 397407):
1. When `has_guide_offset` is true:
- `joint_pos := center - guide_offset` (the guide joint in the part's E-space; the editor
stored `guide_offset = (pos + C) joint_preview`, so `C guide_offset = joint_preview
pos` — the joint position in the adapter's local frame, up to the preview translation
which is distance-preserving).
- `d_joint := j_prime.distance_to(joint_pos)`; `d_far := f_pt_prime.distance_to(joint_pos)`.
- If `d_far < d_joint` (strict): `anchor = f_pt_prime`, `v = -f_prime` (the far end
attaches — replaces the flip heuristic for this case).
Else: `anchor = j_prime`, `v = f_prime`.
2. When `has_guide_offset` is **false** (old files): keep the current family rules + 180°
flip heuristic exactly as today.
3. `theta`, `s`, the Round 5 offset `t = (guide_offset + (anchor center)).rotated(c_node)`,
the head fallback (`HEAD_CHIN_DROP`), and everything downstream are **unchanged** — they
already consume `anchor`/`v` generically.
Note: this answers the user's hypothesis directly — the pivot point was indeed on the wrong
drawn side; instead of guessing per-side conventions (or baking rotation/scale, which does not
record which end is the joint), the stored guide placement decides the pivot side.
## 3. Files modified
| File | Changes |
|---|---|
| `scripts/stk_rig_adapter.gd` | Nearest-end anchor selection when `guide_offset` is present (replaces the family-side + flip choice for that case). |
| `docs/phase9_round6_bugfix_spec.md` | This file. |
## 4. Edge cases
- **Old files without `guide_offset`** → family rules + flip heuristic (current behavior).
- **Tie** (strict `<`) → keeps the family-rule end.
- **Flipped parts with `guide_offset`** → the far end is naturally nearest (the flip behavior
is preserved without the heuristic).
- **Misplaced parts** (placement error > half the part length) → the wrong end may win;
degenerate input, acceptable.
- **Head** → the chin is nearest the Neck (≈43 vs ≈191); the Round 5 offset and the
`HEAD_CHIN_DROP` fallback are unchanged.
## 5. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless smoke test (spawn the current `break.stk`):
- `Body/LeftLowerLeg`: the mounted knee end ≈ local (0, 0) (was the ankle end) and the far
end ≈ (0, +200) — shin hangs from the knee.
- `Body/RightLowerArm`: the mounted elbow end ≈ local (0, 0), far end ≈ (0, +200).
- Regressions: `Body/LeftLowerArm` elbow ≈ (0,0); `Body/RightLowerLeg` knee ≈ (0,0);
`Body/RightUpperArm` shoulder ≈ (0,0) (flip case via nearest rule); torso flipped neck
end ≈ (0,0); head chin ≈ (0, +28) (guide-offset present → no chin-drop, chin ≈ 28 from
the neck per the stored offset); all parts' far ends along +Y local (bone-aligned).
- No-guide-offset fallback (synthetic dict without the key): lower limbs use the family
rules + flip heuristic (regression: flipped torso anchor = far end; 90° limbs anchored at
the family side).
- IK regression: moving a hand target still rotates the body nodes with the bones.
3. Cleanup temp test files.
## 6. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Joint end = the fitted end nearest the stored guide joint (`C guide_offset`) | The drawn-side conventions are inconsistent per part; the placement is the ground truth. |
| D2 | Nearest rule only when `guide_offset` is present | Old files keep exact current behavior. |
| D3 | Keep θ/s/t and the head fallback unchanged | They consume `anchor`/`v` generically; the placement math is already correct. |
| D4 | No baking of rotation/scale into the .stk | Baking alone cannot record which drawn end is the joint; the stored placement already encodes it. |
## 7. Implementation order
1. `stk_rig_adapter.gd` — nearest-end anchor selection (guarded by `has_guide_offset`).
2. Headless smoke test + parse check.
3. Docs (BUGS.md Round 6 note, AGENTS.md, README.md).
+103
View File
@@ -0,0 +1,103 @@
# Phase 9 Round 7 — Feature: Draggable Torso & Head IK Targets in the Test Harness
## Overview
User request (test harness):
1. **Draggable torso target** — so the user can drag the whole stickman around.
2. **Draggable head target** — so the user can test the LookAt IK for the head.
The rig already provides both targets:
- `IK_Targets/Torso` (Marker2D at the hips) with a child `RemoteTransform2D` whose
`remote_path` is `../../../Skeleton2D/Torso` — moving the marker moves the hip bone, and
every other bone (Head/arms/legs) and `Body/*` visual follows.
- `IK_Targets/Head` (Marker2D at `(0, 624)`) — the `SkeletonModification2DLookAt` aim point
for the Head bone (`bone_index = 1`, constrained, `constraint_in_localspace = true`).
The harness (`scripts/test_harness.gd`) already has generic handle dragging
(`IK_HANDLE_PATHS``_hit_test_handle``_dragging_handle``_handle_mouse_motion` sets
`global_position`; `_draw_ik_handles` draws markers). Only the two new handles need wiring,
plus torso-follow semantics.
## 1. Fix specification — `scripts/test_harness.gd`
### 1a. Register the new handles
Add to `IK_HANDLE_PATHS`:
```gdscript
"Head": "IK_Targets/Head",
"Torso": "IK_Targets/Torso",
```
(Note: `LEAF_BONE_IK_PATHS` stays unchanged — the head's LookAt aim point is not a bone tip,
and the Torso is not a leaf bone.)
### 1b. Torso drag (resolved: bones only)
The Torso handle behaves like every other handle: dragging it moves only the marker (the
`IK_Targets/Torso` RemoteTransform2D then moves the hip bone, and the bone hierarchy follows).
The other IK targets (hands/legs/head) **stay put**, so dragging the figure away from them
stretches the limbs toward the stationary targets — per user decision.
### 1c. Marker colors
`_draw_ik_handles` currently colors hands green, everything else blue. Add distinct colors:
- `HANDLE_COLOR_HEAD := Color(1.0, 1.0, 0.0)` (yellow) for `"Head"`.
- `HANDLE_COLOR_TORSO := Color(1.0, 0.0, 1.0)` (magenta) for `"Torso"`.
### 1d. Head aim line (visual aid for the LookAt test)
In `_draw_ik_handles`, when both the head bone and the head marker exist, draw a thin
semi-transparent line from the head bone origin (`Skeleton2D/Torso/Head` global position) to
the head marker so the user can see what the head is aiming at. (Optional but helpful; use the
existing overlay draw style, e.g. width 1.5/zoom, alpha ~0.5.)
## 2. Files modified
| File | Changes |
|---|---|
| `scripts/test_harness.gd` | `IK_HANDLE_PATHS` + 2 entries (Head, Torso); head/torso marker colors; head aim line. |
| `docs/phase9_round7_feature_spec.md` | This file. |
## 3. Edge cases
- **Missing nodes** (foreign rig): `_ik_handles` lookups are already null-guarded; the aim
line needs a null guard for the head bone.
- **Dragging the Torso** moves the markers only (the `RayCast_*` helpers under `IK_Targets`
stay put — they are not handles).
- **Head LookAt constraints** — the head bone rotates within its authored constraint range;
dragging the head marker far away clamps the rotation (expected rig behavior).
- **Camera** — does not follow the dragged figure (unchanged).
## 4. Test plan
1. Parse check: `..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit`.
2. Headless rig-level verification (SceneTree script, spawn `break.stk`):
- Move `IK_Targets/Torso` by `(50, 30)` → await a frame → assert `Skeleton2D/Torso`
global position moved by ≈ the delta, and every `Body/*` visual node's global position
moved by ≈ the same delta (rigid translation via the Torso RemoteTransform2D).
- Move `IK_Targets/Head` from `(0, 624)` to `(300, 624)` → await a frame → assert the
Head bone `global_rotation` changed, and `Body/Head.global_rotation` changed with it
(rotation push) — LookAt works.
3. Harness code review: `IK_HANDLE_PATHS` has 6 entries (incl. Head, Torso); marker colors
per §1c (yellow head, magenta torso); the head aim line is null-guarded and drawn in the
IK overlay; the generic drag flow needs no changes (the Torso marker's RemoteTransform2D
moves the hip bone on drag). (Mouse-drag flow is UI-side; the same `_handle_mouse_motion`
math is covered by the review.)
4. Manual F6 check: drag the magenta torso marker — whole figure moves; drag the yellow head
marker — the head turns to look at it.
5. Cleanup temp files.
## 5. Design decisions
| # | Decision | Justification |
|---|---|---|
| D1 | Torso drag = bones only (no target following) | User decision: dragging the figure away from the stationary limb/head targets stretches the limbs — useful for testing IK reach. |
| D2 | Head/torso get distinct marker colors | 6 markers need visual separation; matches the existing color-coded style. |
| D3 | Head aim line drawn in the IK overlay | Makes the LookAt target relationship visible. |
## 6. Implementation order
1. `test_harness.gd` — new handles + colors + aim line + torso-follow.
2. Headless verification + parse check.
3. Docs (BUGS.md Round 7 note, AGENTS.md, README.md).
+23 -3
View File
@@ -48,10 +48,10 @@ const DEFAULT_POSITIONS: Dictionary = {
"right_lower_leg": Vector2(165, 210),
}
const FILE_VERSION := "1.4"
const FILE_VERSION := "1.5"
const FILE_FILTER := "*.stk ; Stickman Files"
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"]
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2).
const PROPORTIONS: Dictionary = {
@@ -66,6 +66,22 @@ const X_AXIS_PARTS: PackedStringArray = [
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
]
# Phase 9 Round 5: map each body part to its silhouette-guide joint (see
# whole_stickman_preview.gd GUIDE_JOINTS). Used to compute the per-part
# guide_offset (bbox center guide joint, preview space) at save time.
const GUIDE_JOINT_FOR_PART: Dictionary = {
"head": "Neck",
"torso": "Hips",
"left_upper_arm": "LeftShoulder",
"left_lower_arm": "LeftElbow",
"right_upper_arm": "RightShoulder",
"right_lower_arm": "RightElbow",
"left_upper_leg": "Hips",
"left_lower_leg": "LeftKnee",
"right_upper_leg": "Hips",
"right_lower_leg": "RightKnee",
}
const SETTINGS_PATH := "user://settings.json"
const SETTINGS_VERSION := "1.0"
const DEFAULT_GRID_SIZE := 15
@@ -413,6 +429,9 @@ func _collect_all_shape_data() -> Dictionary:
var rot := _whole_preview.get_part_rotation(part_name)
var scl := _whole_preview.get_part_scale(part_name)
var pl := _compute_part_pivot_length(shapes_arr, part_name)
var pivot_vector := Vector2(float(pl["pivot"]["x"]), float(pl["pivot"]["y"]))
var joint_preview := _whole_preview.get_guide_joint_preview(GUIDE_JOINT_FOR_PART[part_name])
var guide_offset := (pos + pivot_vector) - joint_preview
all_data[part_name] = {
"shapes": shapes_arr,
"position": {"x": pos.x, "y": pos.y},
@@ -420,6 +439,7 @@ func _collect_all_shape_data() -> Dictionary:
"scale": {"x": scl.x, "y": scl.y},
"pivot": pl["pivot"],
"length": pl["length"],
"guide_offset": {"x": guide_offset.x, "y": guide_offset.y},
}
return all_data
@@ -449,7 +469,7 @@ func _apply_json_data(json: Variant) -> String:
var version: String = dict.get("version", "")
if version not in SUPPORTED_VERSIONS:
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', or '1.4')" % version
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', '1.4', or '1.5')" % version
_stickman_name_edit.text = dict.get("stickman_name", "")
+228 -75
View File
@@ -4,16 +4,26 @@ extends RefCounted
##
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits
## the skeleton bone lengths, recalibrates the IK targets, and mounts the
## .stk vector shapes onto the rig's Body/ visual nodes. Consumed by a future
## runtime pipeline, never referenced by the editor.
## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging"
## convention (joint end at the local origin, far end along +Y). The
## RemoteTransform2D drivers keep `update_rotation = true`, so each mounted
## part rotates to follow its bone in every pose (IK flexing included).
## Consumed by a future runtime pipeline, never referenced by the editor.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_LINE_WIDTH := 16.0
const DEFAULT_LINE_WIDTH := 2.0
const ELBOW_REST_Y := -256.0
## Head chin drop (px): the editor's pose guide draws the head as a circle of
## radius 100 centered at the Head joint (0, -463.5), so its bottom sits at
## -363.5; the neck (Head bone origin) is at -391.5. The mounted head's chin
## (its local origin) therefore drops 28 px below the neck so the head overlaps
## the torso the same way the guide circle does.
const HEAD_CHIN_DROP := 28.0
const DEFAULT_PROPORTIONS: Dictionary = {
"upper_arm_length": 168.0,
"lower_arm_length": 200.0,
@@ -30,11 +40,6 @@ const PART_KEYS: PackedStringArray = [
"right_upper_leg", "right_lower_leg",
]
## Parts whose primary (bone-aligned) axis is horizontal (X).
const X_AXIS_PARTS: PackedStringArray = [
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
]
## Bone node paths (relative to rig root), keyed by part name.
const BONE_PATHS: Dictionary = {
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm",
@@ -62,10 +67,9 @@ const BODY_PATHS: Dictionary = {
}
## RemoteTransform2D driver node paths (relative to rig root), keyed by part
## name. Each driver pushes its transform onto the matching Body/* visual node
## (see master_rig.tscn). We neutralize their rotation so the Body/* nodes stay
## in the clean unrotated frame the mount math assumes (position/scale pushes
## are preserved).
## name. Each driver pushes the matching Body/* node's global transform from
## its bone; its `global_rotation` is the bone frame used (Phase 9 Round 5) to
## convert the master-space guide_offset into a bone-relative placement.
const DRIVER_PATHS: Dictionary = {
"head": "Skeleton2D/Torso/Head/RemoteTransform2D",
"torso": "Skeleton2D/Torso/RemoteTransform2D",
@@ -85,6 +89,7 @@ const IK_LEFT_LEG := "IK_Targets/Left_Leg"
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
const HEAD_DRIVER_PATH := "Skeleton2D/Torso/Head/RemoteTransform2D"
# ---------------------------------------------------------------------------
# Public API
@@ -93,7 +98,6 @@ const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
_fit_bones(stk_data, rig)
_recalibrate_ik(stk_data, rig)
_neutralize_driver_rotations(rig)
_mount_shapes(stk_data, rig)
# ---------------------------------------------------------------------------
@@ -140,6 +144,11 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
if head_bone is Node2D:
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
# Head driver — zero the RemoteTransform2D local position so Body/Head sits
# on the neck joint (the authored offset centered the old 100-px circle
# 72 px above the neck; the mounted head's chin is its local origin).
_set_prop(rig, HEAD_DRIVER_PATH, "position", Vector2.ZERO)
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
var node := rig.get_node_or_null(NodePath(path))
@@ -183,18 +192,6 @@ static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void:
else:
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_HAND)
# ---------------------------------------------------------------------------
# Driver neutralization
# ---------------------------------------------------------------------------
static func _neutralize_driver_rotations(rig: Node2D) -> void:
for part_name: String in DRIVER_PATHS:
var driver := rig.get_node_or_null(NodePath(DRIVER_PATHS[part_name])) as RemoteTransform2D
if driver == null:
push_warning("StkRigAdapter: missing RemoteTransform2D driver '%s' for part '%s'; skipped." % [DRIVER_PATHS[part_name], part_name])
continue
driver.update_rotation = false
# ---------------------------------------------------------------------------
# Visual shape mount
# ---------------------------------------------------------------------------
@@ -213,9 +210,10 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
push_warning("StkRigAdapter: missing Body node for part '%s'; skipped." % part_name)
continue
# Reset the node's authored transform so the mount math starts from a
# clean unrotated, unit-scaled frame. Position is owned by the
# RemoteTransform2D driver and is left untouched.
# Reset the node's authored scale/rotation so the mount math starts from
# a unit-scaled, unrotated frame (position is owned by the
# RemoteTransform2D driver and is left untouched; the driver overwrites
# scale/rotation each frame anyway).
_reset_node_transform(visual)
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
@@ -228,27 +226,51 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
_clear_visual_children(visual)
var shapes: Array = []
var rotation_deg := 0.0
var part_scale := Vector2.ONE
var guide_offset := Vector2.ZERO
var has_guide_offset := false
var part_data: Variant = body_parts.get(part_name, {})
if part_data is Dictionary:
var pd := part_data as Dictionary
var shapes_var: Variant = pd.get("shapes", [])
if shapes_var is Array:
shapes = shapes_var as Array
# Phase 9 Round 3: the preview's per-part rotation (degrees) and
# scale about the bbox center are applied to the mounted geometry.
rotation_deg = float(pd.get("rotation", 0.0))
var scale_var: Variant = pd.get("scale", {})
if scale_var is Dictionary:
var sd := scale_var as Dictionary
part_scale = Vector2(float(sd.get("x", 1.0)), float(sd.get("y", 1.0)))
# Phase 9 Round 5: guide-relative placement (write-only metadata;
# absent on old files → offset 0).
var go_var: Variant = pd.get("guide_offset")
if go_var is Dictionary:
var god := go_var as Dictionary
guide_offset = Vector2(float(god.get("x", 0.0)), float(god.get("y", 0.0)))
has_guide_offset = true
# Recompute the joint anchor and part length from the shape bbox at
# mount time (the file's pivot/length fields are write-only metadata
# and are no longer trusted for anchoring/scaling).
# Phase 9 Round 5: the part's driver rotation (bone frame) used to
# convert the master-space guide offset into a bone-relative
# translation. Null-guarded; fallback 0.0.
var c_node := 0.0
var driver := rig.get_node_or_null(NodePath(DRIVER_PATHS[part_name]))
if driver is Node2D:
c_node = (driver as Node2D).global_rotation
# Recompute the mount transform (part rotation/scale applied first, then
# the fitted anchor, alignment rotation and bone-fit scale) from the
# shape bbox at mount time. The file's pivot/length fields are
# write-only metadata and are never trusted for mounting.
var bbox := _compute_part_bbox(shapes)
if float(bbox["min_x"]) > float(bbox["max_x"]) or float(bbox["min_y"]) > float(bbox["max_y"]):
continue
var anchor := _compute_anchor(bbox, part_name)
var part_length := _compute_part_length(bbox, part_name)
var bone_length := _bone_length_for(part_name, proportions)
var scale := _compute_scale(part_name, part_length, bone_length)
var mt := _compute_mount_transform(bbox, part_name, proportions, rotation_deg, part_scale, guide_offset, has_guide_offset, c_node)
for shape in shapes:
if shape is Dictionary:
_mount_shape(visual, shape as Dictionary, anchor, scale)
_mount_shape(visual, shape as Dictionary, mt)
static func _bone_length_for(part_name: String, proportions: Dictionary) -> float:
@@ -305,33 +327,150 @@ static func _compute_part_bbox(shapes: Array) -> Dictionary:
}
static func _compute_anchor(bbox: Dictionary, part_name: String) -> Vector2:
var cx := (float(bbox["min_x"]) + float(bbox["max_x"])) * 0.5
var cy := (float(bbox["min_y"]) + float(bbox["max_y"])) * 0.5
match part_name:
"head":
return Vector2(cx, float(bbox["max_y"])) # neck base
"left_upper_arm", "left_lower_arm":
return Vector2(float(bbox["max_x"]), cy) # shoulder at right end
"right_upper_arm", "right_lower_arm":
return Vector2(float(bbox["min_x"]), cy) # shoulder at left end
_: # torso + all legs
return Vector2(cx, float(bbox["min_y"])) # hip/neck top-center
## Computes the mount transform for a part: `{ "anchor": Vector2, "theta":
## float, "s": float, "center": Vector2, "part_rotation": float,
## "part_scale": Vector2, "offset": Vector2 }`.
##
## Pipeline (spec §2c):
## 1. The raw bbox center C, raw joint end J_raw, and raw far point F_pt_raw
## come from the Round 2 family rules (head/torso bottom-center →
## top-center; horizontally-drawn limbs end-to-end; vertical limbs
## top-center → bottom-center).
## 2. The preview's part transform E(P) = C + R(rot)·S·(P C) is applied to
## the anchor and far point: J' = E(J_raw), F_pt' = E(F_pt_raw),
## F' = F_pt' J'.
## 3. Phase 9 Round 6: when `guide_offset` is present, the anchor end is
## whichever transformed end (J' or F_pt') is nearest the guide joint
## (C guide_offset): A = F_pt', V = F' if the far end is nearer, else
## A = J', V = F'. Old files (no guide_offset) fall back to the 180° flip
## heuristic (|wrapf(rot)| > 0.75·π attaches the drawn far end at the
## joint; otherwise A = J', V = F').
## 4. Alignment θ = V.normalized().angle_to(Vector2.DOWN) rotates the fitted
## long axis onto the hanging frame; the bone-fit scale s = bone_length/|V|
## is measured on the transformed extent. The head mounts upright,
## unscaled (θ = 0, s = 1) but still applies E, plus a rig-space
## translation offset (head only) that drops the chin below the neck.
## 5. Phase 9 Round 5: when `guide_offset` is present, the offset becomes
## t = (guide_offset + (A C)).rotated(c_node) — the anchor's placement
## relative to the guide joint, converted to the driver's bone frame —
## which reproduces the editor's guide-relative placement (and, for the
## head, subsumes the HEAD_CHIN_DROP fallback).
static func _compute_mount_transform(bbox: Dictionary, part_name: String, proportions: Dictionary, rotation_deg: float, part_scale: Vector2, guide_offset: Vector2, has_guide_offset: bool, c_node: float) -> Dictionary:
var min_x := float(bbox["min_x"])
var min_y := float(bbox["min_y"])
var max_x := float(bbox["max_x"])
var max_y := float(bbox["max_y"])
var cx := (min_x + max_x) * 0.5
var cy := (min_y + max_y) * 0.5
var width := max_x - min_x
var height := max_y - min_y
var center := Vector2(cx, cy)
# Raw joint end (J_raw) and far point (F_pt_raw), per the Round 2 family
# rules. Head and torso mount bottom-center (chin / hip end) with the far
# point at top-center (cap / neck end); limbs auto-detect the drawn long
# axis (horizontal: end-to-end; vertical: top-center → bottom-center).
var j_raw := Vector2.ZERO
var f_pt_raw := Vector2.ZERO
if part_name == "head" or part_name == "torso":
j_raw = Vector2(cx, max_y)
f_pt_raw = Vector2(cx, min_y)
else:
var long_axis_is_x := width >= height
if part_name.begins_with("left_"):
if long_axis_is_x:
j_raw = Vector2(max_x, cy)
f_pt_raw = Vector2(min_x, cy)
else:
j_raw = Vector2(cx, min_y)
f_pt_raw = Vector2(cx, max_y)
else:
if long_axis_is_x:
j_raw = Vector2(min_x, cy)
f_pt_raw = Vector2(max_x, cy)
else:
j_raw = Vector2(cx, min_y)
f_pt_raw = Vector2(cx, max_y)
# Apply the preview's part transform to the anchor and far point.
var rot_rad := deg_to_rad(rotation_deg)
var j_prime := _apply_part_transform(j_raw, center, part_scale, rot_rad)
var f_pt_prime := _apply_part_transform(f_pt_raw, center, part_scale, rot_rad)
var f_prime := f_pt_prime - j_prime
# Phase 9 Round 6: when guide_offset is present, the stored guide placement is
# the ground truth for which drawn end is the joint — pick whichever
# transformed end (j_prime or f_pt_prime) is nearest the guide joint
# (center - guide_offset). This replaces the family-side + 180° flip heuristic
# for that case and naturally reproduces the flip (a flipped part's far end
# lands near the joint). Old files (no guide_offset) keep the flip heuristic.
var anchor: Vector2
var v: Vector2
if has_guide_offset:
var joint_pos := center - guide_offset
var d_joint := j_prime.distance_to(joint_pos)
var d_far := f_pt_prime.distance_to(joint_pos)
if d_far < d_joint:
anchor = f_pt_prime
v = -f_prime
else:
anchor = j_prime
v = f_prime
else:
# 180° flips attach the drawn far end at the joint (the end the user rotated
# into the joint position), making the rotation visibly applied.
var flipped := absf(wrapf(rot_rad, -PI, PI)) > PI * 0.75
if flipped:
anchor = f_pt_prime
v = -f_prime
else:
anchor = j_prime
v = f_prime
var v_len := v.length()
var theta := 0.0
if v_len > 0.0001:
theta = v.normalized().angle_to(Vector2.DOWN)
var s := 1.0
var offset := Vector2.ZERO
# Phase 9 Round 5: guide-relative placement. delta = guide_offset + (A C)
# is the anchor's offset from its guide joint in master space; t rotates it
# into the driver's (bone) frame so the placement stays bone-relative as the
# rig flexes. Applied only when the key is present (old files keep the
# offset-0 / chin-drop behavior).
if has_guide_offset:
offset = (guide_offset + (anchor - center)).rotated(-c_node)
if part_name == "head":
# Head mounts upright, unscaled — a bone-fit scale would double-scale the
# face; E already applied the user's part scale. The chin (local origin)
# is dropped below the neck by HEAD_CHIN_DROP so the head overlaps the
# torso like the editor's pose guide. That drop is the old-file fallback;
# when guide_offset is present it is subsumed by the computed offset.
theta = 0.0
s = 1.0
if not has_guide_offset:
offset = Vector2(0.0, HEAD_CHIN_DROP)
elif v_len > 0.0001:
s = _bone_length_for(part_name, proportions) / v_len
return {
"anchor": anchor,
"theta": theta,
"s": s,
"center": center,
"part_rotation": rot_rad,
"part_scale": part_scale,
"offset": offset,
}
static func _compute_part_length(bbox: Dictionary, part_name: String) -> float:
if X_AXIS_PARTS.has(part_name):
return float(bbox["max_x"]) - float(bbox["min_x"])
return float(bbox["max_y"]) - float(bbox["min_y"])
static func _compute_scale(part_name: String, part_length: float, bone_length: float) -> Vector2:
var primary := 1.0
if part_name != "head" and part_length > 0.0001:
primary = bone_length / part_length
if X_AXIS_PARTS.has(part_name):
return Vector2(primary, 1.0) # arms: X is primary
return Vector2(1.0, primary) # legs/torso/head: Y is primary (head → (1.0, 1.0))
## Applies the preview's part transform E(P) = C + R(rot)·S·(P C): scale
## about the bbox center, then rotate about the bbox center.
static func _apply_part_transform(pt: Vector2, center: Vector2, part_scale: Vector2, rot_rad: float) -> Vector2:
return center + Vector2((pt.x - center.x) * part_scale.x, (pt.y - center.y) * part_scale.y).rotated(rot_rad)
static func _reset_node_transform(visual: Node) -> void:
@@ -340,26 +479,30 @@ static func _reset_node_transform(visual: Node) -> void:
(visual as Node2D).rotation = 0.0
static func _mount_shape(visual: Node, shape: Dictionary, anchor: Vector2, scale: Vector2) -> void:
var pts := _transform_points(shape.get("points", []), anchor, scale)
static func _mount_shape(visual: Node, shape: Dictionary, mt: Dictionary) -> void:
var anchor: Vector2 = mt["anchor"]
var theta: float = mt["theta"]
var s: float = mt["s"]
var center: Vector2 = mt["center"]
var part_rotation: float = mt["part_rotation"]
var part_scale: Vector2 = mt["part_scale"]
var offset: Vector2 = mt["offset"]
var pts := _transform_points(shape.get("points", []), anchor, theta, s, center, part_rotation, part_scale, offset)
if pts.size() < 2:
return
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
var closed := bool(shape.get("closed", false))
# Phase 9 Round 3: one node per shape — closed shapes mount as a single
# Polygon2D (fill only, no outline Line2D); open shapes mount as a single
# Line2D.
if closed:
var poly := Polygon2D.new()
poly.polygon = pts
poly.color = color
visual.add_child(poly)
var outline := Line2D.new()
outline.points = pts
outline.closed = true
outline.width = DEFAULT_LINE_WIDTH
outline.default_color = color
visual.add_child(outline)
else:
var line := Line2D.new()
line.points = pts
@@ -368,23 +511,33 @@ static func _mount_shape(visual: Node, shape: Dictionary, anchor: Vector2, scale
visual.add_child(line)
static func _transform_points(pts_var: Variant, anchor: Vector2, scale: Vector2) -> PackedVector2Array:
static func _transform_points(pts_var: Variant, anchor: Vector2, theta: float, s: float, center: Vector2, part_rotation: float, part_scale: Vector2, offset: Vector2) -> PackedVector2Array:
var out := PackedVector2Array()
if pts_var is Array:
for p in pts_var as Array:
if p is Dictionary:
var d := p as Dictionary
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
out.append(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
elif p is Vector2:
var pt := p as Vector2
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
out.append(_map_point(p as Vector2, anchor, theta, s, center, part_rotation, part_scale, offset))
elif pts_var is PackedVector2Array:
for pt in pts_var as PackedVector2Array:
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
out.append(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
return out
## Maps one drawn point into the rig's hanging frame:
## Q = E(P) = C + R(rot)·S·(P C) (the preview's part transform)
## v = R(θ)·(Q A); v.y *= s (align + bone-fit scale along the axis)
## v += offset (rig-space translation, head chin drop)
static func _map_point(pt: Vector2, anchor: Vector2, theta: float, s: float, center: Vector2, part_rotation: float, part_scale: Vector2, offset: Vector2) -> Vector2:
var q := _apply_part_transform(pt, center, part_scale, part_rotation)
var v := (q - anchor).rotated(theta)
v.y *= s
return v + offset
static func _reset_own_geometry(visual: Node) -> void:
if visual is Line2D:
(visual as Line2D).points = PackedVector2Array()
+72 -4
View File
@@ -24,6 +24,8 @@ const BONE_COLOR_RIGHT := Color(1.00, 0.50, 0.20)
const BONE_COLOR_CENTRAL := Color(1.00, 1.00, 1.00)
const HANDLE_COLOR_HAND := Color(0.00, 1.00, 0.00) # green
const HANDLE_COLOR_FOOT := Color(0.00, 0.50, 1.00) # blue
const HANDLE_COLOR_HEAD := Color(1.00, 1.00, 0.00) # yellow
const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta
const SKELETON_PATH := "Skeleton2D"
@@ -35,11 +37,28 @@ const QUICK_LOADS: Array = [
]
## IK handle node paths (relative to rig root), keyed by handle name.
## The four limb targets drive TwoBoneIK; "Head" is the LookAt aim point and
## "Torso" is the hip anchor (dragging it translates the whole rig via its
## RemoteTransform2D — no target-following logic).
const IK_HANDLE_PATHS: Dictionary = {
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
"Head": "IK_Targets/Head",
"Torso": "IK_Targets/Torso",
}
## Leaf bone → IK target node path (relative to rig root), keyed by bone name.
## Used by the bone overlay to draw the forearm/shin segments out to their
## wrist/ankle targets. The Head leaf is NOT listed here — its IK target is a
## LookAt aim point, so it draws along its own bone direction instead (see
## _draw_bones' leaf fallback).
const LEAF_BONE_IK_PATHS: Dictionary = {
"LeftLowerArm": "IK_Targets/Left_Hand",
"RightLowerArm": "IK_Targets/Right_Hand",
"LeftLowerLeg": "IK_Targets/Left_Leg",
"RightLowerLeg": "IK_Targets/Right_Leg",
}
# ---------------------------------------------------------------------------
@@ -282,9 +301,35 @@ func _draw_bones() -> void:
var origin := bone.global_position
_debug_overlay.draw_circle(origin, dot_r, color)
var parent := bone.get_parent()
if parent is Bone2D:
_debug_overlay.draw_line((parent as Bone2D).global_position, origin, color, line_w)
# Collect the bone's Bone2D children (true bone segments, including the
# nested lower bones).
var bone_children: Array[Bone2D] = []
for child in bone.get_children():
if child is Bone2D:
bone_children.append(child as Bone2D)
if bone_children.is_empty():
# Leaf bone — draw out to its IK target (forearm/shin), falling
# back to the bone's own direction (bone_angle + rotation) when
# there is no target (e.g. the Head, whose target is a LookAt aim
# point, not a joint).
var target := _leaf_bone_target(bone)
if target != null:
_debug_overlay.draw_line(origin, target.global_position, color, line_w)
else:
var fallback := origin + Vector2(bone.get_length(), 0.0).rotated(deg_to_rad(bone.bone_angle)).rotated(bone.global_rotation)
_debug_overlay.draw_line(origin, fallback, color, line_w)
else:
for child_bone in bone_children:
_debug_overlay.draw_line(origin, child_bone.global_position, color, line_w)
func _leaf_bone_target(bone: Bone2D) -> Node2D:
if not LEAF_BONE_IK_PATHS.has(bone.name):
return null
if _rig == null or not is_instance_valid(_rig):
return null
return _rig.get_node_or_null(NodePath(str(LEAF_BONE_IK_PATHS[bone.name]))) as Node2D
func _draw_ik_handles() -> void:
@@ -296,11 +341,34 @@ func _draw_ik_handles() -> void:
var handle := _ik_handles[handle_name] as Marker2D
if handle == null or not is_instance_valid(handle):
continue
var color := HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
var color := _handle_color(handle_name)
var pos := handle.global_position
_debug_overlay.draw_circle(pos, r, color)
_debug_overlay.draw_arc(pos, r, 0.0, TAU, 24, Color(1.0, 1.0, 1.0, 0.8), outline_w)
# Head aim line — visual aid showing what the head bone is aiming at.
var head_handle: Marker2D = _ik_handles.get("Head", null) as Marker2D
if head_handle != null and is_instance_valid(head_handle) \
and _skeleton != null and is_instance_valid(_skeleton):
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head_bone != null and is_instance_valid(head_bone):
_debug_overlay.draw_line(
head_bone.global_position,
head_handle.global_position,
Color(1.0, 1.0, 0.0, 0.5),
1.5 / zoom
)
func _handle_color(handle_name: String) -> Color:
match handle_name:
"Head":
return HANDLE_COLOR_HEAD
"Torso":
return HANDLE_COLOR_TORSO
_:
return HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
func _bone_color(bone_name: String) -> Color:
if bone_name.begins_with("Left"):
+10
View File
@@ -271,6 +271,16 @@ func set_show_guide(enabled: bool) -> void:
preview_area.queue_redraw()
## Returns the preview-space position of a guide joint (a master-space
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
## unknown joint names.
func get_guide_joint_preview(joint_name: String) -> Vector2:
if not GUIDE_JOINTS.has(joint_name):
push_warning("WholeStickmanPreview: unknown guide joint '%s'." % joint_name)
return Vector2.ZERO
return _guide_to_preview(GUIDE_JOINTS[joint_name])
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
+89 -49
View File
@@ -1,5 +1,5 @@
{
"version": "1.4",
"version": "1.5",
"stickman_name": "",
"part_order": [
"torso",
@@ -59,7 +59,7 @@
"y": 227.806777954102
},
{
"x": 455.448852539062,
"x": 455.448852539063,
"y": 213.165756225586
},
{
@@ -67,7 +67,7 @@
"y": 193.165756225586
},
{
"x": 455.448852539062,
"x": 455.448852539063,
"y": 173.165756225586
},
{
@@ -134,7 +134,7 @@
},
{
"x": 514.874938964844,
"y": 174.483520507812
"y": 174.483520507813
},
{
"x": 497.417388916016,
@@ -246,31 +246,31 @@
"shape_type": "circle",
"points": [
{
"x": 490.218383789062,
"y": 122.896606445312
"x": 490.218383789063,
"y": 122.896606445313
},
{
"x": 510.218383789062,
"x": 510.218383789063,
"y": 128.255599975586
},
{
"x": 524.859375,
"y": 142.896606445312
"y": 142.896606445313
},
{
"x": 530.218383789062,
"y": 162.896606445312
"x": 530.218383789063,
"y": 162.896606445313
},
{
"x": 450.218383789062,
"y": 162.896606445312
"x": 450.218383789063,
"y": 162.896606445313
},
{
"x": 455.577392578125,
"y": 142.896606445312
"y": 142.896606445313
},
{
"x": 470.218383789062,
"x": 470.218383789063,
"y": 128.255599975586
}
],
@@ -288,8 +288,8 @@
}
],
"position": {
"x": 248.278564453125,
"y": 126.460693359375
"x": 408.278564453125,
"y": 381.460693359375
},
"rotation": -360.0,
"scale": {
@@ -300,7 +300,11 @@
"x": 504.221405029297,
"y": 178.031181335449
},
"length": 110.269149780273
"length": 110.269149780273,
"guide_offset": {
"x": 30.5,
"y": -78.7581176757813
}
},
"torso": {
"shapes": [
@@ -335,10 +339,10 @@
}
],
"position": {
"x": 419.5,
"y": 344.0
"x": 579.5,
"y": 569.0
},
"rotation": 0.0,
"rotation": 180.0,
"scale": {
"x": 0.857142865657806,
"y": 3.9898989200592
@@ -347,7 +351,11 @@
"x": 300.5,
"y": 258.5
},
"length": 99.0
"length": 99.0,
"guide_offset": {
"x": -2.0,
"y": -202.25
}
},
"left_upper_arm": {
"shapes": [
@@ -382,8 +390,8 @@
}
],
"position": {
"x": 136.5,
"y": 364.5
"x": 306.5,
"y": 604.5
},
"rotation": 0.0,
"scale": {
@@ -394,7 +402,11 @@
"x": 489.5,
"y": 169.5
},
"length": 107.0
"length": 107.0,
"guide_offset": {
"x": -86.0,
"y": -7.75
}
},
"left_lower_arm": {
"shapes": [
@@ -406,11 +418,11 @@
"y": 127.010650634766
},
{
"x": 533.026489257812,
"x": 533.026489257813,
"y": 127.221878051758
},
{
"x": 532.166625976562,
"x": 532.166625976563,
"y": 142.74137878418
},
{
@@ -429,8 +441,8 @@
}
],
"position": {
"x": 70.0000305175781,
"y": 295.0
"x": 234.999969482422,
"y": 535.000061035156
},
"rotation": 90.0,
"scale": {
@@ -441,7 +453,11 @@
"x": 479.999969482422,
"y": 135.0
},
"length": 106.053039550781
"length": 106.053039550781,
"guide_offset": {
"x": 0.99993896484375,
"y": -103.749938964844
}
},
"right_upper_arm": {
"shapes": [
@@ -476,8 +492,8 @@
}
],
"position": {
"x": 481.0,
"y": 377.0
"x": 641.0,
"y": 617.0
},
"rotation": -180.0,
"scale": {
@@ -488,7 +504,11 @@
"x": 329.0,
"y": 158.0
},
"length": 107.0
"length": 107.0,
"guide_offset": {
"x": 88.0,
"y": -6.75
}
},
"right_lower_arm": {
"shapes": [
@@ -523,8 +543,8 @@
}
],
"position": {
"x": 482.087707519531,
"y": 289.335083007812
"x": 637.087707519531,
"y": 529.335083007813
},
"rotation": 90.0,
"scale": {
@@ -535,7 +555,11 @@
"x": 412.91227722168,
"y": 143.680892944336
},
"length": 106.053070068359
"length": 106.053070068359,
"guide_offset": {
"x": 0.0,
"y": -100.734008789063
}
},
"left_upper_leg": {
"shapes": [
@@ -570,8 +594,8 @@
}
],
"position": {
"x": 141.208190917969,
"y": 714.632446289062
"x": 296.208190917969,
"y": 959.632446289063
},
"rotation": -60.0,
"scale": {
@@ -582,7 +606,11 @@
"x": 536.5,
"y": 154.0
},
"length": 28.0
"length": 28.0,
"guide_offset": {
"x": -49.2918090820313,
"y": 83.8824462890625
}
},
"left_lower_leg": {
"shapes": [
@@ -617,8 +645,8 @@
}
],
"position": {
"x": 115.5,
"y": 879.0
"x": 275.5,
"y": 1129.0
},
"rotation": 90.0,
"scale": {
@@ -629,7 +657,11 @@
"x": 512.0,
"y": 171.0
},
"length": 26.0
"length": 26.0,
"guide_offset": {
"x": 0.5,
"y": 94.25
}
},
"right_upper_leg": {
"shapes": [
@@ -664,8 +696,8 @@
}
],
"position": {
"x": 318.218627929688,
"y": 727.340270996094
"x": 473.218627929688,
"y": 957.340148925781
},
"rotation": 60.0,
"scale": {
@@ -676,7 +708,11 @@
"x": 464.0,
"y": 163.0
},
"length": 28.0
"length": 28.0,
"guide_offset": {
"x": 55.2186279296875,
"y": 90.590087890625
}
},
"right_lower_leg": {
"shapes": [
@@ -711,8 +747,8 @@
}
],
"position": {
"x": 403.5,
"y": 899.5
"x": 558.5,
"y": 1139.5
},
"rotation": 90.0,
"scale": {
@@ -723,11 +759,15 @@
"x": 419.0,
"y": 163.0
},
"length": 26.0
"length": 26.0,
"guide_offset": {
"x": 0.5,
"y": 96.75
}
}
},
"metadata": {
"created_at": "2026-08-18T21:39:41",
"modified_at": "2026-08-18T21:39:41"
"created_at": "2026-08-21T01:09:20",
"modified_at": "2026-08-21T01:09:20"
}
}