spine-rigc 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -6
- package/cli.ts +39 -5
- package/docs/AUTHORING.md +611 -24
- package/docs/SPEC_COVERAGE.md +16 -10
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1013 -92
- package/src/compile.ts +325 -6
- package/src/ladder.ts +1 -1
- package/src/render.ts +22 -2
- package/src/rig.ts +169 -6
- package/src/timelines.ts +9 -5
- package/src/types.ts +80 -1
- package/src/validate.ts +192 -2
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -522,6 +522,9 @@ Legend: ✅ emits · 🟡 partial · ❌ not emitted · 🚫 deliberately exclud
|
|
|
522
522
|
|
|
523
523
|
### 2.1 Coverage against Part 1
|
|
524
524
|
|
|
525
|
+
> 🔄 **Re-synced 2026-08-23** against `src/compile.ts`/`src/rig.ts` on `main`. Only the rows below
|
|
526
|
+
> were touched; the rest of this table was not re-verified in this pass.
|
|
527
|
+
|
|
525
528
|
**Header** (`compile.ts:634-646`)
|
|
526
529
|
|
|
527
530
|
| Part-1 row | rigc | Note |
|
|
@@ -530,7 +533,8 @@ Legend: ✅ emits · 🟡 partial · ❌ not emitted · 🚫 deliberately exclud
|
|
|
530
533
|
| `x`, `y` | ✅ | always literal `0, 0` |
|
|
531
534
|
| `width`, `height` | ✅ | from `manifest.crop.w/h` |
|
|
532
535
|
| `hash` | ❌ | never emitted (harmless; the parser stores it and nothing reads it) |
|
|
533
|
-
| `fps`, `images`, `
|
|
536
|
+
| `fps`, `images`, `referenceScale` | ✅ | copied from `rig.skeleton` when the rig spec gives them, omitted when it does not (`compile.ts:810-812`, landed in `c5eda3b`) |
|
|
537
|
+
| `audio` | ❌ | |
|
|
534
538
|
|
|
535
539
|
**Bones** (`compile.ts:367-456`; type at `types.ts:285-292`)
|
|
536
540
|
|
|
@@ -551,7 +555,7 @@ Legend: ✅ emits · 🟡 partial · ❌ not emitted · 🚫 deliberately exclud
|
|
|
551
555
|
| `attachment` | ✅ — only when the motion spec's `setup` block names one; `null` means "show nothing" and the key is omitted |
|
|
552
556
|
| `color` | ✅ — from `setup.color`, hex-encoded (`compile.ts:63-66`) |
|
|
553
557
|
| `dark` | 🚫 **A12_NO_DARK_COLOR** (`validate.ts:250-262`) |
|
|
554
|
-
| `blend` |
|
|
558
|
+
| `blend` | ✅ — copied from the rig spec's slot when it gives one (`compile.ts:600`, landed in `c5eda3b`) |
|
|
555
559
|
| `visible` | ❌ |
|
|
556
560
|
| draw order = slots array order | ✅, and **A26_SLOT_DRAW_ORDER** pins it to the archetype's `slotOrder` table |
|
|
557
561
|
|
|
@@ -564,11 +568,11 @@ Legend: ✅ emits · 🟡 partial · ❌ not emitted · 🚫 deliberately exclud
|
|
|
564
568
|
| --- | --- | --- |
|
|
565
569
|
| `region` | 🟡 | emits `width`, `height` (always, from PNG measurement — the fix for case 6c), `x`, `y` (only when non-zero), `rotation` (only when non-zero, cancelling the bone's world rotation). ❌ `path` (region name == attachment name == PNG basename, enforced by **A08** + **A27**), ❌ `scaleX`/`scaleY`, ❌ `color`, ❌ `sequence` |
|
|
566
570
|
| `mesh` | 🟡 | emits `type`, `uvs`, `triangles`, `vertices` (**weighted encoding only**), `hull`, `width`, `height`. ❌ `edges`, ❌ `path`, ❌ `color`, ❌ `sequence`. Unweighted meshes are 🚫 **A20_MESH_WEIGHTS_COHERENT** (`validate.ts:430-433`) |
|
|
567
|
-
| `linkedmesh` | ❌ | |
|
|
568
|
-
| `boundingbox` |
|
|
569
|
-
| `path` | ❌ | |
|
|
570
|
-
| `point` | ❌ | |
|
|
571
|
-
| `clipping` | 🚫 | **A11_NO_CLIPPING_ATTACHMENTS**
|
|
571
|
+
| `linkedmesh` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
|
|
572
|
+
| `boundingbox` | ✅ | `vertexCount` (required and cross-checked), `vertices` **or** by-name `weights`, `color`. **A33_VERTEX_ATTACHMENT_GEOMETRY** |
|
|
573
|
+
| `path` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
|
|
574
|
+
| `point` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
|
|
575
|
+
| `clipping` | ✅ under `--profile spine` · 🚫 under `spine-html` | `end` (refused when it names no slot), `convex`, `inverse`, `vertexCount`, geometry, `color`. **A33**, and **A11_NO_CLIPPING_ATTACHMENTS** is the renderer-profile refusal |
|
|
572
576
|
| `sequence` block | ❌ | |
|
|
573
577
|
|
|
574
578
|
Mesh geometry is generated by exactly two procedural generators (`mesh.ts`): `buildRingMesh`
|
|
@@ -580,8 +584,8 @@ chain). There is **no triangulator for arbitrary art** and no importer for edito
|
|
|
580
584
|
| Part-1 type | rigc |
|
|
581
585
|
| --- | --- |
|
|
582
586
|
| `physics` | ✅ — full field set: `bone`, the five components `x`/`y`/`rotate`/`scaleX`/`shearX`, and `inertia`/`strength`/`damping`/`mass`/`wind`/`gravity`/`mix`/`fps`/`limit`. Values equal to the parser default are **omitted** (`compile.ts:555-559`). ❌ `scaleY` (ScaleYMode), ❌ the seven `*Global` flags, ❌ `skin` |
|
|
583
|
-
| `ik` |
|
|
584
|
-
| `transform` |
|
|
587
|
+
| `ik` | ✅ — `bones`, `target`, `scaleY`/`mix`/`softness`/`bendPositive`/`compress`/`stretch`/`skin` (`compile.ts:1425-1430`, landed in `c5eda3b`) |
|
|
588
|
+
| `transform` | ✅ — `bones`, `source`, the 4.3 `properties{from→to}` model, and the full field set (`compile.ts:1431-1462`, landed in `c5eda3b`) |
|
|
585
589
|
| `path` | ❌ |
|
|
586
590
|
| `slider` | ❌ |
|
|
587
591
|
| flat `constraints[]` shape | ✅ — and **A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS** actively rejects the 4.1/4.2 shape (`validate.ts:225-235`), which is the machine form of case 6a |
|
|
@@ -602,7 +606,7 @@ chain). There is **no triangulator for arbitrary art** and no importer for edito
|
|
|
602
606
|
| `attachments.<skin>.…deform` | ❌ (validator knows it: 1 channel, `validate.ts:104-107`) |
|
|
603
607
|
| `attachments.…sequence` | ❌ |
|
|
604
608
|
| `drawOrder`, `drawOrderFolder` | ❌ |
|
|
605
|
-
| `events` (+ the `root.events` block) |
|
|
609
|
+
| `events` (+ the `root.events` block) | ✅ — the rig spec declares the names (`rig.ts` `RigEvent`), the motion spec's per-animation `events` array fires them, and **A32_EVENT_KEYS_RESOLVE** checks the three ways the timeline goes wrong quietly. `int`/`float`/`string` overrides and `audio`/`volume`/`balance` all round-trip |
|
|
606
610
|
| animation `color` | ❌ |
|
|
607
611
|
|
|
608
612
|
**Curves** — ✅ all three encodings. Linear = no `curve` key; `"stepped"` passes through as the string
|
|
@@ -663,6 +667,8 @@ This is the split Part 4(c) needs. **Spine-validity** = the file is wrong for an
|
|
|
663
667
|
| `A24_AXIS_SPACE_STROKE` | archetype | screen-space stroke keys |
|
|
664
668
|
| `A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares detached, parented under the bone it must stay clear of |
|
|
665
669
|
| `A26_SLOT_DRAW_ORDER` | archetype | draw order ≠ the archetype's slot table |
|
|
670
|
+
| `A32_EVENT_KEYS_RESOLVE` | validity | an event key firing an undeclared name (parser throws), key times going backwards (silent — frames are filled in array order), `volume`/`balance` on an event with no `audio` (silent) |
|
|
671
|
+
| `A33_VERTEX_ATTACHMENT_GEOMETRY` | validity | a bounding box or clipping polygon with a missing or disagreeing `vertexCount` (`undefined << 1` = 0, so the coordinates are decoded as a weight run), a weighted run of the wrong length, an out-of-range bone index, or a clipping `end` naming no slot (`findSlot` returns null and the null is assigned) |
|
|
666
672
|
| `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | policy | region name ≠ PNG basename |
|
|
667
673
|
| `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | ribbon rows with divergent weights |
|
|
668
674
|
| `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | stroke past the measured contact depth |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"lint": "bunx eslint .",
|
|
52
52
|
"fetch-examples": "bash scripts/fetch-examples.sh",
|
|
53
53
|
"bench:usage": "bun bench/count_features.ts",
|
|
54
|
+
"viewer": "vite --config viewer/vite.config.ts",
|
|
54
55
|
"prepublishOnly": "bun run typecheck && bun run lint && bun run selftest"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
@@ -59,7 +60,9 @@
|
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@types/bun": "^1.4.0",
|
|
61
62
|
"eslint": "^10.9.0",
|
|
63
|
+
"spine-html": "^0.4.1",
|
|
62
64
|
"typescript": "^5.9.3",
|
|
63
|
-
"typescript-eslint": "^8.67.0"
|
|
65
|
+
"typescript-eslint": "^8.67.0",
|
|
66
|
+
"vite": "^8.2.2"
|
|
64
67
|
}
|
|
65
68
|
}
|
package/src/chains.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bone chains — the unit an author actually repairs.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a figure needs more than one number per shot
|
|
5
|
+
*
|
|
6
|
+
* `check` reports a shot's MAE and its worst slot. On a ball that is the whole
|
|
7
|
+
* story. On a figure with a dozen joints it is a verdict with nowhere to put it:
|
|
8
|
+
* spineboy's second attempt read "structure ✅, motion ✗" across sixteen sets, and
|
|
9
|
+
* the fix that verdict implies is *"animate it again"*. What an author repairs is
|
|
10
|
+
* a **limb** — the gun arm, the front leg — so the report has to be able to say
|
|
11
|
+
* which limb the error is in, and how much of it (issue #123).
|
|
12
|
+
*
|
|
13
|
+
* The decomposition is **candidate-side**, and that is not an implementation
|
|
14
|
+
* detail. `docs/LADDER.md`'s honesty rule says the reference is pixels; a chain
|
|
15
|
+
* derived from the reference skeleton would be `check` reading the answer. So the
|
|
16
|
+
* chains here come from the candidate's own bone tree — its own idea of what its
|
|
17
|
+
* parts are — and the reference stays a picture that gets carved up by where the
|
|
18
|
+
* candidate drew.
|
|
19
|
+
*
|
|
20
|
+
* ## The rule
|
|
21
|
+
*
|
|
22
|
+
* **Cut the bone tree at every branch point.** A chain starts at a root bone or
|
|
23
|
+
* at a child of a bone with more than one child, and runs down while each bone it
|
|
24
|
+
* reaches has exactly one child. On a biped that is exactly the set of parts an
|
|
25
|
+
* author names: `front-thigh → front-shin → front-foot`, `rear-upper-arm →
|
|
26
|
+
* rear-bracer → gun → muzzle`, `neck → head`. On a serial figure with no branch
|
|
27
|
+
* at all — rung 8's pendulum, seven bones in a line — it is one chain, which is
|
|
28
|
+
* the honest answer: a pendulum *has* one chain, and the per-slot table below it
|
|
29
|
+
* still names which link drifted.
|
|
30
|
+
*
|
|
31
|
+
* **A single-bone chain that is itself a branch point folds into its parent
|
|
32
|
+
* chain.** Without this, spineboy's `torso` — one bone with three children — is a
|
|
33
|
+
* chain of its own carrying one slot, and the trunk above it (`root`, `hip`) is a
|
|
34
|
+
* chain carrying none: two rows that between them say what one row says. With it
|
|
35
|
+
* the trunk reads `root, hip, torso` and the figure decomposes into six chains
|
|
36
|
+
* that each own a limb.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ **Only when it is a branch point.** A single-bone chain that is a *leaf* is a
|
|
39
|
+
* real part — a one-bone arm, or rung 3's two independent bones under one root —
|
|
40
|
+
* and folding those would collapse the whole fixture into a single chain and
|
|
41
|
+
* attribute nothing. The rule keys on "does this bone branch", not on length.
|
|
42
|
+
*
|
|
43
|
+
* A chain is **named after the first bone in it that carries a slot**, root-ward
|
|
44
|
+
* first, falling back to its first bone when none does. Membership never depends
|
|
45
|
+
* on the name: it is a label, chosen so the roster reads `torso` rather than
|
|
46
|
+
* `root`. Bone names are unique in a skeleton, so chain names are too.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** One bone, as much of it as a chain cares about. */
|
|
50
|
+
export interface BoneNode {
|
|
51
|
+
name: string;
|
|
52
|
+
/** The parent's name, or `null` for a root bone. */
|
|
53
|
+
parent: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One slot, as much of it as a chain cares about. */
|
|
57
|
+
export interface SlotNode {
|
|
58
|
+
name: string;
|
|
59
|
+
/** The bone it hangs from. */
|
|
60
|
+
bone: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A run of bones cut out of the tree at its branch points, and what it draws. */
|
|
64
|
+
export interface BoneChain {
|
|
65
|
+
/** The first bone in it that carries a slot, else its first bone. */
|
|
66
|
+
name: string;
|
|
67
|
+
/** Its bones, root-ward first. */
|
|
68
|
+
bones: string[];
|
|
69
|
+
/** The slots those bones carry, in the skeleton's own slot order. */
|
|
70
|
+
slots: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Building {
|
|
74
|
+
bones: string[];
|
|
75
|
+
slots: string[];
|
|
76
|
+
/** Set when this chain was folded away into another. */
|
|
77
|
+
foldedInto: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cut a bone tree into chains — see the rule at the top of this file.
|
|
82
|
+
*
|
|
83
|
+
* Deterministic in the order the bones arrive: chains come back in the order
|
|
84
|
+
* their first bone appears, and a bone that folds is appended to its parent
|
|
85
|
+
* chain's list. Both matter, because the roster is printed and a report that
|
|
86
|
+
* reorders itself between runs is a report nobody can diff.
|
|
87
|
+
*/
|
|
88
|
+
export function chainsOf(bones: BoneNode[], slots: SlotNode[]): BoneChain[] {
|
|
89
|
+
const order = new Map<string, number>();
|
|
90
|
+
for (const bone of bones) if (!order.has(bone.name)) order.set(bone.name, order.size);
|
|
91
|
+
|
|
92
|
+
const children = new Map<string, string[]>();
|
|
93
|
+
for (const bone of bones) {
|
|
94
|
+
if (bone.parent === null || !order.has(bone.parent)) continue;
|
|
95
|
+
const list = children.get(bone.parent);
|
|
96
|
+
if (list) list.push(bone.name);
|
|
97
|
+
else children.set(bone.parent, [bone.name]);
|
|
98
|
+
}
|
|
99
|
+
const childrenOf = (name: string): string[] => children.get(name) ?? [];
|
|
100
|
+
|
|
101
|
+
// A chain starts at a root, or wherever the tree forked above it.
|
|
102
|
+
const starts = bones.filter((b) => b.parent === null || !order.has(b.parent) || childrenOf(b.parent).length > 1);
|
|
103
|
+
|
|
104
|
+
const built: Building[] = [];
|
|
105
|
+
const chainOfBone = new Map<string, number>();
|
|
106
|
+
for (const start of starts) {
|
|
107
|
+
const run = [start.name];
|
|
108
|
+
let at = start.name;
|
|
109
|
+
for (;;) {
|
|
110
|
+
const next = childrenOf(at);
|
|
111
|
+
if (next.length !== 1) break;
|
|
112
|
+
at = next[0];
|
|
113
|
+
run.push(at);
|
|
114
|
+
}
|
|
115
|
+
const index = built.length;
|
|
116
|
+
built.push({ bones: run, slots: [], foldedInto: null });
|
|
117
|
+
for (const bone of run) chainOfBone.set(bone, index);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Fold the hubs. Root-ward first, so a chain's parent is already final when it
|
|
121
|
+
// is asked for — and through `resolve`, so a hub whose own parent folded lands
|
|
122
|
+
// in the chain that absorbed it rather than in one that no longer exists.
|
|
123
|
+
const resolve = (index: number): number => {
|
|
124
|
+
let at = index;
|
|
125
|
+
while (built[at].foldedInto !== null) at = built[at].foldedInto as number;
|
|
126
|
+
return at;
|
|
127
|
+
};
|
|
128
|
+
const byDepth = built
|
|
129
|
+
.map((chain, index) => ({ index, at: order.get(chain.bones[0]) as number }))
|
|
130
|
+
.sort((a, b) => a.at - b.at);
|
|
131
|
+
for (const { index } of byDepth) {
|
|
132
|
+
const chain = built[index];
|
|
133
|
+
if (chain.bones.length !== 1) continue;
|
|
134
|
+
const only = chain.bones[0];
|
|
135
|
+
// A leaf that stands alone is a part in its own right; a hub is not.
|
|
136
|
+
if (childrenOf(only).length === 0) continue;
|
|
137
|
+
const parent = bones.find((b) => b.name === only)?.parent ?? null;
|
|
138
|
+
if (parent === null || !chainOfBone.has(parent)) continue;
|
|
139
|
+
const target = resolve(chainOfBone.get(parent) as number);
|
|
140
|
+
if (target === index) continue;
|
|
141
|
+
built[target].bones.push(only);
|
|
142
|
+
chain.foldedInto = target;
|
|
143
|
+
chainOfBone.set(only, target);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const slot of slots) {
|
|
147
|
+
const owner = chainOfBone.get(slot.bone);
|
|
148
|
+
if (owner === undefined) continue;
|
|
149
|
+
built[resolve(owner)].slots.push(slot.name);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return built
|
|
153
|
+
.filter((chain) => chain.foldedInto === null)
|
|
154
|
+
.map((chain) => ({
|
|
155
|
+
name: chain.bones.find((bone) => chain.slots.some((slot) => slotBone(slots, slot) === bone)) ?? chain.bones[0],
|
|
156
|
+
bones: chain.bones,
|
|
157
|
+
slots: chain.slots,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function slotBone(slots: SlotNode[], name: string): string | null {
|
|
162
|
+
return slots.find((slot) => slot.name === name)?.bone ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The chain each slot belongs to, keyed by slot name. */
|
|
166
|
+
export function chainBySlot(chains: BoneChain[]): Map<string, string> {
|
|
167
|
+
const out = new Map<string, string>();
|
|
168
|
+
for (const chain of chains) for (const slot of chain.slots) out.set(slot, chain.name);
|
|
169
|
+
return out;
|
|
170
|
+
}
|