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/README.md +64 -1
- package/cli.ts +205 -11
- package/docs/AUTHORING.md +357 -54
- package/docs/INGEST.md +185 -41
- package/docs/SPEC_COVERAGE.md +13 -1
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +33 -11
- package/src/atlas.ts +57 -13
- package/src/check.ts +83 -1
- package/src/compile.ts +284 -58
- package/src/diff.ts +125 -2
- package/src/ingest.ts +1078 -0
- package/src/render.ts +104 -10
- package/src/rig.ts +92 -8
- package/src/types.ts +39 -7
- package/src/validate.ts +205 -27
- package/tools/editor_roundtrip.ts +172 -21
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
|
|
54
|
-
`skeleton.json` path directly, and none of them needs a rig
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
skeleton is `build`, and `build`'s
|
|
58
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
257
|
-
`x`/`y`/`rotation`, an attachment's offset, or a key's value —
|
|
258
|
-
*names*, *counts*, *order* and *kinds*. §4.1 moves a pivot 236.5
|
|
259
|
-
of the 49 measures still reads **1.000**. ⇒ Never take a green
|
|
260
|
-
a geometric edit did not land, and never take it as evidence
|
|
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.
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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. `
|
|
663
|
-
|
|
664
|
-
|
|
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.** `
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
1014
|
-
and
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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.
|
|
1094
|
-
|
|
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
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -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
|
|
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.
|
|
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": {
|
package/skills/ingest/SKILL.md
CHANGED
|
@@ -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,
|
|
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
|
|
21
|
-
|
|
22
|
-
before it compiles
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
an export (§3), and the re-pivot, rename and extend
|
|
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
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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'
|