spine-rigc 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +2 -2
- package/docs/AUTHORING.md +3 -3
- package/package.json +1 -1
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a06.ts +195 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a19.ts +295 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +255 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a41.ts +83 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a47.ts +59 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/constraint_words.ts +177 -0
- package/src/assertions/facts/animated_bones.ts +28 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/constraints.ts +131 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +71 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/skin_entries.ts +4 -2
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/skin_meshes.ts +49 -0
- package/src/assertions/facts/stage.ts +24 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +30 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/constraints.ts +207 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +130 -13
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +90 -0
- package/src/assertions/model/parse.ts +2 -0
- package/src/assertions/model/region_joins.ts +59 -0
- package/src/assertions/model/skin_entries.ts +2 -1
- package/src/assertions/model/skin_members.ts +62 -0
- package/src/assertions/model/skin_meshes.ts +90 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/vertex_polygons.ts +71 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/check.ts +8 -6
- package/src/compile.ts +145 -38
- package/src/core/index.ts +154 -24
- package/src/deformstructure.ts +16 -7
- package/src/emit_spine.ts +21 -41
- package/src/keyorder.ts +40 -0
- package/src/model.ts +183 -20
- package/src/render.ts +66 -25
- package/src/render_core.ts +41 -13
- package/src/validate.ts +601 -2328
package/cli.ts
CHANGED
|
@@ -1237,7 +1237,7 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
1237
1237
|
|
|
1238
1238
|
writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
|
|
1239
1239
|
writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
|
|
1240
|
-
// rigc's own record of the compiled rig (`rigc-compiled/
|
|
1240
|
+
// rigc's own record of the compiled rig (`rigc-compiled/3`, issue #922;
|
|
1241
1241
|
// its `pages` the atlas just written, issue #1016), written with the pair
|
|
1242
1242
|
// and only after the same gate — under `--pack`, the packed one. rigc's own posing core
|
|
1243
1243
|
// reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
|
|
@@ -1588,7 +1588,7 @@ function resolveViewable(flags: Record<string, string>): {
|
|
|
1588
1588
|
}
|
|
1589
1589
|
|
|
1590
1590
|
/** What `render` and `check` say where a rigc build's atlas is not there (issue #1020). */
|
|
1591
|
-
const ATLAS_ABSENT = 'a build the core poses from a rigc-compiled/2 document needs none';
|
|
1591
|
+
const ATLAS_ABSENT = 'a build the core poses from a rigc-compiled/2 or /3 document needs none';
|
|
1592
1592
|
|
|
1593
1593
|
/**
|
|
1594
1594
|
* The candidate `render` and `check` draw, with its atlas text — or `null`
|
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`, 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/
|
|
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/3`: the stage the header declares, or `null`; the bones, slots, skins, constraints, events and animations the Spine files were emitted from, in the spec's order, and `editorOrder`, the order `skeleton.json` lists the skins, each skin's slot keys and the animations in; the rest of what posing them takes; then `pages`, every page of the `skeleton.atlas` written beside it, its `pma` and its `scale:` line (`null` where it states none), and where each region sits on it — after `--pack` the packed pages, after `--copy-images` the copies' names), written after the same gate as the pair and ending in `spine.sha256`, the digest of the `skeleton.json` written beside it. A `rigc-compiled/2` document (rigc 1.7) states no stage, editor order, `pma` or `scale`, and is still read: `render` and `check` then read those off `skeleton.json` and the atlas, and the `poser` line names both files. A `rigc-compiled/1` document (rigc 1.6) has no `pages` either and is still read: the core then takes the placement from the atlas beside it, the `poser` line says so, and with no atlas there `render` and `check` refuse it naming the file. A `rigc-compiled/2` or `/3` build is rendered and checked with its `skeleton.atlas` gone (`--candidate` below). 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** |
|
|
@@ -207,10 +207,10 @@ What the flags mean:
|
|
|
207
207
|
| `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
|
|
208
208
|
| `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
|
|
209
209
|
| `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
|
|
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/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. With the atlas gone, `check`'s texture note says whether
|
|
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. 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. 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; the order its animations and skins are listed in, and its stage, off its own `skeleton.json
|
|
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. 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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, 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": {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A04, the body (issue #1025, step 4c of #380): every mesh has triangles, in
|
|
3
|
+
* threes, each an index into its own vertices (case 6f).
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` clause by clause. The three clauses about
|
|
6
|
+
* the triangles are about the rig — a document's mesh states its triangles
|
|
7
|
+
* and its vertices, so the same wrongness has a place to live there (where
|
|
8
|
+
* the reader refuses two of the three by the record's name) — and moved. The
|
|
9
|
+
* two about the vertex run — a weighted run whose length is not a multiple of
|
|
10
|
+
* three, an unweighted array of another length than the `uvs` — are about
|
|
11
|
+
* the encoding: the document states the weighted form outright, and the flat
|
|
12
|
+
* run is written by the emitter. They stay with the round trip, which hands
|
|
13
|
+
* their findings in as each mesh's `encoding`, printed where the body always
|
|
14
|
+
* printed them.
|
|
15
|
+
*/
|
|
16
|
+
import type { Verdicts } from '../harness.ts';
|
|
17
|
+
import type { MeshFacts } from '../facts/mesh_attachments.ts';
|
|
18
|
+
import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
|
|
19
|
+
|
|
20
|
+
export function a04MeshTrianglesAndEncoding({ fail, skip }: Verdicts, { meshes }: MeshFacts): void {
|
|
21
|
+
if (meshes.length === 0) return skip('A04_MESH_TRIANGLES_AND_ENCODING', SKIP_NO_MESH_ATTACHMENT);
|
|
22
|
+
for (const mesh of meshes) {
|
|
23
|
+
if (!mesh.triangles || mesh.triangles.length === 0) {
|
|
24
|
+
fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" has no triangles`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (mesh.triangles.length % 3 !== 0) {
|
|
28
|
+
fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" triangle count is not a multiple of 3`);
|
|
29
|
+
}
|
|
30
|
+
const vertexCount = mesh.worldVerticesLength / 2;
|
|
31
|
+
for (const idx of mesh.triangles) {
|
|
32
|
+
if (idx < 0 || idx >= vertexCount) {
|
|
33
|
+
fail('A04_MESH_TRIANGLES_AND_ENCODING', `mesh "${mesh.name}" index ${idx} is outside 0..${vertexCount - 1}`);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// The encoding's own clauses, kept with the round trip (the header).
|
|
38
|
+
for (const detail of mesh.encoding) fail('A04_MESH_TRIANGLES_AND_ENCODING', detail);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A06, the body (issue #1025, step 4c of #380): every page the atlas names is
|
|
3
|
+
* the size its PNG is, every region lies inside its page, and — under
|
|
4
|
+
* `spine-html` — no page claims premultiplied alpha, no two regions on one page
|
|
5
|
+
* overlap and no region is turned.
|
|
6
|
+
*
|
|
7
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
|
|
8
|
+
* regions are a fact (`../facts/atlas_regions.ts`), the directory is the
|
|
9
|
+
* caller's, and the profile's policy switch is passed in. The `check` call —
|
|
10
|
+
* and the argument for the SKIP guards it shares with A17 and A19 (#568, #608)
|
|
11
|
+
* — stays in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import type { Verdicts } from '../harness.ts';
|
|
16
|
+
import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
|
|
17
|
+
import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
|
|
18
|
+
import { pageFootprint, pageGridSentence } from '../../atlas.ts';
|
|
19
|
+
import { readPngHeader } from '../../png.ts';
|
|
20
|
+
|
|
21
|
+
export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
|
|
22
|
+
if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
|
|
23
|
+
if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
|
|
24
|
+
for (const page of atlas.pages) {
|
|
25
|
+
const abs = resolve(input.atlasDir, page.name);
|
|
26
|
+
if (!existsSync(abs)) continue; // A17 owns this
|
|
27
|
+
// 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
|
|
28
|
+
// #732). The reader's throw used to reach this rule's catch and print as
|
|
29
|
+
// `threw: not a PNG (bad signature)`: the verdict right, the sentence a
|
|
30
|
+
// stack message that named neither what the file was nor what rigc reads.
|
|
31
|
+
// `pngProblem` ([`src/png.ts`](png.ts)) is the one sentence every reader
|
|
32
|
+
// of a page states; this rule prefixes the size the atlas declares, the
|
|
33
|
+
// value the file would have had to carry.
|
|
34
|
+
//
|
|
35
|
+
// ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
|
|
36
|
+
// header carries its dimensions in a fixed field, and reading them would
|
|
37
|
+
// print a number no oracle in this tree has checked (rigc links no WebP
|
|
38
|
+
// reader to compare a parse against), about a page that is refused here
|
|
39
|
+
// either way — and measured, the variant that PASSED a WebP page whose
|
|
40
|
+
// size agreed built green under the default profile and wrote a
|
|
41
|
+
// directory that `render` then refused.
|
|
42
|
+
const header = readPngHeader(abs);
|
|
43
|
+
if (header.problem !== null) {
|
|
44
|
+
fail(
|
|
45
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
46
|
+
`page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so the ` +
|
|
47
|
+
`size was not measured: ${header.problem}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const info = header.info;
|
|
51
|
+
const onPage = atlas.regions.filter((region) => region.page.name === page.name);
|
|
52
|
+
const gridSaid = info === null ? null : pageGridSentence(page, info, onPage);
|
|
53
|
+
if (gridSaid !== null) {
|
|
54
|
+
// 🔒 **Still validity, and now for a measured reason rather than an
|
|
55
|
+
// inherited one** (issue #715). The card that opened this expected the
|
|
56
|
+
// clause to move behind the profile switch once the runtime's mapping
|
|
57
|
+
// was measured — it is declared-size-relative, so a rescaled page
|
|
58
|
+
// draws. What settles it the other way is that the sentence below can
|
|
59
|
+
// name a repair the FORMAT already provides (`scale:`), and that two of
|
|
60
|
+
// the three readers a wrong grid breaks are rigc's own and run under
|
|
61
|
+
// both profiles. The sentence, and the one derivation of the ratio under
|
|
62
|
+
// it, is `pageGridSentence` in [`src/atlas.ts`](atlas.ts): the compiler's
|
|
63
|
+
// region lift states the same one when it is asked for texels on such a
|
|
64
|
+
// page (issue #750), so the two cannot come to describe one page two ways.
|
|
65
|
+
fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', gridSaid);
|
|
66
|
+
}
|
|
67
|
+
// 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
|
|
68
|
+
// CONVENTION, not the atlas format's rules — a packed page with
|
|
69
|
+
// `rotate: 90` is what the Spine packer produces and every official example
|
|
70
|
+
// ships one. Under `spine` an atlas is judged only on whether its declared
|
|
71
|
+
// size matches the file it names.
|
|
72
|
+
if (policy && page.pma) {
|
|
73
|
+
fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `page "${page.name}" claims premultiplied alpha; parts are straight alpha`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 🔒 **A region's rectangle lies inside the page it names, and that is
|
|
77
|
+
// VALIDITY** (issue #694). The clause is not new — it has read the same four
|
|
78
|
+
// numbers since issue #266 — but it stood inside the policy half below, and
|
|
79
|
+
// behind a guard that skipped any page carrying one region that covered it.
|
|
80
|
+
// Both fences were wrong for it. A rectangle that leaves its page is not a
|
|
81
|
+
// convention somebody else may hold differently: `u2 > 1` samples whatever
|
|
82
|
+
// the wrap mode returns, which is never the drawing the pack was made of, so
|
|
83
|
+
// the part draws garbage or nothing at all. Measured on a pack shaped like
|
|
84
|
+
// the two production atlases that found this — a page declaring 2048x256
|
|
85
|
+
// with regions at `2274,0 980x200` and `0,252 100x258` — every atlas
|
|
86
|
+
// assertion passed under the default profile and the first of the two parts
|
|
87
|
+
// drew 0 of the 10,517 pixels it draws when the same rig is built loose.
|
|
88
|
+
//
|
|
89
|
+
// ⭐ The tool's other half already said so, which is what settles where the
|
|
90
|
+
// clause belongs. `resolveFromAtlas` ([`src/compile.ts`](compile.ts)) refuses
|
|
91
|
+
// exactly this rectangle as a `CompileError` under every profile when a pack
|
|
92
|
+
// arrives through `--atlas-in`, so while it was policy here the compiler and
|
|
93
|
+
// the gate disagreed about the same four numbers — and the gate is the only
|
|
94
|
+
// half that a skeleton and a pack somebody else made ever reach.
|
|
95
|
+
//
|
|
96
|
+
// ⚠️ It is stated over `atlas.regions` rather than per page group, because
|
|
97
|
+
// the question is about one region and its own page and needs no neighbour:
|
|
98
|
+
// a page carrying a single full-page region is measured too, and answers
|
|
99
|
+
// trivially. The rectangle is `pageFootprint`'s and nobody else's here
|
|
100
|
+
// (issue #579): what `TextureAtlas` transposes at 90 and not at 270 is
|
|
101
|
+
// `u2`/`v2`, a UV pair `MeshAttachment.computeUVs` never reads for an atlas
|
|
102
|
+
// region, and the page rectangle is a different quantity — transposed at
|
|
103
|
+
// BOTH quarter turns. A region that fits only *because* it is turned is
|
|
104
|
+
// inside its page, and this clause says so.
|
|
105
|
+
for (const region of atlas.regions) {
|
|
106
|
+
const foot = pageFootprint(region);
|
|
107
|
+
if (
|
|
108
|
+
region.x < 0 ||
|
|
109
|
+
region.y < 0 ||
|
|
110
|
+
region.x + foot.width > region.page.width ||
|
|
111
|
+
region.y + foot.height > region.page.height
|
|
112
|
+
) {
|
|
113
|
+
fail(
|
|
114
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
115
|
+
`region "${region.name}" occupies ${region.x},${region.y} ${foot.width}x${foot.height} of page ` +
|
|
116
|
+
`"${region.page.name}", which is ${region.page.width}x${region.page.height} — a region that runs off ` +
|
|
117
|
+
'its page samples texels that are not there',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!policy) return;
|
|
122
|
+
// ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2). This
|
|
123
|
+
// clause used to be the first alternative alone — every region's UVs
|
|
124
|
+
// `(0,0)-(1,1)` — which is rigc's *unpacked* convention and exactly what
|
|
125
|
+
// `build --pack` stops being true, so `--pack --profile spine-html` had to be
|
|
126
|
+
// a named CLI refusal. A pack is not a defect, and refusing it under this
|
|
127
|
+
// profile meant the one shape that exercises the renderer's shared-page
|
|
128
|
+
// sampling could never be gated by the renderer's own rulebook.
|
|
129
|
+
//
|
|
130
|
+
// What the first alternative bought was the attachment -> region -> file
|
|
131
|
+
// chain being checkable exactly, and `A27` already owns that half and already
|
|
132
|
+
// stands down on a multi-region page. What is left to check on a *tiling*
|
|
133
|
+
// page is what makes shared-page sampling well defined at all: no two regions
|
|
134
|
+
// on one page overlapping. A foreign pack can fail that while loading clean,
|
|
135
|
+
// and two overlapping rectangles put one drawing inside another's.
|
|
136
|
+
//
|
|
137
|
+
// ⚠️ Its sibling — every region inside the page it names — moved above and
|
|
138
|
+
// out of this profile in issue #694, and the two are not symmetrical. That
|
|
139
|
+
// one is broken for every consumer; this one is a statement about what a
|
|
140
|
+
// pack MEANS, and the corpus is the evidence rather than the taste:
|
|
141
|
+
// measured over the ten atlases in `examples/`, 0 of 132 regions are off
|
|
142
|
+
// their page and 49 pairs on four of those pages overlap, editor-exported
|
|
143
|
+
// and correct. A rule that called those files broken would be one
|
|
144
|
+
// consumer's convention refusing everybody else's data, which is the thing
|
|
145
|
+
// the profile split exists to prevent.
|
|
146
|
+
//
|
|
147
|
+
// ⚠️ Rotation stays refused either way, and that is not the same clause: it
|
|
148
|
+
// is about rigc's own packer never turning a region, which is a statement
|
|
149
|
+
// about what rigc WRITES. It stopped being a statement about what rigc can
|
|
150
|
+
// read in issue #570 — `extractRegion` now lifts a rotated region back off
|
|
151
|
+
// its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
|
|
152
|
+
// must not be re-merged: an artifact under the renderer's own rulebook that
|
|
153
|
+
// rigc did not pack is still a foreign artifact, whatever rigc can measure.
|
|
154
|
+
const regionsPerPage = new Map<string, AtlasRegionEntry[]>();
|
|
155
|
+
for (const region of atlas.regions) {
|
|
156
|
+
const on = regionsPerPage.get(region.page.name);
|
|
157
|
+
if (on) on.push(region);
|
|
158
|
+
else regionsPerPage.set(region.page.name, [region]);
|
|
159
|
+
}
|
|
160
|
+
for (const [pageName, on] of regionsPerPage) {
|
|
161
|
+
// The `onePartPerPage` guard that stood here went with the clause it was
|
|
162
|
+
// written for: with only the pair check left, a page carrying one region
|
|
163
|
+
// has no pair and the loop below does nothing on it anyway. Nothing reads
|
|
164
|
+
// `u`/`v`/`u2`/`v2` in this assertion any more, which is the point of
|
|
165
|
+
// #579 kept rather than restated.
|
|
166
|
+
const rects = on.map((region) => {
|
|
167
|
+
const foot = pageFootprint(region);
|
|
168
|
+
return { name: region.name, x: region.x, y: region.y, width: foot.width, height: foot.height };
|
|
169
|
+
});
|
|
170
|
+
for (let i = 0; i < rects.length; i++) {
|
|
171
|
+
for (let j = i + 1; j < rects.length; j++) {
|
|
172
|
+
const a = rects[i];
|
|
173
|
+
const b = rects[j];
|
|
174
|
+
if (a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height) {
|
|
175
|
+
fail(
|
|
176
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
177
|
+
`regions "${a.name}" (${a.x},${a.y} ${a.width}x${a.height}) and "${b.name}" (${b.x},${b.y} ` +
|
|
178
|
+
`${b.width}x${b.height}) overlap on page "${pageName}"; a page is one part covering it exactly or ` +
|
|
179
|
+
'a tiling of regions that do not, and two rectangles over the same texels put one drawing inside ' +
|
|
180
|
+
"the other's",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const region of atlas.regions) {
|
|
187
|
+
if (region.degrees !== 0) {
|
|
188
|
+
fail(
|
|
189
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
190
|
+
`region "${region.name}" is rotated; rigc's own packer never turns a region (see PACK_NO_ROTATE in ` +
|
|
191
|
+
'src/atlas.ts), so this atlas came from somewhere else',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A08, the body (issue #1025, step 4c of #380): every region an attachment
|
|
3
|
+
* resolves through is a region the atlas has, spelled exactly.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the atlas's
|
|
6
|
+
* region names and the skin entries' lookups are a fact
|
|
7
|
+
* (`../facts/region_joins.ts`) rather than a `TextureAtlas` built here and the
|
|
8
|
+
* raw JSON's walk. Why the rule runs before the round trip, and what A00 does
|
|
9
|
+
* about a miss it names, is argued above the `check` call in `validate()`,
|
|
10
|
+
* which stays there.
|
|
11
|
+
*
|
|
12
|
+
* `pathsWithNoRegion` is filled, not read: every lookup this body refuses for
|
|
13
|
+
* want of a region, which `validate()`'s A00 reads to defer to this rule rather
|
|
14
|
+
* than restate the miss. The model side hands a set nobody reads.
|
|
15
|
+
*/
|
|
16
|
+
import type { Verdicts } from '../harness.ts';
|
|
17
|
+
import type { RegionJoinFacts } from '../facts/region_joins.ts';
|
|
18
|
+
import { SKIP_NO_ATTACHMENT_REGION_JOIN } from '../reasons.ts';
|
|
19
|
+
|
|
20
|
+
export function a08RegionNamesMatchAttachments({ fail, skip }: Verdicts, facts: RegionJoinFacts, pathsWithNoRegion: Set<string>): void {
|
|
21
|
+
if (facts.regionNames === null) {
|
|
22
|
+
return skip(
|
|
23
|
+
'A08_REGION_NAMES_MATCH_ATTACHMENTS',
|
|
24
|
+
'the atlas text does not parse, so there are no region names to join against (A00 owns that failure)',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const regionNames = new Set(facts.regionNames);
|
|
28
|
+
if (facts.joins === null) {
|
|
29
|
+
return skip('A08_REGION_NAMES_MATCH_ATTACHMENTS', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
30
|
+
}
|
|
31
|
+
let joined = 0;
|
|
32
|
+
for (const join of facts.joins) {
|
|
33
|
+
// A `sequence` the walk will not guess at: say nothing rather than invent
|
|
34
|
+
// a region name. A00 still has the last word on it.
|
|
35
|
+
if (join.lookups === null) continue;
|
|
36
|
+
for (const lookup of join.lookups) {
|
|
37
|
+
joined++;
|
|
38
|
+
const at = `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`;
|
|
39
|
+
const present = regionNames.has(lookup);
|
|
40
|
+
if (!present) pathsWithNoRegion.add(lookup);
|
|
41
|
+
if (lookup !== lookup.trim()) {
|
|
42
|
+
fail(
|
|
43
|
+
'A08_REGION_NAMES_MATCH_ATTACHMENTS',
|
|
44
|
+
`${at}: attachment "${join.name}" resolves through path ${JSON.stringify(lookup)}, which has stray ` +
|
|
45
|
+
`whitespace — the atlas is matched on the exact string, and ${
|
|
46
|
+
present
|
|
47
|
+
? 'the region it finds carries the same padding'
|
|
48
|
+
: regionNames.has(lookup.trim())
|
|
49
|
+
? `the region this atlas has is ${JSON.stringify(lookup.trim())}, without it`
|
|
50
|
+
: 'no region of this atlas carries it'
|
|
51
|
+
}`,
|
|
52
|
+
);
|
|
53
|
+
} else if (!present) {
|
|
54
|
+
// Not a guess and not a repair — a region the atlas DOES hold that
|
|
55
|
+
// differs from the wanted name only by case or padding. It is
|
|
56
|
+
// reported because it was measured, and where there is none the
|
|
57
|
+
// sentence says nothing at all.
|
|
58
|
+
const near = [...regionNames].find((r) => r.trim().toLowerCase() === lookup.toLowerCase());
|
|
59
|
+
fail(
|
|
60
|
+
'A08_REGION_NAMES_MATCH_ATTACHMENTS',
|
|
61
|
+
`${at}: attachment "${join.name}" wants region "${lookup}", which this atlas does not have${
|
|
62
|
+
near === undefined ? '' : ` — it does have ${JSON.stringify(near)}`
|
|
63
|
+
}. Either the skeleton's "path" or the atlas region name is the one that moved`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const region of regionNames) {
|
|
69
|
+
if (region !== region.trim()) {
|
|
70
|
+
fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `atlas region ${JSON.stringify(region)} has stray whitespace`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (joined === 0 && regionNames.size === 0) {
|
|
74
|
+
return skip('A08_REGION_NAMES_MATCH_ATTACHMENTS', SKIP_NO_ATTACHMENT_REGION_JOIN);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A13, the body (issue #1025, step 4c of #380): the mesh slots and each mesh's
|
|
3
|
+
* triangles stay within the budget the rig declares.
|
|
4
|
+
*
|
|
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
|
|
7
|
+
* prelude filled from spine-core's loaded skins, and the rig info is the
|
|
8
|
+
* caller's. Why the two budgets come from the rig and never from here stays
|
|
9
|
+
* above the `check` call in `validate()`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Verdicts } from '../harness.ts';
|
|
12
|
+
import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
13
|
+
import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
|
|
14
|
+
import type { RigInfo } from '../../types.ts';
|
|
15
|
+
|
|
16
|
+
export function a13MeshBudget({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, input: { rig?: RigInfo }): void {
|
|
17
|
+
const meshAttachments = meshes;
|
|
18
|
+
const meshSlots = new Set(meshes.map((mesh) => mesh.slot));
|
|
19
|
+
const slotBudget = input.rig?.meshSlotBudget ?? null;
|
|
20
|
+
const triangleBudget = input.rig?.meshTriangleBudget ?? null;
|
|
21
|
+
if (slotBudget === null && triangleBudget === null) {
|
|
22
|
+
return skip(
|
|
23
|
+
'A13_MESH_BUDGET',
|
|
24
|
+
input.rig
|
|
25
|
+
? `the rig "${input.rig.archetype}" declares no \`invariants.meshSlots\` or \`invariants.meshTriangles\` budget`
|
|
26
|
+
: 'no rig info (validating a bare directory), so no budget is declared',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
// Two clauses and only one of them has a subject that can vanish (#580).
|
|
30
|
+
// A slot budget is a ceiling on a COUNT, and zero is a count — "this rig
|
|
31
|
+
// uses 0 of its 3 mesh slots" is a measurement — so that half holds on a
|
|
32
|
+
// skeleton with no mesh. A triangle budget is a ceiling on each mesh, so a
|
|
33
|
+
// rig that declares only that one and carries no mesh has measured
|
|
34
|
+
// nothing at all.
|
|
35
|
+
if (slotBudget === null && meshAttachments.length === 0) {
|
|
36
|
+
return skip(
|
|
37
|
+
'A13_MESH_BUDGET',
|
|
38
|
+
`the rig "${input.rig?.archetype}" budgets mesh triangles and nothing else, and ${SKIP_NO_MESH_ATTACHMENT}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (slotBudget !== null && meshSlots.size > slotBudget) {
|
|
42
|
+
fail('A13_MESH_BUDGET', `${meshSlots.size} mesh slots, the rig budgets ${slotBudget}`);
|
|
43
|
+
}
|
|
44
|
+
if (triangleBudget === null) return;
|
|
45
|
+
for (const mesh of meshAttachments) {
|
|
46
|
+
const tris = (mesh.triangles?.length ?? 0) / 3;
|
|
47
|
+
if (tris > triangleBudget) {
|
|
48
|
+
fail('A13_MESH_BUDGET', `mesh "${mesh.name}" has ${tris} triangles, the rig budgets ${triangleBudget}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A14, the body (issue #1025, step 4c of #380): no mesh spans the whole
|
|
3
|
+
* stage.
|
|
4
|
+
*
|
|
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`)
|
|
7
|
+
* rather than spine-core's loaded skins and skeleton header. The `check` call
|
|
8
|
+
* stays in `validate()`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
12
|
+
import type { StageFacts } from '../facts/stage.ts';
|
|
13
|
+
|
|
14
|
+
export function a14NoFullFrameMesh({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, stage: StageFacts): void {
|
|
15
|
+
const meshAttachments = meshes;
|
|
16
|
+
const stageW = stage.width || 0;
|
|
17
|
+
const stageH = stage.height || 0;
|
|
18
|
+
// ⚠️ A stage-less skeleton has nothing for a mesh to span, and this rule
|
|
19
|
+
// used to report that as a PASS — the `stageW && stageH` guard below reads
|
|
20
|
+
// as a measurement of a 0x0 stage that no mesh can reach. It was only ever
|
|
21
|
+
// reachable from a foreign file until a rig spec could *declare* no stage
|
|
22
|
+
// (issue #578), and a pass certifying an unmeasured rig is the exact
|
|
23
|
+
// failure mode `A21_MESH_RIM_PINNED`'s `|| 'ring'` default was (#44).
|
|
24
|
+
if (!stageW || !stageH) {
|
|
25
|
+
return skip(
|
|
26
|
+
'A14_NO_FULL_FRAME_MESH',
|
|
27
|
+
'the skeleton declares no stage size, so there is no full frame for a mesh to span',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
for (const mesh of meshAttachments) {
|
|
31
|
+
if (mesh.width >= stageW && mesh.height >= stageH) {
|
|
32
|
+
fail('A14_NO_FULL_FRAME_MESH', `mesh "${mesh.name}" spans the whole ${stageW}x${stageH} stage`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A15, the body (issue #1025, step 4c of #380): `idle` keys no bone that
|
|
3
|
+
* drives a mesh, unless the rig declares that its idle deforms them.
|
|
4
|
+
*
|
|
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
|
|
7
|
+
* than spine-core's loaded skins and the weight run decoded here, the bones
|
|
8
|
+
* `idle` keys are a fact (`../facts/animated_bones.ts`) rather than the raw
|
|
9
|
+
* JSON, and the rig info is the caller's. Why the rule assumes meshes are
|
|
10
|
+
* mostly static, and why a painting rig may say otherwise, stays above the
|
|
11
|
+
* `check` call in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Verdicts } from '../harness.ts';
|
|
14
|
+
import type { MeshEntry, SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
15
|
+
import type { AnimatedBoneFacts } from '../facts/animated_bones.ts';
|
|
16
|
+
import type { RigInfo } from '../../types.ts';
|
|
17
|
+
|
|
18
|
+
export function a15IdleNoMeshBoneKeys({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, animated: AnimatedBoneFacts, input: { rig?: RigInfo }): void {
|
|
19
|
+
const A15 = 'A15_IDLE_NO_MESH_BONE_KEYS';
|
|
20
|
+
/**
|
|
21
|
+
* Every mesh attachment, loaded, with the bones that drive it — its slot's
|
|
22
|
+
* bone, and, once weights exist, every bone its weights name. A ring mesh
|
|
23
|
+
* is driven by its CONTROL bone, a different bone from the slot's, and
|
|
24
|
+
* checking only the slot bone would let `idle` key the one bone that
|
|
25
|
+
* actually dirties the canvas every frame.
|
|
26
|
+
*/
|
|
27
|
+
const meshDrivers: Array<{ mesh: MeshEntry; bones: Set<string> }> = [];
|
|
28
|
+
for (const mesh of meshes) {
|
|
29
|
+
const bones = new Set<string>([mesh.slotBone]);
|
|
30
|
+
for (const bone of mesh.weightBones) bones.add(bone);
|
|
31
|
+
meshDrivers.push({ mesh, bones });
|
|
32
|
+
}
|
|
33
|
+
const meshBoneNames = new Set<string>();
|
|
34
|
+
for (const { bones } of meshDrivers) for (const name of bones) meshBoneNames.add(name);
|
|
35
|
+
const declared = input.rig?.idleDrivesMeshes ?? null;
|
|
36
|
+
/**
|
|
37
|
+
* A declaration that switches off nothing is refused rather than skipped,
|
|
38
|
+
* the standard `consumerDrivenMix` is held to: an opt-out that exempts
|
|
39
|
+
* nothing reads exactly like one that worked, and the next reader cannot
|
|
40
|
+
* tell the rig that needs it from the rig it was copied onto.
|
|
41
|
+
*/
|
|
42
|
+
const stale = (why: string): void => {
|
|
43
|
+
fail(
|
|
44
|
+
A15,
|
|
45
|
+
`the rig "${input.rig?.archetype}" declares invariants.idleDrivesMeshes ("${declared}"), but ${why}, so the ` +
|
|
46
|
+
'declaration switches off nothing — remove it',
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
// The same shape as A06's and A17's guards, found by auditing for it
|
|
50
|
+
// (#568): a rig with no `idle` at all has nothing here to be wrong, and a
|
|
51
|
+
// rule that reports "held" over a subject that does not exist is the
|
|
52
|
+
// vacuous pass this file's own doctrine refuses. The two states get their
|
|
53
|
+
// own sentences because they are different absences — no such animation,
|
|
54
|
+
// versus one that keys no bone.
|
|
55
|
+
const idleBones = animated.bonesKeyedBy('idle');
|
|
56
|
+
if (idleBones === undefined) {
|
|
57
|
+
if (declared !== null) return stale('the skeleton declares no "idle" animation');
|
|
58
|
+
return skip(A15, 'the skeleton declares no "idle" animation, so nothing here can key a mesh-driving bone');
|
|
59
|
+
}
|
|
60
|
+
if (idleBones === null) {
|
|
61
|
+
if (declared !== null) return stale('"idle" carries no bone timeline at all');
|
|
62
|
+
return skip(A15, '"idle" carries no bone timeline at all, so there is no key to hold against the mesh-driving bones');
|
|
63
|
+
}
|
|
64
|
+
const keyed = idleBones.filter((name) => meshBoneNames.has(name));
|
|
65
|
+
if (declared !== null) {
|
|
66
|
+
if (keyed.length === 0) return stale('"idle" keys no bone that drives a mesh');
|
|
67
|
+
const keyedSet = new Set(keyed);
|
|
68
|
+
const moved = meshDrivers.filter(({ bones }) => [...bones].some((name) => keyedSet.has(name)));
|
|
69
|
+
// `worldVerticesLength` is two numbers per vertex whatever the encoding,
|
|
70
|
+
// which is the count the runtime recomputes; reading `vertices.length`
|
|
71
|
+
// would count a weighted mesh's bone entries instead.
|
|
72
|
+
const vertices = moved.reduce((sum, { mesh }) => sum + mesh.worldVerticesLength / 2, 0);
|
|
73
|
+
const SHOWN = 8;
|
|
74
|
+
const names =
|
|
75
|
+
keyed.slice(0, SHOWN).map((name) => `"${name}"`).join(', ') + (keyed.length > SHOWN ? ` +${keyed.length - SHOWN}` : '');
|
|
76
|
+
return skip(
|
|
77
|
+
A15,
|
|
78
|
+
`declared by the rig ("${declared}"): idle keys ${keyed.length} bone(s) that drive ${moved.length} mesh ` +
|
|
79
|
+
`attachment(s) totalling ${vertices} vertices — ${names} — and each of those meshes is recomputed on every ` +
|
|
80
|
+
'frame it is shown',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
for (const [i, boneName] of keyed.entries()) {
|
|
84
|
+
// The case is named once, on the first line an agent reads, rather than
|
|
85
|
+
// after every bone: the first painting rig this met printed 42 of these,
|
|
86
|
+
// and 42 identical sentences read as 42 separate mistakes. It rides the
|
|
87
|
+
// first finding rather than being a finding of its own, so the count of
|
|
88
|
+
// FAIL lines is still the count of bones.
|
|
89
|
+
const hint =
|
|
90
|
+
i > 0
|
|
91
|
+
? ''
|
|
92
|
+
: `. ${keyed.length} bone(s) keyed by idle drive meshes; if this idle is meant to deform them (a painting ` +
|
|
93
|
+
'rig), declare invariants.idleDrivesMeshes: { "why": … } in the rig spec — or, where the motion belongs to ' +
|
|
94
|
+
'a pivot above the mesh, key that pivot one link up (FACE.md §3)';
|
|
95
|
+
fail(A15, `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip${hint}`);
|
|
96
|
+
}
|
|
97
|
+
}
|