Files
stickman/docs/phase8_spec.md
T
ryan 48d98ce0fe 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.
2026-08-18 17:12:54 -04:00

444 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.0v1.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.0v1.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.0v1.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`).