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.
Files changed (45) hide show
  1. package/cli.ts +9 -0
  2. package/docs/AUTHORING.md +2 -2
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a09.ts +82 -0
  5. package/src/assertions/bodies/a12.ts +30 -0
  6. package/src/assertions/bodies/a24.ts +68 -0
  7. package/src/assertions/bodies/a25.ts +39 -0
  8. package/src/assertions/bodies/a26.ts +61 -0
  9. package/src/assertions/bodies/a29.ts +34 -0
  10. package/src/assertions/bodies/a30.ts +50 -0
  11. package/src/assertions/bodies/a32.ts +44 -0
  12. package/src/assertions/bodies/a39.ts +303 -0
  13. package/src/assertions/bodies/a43.ts +181 -0
  14. package/src/assertions/bodies/a46.ts +224 -0
  15. package/src/assertions/facts/animation_durations.ts +37 -0
  16. package/src/assertions/facts/bone_timelines.ts +37 -0
  17. package/src/assertions/facts/deform_survey.ts +27 -0
  18. package/src/assertions/facts/event_keys.ts +55 -0
  19. package/src/assertions/facts/sequences.ts +85 -0
  20. package/src/assertions/facts/skeleton_roster.ts +45 -0
  21. package/src/assertions/facts/two_colour.ts +52 -0
  22. package/src/assertions/inward_advance.ts +58 -0
  23. package/src/assertions/model/animation_durations.ts +57 -0
  24. package/src/assertions/model/bone_timelines.ts +56 -0
  25. package/src/assertions/model/constraints.ts +34 -16
  26. package/src/assertions/model/deform_survey.ts +24 -0
  27. package/src/assertions/model/event_keys.ts +45 -0
  28. package/src/assertions/model/index.ts +64 -2
  29. package/src/assertions/model/linked_meshes.ts +2 -2
  30. package/src/assertions/model/mesh_attachments.ts +1 -1
  31. package/src/assertions/model/region_joins.ts +20 -12
  32. package/src/assertions/model/sequences.ts +157 -0
  33. package/src/assertions/model/skeleton_roster.ts +23 -0
  34. package/src/assertions/model/skin_entries.ts +18 -8
  35. package/src/assertions/model/skin_members.ts +5 -3
  36. package/src/assertions/model/skin_meshes.ts +1 -1
  37. package/src/assertions/model/slot_colour.ts +5 -4
  38. package/src/assertions/model/two_colour.ts +61 -0
  39. package/src/assertions/model/vertex_polygons.ts +9 -8
  40. package/src/compile.ts +114 -14
  41. package/src/deformmeasure.ts +35 -2270
  42. package/src/deformstructure.ts +60 -18
  43. package/src/deformsurvey.ts +2295 -0
  44. package/src/render.ts +80 -4
  45. 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.0",
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
+ }