spine-rigc 0.8.0 → 0.9.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 CHANGED
@@ -12,6 +12,32 @@
12
12
  verified by a `spine-core` round-trip. Built so AI agents can author rigs and check
13
13
  their own work.
14
14
 
15
+ ## What you get
16
+
17
+ <p align="center">
18
+ <img src="https://raw.githubusercontent.com/firejune/rigc/main/assets/rigc-demo.gif" alt="Loose part PNGs assembling themselves into a character that breathes, blinks and waves" width="600" />
19
+ </p>
20
+
21
+ <p align="center"><em>Fourteen hand-drawn part PNGs, one rig spec, one motion spec — the assembly,
22
+ the breathing and the wave are all rigc-compiled Spine animations, rendered with
23
+ <code>rigc render</code>.</em></p>
24
+
25
+ Loose part PNGs and two small JSON files in; **Spine 4.3 skeleton data out** — a
26
+ `skeleton.json` and a `skeleton.atlas` that load in any Spine runtime and **import
27
+ into the Spine editor**. Nothing is written unless a round-trip through Spine's own
28
+ parser and a list of named assertions all come back green.
29
+
30
+ | You have | You run | You get |
31
+ | --- | --- | --- |
32
+ | part PNGs, a rig spec and a motion spec | `rigc build` | `skeleton.json` + `skeleton.atlas` — or a failure named by rule, and **nothing on disk** |
33
+ | a compiled rig | `rigc render` | every animation as PNG frames, plus one labelled contact sheet of the whole shot |
34
+ | a compiled rig | `rigc preview` | one self-contained `.html` that plays it in Spine's own web player |
35
+ | two to four compiled rigs | `rigc vote` | one ballot page a human picks from, and the answer checked into a ledger |
36
+ | a picture of a key pose | `rigc pose` | where each loose part PNG sits in it, in spec coordinates — the movement between two poses is then yours to key ([docs/MOTION.md](docs/MOTION.md)) |
37
+
38
+ Everything in that table needs Bun and this package: no clone, no reference art, no
39
+ art pipeline, no server.
40
+
15
41
  ## What rigc is, and what it is not
16
42
 
17
43
  rigc emits **Spine's own skeleton data format**. That is the whole positioning, and
@@ -223,8 +249,8 @@ rigc: wrote …/buoy/spine/skeleton.atlas
223
249
  ```
224
250
 
225
251
  `profile=spine` is the rulebook that judged it: *is this valid Spine 4.3 that any
226
- runtime plays correctly?* That is the default, and the [Profiles](#profiles--wrong-versus-not-how-we-do-it-here)
227
- section below is where the other one lives. `validate` then re-reads those
252
+ runtime plays correctly?* That is the default, and the [Profiles](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md#profiles--wrong-versus-not-how-we-do-it-here)
253
+ section of the benchmark dossier is where the other one lives. `validate` then re-reads those
228
254
  artifacts from disk and ends `rigc: green`. That is a rig. `spine/skeleton.json`
229
255
  is Spine 4.3 skeleton data — it loads in a Spine runtime and it imports into the
230
256
  Spine editor.
@@ -291,7 +317,7 @@ The voter picks a winner or says "tie / no preference"; the page hands them a
291
317
  small JSON file to save; `--record` checks that file against the ballot's own
292
318
  hashes and appends one line to an append-only ledger, refusing by name anything
293
319
  that does not belong to it. See
294
- [Letting someone choose](#letting-someone-choose--rigc-vote).
320
+ [Letting someone choose](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md#letting-someone-choose--rigc-vote).
295
321
 
296
322
  **Where to go next.**
297
323
 
@@ -315,159 +341,105 @@ that does not belong to it. See
315
341
  [docs/PROMPTING.md](docs/PROMPTING.md) is the operator's page — the six prompt
316
342
  clauses a measured pilot run paid for, and what you can leave unsaid.
317
343
 
318
- ## The yardstick
319
-
320
- The measure of whether this works is **Spine's own official example projects**
321
- the `1-weight-and-mass` `8-follow-through` series as a difficulty ladder (one
322
- animation principle per rig, in roughly ascending order), and **spineboy as the
323
- graduation exam**. The question is structural and per-frame: given the same source
324
- art and a spec, does a compiled rig match the official export in bone hierarchy,
325
- timeline shape, mesh topology and posed vertex positions?
326
-
327
- `scripts/fetch-examples.sh` downloads those projects into a gitignored `examples/`
328
- directory (they are not redistributed here see [NOTICE.md](NOTICE.md) for the
329
- per-example licence terms).
330
-
331
- ### Comparing a rig against a reference `rigc diff`
344
+ ## See what you built, and let someone choose
345
+
346
+ Steps 6 and 7 above are the three commands that need nothing but a compiled rig no
347
+ reference frames, no second package, no server. **`render`** writes every frame as a
348
+ PNG plus one contact-sheet grid of the whole shot; **`preview`** writes one
349
+ self-contained `.html` that plays it in Spine's own web player; **`vote`** puts two to
350
+ four candidates in one page and takes a human's answer back. Reach for them the moment
351
+ a rig compiles green, because green says nothing at all about the picture.
352
+
353
+ Three properties of `vote` are worth stating, because they are what make its ledger
354
+ usable by the next agent rather than by a reader: **a tie is a recorded outcome, not a
355
+ missing one** — `both-unacceptable` is the tie that means *propose again*, and it is
356
+ unreachable if ties are not recordable; **the winner is a digest, not a label**, since
357
+ `B` means nothing outside one ballot while a digest identifies the same pixels
358
+ anywhere; and **every line carries a reason code** from a closed enumeration that is
359
+ enforced, so *"tie, because this one is better"* is refused.
360
+
361
+ 🎞️ **Authoring the movement those pages show you** — key poses, in-betweening, and how
362
+ to spread candidates so a ballot informs — is [docs/MOTION.md](docs/MOTION.md).
363
+
364
+ 📐 **`rigc pose --images parts/ --frame poseA.png` runs the other way.** Every command
365
+ above takes something you authored and tells you about it; this one takes a **picture
366
+ the user already has** — one key pose — and reports where each loose part PNG sits in
367
+ it, so those coordinates go into the rig and the motion **by construction** and the
368
+ effort goes into the part no instrument can measure: the movement between two poses.
369
+ A part that matches nowhere is refused by name, two near-equal placements are reported
370
+ as both, and nothing it prints is a score. Fields, the coordinate contract and the
371
+ limits: [AUTHORING.md §11](docs/AUTHORING.md).
372
+
373
+ ## Commands
374
+
375
+ Every command takes its paths explicitly. `rigc <command> --help` prints its flags, and
376
+ [AUTHORING.md §0](docs/AUTHORING.md) is the same list with what each flag means, which
377
+ commands take it and what its default is.
378
+
379
+ | Command | Does |
380
+ | --- | --- |
381
+ | `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained |
382
+ | `validate <dir>` | re-gates artifacts already on disk |
383
+ | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
384
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
385
+ | `preview --candidate <dir>` | one self-contained `.html` that plays it |
386
+ | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
387
+ | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
388
+ | `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
389
+ | `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
390
+ | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
391
+
392
+ `diff`, `check` and `bench` measure against something you were given; the first two
393
+ work on any frames you have, and `bench` is a repository workflow that needs a clone
394
+ and `bun run fetch-examples`. The reasoning behind all three is in
395
+ [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
396
+
397
+ `build` and `validate` both default to `--profile spine` — the 25 validity rules, which
398
+ ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
399
+ adds all 39: the other 14 are one renderer's policy and one canvas budget's, and they
400
+ fire on perfectly correct editor-produced Spine data, so reach for that profile when
401
+ you are shipping into *that* project rather than to be thorough. A report always names
402
+ the profile it ran and lists what that profile left out.
403
+
404
+ Several cuts can also be registered in a `cuts.json` and built by name
405
+ (`build --cut my_cut --cuts path/to/cuts.json`); every path in that table resolves
406
+ relative to the `cuts.json` file itself, so the table lives with the project that owns
407
+ the art. Its shape is under
408
+ [Usage](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md#usage).
409
+
410
+ ## Documentation
411
+
412
+ | Document | For |
413
+ | --- | --- |
414
+ | 📘 **[docs/AUTHORING.md](docs/AUTHORING.md)** | **the format guide, and the one to read before writing a spec.** Both input files field by field with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build → read the report → fix → repeat loop, the map from every named failure to the file that has to change, and the features rigc refuses by name so you do not spend a loop discovering them. It travels **inside the npm package**, at `node_modules/spine-rigc/docs/AUTHORING.md` |
415
+ | 🎞️ **[docs/MOTION.md](docs/MOTION.md)** | **the key-pose recipe.** How to get two poses, what a pair of poses does and does not fix, the in-betweening rules and where each comes from, and how to spread candidates so a ballot informs. Ships in the package too |
416
+ | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
417
+ | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
418
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 36 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
419
+ | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
332
420
 
333
- ```bash
334
- bun cli.ts diff candidate.json reference.json [--json report.json]
335
- ```
421
+ ## Why you can trust the output
336
422
 
337
- `diff` reads two skeletons and reports a ratio per **measure**, grouped into six
338
- sections bones, slots, attachments, constraints, animations, events — and it
339
- does **not** combine them into a score. A single "87% match" cannot tell a rig
340
- with the right skeleton and the wrong timing apart from a rig with the right
341
- timing and the wrong skeleton, and those are opposite diagnoses.
342
-
343
- Three properties the measures are built to have:
344
-
345
- - **`diff X X` is 1.000 on every measure.** A comparison tool that cannot
346
- recognise identity is reporting noise, and noise looks like a small honest gap.
347
- The selftest asserts it.
348
- - **A difference moves as few measures as possible.** Reordering two slots moves
349
- `slots.order` and nothing else — not the slot-to-bone bindings, not the setup
350
- attachments — so the report says *where* a rig is wrong, not just *how much*.
351
- Each selftest case names the exact set of measures its edit may disturb.
352
- - **Name-agnostic figures sit beside name-matched ones.** A candidate that builds
353
- the right tree under its own bone names scores 0 on `bones.parent_by_name` and
354
- 1.000 on `bones.depth_histogram` and `bones.degree_sequence`. Reporting only the
355
- first calls a correct rig a total failure; reporting only the second calls any
356
- 14-bone tree a match. That holds at the section level too: `bones` and `slots`
357
- are the two sections whose measures are mostly name-keyed, so each reports **two
358
- figures**, and the pair is the finding —
359
-
360
- ```
361
- bones 0.567 (name-matched) · 1.000 (name-agnostic)
362
- ```
363
-
364
- reads *"the tree is right and the vocabulary is different"*, which the single
365
- mean on its own could not say. They are two comparisons with their own measure
366
- sets rather than two halves of one; the name-matched figure is unchanged, so
367
- older reports stay comparable. `sections[].nameAgnostic` in the JSON lists them.
368
-
369
- An assertion or measure with nothing to compare reports its `total` as 0 and says
370
- so, exactly as the validator's SKIP does — a vacuous 1.000 that looks earned is
371
- the same false green in a different costume.
372
-
373
- ### Checking a rig against the pictures — `rigc check`
423
+ rigc is measured against **Spine's own official example projects** the
424
+ `1-weight-and-mass` `8-follow-through` series as a difficulty ladder, with spineboy
425
+ as the graduation exam.
374
426
 
375
- ```bash
376
- bun cli.ts check --candidate path/to/spine --frames bench/reference/3-timing-and-spacing
377
- ```
378
-
379
- ⭐ **Neither the gate nor `diff` can see a wrong animation.** The gate checks
380
- validity: it parses the skeleton, steps every animation and refuses anything
381
- degenerate, and it has no opinion about whether the animation is the one that was
382
- asked for. `diff` checks structure: a reversed easing is the same timeline, the
383
- same key count and the same curve kind. Three honest ladder runs have now produced
384
- **zero** validator FAILs between them, and one of them shipped a build in which
385
- every easing in the file was reversed — green, and sincerely reported as done.
386
-
387
- `check` is the instrument for that. It renders the candidate with the same
388
- rasteriser that drew the reference frames, onto the same pixel grid, and reports
389
- per animation and per frame:
390
-
391
- - **MAE over the union alpha** — the mean absolute RGB difference over the pixels
392
- either side covers, 0..255. The whole-frame figure is printed beside it and never
393
- instead of it: most of a frame is background on both sides, so that number is
394
- small for every candidate and the gap between a good one and a bad one smaller
395
- still.
396
- - **The framing** — where the candidate's drawn pixels sit against the reference's,
397
- as a scale, an offset and a residual. It is printed first because it is upstream
398
- of everything else: get it wrong and the error arrives disguised as motion. On a
399
- skeleton root it is decided **per animation directory**: a set whose own pixels
400
- land in the box `frames.json` records is measured there, exactly, and the rest
401
- share one fitted framing. `--framing shared` measures every set in the shared one
402
- — the whole-root behaviour before issue #100, and worth 15–25 MAE on a character.
403
- A **fitted** framing then gets one last pass that searches whole-pixel offsets
404
- (±2 px) for the lowest MAE and takes the best one, because a fit registers extent
405
- and the best fit of two extents is not the best alignment of two pictures — a
406
- constant pixel is worth up to 30 % of a set's figure (issue #146). The line says
407
- what it moved and what that was worth, and it says so when the identity won as
408
- well. A box that is **not** an estimate — `frames.json`'s own, or one you pinned —
409
- is never moved: there the same search is reported as a finding, because a constant
410
- pixel inside the right box is the candidate's own figure sitting off, not framing.
411
- - **The whole shot, against the contact sheet** — a set that ships a couple of
412
- stills and folds every sampled frame into one `contact.png` (rung 2's do,
413
- spineboy's `@30fps` sets do) used to be compared on the stills alone, honestly
414
- reported and empty behind: nothing at all was measured about the frames in
415
- between. `check` now samples the candidate at the set's own rate and compares it
416
- against the sheet's own tiles, whose grid it measures off the sheet (issue #36).
417
- MAE only, and a sheet that is not a grid of those frames is refused by name.
418
- - **Per-frame change** — how many pixels each side moved since **its own** previous
419
- frame, compared against each other. It is the only measure here that looks at the
420
- relation between two frames rather than at one, and it is what catches a held pose
421
- the candidate does not hold, or a one-frame event that never fired: both are cheap
422
- in every individual frame and invisible to an aggregate.
423
- - **Per-slot drift** — where each of the candidate's own slots landed against the
424
- reference frame, in pixels. MAE says *how wrong*; a slot's drift says *which
425
- part, which way, how far*. Where the reference merged two parts into one blob —
426
- the trap [AUTHORING §8](docs/AUTHORING.md) opens with, and it counts as merged
427
- even when one part is most of the blob (issue #37) — the slot is
428
- template-matched against its own rendered pixels instead, with a confidence; and
429
- where nothing inside the distance that slot could plausibly have moved matches
430
- it, the answer is **no match** rather than a number about some other part.
431
- - **Per-chain attribution** — the same two, rolled up onto the unit an author
432
- repairs. `check` cuts the **candidate's own** bone tree into chains at its branch
433
- points and prints, per chain per set, the worst slot drift with its slot and
434
- frame, the mean, the error per pixel inside it, and its share of the set's error
435
- over the reference's own drawn pixels — plus one rollup line per chain across
436
- every set. A figure with a dozen joints otherwise collapses to one number a shot,
437
- and *"motion ✗"* over sixteen shots does not say which limb to re-key.
438
-
439
- 🔒 **It never reads the reference skeleton.** It opens the candidate and PNG
440
- frames, and nothing else: every reference-side read goes through one guard that
441
- refuses a path which is not a `.png` or the frame set's `frames.json`, and the
442
- selftest makes that guard fire. That is what lets `check` sit *inside* an
443
- authoring loop where `bench` cannot — running it as often as you like does not
444
- stop a run being an authoring run.
445
-
446
- The candidate is framed **by its own drawn pixels**, not by the reference's world
447
- box. A candidate is authored in its own coordinate system and under the ladder's
448
- honesty rule could not be authored in any other, so both sides are measured the
449
- same way — the content box of what each actually draws — and one similarity
450
- transform, fitted by least squares over every edge of every frame, carries the
451
- candidate's onto the reference's. Two skeletons depicting the same shot land on the
452
- same pixels whatever coordinates they were authored in, an invisible transparent
453
- margin cannot move the result, and no single quad corner in a single frame can set
454
- the scale for a run.
455
-
456
- There is no pass mark **in the tool**, for the same reason `diff` has none. The
457
- ladder's pass definition and its thresholds are a document read by a person over
458
- the whole table — [docs/GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) states the clauses and
459
- [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md)'s *Operating rules* derives them — and not an
460
- exit code either command could produce.
461
-
462
- ### Benchmark ladder — the rungs, and where they stand
463
-
464
- 🎓 **The ladder is complete, 2026-08-28.** All eight numbered rungs and the
465
- spineboy graduation exam are cleared under gate v2.1 and hold under **v2.2**, every clause PASS or SKIP:
427
+ 🎓 **The ladder was completed 2026-08-28.** All eight numbered rungs and the
428
+ spineboy graduation exam cleared under gate v2.1 and held under **v2.2**, every clause PASS or SKIP:
466
429
  worst attributable slot drift **5.55 px** against a 6.0 px bar, and **0 of 124**
467
430
  frame-change disagreements. Recompiling the same spec in a different session
468
431
  reproduced every field of the measurement record **to the digit**. The rungs stay
469
432
  in place as regression gates.
470
433
 
434
+ ⚠️ **Current state, 2026-09-02: seven of the eight hold, and rung 7 is open.**
435
+ `check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
436
+ changed which box a set is measured in, and rung 7's stored candidate fails **G2**
437
+ under it — one of its three slots draws in every set and is attributable in none,
438
+ and no read-down kind survives the framing change. **Rungs 1–6 and 8 and the
439
+ graduation exam are unaffected**: each reproduces its gated figures to the digit,
440
+ and the 5.55 px and 0-of-124 figures above are among them. Verdict and reasoning
441
+ in [docs/LADDER.md](docs/LADDER.md)'s *PR #254 instrument re-inspection*.
442
+
471
443
  ⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
472
444
  protocol reach the bar across a bounded series of honest attempts, each residual
473
445
  diagnosed and fixed** — spineboy took five, and the last inherited its
@@ -475,574 +447,11 @@ predecessor's specs under the run protocol's inheritance clause. It is **not**
475
447
  that an agent authors a spineboy-scale rig from the brief alone in one run: the
476
448
  ladder has not demonstrated that, and each row records which of the two it is.
477
449
 
478
- **[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) is the live ledger**: the rung order
479
- (blockers rung 3 first 1 · 2 · 4 · 5 6 → 8 → 7 → spineboy), what each
480
- rung gates on, how a rung is scored, the honesty rule that keeps the reference
481
- export away from the authoring agent, the operating rules — what a pass is, and
482
- the numbered thresholds of the current gate (**gate v2.2**, stated in [docs/GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md)) that decide one — and a status table. Run
483
- one with:
484
-
485
- ```bash
486
- bun cli.ts bench 3 --candidate path/to/candidate/spine
487
- ```
488
-
489
- `bench` validates the candidate under `--profile spine`, diffs it against that
490
- rung's reference export, and prints both. It exits non-zero only when validation
491
- fails: the diff has no threshold, because there is no rung score. Add
492
- `--frames <dir>` and it folds in the `check` table below, so a ladder row carries
493
- fidelity as well as structure.
494
-
495
- #### What the rungs need
496
-
497
- [docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md) surveys the full Spine 4.3 export surface against what
498
- rigc emits and against what the nine examples measurably use (`bun run bench:usage` regenerates the
499
- counts). Three blockers sat *before* rung 1: **B1**, the bone tree was code in `archetype.ts` rather
500
- than data, so no example could be expressed at all; **B2**, `A16`'s regex rejected the `"4.3.75-beta"`
501
- that every example declares; and **B3**, every example ships a **packed** atlas (13–50 regions per
502
- page) against rigc's one-part-per-page model, which `A06` enforced unconditionally. **B1 and B2 are
503
- closed**; B3's validator half is (the packed-atlas clauses live behind `--profile`, above) and its
504
- emitter half — no packer, no atlas importer — is not. Ordered gap list in Part 4 of that document;
505
- live status, and B1's proof, in [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
506
-
507
- ## Looking at a rig — `rigc render` and `rigc preview`
508
-
509
- The validator cannot see a wrong pose and says so honestly; `check` can, and needs
510
- reference frames a first user does not have. That left looking as the one thing
511
- the package could not do, and these two commands are it. Both take a compiled
512
- artifact — the directory `build --out` wrote — and neither needs a reference, a
513
- clone or a server:
514
-
515
- ```bash
516
- rigc render --candidate spine [--animation <name>] [--fps 12] [--max 256] [--out render/]
517
- rigc preview --candidate spine [--animation <name>] [--out preview.html]
518
- ```
519
-
520
- `render` writes `render/<animation>/f0000.png…` plus a `contact.png` grid of every
521
- frame and a `frames.json` sidecar describing the world box they are pictures of —
522
- the same frame-set shape `bench/render_reference.ts` writes and `rigc check`
523
- reads, drawn by the same rasteriser, so the output is a frame set rather than a
524
- pile of images. `preview` writes one self-contained `.html` that plays the
525
- artifact in the official Spine Web Player, with the skeleton, the atlas and every
526
- page embedded as data URIs; the player is loaded from unpkg rather than copied, so
527
- the first open needs a network and rigc redistributes nothing Esoteric Software
528
- owns ([NOTICE.md](NOTICE.md)).
529
-
530
- They complement each other rather than overlap. `render` is offline, deterministic
531
- and measurable — its pixels are the ones `check` reports on. `preview` is the
532
- interop proof: what plays there was played by Esoteric's own runtime, not by ours.
533
-
534
- 🎞️ **Authoring the movement that these two show you** — key poses, in-betweening,
535
- and how to spread candidates so a ballot informs — is
536
- [docs/MOTION.md](docs/MOTION.md).
537
-
538
- ### Letting someone choose — `rigc vote`
539
-
540
- Sometimes looking is not enough on its own, because there is more than one
541
- candidate and no instrument that can separate them: a pose fit with two local
542
- optima that measure the same, a key density that is a matter of taste, a first
543
- draft with no reference to compare against. `vote` is the deliberate human gate
544
- for exactly that residue, and only for that residue.
545
-
546
- ```bash
547
- rigc vote --candidate spine-a --candidate spine-b [--animation <name>] [--out ballot.html]
548
- rigc vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl] [--again]
549
- ```
550
-
551
- The first form writes one self-contained `ballot.html`: two to four compiled
552
- candidates side by side, each in its own official player, looping, with one
553
- button that restarts them together. The panes are labelled `A`, `B`, `C`, `D` and
554
- show **no paths** — a voter who can see that `B` came out of `experiments/` is not
555
- comparing pictures any more — so the path→label mapping lives in a manifest
556
- embedded in the same file and is never rendered. A voter picks a winner or says
557
- "tie / no preference", optionally writes a sentence, and copies or downloads a
558
- small JSON result the page prints the filename for.
559
-
560
- The second form checks that result against the ballot's own manifest and appends
561
- it to an append-only JSONL ledger. Nothing is trusted: the result carries a
562
- content **digest** per candidate, and a result whose digests are not this
563
- ballot's, whose choice is not on it, or whose reason code contradicts its choice
564
- is refused by a named rule (`V02_CANDIDATE_DIGESTS_ARE_THE_BALLOTS` and friends)
565
- with nothing appended. A second vote on one ballot needs `--again`.
566
-
567
- The loop it is built for, in one line: **the agent compiles N candidates that all
568
- pass the gate → `rigc vote` writes the ballot → a human opens it, watches, and
569
- votes → `rigc vote --record` checks the answer into `votes.jsonl` → the agent
570
- reads the ledger and proceeds.** Compile first, vote last: a candidate reaches a
571
- ballot only because it already validated green, so the human is never asked to
572
- read JSON, a diff or a spec.
573
-
574
- Three properties are worth stating because they are what make the ledger usable
575
- by the next agent rather than by a reader:
576
-
577
- - **A tie is a recorded outcome, not a missing one.** The ledger distinguishes a
578
- ballot with a winner, a ballot the human called a tie, and a ballot nobody
579
- opened. `both-unacceptable` is the tie that means *propose again*, and it is
580
- unreachable if ties are not recordable.
581
- - **The winner is a digest, not a label.** `B` means nothing outside one ballot;
582
- the digest identifies the same pixels anywhere. Every line also carries its
583
- `coverage` — which candidates the vote compared — so completeness is
584
- computable rather than assumed.
585
- - **Every line carries a reason code** from a closed enumeration, and the
586
- enumeration is enforced: "tie, because this one is better" is refused.
587
-
588
- Same player, same posture as `preview`: referenced from a CDN, never vendored,
589
- and the file contains only your own art ([NOTICE.md](NOTICE.md)).
590
-
591
- ## Reading a pose you were given — `rigc pose`
592
-
593
- ```bash
594
- rigc pose --images parts/ --frame poseA.png [--out pose.json]
595
- ```
596
-
597
- Every command above takes something you authored and tells you about it. This one
598
- runs the other way: it takes a **picture the user already has** — one key pose —
599
- and reports where each loose part PNG sits in it, so an agent can write those
600
- coordinates into a rig and a motion **by construction** and spend its effort on the
601
- part no instrument can measure, the movement between two poses.
602
-
603
- ```
604
- PLACE torso.png x= 44.4 y= 65.4 rot= 0.0° scale=1.118 residual=0.0770 unexplained= 19%
605
- AMBIG arm.png x= 27.1 y= 56.3 rot= -35.2° scale=1.111 residual=0.0262 unexplained= 2%
606
- alt 2: x= 61.5 y= 56.3 rot= 35.4° scale=1.116 residual=0.0279 unexplained= 2%
607
- PLACE ball.png x= 44.3 y= 104.6 rot= 0.0° scale=1.144 residual=0.0203 unexplained= 3%
608
- rotation is a FREE degree of freedom — the 0° above is a placeholder
609
- REFUSE foreign.png no-match: the best placement found has residual 0.4245, above --max-residual 0.25
610
- ```
611
-
612
- 🚨 **Nothing here is a score, and no pass bar attaches to any of it.** `check` and
613
- `bench` measure a build against a reference, so their numbers mean *how close*. A
614
- pose frame is not a reference — it is a **given condition**, and once the spec
615
- states those coordinates there is nothing left to be close to. The residual says
616
- how far to trust a placement and where two answers are equally good, which is a
617
- different job and needs the opposite defaults:
618
-
619
- - a part that matches nowhere is **refused by name**, with its best guess still in
620
- the JSON — a refusal tells you not to trust a number rather than hiding it;
621
- - two near-equal optima are reported as **both**, flagged `ambiguous`, never
622
- silently resolved. Two identical limbs look exactly like that;
623
- - a part whose rotation genuinely does not matter — a ball — reports rotation as a
624
- **free degree of freedom** rather than as a failure;
625
- - a part the canvas cannot contain at any tested scale, and a part with no material
626
- in it at all, each get their own named refusal.
627
-
628
- ⚠️ **Residuals degrade under occlusion and there is no depth solver here.** A part
629
- drawn behind another has the occluder's pixels where its own should be, so its
630
- residual rises at the *correct* placement; `unexplained` is the share of the part
631
- that disagrees, and a middling residual beside a high `unexplained` usually means
632
- *right place, seen through something else*. The output carries its own `caveats`
633
- block saying so. Fields, coordinate contract and the rest of the limits:
634
- **[AUTHORING.md §11](docs/AUTHORING.md)**.
635
-
636
- ## Run viewer — watching a *run* instead of reading it
637
-
638
- 🔎 **This is the ladder's instrument, not the way to look at your own rig** — that
639
- is the section above. The viewer is reference-bound and repository-bound, and it
640
- deliberately never ships.
641
-
642
- `check.txt` says a candidate's worst frame is f0012 at 56 MAE. The viewer shows
643
- you f0012.
644
-
645
- ```bash
646
- bun run viewer # http://localhost:5173
647
- ```
648
-
649
- Pick a run, a candidate and an animation. The left pane plays the candidate's
650
- emitted `skeleton.json` — rendered by **[spine-html](https://github.com/firejune/spine-html)**,
651
- plain DOM, one CSS matrix per slot — and the right pane shows the reference
652
- frames for the same animation from `bench/reference/`, indexed by the scrubber's
653
- time at the frame set's own fps. Both panes use the world box the run was
654
- measured in (`bench.json`'s `check.viewport`, per frame set where the run framed
655
- them separately), so the two pictures are comparable exactly as far as the
656
- check's numbers say they are — and the pane label names which box that was.
657
- Under them: `bench.json`'s section means and the framing plus per-animation
658
- summary from `check.txt`.
659
-
660
- It is also the smallest end-to-end proof the two modules have. rigc emits Spine
661
- data; spine-html consumes Spine data; neither is checking its own work when the
662
- skeleton one wrote comes up animating in the other.
663
-
664
- Every run under `bench/runs/` is listed, including the ones that predate a
665
- convention — those are greyed out with the reason (a missing atlas page usually
666
- means `bun run fetch-examples` has not run) rather than dropped, because the
667
- ladder's history is part of what the viewer is for.
668
-
669
- 🚫 **There is no build, and that is deliberate.** The viewer reads the working
670
- tree: the runs, the reference frames, and `examples/` — which is Esoteric
671
- Software's art, fetched rather than redistributed and non-commercial even then
672
- (see [NOTICE.md](NOTICE.md)). A bundle would copy those pixels into a
673
- distributable artifact. So there is one mode, `vite dev` on localhost, the dev
674
- server serves nothing outside `bench/` and `examples/`, and `vite build` fails
675
- on purpose. `viewer/` is not in `package.json`'s `files`, so it never ships
676
- either; it is also outside the root `tsconfig.json` (it needs the DOM lib, which
677
- the rest of the repository must not have) and is type-checked on its own with
678
- `bunx tsc -p viewer --noEmit`. `bun run lint` covers it like everything else.
679
-
680
- ## What exists today
681
-
682
- **Inputs — three files, one domain each.** Only the middle one is required.
683
-
684
- - A **cut manifest** (`FaceManifest` in [`src/types.ts`](src/types.ts)) owns
685
- **measured art**. Crop rectangle, the base plate, one entry per part with its
686
- offset and size, mask polygons, the state machine, bone anchors, and — for a
687
- joint cut — the entry point, the insertion axis (`deg` in screen degrees plus a
688
- `unit` vector, cross-checked against each other), stroke amplitudes and any
689
- measured ceilings. The compiler **never re-measures art**: every number here is
690
- produced by a measuring tool or by the pipeline that cut the plates, and rigc
691
- only reads it. **Optional** — a skeleton with no measured art behind it (any of
692
- the benchmark examples) has none.
693
- - A **rig spec** (`RigSpec` in [`src/rig.ts`](src/rig.ts), `spec: "rigc-rig/1"`)
694
- owns **skeleton structure**: bones, slots, skins and their attachments, the 4.3
695
- typed `constraints` array, and the invariants the emitted JSON cannot state
696
- about itself. Its vocabulary is deliberately **Spine's own** — same concepts,
697
- same field names, same defaults, cited to `SkeletonJson.ts` line numbers — so an
698
- agent that has read Spine's documentation can author one without learning a
699
- second vocabulary. rigc's additions sit on top and are namespaced: `from` on a
700
- bone takes its position from the manifest instead of a literal that would drift
701
- away from the art; `image` on an attachment names a PNG and rigc measures it;
702
- `generator` on a mesh invokes a builder from `src/mesh.ts`; `invariants` carries
703
- the axis bone, the forbidden parentage, the mesh budget.
704
- - A **motion spec** (`MotionSpec`, `spec: "rigc-motion/1"`) owns **time**: the rig
705
- it was authored against, named easing handles, setup overrides, a physics tuning
706
- table, and the animations — each with a declared duration, a loop flag, its
707
- tracks, and five timeline families that sit on the animation rather than in `tracks`:
708
- `drawOrder` and `events`, which name no target at all, and `ik`, `transform`
709
- and `deform`, whose keys carry named fields instead of one value (an IK mix and
710
- softness, six transform mixes, a sparse run of vertex offsets) — which is also
711
- where 4.3 writes each of them.
712
-
713
- **Outputs — two files per cut**, written to the cut's `out` directory:
714
-
715
- - `skeleton.json` — Spine **4.3** skeleton data. Bones, slots in draw order, one
716
- skin, animations, and constraints in the 4.3 single `constraints` array.
717
- - `skeleton.atlas` — a **one-part-per-page** atlas: every region covers its whole
718
- page, `pma: false`. That convention is what makes the region/attachment/filename
719
- join key checkable exactly rather than by convention.
720
-
721
- **Where the three meet.** A manifest part joins a rig slot by its `rig_slot` field
722
- (falling back to `slot`), and that slot's position in the rig's `slots` array **is**
723
- the draw order — a manifest whose `draw_order` numbers disagree is a compile error
724
- rather than a silent overrule. A slot filled by both a manifest part and a rig skin
725
- is likewise refused, as is a setup pose declared in both the rig and the motion
726
- spec: one fact, one author. A missing anchor is a compile error by design, so that
727
- copying another cut's numbers is not the path of least resistance.
728
-
729
- Two things are code and stay code, because neither is a table of numbers: the
730
- **mesh generators** in [`src/mesh.ts`](src/mesh.ts), which encode a deformation
731
- model (what is pinned, what may move, how authority falls off), and the
732
- **coordinate contract** in [`src/transform.ts`](src/transform.ts).
733
-
734
- ### The validator
735
-
736
- [`src/validate.ts`](src/validate.ts) parses the emitted artifacts with `spine-core`
737
- and then runs 36 named assertions over the loaded skeleton. Each one exists because
738
- the failure it catches is **silent**: the file loads, animates, and lies.
739
-
740
- Assertions whose data is absent are reported as **SKIP**, never folded into the pass
741
- count — an assertion with nothing to check has not checked anything.
742
-
743
- #### Profiles — "wrong" versus "not how we do it here"
744
-
745
- Not all 36 rules are about Spine. Some are about **spine-html**, the renderer this
746
- compiler was built to feed, and about one project's frame budget; they fire on real,
747
- correct, editor-produced Spine data, because the official example projects carry
748
- clipping attachments, unweighted meshes, 116-triangle meshes and packed atlases —
749
- all valid, none of them things spine-html will draw. Others are about **rigc's own
750
- rigs** and mean nothing at all on a skeleton rigc did not compile — they read the
751
- rig spec's `invariants` block, and they **SKIP** when it is absent rather than
752
- counting as passes.
753
-
754
- So `validate` and `build` take a `--profile`:
755
-
756
- | Profile | Runs | For |
757
- | --- | --- | --- |
758
- | `spine` | the 22 validity rules | **the default.** Is this valid Spine 4.3 that any runtime plays correctly? |
759
- | `spine-html` | all 36 | Opt-in. Is this a rig *this* project can ship? |
760
-
761
- `spine` is the default because it is the question this package's output answers:
762
- the artifact imports into the Spine editor and plays in any 4.3 runtime, and
763
- that is what the 22 validity rules are about. The other 14 are somebody's policy
764
- — one renderer's, one canvas budget's, one compiler's own formations' — and a
765
- rig arriving from anywhere else has no stake in them. Ask for them with
766
- `--profile spine-html` when you want them.
767
-
768
- The **Profile** column below says which is which — `both` = validity, `renderer` and
769
- `archetype` = `spine-html` only, and **`both ◑`** = a mixed assertion whose validity
770
- half always runs while its policy clauses are gated (A06's `pma`/rotation/full-page
771
- clauses, A08's "the two names must be identical", A20's "a mesh must be weighted at
772
- all"). A report always names the profile it ran and lists what that profile left
773
- out, on `PROF` lines: a `--profile spine` green means *valid Spine*, never *passes
774
- the renderer policy*.
775
-
776
- | Assertion | Profile | Holds that |
777
- | --- | --- | --- |
778
- | `A00_ROUNDTRIP_PARSE` | both | `spine-core` parses the skeleton and the atlas without throwing |
779
- | `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 |
780
- | `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 |
781
- | `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) |
782
- | `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 |
783
- | `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 |
784
- | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | each page's declared `size:` matches the PNG on disk, and its region covers the whole page |
785
- | `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 |
786
- | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both ◑ | every attachment name resolves to a region of exactly that name |
787
- | `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 |
788
- | `A10_NO_NAN_AFTER_STEPPING` | both | stepping every animation frame by frame produces no NaN anywhere in the pose |
789
- | `A11_NO_CLIPPING_ATTACHMENTS` | renderer | no clipping attachments (the renderer skips them silently) |
790
- | `A12_NO_DARK_COLOR` | renderer | no dark / two-colour tint on slots or timelines — parsed, then ignored |
791
- | `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 |
792
- | `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) |
793
- | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | `idle` keys no bone that drives a mesh, directly or as its control bone |
794
- | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` version label is on the 4.3 line (the parser never checks it) |
795
- | `A17_ATLAS_PAGE_FILES_EXIST` | both | every page the atlas declares is a file on disk |
796
- | `A18_DETERMINISTIC_EMIT` | both | a second, independent compile of the same inputs is byte-identical. SKIPs when re-gating artifacts already on disk |
797
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | every overlay part image can be transparent somewhere — an alpha channel (colour type 4 or 6) **or** a `tRNS` chunk, which is where indexed and greyscale PNGs keep theirs. Only the base plate — identified structurally as the region covering the stage — may be opaque |
798
- | `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 |
799
- | `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 |
800
- | `A22_MESH_UVS_IN_UNIT_RANGE` | both | every UV lies inside its region |
801
- | `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 |
802
- | `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) |
803
- | `A25_DETACHED_BONE_PARENTAGE` | archetype | bones that must stay detached are not parented under a moving part |
804
- | `A26_SLOT_DRAW_ORDER` | archetype | the slots array — which *is* the draw order — matches the rig spec's slot table |
805
- | `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 |
806
- | `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 |
807
- | `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) |
808
- | `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) |
809
- | `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 |
810
- | `A32_EVENT_KEYS_RESOLVE` | both | every event key fires an event the skeleton declares, no key sits earlier in time than the one before it, and `volume`/`balance` appear only on an event with an `audio` path. Only the first of those is loud in the parser; the other two load clean and drop the firing or the value in silence. SKIPs when no animation carries an event timeline |
811
- | `A33_VERTEX_ATTACHMENT_GEOMETRY` | both | every bounding box and clipping polygon states a `vertexCount` that agrees with its vertex array, its weighted run decodes to that many vertices with bone indices in range, and a clipping `end` names a slot that exists. All three load clean: a missing count reads as zero and empties the polygon, and a missing end slot makes the clip run to the bottom of the draw order. SKIPs when the skeleton carries neither type |
812
- | `A34_CONSTRAINT_TIMELINE_TARGETS` | both | every `ik` / `transform` timeline names a constraint of that type and carries at least one key. The name-and-type miss is loud in the parser (`IK Constraint not found`) and this one says which constraints the skeleton *does* have; the empty key array is silent — `readAnimation` reads key 0, finds nothing and skips the timeline without a word. SKIPs when no animation carries one |
813
- | `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT` | both | every deform key's run lands inside the attachment's own deform array, starts on an even index, holds an even count of finite numbers, and names a skin/slot/attachment triple that resolves. The array is one `x, y` pair per **vertex** on an unweighted attachment and one per **bone influence** on a weighted one, so its length is measured from the attachment rather than assumed. An overlong run is the format's quietest defect: `Utils.arrayCopy` into a `Float32Array` drops everything past the end, so part of the mesh deforms and it looks nearly right. SKIPs when no animation carries a deform timeline |
814
-
815
- ## Usage
816
-
817
- 📘 **Writing a spec? Read [docs/AUTHORING.md](docs/AUTHORING.md) first.** It is the
818
- guide an agent rigs from: both input files with a complete minimal example each,
819
- every field with its Spine meaning, the rules that decide what is emitted, the
820
- build → read the report → fix → repeat loop, the map from every named failure to
821
- the file that has to change, and the list of format features rigc refuses by name
822
- so you do not spend a loop discovering them. It travels **inside the npm package**
823
- too, so an agent working from an install has it on disk at
824
- `node_modules/spine-rigc/docs/AUTHORING.md`.
825
-
826
- Compile by spelling out the paths. `--manifest` is optional; `--images <dir>` says
827
- where a rig spec's `image` references live (it overrides the rig's own `images`
828
- field):
829
-
830
- ```bash
831
- bun cli.ts build \
832
- --rig path/to/my_rig.rig.json \
833
- --motion path/to/my.motion.json \
834
- --out path/to/spine \
835
- [--manifest path/to/manifest.json] [--images path/to/images]
836
- ```
837
-
838
- By default, atlas page paths point back at the source art wherever it lives —
839
- often outside `--out` — so add `--copy-images` when `spine/` itself needs to be
840
- self-contained (zipped, committed, or handed off on its own): it copies every
841
- referenced page PNG into `--out` and rewrites the atlas to match.
842
-
843
- …or register cuts in a `cuts.json` and build them by name. Every path in the table
844
- resolves **relative to the `cuts.json` file itself**, so the table lives with the
845
- project that owns the art:
846
-
847
- ```json
848
- {
849
- "my_cut": {
850
- "rig": "rigs/my_rig.rig.json",
851
- "manifest": "output/my_cut/manifest.json",
852
- "motion": "specs/my_cut.motion.json",
853
- "out": "output/my_cut/spine"
854
- }
855
- }
856
- ```
857
-
858
- ```bash
859
- bun cli.ts build --cut my_cut --cuts path/to/cuts.json
860
- ```
861
-
862
- `build` compiles, then validates, and **writes only if the gate is green**. Other
863
- commands:
864
-
865
- ```bash
866
- bun cli.ts explain --cut my_cut --cuts path/to/cuts.json # the compiled rig as a table
867
- bun cli.ts validate path/to/spine # re-gate artifacts already on disk
868
- bun cli.ts validate --profile spine-html path/to/spine # …and this project's policy too (see Profiles)
869
- bun cli.ts diff candidate.json reference.json # structural comparison
870
- bun cli.ts check --candidate path/to/spine \
871
- --frames bench/reference/3-timing-and-spacing # against pictures
872
- bun cli.ts bench 3 --candidate path/to/spine # one rung of the ladder
873
- bun cli.ts render --candidate path/to/spine # PNG frames + a contact sheet
874
- bun cli.ts preview --candidate path/to/spine # one .html that plays it
875
- bun cli.ts vote --candidate path/to/a --candidate path/to/b # one .html that asks which
876
- bun cli.ts vote --record vote-<id>.json # check the answer into votes.jsonl
877
- bun cli.ts pose --images path/to/parts --frame poseA.png # read a pose OUT of a picture
878
- ```
879
-
880
- `validate` on a bare directory checks what it can see. Adding `--cut`/`--cuts` lets
881
- it re-derive the declared durations and the structural expectations too, and the
882
- report says which it had. `build` and `validate` both default to `--profile spine`,
883
- the 22 validity rules; `--profile spine-html` adds this project's renderer and
884
- archetype policy on top.
885
-
886
- `render` and `preview` are the two that need no reference at all — see
887
- [Looking at a rig](#looking-at-a-rig--rigc-render-and-rigc-preview). Run either
888
- straight after a green `build`, on the same directory `--out` wrote. `vote` is the
889
- same idea with more than one candidate in the page and an answer coming back —
890
- see [Letting someone choose](#letting-someone-choose--rigc-vote). `pose` is the
891
- one command that runs *before* a spec exists rather than after — see
892
- [Reading a pose you were given](#reading-a-pose-you-were-given--rigc-pose).
893
-
894
- ## Checks
895
-
896
- ```bash
897
- bun run typecheck # bunx tsc --noEmit over cli.ts, selftest.ts, src/, bench/, tools/, fixtures/
898
- bun run lint # one rule: @typescript-eslint/no-explicit-any, as an error
899
- bun run selftest # the validator's own negative controls (next section)
900
- ```
901
-
902
- All three run on every push and pull request —
903
- [`.github/workflows/ci.yml`](https://github.com/firejune/rigc/blob/main/.github/workflows/ci.yml). Bun runs the sources
904
- directly, so the first two are not on the path of anything; they exist because a
905
- convention nothing checks is a convention. `tsconfig.json` is
906
- `strict: false` with `strictNullChecks: true` and says in place why the rest is
907
- not on yet; `eslint.config.js` says why it carries exactly one rule.
908
-
909
- ## Selftest
910
-
911
- ```bash
912
- bun run selftest # everything below; no arguments needed
913
- bun run selftest --cuts path/to/cuts.json # …plus an extra suite over those cuts
914
- ```
915
-
916
- A gate nobody has seen fail is not a gate. The selftest compiles a rig, breaks the
917
- result one way at a time — 45 deliberate breaks, each modelled on a mistake that was
918
- actually made or actually measured — and asserts that the **named** assertion fires
919
- for each. Two further edits are *tolerance* controls the gate must let through,
920
- because a widened assertion can fail by firing too often as easily as by firing too
921
- rarely.
922
-
923
- **The rigs it breaks are generated.** [`fixtures/public.ts`](https://github.com/firejune/rigc/blob/main/fixtures/public.ts)
924
- writes three synthetic cuts into a temp directory on every run, and between them
925
- they carry every structure the assertions have an opinion about — region
926
- attachments, attachment swaps, rgba fades, a ring mesh on a control bone, a ribbon
927
- on a bone chain, an axis bone whose subtree travels along it, a detached emitter,
928
- physics constraints, and two measured ceilings. Every plate is a checkerboard with
929
- `PLACEHOLDER` burned into it: they exist to be structurally real, and no claim
930
- about appearance is made from any of them.
931
-
932
- A fifth suite breaks an **input** instead of an artifact: nine malformed rig specs
933
- that the compiler must refuse by name — a forward parent reference, a duplicate bone
934
- name, a slot naming a missing bone, an ik target that does not exist, an attachment
935
- image that is not on disk, an authored mesh binding a bone the rig does not have,
936
- one that uses raw bone indices without asking for them, a wrong `spec` field, and a
937
- constraint type the emitter cannot write. Each of those produces a file Spine's own
938
- parser would accept while quietly meaning something else.
939
-
940
- A **motion** spec can be wrong the same way, and the shape that costs the most is
941
- the quietest: a key time that lands past the animation's declared duration is never
942
- sampled, so the motion it was meant to carry simply does not happen. Five controls
943
- hold that line — a key sitting exactly on a duration of 68/12 s is legal and must
944
- compile, a key that 4 dp rounding pushed 0.000034 s past one is refused by name, the
945
- same overshoot in an artifact the compiler never saw is caught by `A09`, an animation
946
- whose last key is a frame short of its declared end is still accepted because that
947
- direction is a different question, and a 32-second animation keyed exactly on its own
948
- duration is *not* failed for the float32 grid its times come back on.
949
-
950
- There is a positive control per suite as well: the pristine artifacts must come back
951
- with zero failures, because a validator that failed everything would otherwise look
952
- like a validator that worked.
953
-
954
- `rigc check` gets the same treatment, and its pair is deliberately the same rig
955
- twice: the rung 3 transcription against rung 3's frames, and then that transcription
956
- with every key time reversed. Reversing leaves the structure untouched — same
957
- timelines, same key count, same duration, and the gate stays green, which the
958
- control asserts — and changes only what the shot looks like. Faithful reads 0.67 px
959
- of slot drift; reversed reads 66.8 px. A third control makes the frames-only read
960
- guard refuse a reference skeleton, because an honesty invariant nobody has seen
961
- refuse anything is not an invariant.
962
-
963
- The **mesh** path gets the same pair, on the rung 6 transcription against rung 6's
964
- frames. Faithful reads a median 0.08 px of drift on the mesh-bearing slots; the
965
- break is the one an authored mesh is actually exposed to — its weights bind bones
966
- by *index*, so inserting a bone anywhere ahead of them rebinds every vertex in
967
- silence — and it reads 31 px with the gate still green. Four further controls run
968
- on a generated fixture and need no corpus: a ring mesh is posed at all, its pixels
969
- reach the coverage mask `check` reads, an all-zero deform is the identity while a
970
- real one moves the centroid, and two triangles sharing an edge draw it once.
971
-
972
- Point the run at a `cuts.json` and an **extra suite** compiles every cut in it,
973
- gates the result, and compiles it a second time for `A18`. That one is a positive
974
- control on purpose: what real art adds is geometry a fixture cannot fake — measured
975
- offsets, a measured axis, a measured ceiling, a mesh built over a contour nobody
976
- drew by hand — so the question it asks is whether the whole gate still comes back
977
- green on it. Without a cuts file it says it was skipped and the run passes on the
978
- public suite alone; a cuts path that is *named and missing* exits 2.
979
-
980
- Two suites measure against the Spine example corpus, which is downloaded rather
981
- than redistributed. When `examples/` is absent they say so loudly and the summary
982
- repeats it — an absent corpus is a hole in the run, not a pass — and a run in which
983
- nothing substantive executed exits 2 rather than printing green.
984
-
985
- ## Layout
986
-
987
- ```
988
- tsconfig.json type-check config (noEmit); eslint.config.js — the no-any gate
989
- cli.ts build / validate / explain / diff / check / bench / render / preview / vote / pose
990
- selftest.ts the validator's own negative controls, and diff's and check's
991
- fixtures/ public.ts — the three synthetic cuts the selftest breaks
992
- src/
993
- compile.ts rig + motion spec (+ manifest) -> skeleton JSON + atlas text (pure data assembly)
994
- rig.ts the rig spec — `spec: "rigc-rig/1"`, the skeleton as data
995
- validate.ts spine-core round trip + the 36 assertions
996
- diff.ts structural comparison of two skeletons, one ratio per measure
997
- render.ts the rasteriser (regions + meshes), shared by the reference renderer,
998
- `rigc render` and check
999
- preview.ts the single-file HTML player page — the artifact embedded as data
1000
- URIs, played by the official Spine Web Player (referenced, not vendored)
1001
- ballot.ts the same page with 2–4 candidates in it and a vote coming back —
1002
- candidate digests, the ballot manifest, and the refusals that
1003
- stand between a saved vote and the ledger
1004
- check.ts a candidate against rendered frames — pixels and per-slot drift,
1005
- and it never opens the reference skeleton
1006
- pose.ts the other direction: loose part PNGs against ONE pose frame, and
1007
- where each part sits in it. An entry instrument — it reads a given
1008
- condition into spec coordinates and grades nothing
1009
- ladder.ts which example is which rung, and which file in it is the reference
1010
- timelines.ts the 4.3 timeline catalogue and its walker (shared, pure JSON)
1011
- mesh.ts ring and ribbon mesh builders, weighted-vertex encoding
1012
- transform.ts crop pixels (y down) <-> Spine world (y up), world transforms
1013
- png.ts PNG header reader (size, colour type, tRNS; no pixel decode)
1014
- errors.ts CompileError, and NotImplementedError for what the format holds
1015
- and the emitter does not write
1016
- types.ts manifest, motion spec, and emitted-JSON shapes
1017
- tools/ measurement and plate helpers (see below)
1018
- scripts/ fetch-examples.sh
1019
- bench/ count_features.ts — what the example corpus actually uses
1020
- render_reference.ts — a rung's official export as PNG frames
1021
- briefs/ — what an authoring agent is told about a rung
1022
- reference/ — those frames, with the licence they travel under
1023
- runs/ — one directory per attempt, and the run protocol
1024
- transcriptions/ — rung specs transcribed from a reference export,
1025
- which measure expressiveness and NOT authoring (see LADDER.md)
1026
- viewer/ the run viewer — dev server only, no build (see above)
1027
- vite.config.ts /api/inventory and /repo/<path>, and the build refusal
1028
- inventory.ts what is under bench/runs, resolved to URLs
1029
- main.ts the two panes, the transport, the report
1030
- docs/ AUTHORING.md (how to author a rig), GATE.md (the clause statements
1031
- a candidate is graded against), LADDER.md (live rung status),
1032
- SPEC_COVERAGE.md (format survey),
1033
- feature_matrix.{csv,json}
1034
- .github/ workflows/ — ci.yml (the gates) and release.yml (release-please)
1035
- CONTRIBUTING.md how to propose a change; RELEASING.md — how a version is cut
1036
- ```
1037
-
1038
- `tools/` are standalone utilities, each taking its paths as arguments:
1039
-
1040
- | Tool | Does |
1041
- | --- | --- |
1042
- | `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 |
1043
- | `contact.ts` | plate-vs-plate overlap measurement — the largest advance that keeps two footprints disjoint |
1044
- | `plate.ts` / `png_probe.mjs` | minimal PNG read/write and decode. The writer emits colour type 6 only; the reader takes every colour type and bit depth PNG allows except interlaced, expanding indexed palettes (`PLTE` + `tRNS`) and greyscale to RGBA — because the gate accepts that art, so the renderer has to as well (issue #226) |
1045
- | `font5x7.ts` | bitmap labels for diagnostic images and generated plates |
450
+ The whole dossier — the yardstick, `diff` and `check` and what neither of them can
451
+ see, every rung, the run viewer, the 39 assertions and the selftest behind them is
452
+ [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
453
+ Live rung status is
454
+ [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
1046
455
 
1047
456
  ## Contributing
1048
457