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