@rig-c/core 2.39.1 → 2.40.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/NOTICE.md CHANGED
@@ -40,8 +40,8 @@ Software's terms, not a licence term of this project:
40
40
  development dependency for its own gate, and so does an install with
41
41
  `spine-core` added beside it. Validation is not optional on either: the gate
42
42
  runs through the runtime where it is installed and through rigc's own validator
43
- where it is not, and the two are held to the same verdicts in CI on 49 of the
44
- 50 assertions — the official parser's own parse runs only where the runtime is.
43
+ where it is not, and the two are held to the same verdicts in CI on 50 of the
44
+ 51 assertions — the official parser's own parse runs only where the runtime is.
45
45
 
46
46
  > **If a product integrates a Spine Runtime to play rigc's output, that integration
47
47
  > is subject to the terms above** — rigc neither creates those terms nor removes
package/README.md CHANGED
@@ -64,12 +64,12 @@ it cuts both ways:
64
64
  is a development dependency: this repository uses it to hold rigc's own validator
65
65
  to the round trip's verdicts — in CI, on the public example recipes and every
66
66
  selftest call, and on a private corpus of production rigs measured before each
67
- release. Of the 50 assertions, 49 run on both; `A00_ROUNDTRIP_PARSE`, the official
67
+ release. Of the 51 assertions, 50 run on both; `A00_ROUNDTRIP_PARSE`, the official
68
68
  parser's own parse, runs only where the runtime is installed and is reported as a
69
69
  SKIP elsewhere. There, in the parse's place, the report also carries the model
70
70
  side's own two parse rules, `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES`,
71
71
  which no round-trip rule matches and the registry does not hold — so its summary
72
- totals 52 on the core entry, and its last line counts 43 rules on the model side
72
+ totals 53 on the core entry, and its last line counts 44 rules on the model side
73
73
  ([AUTHORING §5.2](docs/AUTHORING.md) has a row for each). Install it beside rigc
74
74
  (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs the round
75
75
  trip on every build. The
@@ -595,9 +595,9 @@ first three work on any reference you have, and `bench` is a repository workflow
595
595
  and `bun run fetch-examples`. The reasoning behind them is in
596
596
  [the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md).
597
597
 
598
- `build` and `validate` both default to `--profile spine` — the 34 validity rules, which
598
+ `build` and `validate` both default to `--profile spine` — the 35 validity rules, which
599
599
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
600
- adds all 50: the other 16 are one renderer's policy and one canvas budget's, and they
600
+ adds all 51: the other 16 are one renderer's policy and one canvas budget's, and they
601
601
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
602
602
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
603
603
  yourself: author under `--profile spine-html` and read the extra 16 as findings, and
@@ -759,7 +759,7 @@ letting `A17` blame the editor for the harness's own doing.
759
759
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
760
760
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
761
761
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | **the Spine 4.3 format, field by field.** Every key the 4.3 JSON parser and atlas reader take, what each defaults to, and the parser line it comes from — the rows rigc's refusals cite by part number. Ships in the package too |
762
- | 🎓 **[the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 50 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
762
+ | 🎓 **[the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 51 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
763
763
  | 📋 [LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/rig-c/core/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/rig-c/core/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
764
764
  | 🔬 [SURVEY_2026-08-22.md](https://github.com/rig-c/core/blob/main/docs/SURVEY_2026-08-22.md) | the survey the ladder was climbed by: rigc and the official examples as measured on 2026-08-22, and the gap list ordered by rung. A dated record, not kept current. Repository material |
765
765
  | 🧬 [GENERATIONS.md](https://github.com/rig-c/core/blob/main/docs/GENERATIONS.md) | **Spine data from another generation.** Why a 3.8–4.2 file read as 4.3 fails in silence, the policy that follows (detect from `skeleton.spine`, never guess, play on the matching runtime), what `A16` and `ingest` do with such a file, and the editor as the migration path. Repository material |
@@ -809,7 +809,7 @@ quality."* All six, with their verdicts, are in
809
809
  [docs/LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md).
810
810
 
811
811
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
812
- see, every rung, the run viewer, the 50 assertions and the selftest behind them — is
812
+ see, every rung, the run viewer, the 51 assertions and the selftest behind them — is
813
813
  [docs/BENCHMARK.md](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md).
814
814
  Live rung status is
815
815
  [docs/LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md).
package/docs/AUTHORING.md CHANGED
@@ -233,7 +233,7 @@ What the flags mean:
233
233
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
234
234
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
235
235
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
236
- | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 50, opt-in |
236
+ | `--profile` | `spine` = the 35 validity rules (**the default**) · `spine-html` = all 51, opt-in |
237
237
  | `--report` | `build` and `repack`: also write the gate's report as a JSON document (`build-report/1`) to this file — every gate's `PASS`, `SKIP` and `FAIL` rows, its summary figures and stats line, which supplier judged it, and every `pack:` line's figures. Written when the command writes `--out` and when a gate is red, before the exit; a file already at the path is removed first, so a compile error or a refused repack leaves none. Never inside `--out`, refused by name. The printed lines are byte-identical with it and without it — **§5.3** |
238
238
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
239
239
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
@@ -4031,7 +4031,7 @@ is not an array. Every field is optional and each is the payload a firing
4031
4031
  Optional with one exception, and only meaningful for rigc's own formations:
4032
4032
  `meshSlots` and `meshTriangles` (the two halves of the mesh budget `A13` measures
4033
4033
  against), `axisBone`, `massBone`, `detached`, `deformMayFold`,
4034
- `consumerDrivenMix`, `idleDrivesMeshes`.
4034
+ `consumerDrivenMix`, `idleDrivesMeshes`, `axes`.
4035
4035
  Nothing in skeleton
4036
4036
  JSON records that a
4037
4037
  bone carries a cut's axis or that a parentage is forbidden, so the rig spec says it
@@ -4154,6 +4154,61 @@ onto it: on the first such rig that was 42 extra bones for an identical pose.
4154
4154
  - ⚠️ A15 is a renderer rule, so under `--profile spine` it reports `PROF` and the
4155
4155
  declaration is not read at all, stale or not.
4156
4156
 
4157
+ 📐 **`axes` holds bones to a line the rig declares.** A `scaleY` key squashes along
4158
+ the bone's own y axis, a `translateY` key moves along it — and so does whatever a
4159
+ consumer writes to the bone at run time. On a face painted upright with every bone
4160
+ at rotation 0, that is the face's axis. On a face painted **tilted** it is the
4161
+ page's: a blink squashes the eye along the page's vertical instead of across the
4162
+ eye line, a brow rises toward the page's top instead of the forehead, a mouth
4163
+ opens toward the page's bottom instead of the chin, an iris slides off the lid.
4164
+ The file loads, every other rule passes, and the picture is wrong. Measured on one
4165
+ seated rig: the line through the two eye bones sat 23.7° off the page's
4166
+ horizontal, all five face bones were at rotation 0, and the closed eyes came out
4167
+ level on a tilted face.
4168
+
4169
+ ```json
4170
+ "invariants": {
4171
+ "axes": [
4172
+ { "name": "face", "through": ["eye_r", "eye_l"],
4173
+ "bones": ["eye_r", "eye_l", "brow_r", "brow_l", "mouth"], "tolerance": 5 }
4174
+ ]
4175
+ }
4176
+ ```
4177
+
4178
+ For each entry the **line** runs through the setup-pose world origins of the two
4179
+ `through` bones, first to second, and every bone in `bones` must have its
4180
+ setup-pose **world x axis** within `tolerance` degrees of it — as a line, so a bone
4181
+ pointing the other way along it (turned 180°, or mirrored by a world scaleX of −1)
4182
+ lies along it too. The setup pose is the one `A50` reads: constraints applied, no
4183
+ physics, no skin set. `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` holds it (§5.2).
4184
+
4185
+ - **Held whether or not an animation keys the bone.** A consumer writes these bones
4186
+ at run time — `openTrack({ adjust })` on the core entry — which no animation in
4187
+ the file shows.
4188
+ - **The fix** for a bone that fails: rotate it in the rig spec onto the line (its
4189
+ `rotation`, so its world x axis lands on the axis angle the failure prints) and
4190
+ counter-rotate its attachments by the same angle, so the setup pose stays where
4191
+ it is. Its children inherit the rotation, so restate their `x`, `y` and
4192
+ `rotation` in its new frame too.
4193
+ - Compile errors, each naming `invariants.axes` and the entry: a missing `name`,
4194
+ a `name` twice, a `through` that is not two bone names, a bone in `through` or
4195
+ `bones` the rig does not declare, `through` naming one bone twice, an empty
4196
+ `bones`, a bone twice in `bones`, and a `tolerance` that is missing or not
4197
+ above 0 and below 90. **There is no default tolerance** — rigc does not invent
4198
+ one. Two lines meet at 0° to 90°, so 90 or more admits every orientation and
4199
+ could never fail; 0 would refuse a bone rotated exactly onto the line whenever
4200
+ the posed arithmetic rounds.
4201
+ - ⛔ **Two `through` bones on one world origin** draw no line. The rig spec cannot
4202
+ show that — a constraint can put them there — so it is the gate's refusal, not
4203
+ the compiler's: `axis "face": "eye_r" and "eye_l" share the world origin (…) at
4204
+ the setup pose, so they draw no line`.
4205
+ - Undeclared, `A51` reports **SKIP** — *the rig "X" declares no axes
4206
+ (invariants.axes)* — never a pass. It is a **validity** rule, unlike the
4207
+ archetype rules above: it can fire only on a declaration, so it never fires on
4208
+ foreign data, and it runs under the default `--profile spine`, where a
4209
+ declaration the profile did not read would be one nothing reads. The
4210
+ declaration is a statement to the gate and changes no emitted Spine byte.
4211
+
4157
4212
  ---
4158
4213
 
4159
4214
  ## 4. The motion spec, field by field
@@ -6427,9 +6482,9 @@ prints six rows and is one failure here.
6427
6482
  `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES` — the first two rows of the
6428
6483
  table below — which stand where `A00_ROUNDTRIP_PARSE`'s parse stands and have no
6429
6484
  round-trip counterpart, so they are not in the registry. That is the whole of the
6430
- difference between the two totals: `<N>` is 52 on the core entry and 50 on `cli.ts build`,
6431
- and the core report's last line states the split — 43 rules on the model side,
6432
- which are the 41 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6485
+ difference between the two totals: `<N>` is 53 on the core entry and 51 on `cli.ts build`,
6486
+ and the core report's last line states the split — 44 rules on the model side,
6487
+ which are the 42 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6433
6488
  and `A00_ROUNDTRIP_PARSE`, which it reports as a SKIP.
6434
6489
 
6435
6490
  🚨 **When `A00_ROUNDTRIP_PARSE` fails, read the report as a report about A00 and
@@ -6494,6 +6549,7 @@ Fix A00 and run it again.
6494
6549
  | `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | a transform constraint none of whose mixes **for a property it drives** is away from 0 at setup or on any value an animation poses (§4.10, §4.12), or one whose `properties` name no `to` at all. A property is applied only when its own mix `!== 0`, and a key that omits a mix reads it as 1, so the six-mix early return of `TransformConstraint.update` would take a key of `mixRotate: 0` alone as a rescue — [measured] that key, and one keying `mixX` 1 on a rotate-only constraint, pose every bone exactly where no constraint does. A negative mix runs, and five transforms in the editor's example exports rest at −1. `transform constraint "C" drives rotate and has mixRotate 0 at setup, and none of the 1 animation keys its mix above 0; a mix is read only for a property the constraint drives, and update() skips each one at 0, so nothing ever moves "follower" — rest mixRotate above 0, or key its mix above 0 in an animation, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "transform", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused as `A47`'s is; one that drives no property is refused with its own sentence declared or not, since no mix it carries is read by anybody. **SKIP** when the skeleton declares no transform constraint, and **SKIP by name** when every transform constraint it declares is declared consumer-driven: `every transform constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the stats line names the declared (`transformConsumerDriven`) |
6495
6550
  | `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` | renderer | two regions on one page whose rectangles overlap **and** whose footprints overlap — so one drawing samples texels that are the other's. A region's **footprint** is what samples it: a region a mesh names (its `path`, else its name, every frame of a `sequence` included) draws the mesh's hull loop and its triangles, its UVs placed on the page as `MeshAttachment.computeUVs` places them (a **linked mesh** draws its source's); a region a region attachment names, or that nothing names, draws its whole rectangle — and so does a mesh region whose UVs leave 0..1, whose hull or triangles do not index its vertices, or that is turned by `rotate:`, and every region when a `sequence` cannot be read. For two rectangles that is exactly the clause `A06` held until issue #1099 — an editor alias, a sequence frame, two rectangles moved over the same texels are refused as they were — and it accepts what `--pack-shape polygon` writes: a neighbour inside a mesh's rectangle where the hull is not (§0.1). **Touching is not overlapping**: interiors are compared, every comparison `FOOTPRINT_SLACK` (1e-6 texels) in favour of apart. `regions "ear_l" (341,696 112x420) and "mouth" (332,709 96x48) overlap on page "skeleton.png", and so do their footprints — "ear_l" draws the hull of mesh "ear_l" (skin "default", slot "ear_l"), "mouth" draws its whole rectangle, which region attachment "mouth" (skin "default", slot "mouth") draws; what they draw overlaps by 1916.5512 texel(s) of area inside 347,709 81x48. One drawing samples texels that are the other's: move one region clear of the other's footprint, or pack the page again` — `gallery/nod`'s polygon pack with `mouth` moved onto a triangle of the `ear_l` mesh (`PK88`). Nothing about the **padding** is read — the atlas and the model document do not state it — and the `2 · padding` the packer keeps is the packer's own (`PK81`). A renderer rule because two regions over the same texels is what an editor's packer writes for an alias or a sequence: 49 pairs on four of the ten atlases in `examples/`. **SKIP** when the atlas declares no page or no region, and when no page carries two regions — every unpacked build |
6496
6551
  | `A50_STAGE_BOX_IS_THE_STAGE` | both | the stage box a rig asked for (`skeleton.stageBox`, §3.1) is not the stage the model document states. Read on the data a consumer reads and on the pose it reads: the slot carries, in the `default` skin under the asked name, an unweighted bounding box of four vertices whose **stored** vertices are the stage's corners exactly — `(x, y)`, `(x + w, y)`, `(x + w, y + h)`, `(x, y + h)`, as the float32 the runtime loads — and whose **world** vertices at the setup pose (constraints applied, no physics, no skin set) are those corners as the runtime poses them on an unrotated root, compared on the header's 1e-6 grid at float32. `the stage box (slot "stage", attachment "stage"): vertex 1 is stored as (65, 0) and the stage's corner is (64, 0) — the stage 0,0 64x48, bottom-left first and counter-clockwise`; a root moved at setup reads *vertex 0 poses at (1, 0) at the setup pose and the stage's corner poses at (0, 0) on an unrotated root, on the header's grid — something moves the box's bone at setup (a constraint, or a pose the root does not state)*; a slot the skeleton lacks, no attachment of that name, another type, or a weighted box are each named too. Through spine-core it reads `findSlot`, `defaultSkin.getAttachment` and `computeWorldVertices` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side the document's record and rigc's core poser, held to spine-core's poses at tolerance 0 (`PG06`, `PG07`). **SKIP** where nothing asks: *nothing asks for a stage box: no rigc-compiled/3 model document beside this skeleton states one …* — a rig without the field, and any skeleton with no `/3` document beside it, an export among them |
6552
+ | `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` | both | a bone the rig holds to a line (`invariants.axes`, §3.7) whose world x axis at the setup pose — constraints applied, no physics, no skin set, the pose `A50` reads — is more than the entry's `tolerance` off the line through its two `through` bones' world origins, compared as lines (0° to 90°): `axis "face" (through "eye_r"→"eye_l", 30.0000°): bone "brow_l" world x axis at 36.0000°, 6.0000° off, tolerance 5° — rotate the bone in the rig spec onto the line and counter-rotate its attachments so the setup pose stays put`. Every angle is Spine world degrees (y up), counter-clockwise from +x. **Fix it in the rig spec**: set the bone's `rotation` so its world x axis lands on the axis angle the line prints, counter-rotate its attachments by the same angle so the setup pose does not move, and restate its children in its new frame. Also fails, by the entry's name: two `through` bones on one world origin (*share the world origin (x, y) at the setup pose, so they draw no line* — move one, or name another pair); a bone with no world x axis (a scale of 0); a bone inactive with no skin set (*so the setup pose places it nowhere*); a bone the skeleton does not have. Held whether or not an animation keys the bone: a consumer writes it at run time. Through spine-core it reads `appliedPose` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side rigc's core poser, held to spine-core's poses at tolerance 0 (`AX01`–`AX05`). **SKIP** when the rig declares no axes — *the rig "X" declares no axes (invariants.axes)* — and on a bare directory with no rig |
6497
6553
 
6498
6554
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
6499
6555
  clauses are gated by profile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.39.1",
3
+ "version": "2.40.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A51, the body (issue #1351): the bones a rig declares to share an axis lie
3
+ * along it at the setup pose.
4
+ *
5
+ * For each `invariants.axes` entry the line is drawn through the setup-pose
6
+ * world origins of its two `through` bones, first to second, and every bone
7
+ * of its `bones` must have its setup-pose world x axis within `tolerance`
8
+ * degrees of that line — as a line, so a bone pointing the opposite way (a
9
+ * mirrored bone, a world scaleX of −1) lies along it too. Why the rule exists,
10
+ * and why it holds bones no animation keys, is above its `check` call in
11
+ * `validate()` and on `RigInvariants.axes` in `src/rig.ts`.
12
+ *
13
+ * Reads the declaration from the rig info and the pose from a fact
14
+ * (`../facts/setup_world.ts`), so the body is one function over spine-core's
15
+ * setup pose and rigc's core's. Every angle it prints is a degree in Spine's
16
+ * world (y up), counter-clockwise from +x.
17
+ *
18
+ * Links nothing from the runtime.
19
+ */
20
+ import type { Verdicts } from '../harness.ts';
21
+ import type { SetupBoneFrame, SetupWorldFacts } from '../facts/setup_world.ts';
22
+ import type { RigInfo } from '../../types.ts';
23
+
24
+ const CODE = 'A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS';
25
+
26
+ /** Degrees, to the four places every angle on this rule's lines is printed at. */
27
+ const deg = (v: number): string => `${v.toFixed(4)}°`;
28
+
29
+ /** How far apart two directions are as lines, in degrees: 0 to 90. */
30
+ export function lineGap(a: number, b: number): number {
31
+ const d = (((a - b) % 180) + 180) % 180;
32
+ return Math.min(d, 180 - d);
33
+ }
34
+
35
+ /** The world angle of a bone's x axis, in degrees — `atan2(c, a)`, the angle spine-core's `getWorldRotationX` reports. */
36
+ export function worldAxisDegrees(frame: SetupBoneFrame): number {
37
+ return (Math.atan2(frame.c, frame.a) * 180) / Math.PI;
38
+ }
39
+
40
+ export function a51BonesLieAlongTheirDeclaredAxis({ fail, skip }: Verdicts, facts: SetupWorldFacts, input: { rig?: RigInfo }): void {
41
+ const rig = input.rig;
42
+ if (!rig) return skip(CODE, 'no rig info (validating a bare directory)');
43
+ if (!rig.axes.length) return skip(CODE, `the rig "${rig.archetype}" declares no axes (invariants.axes)`);
44
+ /** A bone's frame, or the failure that says why there is none. */
45
+ const placed = (axis: string, bone: string): SetupBoneFrame | null => {
46
+ const frame = facts.frameOf(bone);
47
+ if (frame === null) {
48
+ fail(CODE, `axis "${axis}" names bone "${bone}", which the skeleton does not have`);
49
+ return null;
50
+ }
51
+ if (typeof frame === 'string') {
52
+ fail(CODE, `axis "${axis}": ${frame}, so it cannot be held to the axis`);
53
+ return null;
54
+ }
55
+ return frame;
56
+ };
57
+ for (const axis of rig.axes) {
58
+ const [from, to] = axis.through;
59
+ const a = placed(axis.name, from);
60
+ const b = placed(axis.name, to);
61
+ if (a === null || b === null) continue;
62
+ const dx = b.worldX - a.worldX;
63
+ const dy = b.worldY - a.worldY;
64
+ // Exactly one origin, and no threshold: any distance above 0 draws a line, and a threshold would be a number the rig did not state.
65
+ if (dx === 0 && dy === 0) {
66
+ fail(
67
+ CODE,
68
+ `axis "${axis.name}": "${from}" and "${to}" share the world origin (${a.worldX}, ${a.worldY}) at the setup pose, so they draw no line — ` +
69
+ 'name two bones whose origins differ',
70
+ );
71
+ continue;
72
+ }
73
+ const line = (Math.atan2(dy, dx) * 180) / Math.PI;
74
+ const head = `axis "${axis.name}" (through "${from}"→"${to}", ${deg(line)})`;
75
+ for (const bone of axis.bones) {
76
+ const frame = placed(axis.name, bone);
77
+ if (frame === null) continue;
78
+ if (frame.a === 0 && frame.c === 0) {
79
+ fail(CODE, `${head}: bone "${bone}" has no world x axis at the setup pose (the x column of its world matrix is 0, 0 — a scale of 0), so it lies along no line`);
80
+ continue;
81
+ }
82
+ const found = worldAxisDegrees(frame);
83
+ const off = lineGap(found, line);
84
+ if (off <= axis.tolerance) continue;
85
+ fail(
86
+ CODE,
87
+ `${head}: bone "${bone}" world x axis at ${deg(found)}, ${deg(off)} off, tolerance ${axis.tolerance}° — ` +
88
+ 'rotate the bone in the rig spec onto the line and counter-rotate its attachments so the setup pose stays put',
89
+ );
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Each bone's world frame at the setup pose (issue #1351) — what
3
+ * `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` reads: a bone's world origin, and
4
+ * the column of its world matrix its x axis points along.
5
+ *
6
+ * ⭐ **The pose is A50's**, on both suppliers: a fresh skeleton at the setup
7
+ * pose with constraints applied, no physics stepped and no skin set —
8
+ * `setupPose()` then `updateWorldTransform(Physics.none)` through spine-core
9
+ * (`spineSetupWorld` in `../../validate.ts`), `poseSetup` under no skin
10
+ * through rigc's core (`../model/setup_world.ts`). `tools/core_gate.ts` holds
11
+ * the core's setup world to spine-core's at tolerance 0, which is what lets the
12
+ * two print one line.
13
+ *
14
+ * A bone is answered lazily: a rig that declares no axis poses nothing.
15
+ *
16
+ * Links nothing from the runtime.
17
+ */
18
+
19
+ /** A bone's world frame at the setup pose: its origin, and its world x axis `(a, c)`. */
20
+ export interface SetupBoneFrame {
21
+ readonly worldX: number;
22
+ readonly worldY: number;
23
+ readonly a: number;
24
+ readonly c: number;
25
+ }
26
+
27
+ /** What A51 reads. */
28
+ export interface SetupWorldFacts {
29
+ /**
30
+ * The bone's frame at the setup pose; a sentence saying why the setup pose
31
+ * places it nowhere (inactive with no skin set, a setup pose the core does
32
+ * not pose); or `null` when the skeleton has no bone of that name.
33
+ */
34
+ frameOf(bone: string): SetupBoneFrame | string | null;
35
+ }
36
+
37
+ /** The sentence for a bone the setup pose leaves inactive — one spelling for both suppliers. */
38
+ export function inactiveAtSetup(bone: string): string {
39
+ return `bone "${bone}" is inactive with no skin set, so the setup pose places it nowhere`;
40
+ }
@@ -97,6 +97,7 @@ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
97
97
  A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
98
98
  A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP: 'renderer',
99
99
  A50_STAGE_BOX_IS_THE_STAGE: 'validity',
100
+ A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS: 'validity',
100
101
  };
101
102
 
102
103
  /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `spine-html` every kind. */
@@ -75,6 +75,7 @@ const RIG_FIELDS: Readonly<Record<keyof RigInfo, Check>> = {
75
75
  deformMayFold: list(str),
76
76
  consumerDrivenMix: list(shape({ type: oneOf(['ik', 'transform']), constraint: str, why: str })),
77
77
  idleDrivesMeshes: nullable(str),
78
+ axes: list(shape({ name: str, through: list(str, 2), bones: list(str), tolerance: num })),
78
79
  basePlates: list(str),
79
80
  meshSlotBudget: nullable(num),
80
81
  meshTriangleBudget: nullable(num),
@@ -64,6 +64,9 @@ import { a06AtlasPageSizeMatchesPng } from '../bodies/a06.ts';
64
64
  import { a19OverlayPngsHaveAlpha } from '../bodies/a19.ts';
65
65
  import { a49PackedFootprintsDoNotOverlap } from '../bodies/a49.ts';
66
66
  import { a50StageBoxIsTheStage } from '../bodies/a50.ts';
67
+ import { a51BonesLieAlongTheirDeclaredAxis } from '../bodies/a51.ts';
68
+ import { modelSetupWorld } from './setup_world.ts';
69
+ import type { SetupWorldFacts } from '../facts/setup_world.ts';
67
70
  import { modelStageBox } from './stage_box.ts';
68
71
  import type { StageBoxFacts } from '../facts/stage_box.ts';
69
72
  import { a27RegionNameMatchesPageFilename } from '../bodies/a27.ts';
@@ -197,6 +200,8 @@ export interface ModelSupply {
197
200
  steppedPoses: (read: ReadDocument) => SteppedPoseFacts;
198
201
  /** The stage box the document asks for and what its default skin holds there (issue #1168, `./stage_box.ts`). */
199
202
  stageBox: (read: ReadDocument) => StageBoxFacts;
203
+ /** Each bone's world frame at the setup pose, A50's pose (issue #1351, `./setup_world.ts`). */
204
+ setupWorld: (read: ReadDocument) => SetupWorldFacts;
200
205
  /** The rig info the document declares (issue #1054, `./declared.ts`); a caller's `rig` beside it is refused by name. */
201
206
  rigInfo: (read: ReadDocument, input: ModelValidateInput) => RigInfo;
202
207
  /** Each animation's declared duration as the document states it (issue #1054); a caller's `declaredDurations` beside it is refused by name. */
@@ -228,6 +233,7 @@ export const MODEL_SUPPLY: ModelSupply = {
228
233
  constraintTargets: modelConstraintTargets,
229
234
  steppedPoses: modelSteppedPoses,
230
235
  stageBox: modelStageBox,
236
+ setupWorld: modelSetupWorld,
231
237
  rigInfo: (read, input) => {
232
238
  refuseDeclaredBeside(read, input, 'rig');
233
239
  return modelRigInfo(read);
@@ -318,6 +324,7 @@ export const MOVED_ASSERTIONS: readonly MovedAssertion[] = [
318
324
  { code: 'A28_RIBBON_ROWS_SHARE_WEIGHTS', run: (v, read, input, supply) => a28RibbonRowsShareWeights(v, supply.meshes(read), supply.rigInfo(read, input)), unread: SKIP_NO_MODEL },
319
325
  { code: 'A29_STROKE_WITHIN_CONTACT_DEPTH', run: (v, read, input, supply) => a29StrokeWithinContactDepth(v, supply.boneTimelines(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
320
326
  { code: 'A30_STROKE_WITHIN_CAP_CONTAINMENT', run: (v, read, input, supply) => a30StrokeWithinCapContainment(v, supply.boneTimelines(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
327
+ { code: 'A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS', run: (v, read, input, supply) => a51BonesLieAlongTheirDeclaredAxis(v, supply.setupWorld(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL },
321
328
  ];
322
329
 
323
330
  /** The codes the model side prints: its two parse rules, then the moved assertions. */
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The model side's supply of `SetupWorldFacts` (issue #1351): each bone's
3
+ * world frame at the setup pose as rigc's core poses it — `poseSetup` under no
4
+ * skin, constraints applied, the pose `./stage_box.ts` reads for A50 — posed
5
+ * once, on the first question, so a rig that declares no axis poses nothing.
6
+ *
7
+ * Links nothing from the runtime.
8
+ */
9
+ import { activeBones, poseSetup, rawNumber, underNoSkin } from '../../core/index.ts';
10
+ import type { CoreWorld } from '../../core/world.ts';
11
+ import { inactiveAtSetup, type SetupWorldFacts } from '../facts/setup_world.ts';
12
+ import type { ReadDocument } from './parse.ts';
13
+
14
+ export function modelSetupWorld(read: ReadDocument): SetupWorldFacts {
15
+ let posed: { active: ReadonlySet<string>; world: Map<string, CoreWorld> | string } | null = null;
16
+ const pose = (): { active: ReadonlySet<string>; world: Map<string, CoreWorld> | string } => {
17
+ if (posed !== null) return posed;
18
+ const view = underNoSkin(read.doc);
19
+ const setup = poseSetup(view, { round: rawNumber });
20
+ const world = setup.world ?? setup.absent.find(([block]) => block === 'setup.bones')?.[1] ?? "rigc's core does not pose this setup pose";
21
+ posed = { active: activeBones(view), world };
22
+ return posed;
23
+ };
24
+ return {
25
+ frameOf: (bone) => {
26
+ if (!read.doc.bones.some((b) => b.name === bone)) return null;
27
+ const { active, world } = pose();
28
+ if (!active.has(bone)) return inactiveAtSetup(bone);
29
+ if (typeof world === 'string') return world;
30
+ const frame = world.get(bone);
31
+ if (frame === undefined) return `bone "${bone}" has no setup world transform`;
32
+ return { worldX: frame.worldX, worldY: frame.worldY, a: frame.a, c: frame.c };
33
+ },
34
+ };
35
+ }
package/src/compile.ts CHANGED
@@ -7555,6 +7555,8 @@ function buildRigInfo(
7555
7555
  consumerDrivenMix: (rig.invariants?.consumerDrivenMix ?? []).map((e) => ({ type: e.type, constraint: e.constraint, why: e.why })),
7556
7556
  // `parseRigSpec` refused every shape but a non-blank `why`.
7557
7557
  idleDrivesMeshes: rig.invariants?.idleDrivesMeshes?.why ?? null,
7558
+ // `parseRigSpec` resolved every name and refused every shape (issue #1351).
7559
+ axes: (rig.invariants?.axes ?? []).map((e) => ({ name: e.name, through: [e.through[0], e.through[1]] as [string, string], bones: [...e.bones], tolerance: e.tolerance })),
7558
7560
  // `isBase` is set on the manifest route only; every other image says false.
7559
7561
  basePlates: images.filter((img) => img.isBase).map((img) => img.region),
7560
7562
  meshSlotBudget: rig.invariants?.meshSlots ?? null,
package/src/rig.ts CHANGED
@@ -1757,6 +1757,51 @@ export interface RigInvariants {
1757
1757
  * refused at the gate: it would switch off nothing while reading like it did.
1758
1758
  */
1759
1759
  idleDrivesMeshes?: RigIdleDrivesMeshes;
1760
+ /**
1761
+ * Lines some of the rig's bones are held to: for each entry, the line
1762
+ * through the setup-pose world origins of the two `through` bones, and every
1763
+ * bone in `bones` with its setup-pose world x axis within `tolerance` degrees
1764
+ * of it, as a line — `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (issue #1351).
1765
+ *
1766
+ * 🔑 **Why a bone's axes are the rig's business.** A `scaleY` key squashes
1767
+ * along the bone's own y axis and a `translateY` key moves along it. On a face
1768
+ * painted upright with every bone at rotation 0 that is the face's axis; on a
1769
+ * face painted tilted it is the page's, so a blink squashes the eye along the
1770
+ * page's vertical rather than across the eye line, a brow rises toward the
1771
+ * page's top rather than the forehead — and the parser, the round trip and
1772
+ * every other rule accept it. The author cannot see it. Rotating the bones to
1773
+ * the face's line (their attachments counter-rotated, so the setup pose is
1774
+ * unmoved) is the fix, and this is the declaration that holds it.
1775
+ *
1776
+ * 🚨 **Held whether or not any animation keys the bones.** A consumer writes
1777
+ * them at run time (`openTrack({ adjust })` on the core entry), which no
1778
+ * animation in the file shows, so "nothing keys it" is not "nothing reads it".
1779
+ *
1780
+ * ⚠️ `tolerance` is **required**, finite, above 0 and below 90 — the compiler
1781
+ * does not invent one. Two lines meet at 0° to 90°, so a tolerance of 90 or
1782
+ * more admits every orientation and could never fail; and a bone's world
1783
+ * angle is read off a posed matrix in floating point, so 0 would refuse a
1784
+ * bone rotated exactly onto the line whenever the arithmetic rounds.
1785
+ *
1786
+ * Every name resolves or is refused by name: an unknown bone in `through` or
1787
+ * `bones`, `through` naming one bone twice, a repeated entry `name`. Two
1788
+ * `through` bones that land on one world origin draw no line, and that is
1789
+ * known only once the setup pose is posed — constraints included — so the
1790
+ * gate refuses it, by the entry's name, rather than this parser.
1791
+ */
1792
+ axes?: RigAxis[];
1793
+ }
1794
+
1795
+ /** One line some bones are held to — `invariants.axes` (`A51`). */
1796
+ export interface RigAxis {
1797
+ /** Names the entry in every failure; unique among the rig's axes. */
1798
+ name: string;
1799
+ /** The two bones whose setup-pose world origins draw the line, first to second. Two different bones. */
1800
+ through: string[];
1801
+ /** The bones whose setup-pose world x axis must lie along it. */
1802
+ bones: string[];
1803
+ /** Degrees, required: above 0 and below 90 — see `RigInvariants.axes`. */
1804
+ tolerance: number;
1760
1805
  }
1761
1806
 
1762
1807
  /** The rig's `idle` is meant to deform meshes — `invariants.idleDrivesMeshes` (`A15`). */
@@ -1869,7 +1914,8 @@ export const RIG_KEYS = {
1869
1914
  RigBoneFrom: ['anchor', 'slotWindow', 'meshCenter', 'rotation'],
1870
1915
  RigSlot: ['name', 'bone', 'attachment', 'color', 'dark', 'blend'],
1871
1916
  RigEvent: ['int', 'float', 'string', 'audio', 'volume', 'balance'],
1872
- RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'consumerDrivenMix', 'idleDrivesMeshes'],
1917
+ RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'consumerDrivenMix', 'idleDrivesMeshes', 'axes'],
1918
+ RigAxis: ['name', 'through', 'bones', 'tolerance'],
1873
1919
  RigIdleDrivesMeshes: ['why'],
1874
1920
  RigDetachedRule: ['bone', 'notUnder', 'why'],
1875
1921
  RigDeformFoldExemption: ['slot', 'why'],
@@ -1977,8 +2023,9 @@ export const RIG_TYPES = {
1977
2023
  RigEvent: { int: 'number', float: 'number', string: 'string', audio: 'string', volume: 'number', balance: 'number' },
1978
2024
  RigInvariants: {
1979
2025
  meshSlots: 'number', meshTriangles: 'number', axisBone: 'string', massBone: 'string', detached: 'object[]',
1980
- deformMayFold: 'object[]', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object',
2026
+ deformMayFold: 'object[]', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object', axes: 'object[]',
1981
2027
  },
2028
+ RigAxis: { name: 'string', through: 'string[]', bones: 'string[]', tolerance: 'number' },
1982
2029
  RigIdleDrivesMeshes: { why: 'string' },
1983
2030
  RigDetachedRule: { bone: 'string', notUnder: 'string', why: 'string' },
1984
2031
  RigDeformFoldExemption: { slot: 'string', why: 'string' },
@@ -2312,6 +2359,9 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2312
2359
  at(rule, 'RigConsumerDrivenMix', `invariants.consumerDrivenMix[${i}]`, ['invariants', 'consumerDrivenMix', i]);
2313
2360
  }
2314
2361
  at(raw.invariants.idleDrivesMeshes, 'RigIdleDrivesMeshes', 'invariants.idleDrivesMeshes', ['invariants', 'idleDrivesMeshes']);
2362
+ for (const [i, rule] of (Array.isArray(raw.invariants.axes) ? raw.invariants.axes : []).entries()) {
2363
+ at(rule, 'RigAxis', `invariants.axes[${i}]`, ['invariants', 'axes', i]);
2364
+ }
2315
2365
  }
2316
2366
 
2317
2367
  for (const [skinName, skin] of Object.entries(isObj(raw.skins) ? raw.skins : {})) {
@@ -2777,6 +2827,58 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2777
2827
  }
2778
2828
  }
2779
2829
 
2830
+ // `invariants.axes` (issue #1351) — a field that TURNS A CHECK ON, and every
2831
+ // way an entry could check nothing while reading like it did is refused by
2832
+ // name: a bone that resolves to nothing, a line through one bone, a repeated
2833
+ // name, a tolerance that is missing or admits every orientation. What it
2834
+ // cannot refuse is two `through` bones landing on one world origin: that is
2835
+ // a fact about the posed setup pose, constraints included, which this parser
2836
+ // does not pose — `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` refuses it by the
2837
+ // entry's name.
2838
+ const axes = spec.invariants?.axes;
2839
+ if (axes !== undefined) {
2840
+ const shape = 'an array of { "name": …, "through": [<bone>, <bone>], "bones": [<bone>, …], "tolerance": <degrees> }';
2841
+ if (!Array.isArray(axes)) throw new CompileError(`${where}: invariants.axes is ${JSON.stringify(axes)}, expected ${shape}`);
2842
+ const named = new Set<string>();
2843
+ for (const [i, entry] of axes.entries()) {
2844
+ const field = `invariants.axes[${i}]`;
2845
+ if (!isObj(entry) || typeof entry.name !== 'string' || entry.name.length === 0) {
2846
+ throw new CompileError(`${where}: ${field} needs a "name" — ${shape}`);
2847
+ }
2848
+ const axis = `${field} "${entry.name}"`;
2849
+ if (named.has(entry.name)) throw new CompileError(`${where}: ${field}: invariants.axes names "${entry.name}" twice — a failure names its entry, and two entries of one name could not be told apart`);
2850
+ named.add(entry.name);
2851
+ const through: unknown = entry.through;
2852
+ if (!Array.isArray(through) || through.length !== 2 || !through.every((b): b is string => typeof b === 'string')) {
2853
+ throw new CompileError(`${where}: ${axis}: "through" is ${JSON.stringify(through)}, expected two bone names — the line is drawn through their setup-pose world origins`);
2854
+ }
2855
+ for (const [k, bone] of through.entries()) {
2856
+ if (!seen.has(bone)) throw new CompileError(`${where}: ${axis}: through[${k}] names bone "${bone}", which this rig does not declare — a line through nothing holds nothing`);
2857
+ }
2858
+ if (through[0] === through[1]) {
2859
+ throw new CompileError(`${where}: ${axis}: "through" names bone "${through[0]}" twice — one bone's origin is a point, and a point draws no line`);
2860
+ }
2861
+ const bones: unknown = entry.bones;
2862
+ if (!Array.isArray(bones) || bones.length === 0 || !bones.every((b): b is string => typeof b === 'string')) {
2863
+ throw new CompileError(`${where}: ${axis}: "bones" is ${JSON.stringify(bones)}, expected a non-empty array of bone names — the bones held to the line`);
2864
+ }
2865
+ const held = new Set<string>();
2866
+ for (const [k, bone] of bones.entries()) {
2867
+ if (!seen.has(bone)) throw new CompileError(`${where}: ${axis}: bones[${k}] names bone "${bone}", which this rig does not declare — a name that resolves to nothing is held to nothing, and reads like it was`);
2868
+ if (held.has(bone)) throw new CompileError(`${where}: ${axis}: "bones" names bone "${bone}" twice`);
2869
+ held.add(bone);
2870
+ }
2871
+ const tolerance: unknown = entry.tolerance;
2872
+ if (typeof tolerance !== 'number' || !Number.isFinite(tolerance) || !(tolerance > 0) || !(tolerance < 90)) {
2873
+ throw new CompileError(
2874
+ `${where}: ${axis}: "tolerance" is ${tolerance === undefined ? 'absent' : JSON.stringify(tolerance)}; it must be a number of degrees above 0 and below 90, ` +
2875
+ 'and there is no default — two lines meet at 0° to 90°, so 90 or more admits every orientation and can never fail, ' +
2876
+ 'and 0 refuses a bone rotated exactly onto the line whenever the posed arithmetic rounds',
2877
+ );
2878
+ }
2879
+ }
2880
+ }
2881
+
2780
2882
  // --- skins: the attachment table, and what the skin ACTIVATES --------------
2781
2883
  //
2782
2884
  // The lists resolve by name like everything else in this format, and the
package/src/types.ts CHANGED
@@ -1495,6 +1495,15 @@ export interface RigInfo {
1495
1495
  * `validate <dir>` has no rig, so null, and A15 refuses per bone as before.
1496
1496
  */
1497
1497
  idleDrivesMeshes: string | null;
1498
+ /**
1499
+ * The lines the rig declares some of its bones to lie along, from
1500
+ * `invariants.axes`, in the rig spec's order — each with the two bones whose
1501
+ * setup-pose world origins draw it, the bones held to it and the tolerance in
1502
+ * degrees. Empty is the ordinary case, and `A51` SKIPs on it. `parseRigSpec`
1503
+ * resolved every name and refused every shape before this was built (issue
1504
+ * #1351).
1505
+ */
1506
+ axes: Array<{ name: string; through: [string, string]; bones: string[]; tolerance: number }>;
1498
1507
  /**
1499
1508
  * The atlas region(s) the build names as its base plate — the one image
1500
1509
  * `A19_OVERLAY_PNGS_HAVE_ALPHA` lets be opaque — in compile order.
package/src/validate.ts CHANGED
@@ -105,6 +105,8 @@ import { a06AtlasPageSizeMatchesPng } from './assertions/bodies/a06.ts';
105
105
  import { a19OverlayPngsHaveAlpha } from './assertions/bodies/a19.ts';
106
106
  import { a49PackedFootprintsDoNotOverlap } from './assertions/bodies/a49.ts';
107
107
  import { a50StageBoxIsTheStage } from './assertions/bodies/a50.ts';
108
+ import { a51BonesLieAlongTheirDeclaredAxis } from './assertions/bodies/a51.ts';
109
+ import { inactiveAtSetup, type SetupWorldFacts } from './assertions/facts/setup_world.ts';
108
110
  import { SKIP_NO_STAGE_BOX, type StageBoxFacts } from './assertions/facts/stage_box.ts';
109
111
  import { a27RegionNameMatchesPageFilename } from './assertions/bodies/a27.ts';
110
112
  import type { MeshEntry as MeshAttachmentEntry, MeshFacts } from './assertions/facts/mesh_attachments.ts';
@@ -1309,6 +1311,31 @@ export function spineStageBox(data: ReturnType<SkeletonJson['readSkeletonData']>
1309
1311
  return { asked, why: '', slot: true, held: { type: 'boundingbox', weighted, stored, world } };
1310
1312
  }
1311
1313
 
1314
+ /**
1315
+ * The runtime's supply of `SetupWorldFacts` (issue #1351): each bone's world
1316
+ * frame on a fresh skeleton at the setup pose — `setupPose()`,
1317
+ * `updateWorldTransform(Physics.none)`, no skin set, the pose `spineStageBox`
1318
+ * reads for A50 — posed once, on the first question.
1319
+ */
1320
+ export function spineSetupWorld(data: ReturnType<SkeletonJson['readSkeletonData']>): SetupWorldFacts {
1321
+ let skeleton: Skeleton | null = null;
1322
+ return {
1323
+ frameOf: (name) => {
1324
+ if (data.findBone(name) === null) return null;
1325
+ if (skeleton === null) {
1326
+ skeleton = new Skeleton(data);
1327
+ skeleton.setupPose();
1328
+ skeleton.updateWorldTransform(Physics.none);
1329
+ }
1330
+ const bone = skeleton.findBone(name);
1331
+ if (bone === null) return null;
1332
+ if (!bone.active) return inactiveAtSetup(name);
1333
+ const { a, c, worldX, worldY } = bone.appliedPose;
1334
+ return { worldX, worldY, a, c };
1335
+ },
1336
+ };
1337
+ }
1338
+
1312
1339
  /** The box a `rigc-compiled/3` document's stage asks for, with that stage — or the SKIP's reason where nothing asks (`spineStageBox`). */
1313
1340
  function documentStageBox(modelText: string | undefined): StageBoxFacts['asked'] & object | string {
1314
1341
  if (modelText === undefined) return SKIP_NO_STAGE_BOX;
@@ -3422,6 +3449,28 @@ export function validate(input: ValidateInput): ValidateReport {
3422
3449
  // re-measure containment for the scaled contour and say so.
3423
3450
  check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => a30StrokeWithinCapContainment(verdicts, rawBoneTimelines(raw), input));
3424
3451
 
3452
+ // --- A51: the bones a rig declares to share an axis lie along it ---------
3453
+ //
3454
+ // ⭐ A `scaleY` key squashes along the bone's own y axis and a `translateY`
3455
+ // key moves along it, so on a face painted tilted with its bones at rotation
3456
+ // 0 a blink squashes the eye along the page's vertical rather than across the
3457
+ // eye line — measured on a seated rig whose eye line is 23.7° off the page,
3458
+ // where all five face bones sat at 0 and the closed eyes came out level on a
3459
+ // tilted face (issue #1351). The file loads, every other rule passes, and the
3460
+ // picture is wrong: the author cannot see it, which is the case this gate is
3461
+ // for. The rig declares the line (`invariants.axes`) and the bones held to
3462
+ // it; this measures their setup-pose world x axes against it, as A50 poses
3463
+ // the setup pose.
3464
+ //
3465
+ // 🔒 **Validity, not archetype.** It fires only on a declaration, so it
3466
+ // cannot fire on foreign data — the reason archetype rules leave the default
3467
+ // profile — and a declaration the default profile never read would be one
3468
+ // nothing reads. And it holds every declared bone, keyed or not: a consumer
3469
+ // writes them at run time (`openTrack({ adjust })`), which no animation shows.
3470
+ check('A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS', () =>
3471
+ skeletonData === null ? skip('A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS', SKIP_NO_SKELETON) : a51BonesLieAlongTheirDeclaredAxis(verdicts, spineSetupWorld(skeletonData), input),
3472
+ );
3473
+
3425
3474
  // --- A18: determinism ----------------------------------------------------
3426
3475
  // The body is `./assertions/bodies/a18.ts` (issue #1060): the model document's clause, which the model side
3427
3476
  // runs too, and the Spine pair's two clauses handed to it as this side's encoding findings, in their order.