Add StkRigAdapter for runtime skeleton fitting and shape mounting
- Implemented StkRigAdapter class to adapt a master rig to a loaded .stk dictionary. - Added methods for fitting bone lengths, recalibrating IK targets, and mounting vector shapes. - Defined constants for default proportions and bone paths. - Included error handling for missing nodes and invalid data structures.
This commit is contained in:
@@ -19,9 +19,15 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
## Architecture
|
||||
- `scripts/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar,
|
||||
save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels.
|
||||
Writes `FILE_VERSION "1.2"`; auto-migrates `"1.0"`/`"1.1"` files on load. Coordinates cross-panel
|
||||
Writes `FILE_VERSION "1.4"`; auto-migrates `"1.0"`–`"1.3"` files on load. Coordinates cross-panel
|
||||
selection so only one shape is selected at a time (`shape_selected` → deselect others).
|
||||
Collects per-part `{shapes[], position, rotation, scale}` for save/load.
|
||||
Collects per-part `{shapes[], position, rotation, scale, pivot, length}` for save/load.
|
||||
- **Phase 8 save export:** writes top-level `proportions` (hardcoded master-rig rest-pose
|
||||
constants 168/200/200/200/391.5 via the `PROPORTIONS` const) and per-part `pivot`/`length`
|
||||
computed from the panel's local shape bounding box (`_compute_part_pivot_length()`): `pivot` =
|
||||
bbox center, `length` = bbox width for the 4 arm parts (`X_AXIS_PARTS`) and bbox height
|
||||
otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load,
|
||||
recomputed on every save.
|
||||
- **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8,
|
||||
most-recent-first), loads from `settings.json` (`recent_colors` key) in
|
||||
`_load_settings()`, saves on each color selection via `_save_settings()`, and
|
||||
@@ -37,6 +43,17 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`global_to_world(global_pos)`, and writes `"X: ### Y: ###"` to `_status_cursor_coords`.
|
||||
- **Phase 6 snap status:** `_status_snap_status` displays `"SNAP: ON"` / `"SNAP: OFF"`,
|
||||
set in `_ready()` and re-synced when snap is toggled (`_on_edit_menu_id_pressed`).
|
||||
- **Phase 7 pose guide toggle:** stores `_show_guide: bool` (default `true`), persisted to
|
||||
`settings.json` under the `show_pose_guide` key (default `true`) via `_load_settings()` /
|
||||
`_save_settings()`. The View menu item (id 1) is a **dynamic, text-only** label (no
|
||||
checkmark): "Hide Pose Guide" while the guide is visible, "Show Pose Guide" while hidden,
|
||||
set via `_guide_menu_label()`. `_update_guide_menu_item()` refreshes only the item text;
|
||||
it is synced on the `about_to_popup` signal (`_on_view_menu_about_to_popup`) and again at
|
||||
the end of `_load_settings()` (so a persisted `show_pose_guide: false` shows "Show Pose
|
||||
Guide" immediately at startup). `_on_view_menu_id_pressed` (id 1) toggles the state, saves,
|
||||
and broadcasts. `_broadcast_settings()` pushes the value to the preview via
|
||||
`WholeStickmanPreview.set_show_guide(_show_guide)` (called in `_ready()` after
|
||||
`_load_settings()`).
|
||||
- `scripts/body_part_panel.gd` — `class_name BodyPartPanel`, `extends PanelContainer`.
|
||||
Reusable per-part editor. Public API:
|
||||
- `set_shape_data(data: Variant)` — import shape data (Array or single Dictionary; used on Load/Clear)
|
||||
@@ -69,6 +86,36 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
- **Selection gizmos always on top:** bounding box, rotation circle, and scale
|
||||
crosses for the selected part are drawn in a second pass after all parts,
|
||||
via `_selected_gizmo_bounds`, so they always render in front.
|
||||
- `scripts/whole_stickman_preview.gd` — `class_name WholeStickmanPreview`, `extends Control`;
|
||||
the assembly preview. Owns per-part position/rotation/scale, Z-order (`_part_order`),
|
||||
selection + gizmos, grid drawing, and pan/zoom. Public API includes `set_body_parts()`,
|
||||
`set_show_guide(enabled: bool)`, `reset_view()`, and `is_cursor_over_preview(global_pos)`.
|
||||
- **Phase 7 pose silhouette guide:** `set_show_guide()` stores `_show_guide: bool`
|
||||
(default `true`) and redraws; `_draw_silhouette_guide()` is called in `_on_preview_draw()`
|
||||
after the part loop and before the drag highlight/selection gizmos, so it renders above
|
||||
the grid **and in front of user parts** (ghosting over them), below the selection
|
||||
gizmos and drag highlight. The guide is **centered** at the default view and after
|
||||
`Reset Views`, computed at draw time from the live preview size via
|
||||
`_guide_to_preview()` = `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE +
|
||||
preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
|
||||
world-space fixture that moves with pan/zoom. Joint positions are **hardcoded
|
||||
constants** derived from the `master_rig.tscn` rest pose (`GUIDE_JOINTS`: 13 anchors
|
||||
Head/Neck/Shoulders/Elbows/Wrists/Hips/Knees/Ankles;
|
||||
`GUIDE_SCALE = 1.0`, `GUIDE_FIGURE_CENTER = (0, -93.75)`, `GUIDE_HEAD_RADIUS = 100.0`).
|
||||
Color-coded: left limbs cyan-blue `Color(0.35, 0.70, 1.00)`, right limbs orange-red
|
||||
`Color(1.00, 0.50, 0.20)`, central spine/head white, with lines at alpha `0.45` and joint
|
||||
dots at alpha `0.65`. Joint dots use `GUIDE_JOINT_RADIUS / _zoom` (6 px constant screen
|
||||
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
|
||||
part dragging/selection.
|
||||
- `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)`
|
||||
fits an instantiated `master_rig.tscn` to a loaded `.stk` 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` and `Left|Right_Leg` targets),
|
||||
and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual nodes — open → `Line2D`,
|
||||
closed → `Polygon2D` fill + `Line2D` outline, width 16). 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.
|
||||
- Scenes:
|
||||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
||||
@@ -95,15 +142,22 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
- **Phase 4:** A panel stores a `shapes[]` array of shape dictionaries. Z-order = array
|
||||
position (first = back, last = front). Per-part data includes `{shapes[], position,
|
||||
rotation, scale}`.
|
||||
- The JSON `.stk` format is defined in `README.md` (versioned `"1.2"`, extensible;
|
||||
`"1.0"`/`"1.1"` files auto-migrate on load).
|
||||
- **Phase 8:** per-part data adds `pivot` `{x, y}` (local bounding-box center = rotation
|
||||
origin) and `length` (float, bbox extent along the segment axis) for format v1.4; the
|
||||
`.stk` root also gains a top-level `proportions` object (5 rig bone lengths). All three
|
||||
are write-only metadata recomputed on every save — never read back on load.
|
||||
- The JSON `.stk` format is defined in `README.md` (versioned `"1.4"`, extensible;
|
||||
`"1.0"`–`"1.3"` files auto-migrate on load).
|
||||
|
||||
### settings.json (Phase 6)
|
||||
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
|
||||
loaded in `_load_settings()`.
|
||||
- Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`.
|
||||
- Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`, `show_pose_guide`.
|
||||
- `recent_colors: Array[String]` — the last up-to-8 selected hex colors, most-recent-first.
|
||||
Populated on load and flushed on every color selection.
|
||||
- `show_pose_guide: bool` — whether the pose silhouette guide is visible in the Whole
|
||||
Stickman preview. Default `true`. Loaded in `_load_settings()` and flushed on every toggle
|
||||
via `_broadcast_settings()`.
|
||||
|
||||
### Legacy scene (do not delete)
|
||||
- `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets +
|
||||
|
||||
@@ -39,10 +39,30 @@
|
||||
## Stickman editor (Phase 6 Round 1)
|
||||
|
||||
### Seletion bounding box as well as rotation 'dot' should stay in front of all other objects.
|
||||
|
||||
If an object is selected, it's bounding box and rotation dot should be in front of all other objects.
|
||||
|
||||
### Touchpad panning is too slow
|
||||
|
||||
Panning using the touchpad does not moves too slowly. We need to increase the distance the panning moves through touchpad.
|
||||
|
||||
### Zooming in/out
|
||||
|
||||
When zooming in, we should use the mouse cursor as the zoom in point. If the cursor is in the window should determine where the zoom should happen.
|
||||
|
||||
## Stickman editor (Phase 7 Round 1)
|
||||
|
||||
### Silhouette starting position (or camera)
|
||||
|
||||
When starting the application, the silhouette stickman should be in the center of the view. Right now it starts on the left upper side of the view so the user has to pan over.
|
||||
Either move the silhouette or the camera.
|
||||
|
||||
### Showing the pose guide
|
||||
|
||||
When clicking View->Show Pose Guide the guide toggles on and off, but the text never changes.
|
||||
By default the pose is visible so the menu text should say "Hide Pose Guide"
|
||||
When it is hidden it should say "Show Pose Guide"
|
||||
|
||||
### Guide visibility
|
||||
|
||||
The guide and joints should slightly 'ghost' in front of the objects so that the use can see how well the objects are aligned to the joints and limbs.
|
||||
|
||||
+55
@@ -268,23 +268,78 @@ The .stk file should account for the new attributes introduced in this phase.
|
||||
## Stickman editor (Phase 6)
|
||||
|
||||
### Touchpad controls
|
||||
|
||||
Currently the mouse handles most of the controls. We need to adapt the controls to a touchpad as well. We should make the following adaptions:
|
||||
|
||||
- Panning in each window is handled by holding down the middle mouse button and moving the mouse. This should be done on a touchpad by using a 2 finger drag.
|
||||
- Zoom in handled by the scroll wheel on the mouse. It should also be handled by pinch zooming on the touchpad.
|
||||
|
||||
### Color picker recent colors
|
||||
|
||||
The color picker currently is not storing recent colors. It should keep track of at least 8 colors that were used previously in the project so that the user can reuse colors.
|
||||
|
||||
### Bottom of screen status bar
|
||||
|
||||
Just like the menu bar at the top of the screen, the bottom of the screen should have a status bar that will hold information about the project, current operation, etc. It should be easily readable
|
||||
|
||||
### Pixel (x, y) display on the status bar
|
||||
|
||||
In the status bar described above, we should display the current coordinates of the cursor in the current window in pixels. The coordinates should be based off of which window the cursor is currently over. Make sure to take into account panning and zooming, so the windows should probably have a set coordinate limit. Maybe (0, 0) to (large x, large y) This coordinate display should be on the left side of the status bar.
|
||||
|
||||
### Snap status
|
||||
|
||||
In the status bar mentioned above, there should a display that shows if snapping is on or off. Label it as "SNAP: OFF" or "SNAP: ON". That display should be on the right side of the status bar.
|
||||
|
||||
## Stickman editor (Phase 7)
|
||||
|
||||
### Stickman pose silhouette guide
|
||||
|
||||
In our 'whole stickman' window, we need to create a silhouette guide that the user can use to place the stickman part shapes. This will help with rigging the parts with bones later on.
|
||||
Currently we have a new scene master_rig.tscn that I want to use as the 'pose'
|
||||
The pose should be rendered semi-transparent with visual joint anchors matching the rest pose dimensions of the master_rig.tscn
|
||||
The joints should be small circular joint anchor indicators (radius 6.0 px) at all key pivot locations: Head, Neck, LeftShoulder, LeftElbow, LeftWrist, RightShoulder, RightElbow, RightWrist, Hips, LeftKnee, LeftAnkle, RightKnee, RightAnkle.
|
||||
Use clear visual separation to prevent misassigned limb axes:
|
||||
Left Side (Left Arm / Left Leg): Cyan/Blue tint
|
||||
Right Side (Right Arm / Right Leg): Orange/Red tint
|
||||
Central Axis (Spine / Head): Neutral White tint
|
||||
|
||||
## Stickman editor (Phase 8)
|
||||
|
||||
### .stk proportion & joint export + runtime skeleton adapter
|
||||
|
||||
We are building a dynamic stickman pipeline. .stk vector graphics need to automatically fit onto master_rig.tscn. The .stk JSON currently contains vector shapes, part transforms, and colors, but lacks explicit joint pivot and bone length definitions.
|
||||
Update .stk Save Exporter:
|
||||
When saving/exporting a .stk file after silhouette alignment, calculate and append a top-level "proportions" object containing:
|
||||
upper_arm_length: distance between shoulder joint and elbow joint.
|
||||
lower_arm_length: distance between elbow joint and wrist joint.
|
||||
upper_leg_length: distance between hip joint and knee joint.
|
||||
lower_leg_length: distance between knee joint and ankle joint.
|
||||
torso_length: height distance from hip base to neck.
|
||||
Inside each part under "body_parts", add:
|
||||
"pivot": { "x": float, "y": float } (local origin point of rotation).
|
||||
"length": float (calculated pixel length for that specific segment).
|
||||
|
||||
### Runtime skeleton adapter
|
||||
|
||||
Build 'StkRigAdapter.gd' (Godot GDScript Loader):
|
||||
Write a utility script StkRigAdapter.gd that takes a loaded .stk JSON dictionary and an instantiated master_rig.tscn node.
|
||||
|
||||
#### Bone Fitting Logic:
|
||||
|
||||
Read "proportions" from .stk.
|
||||
Adjust Skeleton2D/Torso/LeftUpperArm.length and set LeftLowerArm.position.x = -upper_arm_length.
|
||||
Adjust Skeleton2D/Torso/RightUpperArm.length and set RightLowerArm.position.x = upper_arm_length.
|
||||
Adjust Skeleton2D/Torso/LeftUpperLeg.length and set LeftLowerLeg.position.y = upper_leg_length.
|
||||
Adjust Skeleton2D/Torso/RightUpperLeg.length and set RightLowerLeg.position.y = upper_leg_length.
|
||||
|
||||
#### IK Target Recalibration:
|
||||
|
||||
Update IK_Targets/Left_Leg.position.y and IK_Targets/Right_Leg.position.y to equal (upper_leg_length + lower_leg_length).
|
||||
Update Hand IK target default rests to match total arm length.
|
||||
|
||||
#### Visual Shape Mount:
|
||||
|
||||
Replace default Line2D nodes under $Body/ or instantiate .stk vector shape nodes attached as children to their corresponding Bone2D / RemoteTransform2D paths.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ The editor is organized as **11 sub-windows** in a 3-column layout:
|
||||
- **Center column (5)** — Torso, Right Upper Arm, Right Lower Arm, Right Upper Leg, Right Lower Leg
|
||||
- **Right column (1)** — **Whole Stickman** preview
|
||||
|
||||
The editor is driven by three menus: **File** (Save / Load / Clear), **Edit** (Configure Grid... / Snap to Grid), and **View** (Reset Views).
|
||||
The editor is driven by three menus: **File** (Save / Load / Clear), **Edit** (Configure Grid... / Snap to Grid), and **View** (Reset Views / Hide or Show Pose Guide).
|
||||
|
||||
Each body-part panel is an independent vector drawing surface with per-panel zoom **and panning (middle-mouse drag)**. A configurable background grid helps align vertices, and snap-to-grid can be enabled for both vertex dragging and whole-figure assembly. **Each panel supports multiple shapes** with Z-ordering controls (Send Back / Bring Forward) and shape-level Copy/Paste and Mirror operations. The Whole Stickman panel assembles every part into one figure — treating all shapes in a panel as a single unit — and lets you reposition, rotate, and scale each part, reorder the parts (Z-order), and mirror them.
|
||||
|
||||
@@ -96,7 +96,29 @@ Scroll the mouse wheel inside any body-part panel or the Whole Stickman preview
|
||||
- **Send Back / Bring Forward** — change the part's draw order in the preview.
|
||||
- **Mirror X / Mirror Y** — flip the part around its bounding-box center.
|
||||
|
||||
### 5. Grid & Panning
|
||||
### 5. Pose Silhouette Guide
|
||||
|
||||
The Whole Stickman preview can render a **pose silhouette guide**: a semi-transparent, color-coded stick figure that marks the rest-pose pivot joints of the future rig defined by `master_rig.tscn`. Its 13 joint anchors (Head, Neck, Left/Right Shoulder, Left/Right Elbow, Left/Right Wrist, Hips, Left/Right Knee, Left/Right Ankle) correspond to the exact pivot locations a future `Skeleton2D` rig will use, so you can align your body-part shapes to the same pivots.
|
||||
|
||||
The guide is a **view aid only**: it is drawn above the grid **and in front of user parts** (ghosting over them), moves with pan/zoom, is **not** hit-testable (it never intercepts part dragging or selection), and is **not** saved to the `.stk` file.
|
||||
|
||||
**Color coding** (both lines and joint dots use these color groups):
|
||||
|
||||
| Group | Joints | Color | Line alpha | Joint alpha |
|
||||
|---|---|---|---|---|
|
||||
| Left (cyan/blue) | LeftShoulder, LeftElbow, LeftWrist, LeftKnee, LeftAnkle | `(0.35, 0.70, 1.00)` | `0.45` | `0.65` |
|
||||
| Right (orange/red) | RightShoulder, RightElbow, RightWrist, RightKnee, RightAnkle | `(1.00, 0.50, 0.20)` | `0.45` | `0.65` |
|
||||
| Central (white) | Hips, Neck, Head | `(1.00, 1.00, 1.00)` | `0.45` | `0.65` |
|
||||
|
||||
- **Lines** (limbs + spine) render semi-transparent with line alpha `0.45`; the **head outline** is a white semi-transparent circle of radius 100 (1:1 `master_rig.tscn` scale) centered on the Head joint.
|
||||
- **Joint dots** are constant **6 px** on screen at every zoom level (`GUIDE_JOINT_RADIUS / _zoom`).
|
||||
- The guide is **ON by default**. Toggle it via **View → Hide Pose Guide** / **View → Show Pose Guide** (the menu item is a dynamic, text-only label — no checkmark — reading "Hide Pose Guide" while the guide is visible and "Show Pose Guide" while it is hidden); the setting persists to `user://settings.json` (`show_pose_guide`).
|
||||
|
||||
**Centered at the default view:** the guide's figure bounding box is centered at the preview-area center, computed at draw time from the live preview size (`GUIDE_FIGURE_CENTER = (0, -93.75)`; `_guide_to_preview()` maps `master_pos` to `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5`). It is centered at startup and after **Reset Views** (zoom 1 / pan 0), and re-centers automatically when the window is resized. Because it remains a **world-space fixture**, panning/zooming moves it with the grid and parts.
|
||||
|
||||
**1:1 master-rig scale (deliberate):** the guide matches `master_rig.tscn` proportions exactly (a figure of ≈ 336 × 940 px, roughly x ∈ [-168, 168], y ∈ [-563.5, 376]). Because the default starter parts are small, the guide intentionally **dwarfs** them. At 1:1 zoom the ~940 px figure is taller than a typical panel, so the head and feet clip equally above/below the fold when centered — accepted. `GUIDE_SCALE`/`GUIDE_FIGURE_CENTER` are the tunables if a different size or placement is wanted later.
|
||||
|
||||
### 6. Grid & Panning
|
||||
|
||||
All 10 body-part panels and the Whole Stickman preview share a global background grid (default cell size **15 px**, Phase 5; previously 5 px):
|
||||
|
||||
@@ -113,7 +135,7 @@ All 10 body-part panels and the Whole Stickman preview share a global background
|
||||
- **View → Reset Views** restores 100% zoom and the origin offset on all panels.
|
||||
4. Grid lines are drawn behind shapes and scale with pan/zoom, so they stay aligned to world coordinates.
|
||||
|
||||
### 6. Changing Colors
|
||||
### 7. Changing Colors
|
||||
|
||||
Colors are easy to change on any existing shape:
|
||||
|
||||
@@ -124,7 +146,7 @@ Colors are easy to change on any existing shape:
|
||||
|
||||
The chosen color is stored in the shape's `color` field and is saved/loaded with the `.stk` file.
|
||||
|
||||
### 7. Deleting
|
||||
### 8. Deleting
|
||||
|
||||
Two levels of deletion are available:
|
||||
|
||||
@@ -138,7 +160,7 @@ Two levels of deletion are available:
|
||||
2. If 2 vertices remain, the shape becomes an open **line** (`closed = false`).
|
||||
3. If 1 vertex or fewer remains, the entire shape is cleared.
|
||||
|
||||
### 8. Multiple Shapes & Z-Ordering
|
||||
### 9. Multiple Shapes & Z-Ordering
|
||||
|
||||
Each body-part panel can contain **more than one shape**. New shapes are created via the standard right-click context menu and are drawn on top of existing shapes. Shapes are drawn in Z-order (last in the list = frontmost).
|
||||
|
||||
@@ -164,7 +186,7 @@ Only one shape within a panel can be selected at a time for vertex editing. Left
|
||||
2. The shape's vertices are mirrored around the shape's bounding-box center.
|
||||
3. Mirroring recomputes the `points` array in place — no extra fields are stored; the result is saved as normal vertex data.
|
||||
|
||||
### 9. Whole Stickman Manipulation (Selection, Rotation, Scale)
|
||||
### 10. Whole Stickman Manipulation (Selection, Rotation, Scale)
|
||||
|
||||
The Whole Stickman preview treats all shapes in a body-part panel as **one combined object**. Each part can be independently translated, rotated, and scaled.
|
||||
|
||||
@@ -197,21 +219,21 @@ The Whole Stickman preview treats all shapes in a body-part panel as **one combi
|
||||
- Selection respects Z-order: hit-testing runs **front-to-back**, so the frontmost part under the pointer is selected.
|
||||
- Part Z-order is saved in the `.stk` file as the top-level `part_order` array.
|
||||
|
||||
### 10. Save
|
||||
### 11. Save
|
||||
|
||||
1. Click **File → Save**.
|
||||
2. Choose a location and name. The default extension is `.stk` (appended automatically if omitted).
|
||||
3. Click **Save**. The figure is written as JSON.
|
||||
|
||||
### 11. Load
|
||||
### 12. Load
|
||||
|
||||
1. Click **File → Load**.
|
||||
2. Select a `.stk` file.
|
||||
3. On success, all body-part panels and the Whole Stickman preview are populated. On failure, an error dialog reports the problem (missing file, parse error, or unsupported version).
|
||||
|
||||
> v1.0, v1.1, and v1.2 files are automatically migrated to v1.3 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).
|
||||
> 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.
|
||||
|
||||
### 12. Clear
|
||||
### 13. Clear
|
||||
|
||||
1. Click **File → Clear**.
|
||||
2. A confirmation dialog warns that the current stickman will be cleared.
|
||||
@@ -223,7 +245,7 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.3",
|
||||
"version": "1.4",
|
||||
"stickman_name": "Bob",
|
||||
"part_order": [
|
||||
"head",
|
||||
@@ -237,6 +259,13 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
||||
"right_upper_leg",
|
||||
"right_lower_leg"
|
||||
],
|
||||
"proportions": {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5
|
||||
},
|
||||
"body_parts": {
|
||||
"head": {
|
||||
"shapes": [
|
||||
@@ -253,7 +282,9 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
||||
],
|
||||
"position": { "x": 150, "y": 40 },
|
||||
"rotation": 0.0,
|
||||
"scale": { "x": 1.0, "y": 1.0 }
|
||||
"scale": { "x": 1.0, "y": 1.0 },
|
||||
"pivot": { "x": 122.0, "y": 39.5 },
|
||||
"length": 10.0
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
@@ -267,9 +298,10 @@ Files are UTF-8 JSON, pretty-printed with tab indentation. The format is version
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `version` | `string` | Format version. Currently `"1.3"`. Loading supports `"1.0"`–`"1.3"` (auto-migrated). |
|
||||
| `version` | `string` | Format version. Currently `"1.4"`. Loading supports `"1.0"`–`"1.4"` (auto-migrated). |
|
||||
| `stickman_name` | `string` | Optional display name for the figure. |
|
||||
| `part_order` | `array[string]` | **Phase 5.** Render/Z-order of parts in the Whole Stickman preview, front-to-back semantics per array position (first = back, last = front). Absent on v1.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. |
|
||||
| `body_parts` | `object` | Map of `body_part_name → shape` objects. Keyed by the 10 internal part names below. |
|
||||
| `metadata.created_at` | `string` | Timestamp written on save. |
|
||||
| `metadata.modified_at` | `string` | Timestamp written on save. |
|
||||
@@ -288,6 +320,8 @@ Each body part is an object with the following keys:
|
||||
| `position` | `object` | `{ "x": float, "y": float }` position of the part within the Whole Stickman preview. |
|
||||
| `rotation` | `float` | Rotation in degrees. `0.0` = original orientation. Defaults to `0.0` for v1.0/v1.1 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. |
|
||||
| `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. |
|
||||
|
||||
### Shape object
|
||||
|
||||
@@ -311,7 +345,8 @@ Phase 3 introduces a global settings file stored in Godot's **user data** direct
|
||||
{
|
||||
"version": "1.0",
|
||||
"grid_size": 15,
|
||||
"snap_to_grid": false
|
||||
"snap_to_grid": false,
|
||||
"show_pose_guide": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -322,10 +357,11 @@ Phase 3 introduces a global settings file stored in Godot's **user data** direct
|
||||
| `version` | `string` | `"1.0"` | Settings file version (for future extensibility). |
|
||||
| `grid_size` | `int` | `15` | Grid interval in pixels, applied equally to width and height. Default changed from `5` to `15` in Phase 5. Clamped to 1–100 on load. |
|
||||
| `snap_to_grid` | `bool` | `false` | Whether snap-to-grid is active. |
|
||||
| `show_pose_guide` | `bool` | `true` | Whether the pose silhouette guide is visible in the Whole Stickman preview. |
|
||||
|
||||
Behavior:
|
||||
- **Load** — read on editor startup; if the file is missing or fails to parse, defaults (`grid_size = 15`, `snap_to_grid = false`) are used silently (no error dialog).
|
||||
- **Save** — written whenever the user changes the grid size or toggles Snap to Grid.
|
||||
- **Load** — read on editor startup; if the file is missing or fails to parse, defaults (`grid_size = 15`, `snap_to_grid = false`, `show_pose_guide = true`) are used silently (no error dialog).
|
||||
- **Save** — written whenever the user changes the grid size, toggles Snap to Grid, or toggles the pose guide.
|
||||
- **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting.
|
||||
- **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views".
|
||||
|
||||
@@ -336,9 +372,10 @@ Behavior:
|
||||
| `res://project.godot` | Engine config; sets main scene to the editor and enabled features. |
|
||||
| `res://scenes/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
|
||||
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
||||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.3 serialization with multi-shape/rotation/scale and `part_order`, `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/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, 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://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. |
|
||||
@@ -349,14 +386,14 @@ Behavior:
|
||||
File Edit View
|
||||
──────────── ──────────────────── ───────────
|
||||
Save Configure Grid... Reset Views
|
||||
Load ─────────
|
||||
──────── Snap to Grid (check)
|
||||
Load ───────── ─────────────
|
||||
──────── Snap to Grid (check) Hide/Show Pose Guide
|
||||
Clear
|
||||
```
|
||||
|
||||
- **File** — Save, Load, Clear (Phase 1).
|
||||
- **Edit** — `Configure Grid...` (dialog with a 1–100 SpinBox) and the checkable `Snap to Grid` toggle (Phase 3).
|
||||
- **View** — `Reset Views` (resets zoom to 100% and pan offset to origin on every panel) (Phase 3).
|
||||
- **View** — `Reset Views` (resets zoom to 100% and pan offset to origin on every panel) (Phase 3) and the dynamic text-only `Hide/Show Pose Guide` toggle (Phase 7): reads **"Hide Pose Guide"** while the guide is visible and **"Show Pose Guide"** while it is hidden.
|
||||
|
||||
### Context menu (per body-part panel)
|
||||
|
||||
@@ -415,3 +452,7 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
||||
> **Phase 4:** Each body part can have multiple shapes in Z-order. The Whole Stickman preview treats all shapes in a panel as one combined object, with selection (white bounding box), rotation (circle gizmo below box), and scale (cross gizmos at corners). The `.stk` format evolved to v1.2 with a `shapes` array per part plus `rotation` and `scale` fields. Rotation snaps to 15° with Ctrl; scale locks aspect ratio with Ctrl.
|
||||
|
||||
> **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0–v1.2 files remain backward compatible and are migrated on load.
|
||||
|
||||
> **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.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.
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Phase 7 Round 1 — Architectural Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This round fixes three user-reported bugs in the **pose silhouette guide** introduced in Phase 7 (see `BUGS.md`, "Stickman editor (Phase 7 Round 1)"):
|
||||
|
||||
1. **Silhouette starting position** — on startup the guide is anchored at a fixed world position (`GUIDE_OFFSET = (170, 580)`) that puts the figure up-left of the preview, forcing the user to pan over to it. The guide should start **centered** in the Whole Stickman view.
|
||||
2. **Menu label never changes** — View → "Show Pose Guide" toggles visibility but its text stays "Show Pose Guide" regardless of state. It must read **"Hide Pose Guide"** when visible and **"Show Pose Guide"** when hidden.
|
||||
3. **Guide renders behind parts** — the guide is drawn *below* user parts, so it cannot be used to judge how well parts align to the joints/limbs. It should "ghost" *in front of* the parts.
|
||||
|
||||
All three are confined to two scripts (`scripts/whole_stickman_preview.gd`, `scripts/stickman_editor.gd`) and touch no `.stk` format and no `settings.json` schema (the `show_pose_guide` key already exists). **No `.tscn` changes are required.**
|
||||
|
||||
Key behavior that is **preserved** (must not regress):
|
||||
|
||||
- The guide is a **world-space fixture**: it pans/zooms with the grid and parts (drawn under `draw_set_transform(_pan_offset, 0, Vector2(_zoom, _zoom))`).
|
||||
- The guide is **pure drawing** — no `_gui_input` hit-testing — so it never intercepts part dragging/selection or the right-click context menu.
|
||||
- 1:1 master-rig scale (`GUIDE_SCALE = 1.0`), color-coded limbs (left cyan-blue, right orange-red, central white), 6 px constant joint dots.
|
||||
- `Reset Views` restores zoom 1.0 / pan (0,0); Clear/Load do not touch guide visibility.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bug 1 — Centering the guide at startup
|
||||
|
||||
### 1a. Problem
|
||||
|
||||
`GUIDE_OFFSET = Vector2(170.0, 580.0)` (a `const` at `whole_stickman_preview.gd:48`) anchors the figure's **hips** at preview-world `(170, 580)`. The 1:1 figure spans roughly world `x ∈ [2, 338]`, `y ∈ [16.5, 956]` (center ≈ `(170, 486)`). On a typical panel (~640 × 600 px) at the default view (zoom 1, pan 0), that places the head near the top-left and the feet far below the fold — the user must pan to reach a centered figure.
|
||||
|
||||
### 1b. Recommended approach — dynamic, draw-time centering
|
||||
|
||||
Replace the static `GUIDE_OFFSET` with a **figure-center constant** and compute the offset **at draw time** from the live `preview_area.size`. This centers the figure's bounding-box center at the preview-area center in **world space**, so at the default view (zoom 1, pan 0) the figure is centered on screen, and it **re-centers automatically on window resize** (because `preview_area.size` is re-read on every draw).
|
||||
|
||||
Why this approach:
|
||||
|
||||
- **Deterministic & resize-proof** — centering is a pure function of the current viewport, recomputed every frame; no reliance on a guessed window size or on `_ready()`-time layout (which is not yet settled when the script initializes).
|
||||
- **Minimal** — a one-line change to `_guide_to_preview()` plus a renamed constant; no new state, no camera/timing machinery.
|
||||
- **Preserves world-fixture semantics** — the guide is *not* screen-locked; it still moves with pan/zoom. "Centered" is true at the default view and after `Reset Views`, and the user can still pan/zoom the guide anywhere afterward.
|
||||
|
||||
### 1c. Code changes (`scripts/whole_stickman_preview.gd`)
|
||||
|
||||
Replace the constant (line 48):
|
||||
|
||||
```gdscript
|
||||
const GUIDE_OFFSET: Vector2 = Vector2(170.0, 580.0) # master_rig (0,0) [hips] -> preview world
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```gdscript
|
||||
const GUIDE_FIGURE_CENTER: Vector2 = Vector2(0.0, -93.75) # master-space bbox center of the 1:1 guide figure
|
||||
```
|
||||
|
||||
Replace `_guide_to_preview()` (lines 468–469):
|
||||
|
||||
```gdscript
|
||||
func _guide_to_preview(master_pos: Vector2) -> Vector2:
|
||||
return master_pos * GUIDE_SCALE + GUIDE_OFFSET
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```gdscript
|
||||
func _guide_to_preview(master_pos: Vector2) -> Vector2:
|
||||
# Center the guide figure's bounding box at the preview-area center.
|
||||
# At zoom 1 / pan (0,0) — startup and Reset Views — this is the on-screen center.
|
||||
return (master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5
|
||||
```
|
||||
|
||||
`GUIDE_FIGURE_CENTER` derivation (mirrors `phase7_spec.md` §3 style):
|
||||
|
||||
| Bound | Master-space | Source |
|
||||
|---|---|---|
|
||||
| Top | `-563.5` | Head circle center `(0, -463.5)` minus `GUIDE_HEAD_RADIUS` (100) |
|
||||
| Bottom | `+376.0` | `Ankle` joints (`LeftAnkle`/`RightAnkle`) |
|
||||
| Left / Right | `±168.0` | `Elbow`/`Wrist` joints |
|
||||
|
||||
- Center X = `0` (figure is left/right symmetric).
|
||||
- Center Y = `(-563.5 + 376.0) / 2 = -93.75`.
|
||||
|
||||
> `GUIDE_SCALE` stays `1.0` and `GUIDE_HEAD_RADIUS` stays `100.0` — the bug is about *position*, not *size*.
|
||||
|
||||
### 1d. Alternatives considered (not chosen)
|
||||
|
||||
| Option | Description | Rejected because |
|
||||
|---|---|---|
|
||||
| (b) Static offset tuned for a typical window | Pick a new fixed `GUIDE_OFFSET` that looks centered at a representative size. | Fragile: breaks on different window sizes / DPI; still not "centered" in general. |
|
||||
| (c) Auto-fit camera at startup | Set `_zoom`/`_pan_offset` once so the *whole* figure (head→feet) fits centered. | Needs the panel's final size, which isn't ready in `_ready()` (requires deferred/`NOTIFICATION_RESIZED` handling); interacts badly with `Reset Views` (resets to zoom 1/pan 0, un-centering again) and shrinks the default starter parts. Adds timing complexity for a cosmetic fix. |
|
||||
|
||||
> **Open question (see §7 Q1):** centering alone makes head and feet clip *equally* above/below the fold at 1:1 zoom (the figure is ~940 px tall vs a ~600 px panel). If full visibility is required, option (c) must be layered on top.
|
||||
|
||||
---
|
||||
|
||||
## 2. Bug 2 — Dynamic menu label
|
||||
|
||||
### 2a. Problem
|
||||
|
||||
`_guide_menu_label()` (`stickman_editor.gd:271–272`) always returns `"Show Pose Guide"`, and `_update_guide_menu_item()` (`:266–268`) only refreshes the checkmark, never the text. So the label is static even though the toggle works.
|
||||
|
||||
### 2b. Recommended approach
|
||||
|
||||
Make the label reflect the **action** the click will perform, per the user's exact wording, and refresh the **text** whenever the menu is about to open or the toggle fires:
|
||||
|
||||
- Visible (`_show_guide == true`) → **"Hide Pose Guide"**
|
||||
- Hidden (`_show_guide == false`) → **"Show Pose Guide"**
|
||||
|
||||
**User decision: drop the checkmark.** The item is text-only; the action wording itself communicates the current state. Remove all `set_item_checked` usage for this item (setup + refresh).
|
||||
|
||||
### 2c. Code changes (`scripts/stickman_editor.gd`)
|
||||
|
||||
Replace `_guide_menu_label()` (lines 271–272):
|
||||
|
||||
```gdscript
|
||||
func _guide_menu_label() -> String:
|
||||
return "Show Pose Guide"
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```gdscript
|
||||
func _guide_menu_label() -> String:
|
||||
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
|
||||
```
|
||||
|
||||
Replace `_update_guide_menu_item()` (lines 266–268):
|
||||
|
||||
```gdscript
|
||||
func _update_guide_menu_item() -> void:
|
||||
if _view_menu:
|
||||
_view_menu.set_item_checked(1, _show_guide)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```gdscript
|
||||
func _update_guide_menu_item() -> void:
|
||||
if _view_menu:
|
||||
_view_menu.set_item_text(1, _guide_menu_label())
|
||||
```
|
||||
|
||||
Also remove the now-obsolete `view_menu.set_item_checked(1, _show_guide)` call from `_setup_menu_bar()` (the item is no longer checkable).
|
||||
|
||||
### 2d. Startup-sync fix (related robustness gap)
|
||||
|
||||
`_load_settings()` (`:540–571`) already re-syncs the Edit menu text after load (`_edit_menu.set_item_text(1, _snap_menu_label())` at `:570–571`), but it does **not** re-sync the View menu. If `settings.json` has `show_pose_guide: false`, the label is stale ("Hide Pose Guide") until the menu first opens. Add a sync call at the end of `_load_settings()`, right after `_show_guide` is assigned (line 558):
|
||||
|
||||
```gdscript
|
||||
_show_guide = bool(d.get("show_pose_guide", true))
|
||||
# ... existing grid/clamp/recent_colors logic ...
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
if _view_menu:
|
||||
_update_guide_menu_item()
|
||||
```
|
||||
|
||||
This mirrors the existing Edit-menu pattern and guarantees the label is correct immediately after load (the `about_to_popup` handler remains as a defensive re-sync).
|
||||
|
||||
> Note: `_setup_menu_bar()` runs *before* `_load_settings()` in `_ready()` (`:103` vs `:109`), so the initial label uses the `_show_guide` default (`true` → "Hide Pose Guide"), which is correct for the default state; the load-sync covers the non-default case.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bug 3 — Draw the guide *in front of* parts
|
||||
|
||||
### 3a. Problem
|
||||
|
||||
In `_on_preview_draw()` (`whole_stickman_preview.gd:318–408`), `_draw_silhouette_guide()` is called at line 322, **before** the `for part_name in _part_order` loop (`:325–384`). User parts (opaque fills) therefore cover the guide, hiding the joints/limbs exactly where the user needs to check alignment.
|
||||
|
||||
### 3b. Recommended draw order
|
||||
|
||||
Move `_draw_silhouette_guide()` to draw **after** the part loop but **before** the selection gizmos (and before/after the drag highlight as specified below):
|
||||
|
||||
```
|
||||
1. draw_set_transform(_pan_offset, 0, Vector2(_zoom, _zoom)) (line 319)
|
||||
2. _draw_grid() (line 321)
|
||||
3. _selected_gizmo_bounds = Rect2() (line 323)
|
||||
4. part loop + labels (lines 325–384)
|
||||
5. _draw_silhouette_guide() ← MOVED here (ghost over parts)
|
||||
6. dragged-part highlight (lines 386–390)
|
||||
7. selection gizmos (lines 392–408)
|
||||
```
|
||||
|
||||
**Z-order rationale:**
|
||||
|
||||
- **Guide over parts** — the semi-transparent guide (line alpha 0.45, joint alpha 0.65) drawn *after* parts "ghosts" over the opaque fills, so limbs/joints remain visible through/over a part. This is the whole point of Bug 3.
|
||||
- **Selection gizmos above the guide** — the bounding box, rotation circle, and scale crosses are the user's manipulation affordances; they must never be obscured by the (now-front) guide. They stay last.
|
||||
- **Drag highlight above the guide** — the yellow drag rect (drawn during `Interaction.TRANSLATE`) is also an affordance; keep it above the guide for the same reason. (Draw guide at step 5, before the highlight at step 6, so highlight + gizmos remain on top.)
|
||||
- **Labels** — part labels (`H`, `T`, `LUA`, …) are drawn inside the part loop (step 4), so the guide may overlap them slightly. This is acceptable: labels are small, and the guide is semi-transparent. (Splitting labels into a later pass is not worth the complexity.)
|
||||
|
||||
### 3c. Code changes (`scripts/whole_stickman_preview.gd`)
|
||||
|
||||
Delete line 322 (`_draw_silhouette_guide()` from its current position) and insert it after the part loop, immediately before the "Highlight dragged part" block (i.e., between line 384 and line 386):
|
||||
|
||||
```gdscript
|
||||
# Phase 7: ghost the silhouette guide IN FRONT of parts (below selection affordances)
|
||||
_draw_silhouette_guide()
|
||||
|
||||
# Highlight dragged part
|
||||
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
|
||||
...
|
||||
```
|
||||
|
||||
### 3d. Alpha values — no change recommended
|
||||
|
||||
The existing alphas (lines 0.45, joints 0.65) already produce the intended "ghost" effect; now that they render over parts instead of under them, the semi-transparency is exactly what keeps them reading as a ghost rather than a solid overlay. Keep the constants unchanged for consistency. (If, during manual review, the guide feels too heavy over large filled parts, a single-line tweak to `GUIDE_COLOR_*` / `GUIDE_JOINT_COLOR_*` is the fallback — noted here, not applied.)
|
||||
|
||||
### 3e. Hit-testing — unaffected
|
||||
|
||||
The guide is pure `draw_*` calls and participates in **no** input logic. Hit-testing runs entirely on `_part_bounds` (`_try_start_interaction` at `:659`, `_handle_right_click` at `:944`), which is unchanged. Drawing the guide over parts cannot intercept clicks or the context menu. `preview_area.clip_contents = true` still clips the guide to the panel bounds (as before).
|
||||
|
||||
---
|
||||
|
||||
## 4. Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/whole_stickman_preview.gd` | Replace `GUIDE_OFFSET` const with `GUIDE_FIGURE_CENTER`; re-center in `_guide_to_preview()`; move `_draw_silhouette_guide()` call after the part loop (before the drag highlight). |
|
||||
| `scripts/stickman_editor.gd` | Make `_guide_menu_label()` dynamic; refresh text in `_update_guide_menu_item()` and drop the checkmark (`set_item_checked` calls removed); add `_update_guide_menu_item()` call in `_load_settings()`. |
|
||||
| `docs/phase7_round1_spec.md` | This file. |
|
||||
| `README.md` | Update §5 "Pose Silhouette Guide" (centering behavior, "ghosts in front of parts", dynamic menu label) and the menu-bar structure diagram (§ "Menu bar structure"). |
|
||||
| `AGENTS.md` | Update the Phase 7 notes: `GUIDE_OFFSET (170, 580)` → dynamic centering via `GUIDE_FIGURE_CENTER`; "renders above the grid but below user parts" → "above the grid and above user parts, below selection gizmos"; "Show Pose Guide" → dynamic "Hide/Show Pose Guide" label. |
|
||||
|
||||
No `.stk` / `settings.json` / `.tscn` changes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Edge Cases & Constraints
|
||||
|
||||
- **Guide vs. pan/zoom** — the guide remains a world-space fixture. It is centered only at the default view (zoom 1, pan 0) and after `Reset Views`; panning/zooming moves it with the grid/parts exactly as before.
|
||||
- **Window resize** — because `_guide_to_preview()` re-reads `preview_area.size` each draw, the guide re-centers on resize. This means the guide's world position shifts relative to already-placed parts when the window is resized; accepted as a cosmetic consequence for a *view aid* (and is the desired "handles resize for free" behavior). Parts/grid stay fixed; only the guide re-centers.
|
||||
- **`preview_area.size` is zero before first layout** — `_on_preview_draw` is connected to `preview_area.draw`, which only fires after the control is laid out, so `size` is valid at draw time. No guard required, but note the figure-center maps to `(0,0)`-relative origin in the (theoretical) zero-size case.
|
||||
- **Negative master coords** — centering produces negative world coords for the head/arms (e.g., head top at world y ≈ −170 on a 600 px panel); the grid already handles negative world space (it draws from `floor(world_origin / gs) * gs`), so no change is needed.
|
||||
- **Vertical clipping at 1:1 zoom** — the figure (~940 px) is taller than a typical panel (~600 px), so head and feet clip equally above/below the fold when centered. This is the 1:1-scale decision from Phase 7, unchanged here; if full visibility is wanted, auto-fit (option (c)) must be added (see Q1).
|
||||
- **Menu state after load** — with the `_load_settings()` sync added, a persisted `show_pose_guide: false` shows "Show Pose Guide" immediately at startup; the `about_to_popup` handler remains as a defensive re-sync.
|
||||
- **Clear / Load / Reset Views** — none of them touch `_show_guide` or the label; `Reset Views` re-centers the guide (zoom 1/pan 0 + draw-time centering) but does not change its visibility.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing / Verification
|
||||
|
||||
There is no automated test suite; verification is manual in the editor plus the project parse check.
|
||||
|
||||
1. **Parse check** — run from `C:\Godot4\stickman`:
|
||||
```
|
||||
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit
|
||||
```
|
||||
(The plain `--check-only` form hangs on renderer init in 4.7.1; use the `--headless --check-only --quit` variant.)
|
||||
2. **Bug 1 — centered** — launch the app; the guide's head/neck/torso/hips/knees should appear centered horizontally in the preview (head/feet may clip above/below at 1:1 zoom — expected). No panning needed to find it.
|
||||
3. **Bug 1 — resize** — drag-resize the window; the guide stays centered in the preview area.
|
||||
4. **Bug 1 — Reset Views** — after panning/zooming away, View → Reset Views returns zoom 1 / pan 0 with the guide centered again.
|
||||
5. **Bug 2 — label** — View menu shows "Hide Pose Guide" by default; clicking toggles the guide off and the item becomes "Show Pose Guide"; clicking again restores both. Re-open the menu to confirm the text persists correctly across opens (no checkmark either way).
|
||||
6. **Bug 2 — persistence** — set `show_pose_guide: false` in `settings.json`, relaunch; the menu reads "Show Pose Guide" and the guide is hidden.
|
||||
7. **Bug 3 — in front** — draw a filled part over a joint; the guide line/joint dot should be visible *through/over* the part (ghosted), while the selected part's bounding box / rotation circle / scale crosses still render on top of the guide.
|
||||
8. **Bug 3 — drag highlight** — during a translation drag, the yellow highlight stays visible above the guide.
|
||||
9. **Non-regression** — with the guide on, left-click-drag a part and right-click for the context menu; behavior unchanged (guide is not hit-testable).
|
||||
|
||||
---
|
||||
|
||||
## 7. Clarifying Questions (resolved)
|
||||
|
||||
1. **Bug 1 — center vs. fit.** *Resolved:* **centering only** (no auto-zoom-to-fit). At 1:1 zoom the head/feet clip equally above/below the fold — accepted.
|
||||
2. **Bug 1 — world anchoring.** *Resolved:* the guide stays a **world-space fixture** (moves with pan/zoom; centered at default view and after Reset Views).
|
||||
3. **Bug 3 — z-order vs. affordances.** *Resolved:* **grid → parts → guide → drag highlight → selection gizmos** — gizmos and drag highlight stay on top of the ghosted guide.
|
||||
4. **Bug 2 — checkmark.** *Resolved:* **drop the checkbox** — text-only dynamic action label ("Hide Pose Guide" / "Show Pose Guide"); all `set_item_checked` usage for this item is removed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommended Implementation Order
|
||||
|
||||
1. `scripts/whole_stickman_preview.gd` — Bug 1 centering (`GUIDE_FIGURE_CENTER` + `_guide_to_preview`).
|
||||
2. `scripts/whole_stickman_preview.gd` — Bug 3 reorder (`_draw_silhouette_guide()` move).
|
||||
3. `scripts/stickman_editor.gd` — Bug 2 dynamic label + `_update_guide_menu_item()` + `_load_settings()` sync.
|
||||
4. Manual verification (§6) + `--headless --check-only --quit`.
|
||||
5. Doc updates (`README.md`, `AGENTS.md`).
|
||||
@@ -0,0 +1,409 @@
|
||||
# Phase 7 — Architectural Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Phase 7 adds a **silhouette guide** to the **Whole Stickman** preview window. The guide is a semi-transparent stick figure whose joint anchors match the **rest-pose dimensions of `master_rig.tscn`**, so the user can align their body-part shapes to the same pivots the future `Skeleton2D` rig will use. The guide is a **view aid only** — it is never part of the figure, never hit-testable, and never saved to `.stk`.
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- Drawn **in world space** inside `WholeStickmanPreview`, so it **moves with pan/zoom** exactly like the grid and user parts.
|
||||
- Drawn **above the grid, below user parts** (so it is visible but never obscures the user's shapes).
|
||||
- **13 circular joint anchors** (radius 6.0 px) at: Head, Neck, LeftShoulder, LeftElbow, LeftWrist, RightShoulder, RightElbow, RightWrist, Hips, LeftKnee, LeftAnkle, RightKnee, RightAnkle.
|
||||
- **Color-coded limb sides** to prevent misassigned limb axes:
|
||||
- Left Side (Left Arm / Left Leg): **Cyan/Blue**
|
||||
- Right Side (Right Arm / Right Leg): **Orange/Red**
|
||||
- Central Axis (Spine / Head): **Neutral White**
|
||||
- Toggleable via a new **View → Show Pose Guide** menu item (checkable), default **on**, persisted to `settings.json`.
|
||||
- **User decisions (approved):** guide rendered at **1:1 master-rig scale** (dwarfs the default starter parts — intentional), **on by default**, Head joint at the **head-circle center**, state **persisted** to `settings.json`, joint dots **constant 6 px screen size**.
|
||||
|
||||
The joint positions are **hardcoded constants** derived once from `master_rig.tscn` (not read at runtime). Rationale in §2.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model Changes
|
||||
|
||||
### 1a. `.stk` file — NO changes
|
||||
|
||||
The silhouette guide is a view aid. It is **not** written to, and **not** read from, `.stk` files. `FILE_VERSION` stays `"1.3"`. No migration is required.
|
||||
|
||||
### 1b. `settings.json` — one new key
|
||||
|
||||
Add `show_pose_guide` (bool) to the existing settings file. `SETTINGS_VERSION` stays `"1.0"` (the pattern established in Phase 6, where `recent_colors` was added without a version bump).
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"grid_size": 15,
|
||||
"snap_to_grid": false,
|
||||
"recent_colors": ["#000000"],
|
||||
"show_pose_guide": true
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `show_pose_guide` | `bool` | `true` | Whether the pose silhouette guide is visible in the Whole Stickman preview. |
|
||||
|
||||
Load/save follows the existing `_load_settings()` / `_save_settings()` pattern (missing key → default `false`, silent; corrupt file → defaults).
|
||||
|
||||
### 1c. Joint positions — hardcoded (recommended)
|
||||
|
||||
**Recommendation: hardcode** the 13 joint positions + the head circle radius as `const` data, derived once from `master_rig.tscn`. **Do not** instantiate `master_rig.tscn` at runtime or evaluate its `Skeleton2D`/IK stack. Justification:
|
||||
|
||||
1. **The guide needs the *rest* pose, not the *posed* figure.** `master_rig.tscn`'s `SkeletonModificationStack2D` is enabled and contains 4× `TwoBoneIK` + 1× `LookAt` modifications. Instantiating the scene would apply IK, moving bones *away* from their rest transforms (hands toward `IK_Targets`, head toward the look-at target). The requirement explicitly asks for **rest-pose** dimensions.
|
||||
2. **Zero runtime cost & no scene dependency.** Hardcoded constants are deterministic, editor-safe, and add no node/scene instantiation or potential side effects (no `RemoteTransform2D` writes, no `@tool` script instantiation).
|
||||
3. **Traceable.** The source node + transform for every constant is documented in §3, so the values can be regenerated if `master_rig.tscn` changes.
|
||||
|
||||
The rest transforms are static authored data; reading them at runtime buys nothing and only risks IK side effects.
|
||||
|
||||
---
|
||||
|
||||
## 2. WholeStickmanPreview — Rendering
|
||||
|
||||
All changes live in `scripts/whole_stickman_preview.gd`. No new scene/node is required (the guide is a `_draw()` overlay, mirroring how `_draw_grid()` is already inlined).
|
||||
|
||||
### 2a. New constants
|
||||
|
||||
```gdscript
|
||||
# Phase 7: pose silhouette guide
|
||||
const GUIDE_JOINT_RADIUS: float = 6.0 # screen-space joint dot radius (px)
|
||||
const GUIDE_SCALE: float = 1.0 # master_rig -> preview world scale (1:1, per user decision)
|
||||
const GUIDE_OFFSET: Vector2 = Vector2(170.0, 580.0) # master_rig (0,0) [hips] -> preview world
|
||||
const GUIDE_HEAD_RADIUS: float = 100.0 # master-space head circle radius
|
||||
|
||||
const GUIDE_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.45) # cyan-blue, semi
|
||||
const GUIDE_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.45) # orange-red, semi
|
||||
const GUIDE_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.45) # white, semi
|
||||
|
||||
const GUIDE_JOINT_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.65)
|
||||
const GUIDE_JOINT_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.65)
|
||||
const GUIDE_JOINT_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.65)
|
||||
|
||||
const GUIDE_JOINTS: Dictionary = {
|
||||
"Hips": Vector2(0.0, 0.0),
|
||||
"Neck": Vector2(0.0, -391.5),
|
||||
"Head": Vector2(0.0, -463.5),
|
||||
"LeftShoulder": Vector2(0.0, -248.0),
|
||||
"RightShoulder": Vector2(0.0, -248.0),
|
||||
"LeftElbow": Vector2(-168.0, -256.0),
|
||||
"RightElbow": Vector2(168.0, -256.0),
|
||||
"LeftWrist": Vector2(-168.0, -456.0),
|
||||
"RightWrist": Vector2(168.0, -456.0),
|
||||
"LeftKnee": Vector2(-95.0, 176.0),
|
||||
"RightKnee": Vector2(95.0, 176.0),
|
||||
"LeftAnkle": Vector2(-96.0, 376.0),
|
||||
"RightAnkle": Vector2(96.0, 376.0),
|
||||
}
|
||||
|
||||
const GUIDE_LEFT_JOINTS: PackedStringArray = [
|
||||
"LeftShoulder", "LeftElbow", "LeftWrist", "LeftKnee", "LeftAnkle"
|
||||
]
|
||||
const GUIDE_RIGHT_JOINTS: PackedStringArray = [
|
||||
"RightShoulder", "RightElbow", "RightWrist", "RightKnee", "RightAnkle"
|
||||
]
|
||||
# any joint not in LEFT/RIGHT is central (Hips, Neck, Head)
|
||||
|
||||
const GUIDE_BONES: Array = [
|
||||
["Hips", "Neck"], # spine (central)
|
||||
["LeftShoulder", "LeftElbow"], # left upper arm
|
||||
["LeftElbow", "LeftWrist"], # left forearm
|
||||
["RightShoulder", "RightElbow"], # right upper arm
|
||||
["RightElbow", "RightWrist"], # right forearm
|
||||
["Hips", "LeftKnee"], # left thigh
|
||||
["LeftKnee", "LeftAnkle"], # left shin
|
||||
["Hips", "RightKnee"], # right thigh
|
||||
["RightKnee", "RightAnkle"], # right shin
|
||||
]
|
||||
```
|
||||
|
||||
> The 13 joint names in `GUIDE_JOINTS` are exactly the key pivot locations required by PROJECT.md. `GUIDE_SCALE` and `GUIDE_OFFSET` are the only tunables mapping master-rig coordinates into preview world space (see §3 for the chosen values).
|
||||
|
||||
### 2b. New state + public API
|
||||
|
||||
```gdscript
|
||||
var _show_guide: bool = true # Phase 7 (default ON per user decision)
|
||||
|
||||
func set_show_guide(enabled: bool) -> void:
|
||||
_show_guide = enabled
|
||||
preview_area.queue_redraw()
|
||||
```
|
||||
|
||||
### 2c. Draw order (updated)
|
||||
|
||||
In `_on_preview_draw()` the order becomes:
|
||||
|
||||
```
|
||||
1. draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
|
||||
2. _draw_grid() (existing — behind everything)
|
||||
3. _draw_silhouette_guide() (NEW — above grid, below parts)
|
||||
4. part loop + labels (existing — user parts)
|
||||
5. selection gizmos (existing — always on top)
|
||||
```
|
||||
|
||||
The guide is inserted **after** `_draw_grid()` and **before** the `for part_name in _part_order` loop, so user parts render over it.
|
||||
|
||||
### 2d. `_draw_silhouette_guide()`
|
||||
|
||||
```gdscript
|
||||
func _draw_silhouette_guide() -> void:
|
||||
if not _show_guide:
|
||||
return
|
||||
|
||||
var lw: float = 1.5 / _zoom # match _draw_polyline_preview stroke
|
||||
var jr: float = GUIDE_JOINT_RADIUS / _zoom # constant 6px screen size
|
||||
|
||||
# 1. Limb / spine segments (semi-transparent)
|
||||
for bone: Array in GUIDE_BONES:
|
||||
var a: String = bone[0] as String
|
||||
var b: String = bone[1] as String
|
||||
if not (GUIDE_JOINTS.has(a) and GUIDE_JOINTS.has(b)):
|
||||
continue
|
||||
var pa: Vector2 = _guide_to_preview(GUIDE_JOINTS[a])
|
||||
var pb: Vector2 = _guide_to_preview(GUIDE_JOINTS[b])
|
||||
preview_area.draw_line(pa, pb, _guide_color(a), lw)
|
||||
|
||||
# 2. Head outline circle (central, semi-transparent)
|
||||
var head_center: Vector2 = _guide_to_preview(GUIDE_JOINTS["Head"])
|
||||
preview_area.draw_arc(
|
||||
head_center,
|
||||
GUIDE_HEAD_RADIUS * GUIDE_SCALE,
|
||||
0.0, TAU, 48,
|
||||
GUIDE_COLOR_CENTRAL, lw
|
||||
)
|
||||
|
||||
# 3. Joint anchors (constant screen size)
|
||||
for joint: String in GUIDE_JOINTS:
|
||||
var p: Vector2 = _guide_to_preview(GUIDE_JOINTS[joint])
|
||||
preview_area.draw_circle(p, jr, _guide_joint_color(joint))
|
||||
|
||||
|
||||
func _guide_to_preview(master_pos: Vector2) -> Vector2:
|
||||
return master_pos * GUIDE_SCALE + GUIDE_OFFSET
|
||||
|
||||
|
||||
func _guide_color(joint_name: String) -> Color:
|
||||
if GUIDE_LEFT_JOINTS.has(joint_name):
|
||||
return GUIDE_COLOR_LEFT
|
||||
if GUIDE_RIGHT_JOINTS.has(joint_name):
|
||||
return GUIDE_COLOR_RIGHT
|
||||
return GUIDE_COLOR_CENTRAL
|
||||
|
||||
|
||||
func _guide_joint_color(joint_name: String) -> Color:
|
||||
if GUIDE_LEFT_JOINTS.has(joint_name):
|
||||
return GUIDE_JOINT_COLOR_LEFT
|
||||
if GUIDE_RIGHT_JOINTS.has(joint_name):
|
||||
return GUIDE_JOINT_COLOR_RIGHT
|
||||
return GUIDE_JOINT_COLOR_CENTRAL
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `draw_arc` (not a filled polygon) is used for the head so the head stays outline-only and semi-transparent; the `Head` joint dot marks the head center.
|
||||
- Joint dots use `GUIDE_JOINT_RADIUS / _zoom` so they are always 6 px on screen, matching the existing vertex-handle convention (`HANDLE_RADIUS / _zoom`, `ROTATION_CIRCLE_RADIUS / _zoom`). This satisfies "radius 6.0 px" literally, independent of `GUIDE_SCALE` and zoom.
|
||||
- The guide is pure drawing — **no `_gui_input` hit-testing is added**, so it can never intercept part dragging/selection.
|
||||
|
||||
### 2e. Color specification
|
||||
|
||||
| Group | Joints | RGB | Alpha (lines) | Alpha (joints) |
|
||||
|---|---|---|---|---|
|
||||
| Left (cyan/blue) | LeftShoulder, LeftElbow, LeftWrist, LeftKnee, LeftAnkle | `(0.35, 0.70, 1.00)` | `0.45` | `0.65` |
|
||||
| Right (orange/red) | RightShoulder, RightElbow, RightWrist, RightKnee, RightAnkle | `(1.00, 0.50, 0.20)` | `0.45` | `0.65` |
|
||||
| Central (white) | Hips, Neck, Head | `(1.00, 1.00, 1.00)` | `0.45` | `0.65` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Master-rig joint derivation
|
||||
|
||||
`master_rig.tscn` is a `Skeleton2D` rooted at `Master` (Node2D at origin). The `Skeleton2D` node has `rotation = -0.0006991282` and its `Torso` bone has `rotation = 0.0006991282` — these cancel to identity at sub-pixel precision, so world-space rest positions are computed as a straightforward hierarchy traversal of the `Bone2D` rest transforms (with the visual `Body/…` `Line2D`/circle nodes and `IK_Targets/*` `Marker2D` used as cross-checks).
|
||||
|
||||
| # | Joint | Source node | Rest position (world) | Rounded |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Hips | `Skeleton2D/Torso` origin | `(0, 0)` | `(0, 0)` |
|
||||
| 2 | Neck | `Skeleton2D/Torso/Head` origin | `(-0.13, -391.51)` | `(0, -391.5)` |
|
||||
| 3 | Head | head circle center (`Body/Head`, radius 100) | `(-0.04, -463.51)` | `(0, -463.5)` |
|
||||
| 4 | LeftShoulder | `Torso/LeftUpperArm` origin | `(0, -248)` | `(0, -248)` |
|
||||
| 5 | RightShoulder | `Torso/RightUpperArm` origin | `(0, -248)` | `(0, -248)` |
|
||||
| 6 | LeftElbow | `Torso/LeftUpperArm/LeftLowerArm` origin | `(-167.8, -256.0)` | `(-168, -256)` |
|
||||
| 7 | RightElbow | `Torso/RightUpperArm/RightLowerArm` origin | `(167.8, -256.0)` | `(168, -256)` |
|
||||
| 8 | LeftWrist | `IK_Targets/Left_Hand` (== forearm bone tip) | `(-168, -456)` | `(-168, -456)` |
|
||||
| 9 | RightWrist | `IK_Targets/Right_Hand` | `(168, -456)` | `(168, -456)` |
|
||||
| 10 | LeftKnee | `Torso/LeftUpperLeg/LeftLowerLeg` origin | `(-94.96, 176.01)` | `(-95, 176)` |
|
||||
| 11 | RightKnee | `Torso/RightUpperLeg/RightLowerLeg` origin | `(94.96, 176.01)` | `(95, 176)` |
|
||||
| 12 | LeftAnkle | `IK_Targets/Left_Leg` (== shin bone tip) | `(-96, 376)` | `(-96, 376)` |
|
||||
| 13 | RightAnkle | `IK_Targets/Right_Leg` | `(96, 376)` | `(96, 376)` |
|
||||
|
||||
Derivation notes (reproducible):
|
||||
|
||||
- **Neck** = `Head` bone origin `(-0.12882307, -391.5079)`. This is the head/spine pivot. (The visual torso `Line2D` renders from `(0,0)` to `(0,-400)`; the neck bone sits ~8.5 px inside that top end, i.e. inside the head circle.)
|
||||
- **Head** = head circle center. The head circle `Body/Head` (`Node2D` with the embedded `@tool` script, `radius = 100`) is driven by `RemoteTransform2D` under the `Head` bone at local offset `(0.0503, -72.0)`: center ≈ `(0, -391.5) + (0.05, -72) = (0, -463.5)`.
|
||||
- **Elbows** = `LeftLowerArm`/`RightLowerArm` bone origins. World = parent arm origin `(0,-248)` rotated by the arm rest rotation `0.0477 rad` applied to the local offset `(±168, 0)` → `(±167.8, -256.0)`.
|
||||
- **Wrists/Ankles** = the `TwoBoneIK` targets (`Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg` in `IK_Targets/`), which match the forearm/shin bone tips in rest pose. Forearm: elbow `(±168,-256)` → wrist `(±168,-456)` (200 px straight down). Shin: knee `(±95,176)` → ankle `(±96,376)` (200 px, ≈vertical).
|
||||
|
||||
### Coordinate mapping to preview space
|
||||
|
||||
Master-rig space is large (≈ 336 × 940 px: x ∈ [-168, 168], y ∈ [-563.5, 376]). Preview world space is the "panel pixel" space where default parts live (`DEFAULT_POSITIONS` around x ∈ [130, 170], y ∈ [40, 210]).
|
||||
|
||||
**User decision: 1:1 scale.** `GUIDE_SCALE = 1.0` + `GUIDE_OFFSET = (170, 580)` anchors the guide's hips at world `(170, 580)` so the head top lands just below the panel title at default zoom/pan (`y ≈ 16.5`), while the full figure (≈ 336 × 940 px, feet at `y ≈ 956`) extends past the panel bottom and must be reached by panning/zooming out — the accepted "dwarfs the default starter parts" trade-off:
|
||||
|
||||
| Guide joint | Preview-world position |
|
||||
|---|---|
|
||||
| Hips | `(170, 580)` |
|
||||
| Neck | `(170, 188.5)` |
|
||||
| Head center (r 100) | `(170, 116.5)` |
|
||||
| Shoulders | `(170, 332)` |
|
||||
| Elbows | `(2, 324)` / `(338, 324)` |
|
||||
| Wrists | `(2, 124)` / `(338, 124)` |
|
||||
| Knees | `(75, 756)` / `(265, 756)` |
|
||||
| Ankles | `(74, 956)` / `(266, 956)` |
|
||||
|
||||
The user aligns their parts to the guide by dragging them onto it (or pans/zooms the guide to a convenient spot — it is a world-space fixture, so panning moves it with the grid). `GUIDE_SCALE`/`GUIDE_OFFSET` remain the two tunable constants if a different size is wanted later.
|
||||
|
||||
---
|
||||
|
||||
## 4. StickmanEditor — UI & persistence
|
||||
|
||||
### 4a. View menu
|
||||
|
||||
In `_setup_menu_bar()`, extend the existing View `PopupMenu` (currently only "Reset Views", id 0) with a **checkable** item:
|
||||
|
||||
```
|
||||
View
|
||||
──────────────
|
||||
Reset Views (id 0)
|
||||
──────────────
|
||||
Show Pose Guide (✓) (id 1, checkable)
|
||||
```
|
||||
|
||||
Mirror the existing Snap-to-Grid pattern: store the view menu as a member (`var _view_menu: PopupMenu`), connect `about_to_popup` to refresh the label, and toggle state in `_on_view_menu_id_pressed`.
|
||||
|
||||
```gdscript
|
||||
# _setup_menu_bar()
|
||||
var view_menu: PopupMenu = PopupMenu.new()
|
||||
view_menu.name = "ViewMenu"
|
||||
view_menu.add_item("Reset Views", 0)
|
||||
view_menu.add_separator()
|
||||
view_menu.add_item(_guide_menu_label(), 1)
|
||||
view_menu.set_item_checked(1, _show_guide)
|
||||
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
|
||||
view_menu.about_to_popup.connect(_on_view_menu_about_to_popup)
|
||||
_menu_bar.add_child(view_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
|
||||
_view_menu = view_menu
|
||||
```
|
||||
|
||||
```gdscript
|
||||
func _on_view_menu_id_pressed(id: int) -> void:
|
||||
match id:
|
||||
0: # Reset Views
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).reset_view()
|
||||
_whole_preview.reset_view()
|
||||
1: # Show Pose Guide (checkable)
|
||||
_show_guide = not _show_guide
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _on_view_menu_about_to_popup() -> void:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _update_guide_menu_item() -> void:
|
||||
if _view_menu:
|
||||
_view_menu.set_item_checked(1, _show_guide)
|
||||
|
||||
|
||||
func _guide_menu_label() -> String:
|
||||
return "Show Pose Guide"
|
||||
```
|
||||
|
||||
(Use `set_item_checked()` + a label for the checkable item; the existing Snap-to-Grid item uses a text `[√]` prefix instead — either is acceptable, but `set_item_checked` is the cleaner Godot idiom. This spec uses `set_item_checked`.)
|
||||
|
||||
### 4b. Settings state + broadcast
|
||||
|
||||
Add `var _show_guide: bool = true` to the editor, extend `_load_settings()` / `_save_settings()` with the `show_pose_guide` key, and push the value to the preview in `_broadcast_settings()`:
|
||||
|
||||
```gdscript
|
||||
# _load_settings()
|
||||
_show_guide = bool(d.get("show_pose_guide", true))
|
||||
|
||||
# _save_settings()
|
||||
var data := {
|
||||
"version": SETTINGS_VERSION,
|
||||
"grid_size": _grid_size,
|
||||
"snap_to_grid": _snap_enabled,
|
||||
"recent_colors": _recent_colors,
|
||||
"show_pose_guide": _show_guide,
|
||||
}
|
||||
|
||||
# _broadcast_settings() (append)
|
||||
_whole_preview.set_show_guide(_show_guide)
|
||||
```
|
||||
|
||||
Because `_broadcast_settings()` is already called in `_ready()` (after `_load_settings()`) and whenever grid/snap changes, the guide visibility is correctly re-applied on startup and on every toggle.
|
||||
|
||||
---
|
||||
|
||||
## 5. Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/whole_stickman_preview.gd` | Add guide constants, `_show_guide` state, `set_show_guide()`, `_draw_silhouette_guide()` + `_guide_to_preview()`/`_guide_color()`/`_guide_joint_color()` helpers; call guide draw between grid and parts in `_on_preview_draw()`. |
|
||||
| `scripts/stickman_editor.gd` | Add `_show_guide` state, `_view_menu` member, "Show Pose Guide" checkable View menu item (id 1), `_on_view_menu_about_to_popup`/`_update_guide_menu_item`, settings key `show_pose_guide`, broadcast to preview. |
|
||||
| `docs/phase7_spec.md` | This file. |
|
||||
| `README.md` | Document the guide, the View menu item, and the settings key (see §7). |
|
||||
|
||||
No `.tscn` changes are required.
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases & Constraints
|
||||
|
||||
- **Guide vs. pan/zoom** — the guide is drawn under `draw_set_transform(_pan_offset, 0, Vector2(_zoom, _zoom))`, so it pans/zooms with everything else. Joint dots use `6.0 / _zoom`, staying a constant 6 px on screen (consistent with existing handles).
|
||||
- **Guide is never interactive** — no hit-testing is added; left-click part selection and right-click context menus are unaffected. The guide cannot block a part the user is trying to grab (parts render over it and win hit-tests).
|
||||
- **Clipping** — `PreviewArea` already has `clip_contents = true`, so the guide clips to the panel bounds like the grid and parts.
|
||||
- **Empty preview** — the guide renders even when no parts exist (it is independent of `_part_shapes`), which is desirable: the user sees the pose before drawing anything.
|
||||
- **Min zoom (0.3)** — the figure shrinks but joint dots stay 6 px; dots will appear relatively large vs. the figure at extreme zoom-out. This matches existing handle behavior and is acceptable for an anchor aid.
|
||||
- **Head circle vs. neck** — the head circle (r 100 in preview space at 1:1) overlaps the neck joint, faithfully mirroring `master_rig.tscn` (head center sits 72 px below the neck in master space). This is intentional.
|
||||
- **`Reset Views`** — resets zoom/pan only; it does **not** change guide visibility (the toggle is independent state).
|
||||
- **Clear / Load** — do not touch `_show_guide`; the toggle is a persistent editor preference, not figure state.
|
||||
- **Settings missing/corrupt** — `show_pose_guide` falls back to `true` (the new default), matching `_load_settings()`'s existing behavior.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing / Verification
|
||||
|
||||
There is no automated test suite in the repo (only a sample figure `stickmen/test.stk`). Verification is manual in the Godot editor, plus the project parse check:
|
||||
|
||||
1. **Load check** — run the project; no parse errors; main scene opens.
|
||||
2. **Toggle** — View → Show Pose Guide is checked by default and the guide is visible; clicking it unchecks it and hides the guide; clicking again re-shows it.
|
||||
3. **Visuals** — with the guide on: 13 joint dots; left limbs cyan/blue, right limbs orange/red, spine+head white; everything semi-transparent (see-through grid behind it).
|
||||
4. **Draw order** — draw a part; confirm it renders **over** the guide; confirm the guide renders **over** the grid.
|
||||
5. **Pan/zoom** — middle-drag and wheel-zoom the preview; the guide tracks the grid and parts exactly; joint dots stay ~6 px.
|
||||
6. **Non-interference** — with the guide visible, left-click-drag a part and right-click for the context menu; behavior is unchanged.
|
||||
7. **Persistence** — the guide is on by default; disable it, close and reopen the project; it stays disabled (settings.json contains `"show_pose_guide": false`).
|
||||
8. **Clear/Load** — clearing or loading a figure leaves the guide setting unchanged.
|
||||
|
||||
> Note: `..\Godot_v4.7.1-stable_win64_console.exe . --check-only` could not be run — the executable is not present under `C:\Godot4\` and no shell tool is available to the architect. The developer should run it before implementation.
|
||||
|
||||
---
|
||||
|
||||
## 8. README Updates
|
||||
|
||||
- **Overview / menu section** — add "Show Pose Guide" under View; describe the color-coded, semi-transparent silhouette and its 13 joint anchors.
|
||||
- **New subsection** (e.g. "Pose Silhouette Guide") — explain purpose (align parts to the future `Skeleton2D` rig pivots from `master_rig.tscn`), the color coding (left = cyan, right = orange, central = white), that it's drawn behind parts and tracks pan/zoom, and that it's a view aid not saved to `.stk`.
|
||||
- **Settings table** — add `show_pose_guide` key (bool, default `true`).
|
||||
- **Menu bar structure diagram** — add the View → Show Pose Guide entry.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended Implementation Order
|
||||
|
||||
1. `whole_stickman_preview.gd` — constants, `_show_guide`, `set_show_guide()`, draw helpers, wire into `_on_preview_draw()`.
|
||||
2. `stickman_editor.gd` — `_show_guide` state + View menu item + handlers.
|
||||
3. Settings persistence (`_load_settings`/`_save_settings`/`_broadcast_settings`).
|
||||
4. Manual verification (list in §7) + `--check-only`.
|
||||
5. README update.
|
||||
@@ -0,0 +1,443 @@
|
||||
# Phase 8 — Architectural Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Phase 8 has two deliverables:
|
||||
|
||||
1. **Save-side `.stk` export** — when saving/exporting, the editor appends a top-level `proportions` object (5 rig bone lengths) and, inside each `body_parts` entry, a `pivot` (`{x, y}`) and `length` (float). This is the **only** editor change; the editor does **not** instantiate the rig.
|
||||
2. **A standalone runtime adapter `StkRigAdapter.gd`** — a GDScript utility that takes a loaded `.stk` dictionary + an instantiated `master_rig.tscn` node and re-fits the skeleton (bone lengths), recalibrates the IK targets, and mounts the `.stk` vector shapes onto the rig's `Body/` visual nodes. This script is consumed by a **future runtime pipeline**, not by the editor.
|
||||
|
||||
Scope is intentionally tight:
|
||||
|
||||
- The editor is **save-side only** — it computes and writes `proportions` / `pivot` / `length`. It never loads `StkRigAdapter.gd` and never imports `master_rig.tscn` (see §5f / §8).
|
||||
- `StkRigAdapter.gd` is a **standalone script** with no hard dependency back into the editor. It is not referenced by any existing scene or autoload.
|
||||
|
||||
Key facts established from exploration (cited throughout):
|
||||
|
||||
- The Phase 7 silhouette guide already hardcodes the 13 rest-pose joint coordinates of `master_rig.tscn` as `GUIDE_JOINTS` in `scripts/whole_stickman_preview.gd:59-73`. These are the **same pivots** the adapter's bone-fitting targets.
|
||||
- `master_rig.tscn`'s bone names/paths match the Phase 8 requirement text exactly (`Skeleton2D/Torso/LeftUpperArm`, `LeftLowerArm`, `RightUpperArm`, `RightLowerArm`, `LeftUpperLeg`, `LeftLowerLeg`, `RightUpperLeg`, `RightLowerLeg`, `IK_Targets/Left_Leg`, `IK_Targets/Right_Leg`, `IK_Targets/Left_Hand`, `IK_Targets/Right_Hand`).
|
||||
- The editor already serializes per-part `shapes`/`position`/`rotation`/`scale` in `scripts/stickman_editor.gd:363-394`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model Changes (`.stk`)
|
||||
|
||||
### 1a. Version bump → `"1.4"`
|
||||
|
||||
`FILE_VERSION` (`stickman_editor.gd:51`) becomes `"1.4"`. `SUPPORTED_VERSIONS` (`:54`) gains `"1.4"` (so loading `["1.0".."1.4"]`). No other load-side migration is required (see 1d).
|
||||
|
||||
### 1b. Top-level `proportions` object
|
||||
|
||||
A new top-level key, sibling to `version` / `stickman_name` / `part_order` / `body_parts` / `metadata`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.4",
|
||||
"stickman_name": "Bob",
|
||||
"part_order": [ "..." ],
|
||||
"proportions": {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5
|
||||
},
|
||||
"body_parts": { "..." },
|
||||
"metadata": { "..." }
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `upper_arm_length` | `float` | Distance shoulder → elbow (== `LeftUpperArm.length`). |
|
||||
| `lower_arm_length` | `float` | Distance elbow → wrist (== `LeftLowerArm.length`). |
|
||||
| `upper_leg_length` | `float` | Distance hip → knee (== `LeftUpperLeg.length`). |
|
||||
| `lower_leg_length` | `float` | Distance knee → ankle (== `LeftLowerLeg.length`). |
|
||||
| `torso_length` | `float` | Height from hip base to neck (`Hips` → `Neck`). |
|
||||
|
||||
**Source = master-rig rest-pose constants (hardcoded), not the user's shapes.** Full derivation in §2.
|
||||
|
||||
### 1c. Per-part `pivot` and `length`
|
||||
|
||||
Inside **each** of the 10 `body_parts[part_name]` objects (siblings of `shapes`/`position`/`rotation`/`scale`):
|
||||
|
||||
```json
|
||||
"torso": {
|
||||
"shapes": [ "..." ],
|
||||
"position": { "x": 150.0, "y": 100.0 },
|
||||
"rotation": 0.0,
|
||||
"scale": { "x": 1.0, "y": 1.0 },
|
||||
"pivot": { "x": 300.5, "y": 250.0 },
|
||||
"length": 99.0
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `pivot` | `object` | `{ "x": float, "y": float }` — the **local origin point of rotation**, i.e. the bounding-box center of all shape points in the part's **local drawing space** (before `position` is applied). |
|
||||
| `length` | `float` | The part's bounding-box extent along its **segment axis** (see §3), in local pixels. |
|
||||
|
||||
Both are **always written for all 10 parts** (empty part → `pivot {0,0}` / `length 0.0`) so the schema is uniform.
|
||||
|
||||
### 1d. Backward compatibility / migration
|
||||
|
||||
- `pivot` / `length` / `proportions` are **write-only** metadata. The editor never reads them back (they are recomputed from live shapes at every save). Therefore the load path (`_apply_json_data`, `stickman_editor.gd:397-476`) requires **no changes** — old v1.0–v1.3 files simply lack these keys and load identically to today; they gain the keys on their next save.
|
||||
- v1.0/v1.1/v1.2/v1.3 files remain loadable (already handled by `SUPPORTED_VERSIONS` + the existing wrap/migrate logic at `:426-439`).
|
||||
- No `settings.json` change (proportions/pivot/length are figure data, not editor preferences).
|
||||
|
||||
---
|
||||
|
||||
## 2. Proportions derivation (master-rig rest-pose constants)
|
||||
|
||||
**Recommendation: hardcode the 5 proportions as `const` values** in `stickman_editor.gd`, derived once from `master_rig.tscn` (the same approach Phase 7 took for `GUIDE_JOINTS`). **Do not** derive them from the user's part bounding boxes.
|
||||
|
||||
Justification:
|
||||
|
||||
1. The proportions describe the **target rig's** joint distances, and the adapter writes them directly into `Bone2D.length` / `position.x/y`. Those values are the authored `master_rig.tscn` bone lengths, not whatever arbitrary size the user happened to draw.
|
||||
2. The requirement says "after silhouette alignment" — the user aligns their parts **to** the guide (the rig rest pose). The guide *is* the source of truth for joint distances; the user's parts are the thing being aligned, so measuring them would be circular and fragile.
|
||||
3. Deterministic, editor-safe, no scene instantiation (mirrors the Phase 7 rationale for hardcoding `GUIDE_JOINTS`).
|
||||
|
||||
### Derivation table
|
||||
|
||||
| Proportion | Source node / joint | Value |
|
||||
|---|---|---|
|
||||
| `upper_arm_length` | `Skeleton2D/Torso/LeftUpperArm.length` (`master_rig.tscn:173`); guide `LeftShoulder`→`LeftElbow` | **168.0** |
|
||||
| `lower_arm_length` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm.length` (`:181`); guide `LeftElbow`→`LeftWrist` | **200.0** |
|
||||
| `upper_leg_length` | `Skeleton2D/Torso/LeftUpperLeg.length` (`:222`); guide `Hips`→`LeftKnee` | **200.0** |
|
||||
| `lower_leg_length` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg.length` (`:230`); guide `LeftKnee`→`LeftAnkle` | **200.0** |
|
||||
| `torso_length` | `Hips (0,0)` → `Neck (0,-391.5)` (`Skeleton2D/Torso/Head.position.y`, `:152`) | **391.5** |
|
||||
|
||||
Notes (reproducible):
|
||||
|
||||
- **Bone length vs. joint distance.** `upper_arm_length` is the *bone length* `168.0`, not the literal `|shoulder - elbow| = sqrt(168² + 8²) ≈ 168.19`. The 8 px vertical offset comes from the arm bone's `rotation = 0.0477` (`master_rig.tscn:170`), which the adapter does **not** touch. Writing `168.0` reproduces the rest pose exactly when the adapter sets `LeftUpperArm.length = 168.0` and leaves `rotation` alone. (Writing `168.19` would over-extend the bone.) The requirement's "distance between shoulder and elbow" is treated as the bone length.
|
||||
- **`torso_length`** has no corresponding single bone (the spine is the `Head` bone authored at `y = -391.5`); it is informational in `proportions` (no adapter bone op consumes it).
|
||||
- **`RightUpperLeg.length = 90.0` inconsistency.** `master_rig.tscn:245` authors the right upper leg bone at `90.0` while the left is `200.0` (`:222`) and `master_rig2.tscn:262` fixes the right to `200.0`. Use **200.0** (the symmetric/corrected value). The adapter overwrites both legs anyway, so the discrepancy is moot after fitting — but it is flagged in §5f.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-part `pivot` and `length` computation
|
||||
|
||||
Computed in the editor from each panel's local shape points (via `BodyPartPanel.get_shape_data()`, `body_part_panel.gd:195-218`, which returns points as `{x, y}` dicts in **local drawing space**).
|
||||
|
||||
### 3a. `pivot` — local bounding-box center
|
||||
|
||||
```
|
||||
min_x, min_y, max_x, max_y = bounds over ALL points of ALL shapes in the part
|
||||
pivot = { x: (min_x + max_x) / 2, y: (min_y + max_y) / 2 }
|
||||
```
|
||||
|
||||
This is the **local** analogue of `WholeStickmanPreview._compute_center()` (`whole_stickman_preview.gd:509-519`), which computes the world-space center from `pt + position`. Local pivot = world center − `position` = bbox center of local points. Matches the editor's actual rotation pivot.
|
||||
|
||||
### 3b. `length` — extent along the segment axis
|
||||
|
||||
```
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
length = width (for arm parts)
|
||||
= height (for torso, legs, head)
|
||||
```
|
||||
|
||||
**Axis mapping** (matches the adapter's bone convention — arms use `position.x`, legs/torso use `position.y`, §5c):
|
||||
|
||||
| Part family | Parts | Axis |
|
||||
|---|---|---|
|
||||
| Arms | `left_upper_arm`, `left_lower_arm`, `right_upper_arm`, `right_lower_arm` | **X** (`width`) |
|
||||
| Legs + torso + head | `torso`, `left_upper_leg`, `left_lower_leg`, `right_upper_leg`, `right_lower_leg`, `head` | **Y** (`height`) |
|
||||
|
||||
Empty part (no shapes, or all shapes with <2 points) → `pivot {0,0}`, `length 0.0`.
|
||||
|
||||
> **Alternative considered (not chosen):** `length = max(width, height)`. Simpler and axis-agnostic, but loses the arm/leg axis distinction that the adapter's `position.x` vs `position.y` convention encodes. See §10 Q1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Editor changes (`scripts/stickman_editor.gd`)
|
||||
|
||||
### 4a. New constants
|
||||
|
||||
```gdscript
|
||||
const FILE_VERSION := "1.4" # was "1.3" (line 51)
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"] # line 54
|
||||
|
||||
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2)
|
||||
const PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5,
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. New compute helper
|
||||
|
||||
```gdscript
|
||||
const X_AXIS_PARTS: PackedStringArray = [
|
||||
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
|
||||
]
|
||||
|
||||
func _compute_part_pivot_length(shapes_arr: Array, part_name: String) -> Dictionary:
|
||||
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
|
||||
for sd in shapes_arr:
|
||||
if not sd is Dictionary:
|
||||
continue
|
||||
for p in (sd as Dictionary).get("points", []):
|
||||
if not p is Dictionary:
|
||||
continue
|
||||
var px: float = float((p as Dictionary).get("x", 0.0))
|
||||
var py: float = float((p as Dictionary).get("y", 0.0))
|
||||
min_x = min(min_x, px); min_y = min(min_y, py)
|
||||
max_x = max(max_x, px); max_y = max(max_y, py)
|
||||
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return { "pivot": { "x": 0.0, "y": 0.0 }, "length": 0.0 }
|
||||
|
||||
var pivot := { "x": (min_x + max_x) * 0.5, "y": (min_y + max_y) * 0.5 }
|
||||
var length: float
|
||||
if X_AXIS_PARTS.has(part_name):
|
||||
length = max_x - min_x
|
||||
else:
|
||||
length = max_y - min_y
|
||||
return { "pivot": pivot, "length": length }
|
||||
```
|
||||
|
||||
### 4c. Wire into serialization
|
||||
|
||||
**`_collect_all_shape_data()`** (`stickman_editor.gd:363-378`) — extend the per-part dict with `pivot`/`length`:
|
||||
|
||||
```gdscript
|
||||
all_data[part_name] = {
|
||||
"shapes": shapes_arr,
|
||||
"position": {"x": pos.x, "y": pos.y},
|
||||
"rotation": rot,
|
||||
"scale": {"x": scl.x, "y": scl.y},
|
||||
"pivot": pivot_length["pivot"],
|
||||
"length": pivot_length["length"],
|
||||
}
|
||||
```
|
||||
|
||||
**`_build_json_data()`** (`:381-394`) — add the top-level `proportions` key:
|
||||
|
||||
```gdscript
|
||||
return {
|
||||
"version": FILE_VERSION,
|
||||
"stickman_name": _stickman_name_edit.text.strip_edges(),
|
||||
"part_order": _whole_preview.get_part_order(),
|
||||
"proportions": PROPORTIONS.duplicate(),
|
||||
"body_parts": body_parts,
|
||||
"metadata": { "created_at": time_str, "modified_at": time_str },
|
||||
}
|
||||
```
|
||||
|
||||
No change to `_on_save_file_selected` (`:287-300`) beyond what the above touches.
|
||||
|
||||
### 4d. Load path — no change
|
||||
|
||||
`_apply_json_data` (`:397-476`) ignores unknown keys; `proportions`/`pivot`/`length` are never read. Version `"1.4"` is now accepted by the extended `SUPPORTED_VERSIONS`.
|
||||
|
||||
---
|
||||
|
||||
## 5. `StkRigAdapter.gd` (runtime adapter)
|
||||
|
||||
### 5a. Class shape & API
|
||||
|
||||
New file `res://scripts/stk_rig_adapter.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name StkRigAdapter
|
||||
extends RefCounted
|
||||
## Standalone runtime adapter: fits an instantiated master_rig.tscn to a
|
||||
## loaded .stk dictionary (proportions + shapes). Not referenced by the editor.
|
||||
|
||||
static func apply(stk_data: Dictionary, rig: Node2D) -> void
|
||||
```
|
||||
|
||||
- `rig` is the instantiated `master_rig.tscn` root (`Master`, a `Node2D`).
|
||||
- `stk_data` is the parsed `.stk` dictionary (the adapter reads `proportions` and `body_parts`).
|
||||
- All node access goes through `rig.get_node_or_null(NodePath)` with the fixed paths in §5b; **every lookup is null-guarded** so a malformed/foreign scene fails gracefully (push a warning, skip that op) rather than hard-erroring.
|
||||
- `apply()` calls three private helpers in order: `_fit_bones` → `_recalibrate_ik` → `_mount_shapes`.
|
||||
|
||||
### 5b. Part-key → node-path mapping table
|
||||
|
||||
Paths are **relative to the `rig` root** (`Master`). Bone paths are used by `_fit_bones`; `Body` visual paths by `_mount_shapes`.
|
||||
|
||||
| `part_key` | Bone node (fitting) | `Body` visual node (mount) |
|
||||
|---|---|---|
|
||||
| `head` | `Skeleton2D/Torso/Head` (not length-fitted) | `Body/Head` (circle `Node2D`) |
|
||||
| `torso` | — (no torso bone) | `Body/Body` (`Line2D`) |
|
||||
| `left_upper_arm` | `Skeleton2D/Torso/LeftUpperArm` | `Body/LeftUpperArm` |
|
||||
| `left_lower_arm` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm` | `Body/LeftLowerArm` |
|
||||
| `right_upper_arm` | `Skeleton2D/Torso/RightUpperArm` | `Body/RightUpperArm` |
|
||||
| `right_lower_arm` | `Skeleton2D/Torso/RightUpperArm/RightLowerArm` | `Body/RightLowerArm` |
|
||||
| `left_upper_leg` | `Skeleton2D/Torso/LeftUpperLeg` | `Body/LeftUpperLeg` |
|
||||
| `left_lower_leg` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg` | `Body/LeftLowerLeg` |
|
||||
| `right_upper_leg` | `Skeleton2D/Torso/RightUpperLeg` | `Body/RightUpperLeg` |
|
||||
| `right_lower_leg` | `Skeleton2D/Torso/RightUpperLeg/RightLowerLeg` | `Body/RightLowerLeg` |
|
||||
|
||||
The 10 `Body/*` visual nodes and their `RemoteTransform2D` drivers are enumerated in `master_rig.tscn:71-141` (visuals) and `:160-262` (bone `RemoteTransform2D`). Every visual node is already driven by a `RemoteTransform2D` under the matching bone, so mounting geometry **into** these nodes inherits the skeleton's pose for free.
|
||||
|
||||
### 5c. Bone fitting (`_fit_bones`)
|
||||
|
||||
Reads `proportions` (with defaults = §2 values when the key is absent, for robustness). Applies the Phase 8 requirement **verbatim**, plus a marked completion:
|
||||
|
||||
```
|
||||
proportions = stk_data.get("proportions", DEFAULTS)
|
||||
ua = proportions.upper_arm_length
|
||||
la = proportions.lower_arm_length
|
||||
ul = proportions.upper_leg_length
|
||||
ll = proportions.lower_leg_length
|
||||
|
||||
# Arms — upper length + lower-bone origin on X (requirement verbatim)
|
||||
LeftUpperArm.length = ua ; LeftLowerArm.position.x = -ua
|
||||
RightUpperArm.length = ua ; RightLowerArm.position.x = ua
|
||||
|
||||
# Legs — upper length + lower-bone origin on Y (requirement verbatim)
|
||||
LeftUpperLeg.length = ul ; LeftLowerLeg.position.y = ul
|
||||
RightUpperLeg.length = ul ; RightLowerLeg.position.y = ul
|
||||
|
||||
# RECOMMENDED COMPLETION (see note): also fit the lower-bone lengths
|
||||
LeftLowerArm.length = la ; RightLowerArm.length = la
|
||||
LeftLowerLeg.length = ll ; RightLowerLeg.length = ll
|
||||
```
|
||||
|
||||
- The four `*.length = ua/ul` and four `position.x/y` assignments are exactly the requirement's "Bone Fitting Logic".
|
||||
- **Recommended completion:** the requirement omits setting `LeftLowerArm.length` / `RightLowerArm.length` / `LeftLowerLeg.length` / `RightLowerLeg.length` (= `lower_arm_length` / `lower_leg_length`). Without it, lower limbs keep their authored `200.0` and won't scale if proportions differ from defaults. This completion is flagged in §10 Q2; it is safe (a no-op for the default proportions) and makes the adapter actually "fit" all 8 limb bones.
|
||||
- `auto_calculate_length_and_angle` is already `false` on all these bones (`master_rig.tscn:156/172/180/197/205/221/229/245/252`), so direct `.length` writes are authoritative.
|
||||
- Do **not** touch `bone_angle`, `rotation`, or `rest` — those encode the rest-pose orientation and must be preserved.
|
||||
|
||||
### 5d. IK target recalibration (`_recalibrate_ik`)
|
||||
|
||||
```
|
||||
ul = upper_leg_length ; ll = lower_leg_length
|
||||
ua = upper_arm_length ; la = lower_arm_length
|
||||
|
||||
# Leg IK targets (requirement verbatim)
|
||||
IK_Targets/Left_Leg.position.y = ul + ll
|
||||
IK_Targets/Right_Leg.position.y = ul + ll
|
||||
|
||||
# Hand IK targets — "default rests to match total arm length"
|
||||
IK_Targets/Left_Hand.position.x = -ua
|
||||
IK_Targets/Right_Hand.position.x = ua
|
||||
IK_Targets/Left_Hand.position.y = ELBOW_REST_Y - la
|
||||
IK_Targets/Right_Hand.position.y = ELBOW_REST_Y - la
|
||||
```
|
||||
|
||||
- `ELBOW_REST_Y = -256.0` is the authored elbow height (`Body/LeftUpperArm.position.y`, `master_rig.tscn:116`; also the `LeftElbow` guide joint). Keeping `hand.y = elbow_y − lower_arm_length` preserves the elbow→wrist vertical span while the x-position tracks the (possibly re-fitted) upper-arm length. With default proportions this reproduces the authored hand rest `(±168, −456)` exactly.
|
||||
- `IK_Targets/Head` and `IK_Targets/Torso` are **not** touched (no proportion governs them).
|
||||
- The `TwoBoneIK` `target_nodepath`s already point at `../IK_Targets/{Left,Right}_{Hand,Leg}` (`master_rig.tscn:21-49`), so moving the `Marker2D`s is sufficient — no modification-stack edits required.
|
||||
- The leg value `ul + ll = 400.0` differs from the authored `Left_Leg.y = 376.0` (`master_rig.tscn:283`); the authored pose has a slight knee bend (knee world y ≈ 176). Setting `400.0` is the requirement's intent ("stand straight" default reach). Noted, not overridden.
|
||||
|
||||
### 5e. Visual shape mount (`_mount_shapes`)
|
||||
|
||||
For each of the 10 part keys, mount the part's `.stk` shapes into the corresponding `Body/*` node (§5b). Two equivalent strategies are permitted by the requirement ("replace default Line2D nodes **or** instantiate new nodes"); the spec recommends **updating the existing `Body/*` nodes in place**, which preserves the `RemoteTransform2D` driving and keeps node names stable:
|
||||
|
||||
1. **Coordinate transform.** `.stk` shape points are in arbitrary panel-local space (e.g. `test.stk` has points around x∈[150,600]). Transform each point into the `Body` node's local convention:
|
||||
```
|
||||
pt_local = (pt - pivot) * scale_factor
|
||||
scale_factor = bone_length / part_length # per part, per axis (see below)
|
||||
```
|
||||
where `pivot` and `length` come from `body_parts[part_name]`, and `bone_length` is the corresponding proportion (arms → `upper_arm_length`/`lower_arm_length`; legs → `upper_leg_length`/`lower_leg_length`; torso → `torso_length`; head → leave at 1.0 scale, translate only). This maps the part's `pivot` → the node's origin and stretches the drawn segment to the bone length.
|
||||
2. **Node construction per shape** (`.stk` shape dict → Godot nodes):
|
||||
- `color = Color.from_string(shape["color"], Color.WHITE)`
|
||||
- **Open** (`closed == false`) → one `Line2D` with `points = transformed`, `width = DEFAULT_LINE_WIDTH`, `default_color = color`.
|
||||
- **Closed** (`closed == true`) → one `Polygon2D` (`polygon = transformed`, `color = color`) for the fill **plus** one `Line2D` (`closed = true`) for the outline — mirroring the editor's fill+outline rendering (`body_part_panel.gd:380-385`).
|
||||
- `DEFAULT_LINE_WIDTH := 16.0` (matches `master_rig.tscn`'s `width = 16.0`; `.stk` stores no width).
|
||||
3. **Head special case.** `Body/Head` is a `Node2D` with the embedded circle `@tool` script (`master_rig.tscn:73-77`, `radius = 100`). For a `head` part whose shapes are circle-like, set its `radius`/`color` exports from the head bbox; otherwise (or as a uniform v1), replace it with the same `Polygon2D`/`Line2D` treatment as other parts.
|
||||
4. **Empty part** → remove/clear the corresponding `Body` node's geometry (freeze or hide), so a part the user never drew doesn't render the default `Line2D`.
|
||||
|
||||
> The coordinate mapping here is the most under-specified piece of the requirement. §5e defines a concrete, deterministic v1 (translate `pivot`→origin, scale along the segment axis to the bone length). The fine-grained fidelity (matching the user's in-preview `rotation`/`scale`/`position` exactly) is intentionally left to the runtime consumer — see §10 Q3.
|
||||
|
||||
### 5f. `master_rig.tscn` vs `master_rig2.tscn`
|
||||
|
||||
- The adapter targets **`master_rig.tscn`** (the clean scene Phase 7 derived `GUIDE_JOINTS` from). It has **no** `metadata/_local_pose_override_enabled_` on its bones.
|
||||
- `master_rig2.tscn` is a variant that (a) adds `metadata/_local_pose_override_enabled_ = true` to every bone, (b) fixes `RightUpperLeg.length` from `90.0` → `200.0` (`master_rig2.tscn:262`), and (c) explicitly sets `enabled = true` on the modification stack. `clear_pose.gd` (an `EditorScript`) exists to strip the pose-override metadata and reset bone scales to `Vector2.ONE`/`rest`.
|
||||
- **Implication:** the adapter is written against `master_rig.tscn` node names (identical in both) and overwrites lengths anyway, so it works with either — but the `90.0` right-leg value in `master_rig.tscn` is a latent bug the adapter must not rely on (it writes `RightUpperLeg.length` from `proportions`, §5c).
|
||||
|
||||
### 5g. Editor does **not** verify/import the adapter
|
||||
|
||||
The editor only *produces* data the adapter *consumes*. There is no editor→adapter reference, no `preload("res://scripts/stk_rig_adapter.gd")`, no scene that imports it. "Verification" is a standalone concern: the adapter is syntax-checked with the project parse check and (optionally) a future headless smoke test (§8). This keeps the editor decoupled from the runtime pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/stickman_editor.gd` | `FILE_VERSION` → `"1.4"`; add `"1.4"` to `SUPPORTED_VERSIONS`; add `PROPORTIONS` const + `X_AXIS_PARTS` const; add `_compute_part_pivot_length()`; extend `_collect_all_shape_data()` with `pivot`/`length`; add `proportions` to `_build_json_data()`. |
|
||||
| `scripts/stk_rig_adapter.gd` | **New.** `StkRigAdapter` (`RefCounted`) with `static func apply(stk_data, rig)` + `_fit_bones` / `_recalibrate_ik` / `_mount_shapes` + node-path consts (§5). |
|
||||
| `docs/phase8_spec.md` | This file. |
|
||||
| `README.md` | Document the `.stk` v1.4 `proportions`/`pivot`/`length` keys, the `StkRigAdapter.gd` script, and the runtime-pipeline note (§11). |
|
||||
| `AGENTS.md` | Add a Phase 8 note (v1.4 export + `StkRigAdapter.gd`). |
|
||||
|
||||
No `.tscn` changes. No `settings.json` change. No load-path change.
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases & Constraints
|
||||
|
||||
- **Empty part** — no shapes → `pivot {0,0}`, `length 0.0`; the adapter clears/hides that `Body` node's geometry. Never divide by zero in the mount transform (`scale_factor` guards `length <= 0` → 1.0 or skip).
|
||||
- **Multi-shape parts** — `pivot`/`length` are computed over **all** shapes in the part (the part is treated as one object, matching the Whole Stickman preview semantics).
|
||||
- **`part_length == 0` or `bone_length == 0`** — guard the mount scale; fall back to translation-only.
|
||||
- **Negative scale parts** (Phase 5 mirroring sets `scale.x/y` negative) — `pivot`/`length` are computed from the **unscaled local points** (the panel geometry), not the preview transform, so mirroring does not affect them. (The adapter does not consume part `scale`/`rotation`/`position` in v1 — see Q3.)
|
||||
- **Old files loaded then saved** — v1.0–v1.3 files load unchanged and are written out as `"1.4"` with computed `proportions`/`pivot`/`length` on the next save.
|
||||
- **Adapter with a foreign/malformed scene** — every `get_node_or_null` is null-guarded; missing nodes → push a warning and skip, never crash.
|
||||
- **Adapter with a `.stk` lacking `proportions`** — falls back to the §2 default constants, so the rig still fits to the standard rest pose.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing / Verification
|
||||
|
||||
There is no automated test suite in the repo (no `test/` directory, no GUT addon installed — the `tester` agent's GUT template is aspirational; `glob test*.gd` returns nothing). Verification is manual + the project parse check.
|
||||
|
||||
1. **Parse check** — run from `C:\Godot4\stickman` (engine binary per the project: `config/features = "4.7"`, matching the user's 4.7.1 binary):
|
||||
```
|
||||
..\Godot_v4.7.1-stable_win64_console.exe . --headless --check-only --quit
|
||||
```
|
||||
> The plain `--check-only` form hangs on renderer init in 4.7.x; use `--headless --check-only --quit` (as established in `docs/phase7_round1_spec.md` §6). The user's stated command `..\Godot_v4.7.1-stable_win64_console.exe . --check-only` is equivalent in intent but should include `--headless --quit`.
|
||||
2. **Save emits v1.4** — File → Save; inspect the `.stk`: `version == "1.4"`, a top-level `proportions` with the 5 values (`168.0/200.0/200.0/200.0/391.5`), and every `body_parts.*` entry has `pivot {x,y}` + `length` (arm parts' `length` = bbox width; leg/torso/head = bbox height).
|
||||
3. **Empty part** — a part with no shapes writes `pivot {0,0}` / `length 0.0`; no crash.
|
||||
4. **Backward-compat load** — load `stickmen/basic.stk` (v1.0) and `stickmen/test.stk` (v1.1); no error; save; result is v1.4 with computed pivot/length/proportions.
|
||||
5. **Adapter smoke test (standalone, optional/headless)** — a small `--headless` SceneTree script that instantiates `master_rig.tscn`, calls `StkRigAdapter.apply(sample_stk, rig)`, and asserts: `LeftUpperArm.length == 168.0`, `LeftLowerArm.position.x == -168.0`, `LeftUpperLeg.length == 200.0`, `IK_Targets/Left_Leg.position.y == 400.0`, `IK_Targets/Left_Hand.position == (-168.0, -456.0)`. (Add under `test/` later if GUT is introduced; out of scope for this phase.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Design Decisions (summary)
|
||||
|
||||
| # | Decision | One-line justification |
|
||||
|---|---|---|
|
||||
| D1 | `proportions` = hardcoded master-rig rest-pose constants (§2) | They describe the rig's joints and are written verbatim into bones; user shapes are the aligned thing, not the measure. |
|
||||
| D2 | `pivot` = local bbox center; `length` = axis-specific extent (arms→Δx, legs/torso/head→Δy) (§3) | Matches the editor's actual rotation pivot and the adapter's `position.x` vs `position.y` bone convention. |
|
||||
| D3 | Version `"1.3" → "1.4"`, write-only metadata, no load migration (§1) | `pivot`/`length`/`proportions` are recomputed on save; old files load unchanged and gain keys on next save. |
|
||||
| D4 | `StkRigAdapter` = `RefCounted` static `apply()`, standalone script (§5a) | No scene/autoload dependency; consumed by a future runtime pipeline, never by the editor. |
|
||||
| D5 | Adapter targets `master_rig.tscn` (not `master_rig2.tscn`) (§5f) | It's the clean scene Phase 7 derived `GUIDE_JOINTS` from; `master_rig2` is a pose-override variant with a fixed right-leg length. |
|
||||
| D6 | Visual mount = update existing `Body/*` nodes in place (§5e) | Preserves `RemoteTransform2D` driving and node names; `.stk` shape → `Line2D`/`Polygon2D` with pivot→origin + length-normalization. |
|
||||
| D7 | Editor never imports/verifies the adapter (§5g) | Keeps the editor decoupled; the adapter is verified standalone (parse check + optional headless smoke test). |
|
||||
| D8 | Bone fitting adds lower-bone `.length` writes beyond the requirement's literal text (§5c) | Without it the lower limbs don't scale; the addition is a safe no-op at default proportions (flagged Q2). |
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions — RESOLVED (user-approved)
|
||||
|
||||
1. **`length` axis convention (D2).** ✅ **Axis-specific**: arms→X / legs+torso+head→Y (§3b).
|
||||
2. **Lower-bone length fitting (D8).** ✅ **Include** the completion `LeftLowerArm.length = lower_arm_length` etc. (§5c).
|
||||
3. **Visual-mount fidelity (§5e).** ✅ **v1 as specified**: pivot→bone origin + scale to bone length; part `position`/`rotation`/`scale` ignored (noted in adapter docs as a v2 concern).
|
||||
4. **`proportions` arms (§2).** ✅ **168.0** (bone length, reproduces rest pose).
|
||||
5. **Head `length` (§3b).** ✅ Vertical bbox height (circle diameter).
|
||||
|
||||
---
|
||||
|
||||
## 11. README / AGENTS Updates
|
||||
|
||||
- **README §"File format (`.stk`)"** — bump the example to `"1.4"`; add the top-level `proportions` table and the per-part `pivot`/`length` rows to the Part object table; update the migration note (`v1.0–v1.3` auto-migrate; pivot/length/proportions recomputed on save).
|
||||
- **README new subsection (or §"Project structure")** — document `scripts/stk_rig_adapter.gd`: its `static apply()` API, that it fits an instantiated `master_rig.tscn` to a loaded `.stk`, and that it is a runtime-pipeline utility (not used by the editor).
|
||||
- **AGENTS.md** — add a Phase 8 note: `FILE_VERSION "1.4"`; top-level `proportions` + per-part `pivot`/`length` computed on save in `stickman_editor.gd`; `scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) as a standalone runtime adapter targeting `master_rig.tscn`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Recommended Implementation Order
|
||||
|
||||
1. `scripts/stickman_editor.gd` — constants (`FILE_VERSION`, `SUPPORTED_VERSIONS`, `PROPORTIONS`, `X_AXIS_PARTS`) + `_compute_part_pivot_length()`.
|
||||
2. `scripts/stickman_editor.gd` — wire `pivot`/`length` into `_collect_all_shape_data()` and `proportions` into `_build_json_data()`.
|
||||
3. `scripts/stk_rig_adapter.gd` — `apply()` + `_fit_bones()` + `_recalibrate_ik()` + `_mount_shapes()` with §5b paths.
|
||||
4. Manual verification (§8) + `--headless --check-only --quit`.
|
||||
5. Optional headless adapter smoke test.
|
||||
6. Doc updates (`README.md`, `AGENTS.md`).
|
||||
+19
-4
@@ -53,7 +53,7 @@ bone_index = 1
|
||||
bone2d_node = NodePath("Torso/Head")
|
||||
target_nodepath = NodePath("../IK_Targets/Head")
|
||||
enable_constraint = true
|
||||
constraint_angle_min = 54.999985
|
||||
constraint_angle_min = 54.99998
|
||||
constraint_angle_max = 304.9999
|
||||
constraint_angle_invert = true
|
||||
constraint_in_localspace = true
|
||||
@@ -73,7 +73,7 @@ modifications/4 = SubResource("SkeletonModification2DLookAt_j4hao")
|
||||
[node name="Head" type="Node2D" parent="Body" unique_id=864822355]
|
||||
position = Vector2(-0.038591623, -463.50787)
|
||||
rotation = 0.000554086
|
||||
scale = Vector2(0.99999994, 0.99999994)
|
||||
scale = Vector2(0.9999999, 0.9999999)
|
||||
script = SubResource("GDScript_f0s26")
|
||||
|
||||
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
|
||||
@@ -151,7 +151,7 @@ rest = Transform2D(0.99999976, 0.00069912814, -0.00069912814, 0.99999976, 0, 0)
|
||||
[node name="Head" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1487357366]
|
||||
position = Vector2(-0.12882307, -391.5079)
|
||||
rotation = 0.000554086
|
||||
scale = Vector2(0.99999994, 0.99999994)
|
||||
scale = Vector2(0.9999999, 0.9999999)
|
||||
rest = Transform2D(0.9998273, 0.0005539903, -0.0005539903, 0.9998273, -0.12882307, -391.5079)
|
||||
auto_calculate_length_and_angle = false
|
||||
length = 90.0
|
||||
@@ -161,6 +161,10 @@ bone_angle = -90.0
|
||||
position = Vector2(0.050337255, -72.00002)
|
||||
remote_path = NodePath("../../../../Body/Head")
|
||||
|
||||
[node name="RayCast_Aim" type="RayCast2D" parent="Skeleton2D/Torso/Head" unique_id=2000000004]
|
||||
position = Vector2(0, -90)
|
||||
target_position = Vector2(500, 0)
|
||||
|
||||
[node name="LeftUpperArm" type="Bone2D" parent="Skeleton2D/Torso" unique_id=1840957808]
|
||||
position = Vector2(0, -248)
|
||||
rotation = 0.047700156
|
||||
@@ -238,7 +242,7 @@ remote_path = NodePath("../../../../Body/LeftUpperLeg")
|
||||
rotation = -0.4947779
|
||||
rest = Transform2D(0.8800552, -0.47482592, 0.47482592, 0.8800552, 0, 0)
|
||||
auto_calculate_length_and_angle = false
|
||||
length = 200.0
|
||||
length = 90.0
|
||||
bone_angle = 90.0
|
||||
|
||||
[node name="RightLowerLeg" type="Bone2D" parent="Skeleton2D/Torso/RightUpperLeg" unique_id=1819999778]
|
||||
@@ -261,6 +265,9 @@ remote_path = NodePath("../../../../Body/RightUpperLeg")
|
||||
rotation = 3.1415927
|
||||
remote_path = NodePath("../../../Body/Body")
|
||||
|
||||
[node name="RayCast_Ground" type="RayCast2D" parent="." unique_id=2000000003]
|
||||
position = Vector2(0, 350)
|
||||
|
||||
[node name="IK_Targets" type="Node2D" parent="." unique_id=1089334064]
|
||||
|
||||
[node name="Right_Hand" type="Marker2D" parent="IK_Targets" unique_id=1785254130]
|
||||
@@ -275,6 +282,14 @@ position = Vector2(96, 376)
|
||||
[node name="Left_Leg" type="Marker2D" parent="IK_Targets" unique_id=63598929]
|
||||
position = Vector2(-96, 376)
|
||||
|
||||
[node name="RayCast_LeftLeg" type="RayCast2D" parent="IK_Targets" unique_id=2000000001]
|
||||
position = Vector2(-96, 200)
|
||||
target_position = Vector2(0, 200)
|
||||
|
||||
[node name="RayCast_RightLeg" type="RayCast2D" parent="IK_Targets" unique_id=2000000002]
|
||||
position = Vector2(96, 200)
|
||||
target_position = Vector2(0, 200)
|
||||
|
||||
[node name="Head" type="Marker2D" parent="IK_Targets" unique_id=1347666160]
|
||||
position = Vector2(0, -624)
|
||||
|
||||
|
||||
@@ -48,10 +48,23 @@ const DEFAULT_POSITIONS: Dictionary = {
|
||||
"right_lower_leg": Vector2(165, 210),
|
||||
}
|
||||
|
||||
const FILE_VERSION := "1.3"
|
||||
const FILE_VERSION := "1.4"
|
||||
const FILE_FILTER := "*.stk ; Stickman Files"
|
||||
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3"]
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"]
|
||||
|
||||
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2).
|
||||
const PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5,
|
||||
}
|
||||
|
||||
const X_AXIS_PARTS: PackedStringArray = [
|
||||
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
|
||||
]
|
||||
|
||||
const SETTINGS_PATH := "user://settings.json"
|
||||
const SETTINGS_VERSION := "1.0"
|
||||
@@ -89,7 +102,11 @@ var _shape_clipboard: Dictionary = {}
|
||||
# Phase 6: recent colors
|
||||
var _recent_colors: Array[String] = []
|
||||
|
||||
# Phase 7: pose silhouette guide (default ON per user decision)
|
||||
var _show_guide: bool = true
|
||||
|
||||
var _edit_menu: PopupMenu
|
||||
var _view_menu: PopupMenu
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -156,9 +173,13 @@ func _setup_menu_bar() -> void:
|
||||
var view_menu: PopupMenu = PopupMenu.new()
|
||||
view_menu.name = "ViewMenu"
|
||||
view_menu.add_item("Reset Views", 0)
|
||||
view_menu.add_separator()
|
||||
view_menu.add_item(_guide_menu_label(), 1)
|
||||
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
|
||||
view_menu.about_to_popup.connect(_on_view_menu_about_to_popup)
|
||||
_menu_bar.add_child(view_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
|
||||
_view_menu = view_menu
|
||||
|
||||
|
||||
func _setup_dialogs() -> void:
|
||||
@@ -225,7 +246,9 @@ func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
1: # Snap to Grid (checkable)
|
||||
_snap_enabled = not _snap_enabled
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_snap_status_label()
|
||||
@@ -233,15 +256,38 @@ func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
|
||||
func _on_edit_menu_about_to_popup() -> void:
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
|
||||
|
||||
func _on_view_menu_id_pressed(id: int) -> void:
|
||||
if id == 0: # Reset Views
|
||||
match id:
|
||||
0: # Reset Views
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).reset_view()
|
||||
_whole_preview.reset_view()
|
||||
1: # Hide/Show Pose Guide
|
||||
_show_guide = not _show_guide
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _on_view_menu_about_to_popup() -> void:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _update_guide_menu_item() -> void:
|
||||
if _view_menu:
|
||||
var idx := _view_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_view_menu.set_item_text(idx, _guide_menu_label())
|
||||
|
||||
|
||||
func _guide_menu_label() -> String:
|
||||
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
|
||||
|
||||
|
||||
func _on_grid_config_confirmed() -> void:
|
||||
@@ -327,6 +373,36 @@ func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
|
||||
# Data collection / JSON helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _compute_part_pivot_length(shapes_arr: Array, part_name: String) -> Dictionary:
|
||||
var min_x := INF
|
||||
var min_y := INF
|
||||
var max_x := -INF
|
||||
var max_y := -INF
|
||||
for sd in shapes_arr:
|
||||
if not sd is Dictionary:
|
||||
continue
|
||||
for p in (sd as Dictionary).get("points", []):
|
||||
if not p is Dictionary:
|
||||
continue
|
||||
var px: float = float((p as Dictionary).get("x", 0.0))
|
||||
var py: float = float((p as Dictionary).get("y", 0.0))
|
||||
min_x = minf(min_x, px)
|
||||
min_y = minf(min_y, py)
|
||||
max_x = maxf(max_x, px)
|
||||
max_y = maxf(max_y, py)
|
||||
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return { "pivot": { "x": 0.0, "y": 0.0 }, "length": 0.0 }
|
||||
|
||||
var pivot := { "x": (min_x + max_x) * 0.5, "y": (min_y + max_y) * 0.5 }
|
||||
var length: float
|
||||
if X_AXIS_PARTS.has(part_name):
|
||||
length = max_x - min_x
|
||||
else:
|
||||
length = max_y - min_y
|
||||
return { "pivot": pivot, "length": length }
|
||||
|
||||
|
||||
func _collect_all_shape_data() -> Dictionary:
|
||||
var all_data: Dictionary = {}
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
@@ -336,11 +412,14 @@ func _collect_all_shape_data() -> Dictionary:
|
||||
var pos := _whole_preview.get_part_position(part_name)
|
||||
var rot := _whole_preview.get_part_rotation(part_name)
|
||||
var scl := _whole_preview.get_part_scale(part_name)
|
||||
var pl := _compute_part_pivot_length(shapes_arr, part_name)
|
||||
all_data[part_name] = {
|
||||
"shapes": shapes_arr,
|
||||
"position": {"x": pos.x, "y": pos.y},
|
||||
"rotation": rot,
|
||||
"scale": {"x": scl.x, "y": scl.y}
|
||||
"scale": {"x": scl.x, "y": scl.y},
|
||||
"pivot": pl["pivot"],
|
||||
"length": pl["length"],
|
||||
}
|
||||
return all_data
|
||||
|
||||
@@ -353,6 +432,7 @@ func _build_json_data() -> Dictionary:
|
||||
"version": FILE_VERSION,
|
||||
"stickman_name": _stickman_name_edit.text.strip_edges(),
|
||||
"part_order": _whole_preview.get_part_order(),
|
||||
"proportions": PROPORTIONS.duplicate(),
|
||||
"body_parts": body_parts,
|
||||
"metadata": {
|
||||
"created_at": time_str,
|
||||
@@ -369,7 +449,7 @@ func _apply_json_data(json: Variant) -> String:
|
||||
|
||||
var version: String = dict.get("version", "")
|
||||
if version not in SUPPORTED_VERSIONS:
|
||||
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', or '1.3')" % version
|
||||
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', or '1.4')" % version
|
||||
|
||||
_stickman_name_edit.text = dict.get("stickman_name", "")
|
||||
|
||||
@@ -527,6 +607,7 @@ func _load_settings() -> void:
|
||||
var d := json as Dictionary
|
||||
_grid_size = int(d.get("grid_size", DEFAULT_GRID_SIZE))
|
||||
_snap_enabled = bool(d.get("snap_to_grid", false))
|
||||
_show_guide = bool(d.get("show_pose_guide", true))
|
||||
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||||
|
||||
var raw_colors: Variant = d.get("recent_colors", [])
|
||||
@@ -539,7 +620,11 @@ func _load_settings() -> void:
|
||||
_recent_colors.pop_back()
|
||||
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
if _view_menu:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
@@ -548,6 +633,7 @@ func _save_settings() -> void:
|
||||
"grid_size": _grid_size,
|
||||
"snap_to_grid": _snap_enabled,
|
||||
"recent_colors": _recent_colors,
|
||||
"show_pose_guide": _show_guide,
|
||||
}
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||||
if file:
|
||||
@@ -563,6 +649,7 @@ func _broadcast_settings() -> void:
|
||||
p.set_snap_enabled(_snap_enabled)
|
||||
_whole_preview.set_grid_size(_grid_size)
|
||||
_whole_preview.set_snap_enabled(_snap_enabled)
|
||||
_whole_preview.set_show_guide(_show_guide)
|
||||
|
||||
|
||||
func _snap_menu_label() -> String:
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
class_name StkRigAdapter
|
||||
extends RefCounted
|
||||
## StkRigAdapter - Standalone runtime adapter (Phase 8).
|
||||
##
|
||||
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits
|
||||
## the skeleton bone lengths, recalibrates the IK targets, and mounts the
|
||||
## .stk vector shapes onto the rig's Body/ visual nodes. Consumed by a future
|
||||
## runtime pipeline, never referenced by the editor.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_LINE_WIDTH := 16.0
|
||||
const ELBOW_REST_Y := -256.0
|
||||
|
||||
const DEFAULT_PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5,
|
||||
}
|
||||
|
||||
const PART_KEYS: PackedStringArray = [
|
||||
"head", "torso",
|
||||
"left_upper_arm", "left_lower_arm",
|
||||
"right_upper_arm", "right_lower_arm",
|
||||
"left_upper_leg", "left_lower_leg",
|
||||
"right_upper_leg", "right_lower_leg",
|
||||
]
|
||||
|
||||
## Bone node paths (relative to rig root), keyed by part name.
|
||||
const BONE_PATHS: Dictionary = {
|
||||
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm",
|
||||
"left_lower_arm": "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm",
|
||||
"right_upper_arm": "Skeleton2D/Torso/RightUpperArm",
|
||||
"right_lower_arm": "Skeleton2D/Torso/RightUpperArm/RightLowerArm",
|
||||
"left_upper_leg": "Skeleton2D/Torso/LeftUpperLeg",
|
||||
"left_lower_leg": "Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg",
|
||||
"right_upper_leg": "Skeleton2D/Torso/RightUpperLeg",
|
||||
"right_lower_leg": "Skeleton2D/Torso/RightUpperLeg/RightLowerLeg",
|
||||
}
|
||||
|
||||
## Body visual node paths (relative to rig root), keyed by part name.
|
||||
const BODY_PATHS: Dictionary = {
|
||||
"head": "Body/Head",
|
||||
"torso": "Body/Body",
|
||||
"left_upper_arm": "Body/LeftUpperArm",
|
||||
"left_lower_arm": "Body/LeftLowerArm",
|
||||
"right_upper_arm": "Body/RightUpperArm",
|
||||
"right_lower_arm": "Body/RightLowerArm",
|
||||
"left_upper_leg": "Body/LeftUpperLeg",
|
||||
"left_lower_leg": "Body/LeftLowerLeg",
|
||||
"right_upper_leg": "Body/RightUpperLeg",
|
||||
"right_lower_leg": "Body/RightLowerLeg",
|
||||
}
|
||||
|
||||
const IK_LEFT_HAND := "IK_Targets/Left_Hand"
|
||||
const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
|
||||
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
|
||||
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
_fit_bones(stk_data, rig)
|
||||
_recalibrate_ik(stk_data, rig)
|
||||
_mount_shapes(stk_data, rig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proportions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _get_proportions(stk_data: Dictionary) -> Dictionary:
|
||||
var raw: Variant = stk_data.get("proportions", {})
|
||||
if raw is Dictionary:
|
||||
return raw as Dictionary
|
||||
return {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bone fitting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var proportions := _get_proportions(stk_data)
|
||||
var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_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 ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
|
||||
# Arms — upper length + lower-bone origin on X.
|
||||
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
|
||||
_set_prop(rig, BONE_PATHS["left_lower_arm"], "position", Vector2(-ua, 0.0))
|
||||
_set_prop(rig, BONE_PATHS["left_lower_arm"], "length", la)
|
||||
_set_prop(rig, BONE_PATHS["right_upper_arm"], "length", ua)
|
||||
_set_prop(rig, BONE_PATHS["right_lower_arm"], "position", Vector2(ua, 0.0))
|
||||
_set_prop(rig, BONE_PATHS["right_lower_arm"], "length", la)
|
||||
|
||||
# Legs — upper length + lower-bone origin on Y.
|
||||
_set_prop(rig, BONE_PATHS["left_upper_leg"], "length", ul)
|
||||
_set_prop(rig, BONE_PATHS["left_lower_leg"], "position", Vector2(0.0, ul))
|
||||
_set_prop(rig, BONE_PATHS["left_lower_leg"], "length", ll)
|
||||
_set_prop(rig, BONE_PATHS["right_upper_leg"], "length", ul)
|
||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
|
||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
|
||||
|
||||
|
||||
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
||||
var node := rig.get_node_or_null(NodePath(path))
|
||||
if node == null:
|
||||
push_warning("StkRigAdapter: missing node '%s'; skipped '%s'." % [path, prop])
|
||||
return
|
||||
node.set(prop, value)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IK target recalibration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var proportions := _get_proportions(stk_data)
|
||||
var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_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 ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
|
||||
var left_leg := rig.get_node_or_null(NodePath(IK_LEFT_LEG)) as Node2D
|
||||
if left_leg != null:
|
||||
left_leg.position = Vector2(left_leg.position.x, ul + ll)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_LEG)
|
||||
|
||||
var right_leg := rig.get_node_or_null(NodePath(IK_RIGHT_LEG)) as Node2D
|
||||
if right_leg != null:
|
||||
right_leg.position = Vector2(right_leg.position.x, ul + ll)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_LEG)
|
||||
|
||||
var left_hand := rig.get_node_or_null(NodePath(IK_LEFT_HAND)) as Node2D
|
||||
if left_hand != null:
|
||||
left_hand.position = Vector2(-ua, ELBOW_REST_Y - la)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_HAND)
|
||||
|
||||
var right_hand := rig.get_node_or_null(NodePath(IK_RIGHT_HAND)) as Node2D
|
||||
if right_hand != null:
|
||||
right_hand.position = Vector2(ua, ELBOW_REST_Y - la)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_HAND)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visual shape mount
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var body_parts_var: Variant = stk_data.get("body_parts", {})
|
||||
if not body_parts_var is Dictionary:
|
||||
push_warning("StkRigAdapter: 'body_parts' missing or invalid; no shapes mounted.")
|
||||
return
|
||||
var body_parts: Dictionary = body_parts_var as Dictionary
|
||||
var proportions := _get_proportions(stk_data)
|
||||
|
||||
for part_name: String in PART_KEYS:
|
||||
var visual := rig.get_node_or_null(NodePath(BODY_PATHS[part_name]))
|
||||
if visual == null:
|
||||
push_warning("StkRigAdapter: missing Body node for part '%s'; skipped." % part_name)
|
||||
continue
|
||||
|
||||
var shapes: Array = []
|
||||
var pivot := Vector2.ZERO
|
||||
var part_length := 0.0
|
||||
var part_data: Variant = body_parts.get(part_name, {})
|
||||
if part_data is Dictionary:
|
||||
var pd := part_data as Dictionary
|
||||
var shapes_var: Variant = pd.get("shapes", [])
|
||||
if shapes_var is Array:
|
||||
shapes = shapes_var as Array
|
||||
var pivot_var: Variant = pd.get("pivot", {})
|
||||
if pivot_var is Dictionary:
|
||||
var pv := pivot_var as Dictionary
|
||||
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 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 scale_factor := 1.0
|
||||
if part_name != "head" and part_length > 0.0001:
|
||||
scale_factor = bone_length / part_length
|
||||
|
||||
_reset_own_geometry(visual)
|
||||
_clear_visual_children(visual)
|
||||
|
||||
for shape in shapes:
|
||||
if shape is Dictionary:
|
||||
_mount_shape(visual, shape as Dictionary, pivot, scale_factor)
|
||||
|
||||
|
||||
static func _bone_length_for(part_name: String, proportions: Dictionary) -> float:
|
||||
match part_name:
|
||||
"left_upper_arm", "right_upper_arm":
|
||||
return float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
||||
"left_lower_arm", "right_lower_arm":
|
||||
return float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
||||
"left_upper_leg", "right_upper_leg":
|
||||
return float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
||||
"left_lower_leg", "right_lower_leg":
|
||||
return float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
"torso":
|
||||
return float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
|
||||
_:
|
||||
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:
|
||||
var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
|
||||
if pts.size() < 2:
|
||||
return
|
||||
|
||||
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
|
||||
var closed := bool(shape.get("closed", false))
|
||||
|
||||
if closed:
|
||||
var poly := Polygon2D.new()
|
||||
poly.polygon = pts
|
||||
poly.color = color
|
||||
visual.add_child(poly)
|
||||
|
||||
var outline := Line2D.new()
|
||||
outline.points = pts
|
||||
outline.closed = true
|
||||
outline.width = DEFAULT_LINE_WIDTH
|
||||
outline.default_color = color
|
||||
visual.add_child(outline)
|
||||
else:
|
||||
var line := Line2D.new()
|
||||
line.points = pts
|
||||
line.width = DEFAULT_LINE_WIDTH
|
||||
line.default_color = color
|
||||
visual.add_child(line)
|
||||
|
||||
|
||||
static func _transform_points(pts_var: Variant, pivot: Vector2, scale_factor: float) -> PackedVector2Array:
|
||||
var out := PackedVector2Array()
|
||||
if pts_var is Array:
|
||||
for p in pts_var as Array:
|
||||
if p is Dictionary:
|
||||
var d := p as Dictionary
|
||||
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
|
||||
out.append((pt - pivot) * scale_factor)
|
||||
elif p is Vector2:
|
||||
out.append(((p as Vector2) - pivot) * scale_factor)
|
||||
elif pts_var is PackedVector2Array:
|
||||
for pt in pts_var as PackedVector2Array:
|
||||
out.append((pt - pivot) * scale_factor)
|
||||
return out
|
||||
|
||||
|
||||
static func _reset_own_geometry(visual: Node) -> void:
|
||||
if visual is Line2D:
|
||||
(visual as Line2D).points = PackedVector2Array()
|
||||
elif visual is Polygon2D:
|
||||
(visual as Polygon2D).polygon = PackedVector2Array()
|
||||
|
||||
|
||||
static func _clear_visual_children(visual: Node) -> void:
|
||||
for child in visual.get_children():
|
||||
if child is Line2D or child is Polygon2D:
|
||||
visual.remove_child(child)
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxdghe86wml5e
|
||||
@@ -42,6 +42,56 @@ const DEFAULT_PART_ORDER: PackedStringArray = [
|
||||
"right_upper_leg", "right_lower_leg"
|
||||
]
|
||||
|
||||
# Phase 7: pose silhouette guide
|
||||
const GUIDE_JOINT_RADIUS: float = 6.0 # screen-space joint dot radius (px)
|
||||
const GUIDE_SCALE: float = 1.0 # master_rig -> preview world scale (1:1, per user decision)
|
||||
const GUIDE_FIGURE_CENTER: Vector2 = Vector2(0.0, -93.75) # master-space bbox center of the 1:1 guide figure
|
||||
const GUIDE_HEAD_RADIUS: float = 100.0 # master-space head circle radius
|
||||
|
||||
const GUIDE_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.45) # cyan-blue, semi
|
||||
const GUIDE_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.45) # orange-red, semi
|
||||
const GUIDE_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.45) # white, semi
|
||||
|
||||
const GUIDE_JOINT_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.65)
|
||||
const GUIDE_JOINT_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.65)
|
||||
const GUIDE_JOINT_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.65)
|
||||
|
||||
const GUIDE_JOINTS: Dictionary = {
|
||||
"Hips": Vector2(0.0, 0.0),
|
||||
"Neck": Vector2(0.0, -391.5),
|
||||
"Head": Vector2(0.0, -463.5),
|
||||
"LeftShoulder": Vector2(0.0, -248.0),
|
||||
"RightShoulder": Vector2(0.0, -248.0),
|
||||
"LeftElbow": Vector2(-168.0, -256.0),
|
||||
"RightElbow": Vector2(168.0, -256.0),
|
||||
"LeftWrist": Vector2(-168.0, -456.0),
|
||||
"RightWrist": Vector2(168.0, -456.0),
|
||||
"LeftKnee": Vector2(-95.0, 176.0),
|
||||
"RightKnee": Vector2(95.0, 176.0),
|
||||
"LeftAnkle": Vector2(-96.0, 376.0),
|
||||
"RightAnkle": Vector2(96.0, 376.0),
|
||||
}
|
||||
|
||||
const GUIDE_LEFT_JOINTS: PackedStringArray = [
|
||||
"LeftShoulder", "LeftElbow", "LeftWrist", "LeftKnee", "LeftAnkle"
|
||||
]
|
||||
const GUIDE_RIGHT_JOINTS: PackedStringArray = [
|
||||
"RightShoulder", "RightElbow", "RightWrist", "RightKnee", "RightAnkle"
|
||||
]
|
||||
# any joint not in LEFT/RIGHT is central (Hips, Neck, Head)
|
||||
|
||||
const GUIDE_BONES: Array = [
|
||||
["Hips", "Neck"], # spine (central)
|
||||
["LeftShoulder", "LeftElbow"], # left upper arm
|
||||
["LeftElbow", "LeftWrist"], # left forearm
|
||||
["RightShoulder", "RightElbow"], # right upper arm
|
||||
["RightElbow", "RightWrist"], # right forearm
|
||||
["Hips", "LeftKnee"], # left thigh
|
||||
["LeftKnee", "LeftAnkle"], # left shin
|
||||
["Hips", "RightKnee"], # right thigh
|
||||
["RightKnee", "RightAnkle"], # right shin
|
||||
]
|
||||
|
||||
enum Interaction { NONE, TRANSLATE, ROTATE, SCALE }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,6 +145,9 @@ var _context_menu: PopupMenu
|
||||
# Phase 6: selection gizmos rendered on top
|
||||
var _selected_gizmo_bounds: Rect2 = Rect2()
|
||||
|
||||
# Phase 7: pose silhouette guide (default ON per user decision)
|
||||
var _show_guide: bool = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -212,6 +265,12 @@ func set_snap_enabled(enabled: bool) -> void:
|
||||
_snap_enabled = enabled
|
||||
|
||||
|
||||
# Phase 7
|
||||
func set_show_guide(enabled: bool) -> void:
|
||||
_show_guide = enabled
|
||||
preview_area.queue_redraw()
|
||||
|
||||
|
||||
func reset_view() -> void:
|
||||
_zoom = 1.0
|
||||
_pan_offset = Vector2.ZERO
|
||||
@@ -323,6 +382,9 @@ func _on_preview_draw() -> void:
|
||||
if bounds.has_area():
|
||||
_selected_gizmo_bounds = bounds
|
||||
|
||||
# Phase 7: ghost the silhouette guide IN FRONT of parts (below selection affordances)
|
||||
_draw_silhouette_guide()
|
||||
|
||||
# Highlight dragged part
|
||||
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
|
||||
var bounds: Variant = _part_bounds.get(_dragging_part)
|
||||
@@ -373,6 +435,58 @@ func _draw_grid() -> void:
|
||||
y += gs
|
||||
|
||||
|
||||
func _draw_silhouette_guide() -> void:
|
||||
if not _show_guide:
|
||||
return
|
||||
|
||||
var lw: float = 1.5 / _zoom # match _draw_polyline_preview stroke
|
||||
var jr: float = GUIDE_JOINT_RADIUS / _zoom # constant 6px screen size
|
||||
|
||||
# 1. Limb / spine segments (semi-transparent)
|
||||
for bone: Array in GUIDE_BONES:
|
||||
var a: String = bone[0] as String
|
||||
var b: String = bone[1] as String
|
||||
if not (GUIDE_JOINTS.has(a) and GUIDE_JOINTS.has(b)):
|
||||
continue
|
||||
var pa: Vector2 = _guide_to_preview(GUIDE_JOINTS[a])
|
||||
var pb: Vector2 = _guide_to_preview(GUIDE_JOINTS[b])
|
||||
preview_area.draw_line(pa, pb, _guide_color(a), lw)
|
||||
|
||||
# 2. Head outline circle (central, semi-transparent)
|
||||
var head_center: Vector2 = _guide_to_preview(GUIDE_JOINTS["Head"])
|
||||
preview_area.draw_arc(
|
||||
head_center,
|
||||
GUIDE_HEAD_RADIUS * GUIDE_SCALE,
|
||||
0.0, TAU, 48,
|
||||
GUIDE_COLOR_CENTRAL, lw
|
||||
)
|
||||
|
||||
# 3. Joint anchors (constant screen size)
|
||||
for joint: String in GUIDE_JOINTS:
|
||||
var p: Vector2 = _guide_to_preview(GUIDE_JOINTS[joint])
|
||||
preview_area.draw_circle(p, jr, _guide_joint_color(joint))
|
||||
|
||||
|
||||
func _guide_to_preview(master_pos: Vector2) -> Vector2:
|
||||
return (master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5
|
||||
|
||||
|
||||
func _guide_color(joint_name: String) -> Color:
|
||||
if GUIDE_LEFT_JOINTS.has(joint_name):
|
||||
return GUIDE_COLOR_LEFT
|
||||
if GUIDE_RIGHT_JOINTS.has(joint_name):
|
||||
return GUIDE_COLOR_RIGHT
|
||||
return GUIDE_COLOR_CENTRAL
|
||||
|
||||
|
||||
func _guide_joint_color(joint_name: String) -> Color:
|
||||
if GUIDE_LEFT_JOINTS.has(joint_name):
|
||||
return GUIDE_JOINT_COLOR_LEFT
|
||||
if GUIDE_RIGHT_JOINTS.has(joint_name):
|
||||
return GUIDE_JOINT_COLOR_RIGHT
|
||||
return GUIDE_JOINT_COLOR_CENTRAL
|
||||
|
||||
|
||||
func _draw_polyline_preview(pts: PackedVector2Array, color: Color, closed: bool) -> void:
|
||||
for i: int in range(pts.size() - 1):
|
||||
preview_area.draw_line(pts[i], pts[i + 1], color, 1.5)
|
||||
|
||||
+114
-57
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.3",
|
||||
"version": "1.4",
|
||||
"stickman_name": "",
|
||||
"part_order": [
|
||||
"torso",
|
||||
@@ -13,6 +13,13 @@
|
||||
"right_upper_leg",
|
||||
"right_lower_leg"
|
||||
],
|
||||
"proportions": {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5
|
||||
},
|
||||
"body_parts": {
|
||||
"head": {
|
||||
"shapes": [
|
||||
@@ -281,14 +288,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 490.884521484375,
|
||||
"y": 133.55908203125
|
||||
"x": 473.278625488281,
|
||||
"y": 381.460693359375
|
||||
},
|
||||
"rotation": -360.0,
|
||||
"scale": {
|
||||
"x": 1.47983860969543,
|
||||
"y": 1.47983860969543
|
||||
}
|
||||
"x": 1.98590219020844,
|
||||
"y": 1.98590219020844
|
||||
},
|
||||
"pivot": {
|
||||
"x": 504.221405029297,
|
||||
"y": 178.031181335449
|
||||
},
|
||||
"length": 110.269149780273
|
||||
},
|
||||
"torso": {
|
||||
"shapes": [
|
||||
@@ -323,14 +335,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 674.5,
|
||||
"y": 221.500030517578
|
||||
"x": 649.5,
|
||||
"y": 599.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"scale": {
|
||||
"x": 0.857142865657806,
|
||||
"y": 1.81818187236786
|
||||
}
|
||||
"y": 3.9898989200592
|
||||
},
|
||||
"pivot": {
|
||||
"x": 300.5,
|
||||
"y": 258.5
|
||||
},
|
||||
"length": 99.0
|
||||
},
|
||||
"left_upper_arm": {
|
||||
"shapes": [
|
||||
@@ -365,14 +382,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 434.0,
|
||||
"y": 247.0
|
||||
"x": 371.5,
|
||||
"y": 609.5
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
"x": 1.6074765920639,
|
||||
"y": 0.782608687877655
|
||||
},
|
||||
"pivot": {
|
||||
"x": 489.5,
|
||||
"y": 169.5
|
||||
},
|
||||
"length": 107.0
|
||||
},
|
||||
"left_lower_arm": {
|
||||
"shapes": [
|
||||
@@ -407,14 +429,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 340.571166992188,
|
||||
"y": 282.654418945313
|
||||
"x": 305.000091552734,
|
||||
"y": 534.999938964844
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"rotation": 90.0,
|
||||
"scale": {
|
||||
"x": 0.988918423652649,
|
||||
"y": 1.38907158374786
|
||||
}
|
||||
"x": 1.88584887981415,
|
||||
"y": 1.25166654586792
|
||||
},
|
||||
"pivot": {
|
||||
"x": 479.999969482422,
|
||||
"y": 135.0
|
||||
},
|
||||
"length": 106.053039550781
|
||||
},
|
||||
"right_upper_arm": {
|
||||
"shapes": [
|
||||
@@ -449,14 +476,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 714.5,
|
||||
"y": 258.5
|
||||
"x": 711.0,
|
||||
"y": 622.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"rotation": -180.0,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
"x": 1.6822429895401,
|
||||
"y": 0.869565904140472
|
||||
},
|
||||
"pivot": {
|
||||
"x": 329.0,
|
||||
"y": 158.0
|
||||
},
|
||||
"length": 107.0
|
||||
},
|
||||
"right_lower_arm": {
|
||||
"shapes": [
|
||||
@@ -491,14 +523,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 720.103637695313,
|
||||
"y": 271.813385009766
|
||||
"x": 707.087768554688,
|
||||
"y": 524.3349609375
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"rotation": 90.0,
|
||||
"scale": {
|
||||
"x": 0.999799728393555,
|
||||
"y": 1.31353163719177
|
||||
}
|
||||
"x": 1.9427238702774,
|
||||
"y": 1.25166583061218
|
||||
},
|
||||
"pivot": {
|
||||
"x": 412.91227722168,
|
||||
"y": 143.680892944336
|
||||
},
|
||||
"length": 106.053070068359
|
||||
},
|
||||
"left_upper_leg": {
|
||||
"shapes": [
|
||||
@@ -533,14 +570,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 394.1083984375,
|
||||
"y": 427.365753173828
|
||||
"x": 366.208190917969,
|
||||
"y": 959.632446289063
|
||||
},
|
||||
"rotation": -45.0,
|
||||
"rotation": -60.0,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
"x": 2.07383060455322,
|
||||
"y": 0.853155195713043
|
||||
},
|
||||
"pivot": {
|
||||
"x": 536.5,
|
||||
"y": 154.0
|
||||
},
|
||||
"length": 28.0
|
||||
},
|
||||
"left_lower_leg": {
|
||||
"shapes": [
|
||||
@@ -575,14 +617,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 386.0,
|
||||
"y": 479.0
|
||||
"x": 345.5,
|
||||
"y": 1129.0
|
||||
},
|
||||
"rotation": 90.0,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
"x": 2.09999990463257,
|
||||
"y": 0.961538791656494
|
||||
},
|
||||
"pivot": {
|
||||
"x": 512.0,
|
||||
"y": 171.0
|
||||
},
|
||||
"length": 26.0
|
||||
},
|
||||
"right_upper_leg": {
|
||||
"shapes": [
|
||||
@@ -617,14 +664,19 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 555.037841796875,
|
||||
"y": 420.346771240234
|
||||
"x": 538.218627929688,
|
||||
"y": 957.340209960938
|
||||
},
|
||||
"rotation": 45.0,
|
||||
"rotation": 60.0,
|
||||
"scale": {
|
||||
"x": 0.977314233779907,
|
||||
"y": 0.923182904720306
|
||||
}
|
||||
"x": 2.10031747817993,
|
||||
"y": 0.757614314556122
|
||||
},
|
||||
"pivot": {
|
||||
"x": 464.0,
|
||||
"y": 163.0
|
||||
},
|
||||
"length": 28.0
|
||||
},
|
||||
"right_lower_leg": {
|
||||
"shapes": [
|
||||
@@ -659,18 +711,23 @@
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"x": 628.999938964844,
|
||||
"y": 487.000061035156
|
||||
"x": 628.5,
|
||||
"y": 1139.5
|
||||
},
|
||||
"rotation": 90.0,
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
}
|
||||
"x": 2.05000042915344,
|
||||
"y": 0.961539387702942
|
||||
},
|
||||
"pivot": {
|
||||
"x": 419.0,
|
||||
"y": 163.0
|
||||
},
|
||||
"length": 26.0
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"created_at": "2026-08-07T23:23:34",
|
||||
"modified_at": "2026-08-07T23:23:34"
|
||||
"created_at": "2026-08-18T00:05:43",
|
||||
"modified_at": "2026-08-18T00:05:43"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user