spine-rigc 1.9.2 → 1.9.4

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.
Files changed (49) hide show
  1. package/cli.ts +28 -4397
  2. package/docs/AUTHORING.md +4 -4
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a06.ts +53 -3
  5. package/src/assertions/bodies/a13.ts +3 -3
  6. package/src/assertions/bodies/a14.ts +3 -3
  7. package/src/assertions/bodies/a15.ts +5 -5
  8. package/src/assertions/bodies/a19.ts +44 -2
  9. package/src/assertions/bodies/a22.ts +3 -3
  10. package/src/assertions/facts/animated_bones.ts +4 -2
  11. package/src/assertions/facts/mesh_attachments.ts +33 -4
  12. package/src/assertions/model/animated_bones.ts +13 -5
  13. package/src/assertions/model/bone_timelines.ts +5 -3
  14. package/src/assertions/model/declared.ts +127 -0
  15. package/src/assertions/model/index.ts +63 -40
  16. package/src/assertions/model/mesh_attachments.ts +31 -2
  17. package/src/assertions/model/parse.ts +9 -1
  18. package/src/assertions/model/runtime_timelines.ts +78 -0
  19. package/src/assertions/model/slider_composition.ts +10 -8
  20. package/src/assertions/model/stepped_poses.ts +3 -2
  21. package/src/bonedist.ts +4 -5
  22. package/src/check.ts +3 -2
  23. package/src/checkpics.ts +1 -1
  24. package/src/cli/core_commands.ts +1532 -0
  25. package/src/cli/shared.ts +1935 -0
  26. package/src/cli/spine_commands.ts +1141 -0
  27. package/src/core/additive.ts +52 -62
  28. package/src/core/constraints.ts +26 -3
  29. package/src/core/constraints_physics.ts +11 -1
  30. package/src/core/constraints_slider.ts +136 -6
  31. package/src/core/index.ts +27 -3
  32. package/src/core/skins.ts +28 -3
  33. package/src/core/walk.ts +9 -36
  34. package/src/correspondence.ts +15 -0
  35. package/src/deformbuild.ts +60 -0
  36. package/src/deformmeasure.ts +20 -48
  37. package/src/deformreport.ts +2 -2
  38. package/src/framing.ts +1 -1
  39. package/src/pose.ts +1 -1
  40. package/src/preview.ts +1 -1
  41. package/src/region_joins.ts +54 -0
  42. package/src/render.ts +206 -2836
  43. package/src/render_core.ts +19 -36
  44. package/src/render_shared.ts +2887 -0
  45. package/src/slots.ts +1 -1
  46. package/src/spine_side.ts +123 -0
  47. package/src/validate.ts +11 -91
  48. package/src/assertions/facts/skin_meshes.ts +0 -49
  49. package/src/assertions/model/skin_meshes.ts +0 -90
package/docs/AUTHORING.md CHANGED
@@ -210,7 +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. 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 |
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`, `check` and `explain`: `core` or `spine` — on `render` and `check`, 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; its atlas, when one is named or present, the one 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. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). 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. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`). On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1) |
213
+ | `--poser` | `render`, `check` and `explain`: `core` or `spine` — on `render` and `check`, 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; its atlas, when one is named or present, the one 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, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). 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) · `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. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). 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. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`). On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1) |
214
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` |
215
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 |
216
216
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
@@ -5847,7 +5847,7 @@ Fix A00 and run it again.
5847
5847
  | `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height`. **SKIP** when the skeleton carries no region attachment |
5848
5848
  | `A04_MESH_TRIANGLES_AND_ENCODING` | both | authored mesh geometry: triangle count not a multiple of 3, an index out of range, or a `vertices` length that disagrees with `uvs` (the weighted/unweighted trap) **SKIP** when the skeleton carries no mesh attachment |
5849
5849
  | `A05_CURVE_ARRAY_LENGTH` | both | a raw `curve` with the wrong number of values, a non-finite number in one, or a curve on a timeline that cannot take one. Four numbers **per value channel**. **SKIP** when no animation carries a timeline at all. Timelines with no `curve` on any key still PASS: every timeline name is checked against the channel table whether or not a curve sits on one |
5850
- | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND` (§0.2). That clause is validity too: nothing in rigc can read such a page back. **SKIP** when the atlas declares no page |
5850
+ | `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND` (§0.2). That clause is validity too: nothing in rigc can read such a page back. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
5851
5851
  | `A07_ATLAS_TEXT_SHAPE` | both | atlas text: a region name with stray whitespace, or a blank line splitting a page block. rigc writes the atlas, so this means a hand-edited file. Three sentences are about blank lines and name three different repairs: `line 1: the file begins with a blank line` (or `with N blank lines` for a run, one finding) is a blank before the first page name — delete it; `line N: the file ends with a blank line` (or `with N blank lines`, one finding) is a blank after the last page block's final line — delete it, since rigc ends the file on one newline; `line N: consecutive blank lines` is two blank lines side by side between the first page and the last — delete one. None of the three is `the last page block declares no region`, which means a page name and its fields with no region after them. rigc writes none of the three: under `--atlas-in` the pack's blank lines are put in rigc's shape on re-emission (§0.2), so any of them means a file rigc did not write, handed to `validate`. ⚠️ An atlas with **no page block at all** — no non-blank line — is not one of those: its subject is absent, so this reports **SKIP** naming the byte count it read, and so do the four rules below whose subject is a page ([#608](https://github.com/firejune/rigc/issues/608)). A rig whose skins need no art writes exactly that file (§3.4), and before #608 this row refused it with two findings naming a page block that was not there. What an empty atlas does **not** excuse is an attachment that wants a region out of it — that is `A08` |
5852
5852
  | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both | three things, and the message says which: an attachment whose `path` names **no region** of this atlas; a `path` carrying **stray whitespace**, printed quoted so you can see it; an **atlas region name** carrying stray whitespace (`A07` names that same line with its line number). The first two are read off the raw file **before** the loader is asked, so the miss is named here with the skin, the slot, the placeholder and the attachment's own name — the four things `AtlasAttachmentLoader`'s own `Region not found in atlas: <path> (attachment: <name>)` does not carry. There is no `spine-html` clause here — a placeholder is free to differ from the region its `path` names **SKIP** when no attachment names a region *and* the atlas declares none — both of its subjects at once |
5853
5853
  | `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the loaded duration ≠ the declared one, or the two sides disagree about which animations exist (R7). Asymmetric by design: a frame of slack for an animation that ends early, and none worth the name for a key *past* the declared end, which is the same rule §4.5 states at compile time — held here against a skeleton the compiler never saw. **SKIP** when neither side has an animation at all — a static rig has no duration |
@@ -5858,9 +5858,9 @@ Fix A00 and run it again.
5858
5858
  | `A14_NO_FULL_FRAME_MESH` | renderer | a mesh spans the whole stage — a full-frame canvas that can never dirty-skip. **SKIP** when the skeleton declares no stage (§3.1): there is no full frame to span, and *unmeasured* must not print the same green as *measured and clear* |
5859
5859
  | `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 |
5860
5860
  | `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`) |
5861
- | `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` |
5861
+ | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. This is the one rule that names a missing page file; `A06` and `A19` SKIP over the page they could not read and point here rather than naming it again. **SKIP** when the atlas declares no page — as it is for `A06`, `A19` and `A27`; see `A07` |
5862
5862
  | `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 |
5863
- | `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 |
5863
+ | `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 — and, rather than a PASS, when a page carrying a part has no file on disk and nothing else here failed: that page's parts were not read, so the reason names the pages not read and how many of the pages carrying a part were (or says none was) and points at `A17`, which fails naming each missing file. A missing page holding only the base plate moves nothing here: this rule had no part on it to read |
5864
5864
  | `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 |
5865
5865
  | `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 |
5866
5866
  | `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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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": {
@@ -18,12 +18,61 @@ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
18
18
  import { pageFootprint, pageGridSentence } from '../../atlas.ts';
19
19
  import { readPngHeader } from '../../png.ts';
20
20
 
21
- export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
21
+ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
22
22
  if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
23
23
  if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
24
+ // Every sentence this body prints goes through here, so the end of the body
25
+ // can tell "nothing failed" from "something failed" (issue #1055, below).
26
+ let failures = 0;
27
+ const fail = (assertion: string, detail: string): void => {
28
+ failures++;
29
+ failed(assertion, detail);
30
+ };
31
+ // 🔒 **A page whose file is not on disk is a page whose size was not
32
+ // measured, and the body says so rather than passing** (issue #1055). Until
33
+ // this the loop below walked past such a page and the assertion came out
34
+ // PASS — on a build with every page file deleted, a PASS over a size clause
35
+ // that had opened nothing, measured on 33 of the 38 recipe-and-profile
36
+ // pairs of the tree's 19 recipes (the other five failed clauses that read
37
+ // only the atlas's numbers: overlap and rotation, under `spine-html`). What the body does instead is decided by what `A17` prints on
38
+ // the same input, so that one missing file is named once, by the rule whose
39
+ // subject it is: A17 FAILs naming every such file and its path, which keeps
40
+ // the report red; this body adds no FAIL of its own about the file (a
41
+ // second naming), and it does not PASS (a certificate over a size nobody
42
+ // read). It SKIPs with a reason that names the pages it did not read and
43
+ // points at A17 — `unreadSizes` below, at both of the body's exits.
44
+ //
45
+ // ⚠️ Only when nothing else here failed. `skip()` is per assertion, and a
46
+ // skip beside failures puts A06 in two of the report's buckets (the #705
47
+ // argument in `a19.ts`); and a FAIL certifies nothing, so the unread page
48
+ // is A17's alone there. ⚠️ Not the tree's other multi-clause rule either
49
+ // (`../reasons.ts`, #580: a multi-clause assertion SKIPs only when EVERY
50
+ // clause had nothing to measure). That rule is about a clause whose subject
51
+ // list is EMPTY — zero is a count, and a count was measured. A page the
52
+ // atlas declares is a member of the size clause's subject that is present
53
+ // and was not read, and the PASS this replaces claimed it was.
54
+ const unread: string[] = [];
55
+ const unreadSizes = (): void => {
56
+ if (failures > 0 || unread.length === 0) return;
57
+ const total = atlas.pages.length;
58
+ skip(
59
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
60
+ unread.length === total
61
+ ? `no page's size was measured: none of the ${total} page file(s) the atlas declares is on disk, which ` +
62
+ "A17_ATLAS_PAGE_FILES_EXIST names file by file. The clauses that read only the atlas's own numbers found " +
63
+ "nothing to fail, and that is not a pass while every page's size is unread"
64
+ : `the size of ${unread.length} of the ${total} page(s) the atlas declares was not measured — ` +
65
+ `${unread.map((name) => JSON.stringify(name)).join(', ')} — because the file is not on disk, which ` +
66
+ `A17_ATLAS_PAGE_FILES_EXIST names. The ${total - unread.length} page(s) that were read, and the clauses ` +
67
+ "that read only the atlas's own numbers, found nothing to fail, and that is not a pass while a page's size is unread",
68
+ );
69
+ };
24
70
  for (const page of atlas.pages) {
25
71
  const abs = resolve(input.atlasDir, page.name);
26
- if (!existsSync(abs)) continue; // A17 owns this
72
+ if (!existsSync(abs)) {
73
+ unread.push(page.name); // A17 names the file; this body says only that it did not read it
74
+ continue;
75
+ }
27
76
  // 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
28
77
  // #732). The reader's throw used to reach this rule's catch and print as
29
78
  // `threw: not a PNG (bad signature)`: the verdict right, the sentence a
@@ -118,7 +167,7 @@ export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }:
118
167
  );
119
168
  }
120
169
  }
121
- if (!policy) return;
170
+ if (!policy) return unreadSizes();
122
171
  // ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2). This
123
172
  // clause used to be the first alternative alone — every region's UVs
124
173
  // `(0,0)-(1,1)` — which is rigc's *unpacked* convention and exactly what
@@ -192,4 +241,5 @@ export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }:
192
241
  );
193
242
  }
194
243
  }
244
+ unreadSizes();
195
245
  }
@@ -3,17 +3,17 @@
3
3
  * triangles stay within the budget the rig declares.
4
4
  *
5
5
  * Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
6
- * are a fact (`../facts/skin_meshes.ts`) rather than the lists `validate()`'s
6
+ * are a fact (`../facts/mesh_attachments.ts`) rather than the lists `validate()`'s
7
7
  * prelude filled from spine-core's loaded skins, and the rig info is the
8
8
  * caller's. Why the two budgets come from the rig and never from here stays
9
9
  * above the `check` call in `validate()`.
10
10
  */
11
11
  import type { Verdicts } from '../harness.ts';
12
- import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
12
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
13
13
  import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
14
14
  import type { RigInfo } from '../../types.ts';
15
15
 
16
- export function a13MeshBudget({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, input: { rig?: RigInfo }): void {
16
+ export function a13MeshBudget({ fail, skip }: Verdicts, { meshes }: Pick<MeshFacts, 'meshes'>, input: { rig?: RigInfo }): void {
17
17
  const meshAttachments = meshes;
18
18
  const meshSlots = new Set(meshes.map((mesh) => mesh.slot));
19
19
  const slotBudget = input.rig?.meshSlotBudget ?? null;
@@ -3,15 +3,15 @@
3
3
  * stage.
4
4
  *
5
5
  * Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
6
- * and the stage are facts (`../facts/skin_meshes.ts`, `../facts/stage.ts`)
6
+ * and the stage are facts (`../facts/mesh_attachments.ts`, `../facts/stage.ts`)
7
7
  * rather than spine-core's loaded skins and skeleton header. The `check` call
8
8
  * stays in `validate()`.
9
9
  */
10
10
  import type { Verdicts } from '../harness.ts';
11
- import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
11
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
12
12
  import type { StageFacts } from '../facts/stage.ts';
13
13
 
14
- export function a14NoFullFrameMesh({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, stage: StageFacts): void {
14
+ export function a14NoFullFrameMesh({ fail, skip }: Verdicts, { meshes }: Pick<MeshFacts, 'meshes'>, stage: StageFacts): void {
15
15
  const meshAttachments = meshes;
16
16
  const stageW = stage.width || 0;
17
17
  const stageH = stage.height || 0;
@@ -3,7 +3,7 @@
3
3
  * drives a mesh, unless the rig declares that its idle deforms them.
4
4
  *
5
5
  * Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
6
- * and the bones that drive them are a fact (`../facts/skin_meshes.ts`) rather
6
+ * and the bones that drive them are a fact (`../facts/mesh_attachments.ts`, the bones derived from the weights by `weightBonesOf`) rather
7
7
  * than spine-core's loaded skins and the weight run decoded here, the bones
8
8
  * `idle` keys are a fact (`../facts/animated_bones.ts`) rather than the raw
9
9
  * JSON, and the rig info is the caller's. Why the rule assumes meshes are
@@ -11,11 +11,11 @@
11
11
  * `check` call in `validate()`.
12
12
  */
13
13
  import type { Verdicts } from '../harness.ts';
14
- import type { MeshEntry, SkinMeshFacts } from '../facts/skin_meshes.ts';
14
+ import { weightBonesOf, type MeshEntry, type MeshFacts } from '../facts/mesh_attachments.ts';
15
15
  import type { AnimatedBoneFacts } from '../facts/animated_bones.ts';
16
16
  import type { RigInfo } from '../../types.ts';
17
17
 
18
- export function a15IdleNoMeshBoneKeys({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, animated: AnimatedBoneFacts, input: { rig?: RigInfo }): void {
18
+ export function a15IdleNoMeshBoneKeys({ fail, skip }: Verdicts, facts: MeshFacts, animated: AnimatedBoneFacts, input: { rig?: RigInfo }): void {
19
19
  const A15 = 'A15_IDLE_NO_MESH_BONE_KEYS';
20
20
  /**
21
21
  * Every mesh attachment, loaded, with the bones that drive it — its slot's
@@ -25,9 +25,9 @@ export function a15IdleNoMeshBoneKeys({ fail, skip }: Verdicts, { meshes }: Skin
25
25
  * actually dirties the canvas every frame.
26
26
  */
27
27
  const meshDrivers: Array<{ mesh: MeshEntry; bones: Set<string> }> = [];
28
- for (const mesh of meshes) {
28
+ for (const mesh of facts.meshes) {
29
29
  const bones = new Set<string>([mesh.slotBone]);
30
- for (const bone of mesh.weightBones) bones.add(bone);
30
+ for (const bone of weightBonesOf(facts, mesh)) bones.add(bone);
31
31
  meshDrivers.push({ mesh, bones });
32
32
  }
33
33
  const meshBoneNames = new Set<string>();
@@ -21,13 +21,20 @@ import { colourTypeName, readPngHeader } from '../../png.ts';
21
21
  import { readPlate } from '../../../tools/plate.ts';
22
22
  import type { RigInfo } from '../../types.ts';
23
23
 
24
- export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
24
+ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
25
25
  // A SKIP for the same reason A06's is (#568). This one is invisible under
26
26
  // `spine`, where the profile excludes the rule before its body runs — and
27
27
  // that is exactly why it was worth finding: `--profile spine-html` reported
28
28
  // it, and A27 below, green on an atlas nothing had read.
29
29
  if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
30
30
  if (atlas.pages.length === 0) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS_PAGE);
31
+ // Every sentence this body prints goes through here, so its end can tell
32
+ // "nothing failed" from "something failed" (issue #1055, at the page loop).
33
+ let failures = 0;
34
+ const fail = (assertion: string, detail: string): void => {
35
+ failures++;
36
+ failed(assertion, detail);
37
+ };
31
38
  const stageW = stage.width ?? 0;
32
39
  const stageH = stage.height ?? 0;
33
40
  // 🔒 **Which image is the base plate is decided ONCE, and the rig's own
@@ -96,10 +103,34 @@ export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: Atl
96
103
  if (on) on.push(region);
97
104
  else sharedPages.set(region.page.name, [region]);
98
105
  }
106
+ // 🔒 **A page whose file is not on disk is a page whose parts were not
107
+ // read, and the body says so rather than passing** (issue #1055). Until
108
+ // this the loop walked past such a page, and on a build with every page
109
+ // file deleted the assertion printed PASS on all 19 of the tree's recipes
110
+ // under `spine-html` having opened nothing. The rule for what it prints
111
+ // instead is `a06.ts`'s, read off what `A17` prints on the same input: A17
112
+ // FAILs naming each missing file, so the report is already red and the file
113
+ // already named; a FAIL here would be the second naming and a PASS a
114
+ // certificate over alpha nobody read. So the body SKIPs, naming the pages it
115
+ // did not read and pointing at A17 — only when it failed nothing else,
116
+ // because `skip()` is per assertion (#705's argument, below) and a FAIL
117
+ // certifies nothing.
118
+ //
119
+ // ⚠️ A page counts only when it carries a part this rule judges, by the
120
+ // predicate the non-PNG branch below uses: a missing page holding nothing
121
+ // but the base plate had nothing for this rule to read either way, so it
122
+ // moves nothing here (the size it was not measured at is `A06`'s to say).
123
+ let pagesWithParts = 0;
124
+ const unread: string[] = [];
99
125
  for (const page of atlas.pages) {
100
126
  const abs = resolve(input.atlasDir, page.name);
101
- if (!existsSync(abs)) continue;
102
127
  const on = sharedPages.get(page.name) ?? [];
128
+ const carriesPart = on.length > 1 ? on.some((region) => !baseRegions.has(region.name)) : !basePages.has(page.name);
129
+ if (carriesPart) pagesWithParts++;
130
+ if (!existsSync(abs)) {
131
+ if (carriesPart) unread.push(page.name); // A17 names the file; this body says only that it did not read it
132
+ continue;
133
+ }
103
134
  // 🔒 **A page that is not a PNG is a non-measurement for every part on
104
135
  // it** (issue #732), stated the way #705's and #715's are: a FAIL naming
105
136
  // what was not read, because `skip()` is per assertion and would delete
@@ -292,4 +323,15 @@ export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: Atl
292
323
  'spine does not run this check.',
293
324
  );
294
325
  }
326
+ if (failures > 0 || unread.length === 0) return;
327
+ skip(
328
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
329
+ unread.length === pagesWithParts
330
+ ? `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
331
+ 'A17_ATLAS_PAGE_FILES_EXIST names file by file, and that is not a pass while every part is unread'
332
+ : `${unread.length} of the ${pagesWithParts} page(s) carrying a part were not read — ` +
333
+ `${unread.map((name) => JSON.stringify(name)).join(', ')} — because the file is not on disk, which ` +
334
+ `A17_ATLAS_PAGE_FILES_EXIST names. Every part on the ${pagesWithParts - unread.length} page(s) that were ` +
335
+ "read can draw a transparent pixel, and that is not a pass while a part's alpha is unread",
336
+ );
295
337
  }
@@ -3,14 +3,14 @@
3
3
  * in the unit square, one pair per vertex.
4
4
  *
5
5
  * Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
6
- * are a fact (`../facts/skin_meshes.ts`). The `check` call stays in
6
+ * are a fact (`../facts/mesh_attachments.ts`). The `check` call stays in
7
7
  * `validate()`.
8
8
  */
9
9
  import type { Verdicts } from '../harness.ts';
10
- import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
10
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
11
11
  import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
12
12
 
13
- export function a22MeshUvsInUnitRange({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts): void {
13
+ export function a22MeshUvsInUnitRange({ fail, skip }: Verdicts, { meshes }: Pick<MeshFacts, 'meshes'>): void {
14
14
  const meshAttachments = meshes;
15
15
  if (meshAttachments.length === 0) return skip('A22_MESH_UVS_IN_UNIT_RANGE', SKIP_NO_MESH_ATTACHMENT);
16
16
  for (const mesh of meshAttachments) {
@@ -11,8 +11,10 @@
11
11
  * rather than folding the first two into an empty list.
12
12
  *
13
13
  * The order is the file's: A15 prints one line per keyed bone in it. The file
14
- * keys an animation's bones in the order the emitter writes the model's
15
- * (`emitAnimation`), which is the document's own list.
14
+ * keys an animation's bones in an object filled in the order the emitter
15
+ * writes the model's (`emitAnimation`), which is the document's own list — so
16
+ * an array-index bone name (`5`, `10`) is listed first whatever its place in
17
+ * that list (issue #1039), and the model side reads it through `keyedOrder`.
16
18
  *
17
19
  * Links nothing from the runtime: `validate()` reads it off the skeleton JSON
18
20
  * as A15 always did, and the model side off the document
@@ -1,8 +1,16 @@
1
1
  /**
2
- * The mesh attachments, as the bodies that read their geometry and their
3
- * weights see them (issue #1025, step 4c of #380) — the census's F07 (vertex
4
- * data), F08 (mesh geometry) and F10 (the linked-mesh join), for A04, A20,
5
- * A21, A28 and the mesh half of A23.
2
+ * The mesh attachments, as every body that reads a mesh sees them (issue
3
+ * #1025, step 4c of #380) — the census's F03, F05, F07 (vertex data), F08
4
+ * (mesh geometry) and F10 (the linked-mesh join), for A04, A13, A14, A15, A20,
5
+ * A21, A22, A28 and the mesh half of A23.
6
+ *
7
+ * ⭐ **One family** (issue #1054). Cut 4c-1 stated the meshes for A13, A14,
8
+ * A15 and A22 as a family of their own (`SkinMeshFacts`) and cut 4c-2 stated
9
+ * them again here; the two walked the same entries in the same order and
10
+ * agreed on every field they shared, and two families of one fact are two
11
+ * places for a supplier to disagree with itself. The size the budget and
12
+ * canvas rules read joined this one, and the bones a mesh's weights name are
13
+ * derived from `weights` (`weightBonesOf`) rather than stated beside them.
6
14
  *
7
15
  * ⭐ **The order is the file's**, as `./skin_entries.ts` states it: every
8
16
  * skin in the file's order, and within a skin the entries slot by slot in the
@@ -56,6 +64,9 @@ export interface MeshEntry {
56
64
  readonly regionUVs: readonly number[];
57
65
  /** `hullLength`: two numbers per hull vertex. */
58
66
  readonly hullLength: number;
67
+ /** Its `width` and `height` — a link's are its source's, which the runtime's `setSourceMesh` writes over the link's own. */
68
+ readonly width: number;
69
+ readonly height: number;
59
70
  /** Per vertex its influences, in order — or `null` for an unweighted mesh. */
60
71
  readonly weights: ReadonlyArray<readonly MeshBinding[]> | null;
61
72
  /** The kept clauses' findings about this mesh's vertex run (`A04`'s encoding rules), in order; never on the model side. */
@@ -69,3 +80,21 @@ export interface MeshFacts {
69
80
  /** Every mesh attachment of every skin, in the file's walk order. */
70
81
  readonly meshes: readonly MeshEntry[];
71
82
  }
83
+
84
+ /**
85
+ * Every bone a mesh's weights name, in the weight run's order (repeats kept),
86
+ * by name — empty for an unweighted mesh. A binding whose index the roster
87
+ * does not hold names nothing here: that index is the encoding's fault, which
88
+ * `A20`'s kept clause names (`MeshBinding.encoding`), and no bone drives the
89
+ * mesh through it.
90
+ */
91
+ export function weightBonesOf(facts: Pick<MeshFacts, 'bones'>, mesh: Pick<MeshEntry, 'weights'>): string[] {
92
+ const names: string[] = [];
93
+ for (const vertex of mesh.weights ?? []) {
94
+ for (const binding of vertex) {
95
+ const name = facts.bones[binding.bone];
96
+ if (name !== undefined) names.push(name);
97
+ }
98
+ }
99
+ return names;
100
+ }
@@ -5,13 +5,21 @@
5
5
  * The document writes each animation's `bones` as a list in the model's map
6
6
  * order, and the emitter writes the Spine file's `bones` group from that same
7
7
  * map, entry for entry, leaving the group out when it is empty
8
- * (`emitAnimation`'s `if (animation.bones.size)`) — so the list's order is the
9
- * file's, an empty list is a file with no `bones` group, and an animation the
10
- * document does not hold is one the file does not key. The first animation of
11
- * a name answers, as the file's object would hold only one.
8
+ * (`emitAnimation`'s `if (animation.bones.size)`) — so an empty list is a file
9
+ * with no `bones` group, and an animation the document does not hold is one the
10
+ * file does not key. The first animation of a name answers, as the file's
11
+ * object would hold only one.
12
+ *
13
+ * 🔢 **The order is `keyedOrder` over the list's, not the list's** (issue
14
+ * #1039). The group is an object keyed by bone name, so the file lists an
15
+ * array-index name (`5`, `10`) first whatever order the emitter filled it in —
16
+ * measured on a walk whose tracks key `-a, 5, 2b, 10, …`: the document lists
17
+ * that, the file and spine-core `5, 10, -a, 2b, …`, and A15 prints one line per
18
+ * keyed bone in the file's order.
12
19
  *
13
20
  * Links nothing from the runtime.
14
21
  */
22
+ import { keyedOrder } from '../../compile.ts';
15
23
  import type { AnimatedBoneFacts } from '../facts/animated_bones.ts';
16
24
  import { isObj } from '../values.ts';
17
25
  import type { ReadDocument } from './parse.ts';
@@ -24,7 +32,7 @@ export function modelAnimatedBones(read: ReadDocument): AnimatedBoneFacts {
24
32
  if (animation === undefined) return undefined;
25
33
  const bones = Array.isArray(animation.bones) ? animation.bones.filter(isObj) : [];
26
34
  if (bones.length === 0) return null;
27
- return bones.map((bone) => String(bone.name));
35
+ return keyedOrder(bones.map((bone) => String(bone.name)));
28
36
  },
29
37
  };
30
38
  }
@@ -6,9 +6,11 @@
6
6
  * `./slot_colour.ts`), over the `bones` group: the animations put in the
7
7
  * file's order by `editorAnimationOrder` (`src/compile.ts`, the function the
8
8
  * emitter is handed), each animation's bones and their timelines laid out as
9
- * the file lays them (`animations.<name>.bones.<bone>.<timeline> = keys`, in
10
- * the document's order, which is the order `emitAnimation` writes the model's
11
- * map in), and the whole handed to the emitter's own `withoutParserDefaults`
9
+ * the file lays them (`animations.<name>.bones.<bone>.<timeline> = keys`,
10
+ * filled in the document's order, which is the order `emitAnimation` writes
11
+ * the model's map in — and read back out of that object, so an array-index
12
+ * bone name comes first as it does in the file: issue #1039 measured this
13
+ * reader already walking the file's order), and the whole handed to the emitter's own `withoutParserDefaults`
12
14
  * and `inEditorKeyOrder` (`src/keyorder.ts`) — so a key field the emitter
13
15
  * leaves out at the parser's default (`time: 0`, a translate key's `y: 0`) is
14
16
  * left out here too, and a body printing a key's `time` prints what it prints
@@ -0,0 +1,127 @@
1
+ /**
2
+ * What the build declared, read off the document (issue #1054): the rig info
3
+ * the archetype and declaration rules read, and each animation's declared
4
+ * duration, which A09 holds the keys against.
5
+ *
6
+ * ⭐ **The document is the one source, on every spec it is read at.** A
7
+ * `rigc-compiled/3`, `/2` and `/1` document all carry the `rig` section
8
+ * (`CompileResult.rig` as `modelDocument` writes it) and every animation's
9
+ * `duration` (the motion spec's, which `readModel` reads as
10
+ * `CoreAnimationTimelines.declared` and refuses when it is not a finite
11
+ * number). Until this module the model side took both from its caller, as
12
+ * `validate()` does, and its verdict moved with what the caller handed it:
13
+ * measured over the 19 recipes with the document's own values left out, A09
14
+ * read SKIP on 38 of 38 cells, ten rules read their bare-directory SKIP, and
15
+ * A20 read FAIL on 7 cells where it reads PASS — without the rig every mesh is
16
+ * judged a generated ring (`meshKindOf`), so an authored mesh fails the
17
+ * generator policy. A value the document states is read from the document,
18
+ * and the same value given beside it is refused by name
19
+ * (`refuseDeclaredBeside`), as issue #1026 made the stage and `pma`
20
+ * (`./given.ts`).
21
+ *
22
+ * 🔒 **The rig section is read field by field, never cast.** A body reads a
23
+ * declaration only through `modelRigInfo`, which refuses by its path a field
24
+ * that is missing, of the wrong type or not one the writer writes — the
25
+ * model side's parse runs it (`./parse.ts`, `A00_MODEL_READ`), so a document
26
+ * whose declarations are malformed is refused where the reader refuses, and
27
+ * never judged against a guess.
28
+ *
29
+ * Links nothing from the runtime.
30
+ */
31
+ import type { MeshKind } from '../../mesh.ts';
32
+ import type { RigInfo } from '../../types.ts';
33
+ import { isObj } from '../values.ts';
34
+ import type { ReadDocument } from './parse.ts';
35
+
36
+ /** A rig section the model side cannot read as rig info: every problem, each by its path, in one sentence. */
37
+ export class DeclaredInputError extends Error {}
38
+
39
+ /** The mesh kinds a rig's `meshKinds` may name: the generators' and `authored`. */
40
+ const MESH_KINDS = ['ring', 'ribbon', 'contour', 'grid', 'segments', 'authored'] as const satisfies ReadonlyArray<MeshKind | 'authored'>;
41
+ // Every kind the type names is in the list, or this line does not type-check.
42
+ const MESH_KINDS_COMPLETE: Exclude<MeshKind | 'authored', (typeof MESH_KINDS)[number]> extends never ? true : false = true;
43
+ void MESH_KINDS_COMPLETE;
44
+
45
+ type Check = (value: unknown, at: string, problems: string[]) => void;
46
+ const say = (problems: string[], at: string, value: unknown, what: string): void => void problems.push(`${at} is ${JSON.stringify(value) ?? 'absent'}, not ${what}`);
47
+ const str: Check = (v, at, p) => (typeof v === 'string' ? undefined : say(p, at, v, 'a string'));
48
+ const bool: Check = (v, at, p) => (typeof v === 'boolean' ? undefined : say(p, at, v, 'true or false'));
49
+ const num: Check = (v, at, p) => (typeof v === 'number' && Number.isFinite(v) ? undefined : say(p, at, v, 'a finite number'));
50
+ const nullable = (inner: Check): Check => (v, at, p) => (v === null ? undefined : inner(v, at, p));
51
+ const list = (inner: Check, length?: number): Check => (v, at, p) => {
52
+ if (!Array.isArray(v) || (length !== undefined && v.length !== length)) return say(p, at, v, length === undefined ? 'a list' : `a list of ${length}`);
53
+ v.forEach((x, i) => inner(x, `${at}[${i}]`, p));
54
+ };
55
+ const record = (inner: Check): Check => (v, at, p) => {
56
+ if (!isObj(v)) return say(p, at, v, 'an object');
57
+ for (const [k, x] of Object.entries(v)) inner(x, `${at}.${k}`, p);
58
+ };
59
+ const oneOf = (words: readonly string[]): Check => (v, at, p) => (typeof v === 'string' && words.includes(v) ? undefined : say(p, at, v, `one of ${words.join(', ')}`));
60
+ const shape = (fields: Readonly<Record<string, Check>>): Check => (v, at, p) => {
61
+ if (!isObj(v)) return say(p, at, v, 'an object');
62
+ for (const [k, check] of Object.entries(fields)) check(v[k], `${at}.${k}`, p);
63
+ for (const k of Object.keys(v)) if (!(k in fields)) p.push(`${at}.${k} is not a field the rig section has`);
64
+ };
65
+
66
+ /** The rig section's fields, as `RigInfo` declares them and the writer writes every one — each required, none defaulted. */
67
+ const RIG_FIELDS: Readonly<Record<keyof RigInfo, Check>> = {
68
+ archetype: str,
69
+ axisBone: nullable(str),
70
+ axisSubtree: list(str),
71
+ detached: list(list(str, 2)),
72
+ slotOrder: nullable(list(str)),
73
+ meshKinds: record(oneOf(MESH_KINDS)),
74
+ meshDeclaredBones: record(list(str)),
75
+ meshSoftBones: record(str),
76
+ deformMayFold: list(str),
77
+ editorRoundTrip: bool,
78
+ consumerDrivenMix: list(shape({ type: oneOf(['ik', 'transform']), constraint: str, why: str })),
79
+ idleDrivesMeshes: nullable(str),
80
+ basePlates: list(str),
81
+ meshSlotBudget: nullable(num),
82
+ meshTriangleBudget: nullable(num),
83
+ contactDepth: nullable(num),
84
+ capContainmentCeiling: nullable(num),
85
+ massBone: nullable(str),
86
+ inwardUnit: nullable(list(num, 2)),
87
+ };
88
+
89
+ /** Every problem with the document's `rig` section, each by its path — empty when it reads as rig info. */
90
+ export function rigSectionProblems(json: Readonly<Record<string, unknown>>): string[] {
91
+ const problems: string[] = [];
92
+ shape(RIG_FIELDS)(json.rig, 'rig', problems);
93
+ return problems;
94
+ }
95
+
96
+ /** The rig info the document declares — its `rig` section, read field by field; a section that does not read is refused by its paths. */
97
+ export function modelRigInfo(read: ReadDocument): RigInfo {
98
+ const problems = rigSectionProblems(read.json);
99
+ if (problems.length > 0) throw new DeclaredInputError(`the document's rig section does not read as rig info: ${problems.length} problem(s): ${problems.join('; ')}`);
100
+ return read.json.rig as RigInfo;
101
+ }
102
+
103
+ /**
104
+ * Each animation's declared duration, as the document states it and the core
105
+ * reads it (`CoreAnimationTimelines.declared`), keyed in the document's order —
106
+ * the motion spec's, the order `CompileResult.declaredDurations` is filled in.
107
+ */
108
+ export function modelDeclaredDurations(read: ReadDocument): Record<string, number> {
109
+ const out: Record<string, number> = {};
110
+ for (const anim of read.doc.animations) out[anim.name] = anim.timelines.declared;
111
+ return out;
112
+ }
113
+
114
+ /** What a caller may not give beside a document, because every document states it. */
115
+ export type DeclaredValue = 'rig' | 'declaredDurations';
116
+
117
+ /** The sentence a value given beside the document is refused with. */
118
+ export function declaredBesideRefusal(spec: string, what: DeclaredValue): string {
119
+ const given = what === 'rig' ? 'the rig info' : 'the declared durations';
120
+ const stated = what === 'rig' ? 'states it itself in its rig section' : "states each animation's duration itself";
121
+ return `the caller gave ${given} beside a ${spec} document, which ${stated} (issue #1054); a second source for one fact is refused rather than one of them silently read`;
122
+ }
123
+
124
+ /** A caller's value beside a document that states it, refused by name. */
125
+ export function refuseDeclaredBeside(read: ReadDocument, input: { rig?: unknown; declaredDurations?: unknown }, what: DeclaredValue): void {
126
+ if (input[what] !== undefined) throw new Error(declaredBesideRefusal(read.doc.spec, what));
127
+ }