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:
@@ -19,15 +19,24 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
## Architecture
|
## Architecture
|
||||||
- `scripts/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar,
|
- `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.
|
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).
|
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
|
- **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`
|
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` =
|
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
|
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,
|
otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load,
|
||||||
recomputed on every save.
|
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.0–v1.4 files load unchanged and gain the
|
||||||
|
key on their next save.
|
||||||
- **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8,
|
- **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8,
|
||||||
most-recent-first), loads from `settings.json` (`recent_colors` key) in
|
most-recent-first), loads from `settings.json` (`recent_colors` key) in
|
||||||
`_load_settings()`, saves on each color selection via `_save_settings()`, and
|
`_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`;
|
- `scripts/whole_stickman_preview.gd` — `class_name WholeStickmanPreview`, `extends Control`;
|
||||||
the assembly preview. Owns per-part position/rotation/scale, Z-order (`_part_order`),
|
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()`,
|
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`
|
- **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()`
|
(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
|
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
|
- `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).
|
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`
|
`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`
|
dictionary, calling three 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`
|
lengths + lower-bone origins, and zeroes the Head driver's local position so the chin sits on
|
||||||
and `Left|Right_Leg` targets), `_neutralize_driver_rotations` (sets `update_rotation = false`
|
the neck joint), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand` and
|
||||||
on the 10 `Body/*` `RemoteTransform2D` drivers so the `Body/*` nodes stay in the clean
|
`Left|Right_Leg` targets), and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual
|
||||||
unrotated frame the mount math assumes; position/scale pushes are retained), and
|
nodes — one node per shape: closed → single `Polygon2D`, open → single `Line2D` width 2). The
|
||||||
`_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual nodes — open → `Line2D`,
|
`RemoteTransform2D` drivers keep their defaults (`update_rotation = true`), so mounted shapes
|
||||||
closed → `Polygon2D` fill + `Line2D` outline, width 16).
|
follow their bones in every pose.
|
||||||
- **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
|
- **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
|
-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
|
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
|
`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.
|
`_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
|
- **Phase 9 Round 2 bugfix (hanging-convention mount):** driver rotation neutralization was
|
||||||
`pivot`/`length` fields — it recomputes a per-part bounding box at mount time via
|
**removed** — the `RemoteTransform2D` drivers keep `update_rotation = true`, so each mounted
|
||||||
`_compute_part_bbox()` (empty bbox → the part is skipped). `_compute_anchor()` derives a
|
part rotates to follow its bone in every pose (IK flexing included). `_mount_shapes()` no
|
||||||
**joint-based anchor** per part family: head → bottom-center `(cx, max_y)`; torso + legs →
|
longer reads the file's `pivot`/`length` fields — it recomputes a per-part bounding box at
|
||||||
top-center `(cx, min_y)`; left arms → `(max_x, cy)`; right arms → `(min_x, cy)`. Scaling is
|
mount time via `_compute_part_bbox()` (empty bbox → the part is skipped) and derives the
|
||||||
**anisotropic** via `_compute_scale()`, applied as a `Vector2`: arms scale X only
|
mount transform via `_compute_mount_transform()`, which emits `{anchor, scale, theta}`:
|
||||||
`(bone_length/part_length, 1.0)`, legs/torso scale Y only `(1.0, bone_length/part_length)`,
|
geometry is mounted in the rig's **hanging convention** (joint anchor at the local origin,
|
||||||
head unscaled `(1.0, 1.0)`, with a `part_length <= 0` guard → `1.0`. `_bone_length_for()`
|
far end along local `+Y`). Anchors: head and torso → bottom-center `(cx, max_y)` (chin / hip
|
||||||
maps each part to its bone length (upper/lower arm/leg, torso); `X_AXIS_PARTS` const
|
end at the origin); left limbs drawn horizontally → `(max_x, cy)`; right limbs drawn
|
||||||
identifies the four arm keys. `_reset_node_transform()` resets each `Body/*` container's
|
horizontally → `(min_x, cy)`; vertically drawn limbs → top-center `(cx, min_y)`. Alignment
|
||||||
scale to `(1, 1)` and rotation to `0` (position untouched, owned by the driver).
|
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 →
|
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.
|
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
|
||||||
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
|
- `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:
|
Bones" / "Show IK Handles" checkboxes; a status label showing the loaded filename. Viewport:
|
||||||
`SubViewportContainer` → `SubViewport` → world `Node2D` + enabled `Camera2D`; middle-mouse pan,
|
`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
|
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
|
fresh one via `StickmanFactory.spawn()`. Debug overlay (a world-space `Node2D` `_draw()`): true
|
||||||
lines between each `Bone2D` global origin and its parent's (color-coded left cyan / right orange /
|
bone **segments** (a joint dot at each `Bone2D` origin + a parent→child line to each `Bone2D`
|
||||||
central white + joint dots) when "Show Bones" is on; colored markers at
|
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_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`
|
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
|
||||||
TwoBoneIK flexes limbs live. The harness enables the modification stack (`enabled = true`) after
|
flexes limbs live. The harness enables the modification stack (`enabled = true`) after each
|
||||||
each spawn.
|
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:
|
||||||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||||||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
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
|
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
|
`.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.
|
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;
|
- **Phase 9 Round 5:** per-part data adds `guide_offset` `{x, y}` (bbox center − guide joint,
|
||||||
`"1.0"`–`"1.3"` files auto-migrate on load).
|
preview space; pure master-space delta) for format v1.5. Write-only metadata like
|
||||||
|
`pivot`/`length`; the load path ignores it, so v1.0–v1.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)
|
### settings.json (Phase 6)
|
||||||
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
|
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
|
||||||
|
|||||||
@@ -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.
|
> 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
|
### Fix Shape Mount Math & Point Scaling in StkRigAdapter.gd
|
||||||
|
|
||||||
Problem Summary:
|
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).
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ The Whole Stickman preview treats all shapes in a body-part panel as **one combi
|
|||||||
2. Select a `.stk` file.
|
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).
|
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
|
### 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_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. |
|
| `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:
|
**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.
|
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
|
||||||
- **Loaded filename** — status label showing the currently loaded file.
|
- **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.
|
- **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`)
|
## File format (`.stk`)
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": "1.4",
|
"version": "1.5",
|
||||||
"stickman_name": "Bob",
|
"stickman_name": "Bob",
|
||||||
"part_order": [
|
"part_order": [
|
||||||
"head",
|
"head",
|
||||||
@@ -308,7 +308,8 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
|||||||
"rotation": 0.0,
|
"rotation": 0.0,
|
||||||
"scale": { "x": 1.0, "y": 1.0 },
|
"scale": { "x": 1.0, "y": 1.0 },
|
||||||
"pivot": { "x": 122.0, "y": 39.5 },
|
"pivot": { "x": 122.0, "y": 39.5 },
|
||||||
"length": 10.0
|
"length": 10.0,
|
||||||
|
"guide_offset": { "x": 122.0, "y": 431.0 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
@@ -322,7 +323,7 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
|||||||
|
|
||||||
| Key | Type | Description |
|
| 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. |
|
| `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.0–v1.2 files; defaults to the internal part-key order when missing. |
|
| `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.0–v1.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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.0–v1.4 files load unchanged and gain the key on their next save. |
|
||||||
|
|
||||||
### Shape object
|
### Shape object
|
||||||
|
|
||||||
@@ -396,13 +398,13 @@ Behavior:
|
|||||||
| `res://project.godot` | Engine config; sets main scene to the editor and enabled features. |
|
| `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/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://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/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 (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/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 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/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://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/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://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://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. |
|
| `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.0–v1.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 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.0–v1.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.0–v1.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.
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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 ≈ 159–160 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`).
|
||||||
@@ -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. |
|
||||||
@@ -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.0–v1.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).
|
||||||
@@ -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 397–407):
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -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).
|
||||||
@@ -48,10 +48,10 @@ const DEFAULT_POSITIONS: Dictionary = {
|
|||||||
"right_lower_leg": Vector2(165, 210),
|
"right_lower_leg": Vector2(165, 210),
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILE_VERSION := "1.4"
|
const FILE_VERSION := "1.5"
|
||||||
const FILE_FILTER := "*.stk ; Stickman Files"
|
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).
|
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2).
|
||||||
const PROPORTIONS: Dictionary = {
|
const PROPORTIONS: Dictionary = {
|
||||||
@@ -66,6 +66,22 @@ const X_AXIS_PARTS: PackedStringArray = [
|
|||||||
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
|
"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_PATH := "user://settings.json"
|
||||||
const SETTINGS_VERSION := "1.0"
|
const SETTINGS_VERSION := "1.0"
|
||||||
const DEFAULT_GRID_SIZE := 15
|
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 rot := _whole_preview.get_part_rotation(part_name)
|
||||||
var scl := _whole_preview.get_part_scale(part_name)
|
var scl := _whole_preview.get_part_scale(part_name)
|
||||||
var pl := _compute_part_pivot_length(shapes_arr, 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] = {
|
all_data[part_name] = {
|
||||||
"shapes": shapes_arr,
|
"shapes": shapes_arr,
|
||||||
"position": {"x": pos.x, "y": pos.y},
|
"position": {"x": pos.x, "y": pos.y},
|
||||||
@@ -420,6 +439,7 @@ func _collect_all_shape_data() -> Dictionary:
|
|||||||
"scale": {"x": scl.x, "y": scl.y},
|
"scale": {"x": scl.x, "y": scl.y},
|
||||||
"pivot": pl["pivot"],
|
"pivot": pl["pivot"],
|
||||||
"length": pl["length"],
|
"length": pl["length"],
|
||||||
|
"guide_offset": {"x": guide_offset.x, "y": guide_offset.y},
|
||||||
}
|
}
|
||||||
return all_data
|
return all_data
|
||||||
|
|
||||||
@@ -449,7 +469,7 @@ func _apply_json_data(json: Variant) -> String:
|
|||||||
|
|
||||||
var version: String = dict.get("version", "")
|
var version: String = dict.get("version", "")
|
||||||
if version not in SUPPORTED_VERSIONS:
|
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", "")
|
_stickman_name_edit.text = dict.get("stickman_name", "")
|
||||||
|
|
||||||
|
|||||||
+228
-75
@@ -4,16 +4,26 @@ extends RefCounted
|
|||||||
##
|
##
|
||||||
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits
|
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits
|
||||||
## the skeleton bone lengths, recalibrates the IK targets, and mounts the
|
## 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
|
## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging"
|
||||||
## runtime pipeline, never referenced by the editor.
|
## 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
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DEFAULT_LINE_WIDTH := 16.0
|
const DEFAULT_LINE_WIDTH := 2.0
|
||||||
const ELBOW_REST_Y := -256.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 = {
|
const DEFAULT_PROPORTIONS: Dictionary = {
|
||||||
"upper_arm_length": 168.0,
|
"upper_arm_length": 168.0,
|
||||||
"lower_arm_length": 200.0,
|
"lower_arm_length": 200.0,
|
||||||
@@ -30,11 +40,6 @@ const PART_KEYS: PackedStringArray = [
|
|||||||
"right_upper_leg", "right_lower_leg",
|
"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.
|
## Bone node paths (relative to rig root), keyed by part name.
|
||||||
const BONE_PATHS: Dictionary = {
|
const BONE_PATHS: Dictionary = {
|
||||||
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm",
|
"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
|
## RemoteTransform2D driver node paths (relative to rig root), keyed by part
|
||||||
## name. Each driver pushes its transform onto the matching Body/* visual node
|
## name. Each driver pushes the matching Body/* node's global transform from
|
||||||
## (see master_rig.tscn). We neutralize their rotation so the Body/* nodes stay
|
## its bone; its `global_rotation` is the bone frame used (Phase 9 Round 5) to
|
||||||
## in the clean unrotated frame the mount math assumes (position/scale pushes
|
## convert the master-space guide_offset into a bone-relative placement.
|
||||||
## are preserved).
|
|
||||||
const DRIVER_PATHS: Dictionary = {
|
const DRIVER_PATHS: Dictionary = {
|
||||||
"head": "Skeleton2D/Torso/Head/RemoteTransform2D",
|
"head": "Skeleton2D/Torso/Head/RemoteTransform2D",
|
||||||
"torso": "Skeleton2D/Torso/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 IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
||||||
|
|
||||||
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
|
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
|
||||||
|
const HEAD_DRIVER_PATH := "Skeleton2D/Torso/Head/RemoteTransform2D"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
@@ -93,7 +98,6 @@ const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
|
|||||||
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
|
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
|
||||||
_fit_bones(stk_data, rig)
|
_fit_bones(stk_data, rig)
|
||||||
_recalibrate_ik(stk_data, rig)
|
_recalibrate_ik(stk_data, rig)
|
||||||
_neutralize_driver_rotations(rig)
|
|
||||||
_mount_shapes(stk_data, 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:
|
if head_bone is Node2D:
|
||||||
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
|
_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:
|
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
||||||
var node := rig.get_node_or_null(NodePath(path))
|
var node := rig.get_node_or_null(NodePath(path))
|
||||||
@@ -183,18 +192,6 @@ static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void:
|
|||||||
else:
|
else:
|
||||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_HAND)
|
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
|
# 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)
|
push_warning("StkRigAdapter: missing Body node for part '%s'; skipped." % part_name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Reset the node's authored transform so the mount math starts from a
|
# Reset the node's authored scale/rotation so the mount math starts from
|
||||||
# clean unrotated, unit-scaled frame. Position is owned by the
|
# a unit-scaled, unrotated frame (position is owned by the
|
||||||
# RemoteTransform2D driver and is left untouched.
|
# RemoteTransform2D driver and is left untouched; the driver overwrites
|
||||||
|
# scale/rotation each frame anyway).
|
||||||
_reset_node_transform(visual)
|
_reset_node_transform(visual)
|
||||||
|
|
||||||
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
|
# 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)
|
_clear_visual_children(visual)
|
||||||
|
|
||||||
var shapes: Array = []
|
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, {})
|
var part_data: Variant = body_parts.get(part_name, {})
|
||||||
if part_data is Dictionary:
|
if part_data is Dictionary:
|
||||||
var pd := part_data as Dictionary
|
var pd := part_data as Dictionary
|
||||||
var shapes_var: Variant = pd.get("shapes", [])
|
var shapes_var: Variant = pd.get("shapes", [])
|
||||||
if shapes_var is Array:
|
if shapes_var is Array:
|
||||||
shapes = shapes_var as 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
|
# Phase 9 Round 5: the part's driver rotation (bone frame) used to
|
||||||
# mount time (the file's pivot/length fields are write-only metadata
|
# convert the master-space guide offset into a bone-relative
|
||||||
# and are no longer trusted for anchoring/scaling).
|
# 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)
|
var bbox := _compute_part_bbox(shapes)
|
||||||
if float(bbox["min_x"]) > float(bbox["max_x"]) or float(bbox["min_y"]) > float(bbox["max_y"]):
|
if float(bbox["min_x"]) > float(bbox["max_x"]) or float(bbox["min_y"]) > float(bbox["max_y"]):
|
||||||
continue
|
continue
|
||||||
var anchor := _compute_anchor(bbox, part_name)
|
var mt := _compute_mount_transform(bbox, part_name, proportions, rotation_deg, part_scale, guide_offset, has_guide_offset, c_node)
|
||||||
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)
|
|
||||||
|
|
||||||
for shape in shapes:
|
for shape in shapes:
|
||||||
if shape is Dictionary:
|
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:
|
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:
|
## Computes the mount transform for a part: `{ "anchor": Vector2, "theta":
|
||||||
var cx := (float(bbox["min_x"]) + float(bbox["max_x"])) * 0.5
|
## float, "s": float, "center": Vector2, "part_rotation": float,
|
||||||
var cy := (float(bbox["min_y"]) + float(bbox["max_y"])) * 0.5
|
## "part_scale": Vector2, "offset": Vector2 }`.
|
||||||
match part_name:
|
##
|
||||||
"head":
|
## Pipeline (spec §2c):
|
||||||
return Vector2(cx, float(bbox["max_y"])) # neck base
|
## 1. The raw bbox center C, raw joint end J_raw, and raw far point F_pt_raw
|
||||||
"left_upper_arm", "left_lower_arm":
|
## come from the Round 2 family rules (head/torso bottom-center →
|
||||||
return Vector2(float(bbox["max_x"]), cy) # shoulder at right end
|
## top-center; horizontally-drawn limbs end-to-end; vertical limbs
|
||||||
"right_upper_arm", "right_lower_arm":
|
## top-center → bottom-center).
|
||||||
return Vector2(float(bbox["min_x"]), cy) # shoulder at left end
|
## 2. The preview's part transform E(P) = C + R(rot)·S·(P − C) is applied to
|
||||||
_: # torso + all legs
|
## the anchor and far point: J' = E(J_raw), F_pt' = E(F_pt_raw),
|
||||||
return Vector2(cx, float(bbox["min_y"])) # hip/neck top-center
|
## 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:
|
## Applies the preview's part transform E(P) = C + R(rot)·S·(P − C): scale
|
||||||
if X_AXIS_PARTS.has(part_name):
|
## about the bbox center, then rotate about the bbox center.
|
||||||
return float(bbox["max_x"]) - float(bbox["min_x"])
|
static func _apply_part_transform(pt: Vector2, center: Vector2, part_scale: Vector2, rot_rad: float) -> Vector2:
|
||||||
return float(bbox["max_y"]) - float(bbox["min_y"])
|
return center + Vector2((pt.x - center.x) * part_scale.x, (pt.y - center.y) * part_scale.y).rotated(rot_rad)
|
||||||
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
static func _reset_node_transform(visual: Node) -> void:
|
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
|
(visual as Node2D).rotation = 0.0
|
||||||
|
|
||||||
|
|
||||||
static func _mount_shape(visual: Node, shape: Dictionary, anchor: Vector2, scale: Vector2) -> void:
|
static func _mount_shape(visual: Node, shape: Dictionary, mt: Dictionary) -> void:
|
||||||
var pts := _transform_points(shape.get("points", []), anchor, scale)
|
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:
|
if pts.size() < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
|
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
|
||||||
var closed := bool(shape.get("closed", false))
|
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:
|
if closed:
|
||||||
var poly := Polygon2D.new()
|
var poly := Polygon2D.new()
|
||||||
poly.polygon = pts
|
poly.polygon = pts
|
||||||
poly.color = color
|
poly.color = color
|
||||||
visual.add_child(poly)
|
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:
|
else:
|
||||||
var line := Line2D.new()
|
var line := Line2D.new()
|
||||||
line.points = pts
|
line.points = pts
|
||||||
@@ -368,23 +511,33 @@ static func _mount_shape(visual: Node, shape: Dictionary, anchor: Vector2, scale
|
|||||||
visual.add_child(line)
|
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()
|
var out := PackedVector2Array()
|
||||||
if pts_var is Array:
|
if pts_var is Array:
|
||||||
for p in pts_var as Array:
|
for p in pts_var as Array:
|
||||||
if p is Dictionary:
|
if p is Dictionary:
|
||||||
var d := p as Dictionary
|
var d := p as Dictionary
|
||||||
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
|
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:
|
elif p is Vector2:
|
||||||
var pt := p as Vector2
|
out.append(_map_point(p as Vector2, anchor, theta, s, center, part_rotation, part_scale, offset))
|
||||||
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
|
|
||||||
elif pts_var is PackedVector2Array:
|
elif pts_var is PackedVector2Array:
|
||||||
for pt in pts_var as 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
|
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:
|
static func _reset_own_geometry(visual: Node) -> void:
|
||||||
if visual is Line2D:
|
if visual is Line2D:
|
||||||
(visual as Line2D).points = PackedVector2Array()
|
(visual as Line2D).points = PackedVector2Array()
|
||||||
|
|||||||
+72
-4
@@ -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 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_HAND := Color(0.00, 1.00, 0.00) # green
|
||||||
const HANDLE_COLOR_FOOT := Color(0.00, 0.50, 1.00) # blue
|
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"
|
const SKELETON_PATH := "Skeleton2D"
|
||||||
|
|
||||||
@@ -35,11 +37,28 @@ const QUICK_LOADS: Array = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
## IK handle node paths (relative to rig root), keyed by handle name.
|
## 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 = {
|
const IK_HANDLE_PATHS: Dictionary = {
|
||||||
"Left_Hand": "IK_Targets/Left_Hand",
|
"Left_Hand": "IK_Targets/Left_Hand",
|
||||||
"Right_Hand": "IK_Targets/Right_Hand",
|
"Right_Hand": "IK_Targets/Right_Hand",
|
||||||
"Left_Leg": "IK_Targets/Left_Leg",
|
"Left_Leg": "IK_Targets/Left_Leg",
|
||||||
"Right_Leg": "IK_Targets/Right_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
|
var origin := bone.global_position
|
||||||
_debug_overlay.draw_circle(origin, dot_r, color)
|
_debug_overlay.draw_circle(origin, dot_r, color)
|
||||||
|
|
||||||
var parent := bone.get_parent()
|
# Collect the bone's Bone2D children (true bone segments, including the
|
||||||
if parent is Bone2D:
|
# nested lower bones).
|
||||||
_debug_overlay.draw_line((parent as Bone2D).global_position, origin, color, line_w)
|
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:
|
func _draw_ik_handles() -> void:
|
||||||
@@ -296,11 +341,34 @@ func _draw_ik_handles() -> void:
|
|||||||
var handle := _ik_handles[handle_name] as Marker2D
|
var handle := _ik_handles[handle_name] as Marker2D
|
||||||
if handle == null or not is_instance_valid(handle):
|
if handle == null or not is_instance_valid(handle):
|
||||||
continue
|
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
|
var pos := handle.global_position
|
||||||
_debug_overlay.draw_circle(pos, r, color)
|
_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)
|
_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:
|
func _bone_color(bone_name: String) -> Color:
|
||||||
if bone_name.begins_with("Left"):
|
if bone_name.begins_with("Left"):
|
||||||
|
|||||||
@@ -271,6 +271,16 @@ func set_show_guide(enabled: bool) -> void:
|
|||||||
preview_area.queue_redraw()
|
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:
|
func reset_view() -> void:
|
||||||
_zoom = 1.0
|
_zoom = 1.0
|
||||||
_pan_offset = Vector2.ZERO
|
_pan_offset = Vector2.ZERO
|
||||||
|
|||||||
+89
-49
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.4",
|
"version": "1.5",
|
||||||
"stickman_name": "",
|
"stickman_name": "",
|
||||||
"part_order": [
|
"part_order": [
|
||||||
"torso",
|
"torso",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"y": 227.806777954102
|
"y": 227.806777954102
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 455.448852539062,
|
"x": 455.448852539063,
|
||||||
"y": 213.165756225586
|
"y": 213.165756225586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"y": 193.165756225586
|
"y": 193.165756225586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 455.448852539062,
|
"x": 455.448852539063,
|
||||||
"y": 173.165756225586
|
"y": 173.165756225586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 514.874938964844,
|
"x": 514.874938964844,
|
||||||
"y": 174.483520507812
|
"y": 174.483520507813
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 497.417388916016,
|
"x": 497.417388916016,
|
||||||
@@ -246,31 +246,31 @@
|
|||||||
"shape_type": "circle",
|
"shape_type": "circle",
|
||||||
"points": [
|
"points": [
|
||||||
{
|
{
|
||||||
"x": 490.218383789062,
|
"x": 490.218383789063,
|
||||||
"y": 122.896606445312
|
"y": 122.896606445313
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 510.218383789062,
|
"x": 510.218383789063,
|
||||||
"y": 128.255599975586
|
"y": 128.255599975586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 524.859375,
|
"x": 524.859375,
|
||||||
"y": 142.896606445312
|
"y": 142.896606445313
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 530.218383789062,
|
"x": 530.218383789063,
|
||||||
"y": 162.896606445312
|
"y": 162.896606445313
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 450.218383789062,
|
"x": 450.218383789063,
|
||||||
"y": 162.896606445312
|
"y": 162.896606445313
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 455.577392578125,
|
"x": 455.577392578125,
|
||||||
"y": 142.896606445312
|
"y": 142.896606445313
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 470.218383789062,
|
"x": 470.218383789063,
|
||||||
"y": 128.255599975586
|
"y": 128.255599975586
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -288,8 +288,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 248.278564453125,
|
"x": 408.278564453125,
|
||||||
"y": 126.460693359375
|
"y": 381.460693359375
|
||||||
},
|
},
|
||||||
"rotation": -360.0,
|
"rotation": -360.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -300,7 +300,11 @@
|
|||||||
"x": 504.221405029297,
|
"x": 504.221405029297,
|
||||||
"y": 178.031181335449
|
"y": 178.031181335449
|
||||||
},
|
},
|
||||||
"length": 110.269149780273
|
"length": 110.269149780273,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 30.5,
|
||||||
|
"y": -78.7581176757813
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"torso": {
|
"torso": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -335,10 +339,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 419.5,
|
"x": 579.5,
|
||||||
"y": 344.0
|
"y": 569.0
|
||||||
},
|
},
|
||||||
"rotation": 0.0,
|
"rotation": 180.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
"x": 0.857142865657806,
|
"x": 0.857142865657806,
|
||||||
"y": 3.9898989200592
|
"y": 3.9898989200592
|
||||||
@@ -347,7 +351,11 @@
|
|||||||
"x": 300.5,
|
"x": 300.5,
|
||||||
"y": 258.5
|
"y": 258.5
|
||||||
},
|
},
|
||||||
"length": 99.0
|
"length": 99.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": -2.0,
|
||||||
|
"y": -202.25
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"left_upper_arm": {
|
"left_upper_arm": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -382,8 +390,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 136.5,
|
"x": 306.5,
|
||||||
"y": 364.5
|
"y": 604.5
|
||||||
},
|
},
|
||||||
"rotation": 0.0,
|
"rotation": 0.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -394,7 +402,11 @@
|
|||||||
"x": 489.5,
|
"x": 489.5,
|
||||||
"y": 169.5
|
"y": 169.5
|
||||||
},
|
},
|
||||||
"length": 107.0
|
"length": 107.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": -86.0,
|
||||||
|
"y": -7.75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"left_lower_arm": {
|
"left_lower_arm": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -406,11 +418,11 @@
|
|||||||
"y": 127.010650634766
|
"y": 127.010650634766
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 533.026489257812,
|
"x": 533.026489257813,
|
||||||
"y": 127.221878051758
|
"y": 127.221878051758
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 532.166625976562,
|
"x": 532.166625976563,
|
||||||
"y": 142.74137878418
|
"y": 142.74137878418
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -429,8 +441,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 70.0000305175781,
|
"x": 234.999969482422,
|
||||||
"y": 295.0
|
"y": 535.000061035156
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -441,7 +453,11 @@
|
|||||||
"x": 479.999969482422,
|
"x": 479.999969482422,
|
||||||
"y": 135.0
|
"y": 135.0
|
||||||
},
|
},
|
||||||
"length": 106.053039550781
|
"length": 106.053039550781,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 0.99993896484375,
|
||||||
|
"y": -103.749938964844
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"right_upper_arm": {
|
"right_upper_arm": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -476,8 +492,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 481.0,
|
"x": 641.0,
|
||||||
"y": 377.0
|
"y": 617.0
|
||||||
},
|
},
|
||||||
"rotation": -180.0,
|
"rotation": -180.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -488,7 +504,11 @@
|
|||||||
"x": 329.0,
|
"x": 329.0,
|
||||||
"y": 158.0
|
"y": 158.0
|
||||||
},
|
},
|
||||||
"length": 107.0
|
"length": 107.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 88.0,
|
||||||
|
"y": -6.75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"right_lower_arm": {
|
"right_lower_arm": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -523,8 +543,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 482.087707519531,
|
"x": 637.087707519531,
|
||||||
"y": 289.335083007812
|
"y": 529.335083007813
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -535,7 +555,11 @@
|
|||||||
"x": 412.91227722168,
|
"x": 412.91227722168,
|
||||||
"y": 143.680892944336
|
"y": 143.680892944336
|
||||||
},
|
},
|
||||||
"length": 106.053070068359
|
"length": 106.053070068359,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 0.0,
|
||||||
|
"y": -100.734008789063
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"left_upper_leg": {
|
"left_upper_leg": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -570,8 +594,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 141.208190917969,
|
"x": 296.208190917969,
|
||||||
"y": 714.632446289062
|
"y": 959.632446289063
|
||||||
},
|
},
|
||||||
"rotation": -60.0,
|
"rotation": -60.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -582,7 +606,11 @@
|
|||||||
"x": 536.5,
|
"x": 536.5,
|
||||||
"y": 154.0
|
"y": 154.0
|
||||||
},
|
},
|
||||||
"length": 28.0
|
"length": 28.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": -49.2918090820313,
|
||||||
|
"y": 83.8824462890625
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"left_lower_leg": {
|
"left_lower_leg": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -617,8 +645,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 115.5,
|
"x": 275.5,
|
||||||
"y": 879.0
|
"y": 1129.0
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -629,7 +657,11 @@
|
|||||||
"x": 512.0,
|
"x": 512.0,
|
||||||
"y": 171.0
|
"y": 171.0
|
||||||
},
|
},
|
||||||
"length": 26.0
|
"length": 26.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 94.25
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"right_upper_leg": {
|
"right_upper_leg": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -664,8 +696,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 318.218627929688,
|
"x": 473.218627929688,
|
||||||
"y": 727.340270996094
|
"y": 957.340148925781
|
||||||
},
|
},
|
||||||
"rotation": 60.0,
|
"rotation": 60.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -676,7 +708,11 @@
|
|||||||
"x": 464.0,
|
"x": 464.0,
|
||||||
"y": 163.0
|
"y": 163.0
|
||||||
},
|
},
|
||||||
"length": 28.0
|
"length": 28.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 55.2186279296875,
|
||||||
|
"y": 90.590087890625
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"right_lower_leg": {
|
"right_lower_leg": {
|
||||||
"shapes": [
|
"shapes": [
|
||||||
@@ -711,8 +747,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 403.5,
|
"x": 558.5,
|
||||||
"y": 899.5
|
"y": 1139.5
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -723,11 +759,15 @@
|
|||||||
"x": 419.0,
|
"x": 419.0,
|
||||||
"y": 163.0
|
"y": 163.0
|
||||||
},
|
},
|
||||||
"length": 26.0
|
"length": 26.0,
|
||||||
|
"guide_offset": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 96.75
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"created_at": "2026-08-18T21:39:41",
|
"created_at": "2026-08-21T01:09:20",
|
||||||
"modified_at": "2026-08-18T21:39:41"
|
"modified_at": "2026-08-21T01:09:20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user