spine-rigc 1.9.0 → 1.9.1
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 +9 -0
- package/docs/AUTHORING.md +2 -2
- package/package.json +1 -1
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/bone_timelines.ts +56 -0
- package/src/assertions/model/constraints.ts +34 -16
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/index.ts +64 -2
- package/src/assertions/model/linked_meshes.ts +2 -2
- package/src/assertions/model/mesh_attachments.ts +1 -1
- package/src/assertions/model/region_joins.ts +20 -12
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +18 -8
- package/src/assertions/model/skin_members.ts +5 -3
- package/src/assertions/model/skin_meshes.ts +1 -1
- package/src/assertions/model/slot_colour.ts +5 -4
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +9 -8
- package/src/compile.ts +114 -14
- package/src/deformmeasure.ts +35 -2270
- package/src/deformstructure.ts +60 -18
- package/src/deformsurvey.ts +2295 -0
- package/src/render.ts +80 -4
- package/src/validate.ts +361 -1103
package/cli.ts
CHANGED
|
@@ -158,6 +158,7 @@ import {
|
|
|
158
158
|
PoserChoiceError,
|
|
159
159
|
refuseUnchosen,
|
|
160
160
|
CandidateAtlasError,
|
|
161
|
+
CandidatePairError,
|
|
161
162
|
SpineRuntimeError,
|
|
162
163
|
throughPoser,
|
|
163
164
|
type Frame,
|
|
@@ -4383,6 +4384,14 @@ try {
|
|
|
4383
4384
|
console.error(`rigc ${command}: ${err.message}`);
|
|
4384
4385
|
process.exit(2);
|
|
4385
4386
|
}
|
|
4387
|
+
// A skeleton spine-core could not load against the atlas beside it (issue #1033) — on a rigc build, a
|
|
4388
|
+
// skeleton.json or an atlas from another build beside this one's model document. The same class as the
|
|
4389
|
+
// missing atlas above: nothing was posed or written, and it is the directory the command was pointed at that
|
|
4390
|
+
// has to change. The message names both files, the reason the runtime drew them and the runtime's own words.
|
|
4391
|
+
if (err instanceof CandidatePairError) {
|
|
4392
|
+
console.error(`rigc ${command}: ${err.message}`);
|
|
4393
|
+
process.exit(2);
|
|
4394
|
+
}
|
|
4386
4395
|
if (err instanceof SpineRuntimeError) {
|
|
4387
4396
|
console.error(`rigc ${command}: ${err.message}`);
|
|
4388
4397
|
process.exit(1);
|
package/docs/AUTHORING.md
CHANGED
|
@@ -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/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 |
|
|
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`. 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; 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. 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) |
|
|
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.9.
|
|
3
|
+
"version": "1.9.1",
|
|
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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A09, the body (issue #1025, cut 4c-3 of step 4c of #380): the compiled
|
|
3
|
+
* duration is the declared duration (rule 4).
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the
|
|
6
|
+
* animations, their durations and their timelines' durations as one fact
|
|
7
|
+
* (`../facts/animation_durations.ts`) where it read the loaded skeleton's
|
|
8
|
+
* `animations`, `findAnimation` and each timeline's `getDuration()`. The
|
|
9
|
+
* declared durations are the caller's on both sides, as the rig info is. The
|
|
10
|
+
* `check` call stays in `validate()`.
|
|
11
|
+
*/
|
|
12
|
+
import type { Verdicts } from '../harness.ts';
|
|
13
|
+
import type { AnimationDurationFacts } from '../facts/animation_durations.ts';
|
|
14
|
+
import { SKIP_NO_DECLARED_DURATION } from '../reasons.ts';
|
|
15
|
+
import { float32Step } from '../../timelines.ts';
|
|
16
|
+
|
|
17
|
+
/** One frame at 60 fps: how far a last key may fall short of the declared end before the declared duration reads as wrong. */
|
|
18
|
+
const FRAME = 1 / 60;
|
|
19
|
+
|
|
20
|
+
export function a09AnimationDurationMatchesSpec({ fail, skip }: Verdicts, facts: AnimationDurationFacts, declaredDurations: Record<string, number> | undefined): void {
|
|
21
|
+
// Without the spec there is no declared duration to compare the compiled
|
|
22
|
+
// one against, and returning here used to count as a PASS — a gate saying
|
|
23
|
+
// it checked something it never looked at.
|
|
24
|
+
if (!declaredDurations) {
|
|
25
|
+
return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', 'no motion spec supplied, so no declared duration to compare against');
|
|
26
|
+
}
|
|
27
|
+
// Same trap one level down. A **static rig** — a skeleton that exists to
|
|
28
|
+
// be posed and carries no animation at all, which is what
|
|
29
|
+
// `1-weight-and-mass`'s second export is — declares nothing and loads
|
|
30
|
+
// nothing, so both loops below iterate zero times and the assertion
|
|
31
|
+
// reported PASS. That is the vacuous green this report is built to refuse:
|
|
32
|
+
// there is no duration here, and saying so is the honest answer.
|
|
33
|
+
if (Object.keys(declaredDurations).length === 0 && facts.animations.length === 0) {
|
|
34
|
+
return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', SKIP_NO_DECLARED_DURATION);
|
|
35
|
+
}
|
|
36
|
+
for (const [name, declared] of Object.entries(declaredDurations)) {
|
|
37
|
+
const anim = facts.animations.find((a) => a.name === name);
|
|
38
|
+
if (!anim) {
|
|
39
|
+
fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `spec declares animation "${name}" but the skeleton has none`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Two arms, and the asymmetry is the point.
|
|
43
|
+
//
|
|
44
|
+
// UNDERSHOOT is R7's question — is the declared duration wrong? An
|
|
45
|
+
// animation may hold its final pose, so a last key a little before the
|
|
46
|
+
// end is ordinary and a frame of that is slack.
|
|
47
|
+
//
|
|
48
|
+
// OVERSHOOT is a different question with a different tolerance.
|
|
49
|
+
// `anim.duration` IS the largest key time (`SkeletonJson.ts:1261` takes
|
|
50
|
+
// the max over every timeline's own duration), so a loaded duration past
|
|
51
|
+
// the declared one means a KEY is past it — and nothing that plays the
|
|
52
|
+
// animation for the duration it declares will ever reach that key. Rung
|
|
53
|
+
// 6 lost a one-frame attachment reveal to a key 3.4e-5 s past the end,
|
|
54
|
+
// 1/500 of FRAME, which this comparison read as agreement (issue #54).
|
|
55
|
+
// `compile.ts` refuses that per timeline now; this is the same rule held
|
|
56
|
+
// against a skeleton the compiler never saw.
|
|
57
|
+
// The slack is one float32 step at the declared duration, and it is the
|
|
58
|
+
// same function the compiler's Rule 4 refuses on (`float32Step`, in
|
|
59
|
+
// `timelines.ts`, which says why it is a step of the float and no longer
|
|
60
|
+
// a fixed 1e-6 plus one).
|
|
61
|
+
const slack = float32Step(declared);
|
|
62
|
+
const past = anim.duration - declared;
|
|
63
|
+
if (past > slack) {
|
|
64
|
+
const late = anim.timelineDurations.filter((d) => d - declared > slack).length;
|
|
65
|
+
fail(
|
|
66
|
+
'A09_ANIMATION_DURATION_MATCHES_SPEC',
|
|
67
|
+
`animation "${name}" has ${late} timeline(s) keyed past the declared duration ${declared}s — ` +
|
|
68
|
+
`the last key is at ${anim.duration}s, ${past.toFixed(6)}s late, so nothing ever samples it`,
|
|
69
|
+
);
|
|
70
|
+
} else if (declared - anim.duration > FRAME) {
|
|
71
|
+
fail(
|
|
72
|
+
'A09_ANIMATION_DURATION_MATCHES_SPEC',
|
|
73
|
+
`animation "${name}" loaded duration ${anim.duration}s, spec declares ${declared}s`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const anim of facts.animations) {
|
|
78
|
+
if (!(anim.name in declaredDurations)) {
|
|
79
|
+
fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `skeleton has animation "${anim.name}" with no spec entry`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A12, the body (issue #1025, cut 4c-4 of #380): no dark colour and no
|
|
3
|
+
* two-colour timeline — both parsed, then silently ignored by spine-html.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the slots
|
|
6
|
+
* are a fact (`../facts/skeleton_roster.ts`) and the slot timelines are A45's
|
|
7
|
+
* (`../facts/slot_colour.ts`), rather than the raw JSON and `walkTimelines`
|
|
8
|
+
* over it. The path printed is the one `walkTimelines` built for a slot
|
|
9
|
+
* timeline, `<animation>.slots.<slot>.<timeline>`, and only a timeline whose
|
|
10
|
+
* keys are a list is one it visited.
|
|
11
|
+
*/
|
|
12
|
+
import type { Verdicts } from '../harness.ts';
|
|
13
|
+
import type { SkeletonRosterFacts } from '../facts/skeleton_roster.ts';
|
|
14
|
+
import type { SlotTimelines } from '../facts/slot_colour.ts';
|
|
15
|
+
|
|
16
|
+
export function a12NoDarkColor({ fail }: Verdicts, { slots }: SkeletonRosterFacts, slotTimelines: readonly SlotTimelines[]): void {
|
|
17
|
+
for (const slot of slots) {
|
|
18
|
+
if (slot.dark) {
|
|
19
|
+
fail('A12_NO_DARK_COLOR', `slot "${slot.name}" declares a dark colour; the renderer ignores it`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const { animation, slot, timelines } of slotTimelines) {
|
|
23
|
+
for (const [name, keys] of Object.entries(timelines)) {
|
|
24
|
+
if (!Array.isArray(keys)) continue;
|
|
25
|
+
if (name === 'rgba2' || name === 'rgb2') {
|
|
26
|
+
fail('A12_NO_DARK_COLOR', `${animation}.slots.${slot}.${name}: two-colour timeline "${name}" is silently ignored by the renderer`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A24, the body (issue #1025, cut 4c-4 of #380): motion under the axis bone
|
|
3
|
+
* stays in AXIS space, and the axis bone itself carries no keys.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the bone
|
|
6
|
+
* timelines are a fact (`../facts/bone_timelines.ts`) rather than the raw
|
|
7
|
+
* JSON's `animations`, walked in the same order, and the rig info is the
|
|
8
|
+
* caller's. Why the rule exists — the generator that wrote screen-space travel
|
|
9
|
+
* — stays above the `check` call in `validate()`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Verdicts } from '../harness.ts';
|
|
12
|
+
import type { BoneTimelineFacts } from '../facts/bone_timelines.ts';
|
|
13
|
+
import type { RigInfo } from '../../types.ts';
|
|
14
|
+
import { isObj, type Json } from '../values.ts';
|
|
15
|
+
|
|
16
|
+
export function a24AxisSpaceStroke({ fail, skip }: Verdicts, { boneTimelines }: BoneTimelineFacts, input: { rig?: RigInfo }): void {
|
|
17
|
+
const rig = input.rig;
|
|
18
|
+
if (!rig) return skip('A24_AXIS_SPACE_STROKE', 'no rig info (validating a bare directory)');
|
|
19
|
+
if (!rig.axisBone) {
|
|
20
|
+
return skip(
|
|
21
|
+
'A24_AXIS_SPACE_STROKE',
|
|
22
|
+
`the rig "${rig.archetype}" declares no axis bone, so there is no axis space for a stroke to leave`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
const subtree = new Set(rig.axisSubtree);
|
|
26
|
+
// The stroke is the subject and its keys are where it lives (#580). A rig
|
|
27
|
+
// that names an axis bone and then keys neither it nor anything under it has
|
|
28
|
+
// no stroke for this rule to find out of axis space, and a loop over nothing
|
|
29
|
+
// used to report that as held.
|
|
30
|
+
let keyed = 0;
|
|
31
|
+
for (const { animation: animName, bone: boneName, timelines } of boneTimelines) {
|
|
32
|
+
if (boneName === rig.axisBone) {
|
|
33
|
+
keyed++;
|
|
34
|
+
fail(
|
|
35
|
+
'A24_AXIS_SPACE_STROKE',
|
|
36
|
+
`"${animName}" keys the axis bone "${boneName}"; the axis angle is a per-cut SETUP value, not animation`,
|
|
37
|
+
);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (subtree.has(boneName)) keyed++;
|
|
41
|
+
if (!subtree.has(boneName) || !isObj(timelines)) continue;
|
|
42
|
+
if ('translatey' in timelines) {
|
|
43
|
+
fail(
|
|
44
|
+
'A24_AXIS_SPACE_STROKE',
|
|
45
|
+
`"${animName}" gives "${boneName}" a translatey timeline; a bone under "${rig.axisBone}" moves along the axis only`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const keys = (timelines as Json).translate;
|
|
49
|
+
if (!Array.isArray(keys)) continue;
|
|
50
|
+
for (const key of keys) {
|
|
51
|
+
if (!isObj(key)) continue;
|
|
52
|
+
const y = Number(key.y ?? 0);
|
|
53
|
+
if (Number.isFinite(y) && Math.abs(y) > 1e-6) {
|
|
54
|
+
fail(
|
|
55
|
+
'A24_AXIS_SPACE_STROKE',
|
|
56
|
+
`"${animName}" keys "${boneName}" translate y=${y} at t=${String(key.time ?? 0)}; the axis bone carries the direction, so keys are translateX only`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (keyed === 0) {
|
|
62
|
+
return skip(
|
|
63
|
+
'A24_AXIS_SPACE_STROKE',
|
|
64
|
+
`no animation keys the axis bone "${rig.axisBone}" or any of the ${rig.axisSubtree.length} bone(s) under ` +
|
|
65
|
+
'it, so this rig has no stroke to hold in axis space',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A25, the body (issue #1025, cut 4c-4 of #380): a bone the rig declares
|
|
3
|
+
* detached from another is not its descendant.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the bones
|
|
6
|
+
* and their parents are a fact (`../facts/skeleton_roster.ts`) rather than the
|
|
7
|
+
* raw JSON's `bones`, and the rig info is the caller's. Why some bones are
|
|
8
|
+
* detached on purpose stays above the `check` call in `validate()`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import type { SkeletonRosterFacts } from '../facts/skeleton_roster.ts';
|
|
12
|
+
import type { RigInfo } from '../../types.ts';
|
|
13
|
+
|
|
14
|
+
export function a25DetachedBoneParentage({ fail, skip }: Verdicts, { bones }: SkeletonRosterFacts, input: { rig?: RigInfo }): void {
|
|
15
|
+
const rig = input.rig;
|
|
16
|
+
if (!rig) return skip('A25_DETACHED_BONE_PARENTAGE', 'no rig info (validating a bare directory)');
|
|
17
|
+
if (!rig.detached.length) {
|
|
18
|
+
return skip('A25_DETACHED_BONE_PARENTAGE', `the rig "${rig.archetype}" declares no forbidden parentage`);
|
|
19
|
+
}
|
|
20
|
+
const parentOf = new Map<string, string | null>();
|
|
21
|
+
for (const bone of bones) parentOf.set(bone.name, bone.parent);
|
|
22
|
+
for (const [child, forbidden] of rig.detached) {
|
|
23
|
+
if (!parentOf.has(child)) {
|
|
24
|
+
fail('A25_DETACHED_BONE_PARENTAGE', `the rig declares "${child}" detached from "${forbidden}" but has no such bone`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (let cursor = parentOf.get(child) ?? null; cursor; cursor = parentOf.get(cursor) ?? null) {
|
|
29
|
+
if (seen.has(cursor)) break; // a cycle; the loader would have thrown first
|
|
30
|
+
seen.add(cursor);
|
|
31
|
+
if (cursor !== forbidden) continue;
|
|
32
|
+
fail(
|
|
33
|
+
'A25_DETACHED_BONE_PARENTAGE',
|
|
34
|
+
`"${child}" is a descendant of "${forbidden}"; it must not be dragged by that bone's motion`,
|
|
35
|
+
);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A26, the body (issue #1025, cut 4c-4 of #380): the slots array IS the rig's
|
|
3
|
+
* slot table — nothing out of order and nothing missing.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the slots'
|
|
6
|
+
* names are a fact (`../facts/skeleton_roster.ts`) rather than the raw JSON's
|
|
7
|
+
* `slots`, and the rig info is the caller's. Why the second half exists (issue
|
|
8
|
+
* #575) stays above the `check` call in `validate()`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import type { SkeletonRosterFacts } from '../facts/skeleton_roster.ts';
|
|
12
|
+
import type { RigInfo } from '../../types.ts';
|
|
13
|
+
|
|
14
|
+
export function a26SlotDrawOrder({ fail, skip }: Verdicts, { slots }: SkeletonRosterFacts, input: { rig?: RigInfo }): void {
|
|
15
|
+
if (!input.rig) return skip('A26_SLOT_DRAW_ORDER', 'no rig info (validating a bare directory)');
|
|
16
|
+
const order = input.rig.slotOrder;
|
|
17
|
+
if (!order) {
|
|
18
|
+
return skip(
|
|
19
|
+
'A26_SLOT_DRAW_ORDER',
|
|
20
|
+
`the rig "${input.rig.archetype}" declares no canonical slot order, so the emitted order has nothing to disagree with`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const names = slots.map((s) => s.name);
|
|
24
|
+
// ⛔ **No empty-subject SKIP here, and #575 is the whole reason** (#580). The
|
|
25
|
+
// empty sequence is a subsequence of every table, so before #575 a skeleton
|
|
26
|
+
// with no slot reported its draw order held and this rule wanted the same
|
|
27
|
+
// guard its neighbours got. It no longer does: the completeness clause below
|
|
28
|
+
// reads zero emitted slots against a declared table as EVERY slot lost,
|
|
29
|
+
// which is the maximal case of exactly the defect #575 filed — so the honest
|
|
30
|
+
// verdict is a FAIL naming them, and a SKIP here would suppress it. Measured:
|
|
31
|
+
// with the guard in place a rig declaring one slot beside an artifact
|
|
32
|
+
// carrying none reported SKIP; with it gone, `"block" is declared and not
|
|
33
|
+
// emitted`. A26 therefore has no vacuous pass left to convert, the way
|
|
34
|
+
// `A07_ATLAS_TEXT_SHAPE` has none.
|
|
35
|
+
let at = 0;
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const found = order.indexOf(name, at);
|
|
38
|
+
if (found < 0) {
|
|
39
|
+
const known = order.indexOf(name);
|
|
40
|
+
fail(
|
|
41
|
+
'A26_SLOT_DRAW_ORDER',
|
|
42
|
+
known < 0
|
|
43
|
+
? `slot "${name}" is not in the archetype's slot table`
|
|
44
|
+
: `slot "${name}" is drawn out of order (table position ${known}, after a slot at ${at})`,
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
at = found + 1;
|
|
49
|
+
}
|
|
50
|
+
const emitted = new Set(names);
|
|
51
|
+
const missing = order.filter((name) => !emitted.has(name));
|
|
52
|
+
if (missing.length > 0) {
|
|
53
|
+
fail(
|
|
54
|
+
'A26_SLOT_DRAW_ORDER',
|
|
55
|
+
`the rig "${input.rig.archetype}" declares ${order.length} slot(s) and the skeleton has ${names.length}: ` +
|
|
56
|
+
`${missing.map((name) => `"${name}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} declared and ` +
|
|
57
|
+
'not emitted. A slot nothing fills is emitted with no setup attachment, not dropped — dropping one ' +
|
|
58
|
+
'moves every slot below it up one index',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A29, the body (issue #1025, cut 4c-4 of #380): inward travel stops where the
|
|
3
|
+
* two masses meet — the measured `stroke.contact_depth`.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the travel
|
|
6
|
+
* is measured over the bone timelines as a fact (`../inward_advance.ts` over
|
|
7
|
+
* `../facts/bone_timelines.ts`) rather than over the raw JSON, and the rig
|
|
8
|
+
* info is the caller's. Why the ceiling is a measured fact about the art stays
|
|
9
|
+
* above the `check` call in `validate()`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Verdicts } from '../harness.ts';
|
|
12
|
+
import type { BoneTimelineFacts } from '../facts/bone_timelines.ts';
|
|
13
|
+
import type { RigInfo } from '../../types.ts';
|
|
14
|
+
import { deepestInwardAdvance } from '../inward_advance.ts';
|
|
15
|
+
|
|
16
|
+
export function a29StrokeWithinContactDepth({ fail, skip, stats }: Verdicts, facts: BoneTimelineFacts, input: { rig?: RigInfo }): void {
|
|
17
|
+
const rig = input.rig;
|
|
18
|
+
if (!rig) return skip('A29_STROKE_WITHIN_CONTACT_DEPTH', 'no rig info (validating a bare directory)');
|
|
19
|
+
if (!rig.contactDepth) {
|
|
20
|
+
return skip(
|
|
21
|
+
'A29_STROKE_WITHIN_CONTACT_DEPTH',
|
|
22
|
+
'the manifest declares no `stroke.contact_depth`, so this cut has no measured contact ceiling to hold the stroke to',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
const deep = deepestInwardAdvance(facts, rig);
|
|
26
|
+
stats.contactDepth = rig.contactDepth;
|
|
27
|
+
stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
|
|
28
|
+
if (deep.total > rig.contactDepth + 1e-6) {
|
|
29
|
+
fail(
|
|
30
|
+
'A29_STROKE_WITHIN_CONTACT_DEPTH',
|
|
31
|
+
`${deep.describe()} but the masses meet at ${rig.contactDepth}px — the two plates would interpenetrate`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A30, the body (issue #1025, cut 4c-4 of #380): inward travel stops where the
|
|
3
|
+
* drawn cover runs out — the measured `stroke.cap_containment_ceiling` — and
|
|
4
|
+
* no bone under the axis scales while that ceiling is declared.
|
|
5
|
+
*
|
|
6
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the travel
|
|
7
|
+
* and the scale timelines are read off the bone timelines as a fact
|
|
8
|
+
* (`../facts/bone_timelines.ts`, through `../inward_advance.ts`) rather than
|
|
9
|
+
* off the raw JSON, and the rig info is the caller's. Why this is not a
|
|
10
|
+
* restatement of A29, and why a scale key is refused rather than measured,
|
|
11
|
+
* stay above the `check` call in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Verdicts } from '../harness.ts';
|
|
14
|
+
import type { BoneTimelineFacts } from '../facts/bone_timelines.ts';
|
|
15
|
+
import type { RigInfo } from '../../types.ts';
|
|
16
|
+
import { deepestInwardAdvance } from '../inward_advance.ts';
|
|
17
|
+
import { isObj } from '../values.ts';
|
|
18
|
+
|
|
19
|
+
export function a30StrokeWithinCapContainment({ fail, skip, stats }: Verdicts, facts: BoneTimelineFacts, input: { rig?: RigInfo }): void {
|
|
20
|
+
const rig = input.rig;
|
|
21
|
+
if (!rig) return skip('A30_STROKE_WITHIN_CAP_CONTAINMENT', 'no rig info (validating a bare directory)');
|
|
22
|
+
if (!rig.capContainmentCeiling) {
|
|
23
|
+
return skip(
|
|
24
|
+
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
25
|
+
'the manifest declares no `stroke.cap_containment_ceiling`, so this cut has no measured containment ceiling',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const deep = deepestInwardAdvance(facts, rig);
|
|
29
|
+
stats.capCeiling = rig.capContainmentCeiling;
|
|
30
|
+
stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
|
|
31
|
+
if (deep.total > rig.capContainmentCeiling + 1e-6) {
|
|
32
|
+
fail(
|
|
33
|
+
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
34
|
+
`${deep.describe()} but the leading contour leaves the occluder's opaque footprint at ` +
|
|
35
|
+
`${rig.capContainmentCeiling}px — the part would be drawn where it should be covered`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const subtree = new Set(rig.axisSubtree);
|
|
39
|
+
for (const { animation: animName, bone: boneName, timelines } of facts.boneTimelines) {
|
|
40
|
+
if (!subtree.has(boneName) || !isObj(timelines)) continue;
|
|
41
|
+
for (const name of Object.keys(timelines)) {
|
|
42
|
+
if (name !== 'scale' && name !== 'scalex' && name !== 'scaley') continue;
|
|
43
|
+
fail(
|
|
44
|
+
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
45
|
+
`"${animName}" gives "${boneName}" a ${name} timeline while a cap-containment ceiling is declared; ` +
|
|
46
|
+
'the ceiling was measured on the undeformed contour, so a scaled plate is outside its evidence',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A32, the body (issue #1025, cut 4c-4 of #380): an event key that sets
|
|
3
|
+
* `volume` or `balance` fires an event that declares an audio path — and the
|
|
4
|
+
* clauses that stay with the round trip, printed where they always printed.
|
|
5
|
+
*
|
|
6
|
+
* Split per clause (`../facts/event_keys.ts`). The rig's clause moved here: a
|
|
7
|
+
* readable document can state a key's `volume` on an event with no `audio`,
|
|
8
|
+
* and the file the emitter writes from it is the file this clause refuses. The
|
|
9
|
+
* other four — no string `name`, an undeclared event, a time that is not a
|
|
10
|
+
* finite number, a time before the key before it — are states the model
|
|
11
|
+
* document's reader refuses by name, so no readable document reaches them;
|
|
12
|
+
* `validate()` runs them over the skeleton JSON and hands their findings in
|
|
13
|
+
* (`kept`), and this body prints them at their place, in their order, before
|
|
14
|
+
* the moved clause and instead of it where one ended the key's reading. The
|
|
15
|
+
* three failure modes and why the rule reads the raw JSON stay above the
|
|
16
|
+
* `check` call in `validate()`.
|
|
17
|
+
*/
|
|
18
|
+
import type { Verdicts } from '../harness.ts';
|
|
19
|
+
import type { EventKeyFacts } from '../facts/event_keys.ts';
|
|
20
|
+
|
|
21
|
+
/** Where a key is, as every A32 line names it — the kept clauses in `src/validate.ts` name it this way too. */
|
|
22
|
+
export function eventKeyAt(animation: string, index: number): string {
|
|
23
|
+
return `animation "${animation}" event key ${index}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function a32EventKeysResolve({ fail, skip }: Verdicts, facts: EventKeyFacts): void {
|
|
27
|
+
if (!facts.parsed) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
28
|
+
if (!facts.animations) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton declares no animations');
|
|
29
|
+
for (const key of facts.keys) {
|
|
30
|
+
for (const finding of key.kept) fail('A32_EVENT_KEYS_RESOLVE', finding);
|
|
31
|
+
if (key.stopped) continue;
|
|
32
|
+
const at = eventKeyAt(key.animation, key.index);
|
|
33
|
+
for (const field of ['volume', 'balance'] as const) {
|
|
34
|
+
if (key.sets[field] && !key.audio) {
|
|
35
|
+
fail(
|
|
36
|
+
'A32_EVENT_KEYS_RESOLVE',
|
|
37
|
+
`${at}: "${key.name}" sets ${field}, but the event declares no audio path — the parser reads ` +
|
|
38
|
+
`${field} only for an event that has one, so it is dropped in silence`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (facts.timelines === 0) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
|
|
44
|
+
}
|