spine-rigc 1.5.3 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/AUTHORING.md CHANGED
@@ -195,7 +195,7 @@ What the flags mean:
195
195
  | --- | --- |
196
196
  | `--rig` | the rig spec — skeleton structure |
197
197
  | `--motion` | the motion spec — time |
198
- | `--out` | directory for `skeleton.json` + `skeleton.atlas`; atlas page paths and `skeleton.images` are written relative to it. On `check` it is a directory of **pictures** instead: one per frame the table lists (every compared frame under `--all-frames`), reference · candidate · difference · overlay at the comparison grid's native size, as `<dir>/<set>/f####.png` beside a `frames.json` that says what they are pictures of. Each `<dir>/<set>/` is cleared first; a file at `<dir>`, or a directory that is or holds `--frames`, is refused by name — **§9.2.1** |
198
+ | `--out` | directory for `skeleton.json` + `skeleton.atlas`, and on `build` `skeleton.model.json` beside them; atlas page paths and `skeleton.images` are written relative to it. `skeleton.model.json` is rigc's own record of the compiled rig (`rigc-compiled/1`: the bones, slots, skins, constraints, events and animations the Spine files were emitted from, in the spec's order, and the rest of what posing them takes), written after the same gate as the pair and ending in `spine.sha256`, the digest of the `skeleton.json` written beside it. It is what rigc's own posing core (`src/core/`) poses: `render` and `check` — and `bench --frames`, which folds `check` in — pose a build through it, and `--poser` below says when they do not. On `check` it is a directory of **pictures** instead: one per frame the table lists (every compared frame under `--all-frames`), reference · candidate · difference · overlay at the comparison grid's native size, as `<dir>/<set>/f####.png` beside a `frames.json` that says what they are pictures of. Each `<dir>/<set>/` is cleared first; a file at `<dir>`, or a directory that is or holds `--frames`, is refused by name — **§9.2.1** |
199
199
  | `--copy-images` | `build` only: also copies every page **the emitted atlas names** into `--out` and rewrites the atlas to the copies, so the directory is self-contained enough to zip or commit on its own, and points `skeleton.images` at `--out` itself so the editor's import finds the parts beside the skeleton (§3.1 says why it is spelled `../<out>/` and not `./`). Under `--atlas-in` those pages are the pack's, not one per part (**§0.2**). Without it, page paths point at the source art |
200
200
  | `--pack` | `build` only: arrange every part onto **shared** atlas page(s), written into `--out` as real PNGs, instead of one page per part. Lossless — nothing is resampled, trimmed or rotated. The default is one page per part — **§0.1** |
201
201
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
@@ -210,6 +210,7 @@ What the flags mean:
210
210
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`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 |
211
211
  | `--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 |
212
212
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
213
+ | `--poser` | `render` and `check`: `core` or `spine` — which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes, with the atlas beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core. Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `the "default" skin names` (no `--skin`, and the default skin names a skin-required bone or constraint, each named) · `and no "default" one` (no `--skin`, and skins with none named `default` — spine-core draws nothing there either, so the render stops at `there is nothing to draw`, exit 2, and only `--poser core` prints this refusal) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row |
213
214
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
214
215
  | `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
215
216
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
@@ -234,7 +235,10 @@ spine-core's own `SkeletonClipping`, in spine-webgl's call order — so a slot f
234
235
  the clip's through its `end` slot draws only what falls inside the polygon, in
235
236
  `render`, `check` and every other picture rigc's own rasteriser draws (`preview`
236
237
  plays in Spine's player, which always clipped); the framing box still counts
237
- what the clip removes, so adding or keying a mask moves no pixel it leaves drawn. Three commands take `--out`: a directory for `render`
238
+ what the clip removes, so adding or keying a mask moves no pixel it leaves drawn,
239
+ and it never counts a slot whose bone the posed skin leaves unposed (a `"skin": true`
240
+ bone the skin does not name, or any bone below one, §3.4.1), which draws no pixel;
241
+ when every drawn slot is such a slot, the render is refused by name (§3.4). Three commands take `--out`: a directory for `render`
238
242
  (default `render/`), the `.html` file for `preview` (default `preview.html`) and
239
243
  for `vote` (default `ballot.html`).
240
244
 
@@ -661,8 +665,9 @@ written against — about a page that is refused either way; and a size-matching
661
665
  WebP page let through that way builds green under the default profile and writes
662
666
  a directory whose next `render` refuses the page.
663
667
 
664
- **What `--out` holds afterwards:** `skeleton.json` and a `skeleton.atlas` that is
665
- the pack, page paths pointing back at the pack's own PNGs — so `rigc validate
668
+ **What `--out` holds afterwards:** `skeleton.json`, the model document
669
+ `skeleton.model.json`, and a `skeleton.atlas` that is the pack, page paths
670
+ pointing back at the pack's own PNGs — so `rigc validate
666
671
  <that directory>` reads it green with no flags, exactly as it reads a loose
667
672
  build's. Add `--copy-images` and the pack's page PNGs are copied in beside the
668
673
  skeleton and the page names become their basenames, which is the same directory
@@ -1332,7 +1337,7 @@ that is a defect in this guide: report it.
1332
1337
  | `x`, `y` | setup-pose bounding box origin | `0` — and refused outright beside a stated absence, below |
1333
1338
  | `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** |
1334
1339
  | `fps` | nonessential editor hint | `SkeletonData.fps` stays 30 |
1335
- | `referenceScale` | 4.2+ physics/scale reference | parser default 100 |
1340
+ | `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) |
1336
1341
  | `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 |
1337
1342
  | `audio` | nonessential: where the editor looks for the skeleton's audio files, as a path from the skeleton file — a string, or `null` for none | **not written unless you state it.** rigc has no audio to point at, so this is a value a spec states or does not; stated, it is carried verbatim, `null` included, because `null` is what an editor export writes when no audio folder is set (all twelve under `examples/` do) and `ingest` carries it from there. Anything but a string or `null` is a compile error naming the value |
1338
1343
 
@@ -1437,7 +1442,7 @@ and the inheritance silently falls back to Normal — assertion `A02` refuses it
1437
1442
  | `attachment` | the **setup pose** attachment name, or `null` for "show nothing" | must come from here or from `motion.setup` (R3) — **except** on a slot nothing fills, where it can only be `null` and may be left out |
1438
1443
  | `color` | `rrggbbaa` tint | opaque white |
1439
1444
  | `dark` | two-colour tint, `rrggbb`. The **setup** half; §4.4's `rgba2` and `rgb2` tracks key it over time and require it | — (🚫 `A12` under `spine-html`) |
1440
- | `blend` | `normal` · `additive` · `multiply` · `screen` | `normal` |
1445
+ | `blend` | `normal` · `additive` · `multiply` · `screen`. Only the first letter's case is free: `Additive` reads as `additive`, while `ADDITIVE` or `mUlTiPlY` reads as **no mode** in the runtime with no error, so `build` refuses it by name (§5.1). Emitted as written | `normal` |
1441
1446
 
1442
1447
  ✅ **Every slot you declare is emitted, in this order.** A slot nothing fills — no
1443
1448
  skin entry, no manifest part — is emitted **empty**: `name` and `bone`, and no
@@ -1532,6 +1537,23 @@ or a timeline key at the time given — and, for a rig spec you wrote, in `rigc
1532
1537
  which prints every bone's local transform. A frame's time is the framing pass's own
1533
1538
  (every animation at 60 fps); the key that overflowed is at or just before it.
1534
1539
 
1540
+ 🦴 **Nor is it said of a skeleton whose every drawn slot hangs from a bone the skin
1541
+ leaves unposed.** A `"skin": true` bone the posed skin does not activate — or any bone
1542
+ below one — has no world transform, and both posers draw its slots through the zero
1543
+ matrix: every vertex at the origin, a box with no extent, and a frame that would be
1544
+ 0x0. That is refused by name before anything is written, exit 2 —
1545
+ ``rigc render: under no skin, every drawn slot hangs from a bone that skin leaves
1546
+ unposed — 1 drawn slot: slot "block" on bone "block", which skin "extra" poses — so no
1547
+ drawn vertex has a world transform and the frame would be 0x0: pose it under a skin
1548
+ that poses those bones (`--skin`), or name the bones in the skin it is posed under``.
1549
+ The fix is the skin, not the art: render under one of the skins named, or list the
1550
+ bone in the skin you render (§3.4.1). The same sentence comes from `render
1551
+ --geometry`, from `rigc check` (as `rigc check: …`), and from `framingViewport` as an
1552
+ `UnframeablePoseError`, whichever poser runs; it names the first three drawn slots,
1553
+ then how many more. Its sibling, `… every drawn vertex sits at the one point (x, y) —
1554
+ …`, is a pose whose bones ARE posed and collapse every vertex onto one point — a bone
1555
+ scaled to 0 on a static rig does it — and there the fix is the bone.
1556
+
1535
1557
  **Region attachment** ([Spine: region attachments](http://esotericsoftware.com/spine-regions)),
1536
1558
  the default `type`:
1537
1559
 
@@ -2191,6 +2213,13 @@ by 245 levels and the same sheet reports **6.08°** — and `A39` refuses at bot
2191
2213
  to 0°. The number is not lying. 99 % of that mesh still turns to 64.80° and one
2192
2214
  triangle does not, and nothing on the first line says so.
2193
2215
 
2216
+ When several triangles fold at the same angle to the six decimals the report
2217
+ carries, the triangle named is the **lowest-numbered** of them — the one `A39`
2218
+ names first when a key turns just past the ceiling, since it lists the
2219
+ triangles a key reverses in ascending order. The choice is made on the reported
2220
+ angle rather than on the full double, so a platform's last-bit difference in
2221
+ `atan` or `pow` does not change which triangle, step and share are printed.
2222
+
2194
2223
  | the figure | how to read it |
2195
2224
  | --- | --- |
2196
2225
  | `x1.003` — the **1st percentile over the ceiling** | near 1 means a *band* of the mesh reaches the limit together, and near 10 means **one triangle** does, which is what a bad texel looks like. The clean and stray sheets above read `x1.003` and `x10.652`. ⚠️ A band is **not** sufficient evidence of a form, which is the third case: **an outline is a band**. A depth sheet estimated over cut-out art has a cliff along the whole silhouette, so its ceiling is a band too and this figure reads 1.02–2.17 — healthy — on a mesh whose angle means nothing. The row below is what tells those two apart |
@@ -5620,6 +5649,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5620
5649
  | `hull vertices must trace the outline in order; the triangles' outline runs …, so vertex a has to follow vertex b in the list, and vertex c does` | §3.4 — renumber along the printed walk |
5621
5650
  | `the triangles do not tile the outline: …` / `the triangles' outline is not one closed loop: …` | §3.4 — a doubled triangle, an unused vertex, a pinch or a hole in `triangles` |
5622
5651
  | `vertex N binds bone "X", which the rig does not declare as a bone` | §3.4 — an authored mesh's `weights` bind by NAME, like everything else in a rig spec. Fix the spelling, or declare the bone. The message names the skin, the slot, the placeholder and the vertex, because an index would name none of them |
5652
+ | `vertex N binds bone index I, which is not in the bone list` | §3.4 — a `"boneIndexing": "raw"` run on a mesh, bounding box, clipping polygon or path names a bone by its place in the bone array, and I is no place in it: past the end, negative or not a whole number. Count the rig's `bones` from 0, or give the bindings by name as `weights`, which cannot miss this way. ⚠️ **Refused earlier, not newly** (issue #917): the run is decoded to bone names once, when the attachment is built, and this is where an index that names no bone stops. Before that a mesh compiled and `A20` named the index at the gate, a bounding box, clipping polygon or path with its `lengths` stated compiled and `A33` named it, and only a path whose `lengths` rigc measures refused here, in this sentence. `MV04` plants it on each of the four kinds |
5653
+ | `the raw weighted run has a bone count of C at index I` / `the raw weighted run is truncated — vertex N claims C bone(s) and the array ends first` / `the raw weighted run decodes to N vertices but there are M uv pairs` | §3.4 — a mesh's `"boneIndexing": "raw"` run is not `boneCount, (index, x, y, weight) × boneCount` per vertex, one vertex per uv pair. These are the polygon's three sentences (whose last clause there reads `but vertexCount is M`), now asked of a mesh as well. Fix the run, or give the bindings by name as `weights`. ⚠️ **Refused earlier, not newly** (issue #917): the mesh run is decoded to bone names when the mesh is built, and a run that does not decode cannot be. Before that it compiled and `A20` named it at the gate (`has weights for 5 vertices but 4 uv pairs`, `vertex 3 has weight NaN`). `MV04` plants each |
5623
5654
  | `image "X.png" is not on disk at …` | fix the name, or point `--images` at the right directory |
5624
5655
  | `image "X.png": /…/X.png is a WebP image (…), not a PNG: its first 12 byte(s) are …` | the file is there and is not a PNG — the name ends in `.png` and the bytes decide. Re-export it as PNG; the same sentence says **truncated** for a PNG that runs out before its `IEND`, and then the repair is a whole copy (§0.2) |
5625
5656
  | `a "contour" generator traces the part's own alpha, and "X.png" is lifted off a packed page at the coordinates the atlas states, which on this file are not where its texels are — so there is no silhouette here to trace, only another part of the page. page "p.png" declares …` | the page's PNG is not the size its atlas declares, and the rest of the message is `A06`'s sentence for it: re-declare the page with the `scale:` header it names, or re-export the page at its declared size (§0.2) |
@@ -5688,6 +5719,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5688
5719
  | `"lengths"[i] is V, below …` / `"lengths"[i] is "…"; every entry is a finite cumulative length` | §3.4 — the array is cumulative, so each entry is at least the one before it |
5689
5720
  | `rig constraint "X": slot "Y" has no path attachment in any skin` | §3.5.1 — give that slot a `"type": "path"` attachment, or aim the constraint at the slot that has one |
5690
5721
  | `rig constraint "X": rotateMode is "CHAINSCALE"; known: Tangent, Chain, ChainScale` | §3.5.1 — only the first letter's case is free; anything else resolves to `undefined` in the parser |
5722
+ | `<file>: slot "X" has blend "ADDITIVE"; known: normal, additive, multiply, screen (only the first letter's case is free — …)` | §3.3 — write the mode in lower case, or with only its first letter upper-cased. `SkeletonJson` reads `blend` through `Utils.enumValue`, which upper-cases the first character and looks the rest up exactly, so `ADDITIVE` loaded as no mode at all (`tools/pose_oracle.ts dump` reads it as `null`), with no error. rigc used to compare the name case-insensitively and build that file green |
5691
5723
  | `rig constraint "X": applies animation "Y", which the motion spec does not declare (it declares: …)` | §3.5.2 — fix the slider's `animation`, or add it to the motion spec |
5692
5724
  | `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
5693
5725
  | `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
@@ -5819,9 +5851,9 @@ Fix A00 and run it again.
5819
5851
  | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | the `idle` animation keys a bone that drives a mesh, directly or as a control bone. The rule assumes meshes are mostly static — the renderer skips redrawing a mesh nothing moved — so it has three states. **FAIL per bone** when the rig declares nothing: `idle keys bone "X", which drives a mesh — meshes never idle-skip`, and the **first** of those lines, once per run, names the case: `K bone(s) keyed by idle drive meshes; if this idle is meant to deform them (a painting rig), declare invariants.idleDrivesMeshes: { "why": … } in the rig spec — or, where the motion belongs to a pivot above the mesh, key that pivot one link up (FACE.md §3)`. It rides the first finding, so the count of FAIL lines is still the count of bones. **SKIP with the cost** when the rig declares `invariants.idleDrivesMeshes` (§3.7): `declared by the rig ("<why>"): idle keys N bone(s) that drive M mesh attachment(s) totalling V vertices — "a", "b" — and each of those meshes is recomputed on every frame it is shown`. **FAIL on a stale declaration** — declared, and the `idle` keys no mesh-driving bone, carries no bone timeline or does not exist: `the rig "X" declares invariants.idleDrivesMeshes ("…"), but "idle" keys no bone that drives a mesh, so the declaration switches off nothing — remove it`. Otherwise **SKIP** when there is no `idle` animation, or when the one there is carries no bone timeline — a rule whose subject does not exist is unmeasured and not satisfied |
5820
5852
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
5821
5853
  | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. **SKIP** when the atlas declares no page — as it is for `A06`, `A19` and `A27`; see `A07` |
5822
- | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
5854
+ | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed — in `skeleton.json`, `skeleton.atlas` or the model document `skeleton.model.json`, the one named (the document's first differing line with it). That is a compiler bug, not a spec bug — report it |
5823
5855
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part cannot draw a transparent pixel, so it would paint a solid rectangle over what is behind it. **The texels decide, on both routes**; the file header is only the fast negative. A file with no alpha channel (colour type 4 or 6) and no `tRNS` chunk has nowhere to keep a clear texel and is refused without being opened, by a sentence that names its colour type — so re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. A file whose header says it **could** be transparent is opened and read until its first texel below full alpha; one clear texel passes, and none is refused in the words the packed route uses for a region, naming the file and what it can hold — `is opaque in every one of its 200x80 texels` … `its file can hold transparency — colour type 6 (truecolour + alpha) carries an alpha channel — and no texel uses it`. Saving as RGBA is not the repair: the runtime draws the texels, not the declaration. On a loose page the file is the part, so the whole decoded image is the rectangle and no atlas coordinate is read. Only the base plate may be opaque, and which image that is is decided once for both routes: the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art. ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page**: a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it**, and the clause above does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so a scan would come back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappears**, the scan finding a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it**: one row per page naming its parts and pointing at `A06`, which names what the file is. **SKIP** when the atlas declares no page |
5824
- | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not. Fix the rig spec's `controls`/`chain`, a `segments` generator's `bones` (its `influence` line names a bone no vertex binds), or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
5856
+ | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. On a rig spec's `"boneIndexing": "raw"` mesh an index out of range or a run that does not decode is refused at compile first (§5.1, issue #917), so here it names a skeleton rigc did not compile. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not. Fix the rig spec's `controls`/`chain`, a `segments` generator's `bones` (its `influence` line names a bone no vertex binds), or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
5825
5857
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1. **SKIPs** by name on authored or linked geometry and on a `segments` mesh, whose outline is weighted by distance like the rest of it — none of them claims a pinned rim |
5826
5858
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment |
5827
5859
  | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, rests at `mix: 0` with **no timeline in any animation keying that `mix` above 0**, has `mass: 0`, has `strength` at or below 0 — `0` says `nothing pulls it back`, below 0 says the offset `is pushed away and grows with every step`, both read off the row's `outside` arms, which the key's refusal quotes too — or has `damping` outside `[0, 1]`, which says `` physics "C" has damping 1.5; must be inside [0, 1] — the per-step decay is `damping ** (60 * step)`, and the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: above 1 every velocity grows on every step and the offset diverges ``, the bound read off the row the key's refusal reads; `1` and `0` are inside, since both are finite at every rate — **at rest, and on every physics timeline key**. The timeline arm reads each key through the runtime's own `PhysicsConstraint*Timeline.set`, so a keyed `mass` is judged as the `massInverse` it becomes, and the detail names the animation, the constraint, the key time, the value and the bound. Two differences between the two arms, and the runtime is the reason for both: a **key** of `mix: 0` is accepted, because `update` opens with `if (mix === 0) return;` and muting a constraint for a stretch is what a mix timeline is for — the editor's own `sack-pro` example keys it there on 24 of its 36 mix keys — and a **key** of `strength: 0` is accepted, because it releases the constraint for the span with `damping` and `inertia` still applied and the next key pulls the offset back, measured through spine-core at no NaN, a coast to a limit and a return in 54 steps. As a **setup** value `strength: 0` is still refused by the arm above, and `mix: 0` is refused only when nothing keys it above 0. The `mix` branch above is why `mix` is the one setup value a key can answer for: at rest the constraint is **inert** rather than broken, so a rig that rests muted and is keyed above 0 is refused by nothing, while a rig resting at `mass: 0` is `massInverse` Infinity before anything plays and no key reaches back into that. The detail of the refusal says both halves and how many animations were searched: `` physics "C" has mix 0 and none of the 3 animations keys its mix above 0; the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: at 0 `update` returns before it does anything (`PhysicsConstraint.js:109-111`), so the constraint is muted — rest it above 0, or key its mix above 0 in an animation ``. ⚠️ Every reason A23 prints for a setup value is that value's row `basis` arm (§4.4's table) — an arithmetic arm names its expression (`physics "C" has massInverse Infinity; mass must be > 0 — at 0 …`), a behavioural one says it is rigc's call and what the value does. A setup `mix` **below** 0 is told the jiggle is applied inverted, not that it is muted: such a constraint is measured moving its bone by exactly the opposite of a positive mix. The search counts the unnamed global timeline for every constraint whose own `mixGlobal` is set, reads each key through the runtime's accessor, counts every sample of a Bezier between two keys as a value the timeline poses — so two keys of 0 joined by a curve lifted above 0 are a rescue, measured to move the bone — and takes an animation a slider applies like any other. It is the one reading `A36` and `A37` use as well. `inertia`, `wind`, `gravity` and the top of `mix` are bounded nowhere, at rest or keyed. `ingest` does not carry a constraint that drives no component into the spec it writes: it omits it with its timelines and reports `PHYSICS_DRIVES_NOTHING` ([INGEST §2.0](INGEST.md)), so this sentence is met on a file, never on a decompiled rebuild. **SKIP** when the skeleton declares no physics constraint — the same sentence `A36` and `A37` print for their own constraint types |
@@ -5834,7 +5866,7 @@ Fix A00 and run it again.
5834
5866
  | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | the animation drives past the measured containment ceiling, or scales a bone in the axis subtree |
5835
5867
  | `A31_DRAW_ORDER_OFFSETS_RESOLVE` | both | a draw-order key names a slot the skeleton does not have, offsets one slot twice, puts a slot outside the slots array, or lists its offsets out of slot order (§4.7). The only assertion that runs **before** `A00` — the last of those shapes makes the loader spin rather than return, so the round trip is refused instead of attempted |
5836
5868
  | `A32_EVENT_KEYS_RESOLVE` | both | an event key fires a name the skeleton's `events` block does not declare, sits earlier in time than the key before it, or sets `volume`/`balance` on an event with no `audio` (§4.8). **SKIP** when no animation carries an event timeline |
5837
- | `A33_VERTEX_ATTACHMENT_GEOMETRY` | both | a bounding box, clipping polygon or path whose `vertexCount` is missing or disagrees with its vertex array, a weighted run that decodes to the wrong number of vertices or an out-of-range bone index, a clipping `end` naming a slot the skeleton does not have, a path whose vertex count is not a multiple of 3, or a path `lengths` array that does not strictly increase (§3.4). **SKIP** when the skeleton carries none of the three |
5869
+ | `A33_VERTEX_ATTACHMENT_GEOMETRY` | both | a bounding box, clipping polygon or path whose `vertexCount` is missing or disagrees with its vertex array, a weighted run that decodes to the wrong number of vertices or an out-of-range bone index (on a rig spec's raw run rigc refuses both at compile first — §5.1, issue #917), a clipping `end` naming a slot the skeleton does not have, a path whose vertex count is not a multiple of 3, or a path `lengths` array that does not strictly increase (§3.4). **SKIP** when the skeleton carries none of the three |
5838
5870
  | `A34_CONSTRAINT_TIMELINE_TARGETS` | both | an `ik`, `transform`, `path`, `physics` or `slider` timeline names a constraint the skeleton does not declare, names one of another type, or carries no keys at all (§4.4, §4.9, §4.10, §4.12). The last is silent: the parser reads key 0, finds nothing, and skips the timeline. The **empty** name under `physics` is not a miss — it is the timeline that names no constraint (§4.4's `"*"`), and it is refused only when it reaches none: `animation "A" physics constraint "" timeline "P": a physics group that names no constraint writes every physics constraint declaring "PGlobal": true, and none of "C", … does — the parser loads it, the runtime walks every constraint, and no constraint takes the key`. Who it reaches is asked of the runtime's own `PhysicsConstraintTimeline.global` on the file's constraints — the reading `A23` and `A42` share. **SKIP** when no animation carries one |
5839
5871
  | `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT` | both | a deform key's run runs past the end of the attachment's deform array, holds a non-finite number, has an empty key array, or names a skin/slot/attachment triple that does not resolve (§4.11). The overrun is the quiet one — the parser copies into a `Float32Array` and drops the tail. ⛔ It does **not** require pair alignment: the runtime has no such rule and a trimmed editor run legitimately starts and ends mid-pair (§4.11). **SKIP** when no animation carries a deform timeline |
5840
5872
  | `A36_PATH_CONSTRAINT_EFFECTIVE` | both | a path constraint whose slot has no path attachment in any skin, one that constrains no bone, or one whose three mixes are all 0 at setup with no animation keying any of them **above 0** (§3.5.1, §4.12). The first is the quiet one: `update()` returns on its first line and the constraint reports mixes it never applies. The third reads the values the loaded timeline poses — keys and Bezier samples alike — the way `A23` reads a physics `mix`, so a `mix` timeline keying 0 only is not a rescue: [measured] it poses every bone exactly where no timeline does. The detail says both halves: `path constraint "P" has mixRotate 0, mixX 0 and mixY 0 at setup and none of the 2 animations keys its mix above 0; update() returns on all-zero mixes, so nothing ever puts a bone on the path — rest one of the three above 0, or key its mix above 0 in an animation`. **SKIP** when the skeleton declares no path constraint |
@@ -6570,6 +6602,21 @@ remember:
6570
6602
  skin alone)`. `check.json` carries the same two under `skin` and
6571
6603
  `referenceSkin`.
6572
6604
 
6605
+ 🔎 **The report also names the poser that drew the candidate**, on a `poser` line
6606
+ under `skin`, in the words `render` prints (issue #968):
6607
+ `poser rigc core — …/skeleton.model.json` for a rigc build the core poses, and
6608
+ `poser spine-core — <why>` otherwise, `<why>` in the words the `--poser` row of
6609
+ the flag table near the top lists — a Spine export (`no skeleton.model.json
6610
+ beside …`), a `skeleton.json` edited after the build (both digests named), a
6611
+ refusal of the core's, or `--poser spine`, among them. `check.json` carries it as
6612
+ `"poser": { "name": "core" | "spine", "note": "<the line>" }`, and `--out`'s
6613
+ `frames.json` under `comparison.poser`. One choice per run: every set, the setup
6614
+ pose and the non-finite sentence are posed through the same poser, and a core
6615
+ refusal partway re-poses all of it on spine-core. `--poser core|spine` forces the
6616
+ choice with `render`'s semantics (`core` on an input that cannot carry it exits 2,
6617
+ nothing written). The two posers are measured to write the same report, byte for
6618
+ byte but the poser line, on every tree row.
6619
+
6573
6620
  A skin name the candidate does not declare is refused with the ones it does —
6574
6621
  `the candidate declares no skin "path"; it declares [default, patch, torn]`.
6575
6622
 
@@ -8522,10 +8569,11 @@ instead:
8522
8569
  - **A mesh attachment writes `path` and `color` right after `type`, `path` before `color` — measured on a production set (#791), not on `examples/`.**
8523
8570
  18 meshes whose `path` differs from their name wrote it second, and 1 with a
8524
8571
  `color` wrote that second; no mesh carried both. The row does not list them,
8525
- so the position is the constructor's — `meshTextureKeys` in
8526
- [`src/compile.ts`](../src/compile.ts), which every mesh route spreads right
8527
- after `type`. A region's `path` and a linked mesh's keys stay where
8528
- they were: neither was in the set, and an analogy is not a measurement.
8572
+ so the position is the constructor's — `emitMesh` in
8573
+ [`src/emit_spine.ts`](../src/emit_spine.ts), which writes the two keys every
8574
+ mesh route computes with `meshTextureKeys` right after `type`. A region's
8575
+ `path` and a linked mesh's keys stay where they were: neither was in the set,
8576
+ and an analogy is not a measurement.
8529
8577
  - **A skin's slot keys are compared after `foldBeforeComparing`, which reads U+3000 as U+0020 and U+FF10–U+FF19 as U+0030–U+0039** —
8530
8578
  the ideographic space as a space, the full-width digits as `0`–`9`
8531
8579
  (`EDITOR_NAME_FOLD`). One map of 95 slot keys, 7 of them carrying those
@@ -8984,7 +9032,8 @@ near zero. A generated cut also disagrees with its picture, which can only make
8984
9032
  harder to place. So the edge is where `pose` fails even on ideal cuts, and never a
8985
9033
  guarantee above it.
8986
9034
 
8987
- Re-run it with `bun tools/pose_floor.ts --textures <level> --json <out>` (one level per
9035
+ Re-run it from a clone of the repository — the tool is repository material, not in the
9036
+ published package — with `bun tools/pose_floor.ts --textures <level> --json <out>` (one level per
8988
9037
  process parallelises it), then `bun tools/pose_floor.ts --from <a.json>,<b.json>,…` to
8989
9038
  print the table, derive the edge and list the misses per cell. `PO22` in
8990
9039
  `bun run selftest` re-measures three of its cells, `PO24` holds those three cells to
package/docs/INGEST.md CHANGED
@@ -189,6 +189,7 @@ rigc render --candidate examples/spineboy/export/spineboy-pro.json \
189
189
  rigc render
190
190
  .. skeleton …/examples/spineboy/export/spineboy-pro.json
191
191
  .. atlas …/examples/spineboy/export/spineboy.atlas
192
+ .. poser spine-core — no skeleton.model.json beside …/examples/spineboy/export/spineboy-pro.json — a Spine export, not a rigc build
192
193
  .. 200x186px at 8 fps, 1 set(s) -> …/render/sb
193
194
  .. walk 9 frame(s), 1.000s + contact.png -> …/render/sb/walk@8fps
194
195
  rigc: wrote …/render/sb/frames.json
package/docs/MOTION.md CHANGED
@@ -1057,6 +1057,7 @@ rigc build --rig semaphore.rig.json --motion semaphore.motion.json --images part
1057
1057
  .. pages=3 regions=3 bones=4 slots=3 animations=1 version=4.3.13 regionAttachments=3 meshAttachments=0 physicsConstraints=0 rig=semaphore profile=spine
1058
1058
  rigc: wrote …/semaphore/spine/skeleton.json
1059
1059
  rigc: wrote …/semaphore/spine/skeleton.atlas
1060
+ rigc: wrote …/semaphore/spine/skeleton.model.json
1060
1061
  rigc: look at it: rigc preview --candidate …/semaphore/spine
1061
1062
  ```
1062
1063
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.5.3",
3
+ "version": "1.6.2",
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, round-trips it through spine-core and named assertions, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/atlas.ts CHANGED
@@ -397,6 +397,31 @@ export function parseAtlasText(text: string): ParsedAtlas {
397
397
  return { pages, regions, lines };
398
398
  }
399
399
 
400
+ /**
401
+ * The region an attachment draws under a name, and the page it sits on —
402
+ * `TextureAtlas.findRegion` and `region.page`, as the loader resolves a
403
+ * region, a mesh and each frame of a series (issue #967).
404
+ *
405
+ * The FIRST region of that name in file order: an atlas naming two regions
406
+ * alike draws the first (measured through spine-core 4.3.13's loader, whose
407
+ * attachment drew the first of two regions named `seq`, and whose `index:`
408
+ * lines did not enter the lookup — a series' frame is found by its NAME,
409
+ * `frameRegionName` in [`src/core/uvs.ts`](core/uvs.ts)). The name is matched
410
+ * exactly as `parseAtlasText` keeps it, the raw line: a region line `art `
411
+ * is not found as `art`, as the runtime does not find it.
412
+ *
413
+ * Returned as a function over the parsed atlas so the core — which reads no
414
+ * file and links nothing — takes it as an input (`UvLookup`); the page UVs
415
+ * themselves are the core's (`regionPageUvs`, `computeUvs`), measured there.
416
+ */
417
+ export function atlasRegionLookup(parsed: ParsedAtlas): (name: string) => { page: AtlasPage; region: AtlasRegion } | null {
418
+ const first = new Map<string, { page: AtlasPage; region: AtlasRegion }>();
419
+ for (const page of parsed.pages) {
420
+ for (const region of page.regions) if (!first.has(region.name)) first.set(region.name, { page, region });
421
+ }
422
+ return (name) => first.get(name) ?? null;
423
+ }
424
+
400
425
  /**
401
426
  * The same atlas text with every page's name line replaced.
402
427
  *
package/src/check.ts CHANGED
@@ -81,6 +81,13 @@ import {
81
81
  FRAMES_SIDECAR,
82
82
  FRAMES_SPEC,
83
83
  nonFinitePoseOf,
84
+ unframeableSentence,
85
+ UnframeablePoseError,
86
+ skinRosterOf,
87
+ candidatePosers,
88
+ spinePoser,
89
+ throughPoser,
90
+ PoserChoiceError,
84
91
  SHEET_COLUMNS,
85
92
  SHEET_FILE,
86
93
  SHEET_GAP,
@@ -89,6 +96,9 @@ import {
89
96
  type FramesSidecar,
90
97
  type FrameSet,
91
98
  type PoseOptions,
99
+ type Poser,
100
+ type PoserChoice,
101
+ type PoserName,
92
102
  type TextureSubstitution,
93
103
  type Viewport,
94
104
  } from './render.ts';
@@ -883,6 +893,19 @@ export interface TextureFromReport {
883
893
 
884
894
  export interface CheckReport {
885
895
  candidate: { skeleton: string; atlas: string };
896
+ /**
897
+ * Which implementation of the posing seam posed the CANDIDATE, and why
898
+ * (issue #968): `core` — rigc's own, reading the `skeleton.model.json` a
899
+ * build writes beside the pair — or `spine` (spine-core), with the reason the
900
+ * core was not used. `note` is `render`'s `poser` line, word for word.
901
+ *
902
+ * In the report because a figure is only readable beside what produced it:
903
+ * the reference side is pixels either way, and a report that did not say
904
+ * which poser drew the candidate would be a number with two possible
905
+ * subjects. The two are measured to give the same report on every tree row;
906
+ * this field is what lets a reader see which one this is.
907
+ */
908
+ poser: { name: PoserName; note: string };
886
909
  framesDir: string;
887
910
  framesRoot: string;
888
911
  /**
@@ -1024,6 +1047,36 @@ export interface CheckOptions {
1024
1047
  * in beside the comparison, from the plates the comparison was computed on.
1025
1048
  */
1026
1049
  plates?: CheckPlates;
1050
+ /**
1051
+ * The files `skeletonText` and `atlasText` were read from — what chooses the
1052
+ * candidate's poser, exactly as `render` chooses its own (`candidatePosers`,
1053
+ * issue #968): rigc's core when the `skeleton.model.json` beside the skeleton
1054
+ * records these skeleton bytes, spine-core otherwise. They must be the files
1055
+ * the texts came from; `cli.ts` reads both from them. Absent, the candidate
1056
+ * is posed through spine-core and the report says why.
1057
+ */
1058
+ candidatePaths?: { skeleton: string; atlas: string };
1059
+ /** `--poser`: force one implementation — see `candidatePosers`. `core` on an input that cannot carry it throws `PoserChoiceError`. */
1060
+ poser?: PoserName;
1061
+ /**
1062
+ * What builds the core poser — `candidatePosers`' own `make`. The suite's
1063
+ * `CH01` passes a planted copy, and nothing else passes any.
1064
+ */
1065
+ makeCorePoser?: Parameters<typeof candidatePosers>[4];
1066
+ }
1067
+
1068
+ /**
1069
+ * Both posers for the candidate `check` is handed, and which one it asked for:
1070
+ * `candidatePosers` over the candidate's files when the caller named them, and
1071
+ * spine-core alone when it handed texts only — there is then no directory to
1072
+ * find a model document in, and the reason says so.
1073
+ */
1074
+ function checkPoserChoice(data: Parameters<typeof spinePoser>[0], options: CheckOptions): PoserChoice {
1075
+ const paths = options.candidatePaths;
1076
+ if (paths !== undefined) return candidatePosers(data, paths.skeleton, paths.atlas, options.poser, options.makeCorePoser);
1077
+ const why = 'the candidate was handed to check as text, with no path to find a skeleton.model.json beside';
1078
+ if (options.poser === 'core') throw new PoserChoiceError(`--poser core: ${why}`);
1079
+ return { forced: options.poser, core: null, why: options.poser === 'spine' ? '--poser spine' : why, spine: spinePoser(data) };
1027
1080
  }
1028
1081
 
1029
1082
  // ---------------------------------------------------------------------------
@@ -1121,6 +1174,11 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1121
1174
  }]`,
1122
1175
  );
1123
1176
  }
1177
+ // Which implementation poses the candidate (issue #968), chosen once for the
1178
+ // whole run — every set, the setup pose and the non-finite sentence — so no
1179
+ // two figures in one report can come from two posers. `--poser core` on an
1180
+ // input that cannot carry it is refused here, before anything is posed.
1181
+ const choice = checkPoserChoice(posable.data, options);
1124
1182
  const poseOptions: PoseOptions | undefined =
1125
1183
  substitution || options.skin !== undefined
1126
1184
  ? { ...(substitution ? { texture: true } : {}), ...(options.skin === undefined ? {} : { skin: options.skin }) }
@@ -1225,28 +1283,54 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1225
1283
  // Pose every set once. Its frames are wanted twice — to frame the candidate and
1226
1284
  // to compare it — and posing twice is both slower and a chance for the framing
1227
1285
  // and the comparison to disagree about what they measured.
1228
- const prepared = sets.map((set) => prepareSet(located.root, set, posable, options.as, poseOptions));
1229
- // 🔒 A pose that is not finite is refused before anything measures it (issue
1230
- // #873), in the words `render` and the geometry export use for it. Measured
1231
- // on a planted bone at x=1e309 before this: an overflowing root read "posed no
1232
- // drawable attachment", one at -1e309 "drew no pixel", and one at +1e309 or a
1233
- // rotation at 1e309 exited 0 with MAE 1.00 — the part missing from every
1234
- // frame, and nothing said why.
1235
- const posed = prepared.filter((p) => p.missing === null);
1236
- const overflow = posed.some((p) =>
1237
- p.frames.some((frame) => frame.pieces.some((piece) => piece.world.some((value) => !Number.isFinite(value)))),
1238
- );
1239
- if (overflow) {
1240
- const found = nonFinitePoseOf(
1241
- posable.data,
1242
- options.skin,
1243
- posed.map((p) => ({ animation: p.candidateAnimation, fps: p.set.fps })),
1286
+ //
1287
+ // Posed through the chosen poser, and ALL of it inside one `throughPoser`
1288
+ // call: a core refusal partway (`CoreInputError` — a concave clip, a
1289
+ // non-finite vertex) re-poses every set on spine-core rather than leaving a
1290
+ // report whose sets were posed by two implementations. The animation roster
1291
+ // a set is checked against is the skeleton's, in its own order, whichever
1292
+ // poser runs — the model document lists the spec's order, and a refusal that
1293
+ // listed the names in another order would be a byte of the report that
1294
+ // depends on the poser.
1295
+ const have = posable.data.animations.map((a) => a.name);
1296
+ const posing = throughPoser(choice, (poser) => {
1297
+ const sampled = sets.map((set) => prepareSet(located.root, set, poser, have, options.as, poseOptions));
1298
+ // 🔒 A pose that is not finite is refused before anything measures it (issue
1299
+ // #873), in the words `render` and the geometry export use for it. Measured
1300
+ // on a planted bone at x=1e309 before this: an overflowing root read "posed no
1301
+ // drawable attachment", one at -1e309 "drew no pixel", and one at +1e309 or a
1302
+ // rotation at 1e309 exited 0 with MAE 1.00 — the part missing from every
1303
+ // frame, and nothing said why.
1304
+ const posedSets = sampled.filter((p) => p.missing === null);
1305
+ const overflow = posedSets.some((p) =>
1306
+ p.frames.some((frame) => frame.pieces.some((piece) => piece.world.some((value) => !Number.isFinite(value)))),
1244
1307
  );
1245
- if (found === null) {
1246
- throw new Error('a drawn piece of the candidate is not finite and no bone or vertex of its pose is — the two were posed differently');
1308
+ if (overflow) {
1309
+ const found = nonFinitePoseOf(
1310
+ poser,
1311
+ options.skin,
1312
+ posedSets.map((p) => ({ animation: p.candidateAnimation, fps: p.set.fps })),
1313
+ );
1314
+ if (found === null) {
1315
+ throw new Error('a drawn piece of the candidate is not finite and no bone or vertex of its pose is — the two were posed differently');
1316
+ }
1317
+ throw new CheckError(`the candidate is posed to a number that is not finite, so no frame of it can be compared: ${found}`);
1247
1318
  }
1248
- throw new CheckError(`the candidate is posed to a number that is not finite, so no frame of it can be compared: ${found}`);
1249
- }
1319
+ // 🔒 A candidate whose every drawn vertex sits at one point is refused in
1320
+ // `render`'s own sentence (issue #997), not measured. Measured before this on
1321
+ // a rig whose one drawn slot hangs from a bone only a non-default skin poses:
1322
+ // "the candidate drew no pixel in any frame that was compared" — true, and
1323
+ // silent on the reason, which is a skin the run did not pose.
1324
+ const unframeable = unframeableSentence(
1325
+ posedSets.map((p) => p.frames),
1326
+ poser.slots,
1327
+ options.skin,
1328
+ skinRosterOf(posable.data),
1329
+ );
1330
+ if (unframeable !== null) throw new UnframeablePoseError(unframeable);
1331
+ return sampled;
1332
+ });
1333
+ const prepared = posing.value;
1250
1334
  const pairs = prepared.flatMap((p) => p.pairs);
1251
1335
  if (pairs.length === 0) {
1252
1336
  notes.push('no reference frame has a candidate frame at the same index — nothing below was measured');
@@ -1548,6 +1632,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1548
1632
  skeleton: options.labels?.skeleton ?? '(in memory)',
1549
1633
  atlas: options.labels?.atlas ?? '(in memory)',
1550
1634
  },
1635
+ poser: { name: posing.poser, note: posing.note },
1551
1636
  framesDir: resolve(options.framesDir),
1552
1637
  framesRoot: located.root,
1553
1638
  skin: options.skin ?? null,
@@ -2430,13 +2515,14 @@ interface PreparedSet {
2430
2515
  function prepareSet(
2431
2516
  root: string,
2432
2517
  set: FrameSet,
2433
- posable: ReturnType<typeof posableFromText>,
2518
+ poser: Poser,
2519
+ /** The skeleton's animation names in its own order — see the call. */
2520
+ have: readonly string[],
2434
2521
  as: string | undefined,
2435
2522
  poseOptions: PoseOptions | undefined,
2436
2523
  ): PreparedSet {
2437
2524
  const notes: string[] = [];
2438
2525
  const wanted = as ?? set.animation;
2439
- const have = posable.data.animations.map((a) => a.name);
2440
2526
  const disk = framesOnDisk(root, set.dir);
2441
2527
 
2442
2528
  if (wanted !== null && !have.includes(wanted)) {
@@ -2464,10 +2550,10 @@ function prepareSet(
2464
2550
  `[${have.join(', ')}] — the setup pose is what was compared`,
2465
2551
  );
2466
2552
  }
2467
- candidateFrames = sampleSetupPose(posable.data, poseOptions);
2553
+ candidateFrames = sampleSetupPose(poser, poseOptions);
2468
2554
  candidateAnimation = null;
2469
2555
  } else {
2470
- candidateFrames = sampleAnimation(posable.data, wanted, set.fps, poseOptions);
2556
+ candidateFrames = sampleAnimation(poser, wanted, set.fps, poseOptions);
2471
2557
  candidateAnimation = wanted;
2472
2558
  }
2473
2559
 
@@ -3416,6 +3502,9 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
3416
3502
  report.referenceSkin === null ? `no skin recorded in ${FRAMES_SIDECAR}` : report.referenceSkin
3417
3503
  }`,
3418
3504
  );
3505
+ // Always printed, like the skin line above it: which implementation posed the
3506
+ // candidate, and why — `render`'s `poser` line, word for word (issue #968).
3507
+ lines.push(` poser ${report.poser.note}`);
3419
3508
  lines.push(
3420
3509
  ` scope ${
3421
3510
  report.framingScope === 'per-shot'
package/src/checkpics.ts CHANGED
@@ -259,6 +259,8 @@ function picturesSidecar(report: CheckReport, written: PicturesWritten[], allFra
259
259
  spec: FRAMES_SPEC,
260
260
  [COMPARISON_FIELD]: {
261
261
  candidate: report.candidate,
262
+ // Which poser drew the candidate pane (issue #968) — the report's own field.
263
+ poser: report.poser,
262
264
  frames: report.framesDir,
263
265
  skin: report.skin,
264
266
  referenceSkin: report.referenceSkin,