spine-rigc 0.22.2 → 0.23.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/docs/INGEST.md CHANGED
@@ -50,12 +50,22 @@ invent one.
50
50
  Two facts decide everything below, and they pull in opposite directions:
51
51
 
52
52
  1. **rigc reads compiled skeleton JSON in more places than you would guess.**
53
- `validate`, `render`, `preview`, `vote`, `check` and `diff` all take a
54
- `skeleton.json` path directly, and none of them needs a rig spec to do it.
55
- 2. **rigc cannot write one back.** There is no command that edits a skeleton, no
56
- importer, and no route from skeleton JSON to specs. The only thing that produces a
57
- skeleton is `build`, and `build`'s input is a rig spec plus a motion spec.
58
- ⇒ **Every route that ends in a changed file goes through transcription** (§2).
53
+ `validate`, `render`, `preview`, `vote`, `check`, `diff` and — since #569 —
54
+ `ingest` all take a `skeleton.json` path directly, and none of them needs a rig
55
+ spec to do it.
56
+ 2. **rigc still cannot EDIT one.** There is no command that opens a skeleton and
57
+ changes it. The only thing that produces a skeleton is `build`, and `build`'s
58
+ input is a rig spec plus a motion spec.
59
+ ⇒ **Every route that ends in a changed file goes through the specs** — and
60
+ since #569 there are two ways to get them: write them (§2, transcription) or
61
+ have `ingest` write them for you from the file itself (§2.0).
62
+
63
+ ⚠️ This clause read *"rigc cannot write one back… no route from skeleton JSON
64
+ to specs"* until 2026-09-17, and the half that was wrong is the second half.
65
+ The route exists now and its contract is an equality — `build(ingest(x))` is
66
+ `x`, byte for byte — which is a stronger statement than anything transcription
67
+ could make. What survives is the first half: nothing **edits** a skeleton, and
68
+ the specs remain the only thing a change is expressed in.
59
69
 
60
70
  ### 0.1 The table
61
71
 
@@ -71,6 +81,7 @@ an upstream `license.txt` (Appendix, and [NOTICE.md](../NOTICE.md)).
71
81
  | **`vote --candidate <a> --candidate <b>`** | ✅ **yes, on either side** | a ballot page. Pairing a foreign export against your own transcription is a legitimate ballot, and the panes carry no paths |
72
82
  | **`check --candidate <skeleton.json> --frames <dir>`** | ✅ **yes** | ⭐ it reads **frames and never a reference skeleton**, so a foreign export enters this one *twice over*: as the candidate, or — via `render` — as the source of the frames. §1.4 |
73
83
  | **`diff <candidate.json> <reference.json>`** | ✅ **yes, both sides** | 49 structural measures over bones, slots, attachments, constraints, animations and events. ⛔ **Blind to every coordinate** — §1.3 |
84
+ | **`ingest <skeleton.json> --out <dir>`** | ✅ **yes — and it is the only reader that WRITES specs** | the `.json` alone; no atlas, no art, no project file. Out come `rig.json`, `motion.json` and a findings report, such that `build`ing them reproduces the skeleton it read **byte for byte**. The seventh reader, and the one that ends §2's hand work — §2.0 and §5 |
74
85
  | **`pose --images <dir> --frame <png>`** | ⛔ **not the skeleton** | loose part PNGs and one picture. A packed atlas page is not loose parts, and pointing it at one produces a confident answer about nothing — §5 |
75
86
  | **`explain --rig … --motion … --out …`** | ⛔ **no** | rig spec + motion spec. It explains **what you wrote**, which makes it a transcription instrument rather than a reading one — §1.5 |
76
87
  | **`build --rig … --motion … --images …`** | ⛔ **no** | specs in, skeleton out. The only writer in the toolchain, and the reason §2 exists |
@@ -212,9 +223,11 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
212
223
 
213
224
  ### 1.3 `diff` — and the two things it cannot see
214
225
 
215
- `diff` takes two compiled skeletons and reports 49 measures in eight groups. Both
216
- sides may be foreign; the interesting pairing during ingest is **your transcription
217
- against the export it came from**:
226
+ `diff` takes two compiled skeletons and reports 49 measures in eight groups, plus two
227
+ blocks that report and gate nothing: the `(reported)` measures beside `attachments`
228
+ and `animations`, and the `skeleton` header block at the top, which measures the stage
229
+ (issue #578). Both sides may be foreign; the interesting pairing during ingest is
230
+ **your transcription against the export it came from**:
218
231
 
219
232
  ```bash
220
233
  rigc diff work/t3/skeleton.json \
@@ -227,6 +240,10 @@ rigc diff
227
240
  reference …/examples/3-timing-and-spacing/export/3-timing-and-spacing-ess.json
228
241
  .. bones=3/3 slots=2/2 skins=1/1 attachments=2/2 constraints=0/0 animations=2/2 events=0/0 (candidate/reference)
229
242
 
243
+ skeleton (reported) (no mean) over 2 measures — the stage, which no reading of the frames could decide
244
+ 1.000 stage_present 1/1 both sides declare a setup-pose stage, or neither does — …
245
+ 1.000 stage_box 4/4 the stage is the same box (x, y, width, height, exactly as stated) — …
246
+
230
247
  bones mean 1.000 over 8 measures
231
248
  1.000 count 3/3 how many bones
232
249
  1.000 names 3/3 the bone names themselves
@@ -253,11 +270,18 @@ Each pair is **matched / total**, where the total is the larger of the two sides
253
270
  count of `2/3` means one side has three of something and only two were matched — and
254
271
  it does not say *which* side has three. The `..` line above is where you read that.
255
272
 
256
- ⛔ **`diff` is blind to every coordinate.** No measure reads a bone's
257
- `x`/`y`/`rotation`, an attachment's offset, or a key's value — only *presence*,
258
- *names*, *counts*, *order* and *kinds*. §4.1 moves a pivot 236.5 units and every one
259
- of the 49 measures still reads **1.000**. ⇒ Never take a green `diff` as evidence that
260
- a geometric edit did not land, and never take it as evidence that one did.
273
+ ⛔ **`diff` is blind to every coordinate a bone, an attachment or a key carries.** No
274
+ measure reads a bone's `x`/`y`/`rotation`, an attachment's offset, or a key's value —
275
+ only *presence*, *names*, *counts*, *order* and *kinds*. §4.1 moves a pivot 236.5
276
+ units and every one of the 49 measures still reads **1.000**. ⇒ Never take a green
277
+ `diff` as evidence that a geometric edit did not land, and never take it as evidence
278
+ that one did.
279
+
280
+ ⚠️ **The one exception is the skeleton's own declared box**, and it is an exception to
281
+ the sentence and not to the rule: `skeleton.stage_box` compares four world numbers,
282
+ but they are numbers an exporter *wrote into the header* rather than a pose anything
283
+ measured, and the block they sit in gates nothing. Moving a pivot does not move them
284
+ either.
261
285
 
262
286
  ⛔ **And its ratios are not a score.** [`src/diff.ts`](../src/diff.ts) says so in the
263
287
  type itself (*"Unweighted mean of the measures below. NOT a quality score"*), and the
@@ -377,17 +401,70 @@ is fine — it is not created.
377
401
 
378
402
  ---
379
403
 
380
- ## 2. Transcription — the route that makes a foreign skeleton yours
404
+ ## 2. Getting specs out of a skeleton
405
+
406
+ Everything in §1 reads. To **change** anything you need specs. There are two routes
407
+ to them and you should almost always take the first.
381
408
 
382
- Everything in §1 reads. To **change** anything you need specs, and getting specs out
383
- of a skeleton is a job rigc does not do for you: the numbers come out of the JSON by
384
- hand, into a rig spec and a motion spec, and `build` emits a new skeleton from those.
409
+ ### 2.0 `ingest` — let the tool write them
410
+
411
+ ```bash
412
+ rigc ingest examples/spineboy/export/spineboy-ess.json --out specs/ --art none
413
+ rigc build --rig specs/rig.json --motion specs/motion.json --atlas-in examples/spineboy/export/spineboy.atlas --out spine
414
+ rigc diff spine/skeleton.json examples/spineboy/export/spineboy-ess.json
415
+ ```
385
416
 
386
- ⚠️ **Say this to the user before starting, because it is the part that surprises.**
387
- Transcription is not a conversion step you run; it *is* the work. What you get for it
388
- is that the file becomes editable by declaration — after transcription a pivot move is
389
- two numbers in a spec (§4.1) and a new animation is an added block (§4.3), where
390
- before it was a hand-edit of emitted JSON with nothing checking it.
417
+ `ingest` reads the skeleton — **only** the skeleton — and writes `rig.json`,
418
+ `motion.json` and `findings.json`. The contract is an equality rather than a
419
+ rulebook: `build(ingest(x))` is `x`, byte for byte on `skeleton.json`, and the
420
+ atlas comes back with the same region blocks (as a multiset — the page order is in
421
+ no field of the file). `bun run selftest` holds every rig this repository builds to
422
+ that on every run, which is the one gate here that compares an emitted file against
423
+ a file rigc did not write.
424
+
425
+ **What it will not do is invent.** Everything the spec format cannot hold is a
426
+ finding with a code — `BLOCK` for a construct the rebuild will be missing, `JUDGE`
427
+ for the two values a skeleton does not carry, `LOSS` for the one number rigc
428
+ re-derives on purpose. A blocker exits non-zero and still writes both files.
429
+
430
+ **Two values are not in a skeleton**, so `ingest` asks rather than guesses:
431
+
432
+ - **the stage** (`skeleton.width`/`height`) — an editor export carries none, and
433
+ `--stage x,y,w,h` is how you supply it. Posing the rig would give the *animated*
434
+ extent, which is a different number from the setup box, so it is not derived. It
435
+ is also the value that costs least to get wrong: no measure `diff` reports reads
436
+ the skeleton header at all, so an absurd box is green everywhere;
437
+ - **each animation's duration** — the format has no such field. The largest key time
438
+ is used, stated in the motion spec's `note`, and recorded as a finding per
439
+ animation. Edit it if you know the real number.
440
+
441
+ And one flag for what the skeleton also does not encode: `--art loose` (the default)
442
+ names an `image` per attachment for `build --images`, `--art none` states
443
+ `width`/`height` for `build --atlas-in`. [AUTHORING §0.3](AUTHORING.md) is the loop
444
+ in full.
445
+
446
+ 📝 Both written specs carry a `note` saying they are decompiled and naming the file
447
+ they came from. Leave it there — §2.4 is why.
448
+
449
+ ### Transcription — the route that made a foreign skeleton yours
450
+
451
+ ⚠️ **The rest of §2 is the route that existed before #569, and it is kept because
452
+ the reading it produces is still the right one** — it is what an author does *after*
453
+ `ingest`, and it is what to fall back on for the constructs `ingest` reports as
454
+ blockers. The numbers come out of the JSON into a rig spec and a motion spec by hand,
455
+ and `build` emits a new skeleton from those.
456
+
457
+ What you get for it is that the file becomes editable by declaration — a pivot move
458
+ is two numbers in a spec (§4.1) and a new animation is an added block (§4.3), where
459
+ before it was a hand-edit of emitted JSON with nothing checking it. That is now what
460
+ `ingest` hands you in one command; the sections below are how to read and change what
461
+ it hands you, and every rule in them applies to a spec `ingest` wrote.
462
+
463
+ 📌 **The cost this section used to warn about is measured, and it is why §5 changed.**
464
+ The smallest skeleton of the corpus behind [#569](https://github.com/firejune/rigc/issues/569)
465
+ transcribed to a **257,422-byte** rig spec, of which 91.8 % is the six geometry
466
+ arrays — numbers, not decisions. A 558-line prototype decompiler reproduced 100 % of
467
+ it, and the only differing paths were the name and the `note`.
391
468
 
392
469
  ### 2.1 The workflow
393
470
 
@@ -409,6 +486,32 @@ before it was a hand-edit of emitted JSON with nothing checking it.
409
486
  copying and not a detail. Leave `invariants` out entirely — it describes rigc's own
410
487
  formations, and an absent field makes an archetype assertion `SKIP`, never pass
411
488
  (AUTHORING §3.7).
489
+
490
+ 📌 **Transcribe the export's empty slots too** — the ones no skin fills anywhere.
491
+ Such a slot still holds an index in the array, and everything below it is counted
492
+ from that index. Write it as `{ "name": …, "bone": … }` with no `attachment`, or
493
+ with `"attachment": null` if you prefer to say it out loud; either way it comes
494
+ back. Before issue #575 it did not: `build` dropped it in silence, so two exports
495
+ declaring 53 and 61 slots came back at 51 and 57 with a green gate, and `diff`
496
+ read 0.962 and 0.934 against the file they had been read from. If a
497
+ transcription's `slots.count` is under 1.000, this is the first thing to check.
498
+
499
+ ⚠️ **No skeleton in `examples/` has one**, which is why the corpus never showed
500
+ this: all twelve exports fill every slot they declare from some skin. What they
501
+ *do* carry is the neighbouring shape — a slot a skin DOES fill whose setup pose
502
+ shows nothing (34 of `spineboy-pro`'s 52 slots). Both are written the same way in
503
+ the file: `attachment` simply absent.
504
+
505
+ ⚠️ **If the export's `skeleton` block carries no `x`/`y`/`width`/`height`, write
506
+ `"width": null, "height": null` and do not invent one** (issue #578). That shape is
507
+ common — the twelve exports in `examples/` all carry the four, and 37 of 37 exports
508
+ in one production corpus carry none of them — and until the `null` pair existed the
509
+ only two moves were a made-up stage or a file that could not be transcribed. The
510
+ made-up stage was the worse one: it is a number nothing in this toolchain could
511
+ contradict, so it survived every gate and every `diff` in silence. Now it does not —
512
+ `diff`'s header block reports `skeleton.stage_present` and `skeleton.stage_box`
513
+ against the source you are copying. Copy the four numbers when they are there;
514
+ state the absence when they are not.
412
515
  4. **`explain`, then `build`.** `explain` first, because it prints what you wrote in a
413
516
  shape you can compare against the export by eye (§1.5) and it never gates. Then
414
517
  `build` under `--profile spine`.
@@ -656,12 +759,22 @@ other one is this project's own renderer and archetype policy.
656
759
  | **renderer policy** (7) | `A11_NO_CLIPPING_ATTACHMENTS`, `A12_NO_DARK_COLOR`, `A13_MESH_BUDGET`, `A14_NO_FULL_FRAME_MESH`, `A15_IDLE_NO_MESH_BONE_KEYS`, `A19_OVERLAY_PNGS_HAVE_ALPHA`, `A27_REGION_NAME_MATCHES_PAGE_FILENAME` |
657
760
  | **archetype policy** (8) | `A21_MESH_RIM_PINNED`, `A24_AXIS_SPACE_STROKE`, `A25_DETACHED_BONE_PARENTAGE`, `A26_SLOT_DRAW_ORDER`, `A28_RIBBON_ROWS_SHARE_WEIGHTS`, `A29_STROKE_WITHIN_CONTACT_DEPTH`, `A30_STROKE_WITHIN_CAP_CONTAINMENT`, `A39_DEFORM_KEEPS_TRIANGLE_WINDING` |
658
761
 
659
- Three further rules — **`A06`**, **`A08`** and **`A20`** — are *mixed*: their validity
660
- clauses run in both profiles and their policy clauses only under `spine-html`. `A06`'s
762
+ Two further rules — **`A06`** and **`A20`** — are *mixed*: their validity clauses run
763
+ in both profiles and their policy clauses only under `spine-html`. `A06`'s
661
764
  size-vs-PNG check is validity; one-part-per-page coverage, rotation and premultiplied
662
- alpha are policy. `A08`'s attachment→region join is validity; requiring the two names
663
- to be *identical* is policy. `A20`'s weight coherence is validity; requiring a mesh to
664
- be weighted at all is policy.
765
+ alpha are policy. `A20`'s weight coherence is validity; requiring a mesh to be
766
+ weighted at all is policy.
767
+
768
+ **`A08` was the third until [#574](https://github.com/firejune/rigc/issues/574).** Its
769
+ policy clause required a skin entry's placeholder to be spelled exactly like the region
770
+ it resolves to — a rule the renderer it was gated under never performed, since
771
+ `spine-html` keys its images on the atlas region name reached through the attachment's
772
+ `path` and reads no placeholder at all. Measured before retiring it: the clause fired
773
+ on **0** attachments across the whole example corpus (no export in `examples/` carries
774
+ a `path` field), and on every rigc rig whose placeholder is not its PNG's basename —
775
+ which is what `path` exists for (AUTHORING §2, R5) and what a placeholder two named
776
+ skins share is emitted as. So it was policy that only ever refused this compiler's own
777
+ correct output.
665
778
 
666
779
  ⚠️ **`--profile spine-html` on foreign data produces a wall of failures that mean
667
780
  nothing about the file.** Same `spineboy-pro.json`, same atlas, one flag changed — the
@@ -918,10 +1031,14 @@ Three things to read out of that, in order:
918
1031
  something is invisible to every measure in that report. Pair it with a `check` against
919
1032
  frames rendered from the original.
920
1033
 
921
- ⚠️ **Do not rename toward what a rule seems to want.** `A08`'s name-identity clause and
922
- `A27`'s region-name-matches-page-filename are both `spine-html` policy (§3.3): under
923
- the default profile they do not fire, and renaming somebody's attachments to satisfy a
924
- policy they never opted into is a change with no benefit to them.
1034
+ ⚠️ **Do not rename toward what a rule seems to want.** `A27`'s
1035
+ region-name-matches-page-filename is `spine-html` policy (§3.3): under the default
1036
+ profile it does not fire, and renaming somebody's attachments to satisfy a policy they
1037
+ never opted into is a change with no benefit to them. `A08` carried a name-identity
1038
+ clause of the same kind until
1039
+ [#574](https://github.com/firejune/rigc/issues/574) retired it, and that one is the
1040
+ argument's own case study — the rename it seemed to want was one no renderer had ever
1041
+ asked for.
925
1042
 
926
1043
  ### 4.3 Extending a foreign skeleton with a new animation
927
1044
 
@@ -1010,12 +1127,36 @@ dependency *can* read it and rigc *does not*:
1010
1127
  that already has the reader, not a parser to write. But it is not there, and nothing on
1011
1128
  this page works on a `.skel` today. Re-export as JSON.
1012
1129
 
1013
- 🚫 **No skeleton-to-spec decompiler.** Nothing turns skeleton JSON back into a rig spec
1014
- and a motion spec. §2 is hand work, and that is the current state rather than a
1015
- temporary one: a decompiler would have to invent the things the spec format exists to
1016
- make explicit — which pivot, which generator, which invariant — and the compiler's own
1017
- rule is that it never invents a value that is not in the spec. ⚠️ Not to be confused
1018
- with the *atlas* importer below, which is a different direction and does exist.
1130
+ ✅ **A skeleton-to-spec decompiler exists: `rigc ingest` (§2.0). This entry used to
1131
+ refuse one, and all three of its reasons were measured and refuted** — issue
1132
+ [#569](https://github.com/firejune/rigc/issues/569), 2026-09-17. The paragraph is
1133
+ kept below rather than deleted, because what it got wrong is more useful than a
1134
+ clean page:
1135
+
1136
+ > 🚫 ~~**No skeleton-to-spec decompiler.** Nothing turns skeleton JSON back into a rig
1137
+ > spec and a motion spec. §2 is hand work, and that is the current state rather than a
1138
+ > temporary one: a decompiler would have to invent the things the spec format exists to
1139
+ > make explicit — **which pivot, which generator, which invariant** — and the compiler's
1140
+ > own rule is that it never invents a value that is not in the spec.~~
1141
+
1142
+ | clause | what the measurement said |
1143
+ | --- | --- |
1144
+ | *which pivot* | ⛔ **refuted.** A bone's setup transform is in the skeleton, in full. 3,951 bones across 37 production exports and 15 rigs built from this tree were transcribed with **zero** decisions, and `diff`'s six bone measures — count, names, `parent_by_name`, order, `length_present`, `inherit_present` — read **1.000** on every file that built |
1145
+ | *which generator* | ⛔ **refuted, and the premise is the error.** A decompiler must choose **no** generator. A generator is a *model* (`src/rig.ts`: *"they encode a deformation model … and a model is not a table of numbers"*); the skeleton holds geometry, and geometry is what the rig spec's authored form takes. Inferring a model would be the invention this clause feared; writing the numbers is its opposite. `gallery/look`'s four generator-built meshes came back as authored geometry and the rebuild is **byte-identical** — so a generator can always be flattened, and that is the direction the information flows |
1146
+ | *which invariant* | ⛔ **refuted by omission, and this page already said how.** §2.1 step 3: *"Leave `invariants` out entirely — an absent field makes an archetype assertion SKIP, never pass."* `ingest` writes none. A decompiled spec is 91.8 % geometry, 8.2 % structure and **0 % intent**, and it says so instead of certifying something nobody measured |
1147
+
1148
+ ⇒ **What survives is the stage, and one value is not "the things the spec format
1149
+ exists to make explicit".** The clause was not wrong that a decompiler meets an
1150
+ invention — it was wrong about *which*, and wrong that it is unavoidable: a refusal
1151
+ naming the field is what this repository does with a missing number everywhere else,
1152
+ and it is what `ingest` does here (§2.0). ⚠️ Not to be confused with the *atlas*
1153
+ importer below, which is a different direction and also exists.
1154
+
1155
+ ⚠️ **What `ingest` is still not.** It reads skeleton JSON and writes two spec files.
1156
+ It does not read a `.spine` project or a binary `.skel` (the two entries above stand
1157
+ unchanged), it does not read the atlas or the art, it does not **edit** a skeleton,
1158
+ and it makes no claim about whether an agent could have *produced* the numbers it
1159
+ copied — only that the spec can carry them and `build` reproduces the file from them.
1019
1160
 
1020
1161
  ✅ **A packer and an importer both exist now, so do not report them as gaps.** This
1021
1162
  non-goal used to read *"rigc emits one region per page and cannot do otherwise"*, and
@@ -1090,9 +1231,12 @@ something is drawn over them are readable through its own draw order and hierarc
1090
1231
 
1091
1232
  📎 To be exact about what is missing: rigc *can* lift a region's drawing back off a
1092
1233
  page — `extractRegion` does it, and the contour mesh generator uses it under
1093
- `--atlas-in` — so what is absent is a **command**, not the capability. It refuses by
1094
- name on a region packed `rotate: 90` (AUTHORING §0.2), which a foreign pack can be and
1095
- rigc's own never is.
1234
+ `--atlas-in` — so what is absent is a **command**, not the capability. Since issue
1235
+ #570 that includes a region the pack **turned** (`rotate: 90`, `180`, `270`, or the
1236
+ older `rotate: true`), which a foreign pack routinely is and rigc's own never is: the
1237
+ lift transcribes `MeshAttachment.computeUVs`, the one routine in spine-core that
1238
+ states where a turned region's texels are, so what a generator measures does not
1239
+ depend on how the art was delivered (AUTHORING §0.2).
1096
1240
 
1097
1241
  🚫 **No `validate --fix`, and no normalisation pass.** Every recipe in §4 is a change
1098
1242
  you state in a spec and rebuild. A tool that rewrote somebody's export in place would
@@ -12,7 +12,7 @@ Reproduce the measurements with `bun run fetch-examples && bun run bench:usage`
12
12
 
13
13
  > 🔼 **This note is dated and does not move; the measurements below are the corpus as it
14
14
  > stood on 2026-08-22 and stay as written. Its statements about what rigc *does* have gone
15
- > stale in six places, and the live ledger is [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md):**
15
+ > stale in the places listed here, and the live ledger is [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md):**
16
16
  >
17
17
  > - **B2 is fixed** — A16 accepts `4.3`, `4.3.N` and `4.3.N-<suffix>`, so all twelve example
18
18
  > exports pass it.
@@ -48,6 +48,18 @@ Reproduce the measurements with `bun run fetch-examples && bun run bench:usage`
48
48
  > `length`/`scale`/`shear`/`inherit`/`skin`/`color` setup fields, slot `dark`/`blend`, region
49
49
  > `path`/`scaleX`/`scaleY`/`color`, mesh `path`/`edges`/`color`, and the header's `fps` /
50
50
  > `referenceScale` / `images`. Part 2's coverage tables predate all of it.
51
+ > - **A08's name-identity clause is retired (issue #574, 2026-09-17)** — so every line below
52
+ > that calls A08 *mixed* or names it as policy describes a rule that no longer exists:
53
+ > §2.1's `region` row ("the region name == attachment name == PNG basename convention
54
+ > **A08** + **A27** enforce" — A27 still enforces its half, A08 enforces none of it),
55
+ > §2.2's classification row and its "Nine assertions are renderer-profile or
56
+ > profile-mixed" paragraph, §4.1's row (c), and §4.3's item 2. A08 is plain **validity**
57
+ > now and identical under both profiles. The measurement behind it: `spine-html` resolves
58
+ > art through the attachment's `path` and keys its images on the atlas region name, in
59
+ > every published version of it, so the clause was a rule no renderer performed — and it
60
+ > fired on **0** attachments in this corpus, because no export in `examples/` carries a
61
+ > `path` field at all. The executive summary's item 8 is the one A08 line here that was
62
+ > already right and has only got more so: A08 is a Spine-validity rule.
51
63
  >
52
64
  > **The measurements of the CORPUS (Part 3) have not moved and stay as written.**
53
65
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ingest
3
- description: Work with a Spine skeleton.json somebody else authored — exported from the Spine editor or another tool — using rigc. Read and validate it, understand a complaint rigc raised about it, transcribe it into rigc specs, normalise, re-pivot or rename it, and extend it with an animation it does not have. Use when the input is an existing skeleton.json with its .atlas and page images rather than loose part PNGs. Not for Live2D file conversion or runtime tracking.
3
+ description: Work with a Spine skeleton.json somebody else authored — exported from the Spine editor or another tool — using rigc. Read and validate it, understand a complaint rigc raised about it, decompile it into rigc specs with `rigc ingest`, normalise, re-pivot or rename it, and extend it with an animation it does not have. Use when the input is an existing skeleton.json with its .atlas and page images rather than loose part PNGs. Not for Live2D file conversion or runtime tracking.
4
4
  license: MIT
5
5
  compatibility: Requires Bun 1.2 or later and the npm package spine-rigc.
6
6
  ---
@@ -17,26 +17,48 @@ not do for you.
17
17
 
18
18
  - **Validation is never bypassed.** `validate` reads a foreign file as it is, and
19
19
  `build` writes nothing on a red gate — AUTHORING §0.
20
- - **The compiler never invents a value.** A transcription states every bone, slot
21
- and key the specs need; what the export left implicit has to be written down
22
- before it compiles — INGEST §2.
20
+ - **The compiler never invents a value, and neither does the decompiler.** The
21
+ specs state every bone, slot and key; what the export left implicit is written
22
+ down before it compiles. `rigc ingest` obeys the same rule from the other side —
23
+ what it cannot read out of the skeleton is a named **finding**, never a guess —
24
+ INGEST §2.0 and §2.
23
25
  - **The validator's messages are the instructions.** A red line on an export is a
24
26
  fact about the file, and sometimes about the rule — INGEST §3 says which, and
25
27
  AUTHORING §5 names the file to change.
26
28
 
29
+ ## Start here: `rigc ingest`
30
+
31
+ ```bash
32
+ rigc ingest hero.json --out specs/ --stage 0,0,1024,768
33
+ rigc build --rig specs/rig.json --motion specs/motion.json --images parts/ --out build/
34
+ ```
35
+
36
+ It reads the skeleton — **only** the skeleton — and writes the rig spec and motion
37
+ spec that rebuild it, byte for byte. Two values are not in the file and it refuses
38
+ rather than guessing them: the **stage** (`--stage`, an editor export carries none)
39
+ and each animation's **duration** (the largest key time, recorded as a finding).
40
+ Read `findings.json`: a `BLOCK` line means the rebuild will be missing something and
41
+ the command exits non-zero. Keep the `note` both specs carry. INGEST §2.0.
42
+
27
43
  ## What this guide will not do
28
44
 
29
- rigc cannot write a skeleton back. There is no command that edits a `skeleton.json`
30
- and no route from it to specs, so every change goes through transcription — INGEST
31
- §0 and §2. `diff`'s ratios say how much of a reference's structure a candidate
32
- reproduces, so extending or renaming a foreign skeleton lowers them by design, and
33
- neither `validate` nor `diff` has a pass bar — INGEST §0 and §4.
45
+ rigc still cannot **edit** a skeleton: there is no command that opens one and
46
+ changes it, so every change is expressed in the specs — INGEST §0. `diff`'s ratios
47
+ say how much of a reference's structure a candidate reproduces, so extending or
48
+ renaming a foreign skeleton lowers them by design, and neither `validate` nor `diff`
49
+ has a pass bar — INGEST §0 and §4.
50
+
51
+ ⚠️ This section said *"there is no route from it to specs, so every change goes
52
+ through transcription"* until 2026-09-17. That route exists now and it is the first
53
+ thing to reach for; transcription by hand is what you fall back on for a construct
54
+ `ingest` reports as a blocker.
34
55
 
35
56
  ## Read, in this order
36
57
 
37
58
  1. [INGEST.md](../../docs/INGEST.md) — what every command will and will not do with
38
- a foreign file (§0), transcription (§2), what each validator complaint means on
39
- an export (§3), and the re-pivot, rename and extend recipes (§4).
59
+ a foreign file (§0), `ingest` and transcription (§2), what each validator
60
+ complaint means on an export (§3), and the re-pivot, rename and extend
61
+ recipes (§4).
40
62
  2. [AUTHORING.md](../../docs/AUTHORING.md) — the two spec files the transcription
41
63
  targets (§3–§4), the failure map (§5–§6), and the coordinate contract (§11).
42
64
  3. Then [RIGGING.md](../../docs/RIGGING.md) for why the re-pivot edit has the shape
package/src/atlas.ts CHANGED
@@ -919,25 +919,69 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
919
919
  * a plate's rows run downwards, so the kept rectangle's top row is
920
920
  * `originalHeight - offsetY - height`.
921
921
  *
922
- * ⛔ A rotated region is refused rather than guessed. `TextureAtlas` transposes
923
- * `u2/v2` at 90 and not at 270, and `RegionAttachment.computeUVs` assigns a
924
- * different corner order at 90 — there are already three opinions in the runtime
925
- * about that mapping and this file is not going to be a fourth. rigc's own packer
926
- * never rotates (`PACK_NO_ROTATE`), so only a foreign atlas can reach this.
922
+ * ## A rotated region is TRANSCRIBED, not guessed at (issue #570)
923
+ *
924
+ * This refused a rotated region until 2026-09-17, on the argument that the
925
+ * runtime holds "three opinions" about the mapping. Measurement refutes the
926
+ * argument: the three are not three readings of one mapping, they are one
927
+ * mapping and two places that do not implement it.
928
+ *
929
+ * * `MeshAttachment.computeUVs` (spine-core 4.3.13,
930
+ * `dist/attachments/MeshAttachment.js:126-162`) is the one routine that
931
+ * states where a region's texels are for **all four** `degrees`, and it is
932
+ * the routine `substituteTexture` in [`src/render.ts`](render.ts) already
933
+ * goes through. The loop below is its inverse, term for term;
934
+ * * `TextureAtlas`'s `u2`/`v2` (`dist/TextureAtlas.js:164-171`) transpose the
935
+ * rectangle at 90 and not at 270, so at 270 they describe a rectangle the
936
+ * page does not have — but `MeshAttachment.computeUVs` never reads them for
937
+ * an atlas region, and neither does this;
938
+ * * `RegionAttachment.computeUVs` (`dist/attachments/RegionAttachment.js:156-167`)
939
+ * assigns the turned corner order at 90 and at nothing else, which is a
940
+ * region-attachment rendering defect (issue #199) and not a statement about
941
+ * where the drawing sits.
942
+ *
943
+ * Inverting the runtime's own expression on texel centres puts kept-rectangle
944
+ * pixel `(x, y)` — `x` from the drawing's left, `y` down from `top` — at page
945
+ * pixel `(X + x, Y + y)` unturned, `(X + y, Y + width - 1 - x)` at 90,
946
+ * `(X + width - 1 - x, Y + height - 1 - y)` at 180 and
947
+ * `(X + height - 1 - y, Y + x)` at 270, writing `X`/`Y` for the region's own
948
+ * `x`/`y`; the packed footprint is `height x width` for the two quarter turns
949
+ * and `width x height` for the other two. `repackRotatedTrimmed` in
950
+ * `selftest.ts` derived the same 270 mapping for issue #199's fixture, and had
951
+ * been shipping it green, while this comment claimed the mapping was unknowable.
952
+ *
953
+ * ⚠️ Any other `degrees` takes the unturned branch, because that is what the
954
+ * runtime does with it: `regionFields.rotate` (`dist/TextureAtlas.js:87-93`)
955
+ * `parseInt`s the value without checking it, and `computeUVs` falls to
956
+ * `default:` for everything that is not 90, 180 or 270. Reading such a region
957
+ * unturned is not a guess, it is agreement with the thing that will draw it.
958
+ *
959
+ * rigc's own packer still never rotates (`PACK_NO_ROTATE`), so only a foreign
960
+ * atlas reaches any branch but the first.
927
961
  */
928
962
  export function extractRegion(page: Plate, region: AtlasRegion): Plate {
929
- if (region.degrees !== 0) {
930
- throw new CompileError(
931
- `region "${region.name.trim()}" is packed rotate: ${region.degrees}; reading a drawing back off a rotated ` +
932
- 'region is not implemented — rigc\'s own packer never rotates, so this is a foreign pack. Supply the loose ' +
933
- 'PNG instead of --atlas-in for the part that needs measuring.',
934
- );
935
- }
936
963
  const out = new Plate(region.originalWidth, region.originalHeight);
937
964
  const top = region.originalHeight - region.offsetY - region.height;
965
+ const { degrees } = region;
938
966
  for (let y = 0; y < region.height; y++) {
939
967
  for (let x = 0; x < region.width; x++) {
940
- out.set(region.offsetX + x, top + y, page.get(region.x + x, region.y + y));
968
+ const px =
969
+ degrees === 90
970
+ ? region.x + y
971
+ : degrees === 180
972
+ ? region.x + region.width - 1 - x
973
+ : degrees === 270
974
+ ? region.x + region.height - 1 - y
975
+ : region.x + x;
976
+ const py =
977
+ degrees === 90
978
+ ? region.y + region.width - 1 - x
979
+ : degrees === 180
980
+ ? region.y + region.height - 1 - y
981
+ : degrees === 270
982
+ ? region.y + x
983
+ : region.y + y;
984
+ out.set(region.offsetX + x, top + y, page.get(px, py));
941
985
  }
942
986
  }
943
987
  return out;
package/src/check.ts CHANGED
@@ -863,6 +863,24 @@ export interface CheckReport {
863
863
  candidate: { skeleton: string; atlas: string };
864
864
  framesDir: string;
865
865
  framesRoot: string;
866
+ /**
867
+ * The skin the CANDIDATE was posed under, or `null` for no skin at all.
868
+ *
869
+ * ⭐ In the report rather than only in the run's arguments because a figure is
870
+ * only readable beside what produced it: on a multi-skin rig the same
871
+ * candidate and the same frames give a different number per skin, and a
872
+ * `check.json` that did not say which one it was is a number with no subject
873
+ * (issue #571).
874
+ */
875
+ skin: string | null;
876
+ /**
877
+ * The skin `frames.json` records for the reference frames, or `null`.
878
+ *
879
+ * `null` covers two facts that are the same on disk — the frames set no skin,
880
+ * and the frames were rendered before the field existed — which is why a
881
+ * mismatch against it is refused and an absence is only noted. See `notes`.
882
+ */
883
+ referenceSkin: string | null;
866
884
  /** One framing per set, or one across every set — see `FramingScope`. */
867
885
  framingScope: FramingScope;
868
886
  /**
@@ -947,6 +965,17 @@ export interface CheckOptions {
947
965
  viewport?: { x: number; y: number; width: number; height: number };
948
966
  /** Play this candidate animation against the frames, when the names differ. */
949
967
  as?: string;
968
+ /**
969
+ * Pose the candidate under this skin — see `PoseOptions.skin` (issue #571).
970
+ *
971
+ * Absent sets no skin, which resolves every slot through the default skin
972
+ * alone: on a multi-skin rig that draws none of the art the named skins carry,
973
+ * and a `check` of it compares blank against blank and reads 0.0000. A name
974
+ * the candidate does not declare is refused with the ones it does, and the
975
+ * reference frames' own recorded skin is checked against this — see the
976
+ * `referenceSkin` field of `CheckReport` for what an absent record means.
977
+ */
978
+ skin?: string;
950
979
  /**
951
980
  * Fit one framing per frame set, or one across every set compared.
952
981
  *
@@ -1026,7 +1055,20 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1026
1055
  const substitution = options.textureFrom
1027
1056
  ? textureSubstitutionFromText(options.textureFrom.atlasText, options.textureFrom.atlasDir)
1028
1057
  : null;
1029
- const poseOptions: PoseOptions | undefined = substitution ? { texture: true } : undefined;
1058
+ // The skin is refused here rather than deeper in the sampler, for the reason
1059
+ // every miss in this project is refused where the names are: the skeleton is
1060
+ // open on this line and the alternatives can be listed.
1061
+ if (options.skin !== undefined && !posable.data.skins.some((s) => s.name === options.skin)) {
1062
+ throw new CheckError(
1063
+ `the candidate declares no skin ${JSON.stringify(options.skin)}; it declares [${
1064
+ posable.data.skins.map((s) => s.name).join(', ') || 'none'
1065
+ }]`,
1066
+ );
1067
+ }
1068
+ const poseOptions: PoseOptions | undefined =
1069
+ substitution || options.skin !== undefined
1070
+ ? { ...(substitution ? { texture: true } : {}), ...(options.skin === undefined ? {} : { skin: options.skin }) }
1071
+ : undefined;
1030
1072
  let background: RGBA;
1031
1073
  let sets: FrameSet[];
1032
1074
  let pixelWidth: number;
@@ -1086,6 +1128,35 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1086
1128
 
1087
1129
  if (sets.length === 0) throw new CheckError(`no frame set to compare in ${options.framesDir}`);
1088
1130
 
1131
+ // --- the skin the frames were rendered under, against the one asked for ----
1132
+ //
1133
+ // ⭐ The asymmetry is the honest part (issue #571). A sidecar that RECORDS a
1134
+ // skin is a claim, and a claim that disagrees is refused by name; a sidecar
1135
+ // that records none is making no claim at all — it either set no skin or was
1136
+ // written before the field existed, and those are the same bytes — so the run
1137
+ // proceeds and says out loud that nothing checked it. Inventing a refusal out
1138
+ // of an absent field would refuse every frame set in this repository.
1139
+ const referenceSkin = located.sidecar?.skin ?? null;
1140
+ if (referenceSkin !== null && referenceSkin !== options.skin) {
1141
+ throw new CheckError(
1142
+ `${FRAMES_SIDECAR} records that these frames were rendered under skin ${JSON.stringify(referenceSkin)}, and ` +
1143
+ `this run poses the candidate ${
1144
+ options.skin === undefined
1145
+ ? 'under no skin at all (the default skin alone)'
1146
+ : `under skin ${JSON.stringify(options.skin)}`
1147
+ }. Two skins are two different pictures of one rig, so the comparison would be a number about the ` +
1148
+ `difference between them. Pass --skin ${JSON.stringify(referenceSkin)}, or render the reference frames ` +
1149
+ `${options.skin === undefined ? 'with no --skin' : `with --skin ${JSON.stringify(options.skin)}`}.`,
1150
+ );
1151
+ }
1152
+ if (referenceSkin === null && options.skin !== undefined) {
1153
+ notes.push(
1154
+ `the candidate is posed under skin ${JSON.stringify(options.skin)} and the reference frames record no skin ` +
1155
+ `at all, so nothing here could check that they are the same picture. A frame set rendered by \`rigc ` +
1156
+ `render --skin\` since #571 carries the name in ${FRAMES_SIDECAR} and this run would have compared it.`,
1157
+ );
1158
+ }
1159
+
1089
1160
  // Pose every set once. Its frames are wanted twice — to frame the candidate and
1090
1161
  // to compare it — and posing twice is both slower and a chance for the framing
1091
1162
  // and the comparison to disagree about what they measured.
@@ -1347,6 +1418,8 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1347
1418
  },
1348
1419
  framesDir: resolve(options.framesDir),
1349
1420
  framesRoot: located.root,
1421
+ skin: options.skin ?? null,
1422
+ referenceSkin,
1350
1423
  framingScope: scope,
1351
1424
  framing: topHow,
1352
1425
  viewport: topViewport === null ? null : framingOfViewport(topViewport),
@@ -3117,6 +3190,15 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
3117
3190
  lines.push(` candidate ${report.candidate.skeleton}`);
3118
3191
  lines.push(` atlas ${report.candidate.atlas}`);
3119
3192
  lines.push(` frames ${report.framesDir}`);
3193
+ // Always printed, on both sides, because the reading a reader has to be able
3194
+ // to make is "which picture of this rig is this" — and a line that appears
3195
+ // only when a skin was named cannot say that the run used none (issue #571).
3196
+ lines.push(
3197
+ ` skin candidate ${report.skin === null ? 'no skin set (the default skin alone)' : report.skin} ` +
3198
+ `frames ${
3199
+ report.referenceSkin === null ? `no skin recorded in ${FRAMES_SIDECAR}` : report.referenceSkin
3200
+ }`,
3201
+ );
3120
3202
  lines.push(
3121
3203
  ` scope ${
3122
3204
  report.framingScope === 'per-shot'