- 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.
444 lines
28 KiB
Markdown
444 lines
28 KiB
Markdown
# 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`).
|