spine-rigc 2.1.3 → 2.2.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
@@ -622,7 +622,10 @@ example corpus, ingested, rebuilt through the pack beside it and `diff`ed agains
622
622
  source, comes back **12 of 12, no blockers, 1.000 on every measure the report
623
623
  carries** — and each rebuild is its export's own text in canonical form, apart from
624
624
  the header's `hash` and `spine`: the editor's project hash and the runtime version
625
- rigc stamps, the two keys the rig spec has no field for by design (12 of 12).
625
+ rigc stamps, the two keys the rig spec has no field for by design (12 of 12) — and
626
+ the header's box, which `build` computes rather than carries (issue #907): the rebuild
627
+ writes spine-core's `getBounds` over the export on the header's 1e-6 grid at float32, equal on all twelve,
628
+ where the editor wrote its own arithmetic, up to 0.0071 units away.
626
629
  [INGEST.md §2.3](docs/INGEST.md) states that pass line and why those two are the
627
630
  exceptions.
628
631
 
@@ -643,7 +646,10 @@ specs are still written — a spec plus a list of what is missing from it beats
643
646
  too**, for the opposite reason: two sources for one value, where the file is the record
644
647
  of what was measured. **All twelve exports in the example corpus carry a stage** and
645
648
  none of them needs the flag. It is the value that costs least to get wrong, because
646
- `diff` reports the box and gates nothing on it.
649
+ `diff` reports the box and gates nothing on it. 🔁 The box an export's header carries
650
+ is its setup-pose bounding box, and it becomes the rebuild's *stage*; the rebuild's own
651
+ header is computed again, because since issue #907 `build` writes the setup-pose
652
+ bounding box there — never the stage — held to spine-core's `getBounds`.
647
653
  - **An animation's duration.** The format has no such field. The largest key time is
648
654
  the only derivable answer and it is what a runtime plays to; it is wrong for an
649
655
  animation that holds its last pose past its last key, so it is recorded as a finding
package/docs/AUTHORING.md CHANGED
@@ -947,7 +947,7 @@ repository builds on every run.
947
947
  | `--art loose` (default) | name an `image` per attachment — `<path or placeholder>.png` — so the rebuild resolves loose PNGs and rigc measures them |
948
948
  | `--art none` | state `width`/`height` only, so the rebuild is `build --atlas-in <pack.atlas>` and every part resolves out of the pack |
949
949
  | `--images <dir>` | **write** the rig spec's own `images` directory, spelled relative to `--out`, so the rebuild is a plain `build --rig … --motion … --out …`. Without it the field is left out and every `image` resolves against `--out` itself, which holds the specs and no art — so every rebuild has to repeat `build --images <dir>`. Refused together with `--art none`, which writes no `image` for it to be the base of |
950
- | `--stage x,y,w,h` | a setup bounding box to **add** to a skeleton that declares none — without it the absence is carried as `"width": null, "height": null`. An editor export *may* be such a file; every export under `examples/` carries a box and `ingest` reads it straight through — so passing the flag at one of them is **refused**, naming both boxes, rather than silently doing nothing |
950
+ | `--stage x,y,w,h` | a stage to **add** to a skeleton whose header carries no box (a header's box — its setup-pose bounding box — is otherwise read as the rebuild's stage) — without it the absence is carried as `"width": null, "height": null`. An editor export *may* be such a file; every export under `examples/` carries a box and `ingest` reads it straight through — so passing the flag at one of them is **refused**, naming both boxes, rather than silently doing nothing |
951
951
  | `--name <n>` | the rig spec's `name`, which the motion spec's `archetype` must equal (default: the file's basename) |
952
952
 
953
953
  ⚠️ **`ingest --images` and `build --images` point opposite ways.** `build --images`
@@ -968,23 +968,40 @@ supply it from the project the file came from, or from the editor's own canvas
968
968
  is recorded as a `NO_STAGE` judgement. A header stating **half** a stage (an origin
969
969
  with no extent) is a `NO_STAGE` blocker: the spec holds a stage as four fields or none.
970
970
 
971
+ 🔁 **Where the rebuild's stage comes from** (issue #907). A **rigc build**'s stage is read
972
+ from the `skeleton.model.json` beside it, when that document's `spine.sha256` is the
973
+ skeleton's digest (the run prints `stage … read from the skeleton.model.json beside it`) —
974
+ its header is the setup-pose bounding box, not the stage. An **export**'s stage is its
975
+ header box. A file's header box is its setup-pose bounding box, and for an export `ingest`
976
+ writes it into the spec as the rebuild's **stage** — the only box an export carries, so `A14` and `A19` on the rebuild measure against it, as
977
+ they did on the export. The rebuild's own header is then not carried but computed:
978
+ `build` writes the bounding box of what it drew, which is `getBounds` of the source at
979
+ float32 when the rebuild draws the source's vertices — on the twelve example exports it
980
+ does, to the bit — and the editor's numbers are its own arithmetic, up to 0.0071 units
981
+ away. So the four header numbers of a rebuild are not the export's spelling, and that
982
+ is not a loss: the export's box is in the spec, as the stage. (A licensed 4.3.26
983
+ editor's command-line import and export carried a header box through **verbatim** —
984
+ a forged `0, 0, 1, 1` came back as `width 1, height 1` — so the editor does not
985
+ recompute it on a round trip either; whatever box a file states travels.)
986
+
971
987
  ⛔ **The flag is refused beside a box the file states.** Two sources for one value, and
972
988
  the file is the one that was measured — so `ingest` names both boxes and stops rather
973
989
  than writing one of them and saying nothing. Drop the flag, or correct `skeleton` in the
974
990
  source if its box is wrong. What it does **not** do is refuse an *omitted origin*:
975
991
  inside a declared extent an omitted `x`/`y` is `0` — the reading `build` emits and
976
992
  `diff` compares — so the written spec states it and a `LOSS HEADER_ORIGIN` line says
977
- the source omitted it and that the rebuild will spell it.
993
+ the source omitted it and that the rebuilt header will spell an origin — the bounding
994
+ box's, wherever the art sits.
978
995
 
979
996
  ⚠️ **All twelve exports under `examples/` declare `x`, `y`, `width` and `height`**,
980
- `ingest` reads each box straight through, and not one needs the flag. What an
997
+ `ingest` reads each box straight through into the stage, and not one needs the flag. What an
981
998
  editor export *may* do is carry none: a rigc build that declares no stage came back
982
999
  from a Spine 4.3.26 round trip with a header of `hash`, `spine`, `images`, `audio`
983
1000
  and **no box at all** — the editor preserves the absence rather than inventing a
984
1001
  stage. So `--stage` is for a file that really has none; the example corpus holds no
985
1002
  such file, and production exports do — 48 of 48 measured. It is the value that
986
- costs least to get wrong: `diff` reports the box as `stage_present` and
987
- `stage_box` and both are `(reported)`, so nothing on the ladder consults them.
1003
+ costs least to get wrong: `diff` reports the header's box as `bounds_present` and
1004
+ `bounds_box` and both are `(reported)`, so nothing on the ladder consults them.
988
1005
 
989
1006
  ⚠️ **The duration is a convention, and it is recorded as one.** Skeleton JSON has no
990
1007
  duration field. The largest key time is the only derivable answer and it is what a
@@ -1530,8 +1547,8 @@ that is a defect in this guide: report it.
1530
1547
 
1531
1548
  | Field | Spine meaning | Default |
1532
1549
  | --- | --- | --- |
1533
- | `x`, `y` | setup-pose bounding box origin | `0` — and refused outright beside a stated absence, below |
1534
- | `width`, `height` | setup-pose bounding box size, **or both `null` for "this skeleton declares no stage"** | falls back to the manifest's crop; **with neither the number nor the `null`, the compile fails** |
1550
+ | `x`, `y` | the **stage**'s origin — rigc's own field: see below | `0` — and refused outright beside a stated absence, below |
1551
+ | `width`, `height` | the **stage**'s size, **or both `null` for "this skeleton declares no stage"** | falls back to the manifest's crop; **with neither the number nor the `null`, the compile fails** |
1535
1552
  | `fps` | nonessential editor hint | `SkeletonData.fps` stays 30 |
1536
1553
  | `referenceScale` | 4.2+ physics/scale reference: a physics constraint's `wind` and `gravity` act over it | parser default 100. Carried as stated — never rounded — and the header leaves it out at exactly 100. `skeleton.model.json` always states it, as `referenceScale` after `spec`: the number stated, or 100 when none is — the value the Spine file is read as — and rigc's posing core steps wind and gravity over it (issue #958) |
1537
1554
  | `images` | where the editor's import looks for the part PNGs, as a path from the skeleton file | **written for you**: under `--copy-images` the `--out` directory itself, spelled `../<its basename>/` (a literal `./` is dropped by the editor on import; a named directory is kept and every part is found — measured on 4.3.23); otherwise the relative path from `--out` to the one directory the spec names every part PNG in (the rig's images directory, or the manifest's plates). A declared value is carried through verbatim — and overridden by `--copy-images`, which moved the parts. Parts spread over several directories have no single true path, so nothing is written |
@@ -1547,6 +1564,33 @@ links, not the editor that will open the file, and the warning is harmless.
1547
1564
  `width`/`height` are what `A14` and `A19` measure against, so a guessed stage is a
1548
1565
  gate measuring against a number nobody wrote down.
1549
1566
 
1567
+ 🔁 **The stage is not what `skeleton.json`'s header carries** (issue #907). In the
1568
+ Spine format the header's `x`, `y`, `width`, `height` are the **setup-pose bounding
1569
+ box** — the box around the attachments at rest, which a player reads to scale and lay
1570
+ the figure out. rigc's stage is something else: the working area the art was painted
1571
+ in (a manifest's crop), the frame the coordinate transform flips against, and what
1572
+ `A14`/`A19` measure against. Until issue #907 `build` copied the stage into the header,
1573
+ so a consumer reading the file the way the format says got the crop — 3.18 times the
1574
+ figure's area on a figure that fills a quarter of its crop. Now:
1575
+
1576
+ - **the header** carries the setup-pose bounding box, which `build` computes with
1577
+ rigc's own core: every region and mesh the default skin shows at the setup pose, no
1578
+ skin set, every constraint applied, on active bones only — clipping polygons,
1579
+ bounding boxes, paths and points not counted — `x`/`y` the least corner (bottom-left,
1580
+ y up), each of the four on the model's 1e-6 grid at float32 (a box edge at the origin
1581
+ is otherwise a cancellation residue such as `1.1368684e-13` that follows the platform's
1582
+ libm to the last bit). It is held to spine-core's
1583
+ `Skeleton.getBounds()` at tolerance 0 (`tools/core_gate.ts`, its `BOUNDS` rows), so
1584
+ both entries write the same bytes;
1585
+ - **the stage** stays the rig spec's (or the crop), and `skeleton.model.json` states it
1586
+ as `stage` — `A14`, `A19` and `explain` read it there, on `build` and on `validate`
1587
+ over a directory whose model document is the skeleton's own;
1588
+ - **no box** is written where the rig declares no stage (`null`, below), where the
1589
+ setup pose draws nothing, or where a region has no atlas rectangle (that build is
1590
+ refused by `A08` anyway).
1591
+
1592
+ You do not author the box and there is no field for it: it is a measurement of the rig.
1593
+
1550
1594
  ⭐ **A skeleton may declare no stage, and saying so is not the same as saying
1551
1595
  nothing.** Write the pair as `null`:
1552
1596
 
@@ -1554,8 +1598,9 @@ nothing.** Write the pair as `null`:
1554
1598
  "skeleton": { "width": null, "height": null }
1555
1599
  ```
1556
1600
 
1557
- and the emitted header carries **none** of `x`/`y`/`width`/`height` — which is
1558
- what an export of a skeleton whose stage was never set looks like. `null` is this
1601
+ and the emitted header carries **none** of `x`/`y`/`width`/`height` — the input
1602
+ that asked for no box gets none, which is also what an export of a skeleton whose
1603
+ bounds were never set looks like. `null` is this
1559
1604
  spec's spelling for a stated absence wherever it has one (`slots[].attachment` is
1560
1605
  `null` for "show nothing").
1561
1606
 
@@ -1574,32 +1619,34 @@ would be:
1574
1619
 
1575
1620
  | Reader | With a stage | Without one |
1576
1621
  | --- | --- | --- |
1577
- | `build` | emits `x`/`y`/`width`/`height` | emits none of them |
1622
+ | `build` | emits `x`/`y`/`width`/`height` — the setup-pose bounding box, not the stage | emits none of them |
1578
1623
  | `A14_NO_FULL_FRAME_MESH` | fails a mesh as big as the stage | **SKIP**, by name |
1579
1624
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | exempts the base plate a cut manifest names — the part whose window is the crop — and, on a build that names none, the one image that covers the stage | a manifest build exempts the plate it names, exactly as with one. A rig-spec build names no base plate, so an opaque part is refused with *"this skeleton declares no stage size to measure one against, and the build names no base plate"* and the two ways to decide it: a `skeleton` stage the plate covers, or *"build from a cut manifest, whose base plate is the part whose window is the crop"* |
1580
- | `diff` | `stage_present` and `stage_box` | `stage_present` 1/1 when neither side declares one (agreement), `stage_box` 0/0 |
1625
+ | `diff` | `bounds_present` and `bounds_box` — the header's box, which is the bounding box | `bounds_present` 1/1 when neither header carries one (agreement), `bounds_box` 0/0 |
1581
1626
  | `explain` | `stage W x H` | `stage none declared` |
1582
1627
  | `render` | frames the posed extent of every animation | the same frames, plus a line saying the viewport is the posed extent and no stage |
1583
1628
  | `preview` | the Spine Web Player frames the posed extent of the animation it plays | the same, plus the same line |
1584
1629
  | `check` | fits the candidate's world box from what it draws | the same figures, plus a note saying so |
1585
1630
  | `build --pack` | never reads it | the same pages and atlas |
1586
- | `ingest` | reads it straight through | writes `"width": null, "height": null`; `--stage` adds one |
1631
+ | `ingest` | reads the header's box into the rebuild's stage — an export carries no other box, so its bounding box becomes the rebuild's stage, and the rebuild's header is computed again | writes `"width": null, "height": null`; `--stage` adds one |
1587
1632
 
1588
1633
  ⚠️ A stage-less skeleton is **unmeasured, not certified**: `A14_NO_FULL_FRAME_MESH`
1589
1634
  reports **SKIP** on one, because there is no full frame for a mesh to span. And
1590
- `rigc diff` reports it — `skeleton.stage_present` and `skeleton.stage_box`, in the
1591
- header block at the top of the report — so a stage somebody invented reads
1592
- below 1.000 against a source that has none.
1635
+ `rigc diff` reports the header's box — `skeleton.bounds_present` and
1636
+ `skeleton.bounds_box`, in the header block at the top of the report — so a build that
1637
+ carries a box reads below 1.000 against a source that carries none.
1593
1638
 
1594
- ⭐ **A stage at `0,0` is not a stage-less one, and an editor export spells it by
1639
+ ⭐ **A box at `0,0` is not a box-less header, and an editor export spells it by
1595
1640
  saying nothing.** The editor omits a header field that is at its default, so a
1596
1641
  skeleton whose box sits at the origin exports as a `width` and a `height` with no
1597
- `x`/`y`; `stage_box` reads that omission as the `0` it means, and its line says so
1598
- — *the extent as stated, an omitted origin as the 0 it means*. So `4/4` on a build
1599
- of yours against an export of that same build is the right answer rather than a
1600
- tolerance, and an origin that really did move still reads below 1.000. The extent
1601
- is the half that is read exactly as stated: omit a `width` and you have declared no
1602
- stage, which `stage_present` is the measure of. Both are reported and gate nothing, for
1642
+ `x`/`y`; `bounds_box` reads that omission as the `0` it means, and its line says so
1643
+ — *the extent as stated, an omitted origin as the 0 it means*. The box is compared
1644
+ exactly: rigc's is `getBounds` on the header's 1e-6 grid at float32, the editor's is its own arithmetic, which
1645
+ on the twelve example exports sits up to 0.0071 units from `getBounds` over the same
1646
+ file — so a rebuild of an export reads below 4/4 here by the editor's arithmetic
1647
+ alone, and that is a finding about two writers rather than a moved rig. The extent
1648
+ is the half that is read exactly as stated: omit a `width` and the header carries no
1649
+ box, which `bounds_present` is the measure of. Both are reported and gate nothing, for
1603
1650
  the reason every reported measure is: no reading of the rendered frames could have
1604
1651
  decided a setup-pose bounding box. The measure inventory that says so lives in
1605
1652
  [BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md), which
package/docs/INGEST.md CHANGED
@@ -217,7 +217,8 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
217
217
 
218
218
  `diff` takes two compiled skeletons and reports 54 measures in eight groups, plus two
219
219
  blocks that report and gate nothing: the `(reported)` measures beside `attachments`
220
- and `animations`, and the `skeleton` header block at the top, which measures the stage.
220
+ and `animations`, and the `skeleton` header block at the top, which measures the header's setup-pose
221
+ bounding box.
221
222
  A ninth group of six joins them when something has paired the two sides'
222
223
  animations — `--as <candidate>=<reference>`, or one animation each side, which pairs by
223
224
  position (§1.3.1). Both sides may be foreign; the interesting pairing during ingest is
@@ -234,9 +235,9 @@ rigc diff
234
235
  reference …/examples/3-timing-and-spacing/export/3-timing-and-spacing-ess.json
235
236
  .. bones=3/3 slots=2/2 skins=1/1 attachments=2/2 constraints=0/0 animations=2/2 events=0/0 (candidate/reference)
236
237
 
237
- skeleton (reported) (no mean) over 2 measures — the stage, which no reading of the frames could decide
238
- 1.000 stage_present 1/1 both sides declare a setup-pose stage, or neither does — …
239
- 1.000 stage_box 4/4 the stage is the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means) — …
238
+ skeleton (reported) (no mean) over 2 measures — the header's setup-pose bounding box, which no reading of the frames could decide
239
+ 1.000 bounds_present 1/1 both headers carry a setup-pose bounding box, or neither does — …
240
+ 0.250 bounds_box 1/4 the header carries the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means) — …
240
241
 
241
242
  bones mean 1.000 over 8 measures
242
243
  1.000 count 3/3 how many bones
@@ -277,20 +278,21 @@ both files through `spine-core`, and a skeleton whose attachments carry a `seque
277
278
  cannot be parsed without the atlas that resolves it — so the measure takes two
278
279
  skeletons **and two packs**, which `rigc diff <a.json> <b.json>` does not have.
279
280
 
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 *declared in 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.
281
+ ⚠️ **The one exception is the header's own box**, and it is an exception to the
282
+ sentence and not to the rule: `skeleton.bounds_box` compares four world numbers — the
283
+ setup-pose bounding box each writer put in its header (rigc's is spine-core's
284
+ `getBounds` on the header's 1e-6 grid at float32 since issue #907; the editor's is its own arithmetic, up to
285
+ 0.0071 units from `getBounds` on the twelve examples) — exactly, and the block they
286
+ sit in gates nothing. The measure was called `stage_box` until #907, when `build`
287
+ stopped writing the stage there.
285
288
 
286
289
  ⭐ **Declared is not the same as written down, and for the origin it is the
287
- difference between a green round trip and a false finding.** The editor omits a header
288
- field at its default, so a stage sitting at `0,0` exports as a `width` and a
289
- `height` and no `x`/`y` at all — there is no other spelling for it. The measure
290
- reads that omission as the `0` it means, so a rigc build whose stage is at the
291
- origin and the editor's export of that build read `stage_box` **4/4**. The extent is still read
292
- exactly as stated: it is what decides whether there is a stage at all, so a missing
293
- `width` is an absent stage rather than a stage of width zero.
290
+ difference between a true reading and a false finding.** The editor omits a header
291
+ field at its default, so a box sitting at `0,0` exports as a `width` and a `height`
292
+ and no `x`/`y` at all — there is no other spelling for it. The measure reads that
293
+ omission as the `0` it means. The extent is still read exactly as stated: it is what
294
+ decides whether there is a box at all, so a missing `width` is an absent box rather
295
+ than a box of width zero.
294
296
 
295
297
  ⛔ **And its ratios are not a score.** [`src/diff.ts`](../src/diff.ts) says so in the
296
298
  type itself (*"Unweighted mean of the measures below. NOT a quality score"*), and the
@@ -509,12 +511,17 @@ at the policy rather than implying the file was read.
509
511
  is the file that was read, byte for byte. No finding is recorded, because nothing
510
512
  was lost and nobody decided anything. `--stage x,y,w,h` is how a caller *adds* a box
511
513
  to such a file, and that is a `NO_STAGE` **judgement**. All twelve exports in the
512
- fetched corpus carry a stage, and `ingest` reads it straight through. The box cannot be
513
- *derived* — posing the rig gives the *animated* extent, which is a different number
514
- from the setup box. 🔸 **Half a stage is a `NO_STAGE` blocker**: an origin with no
514
+ fetched corpus carry a box, and `ingest` reads it straight through into the rebuild's
515
+ stage. A **rigc build**'s stage is read instead from the `skeleton.model.json` beside it,
516
+ when that document's `spine.sha256` is the skeleton's digest — its header is the setup-pose
517
+ bounding box — and an export's from its header box. 🔁 What an export's header carries is its setup-pose bounding box; it is the
518
+ only box the file has, so it becomes the rebuild's stage, and the rebuild's own header
519
+ is computed again — `build` writes the setup-pose bounding box there, never the stage
520
+ (issue #907). The stage cannot be *derived*: it is the working area the art was
521
+ painted in, and no pose of the rig states it. 🔸 **Half a stage is a `NO_STAGE` blocker**: an origin with no
515
522
  extent, or one extent without the other, declares no stage and is not the absence
516
523
  either, and the rig spec holds a stage as four fields or none. It is also the value that costs least to get wrong: `diff`
517
- reports it as two measures of its own (`stage_present`, `stage_box`) and they are
524
+ reports the header's box as two measures of its own (`bounds_present`, `bounds_box`) and they are
518
525
  `(reported)`, so no score reads them and an absurd box is green nearly everywhere.
519
526
  ⛔ **The flag is refused beside a box the file states** — two sources for one value,
520
527
  both named, and the file is the record of what was measured;
@@ -659,8 +666,9 @@ states.
659
666
  ⚠️ **If the export's `skeleton` block carries no `x`/`y`/`width`/`height`, write
660
667
  `"width": null, "height": null` and do not invent one** — which is also what
661
668
  `rigc ingest` writes for such a file. A made-up stage is a number no gate refuses;
662
- `diff`'s header block reports `skeleton.stage_present` and `skeleton.stage_box`
663
- against the source you are copying. Copy the four numbers when they are there;
669
+ `A14` and `A19` measure against the stage you state, and `diff`'s header block
670
+ reports `skeleton.bounds_present` and `skeleton.bounds_box` — your build's bounding
671
+ box, which you do not author, against the source's. Copy the four numbers when they are there;
664
672
  state the absence when they are not.
665
673
  4. **`explain`, then `build`.** `explain` first, because it prints what you wrote in a
666
674
  shape you can compare against the export by eye (§1.5) and it never gates. Then
@@ -704,11 +712,11 @@ State the ambition in the right units, because three different things get called
704
712
  | --- | --- | --- |
705
713
  | **Structural agreement** — same bones, slots, attachments, timelines, key counts, curve kinds | ✅ yes, and `diff` measures it | the 3-timing transcription reads **1.000 on all 54 measures**. Aim here first |
706
714
  | **Geometric agreement** — the same drawn pixels, allowing for the atlas | ✅ yes, and `check` measures it | see below |
707
- | **Byte-identical JSON** | ✅ **for a rebuild, in canonical form, apart from `hash` and `spine`** — the pass line below | a **rebuild** of an editor export (`ingest`, then `build`) is the export. A **transcription** by hand is not held to it: what differs there is what a person chose to write, not the emitter |
715
+ | **Byte-identical JSON** | ✅ **for a rebuild, in canonical form, apart from `hash`, `spine` and the header's box** — the pass line below | a **rebuild** of an editor export (`ingest`, then `build`) is the export. A **transcription** by hand is not held to it: what differs there is what a person chose to write, not the emitter |
708
716
 
709
717
  ⭐ **The pass line of the byte round trip, stated once:** `build(ingest(x))` of an
710
718
  editor export `x` is **identical to `x` in canonical form, apart from `hash` and
711
- `spine`** — canonical form being `JSON.stringify(JSON.parse(text), null, 2)` of each
719
+ `spine`**, and from the header's box (below) — canonical form being `JSON.stringify(JSON.parse(text), null, 2)` of each
712
720
  file, which keeps every number as parsed, every key in its order and every omitted key
713
721
  omitted, and drops only whitespace and the exponent's spelling (an export setting, not
714
722
  a property of the rig). It holds on all twelve exports under `examples/`; for a
@@ -722,11 +730,22 @@ has no field for, by design, and each has its finding:
722
730
  | `hash` | `undefined` vs `"VFWbaK2UoCM"` | the editor's project hash — a value about a file rigc did not write, so a spec that carried it would be claiming an export it did not come from. `HEADER_BOOKKEEPING` |
723
731
  | `spine` | `"4.3.13"` vs `"4.3.75-beta"` | the version of the runtime rigc links, stamped by design and re-checked by `A16`. `HEADER_REDERIVED` |
724
732
 
733
+ 🔁 **The header's box is the third difference, and it is not a key the spec lacks** (issue
734
+ #907). The rig spec carries the export's `x`, `y`, `width`, `height` — as the rebuild's
735
+ stage — and the rebuild does not copy them back: its header carries the setup-pose bounding
736
+ box `build` computes, which is spine-core's `getBounds` over the export on the header's grid
737
+ (the rebuild draws the export's vertices to the bit, so its box is the export's box), where
738
+ the editor wrote its own arithmetic, at most 0.0071 units away on the twelve —
739
+ `spineboy-pro`'s rebuild writes `-188.63641, -7.936074, 418.453, 686.19934` against the
740
+ editor's `-188.6338, -7.939564, 418.4499, 686.2023`. The suite holds each rebuild's box to
741
+ `getBounds` of its source at tolerance 0 (`IG97`) and reads every other byte against a copy
742
+ of the export carrying that box.
743
+
725
744
  🔢 **The rest of the header, and every omitted default, comes back as the export
726
745
  spells it.** The **header's `audio`** is a stated value like `images`, and the rig
727
746
  spec carries it ([AUTHORING §3.1](AUTHORING.md)). The emitter leaves out exactly the
728
747
  keys the parser reads the same way without them ([AUTHORING §10.6c](AUTHORING.md)).
729
- One shape is left and it is deliberate: a stage at the origin, which the editor writes as
748
+ One shape is left and it is deliberate: a box at the origin, which the editor writes as
730
749
  `width`/`height` with no `x`/`y` and rigc spells whole. The 4.3 JSON reader has no
731
750
  default for the origin — an absent one loads as `undefined`, a written `0` as `0` — so
732
751
  it is not a key the parser reads the same way without it, and the row is not in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@ import { ASSERTION_KIND, type AssertionProfile } from '../kinds.ts';
26
26
  import { verdictHarness, type VerdictLists } from '../harness.ts';
27
27
  import { MOVED_ASSERTIONS } from '../model/index.ts';
28
28
  import type { Verdicts } from '../harness.ts';
29
- import type { Json } from '../values.ts';
29
+ import { isObj, type Json } from '../values.ts';
30
30
  import { a01NoLegacyToplevelConstraintArrays } from '../bodies/a01.ts';
31
31
  import { a02NoBoneTransformKey } from '../bodies/a02.ts';
32
32
  import { a05CurveArrayLength } from '../bodies/a05.ts';
@@ -35,6 +35,7 @@ import { a16SkeletonVersion43 } from '../bodies/a16.ts';
35
35
  import { a18DeterministicEmit } from '../bodies/a18.ts';
36
36
  import { a31DrawOrderOffsetsResolve } from '../bodies/a31.ts';
37
37
  import { a35DeformKeysFitTheAttachment } from '../bodies/a35.ts';
38
+ import { parseAtlasText } from '../../atlas.ts';
38
39
 
39
40
  /** What the restated rules read: the emitted texts, the document written beside them, and a second compile's three. */
40
41
  export interface EmittedTextInput {
@@ -92,6 +93,31 @@ export const EMITTED_TEXT_RULES: ReadonlyArray<{ code: string; run: (v: Verdicts
92
93
  },
93
94
  ];
94
95
 
96
+ /**
97
+ * The figures `validate()` reads off the pair it loaded, read off the same two
98
+ * texts here (issue #1114), into `stats` first — the order the round trip's
99
+ * report holds them in: `pages` and `regions` from the atlas text by rigc's own
100
+ * reader (`parseAtlasText`, whose pages and regions are the runtime atlas's in
101
+ * file order), then `bones`, `slots`, `animations` and `version` from the
102
+ * skeleton JSON — the arrays and the animation map the runtime loads them
103
+ * from, and `skeleton.spine` as it states it, `(none)` where it states none.
104
+ * The skeleton's four are left out where its JSON does not parse, as the round
105
+ * trip leaves them out where it loaded nothing; nothing here is a value the
106
+ * texts do not state.
107
+ */
108
+ function pairFigures(stats: Record<string, number | string>, raw: Json | null, atlasText: string): void {
109
+ const atlas = parseAtlasText(atlasText);
110
+ stats.pages = atlas.pages.length;
111
+ stats.regions = atlas.regions.length;
112
+ if (!isObj(raw)) return;
113
+ const listed = (v: unknown): number => (Array.isArray(v) ? v.length : 0);
114
+ stats.bones = listed(raw.bones);
115
+ stats.slots = listed(raw.slots);
116
+ stats.animations = isObj(raw.animations) ? Object.keys(raw.animations).length : 0;
117
+ const declared = isObj(raw.skeleton) ? raw.skeleton.spine : undefined;
118
+ stats.version = declared === undefined || declared === null ? '(none)' : typeof declared === 'string' ? declared : JSON.stringify(declared);
119
+ }
120
+
95
121
  /** Every code the model side does not run, read off the registry — what this file must account for. */
96
122
  export function roundTripOnlyCodes(): string[] {
97
123
  const moved = new Set(MOVED_ASSERTIONS.map((m) => m.code));
@@ -112,6 +138,7 @@ export function validateEmittedText(input: EmittedTextInput): VerdictLists & { p
112
138
  } catch (err) {
113
139
  h.fail(A00, `skeleton JSON is not parseable: ${(err as Error).message}`);
114
140
  }
141
+ pairFigures(h.stats, raw, input.atlasText);
115
142
  for (const rule of EMITTED_TEXT_RULES) {
116
143
  if (rule.code === A00 && raw === null) continue;
117
144
  h.check(rule.code, () => rule.run(h.verdicts, { raw, input }));
@@ -11,9 +11,12 @@
11
11
  * rather than a number standing in for it, and each body reads it as it always
12
12
  * did.
13
13
  *
14
- * Not in the model document: the stage is the emitter's header
15
- * (`SkeletonHeader.stage`), and it enters the document with issue #1026. Until
16
- * then the model side is given it by its caller (`../model/stage.ts`).
14
+ * A `rigc-compiled/3` document states it (`stage`, issue #1026), and since
15
+ * issue #907 that is the only place a rigc build states it: the Spine header
16
+ * carries the setup-pose bounding box. So both suppliers read a rigc build's
17
+ * stage off its document (`spineStage` in `../../validate.ts`,
18
+ * `../model/stage.ts`); an export, which has no document, is read off its
19
+ * header.
17
20
  * Links nothing from the runtime.
18
21
  */
19
22
 
@@ -45,7 +45,7 @@ import { a11NoClippingAttachments } from '../bodies/a11.ts';
45
45
  import { a17AtlasPageFilesExist } from '../bodies/a17.ts';
46
46
  import { a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten } from '../bodies/a45.ts';
47
47
  import { A00_MODEL_READ, A00_MODEL_REGIONS_ON_PAGES, MODEL_PARSE_KIND, modelRead, modelRegionsOnPages, SKIP_NO_MODEL_DOCUMENT, SKIP_NO_MODEL_PAGES, type ReadDocument } from './parse.ts';
48
- import { modelSkinEntries } from './skin_entries.ts';
48
+ import { fileOrderedEntries, modelSkinEntries } from './skin_entries.ts';
49
49
  import { modelAtlasPages } from './atlas_pages.ts';
50
50
  import { modelSlotColour } from './slot_colour.ts';
51
51
  import type { SkinEntryFacts } from '../facts/skin_entries.ts';
@@ -332,6 +332,44 @@ function parse(h: VerdictHarness, modelText: string): { read: ReadDocument | nul
332
332
  return { read, held: h.check(A00_MODEL_REGIONS_ON_PAGES, () => modelRegionsOnPages(h.verdicts, read)) === true };
333
333
  }
334
334
 
335
+ /**
336
+ * The row the rig's two figures are written before (issue #1114): `validate()`
337
+ * sets `rig` and `profile` where its archetype assertions begin, after every
338
+ * figure the bodies above them write and before A29's and A30's, so the figures
339
+ * line lists them in that place on both entries. `RC39` in `selftest.ts`
340
+ * compares the two lines key by key.
341
+ */
342
+ const RIG_FIGURES_BEFORE = 'A24_AXIS_SPACE_STROKE';
343
+ if (!MOVED_ASSERTIONS.some((m) => m.code === RIG_FIGURES_BEFORE)) throw new Error(`internal: the model side writes the rig's figures before ${RIG_FIGURES_BEFORE}, which it no longer runs`);
344
+
345
+ /**
346
+ * The attachment figures `validate()` counts over the skins it loaded (issue
347
+ * #1114), counted over the document's skins in the file's walk order: a
348
+ * `region` record is a region attachment, and a `mesh` or `linkedmesh` record
349
+ * a mesh attachment — the runtime loads a linked mesh as a mesh. Written only
350
+ * where the document read and its regions held, as the round trip writes them
351
+ * only where it loaded the skeleton, and before any body's figure, which is
352
+ * where `validate()` writes them.
353
+ */
354
+ function attachmentFigures(stats: Record<string, number | string>, read: ReadDocument): void {
355
+ const kinds = fileOrderedEntries(read).map((entry) => entry.record.kind);
356
+ stats.regionAttachments = kinds.filter((kind) => kind === 'region').length;
357
+ stats.meshAttachments = kinds.filter((kind) => kind === 'mesh' || kind === 'linkedmesh').length;
358
+ }
359
+
360
+ /**
361
+ * `rig` — the archetype the document's rig section states (`validate()` prints
362
+ * its caller's; the model side reads the document's, issue #1054 — read
363
+ * directly rather than through `supply.rigInfo`, whose refusal of a caller's
364
+ * `rig` beside the document is the rules' line to print, not this figure's),
365
+ * left out where there is no document to state one — and `profile`, the profile this
366
+ * run was handed: the same argument both entries read off `--profile`.
367
+ */
368
+ function rigFigures(stats: Record<string, number | string>, read: ReadDocument | null, input: ModelValidateInput): void {
369
+ if (read !== null) stats.rig = modelRigInfo(read).archetype;
370
+ stats.profile = input.profile;
371
+ }
372
+
335
373
  /**
336
374
  * Run the model side: the parse, then every moved assertion — each over the
337
375
  * document, or skipped naming the parse when there is none — and return the
@@ -343,7 +381,9 @@ export function validateModel(input: ModelValidateInput, plant: Partial<ModelSup
343
381
  const supply: ModelSupply = { ...MODEL_SUPPLY, ...plant };
344
382
  const h = verdictHarness(input.profile, { ...ASSERTION_KIND, ...MODEL_PARSE_KIND }, 'the model side');
345
383
  const { read, held } = parse(h, input.modelText);
384
+ if (read !== null && held) attachmentFigures(h.stats, read);
346
385
  for (const moved of MOVED_ASSERTIONS) {
386
+ if (moved.code === RIG_FIGURES_BEFORE) rigFigures(h.stats, read, input);
347
387
  const doc = read !== null && (held || moved.beforeTheParse === true) ? read : null;
348
388
  h.check(moved.code, () => (doc === null ? h.skip(moved.code, moved.unread) : moved.run(h.verdicts, doc, input, supply)));
349
389
  }
@@ -28,13 +28,13 @@ import { deformReportBlock } from '../deformreport.ts';
28
28
  import { type DiffAnimationPair, diffLines, diffSkeletons } from '../diff.ts';
29
29
  import { ingest, INGEST_GUTTERS, type IngestFinding, IngestSpecRefused, type IngestStage } from '../ingest.ts';
30
30
  import { PARSER_DEFAULTS, parserReading } from '../keyorder.ts';
31
- import { modelDocument } from '../model.ts';
31
+ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
32
32
  import { parseMotionSpec } from '../motion.ts';
33
33
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
34
34
  import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
35
35
  import { type CompileResult } from '../types.ts';
36
36
  import { attachmentRegionJoins } from '../region_joins.ts';
37
- import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson } from './shared.ts';
37
+ import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
38
38
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
39
39
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
40
40
 
@@ -889,11 +889,9 @@ export function cmdExplain(flags: Record<string, string>): void {
889
889
  // A rig may state that it has no stage at all (issue #578), and the two must
890
890
  // not print alike: `undefined x undefined` is what a template does with an
891
891
  // absence, and it reads like a defect in the tool rather than a claim in the
892
- // spec.
893
- const stage =
894
- result.skeleton.skeleton.width === undefined || result.skeleton.skeleton.height === undefined
895
- ? 'none declared'
896
- : `${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height}`;
892
+ // spec. The stage is the model's (issue #907): the header's box is the
893
+ // setup-pose bounding box, which is not the stage and is not printed as it.
894
+ const stage = result.model.stage === null ? 'none declared' : `${result.model.stage.width} x ${result.model.stage.height}`;
897
895
  console.log(`\nstage ${stage} (spine ${result.skeleton.skeleton.spine})`);
898
896
 
899
897
  // The crop note describes where the numbers CAME from, and without a manifest
@@ -1295,9 +1293,16 @@ export function cmdIngest(flags: Record<string, string>, positional: string[]):
1295
1293
  * with them — `JSON.stringify` — and a cast to `RigSpec` here would be this
1296
1294
  * file claiming a parse that did not happen.
1297
1295
  */
1296
+ // A rigc build's stage is the model document's beside it, when that document is this skeleton's (issue #907): its header is the
1297
+ // setup-pose bounding box. An export, or a skeleton with no such document, is read off its header.
1298
+ const documentStage = documentStageBeside(skeletonPath, readFileSync(skeletonPath, 'utf8'));
1299
+ if (documentStage !== undefined) {
1300
+ console.log(` .. stage ${documentStage === null ? 'none declared' : `${documentStage.width} x ${documentStage.height}`} — read from the ${MODEL_DOCUMENT_FILE} beside it (its spine.sha256 is this skeleton's); the header's box is the setup-pose bounding box`);
1301
+ }
1298
1302
  let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
1299
1303
  try {
1300
1304
  result = ingest(readJsonFile(skeletonPath), {
1305
+ ...(documentStage === undefined ? {} : { documentStage }),
1301
1306
  name: flags.name ?? basename(skeletonPath, '.json'),
1302
1307
  art,
1303
1308
  images: specImages,
@@ -1559,7 +1564,9 @@ const MODEL_AND_TEXT_GATE: BuildGate = {
1559
1564
  passed: [...model.passed, ...text.passed],
1560
1565
  skipped: [...model.skipped, ...text.skipped],
1561
1566
  profileSkipped: [...model.profileSkipped, ...text.profileSkipped],
1562
- stats: { ...model.stats, ...text.stats },
1567
+ // The emitted text's figures first: they are the pair's (`pages` … `version`), which `validate()` writes
1568
+ // before any other, and the model side's follow in its order — one line, keyed and ordered as `cli.ts build`'s (issue #1114, `RC39`).
1569
+ stats: { ...text.stats, ...model.stats },
1563
1570
  profile,
1564
1571
  };
1565
1572
  for (const line of reportLines(report)) console.log(line);