spine-rigc 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 firejune (Joon Kyoung)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,76 @@
1
+ # Third-party notices
2
+
3
+ ## Spine Runtimes
4
+
5
+ This project depends on `@esotericsoftware/spine-core`, part of the
6
+ [Spine Runtimes](https://github.com/EsotericSoftware/spine-runtimes),
7
+ Copyright (c) 2013-2025 Esoteric Software LLC, licensed under the
8
+ [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license).
9
+
10
+ Key obligation that propagates to users of this project: integration of the Spine
11
+ Runtimes into software (including via this compiler, which links spine-core to
12
+ validate what it emits) is permitted **provided that each user of the resulting
13
+ product obtains their own Spine Editor license**, and any redistribution includes
14
+ the Spine Runtimes license and copyright notice.
15
+
16
+ ### What that means for rigc, as a chain of facts
17
+
18
+ rigc's own code is MIT (see `LICENSE`). The following is a restatement of Esoteric
19
+ Software's terms, not a licence term of this project:
20
+
21
+ 1. rigc's output **is Spine skeleton data**.
22
+ 2. Playing Spine skeleton data in a product requires **a Spine Runtime**.
23
+ 3. The Spine Runtimes License requires **each user of such a product to own a Spine
24
+ Editor license**.
25
+ 4. rigc **links `spine-core`** itself — validation is not optional and cannot be
26
+ switched off — so the same obligation applies to running rigc at all.
27
+
28
+ > **Using rigc, or shipping rigc's output in a product, requires a Spine Editor
29
+ > license.** rigc does not change that requirement in either direction: it neither
30
+ > creates one where none existed nor removes one that did. It is not a route around
31
+ > the editor licence.
32
+
33
+ ## Example assets
34
+
35
+ The official Spine example projects are the yardstick this compiler is measured
36
+ against. They are owned by Esoteric Software and are **not redistributed** in this
37
+ repository: `scripts/fetch-examples.sh` downloads them into a gitignored
38
+ `examples/` directory for local evaluation.
39
+
40
+ Each example directory upstream carries its own `license.txt`, so the terms are
41
+ per-directory rather than repository-wide. Verified on 2026-08-22 against
42
+ `spine-runtimes` branch `4.3`:
43
+
44
+ | Example | `license.txt` | Copyright line |
45
+ | --------------------- | ------------- | ----------------------------------------------- |
46
+ | `1-weight-and-mass` | present | (c) 2021-2025, Esoteric Software LLC |
47
+ | `2-the-12-principles` | present | (c) 2021-2025, Esoteric Software LLC |
48
+ | `3-timing-and-spacing`| present | (c) 2021-2025, Esoteric Software |
49
+ | `4-wave-principle` | present | (c) 2021-2025, Esoteric Software LLC |
50
+ | `5-squash-and-stretch`| present | (c) 2021-2025, Esoteric Software |
51
+ | `6-arcs` | present | (c) 2022-2025, Esoteric Software |
52
+ | `7-anticipation` | **absent** | — |
53
+ | `8-follow-through` | present | (c) 2024-2025, Esoteric Software |
54
+ | `spineboy` | present | (c) 2013, Esoteric Software LLC |
55
+
56
+ Every `license.txt` above states the same two terms verbatim, differing only in
57
+ the copyright line:
58
+
59
+ > The images in this project may be redistributed as long as they are accompanied
60
+ > by this license file. The images may not be used for commercial use of any
61
+ > kind.
62
+ >
63
+ > The project file is released into the public domain. It may be used as the basis
64
+ > for derivative work.
65
+
66
+ So, for this repository's purposes:
67
+
68
+ - **Images** — redistributable only with the accompanying `license.txt`, and
69
+ **non-commercial only**. That is why they are fetched rather than committed.
70
+ - **Project files** (`.spine`, and the exports derived from them) — **public
71
+ domain**, usable as the basis for derivative work. This is what makes the
72
+ examples usable as a structural yardstick.
73
+ - ⚠️ **`7-anticipation` has no `license.txt` upstream**, so the redistribution
74
+ grant its siblings carry does not exist for it — there is no licence file to
75
+ accompany its images with. `scripts/fetch-examples.sh` prints a warning naming
76
+ it. Treat its images as not redistributable.
package/README.md ADDED
@@ -0,0 +1,558 @@
1
+ <p align="center">
2
+ <img src="assets/banner.svg" alt="rigc - Rig compiler for Spine" width="100%" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/spine-rigc"><img src="https://img.shields.io/npm/v/spine-rigc.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
7
+ <a href="https://www.npmjs.com/package/spine-rigc"><img src="https://img.shields.io/npm/dm/spine-rigc.svg?style=flat-square&color=A855F7" alt="npm downloads" /></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-38BDF8.svg?style=flat-square" alt="license" /></a>
9
+ </p>
10
+
11
+ **Rig compiler for Spine.** Declarative rig specs in, Spine 4.3 skeleton data out,
12
+ verified by a `spine-core` round-trip. Built so AI agents can author rigs and check
13
+ their own work.
14
+
15
+ ## What rigc is, and what it is not
16
+
17
+ rigc emits **Spine's own skeleton data format**. That is the whole positioning, and
18
+ it cuts both ways:
19
+
20
+ - The output loads in any Spine runtime, and it **imports into the Spine editor**.
21
+ A compiled rig is a starting point on a timeline, not a finished shot — **an AI
22
+ drafts, a human refines in the editor**. rigc is complementary to the editor. It
23
+ is not a replacement for it, and it is not a way around one.
24
+ - rigc **links `@esotericsoftware/spine-core`** to validate what it emits — the
25
+ round-trip through the official parser is the only reason its output can be
26
+ trusted at all. So the [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
27
+ applies to rigc exactly as it applies to any other runtime integration.
28
+
29
+ ### Licensing, stated plainly
30
+
31
+ rigc's own code is MIT (see [LICENSE](LICENSE)). That says nothing about Spine, and
32
+ the following is a restatement of Esoteric Software's terms, not a term of ours:
33
+
34
+ 1. rigc's output **is Spine skeleton data**.
35
+ 2. Playing Spine skeleton data in a product requires **a Spine Runtime**.
36
+ 3. The Spine Runtimes License requires **each user of such a product to own a Spine
37
+ editor licence**.
38
+ 4. rigc **links `spine-core`** itself, so the same obligation covers running rigc.
39
+
40
+ > **Using rigc, or shipping rigc's output in a product, requires a Spine editor
41
+ > licence.** rigc does not change that requirement in either direction — it neither
42
+ > adds one nor removes one. If you were going to need an editor licence to ship a
43
+ > Spine animation, you still do; rigc is not a route around it.
44
+
45
+ See [NOTICE.md](NOTICE.md) for the full notice.
46
+
47
+ The problem rigc is aimed at is narrow. An agent asked to author a rig has no way
48
+ to tell whether it succeeded: Spine's JSON parser accepts a great deal of nonsense
49
+ without a murmur — a constraint in the 4.2 shape simply vanishes, a `size:` that
50
+ disagrees with the PNG collapses every UV, a four-number curve array yields NaN,
51
+ a mesh whose vertex count happens to equal its UV count silently loses its bone
52
+ weights. Every one of those loads clean, plays, and is wrong. rigc's answer is to
53
+ make the failure legible: compile from a spec, round-trip through the real parser,
54
+ run a list of named assertions, and **write nothing unless all of them are green.**
55
+
56
+ ## The yardstick
57
+
58
+ The measure of whether this works is **Spine's own official example projects** —
59
+ the `1-weight-and-mass` … `8-follow-through` series as a difficulty ladder (one
60
+ animation principle per rig, in roughly ascending order), and **spineboy as the
61
+ graduation exam**. The question is structural and per-frame: given the same source
62
+ art and a spec, does a compiled rig match the official export in bone hierarchy,
63
+ timeline shape, mesh topology and posed vertex positions?
64
+
65
+ `scripts/fetch-examples.sh` downloads those projects into a gitignored `examples/`
66
+ directory (they are not redistributed here — see [NOTICE.md](NOTICE.md) for the
67
+ per-example licence terms).
68
+
69
+ ### Comparing a rig against a reference — `rigc diff`
70
+
71
+ ```bash
72
+ bun cli.ts diff candidate.json reference.json [--json report.json]
73
+ ```
74
+
75
+ `diff` reads two skeletons and reports a ratio per **measure**, grouped into six
76
+ sections — bones, slots, attachments, constraints, animations, events — and it
77
+ does **not** combine them into a score. A single "87% match" cannot tell a rig
78
+ with the right skeleton and the wrong timing apart from a rig with the right
79
+ timing and the wrong skeleton, and those are opposite diagnoses.
80
+
81
+ Three properties the measures are built to have:
82
+
83
+ - **`diff X X` is 1.000 on every measure.** A comparison tool that cannot
84
+ recognise identity is reporting noise, and noise looks like a small honest gap.
85
+ The selftest asserts it.
86
+ - **A difference moves as few measures as possible.** Reordering two slots moves
87
+ `slots.order` and nothing else — not the slot-to-bone bindings, not the setup
88
+ attachments — so the report says *where* a rig is wrong, not just *how much*.
89
+ Each selftest case names the exact set of measures its edit may disturb.
90
+ - **Name-agnostic figures sit beside name-matched ones.** A candidate that builds
91
+ the right tree under its own bone names scores 0 on `bones.parent_by_name` and
92
+ 1.000 on `bones.depth_histogram` and `bones.degree_sequence`. Reporting only the
93
+ first calls a correct rig a total failure; reporting only the second calls any
94
+ 14-bone tree a match. That holds at the section level too: `bones` and `slots`
95
+ are the two sections whose measures are mostly name-keyed, so each reports **two
96
+ figures**, and the pair is the finding —
97
+
98
+ ```
99
+ bones 0.567 (name-matched) · 1.000 (name-agnostic)
100
+ ```
101
+
102
+ reads *"the tree is right and the vocabulary is different"*, which the single
103
+ mean on its own could not say. They are two comparisons with their own measure
104
+ sets rather than two halves of one; the name-matched figure is unchanged, so
105
+ older reports stay comparable. `sections[].nameAgnostic` in the JSON lists them.
106
+
107
+ An assertion or measure with nothing to compare reports its `total` as 0 and says
108
+ so, exactly as the validator's SKIP does — a vacuous 1.000 that looks earned is
109
+ the same false green in a different costume.
110
+
111
+ ### Checking a rig against the pictures — `rigc check`
112
+
113
+ ```bash
114
+ bun cli.ts check --candidate path/to/spine --frames bench/reference/3-timing-and-spacing
115
+ ```
116
+
117
+ ⭐ **Neither the gate nor `diff` can see a wrong animation.** The gate checks
118
+ validity: it parses the skeleton, steps every animation and refuses anything
119
+ degenerate, and it has no opinion about whether the animation is the one that was
120
+ asked for. `diff` checks structure: a reversed easing is the same timeline, the
121
+ same key count and the same curve kind. Three honest ladder runs have now produced
122
+ **zero** validator FAILs between them, and one of them shipped a build in which
123
+ every easing in the file was reversed — green, and sincerely reported as done.
124
+
125
+ `check` is the instrument for that. It renders the candidate with the same
126
+ rasteriser that drew the reference frames, onto the same pixel grid, and reports
127
+ per animation and per frame:
128
+
129
+ - **MAE over the union alpha** — the mean absolute RGB difference over the pixels
130
+ either side covers, 0..255. The whole-frame figure is printed beside it and never
131
+ instead of it: most of a frame is background on both sides, so that number is
132
+ small for every candidate and the gap between a good one and a bad one smaller
133
+ still.
134
+ - **The framing** — where the candidate's drawn pixels sit against the reference's,
135
+ as a scale, an offset and a residual. It is printed first because it is upstream
136
+ of everything else: get it wrong and the error arrives disguised as motion.
137
+ - **Per-frame change** — how many pixels each side moved since **its own** previous
138
+ frame, compared against each other. It is the only measure here that looks at the
139
+ relation between two frames rather than at one, and it is what catches a held pose
140
+ the candidate does not hold, or a one-frame event that never fired: both are cheap
141
+ in every individual frame and invisible to an aggregate.
142
+ - **Per-slot drift** — where each of the candidate's own slots landed against the
143
+ reference frame, in pixels. MAE says *how wrong*; a slot's drift says *which
144
+ part, which way, how far*. Where the reference merged two parts into one blob —
145
+ the trap [AUTHORING §8](docs/AUTHORING.md) opens with — the slot is
146
+ template-matched against its own rendered pixels instead, with a confidence; and
147
+ where nothing inside the distance that slot could plausibly have moved matches
148
+ it, the answer is **no match** rather than a number about some other part.
149
+
150
+ 🔒 **It never reads the reference skeleton.** It opens the candidate and PNG
151
+ frames, and nothing else: every reference-side read goes through one guard that
152
+ refuses a path which is not a `.png` or the frame set's `frames.json`, and the
153
+ selftest makes that guard fire. That is what lets `check` sit *inside* an
154
+ authoring loop where `bench` cannot — running it as often as you like does not
155
+ stop a run being an authoring run.
156
+
157
+ The candidate is framed **by its own drawn pixels**, not by the reference's world
158
+ box. A candidate is authored in its own coordinate system and under the ladder's
159
+ honesty rule could not be authored in any other, so both sides are measured the
160
+ same way — the content box of what each actually draws — and one similarity
161
+ transform, fitted by least squares over every edge of every frame, carries the
162
+ candidate's onto the reference's. Two skeletons depicting the same shot land on the
163
+ same pixels whatever coordinates they were authored in, an invisible transparent
164
+ margin cannot move the result, and no single quad corner in a single frame can set
165
+ the scale for a run.
166
+
167
+ There is no pass mark, for the same reason `diff` has none.
168
+
169
+ ### Benchmark ladder — the rungs, and where they stand
170
+
171
+ **[docs/LADDER.md](docs/LADDER.md) is the live ledger**: the rung order
172
+ (blockers → rung 3 first → 1 · 2 · 4 · 5 → 6 → 8 → 7 → spineboy), what each
173
+ rung gates on, how a rung is scored, the honesty rule that keeps the reference
174
+ export away from the authoring agent, and a status table. Run one with:
175
+
176
+ ```bash
177
+ bun cli.ts bench 3 --candidate path/to/candidate/spine
178
+ ```
179
+
180
+ `bench` validates the candidate under `--profile spine`, diffs it against that
181
+ rung's reference export, and prints both. It exits non-zero only when validation
182
+ fails: the diff has no threshold, because there is no rung score. Add
183
+ `--frames <dir>` and it folds in the `check` table below, so a ladder row carries
184
+ fidelity as well as structure.
185
+
186
+ #### What the rungs need
187
+
188
+ [docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md) surveys the full Spine 4.3 export surface against what
189
+ rigc emits and against what the nine examples measurably use (`bun run bench:usage` regenerates the
190
+ counts). Three blockers sat *before* rung 1: **B1**, the bone tree was code in `archetype.ts` rather
191
+ than data, so no example could be expressed at all; **B2**, `A16`'s regex rejected the `"4.3.75-beta"`
192
+ that every example declares; and **B3**, every example ships a **packed** atlas (13–50 regions per
193
+ page) against rigc's one-part-per-page model, which `A06` enforced unconditionally. **B1 and B2 are
194
+ closed**; B3's validator half is (the packed-atlas clauses live behind `--profile`, above) and its
195
+ emitter half — no packer, no atlas importer — is not. Ordered gap list in Part 4 of that document;
196
+ live status, and B1's proof, in [docs/LADDER.md](docs/LADDER.md).
197
+
198
+ ## What exists today
199
+
200
+ **Inputs — three files, one domain each.** Only the middle one is required.
201
+
202
+ - A **cut manifest** (`FaceManifest` in [`src/types.ts`](src/types.ts)) owns
203
+ **measured art**. Crop rectangle, the base plate, one entry per part with its
204
+ offset and size, mask polygons, the state machine, bone anchors, and — for a
205
+ joint cut — the entry point, the insertion axis (`deg` in screen degrees plus a
206
+ `unit` vector, cross-checked against each other), stroke amplitudes and any
207
+ measured ceilings. The compiler **never re-measures art**: every number here is
208
+ produced by a measuring tool or by the pipeline that cut the plates, and rigc
209
+ only reads it. **Optional** — a skeleton with no measured art behind it (any of
210
+ the benchmark examples) has none.
211
+ - A **rig spec** (`RigSpec` in [`src/rig.ts`](src/rig.ts), `spec: "rigc-rig/1"`)
212
+ owns **skeleton structure**: bones, slots, skins and their attachments, the 4.3
213
+ typed `constraints` array, and the invariants the emitted JSON cannot state
214
+ about itself. Its vocabulary is deliberately **Spine's own** — same concepts,
215
+ same field names, same defaults, cited to `SkeletonJson.ts` line numbers — so an
216
+ agent that has read Spine's documentation can author one without learning a
217
+ second vocabulary. rigc's additions sit on top and are namespaced: `from` on a
218
+ bone takes its position from the manifest instead of a literal that would drift
219
+ away from the art; `image` on an attachment names a PNG and rigc measures it;
220
+ `generator` on a mesh invokes a builder from `src/mesh.ts`; `invariants` carries
221
+ the axis bone, the forbidden parentage, the mesh budget.
222
+ - A **motion spec** (`MotionSpec`, `spec: "rigc-motion/1"`) owns **time**: the rig
223
+ it was authored against, named easing handles, setup overrides, a physics tuning
224
+ table, and the animations — each with a declared duration, a loop flag, its
225
+ tracks, and optionally a `drawOrder` timeline (the one timeline that names no
226
+ target, so it sits on the animation rather than in `tracks`).
227
+
228
+ **Outputs — two files per cut**, written to the cut's `out` directory:
229
+
230
+ - `skeleton.json` — Spine **4.3** skeleton data. Bones, slots in draw order, one
231
+ skin, animations, and constraints in the 4.3 single `constraints` array.
232
+ - `skeleton.atlas` — a **one-part-per-page** atlas: every region covers its whole
233
+ page, `pma: false`. That convention is what makes the region/attachment/filename
234
+ join key checkable exactly rather than by convention.
235
+
236
+ **Where the three meet.** A manifest part joins a rig slot by its `rig_slot` field
237
+ (falling back to `slot`), and that slot's position in the rig's `slots` array **is**
238
+ the draw order — a manifest whose `draw_order` numbers disagree is a compile error
239
+ rather than a silent overrule. A slot filled by both a manifest part and a rig skin
240
+ is likewise refused, as is a setup pose declared in both the rig and the motion
241
+ spec: one fact, one author. A missing anchor is a compile error by design, so that
242
+ copying another cut's numbers is not the path of least resistance.
243
+
244
+ Two things are code and stay code, because neither is a table of numbers: the
245
+ **mesh generators** in [`src/mesh.ts`](src/mesh.ts), which encode a deformation
246
+ model (what is pinned, what may move, how authority falls off), and the
247
+ **coordinate contract** in [`src/transform.ts`](src/transform.ts).
248
+
249
+ ### The validator
250
+
251
+ [`src/validate.ts`](src/validate.ts) parses the emitted artifacts with `spine-core`
252
+ and then runs 32 named assertions over the loaded skeleton. Each one exists because
253
+ the failure it catches is **silent**: the file loads, animates, and lies.
254
+
255
+ Assertions whose data is absent are reported as **SKIP**, never folded into the pass
256
+ count — an assertion with nothing to check has not checked anything.
257
+
258
+ #### Profiles — "wrong" versus "not how we do it here"
259
+
260
+ Not all 32 rules are about Spine. Some are about **spine-html**, the renderer this
261
+ compiler was built to feed, and about one project's frame budget; they fire on real,
262
+ correct, editor-produced Spine data, because the official example projects carry
263
+ clipping attachments, unweighted meshes, 116-triangle meshes and packed atlases —
264
+ all valid, none of them things spine-html will draw. Others are about **rigc's own
265
+ rigs** and mean nothing at all on a skeleton rigc did not compile — they read the
266
+ rig spec's `invariants` block, and they **SKIP** when it is absent rather than
267
+ counting as passes.
268
+
269
+ So `validate` and `build` take a `--profile`:
270
+
271
+ | Profile | Runs | For |
272
+ | --- | --- | --- |
273
+ | `spine-html` | all 32 | **the default.** Is this a rig this project can ship? |
274
+ | `spine` | the 18 validity rules | Is this valid Spine 4.3 that any runtime plays correctly? |
275
+
276
+ The **Profile** column below says which is which — `both` = validity, `renderer` and
277
+ `archetype` = `spine-html` only, and **`both ◑`** = a mixed assertion whose validity
278
+ half always runs while its policy clauses are gated (A06's `pma`/rotation/full-page
279
+ clauses, A08's "the two names must be identical", A20's "a mesh must be weighted at
280
+ all"). A report always names the profile it ran and lists what that profile left
281
+ out, on `PROF` lines: a `--profile spine` green means *valid Spine*, never *passes
282
+ the renderer policy*.
283
+
284
+ | Assertion | Profile | Holds that |
285
+ | --- | --- | --- |
286
+ | `A00_ROUNDTRIP_PARSE` | both | `spine-core` parses the skeleton and the atlas without throwing |
287
+ | `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | both | no 4.1/4.2-shaped `physics`/`ik`/… array — 4.3 folds them into one typed `constraints` array, and the old shape loads clean while the constraint vanishes |
288
+ | `A02_NO_BONE_TRANSFORM_KEY` | both | no bone uses 4.2's `transform`; 4.3 renamed it `inherit`, and the old key silently falls back to Normal inheritance |
289
+ | `A03_REGION_WIDTH_HEIGHT_FINITE` | both | every region attachment loaded a finite, positive width and height (a missing field loads as NaN, with no error) |
290
+ | `A04_MESH_TRIANGLES_AND_ENCODING` | both | triangles are a multiple of 3, indices are in range, and the vertex array's encoding agrees with the UV count |
291
+ | `A05_CURVE_ARRAY_LENGTH` | both | curve arrays carry 4 numbers per value channel and hold no non-finite value; timelines that cannot take a curve do not carry one. Covers all eleven 4.3 timeline groups — bone, slot, ik, transform, path, physics, slider, deform, drawOrder, drawOrderFolder, events |
292
+ | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | each page's declared `size:` matches the PNG on disk, and its region covers the whole page |
293
+ | `A07_ATLAS_TEXT_SHAPE` | both | the atlas text obeys the parser's whitespace rules — no stray indentation on region names, no blank line splitting a page block |
294
+ | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both ◑ | every attachment name resolves to a region of exactly that name |
295
+ | `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the compiled duration equals the duration the spec declared (skeleton JSON has no duration field — the last key *is* the duration). Two tolerances: a frame of slack for a duration declared long, but a key landing *past* the declared end is held to the grid the times are stored on, because nothing playing the animation ever reaches it. SKIPs without a motion spec |
296
+ | `A10_NO_NAN_AFTER_STEPPING` | both | stepping every animation frame by frame produces no NaN anywhere in the pose |
297
+ | `A11_NO_CLIPPING_ATTACHMENTS` | renderer | no clipping attachments (the renderer skips them silently) |
298
+ | `A12_NO_DARK_COLOR` | renderer | no dark / two-colour tint on slots or timelines — parsed, then ignored |
299
+ | `A13_MESH_BUDGET` | renderer | no more mesh slots than the rig's `invariants.meshSlots`, and no mesh past its `invariants.meshTriangles`. SKIPs when the rig declares neither |
300
+ | `A14_NO_FULL_FRAME_MESH` | renderer | no mesh spans the whole stage (a full-frame mesh is a full-frame canvas that can never dirty-skip) |
301
+ | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | `idle` keys no bone that drives a mesh, directly or as its control bone |
302
+ | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` version label is on the 4.3 line (the parser never checks it) |
303
+ | `A17_ATLAS_PAGE_FILES_EXIST` | both | every page the atlas declares is a file on disk |
304
+ | `A18_DETERMINISTIC_EMIT` | both | a second, independent compile of the same inputs is byte-identical. SKIPs when re-gating artifacts already on disk |
305
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | every overlay page carries an alpha channel; only the base plate — identified structurally as the region covering the stage — may be opaque |
306
+ | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | every weighted vertex has at least one bone, no negative weight, bone indices in range, and each vertex's weights sum to 1. `spine-html` also requires that a mesh be weighted at all and that no binding sit at weight 0 |
307
+ | `A21_MESH_RIM_PINNED` | archetype | a ring mesh's rim vertices are pinned to the anchor bone and its hull is a real ring; a ribbon's entry row stays put. **SKIPs on authored geometry** — rigc did not place its rim |
308
+ | `A22_MESH_UVS_IN_UNIT_RANGE` | both | every UV lies inside its region |
309
+ | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | each physics constraint actually drives a component, is not muted by `mix: 0`, has non-zero mass, and has `damping < 1` so it settles |
310
+ | `A24_AXIS_SPACE_STROKE` | archetype | the stroke is authored in **axis space** — no screen-space Y component anywhere in the axis subtree, and no keys at all on the axis bone (its rotation is the one per-cut setup value) |
311
+ | `A25_DETACHED_BONE_PARENTAGE` | archetype | bones that must stay detached are not parented under a moving part |
312
+ | `A26_SLOT_DRAW_ORDER` | archetype | the slots array — which *is* the draw order — matches the rig spec's slot table |
313
+ | `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | renderer | each region's name equals its page's basename, closing the second link of the attachment → region → file chain |
314
+ | `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | both vertices of a ribbon row carry the same bones at the same weights, so the strip can lengthen and curve but never widen. **SKIPs on authored geometry** — rigc did not pair its rows |
315
+ | `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | the stroke plus any inward keys stays within the cut's measured contact depth (skipped when the manifest declares none) |
316
+ | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | the stroke stays within the cut's measured containment ceiling, and nothing in the axis subtree scales — a scale key changes the contour the ceiling was measured on (skipped when the manifest declares none) |
317
+ | `A31_DRAW_ORDER_OFFSETS_RESOLVE` | both | every draw-order key resolves to a real permutation: known slots, one entry per slot, each landing inside the slots array, offsets in ascending slot order. The **only assertion that runs before `A00`** — descending offsets make `readDrawOrder`'s forward-only cursor spin rather than return, so the round trip is refused by name instead of attempted |
318
+
319
+ ## Install
320
+
321
+ rigc runs on [Bun](https://bun.sh). The package ships its TypeScript sources and
322
+ Bun runs them, so there is no build step and no `dist/` that can drift from the
323
+ repository it was cut from.
324
+
325
+ **The npm package is `spine-rigc`; the command it installs is `rigc`.** npm
326
+ refuses the name `rigc` as too similar to packages that already exist, so the
327
+ project, this repository and the executable keep their name and only the
328
+ registry entry is spelled out.
329
+
330
+ ```bash
331
+ bunx spine-rigc --help # run it without installing
332
+ bun add -g spine-rigc # or install the command
333
+ bun add -d spine-rigc # or pin it in a project
334
+ ```
335
+
336
+ `npx spine-rigc` works too, as long as Bun is on `PATH` — the executable is a
337
+ Bun script, and npm only writes the shim that calls it.
338
+
339
+ Installed, the command is `rigc`. The examples below spell it `bun cli.ts`
340
+ because they are written from a clone of this repository (`bun install`, then run
341
+ the CLI in place); the two are interchangeable — `rigc build …` is
342
+ `bun cli.ts build …`.
343
+
344
+ Two commands are repository workflows rather than package ones: `bench` and
345
+ `check` measure against Spine's official example projects and the reference
346
+ frames rendered from them, which are fetched rather than redistributed (see
347
+ [NOTICE.md](NOTICE.md)). They need a clone and `bun run fetch-examples`, and say
348
+ so by name when the corpus is absent.
349
+
350
+ ## Usage
351
+
352
+ 📘 **Writing a spec? Read [docs/AUTHORING.md](docs/AUTHORING.md) first.** It is the
353
+ guide an agent rigs from: both input files with a complete minimal example each,
354
+ every field with its Spine meaning, the rules that decide what is emitted, the
355
+ build → read the report → fix → repeat loop, the map from every named failure to
356
+ the file that has to change, and the list of format features rigc refuses by name
357
+ so you do not spend a loop discovering them. It travels **inside the npm package**
358
+ too, so an agent working from an install has it on disk at
359
+ `node_modules/spine-rigc/docs/AUTHORING.md`.
360
+
361
+ Compile by spelling out the paths. `--manifest` is optional; `--images <dir>` says
362
+ where a rig spec's `image` references live (it overrides the rig's own `images`
363
+ field):
364
+
365
+ ```bash
366
+ bun cli.ts build \
367
+ --rig path/to/my_rig.rig.json \
368
+ --motion path/to/my.motion.json \
369
+ --out path/to/spine \
370
+ [--manifest path/to/manifest.json] [--images path/to/images]
371
+ ```
372
+
373
+ …or register cuts in a `cuts.json` and build them by name. Every path in the table
374
+ resolves **relative to the `cuts.json` file itself**, so the table lives with the
375
+ project that owns the art:
376
+
377
+ ```json
378
+ {
379
+ "my_cut": {
380
+ "rig": "rigs/my_rig.rig.json",
381
+ "manifest": "output/my_cut/manifest.json",
382
+ "motion": "specs/my_cut.motion.json",
383
+ "out": "output/my_cut/spine"
384
+ }
385
+ }
386
+ ```
387
+
388
+ ```bash
389
+ bun cli.ts build --cut my_cut --cuts path/to/cuts.json
390
+ ```
391
+
392
+ `build` compiles, then validates, and **writes only if the gate is green**. Other
393
+ commands:
394
+
395
+ ```bash
396
+ bun cli.ts explain --cut my_cut --cuts path/to/cuts.json # the compiled rig as a table
397
+ bun cli.ts validate path/to/spine # re-gate artifacts already on disk
398
+ bun cli.ts validate --profile spine path/to/any/skeleton # spec rules only (see Profiles)
399
+ bun cli.ts diff candidate.json reference.json # structural comparison
400
+ bun cli.ts check --candidate path/to/spine \
401
+ --frames bench/reference/3-timing-and-spacing # against pictures
402
+ bun cli.ts bench 3 --candidate path/to/spine # one rung of the ladder
403
+ ```
404
+
405
+ `validate` on a bare directory checks what it can see. Adding `--cut`/`--cuts` lets
406
+ it re-derive the declared durations and the structural expectations too, and the
407
+ report says which it had. `build` and `validate` both take `--profile spine` to drop
408
+ the renderer and archetype policy; the default stays `spine-html`.
409
+
410
+ ## Checks
411
+
412
+ ```bash
413
+ bun run typecheck # bunx tsc --noEmit over cli.ts, selftest.ts, src/, bench/, tools/, fixtures/
414
+ bun run lint # one rule: @typescript-eslint/no-explicit-any, as an error
415
+ bun run selftest # the validator's own negative controls (next section)
416
+ ```
417
+
418
+ All three run on every push and pull request —
419
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Bun runs the sources
420
+ directly, so the first two are not on the path of anything; they exist because a
421
+ convention nothing checks is a convention. `tsconfig.json` is
422
+ `strict: false` with `strictNullChecks: true` and says in place why the rest is
423
+ not on yet; `eslint.config.js` says why it carries exactly one rule.
424
+
425
+ ## Selftest
426
+
427
+ ```bash
428
+ bun run selftest # everything below; no arguments needed
429
+ bun run selftest --cuts path/to/cuts.json # …plus an extra suite over those cuts
430
+ ```
431
+
432
+ A gate nobody has seen fail is not a gate. The selftest compiles a rig, breaks the
433
+ result one way at a time — 45 deliberate breaks, each modelled on a mistake that was
434
+ actually made or actually measured — and asserts that the **named** assertion fires
435
+ for each. Two further edits are *tolerance* controls the gate must let through,
436
+ because a widened assertion can fail by firing too often as easily as by firing too
437
+ rarely.
438
+
439
+ **The rigs it breaks are generated.** [`fixtures/public.ts`](fixtures/public.ts)
440
+ writes three synthetic cuts into a temp directory on every run, and between them
441
+ they carry every structure the assertions have an opinion about — region
442
+ attachments, attachment swaps, rgba fades, a ring mesh on a control bone, a ribbon
443
+ on a bone chain, an axis bone whose subtree travels along it, a detached emitter,
444
+ physics constraints, and two measured ceilings. Every plate is a checkerboard with
445
+ `PLACEHOLDER` burned into it: they exist to be structurally real, and no claim
446
+ about appearance is made from any of them.
447
+
448
+ A fifth suite breaks an **input** instead of an artifact: nine malformed rig specs
449
+ that the compiler must refuse by name — a forward parent reference, a duplicate bone
450
+ name, a slot naming a missing bone, an ik target that does not exist, an attachment
451
+ image that is not on disk, an authored mesh binding a bone the rig does not have,
452
+ one that uses raw bone indices without asking for them, a wrong `spec` field, and a
453
+ constraint type the emitter cannot write. Each of those produces a file Spine's own
454
+ parser would accept while quietly meaning something else.
455
+
456
+ A **motion** spec can be wrong the same way, and the shape that costs the most is
457
+ the quietest: a key time that lands past the animation's declared duration is never
458
+ sampled, so the motion it was meant to carry simply does not happen. Five controls
459
+ hold that line — a key sitting exactly on a duration of 68/12 s is legal and must
460
+ compile, a key that 4 dp rounding pushed 0.000034 s past one is refused by name, the
461
+ same overshoot in an artifact the compiler never saw is caught by `A09`, an animation
462
+ whose last key is a frame short of its declared end is still accepted because that
463
+ direction is a different question, and a 32-second animation keyed exactly on its own
464
+ duration is *not* failed for the float32 grid its times come back on.
465
+
466
+ There is a positive control per suite as well: the pristine artifacts must come back
467
+ with zero failures, because a validator that failed everything would otherwise look
468
+ like a validator that worked.
469
+
470
+ `rigc check` gets the same treatment, and its pair is deliberately the same rig
471
+ twice: the rung 3 transcription against rung 3's frames, and then that transcription
472
+ with every key time reversed. Reversing leaves the structure untouched — same
473
+ timelines, same key count, same duration, and the gate stays green, which the
474
+ control asserts — and changes only what the shot looks like. Faithful reads 0.67 px
475
+ of slot drift; reversed reads 66.8 px. A third control makes the frames-only read
476
+ guard refuse a reference skeleton, because an honesty invariant nobody has seen
477
+ refuse anything is not an invariant.
478
+
479
+ The **mesh** path gets the same pair, on the rung 6 transcription against rung 6's
480
+ frames. Faithful reads a median 0.08 px of drift on the mesh-bearing slots; the
481
+ break is the one an authored mesh is actually exposed to — its weights bind bones
482
+ by *index*, so inserting a bone anywhere ahead of them rebinds every vertex in
483
+ silence — and it reads 31 px with the gate still green. Four further controls run
484
+ on a generated fixture and need no corpus: a ring mesh is posed at all, its pixels
485
+ reach the coverage mask `check` reads, an all-zero deform is the identity while a
486
+ real one moves the centroid, and two triangles sharing an edge draw it once.
487
+
488
+ Point the run at a `cuts.json` and an **extra suite** compiles every cut in it,
489
+ gates the result, and compiles it a second time for `A18`. That one is a positive
490
+ control on purpose: what real art adds is geometry a fixture cannot fake — measured
491
+ offsets, a measured axis, a measured ceiling, a mesh built over a contour nobody
492
+ drew by hand — so the question it asks is whether the whole gate still comes back
493
+ green on it. Without a cuts file it says it was skipped and the run passes on the
494
+ public suite alone; a cuts path that is *named and missing* exits 2.
495
+
496
+ Two suites measure against the Spine example corpus, which is downloaded rather
497
+ than redistributed. When `examples/` is absent they say so loudly and the summary
498
+ repeats it — an absent corpus is a hole in the run, not a pass — and a run in which
499
+ nothing substantive executed exits 2 rather than printing green.
500
+
501
+ ## Layout
502
+
503
+ ```
504
+ tsconfig.json type-check config (noEmit); eslint.config.js — the no-any gate
505
+ cli.ts build / validate / explain / diff / check / bench
506
+ selftest.ts the validator's own negative controls, and diff's and check's
507
+ fixtures/ public.ts — the three synthetic cuts the selftest breaks
508
+ src/
509
+ compile.ts rig + motion spec (+ manifest) -> skeleton JSON + atlas text (pure data assembly)
510
+ rig.ts the rig spec — `spec: "rigc-rig/1"`, the skeleton as data
511
+ validate.ts spine-core round trip + the 32 assertions
512
+ diff.ts structural comparison of two skeletons, one ratio per measure
513
+ render.ts the rasteriser (regions + meshes), shared by the reference renderer and check
514
+ check.ts a candidate against rendered frames — pixels and per-slot drift,
515
+ and it never opens the reference skeleton
516
+ ladder.ts which example is which rung, and which file in it is the reference
517
+ timelines.ts the 4.3 timeline catalogue and its walker (shared, pure JSON)
518
+ mesh.ts ring and ribbon mesh builders, weighted-vertex encoding
519
+ transform.ts crop pixels (y down) <-> Spine world (y up), world transforms
520
+ png.ts PNG header reader (size and colour type, no decode)
521
+ errors.ts CompileError, and NotImplementedError for what the format holds
522
+ and the emitter does not write
523
+ types.ts manifest, motion spec, and emitted-JSON shapes
524
+ tools/ measurement and plate helpers (see below)
525
+ scripts/ fetch-examples.sh
526
+ bench/ count_features.ts — what the example corpus actually uses
527
+ render_reference.ts — a rung's official export as PNG frames
528
+ briefs/ — what an authoring agent is told about a rung
529
+ reference/ — those frames, with the licence they travel under
530
+ runs/ — one directory per attempt, and the run protocol
531
+ transcriptions/ — rung specs transcribed from a reference export,
532
+ which measure expressiveness and NOT authoring (see LADDER.md)
533
+ docs/ AUTHORING.md (how to author a rig), LADDER.md (live rung status),
534
+ SPEC_COVERAGE.md (format survey),
535
+ feature_matrix.{csv,json}
536
+ .github/ workflows/ — ci.yml (the gates) and release.yml (release-please)
537
+ CONTRIBUTING.md how to propose a change; RELEASING.md — how a version is cut
538
+ ```
539
+
540
+ `tools/` are standalone utilities, each taking its paths as arguments:
541
+
542
+ | Tool | Does |
543
+ | --- | --- |
544
+ | `measure_contact_depth.ts` | measures a cut's contact depth from its plates, with the two-sided proof it has to satisfy. Both slot names are required: which plate is the mass and which is the occluder is a fact about one cut, and a default would measure the wrong pair and still print a number |
545
+ | `contact.ts` | plate-vs-plate overlap measurement — the largest advance that keeps two footprints disjoint |
546
+ | `plate.ts` / `png_probe.mjs` | minimal PNG read/write and decode |
547
+ | `font5x7.ts` | bitmap labels for diagnostic images and generated plates |
548
+
549
+ ## Contributing
550
+
551
+ Issues are the ledger; see [CONTRIBUTING.md](CONTRIBUTING.md) for what a change
552
+ has to clear before it lands. Releases are cut by release-please —
553
+ [RELEASING.md](RELEASING.md).
554
+
555
+ ## Licence
556
+
557
+ MIT — see [LICENSE](LICENSE). Third-party terms, including the Spine editor licence
558
+ requirement that this project inherits, are in [NOTICE.md](NOTICE.md).