feat: Implement Stickman Factory and Test Harness for runtime rigging

- Added StickmanFactory to load and instantiate .stk files into master_rig.tscn.
- Created a Test Harness scene for debugging bone scales, vector drawing offsets, and IK limits.
- Updated stk_rig_adapter to fit head bone and mount head shapes as full geometry.
- Enhanced README and PROJECT documentation to reflect new features and usage.
- Introduced UI elements for file selection and visual debugging in the Test Harness.
- Refactored code to improve clarity and maintainability, including removal of unused functions.
This commit is contained in:
2026-08-18 21:44:56 -04:00
parent 48d98ce0fe
commit 1252728c06
10 changed files with 618 additions and 95 deletions
+41 -8
View File
@@ -108,14 +108,42 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
part dragging/selection. part dragging/selection.
- `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**). `static func apply(stk_data, rig)` runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9).
fits an instantiated `master_rig.tscn` to a loaded `.stk` dictionary, calling three private `static func apply(stk_data, rig)` fits an instantiated `master_rig.tscn` to a loaded `.stk`
helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D` lengths + lower-bone origins), dictionary, calling three private helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D`
`_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand` and `Left|Right_Leg` targets), lengths + lower-bone origins), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand`
and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual nodes — open → `Line2D`, and `Left|Right_Leg` targets), and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*`
closed → `Polygon2D` fill + `Line2D` outline, width 16). Targets `master_rig.tscn` node paths; visual nodes — open → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16).
every node lookup is null-guarded (missing node → `push_warning` + skip, never crash). Consumed - **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
by a future runtime pipeline. -proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every
other part — it clears the `Body/Head` node's inline `@tool` circle script via
`set_script(null)` and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. Dead helpers
`_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` were removed.
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `master_rig.tscn` instance:
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
`JSON.parse_string`); returns `{}` + `push_warning` on failure.
- `static func spawn_from_data(stk_data: Dictionary) -> Node2D` — instantiates
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root.
- `static func spawn(path: String) -> Node2D` — `load_stk()` then `spawn_from_data()`;
returns `null` on empty data.
- `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing
offsets, and IK limits in isolation. Top UI bar: "Open .stk…" button → `FileDialog` (`*.stk`);
quick-select buttons for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`; "Show
Bones" / "Show IK Handles" checkboxes; a status label showing the loaded filename. Viewport:
`SubViewportContainer` → `SubViewport` → world `Node2D` + enabled `Camera2D`; middle-mouse pan,
mouse-wheel zoom, camera recenters on each spawn. Each load frees the previous rig and spawns a
fresh one via `StickmanFactory.spawn()`. Debug overlay (a world-space `Node2D` `_draw()`): bone
lines between each `Bone2D` global origin and its parent's (color-coded left cyan / right orange /
central white + joint dots) when "Show Bones" is on; colored markers at
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
IK: click-drag the 4 limb `Marker2D` IK targets; the scene's `SkeletonModificationStack2D`
TwoBoneIK flexes limbs live. The harness enables the modification stack (`enabled = true`) after
each spawn.
- 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`,
@@ -127,6 +155,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
- `scenes/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel - `scenes/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel
sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column
height in their parent VBoxContainer. height in their parent VBoxContainer.
- `scenes/test_harness.tscn` — **standalone staging scene** (Phase 9, not wired into the
editor; run via **F6**). Backed by `scripts/test_harness.gd`. Top UI bar with file open /
quick-select, "Show Bones" / "Show IK Handles" toggles, and a loaded-filename status label;
`SubViewport` world with an enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on
spawn); interactive limb IK via `SkeletonModificationStack2D` TwoBoneIK.
### Body-part data model ### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`, - 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
+33
View File
@@ -341,6 +341,39 @@ Update Hand IK target default rests to match total arm length.
Replace default Line2D nodes under $Body/ or instantiate .stk vector shape nodes attached as children to their corresponding Bone2D / RemoteTransform2D paths. Replace default Line2D nodes under $Body/ or instantiate .stk vector shape nodes attached as children to their corresponding Bone2D / RemoteTransform2D paths.
## Stickman editor (Phase 9)
### Stickman factory
We need to implement a static stickman factory that loads .stk files. (1.4)
It should instantiate master_rig.tscn, resize all Bone2D bones dynamically according to the file's 'proportions', reposition IK targets, and mount custom vector objects(shapes) under $Body/
Proposed specs:
Bone Adaptations:
- Set $Skeleton2D/Torso/Head.position.y = -proportions.torso_length.
- Set $Skeleton2D/Torso/LeftUpperArm.length = proportions.upper_arm_length.
- Set $Skeleton2D/Torso/LeftUpperArm/LeftLowerArm.position.x = -proportions.upper_arm_length.
- Set $Skeleton2D/Torso/RightUpperArm.length = proportions.upper_arm_length.
- Set $Skeleton2D/Torso/RightUpperArm/RightLowerArm.position.x = proportions.upper_arm_length.
- Set $Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg.position.y = proportions.upper_leg_length.
- Set $Skeleton2D/Torso/RightUpperLeg/RightLowerLeg.position.y = proportions.upper_leg_length. IK Target Positions:Reposition $IK_Targets/Left_Hand and $IK_Targets/Right_Hand based on combined arm lengths.Reposition $IK_Targets/Left_Leg and $IK_Targets/Right_Leg to (upper_leg_length + lower_leg_length).Graphics Mount:
- Clear default Line2D and script components on $Body/ subnodes.
- For each body part, instantiate a drawing node that renders the .stk shape points offset by the part's "pivot" coordinate.
### Test harness scene
We need a test harness that serves as a staging ground for debugging bone scales, vector drawing offsets, and IK limits in isolation.
#### What the Test Harness Should Include:
- File Picker UI:
A button opening a FileDialog (or quick-select buttons for test files like break_2.stk) to instantly spawn or swap characters at runtime.
- Visual Debug Overlay Toggles:
Toggle Bone Visibility: Shows/hides Godot's Skeleton2D lines over the rendered .stk graphics so you can verify joint pivot alignment.
- Toggle IK Handles: Shows colored markers for hand and foot IK targets so you can click and drag them around.
- Interactive IK / Pose Dragging:
Simple click-and-drag logic on the screen to pull hand/foot targets and watch the dynamic limb flexing in real time.
## Rules ## Rules
1. Try to use standard Godot controls when applicable 1. Try to use standard Godot controls when applicable
+30 -1
View File
@@ -239,6 +239,30 @@ The Whole Stickman preview treats all shapes in a body-part panel as **one combi
2. A confirmation dialog warns that the current stickman will be cleared. 2. A confirmation dialog warns that the current stickman will be cleared.
3. Confirm to reset all panels and the preview (zoom resets to 100%, pan offset to origin). 3. Confirm to reset all panels and the preview (zoom resets to 100%, pan offset to origin).
### 14. Test Harness & Stickman Factory (Phase 9)
Phase 9 adds the **runtime pipeline** that turns a saved `.stk` file into a live, rigged `master_rig.tscn` instance — plus a standalone staging scene for debugging it in isolation. **Neither is wired into the editor.**
**Stickman Factory (`res://scripts/stickman_factory.gd`)** — the runtime entry point:
| Method | Signature | Behavior |
|---|---|---|
| `load_stk` | `static func load_stk(path: String) -> Dictionary` | Reads a `.stk` file via `FileAccess` + `JSON.parse_string`. Returns `{}` and `push_warning` on failure. |
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()``spawn_from_data()`. Returns `null` on empty data. |
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `Node2D` ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script).
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
- **Open .stk…** — `FileDialog` filtered to `*.stk`; quick-select buttons for `res://stickmen/break.stk`, `res://stickmen/basic.stk`, `res://stickmen/test.stk`.
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
- **Loaded filename** — status label showing the currently loaded file.
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
- **IK drag** — click and drag any of the 4 limb `Marker2D` IK targets (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg`); the scene's `SkeletonModificationStack2D` TwoBoneIK flexes the limb live. The harness enables the modification stack after each spawn.
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.
## File format (`.stk`) ## File format (`.stk`)
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files. Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
@@ -373,7 +397,10 @@ Behavior:
| `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.4 serialization with multi-shape/rotation/scale, `part_order`, and Phase 8 `proportions`/`pivot`/`length`, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/stk_rig_adapter.gd` | **Phase 8.** 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 (open shapes → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16). **Not used by the editor** — consumed by a future runtime pipeline. | | `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9.** 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`), mounting the `.stk` shapes onto the `Body/*` visual nodes (open shapes → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16), and (Phase 9) fitting the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. **Not used by the editor** — consumed by the runtime pipeline. |
| `res://scripts/stickman_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://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. |
| `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. |
@@ -456,3 +483,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`. > **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline. > **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
> **Phase 9:** Adds the **runtime pipeline** for turning a `.stk` file into a live, rigged `master_rig.tscn` instance, plus a standalone staging scene to debug it. A new `res://scripts/stickman_factory.gd` (`class_name StickmanFactory`) provides the runtime entry point: `load_stk(path)` reads/parses a `.stk` (`FileAccess` + `JSON.parse_string`, `{}` + `push_warning` on failure), `spawn_from_data(stk_data)` instantiates `master_rig.tscn` and applies `StkRigAdapter.apply(stk_data, rig)`, and `spawn(path)` chains them (`null` on empty data). `StkRigAdapter` is extended to also fit the head bone (`Head.position.y = -proportions.torso_length`) and to mount the head as **full geometry** like every other part — clearing its inline `@tool` circle script and mounting `.stk` head shapes as `Line2D`/`Polygon2D` (removed the dead `_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` helpers). A new standalone scene/resource pair, `res://scenes/test_harness.tscn` + `res://scripts/test_harness.gd` (run via **F6**), loads `.stk` files (open dialog + quick-select for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), toggles a world-space debug overlay (bone lines + IK-target markers), pans/zooms a `Camera2D`, and lets you click-drag the 4 limb IK targets with live TwoBoneIK flexing (`SkeletonModificationStack2D` enabled on spawn). Neither the factory nor the harness is wired into the editor. **No `.stk` format change** — `FILE_VERSION` stays `"1.4"`.
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/test_harness.gd" id="1_harness"]
[node name="TestHarness" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_harness")
+47
View File
@@ -0,0 +1,47 @@
class_name StickmanFactory
extends RefCounted
## StickmanFactory - Runtime factory for spawning rig instances from .stk files
## (Phase 9).
##
## Loads a versioned .stk JSON dictionary and instantiates a master_rig.tscn,
## adapting it via StkRigAdapter. Consumed by the standalone test harness, not
## referenced by the editor.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const RIG_SCENE := preload("res://master_rig.tscn")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
static func load_stk(path: String) -> Dictionary:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("StickmanFactory: failed to open '%s' (error %d)." % [path, FileAccess.get_open_error()])
return {}
var text := file.get_as_text()
file.close()
var parsed: Variant = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary:
push_warning("StickmanFactory: failed to parse '%s' as JSON." % path)
return {}
return parsed as Dictionary
static func spawn_from_data(stk_data: Dictionary) -> Node2D:
var rig := RIG_SCENE.instantiate() as Node2D
StkRigAdapter.apply(stk_data, rig)
return rig
static func spawn(path: String) -> Node2D:
var data := load_stk(path)
if data.is_empty():
return null
return spawn_from_data(data)
+1
View File
@@ -0,0 +1 @@
uid://cdrs7705oidyc
+15 -49
View File
@@ -61,6 +61,8 @@ const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
const IK_LEFT_LEG := "IK_Targets/Left_Leg" 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"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -90,6 +92,7 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"])) var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"])) var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"])) var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
var torso := float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
# Arms — upper length + lower-bone origin on X. # Arms — upper length + lower-bone origin on X.
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua) _set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
@@ -107,6 +110,12 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul)) _set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll) _set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
# Head — position the head bone at the top of the torso (preserve the
# existing x so the rig's ~ -0.1288 x-offset is retained).
var head_bone := rig.get_node_or_null(NodePath(HEAD_BONE_PATH))
if head_bone is Node2D:
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
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,16 +192,17 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0))) pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0)))
part_length = float(pd.get("length", 0.0)) part_length = float(pd.get("length", 0.0))
# Head: prefer setting the circle script's exports.
if part_name == "head" and ("radius" in visual):
_mount_head_circle(visual, shapes)
continue
var bone_length := _bone_length_for(part_name, proportions) var bone_length := _bone_length_for(part_name, proportions)
var scale_factor := 1.0 var scale_factor := 1.0
if part_name != "head" and part_length > 0.0001: if part_name != "head" and part_length > 0.0001:
scale_factor = bone_length / part_length scale_factor = bone_length / part_length
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
# @tool circle-drawing script; clear it so the head is mounted with the
# same full-geometry path as every other part.
if part_name == "head":
visual.set_script(null)
_reset_own_geometry(visual) _reset_own_geometry(visual)
_clear_visual_children(visual) _clear_visual_children(visual)
@@ -217,21 +227,6 @@ static func _bone_length_for(part_name: String, proportions: Dictionary) -> floa
return 1.0 return 1.0
static func _mount_head_circle(visual: Node, shapes: Array) -> void:
_clear_visual_children(visual)
if shapes.is_empty():
visual.set("radius", 0.0)
return
var radius := 0.0
var bbox := _compute_shapes_bbox(shapes)
if bbox.size.x > 0.0001 or bbox.size.y > 0.0001:
var diameter: float = bbox.size.y if bbox.size.y > 0.0001 else bbox.size.x
radius = diameter * 0.5
visual.set("radius", radius)
visual.set("color", _first_shape_color(shapes))
static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void: static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void:
var pts := _transform_points(shape.get("points", []), pivot, scale_factor) var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
if pts.size() < 2: if pts.size() < 2:
@@ -288,32 +283,3 @@ static func _clear_visual_children(visual: Node) -> void:
if child is Line2D or child is Polygon2D: if child is Line2D or child is Polygon2D:
visual.remove_child(child) visual.remove_child(child)
child.queue_free() child.queue_free()
static func _compute_shapes_bbox(shapes: Array) -> Rect2:
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for shape in shapes:
if not shape is Dictionary:
continue
var pts_var: Variant = (shape as Dictionary).get("points", [])
if pts_var is Array:
for p in pts_var as Array:
if p is Dictionary:
var d := p as Dictionary
min_x = minf(min_x, float(d.get("x", 0.0)))
min_y = minf(min_y, float(d.get("y", 0.0)))
max_x = maxf(max_x, float(d.get("x", 0.0)))
max_y = maxf(max_y, float(d.get("y", 0.0)))
if min_x > max_x or min_y > max_y:
return Rect2()
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
static func _first_shape_color(shapes: Array) -> Color:
for shape in shapes:
if shape is Dictionary:
return Color.from_string(str((shape as Dictionary).get("color", "#ffffff")), Color.WHITE)
return Color.WHITE
+401
View File
@@ -0,0 +1,401 @@
extends Control
## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9).
##
## Loads .stk files via StickmanFactory, spawns a master_rig.tscn instance into
## a SubViewport, and provides a debug overlay (skeleton bones + IK handles)
## plus interactive IK-handle dragging. NOT wired into the editor — run
## standalone via F6 on res://scenes/test_harness.tscn.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const MIN_ZOOM: float = 0.1
const MAX_ZOOM: float = 5.0
const ZOOM_STEP: float = 1.10
const HANDLE_HIT_RADIUS_PX: float = 12.0 # screen-space grab radius
const HANDLE_DRAW_RADIUS_PX: float = 6.0 # screen-space marker radius
const JOINT_DOT_RADIUS_PX: float = 4.0 # screen-space bone-joint dot radius
const BONE_LINE_WIDTH_PX: float = 2.0 # screen-space bone line width
const BONE_COLOR_LEFT := Color(0.35, 0.70, 1.00)
const BONE_COLOR_RIGHT := Color(1.00, 0.50, 0.20)
const BONE_COLOR_CENTRAL := Color(1.00, 1.00, 1.00)
const HANDLE_COLOR_HAND := Color(0.00, 1.00, 0.00) # green
const HANDLE_COLOR_FOOT := Color(0.00, 0.50, 1.00) # blue
const SKELETON_PATH := "Skeleton2D"
## Quick-load buttons: [label, res:// path].
const QUICK_LOADS: Array = [
["Break", "res://stickmen/break.stk"],
["Basic", "res://stickmen/basic.stk"],
["Test", "res://stickmen/test.stk"],
]
## IK handle node paths (relative to rig root), keyed by handle name.
const IK_HANDLE_PATHS: Dictionary = {
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
}
# ---------------------------------------------------------------------------
# Runtime-built node references
# ---------------------------------------------------------------------------
var _viewport_container: SubViewportContainer
var _viewport: SubViewport
var _world: Node2D
var _camera: Camera2D
var _debug_overlay: Node2D
var _status_label: Label
var _file_dialog: FileDialog
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _rig: Node2D = null
var _skeleton: Skeleton2D = null
var _ik_handles: Dictionary = {} # { String : Marker2D }
var _show_bones: bool = true
var _show_ik: bool = true
var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _dragging_handle: Marker2D = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_build_ui()
_viewport_container.resized.connect(_on_viewport_container_resized)
call_deferred("_on_viewport_container_resized")
func _input(event: InputEvent) -> void:
if _file_dialog != null and _file_dialog.visible:
return
if event is InputEventMouseButton:
_handle_mouse_button(event as InputEventMouseButton)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event as InputEventMouseMotion)
# ---------------------------------------------------------------------------
# UI construction
# ---------------------------------------------------------------------------
func _build_ui() -> void:
# Dark background behind the transparent SubViewport.
var bg := ColorRect.new()
bg.color = Color(0.10, 0.10, 0.10, 1.0)
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(bg)
# Top UI bar.
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0
add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
var open_btn := Button.new()
open_btn.text = "Open .stk…"
open_btn.pressed.connect(_on_open_pressed)
hbox.add_child(open_btn)
for entry: Array in QUICK_LOADS:
var btn := Button.new()
btn.text = str(entry[0])
btn.pressed.connect(_on_quick_load.bind(str(entry[1])))
hbox.add_child(btn)
var bones_cb := CheckBox.new()
bones_cb.text = "Show Bones"
bones_cb.button_pressed = true
bones_cb.toggled.connect(_on_show_bones_toggled)
hbox.add_child(bones_cb)
var ik_cb := CheckBox.new()
ik_cb.text = "Show IK Handles"
ik_cb.button_pressed = true
ik_cb.toggled.connect(_on_show_ik_toggled)
hbox.add_child(ik_cb)
_status_label = Label.new()
_status_label.text = "No file loaded"
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
hbox.add_child(_status_label)
# Viewport area (fills everything below the top bar).
_viewport_container = SubViewportContainer.new()
_viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_viewport_container.offset_top = 40.0
_viewport_container.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_viewport_container)
_viewport = SubViewport.new()
_viewport.transparent_bg = true
_viewport_container.add_child(_viewport)
_world = Node2D.new()
_world.name = "World"
_viewport.add_child(_world)
_camera = Camera2D.new()
_camera.enabled = true
_world.add_child(_camera)
_camera.make_current()
_debug_overlay = Node2D.new()
_debug_overlay.name = "DebugOverlay"
_debug_overlay.draw.connect(_on_debug_overlay_draw)
_world.add_child(_debug_overlay)
# File dialog for "Open .stk…".
_file_dialog = FileDialog.new()
_file_dialog.title = "Open .stk"
_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_file_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_file_dialog.file_selected.connect(_on_file_selected)
add_child(_file_dialog)
func _on_viewport_container_resized() -> void:
var size := _viewport_container.size
if size.x < 1.0 or size.y < 1.0:
return
_viewport.size = Vector2i(size)
# ---------------------------------------------------------------------------
# Input handling
# ---------------------------------------------------------------------------
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
if not _is_over_viewport(mb.position):
return
if mb.button_index == MOUSE_BUTTON_MIDDLE:
if mb.pressed:
_is_panning = true
_pan_last = mb.position
else:
_is_panning = false
return
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_apply_zoom(mb.position, ZOOM_STEP)
return
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_apply_zoom(mb.position, 1.0 / ZOOM_STEP)
return
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_dragging_handle = _hit_test_handle(_viewport_to_world(mb.position))
else:
_dragging_handle = null
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _is_panning:
var delta := mm.position - _pan_last
_pan_last = mm.position
_camera.position -= delta / _camera.zoom
_debug_overlay.queue_redraw()
return
if _dragging_handle != null and is_instance_valid(_dragging_handle):
_dragging_handle.global_position = _viewport_to_world(mm.position)
_debug_overlay.queue_redraw()
func _is_over_viewport(screen_pos: Vector2) -> bool:
return _viewport_container.get_global_rect().has_point(screen_pos)
func _viewport_to_world(screen_pos: Vector2) -> Vector2:
var local := screen_pos - _viewport_container.global_position
return (local - _viewport.size * 0.5) / _camera.zoom + _camera.position
func _apply_zoom(screen_pos: Vector2, factor: float) -> void:
var world_before := _viewport_to_world(screen_pos)
var new_zoom := clampf(_camera.zoom.x * factor, MIN_ZOOM, MAX_ZOOM)
_camera.zoom = Vector2(new_zoom, new_zoom)
var world_after := _viewport_to_world(screen_pos)
_camera.position += world_before - world_after
_debug_overlay.queue_redraw()
func _hit_test_handle(world_pos: Vector2) -> Marker2D:
var hit_radius := HANDLE_HIT_RADIUS_PX / _camera.zoom.x
var best: Marker2D = null
var best_dist := hit_radius
for handle_name: String in _ik_handles:
var handle := _ik_handles[handle_name] as Marker2D
if handle == null or not is_instance_valid(handle):
continue
var dist := world_pos.distance_to(handle.global_position)
if dist <= best_dist:
best = handle
best_dist = dist
return best
# ---------------------------------------------------------------------------
# Debug overlay drawing
# ---------------------------------------------------------------------------
func _on_debug_overlay_draw() -> void:
if _show_bones:
_draw_bones()
if _show_ik:
_draw_ik_handles()
func _draw_bones() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var zoom := _camera.zoom.x
var line_w := BONE_LINE_WIDTH_PX / zoom
var dot_r := JOINT_DOT_RADIUS_PX / zoom
for i: int in _skeleton.get_bone_count():
var bone := _skeleton.get_bone(i)
if bone == null:
continue
var color := _bone_color(bone.name)
var origin := bone.global_position
_debug_overlay.draw_circle(origin, dot_r, color)
var parent := bone.get_parent()
if parent is Bone2D:
_debug_overlay.draw_line((parent as Bone2D).global_position, origin, color, line_w)
func _draw_ik_handles() -> void:
var zoom := _camera.zoom.x
var r := HANDLE_DRAW_RADIUS_PX / zoom
var outline_w := 1.5 / zoom
for handle_name: String in _ik_handles:
var handle := _ik_handles[handle_name] as Marker2D
if handle == null or not is_instance_valid(handle):
continue
var color := HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
var pos := handle.global_position
_debug_overlay.draw_circle(pos, r, color)
_debug_overlay.draw_arc(pos, r, 0.0, TAU, 24, Color(1.0, 1.0, 1.0, 0.8), outline_w)
func _bone_color(bone_name: String) -> Color:
if bone_name.begins_with("Left"):
return BONE_COLOR_LEFT
if bone_name.begins_with("Right"):
return BONE_COLOR_RIGHT
return BONE_COLOR_CENTRAL
# ---------------------------------------------------------------------------
# Spawn / swap
# ---------------------------------------------------------------------------
func _on_open_pressed() -> void:
_file_dialog.popup_centered_ratio(0.6)
func _on_quick_load(path: String) -> void:
_load_and_spawn(path)
func _on_file_selected(path: String) -> void:
_load_and_spawn(path)
func _load_and_spawn(path: String) -> void:
_free_current_rig()
var rig := StickmanFactory.spawn(path)
if rig == null:
_status_label.text = "Error: failed to load " + path.get_file()
return
_rig = rig
_world.add_child(_rig)
_world.move_child(_rig, 0) # keep the rig behind the debug overlay
_resolve_rig_nodes()
_ensure_modification_stack_enabled()
_status_label.text = path.get_file()
_recenter_camera()
_debug_overlay.queue_redraw()
func _free_current_rig() -> void:
if _rig != null and is_instance_valid(_rig):
_rig.queue_free()
_rig = null
_skeleton = null
_ik_handles.clear()
_dragging_handle = null
func _resolve_rig_nodes() -> void:
_skeleton = _rig.get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH)
_ik_handles.clear()
for handle_name: String in IK_HANDLE_PATHS:
var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D
if handle != null:
_ik_handles[handle_name] = handle
else:
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
func _ensure_modification_stack_enabled() -> void:
if _skeleton == null:
return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
else:
push_warning("TestHarness: Skeleton2D has no modification_stack assigned.")
func _recenter_camera() -> void:
if _rig == null:
return
# The rig root sits at the hips; nudge the camera up so the whole figure is
# roughly centered in the viewport.
_camera.position = _rig.global_position + Vector2(0.0, -50.0)
_camera.zoom = Vector2.ONE
_debug_overlay.queue_redraw()
# ---------------------------------------------------------------------------
# Checkbox handlers
# ---------------------------------------------------------------------------
func _on_show_bones_toggled(pressed: bool) -> void:
_show_bones = pressed
_debug_overlay.queue_redraw()
func _on_show_ik_toggled(pressed: bool) -> void:
_show_ik = pressed
_debug_overlay.queue_redraw()
+1
View File
@@ -0,0 +1 @@
uid://b7wkt57rkpfo4
+37 -37
View File
@@ -59,7 +59,7 @@
"y": 227.806777954102 "y": 227.806777954102
}, },
{ {
"x": 455.448852539063, "x": 455.448852539062,
"y": 213.165756225586 "y": 213.165756225586
}, },
{ {
@@ -67,7 +67,7 @@
"y": 193.165756225586 "y": 193.165756225586
}, },
{ {
"x": 455.448852539063, "x": 455.448852539062,
"y": 173.165756225586 "y": 173.165756225586
}, },
{ {
@@ -134,7 +134,7 @@
}, },
{ {
"x": 514.874938964844, "x": 514.874938964844,
"y": 174.483520507813 "y": 174.483520507812
}, },
{ {
"x": 497.417388916016, "x": 497.417388916016,
@@ -246,31 +246,31 @@
"shape_type": "circle", "shape_type": "circle",
"points": [ "points": [
{ {
"x": 490.218383789063, "x": 490.218383789062,
"y": 122.896606445313 "y": 122.896606445312
}, },
{ {
"x": 510.218383789063, "x": 510.218383789062,
"y": 128.255599975586 "y": 128.255599975586
}, },
{ {
"x": 524.859375, "x": 524.859375,
"y": 142.896606445313 "y": 142.896606445312
}, },
{ {
"x": 530.218383789063, "x": 530.218383789062,
"y": 162.896606445313 "y": 162.896606445312
}, },
{ {
"x": 450.218383789063, "x": 450.218383789062,
"y": 162.896606445313 "y": 162.896606445312
}, },
{ {
"x": 455.577392578125, "x": 455.577392578125,
"y": 142.896606445313 "y": 142.896606445312
}, },
{ {
"x": 470.218383789063, "x": 470.218383789062,
"y": 128.255599975586 "y": 128.255599975586
} }
], ],
@@ -288,8 +288,8 @@
} }
], ],
"position": { "position": {
"x": 473.278625488281, "x": 248.278564453125,
"y": 381.460693359375 "y": 126.460693359375
}, },
"rotation": -360.0, "rotation": -360.0,
"scale": { "scale": {
@@ -335,8 +335,8 @@
} }
], ],
"position": { "position": {
"x": 649.5, "x": 419.5,
"y": 599.0 "y": 344.0
}, },
"rotation": 0.0, "rotation": 0.0,
"scale": { "scale": {
@@ -382,8 +382,8 @@
} }
], ],
"position": { "position": {
"x": 371.5, "x": 136.5,
"y": 609.5 "y": 364.5
}, },
"rotation": 0.0, "rotation": 0.0,
"scale": { "scale": {
@@ -406,11 +406,11 @@
"y": 127.010650634766 "y": 127.010650634766
}, },
{ {
"x": 533.026489257813, "x": 533.026489257812,
"y": 127.221878051758 "y": 127.221878051758
}, },
{ {
"x": 532.166625976563, "x": 532.166625976562,
"y": 142.74137878418 "y": 142.74137878418
}, },
{ {
@@ -429,8 +429,8 @@
} }
], ],
"position": { "position": {
"x": 305.000091552734, "x": 70.0000305175781,
"y": 534.999938964844 "y": 295.0
}, },
"rotation": 90.0, "rotation": 90.0,
"scale": { "scale": {
@@ -476,8 +476,8 @@
} }
], ],
"position": { "position": {
"x": 711.0, "x": 481.0,
"y": 622.0 "y": 377.0
}, },
"rotation": -180.0, "rotation": -180.0,
"scale": { "scale": {
@@ -523,8 +523,8 @@
} }
], ],
"position": { "position": {
"x": 707.087768554688, "x": 482.087707519531,
"y": 524.3349609375 "y": 289.335083007812
}, },
"rotation": 90.0, "rotation": 90.0,
"scale": { "scale": {
@@ -570,8 +570,8 @@
} }
], ],
"position": { "position": {
"x": 366.208190917969, "x": 141.208190917969,
"y": 959.632446289063 "y": 714.632446289062
}, },
"rotation": -60.0, "rotation": -60.0,
"scale": { "scale": {
@@ -617,8 +617,8 @@
} }
], ],
"position": { "position": {
"x": 345.5, "x": 115.5,
"y": 1129.0 "y": 879.0
}, },
"rotation": 90.0, "rotation": 90.0,
"scale": { "scale": {
@@ -664,8 +664,8 @@
} }
], ],
"position": { "position": {
"x": 538.218627929688, "x": 318.218627929688,
"y": 957.340209960938 "y": 727.340270996094
}, },
"rotation": 60.0, "rotation": 60.0,
"scale": { "scale": {
@@ -711,8 +711,8 @@
} }
], ],
"position": { "position": {
"x": 628.5, "x": 403.5,
"y": 1139.5 "y": 899.5
}, },
"rotation": 90.0, "rotation": 90.0,
"scale": { "scale": {
@@ -727,7 +727,7 @@
} }
}, },
"metadata": { "metadata": {
"created_at": "2026-08-18T00:05:43", "created_at": "2026-08-18T21:39:41",
"modified_at": "2026-08-18T00:05:43" "modified_at": "2026-08-18T21:39:41"
} }
} }