spine-rigc 1.6.2 → 1.8.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.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A11, the body (issue #1025, step 4c of #380): no clipping attachment — the
3
+ * renderer skips them silently. A `renderer` rule, so `spine` never runs it.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the count is
6
+ * a fact (`../facts/skin_entries.ts`).
7
+ */
8
+ import type { Verdicts } from '../harness.ts';
9
+ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
10
+
11
+ export function a11NoClippingAttachments({ fail }: Verdicts, { clippingCount }: SkinEntryFacts): void {
12
+ if (clippingCount > 0) {
13
+ fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
14
+ }
15
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A17, the body (issue #1025, step 4c of #380): every page the atlas names is
3
+ * a file on disk, resolved against the directory the atlas sits in.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the pages are
6
+ * a fact (`../facts/atlas_pages.ts`), and the directory is the caller's. Why
7
+ * the guard is a SKIP and never a bare `return` (issues #568, #608) is argued
8
+ * above the `check` call in `validate()`, which stays there with A06 and A19.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { AtlasPageFacts } from '../facts/atlas_pages.ts';
14
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
15
+
16
+ export function a17AtlasPageFilesExist({ fail, skip }: Verdicts, { atlas }: AtlasPageFacts, input: { atlasDir: string }): void {
17
+ if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
18
+ // The other absence, one ring in (#580): the atlas LOADED and declares no
19
+ // page, so there is no file to find missing. `!atlas` and "zero pages" print
20
+ // different reasons because they are different facts about the artifact.
21
+ if (atlas.pages.length === 0) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS_PAGE);
22
+ for (const page of atlas.pages) {
23
+ const abs = resolve(input.atlasDir, page.name);
24
+ if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
25
+ }
26
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * A45, the body (issue #1025, step 4c of #380): the separable colour timelines
3
+ * own their channels and pose as written.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
6
+ * three facts (`../facts/slot_colour.ts`) where it read the skeleton JSON and
7
+ * the loaded skeleton: the animations' slot timelines in the file's order, as
8
+ * the file spells them; whether an animation exists; and a slot's colour posed
9
+ * at a time on a fresh track, which `validate()` takes from spine-core and the
10
+ * model side from rigc's core. The `check` call stays in `validate()`.
11
+ */
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { SlotColourFacts } from '../facts/slot_colour.ts';
14
+ import { SKIP_NO_SEPARABLE_COLOR } from '../reasons.ts';
15
+ import { atStoredKey, isObj } from '../values.ts';
16
+ import { SLOT_COLOR_CHANNELS } from '../../timelines.ts';
17
+
18
+ // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
19
+ // `alpha` pose no dark colour, so under A43's name a failure would be a
20
+ // verdict saying "two-colour tint" about a file that states one colour —
21
+ // the name would say something other than what it decides (the rule #712
22
+ // applied to A43 itself, against a clause on A10). And the SKIP is the
23
+ // sharper half: a rig with a `dark` and no separable timeline has A43's
24
+ // subject present, so a clause there could only PASS on it, which is a pass
25
+ // for an absent subject.
26
+ //
27
+ // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
28
+ // clauses are about the file against the runtime:
29
+ //
30
+ // 1. **One channel, one timeline.** Each colour timeline poses its
31
+ // channels at EVERY time — before its first key it writes the setup
32
+ // value — so when two of one slot share a channel, the one applied
33
+ // later (the one the file states later) overwrites the other
34
+ // everywhere and the first one's keys on it are read by nothing
35
+ // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
36
+ // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
37
+ // loads without a word.
38
+ // 2. **Posed as written.** Stepped to each key's own time, the channels
39
+ // the timeline writes are the key's: a colour that is not six hex
40
+ // digits loads as NaN, and a key whose time another key repeats is
41
+ // read by nothing, and both parse in silence.
42
+ //
43
+ // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
44
+ // setup alpha is NOT this rule's to see, and nothing that reads only the
45
+ // file can see it: the result is a correct `rgba`, which is also what an
46
+ // author keying the light colour and holding its alpha would write. What
47
+ // differs is what happens UNDER another track that moves the alpha, which
48
+ // is the consumer's composing rather than the object (CLAUDE.md). That
49
+ // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
50
+ //
51
+ // ⚠️ The required values are parsed HERE, as A43's are, and the channel
52
+ // table is `timelines.ts`'s rather than the loaded timelines' property ids:
53
+ // a check that asked the parser what a timeline writes would agree with it
54
+ // whatever it did. A selftest control holds that table to the runtime's ids.
55
+ export function a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten({ fail, skip, stats }: Verdicts, facts: SlotColourFacts): void {
56
+ const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
57
+ /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
58
+ const readRgbHex = (hex: unknown): [number, number, number] | null => {
59
+ if (typeof hex !== 'string') return null;
60
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
61
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
62
+ return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
63
+ };
64
+ /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
65
+ const HEX_STEP = 1 / 510;
66
+ const FLOAT_STEP = 1e-6;
67
+ const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
68
+ const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
69
+
70
+ // -- the subjects, read off the FILE ---------------------------------
71
+ const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
72
+ for (const { animation: animName, slot: slotName, timelines } of facts.slotTimelines) {
73
+ if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
74
+ subjects.push({ anim: animName, slot: slotName, timelines });
75
+ }
76
+ }
77
+ if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
78
+ stats.separableColorTimelines = subjects.reduce(
79
+ (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
80
+ 0,
81
+ );
82
+
83
+ for (const { anim: animName, slot: slotName, timelines } of subjects) {
84
+ const at = `animation "${animName}" slot "${slotName}"`;
85
+ // In FILE order, which is the order `readAnimation` pushes them and so
86
+ // the order they apply in.
87
+ const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
88
+
89
+ // -- clause 1: one channel, one timeline ---------------------------
90
+ let shared = false;
91
+ for (const channel of ['rgb', 'alpha', 'dark'] as const) {
92
+ const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
93
+ if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
94
+ shared = true;
95
+ const last = writers[writers.length - 1];
96
+ fail(
97
+ NAME,
98
+ `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
99
+ `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
100
+ `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
101
+ 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
102
+ );
103
+ }
104
+ if (shared) continue;
105
+
106
+ // -- clause 2: posed as written ------------------------------------
107
+ //
108
+ // ⚠️ Two things this does NOT do, both measured rather than skipped:
109
+ // it does not read the loaded timeline's CLASS back, and it does not
110
+ // hold a channel the timeline leaves alone to the setup pose. Against
111
+ // the linked parser neither can fail — every `rgb` / `alpha` array that
112
+ // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
113
+ // three other outcomes (an empty array, a slot the skeleton lacks, a
114
+ // name outside the switch) throw at `A00` — so either would be a clause
115
+ // nobody can see fire. The selftest reads both off spine-core directly
116
+ // (`S82`–`S84`), where they are measurements of the runtime rather than
117
+ // checks on a file.
118
+ if (!facts.hasAnimation(animName)) {
119
+ fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
120
+ continue;
121
+ }
122
+ for (const name of ['rgb', 'alpha'] as const) {
123
+ const keys = timelines[name];
124
+ if (!Array.isArray(keys)) continue;
125
+ const where = `${at} ${name}`;
126
+ for (const rawKey of keys) {
127
+ if (!isObj(rawKey)) continue;
128
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
129
+ // At the key as the runtime stores it, not one float step before
130
+ // it — see `atStoredKey` (issue #771).
131
+ const posed = facts.posedSlot(animName, slotName, atStoredKey(time));
132
+ if (!posed) {
133
+ fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
134
+ continue;
135
+ }
136
+ const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
137
+ if (name === 'rgb') {
138
+ const stated = readRgbHex(rawKey.color);
139
+ if (stated === null) {
140
+ fail(
141
+ NAME,
142
+ `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
143
+ `digits — and the runtime poses (${show(light.slice(0, 3))})`,
144
+ );
145
+ } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
146
+ fail(
147
+ NAME,
148
+ `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
149
+ `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
150
+ );
151
+ }
152
+ } else {
153
+ // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
154
+ // omits it there, so absence is read the parser's way rather than
155
+ // as a malformed key.
156
+ const stated = rawKey.value === undefined ? 0 : rawKey.value;
157
+ if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
158
+ fail(
159
+ NAME,
160
+ `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
161
+ (typeof stated !== 'number'
162
+ ? ' — an alpha key\'s value is a number'
163
+ : stated < 0 || stated > 1
164
+ ? ' — the runtime clamps a posed alpha to 0..1'
165
+ : ''),
166
+ );
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The atlas's pages, as the bodies that read them see them (issue #1025, step
3
+ * 4c of #380) — the census's F20, without `pma`, which the model document does
4
+ * not hold and a later cut supplies from the caller.
5
+ *
6
+ * Links nothing from the runtime: a spine-core `TextureAtlas` satisfies the
7
+ * shape structurally, which is how `validate()` supplies it, and the model
8
+ * side supplies the document's `pages` section (`../model/atlas_pages.ts`).
9
+ */
10
+
11
+ /** One page: its name, a path relative to the directory the atlas sits in. */
12
+ export interface AtlasPage {
13
+ readonly name: string;
14
+ }
15
+
16
+ /** The atlas, or `null` when there is none to read — the round trip refused it, or the model side read no document. */
17
+ export interface AtlasPageFacts {
18
+ readonly atlas: { readonly pages: readonly AtlasPage[] } | null;
19
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The skin entries, as the bodies that walk them read them (issue #1025, step
3
+ * 4c of #380) — the census's F03, F05 and F06: every skin's attachments, of
4
+ * which kind each is, and a region's name and size.
5
+ *
6
+ * ⭐ **The order is a fact, and it is the file's.** A body that prints one
7
+ * line per offending entry prints them in the order this list holds them, so a
8
+ * supplier that walked another order would print the same findings in another
9
+ * order — a different report about the same rig. The order is the one
10
+ * spine-core's loaded skins list their entries in: the skins in the file's
11
+ * order (`default` first, the rest in the editor's order the emitter writes —
12
+ * `editorSkinOrder` in `src/compile.ts`), and within a skin the entries slot by
13
+ * slot in the skeleton's slot order, each slot's in the order its table states
14
+ * them. The model side produces it by calling the emitter's own order, never
15
+ * by restating it (`../model/skin_entries.ts`); the selftest measures the two
16
+ * walks equal on every multi-skin build it makes.
17
+ *
18
+ * Links nothing from the runtime: a spine-core `RegionAttachment` satisfies
19
+ * the entry shape structurally, which is how `validate()` supplies it.
20
+ */
21
+
22
+ /** One region attachment: the name it loaded under (its `name`, else its placeholder) and its size. */
23
+ export interface RegionEntry {
24
+ readonly name: string;
25
+ readonly width: number;
26
+ readonly height: number;
27
+ }
28
+
29
+ /** What the skins hold, for A03 and A11. */
30
+ export interface SkinEntryFacts {
31
+ /** Every region attachment of every skin, in the file's walk order (the header's ⭐). */
32
+ readonly regionAttachments: readonly RegionEntry[];
33
+ /** How many clipping attachments the skins hold, every skin counted. */
34
+ readonly clippingCount: number;
35
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Slot colour timelines and the colour a slot poses at (issue #1025, step 4c
3
+ * of #380) — the census's F18 and R4: the animations and the slot timelines
4
+ * they key, and one slot's light colour posed by the runtime's rule.
5
+ *
6
+ * ⭐ **The file's order, and the file's spelling.** A45's first clause names
7
+ * the timeline "which the file states last" as the one that wins, so the order
8
+ * of a slot's timelines is a value and not a presentation; and its findings
9
+ * print a key's `time`, `color` and `value` as the file states them. So the
10
+ * list holds, for every animation in the order the file keys them and every
11
+ * slot in the order the animation keys them, that slot's timelines in the
12
+ * file's order, each key as the file spells it. `validate()` reads it off the
13
+ * skeleton JSON; the model side produces it from the document by calling the
14
+ * emitter's own order and its own parser-default and key-order passes
15
+ * (`../model/slot_colour.ts`), so a key the emitter would write without a field
16
+ * reads without it here too.
17
+ */
18
+
19
+ /** One animation's timelines on one slot: timeline name -> keys, as the file states them. */
20
+ export interface SlotTimelines {
21
+ readonly animation: string;
22
+ readonly slot: string;
23
+ readonly timelines: Readonly<Record<string, unknown>>;
24
+ }
25
+
26
+ /** A slot's light colour as posed: four channels, NaN where the pose read no number. */
27
+ export interface PosedSlotColour {
28
+ readonly color: { readonly r: number; readonly g: number; readonly b: number; readonly a: number };
29
+ }
30
+
31
+ /** What A45 reads. */
32
+ export interface SlotColourFacts {
33
+ /** Every (animation, slot) pair whose timelines the file keys, in the header's order. */
34
+ readonly slotTimelines: readonly SlotTimelines[];
35
+ /** Whether the skeleton holds an animation of this name. */
36
+ hasAnimation(name: string): boolean;
37
+ /**
38
+ * The slot's colour with the animation applied at `time` on a fresh,
39
+ * non-looping track from the setup pose, no skin set — or `undefined` for a
40
+ * slot the skeleton does not have.
41
+ */
42
+ posedSlot(animation: string, slot: string, time: number): PosedSlotColour | undefined;
43
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The harness an assertion body runs in, and the report it fills (issue
3
+ * #1025, step 4c of #380).
4
+ *
5
+ * One harness for both sides. `validate()` runs a body over the facts it reads
6
+ * off what spine-core loaded; the model side (`./model/index.ts`) runs the same
7
+ * body over the facts it reads off the model document and the core. What makes
8
+ * a line the same line on both sides is that the body is one function and the
9
+ * harness deciding PASS, SKIP, FAIL and PROF around it is one function too — a
10
+ * second copy of `check` would be a second place a verdict could be decided
11
+ * differently.
12
+ *
13
+ * Moved here from `validate()`, the rule unchanged: an assertion is recorded
14
+ * as passed only when its body neither failed nor skipped, a body that throws
15
+ * is a FAIL whose detail is `threw: <message>`, and a rule the profile does not
16
+ * carry is not run at all and is recorded as out of profile.
17
+ *
18
+ * This module links nothing from the runtime, directly or through what it
19
+ * imports — the condition for the model side to use it, held by the selftest.
20
+ */
21
+ import { kindRunsUnder, type AssertionKind, type AssertionProfile } from './kinds.ts';
22
+
23
+ /** One failure: the assertion's code and the sentence naming the object, the value found and the value required. */
24
+ export interface Failure {
25
+ assertion: string;
26
+ detail: string;
27
+ }
28
+
29
+ /** What a body is handed: how to fail, how to declare it had nothing to measure, and the stats line it may add to. */
30
+ export interface Verdicts {
31
+ fail: (assertion: string, detail: string) => void;
32
+ skip: (assertion: string, reason: string) => void;
33
+ stats: Record<string, number | string>;
34
+ }
35
+
36
+ /** The four lists and the stats a run of bodies leaves — the shape `validate()` returns, less the profile. */
37
+ export interface VerdictLists {
38
+ failures: Failure[];
39
+ /** Assertions that ran and passed, in order. */
40
+ passed: string[];
41
+ /** Assertions that had nothing to run against, with the reason. */
42
+ skipped: Array<{ assertion: string; reason: string }>;
43
+ /** Assertions the profile does not apply, with their kind. */
44
+ profileSkipped: Array<{ assertion: string; kind: 'renderer' | 'archetype' }>;
45
+ stats: Record<string, number | string>;
46
+ }
47
+
48
+ /** A harness: the lists it fills, the two calls a body makes, and `check`, which runs a body. */
49
+ export interface VerdictHarness extends VerdictLists {
50
+ fail: Verdicts['fail'];
51
+ skip: Verdicts['skip'];
52
+ /**
53
+ * Run one assertion; record it as passed only if it neither failed nor skipped,
54
+ * and do not run it at all when the profile does not carry that kind of rule.
55
+ *
56
+ * The unknown-assertion throw is deliberate: an assertion with no entry in
57
+ * the kind table would otherwise pick a profile by accident, and picking
58
+ * wrong means either a rule that never runs or a rule that fires on
59
+ * everybody's data.
60
+ *
61
+ * The body's return value is handed back — `undefined` when the assertion did
62
+ * not run or threw. `validate()`'s A00 uses it so that the loaded atlas and
63
+ * skeleton can be `const`: assigning them from inside this callback leaves
64
+ * the type checker unable to see that they were ever set, and every later read
65
+ * of `atlas.pages` or `data.bones` becomes a property of `never`.
66
+ */
67
+ check: <T>(assertion: string, body: () => T) => T | undefined;
68
+ /** `fail`, `skip` and `stats`, as one value to hand a body. */
69
+ verdicts: Verdicts;
70
+ }
71
+
72
+ /**
73
+ * A fresh harness for one run under `profile`. `kinds` is the kind table the
74
+ * run's codes are looked up in, and `who` names the run in the throw for a code
75
+ * the table lacks — `validate` for the gate, so its message is the one it was.
76
+ */
77
+ export function verdictHarness(profile: AssertionProfile, kinds: Readonly<Record<string, AssertionKind>>, who: string): VerdictHarness {
78
+ const failures: Failure[] = [];
79
+ const passed: string[] = [];
80
+ const skipped: VerdictLists['skipped'] = [];
81
+ const profileSkipped: VerdictLists['profileSkipped'] = [];
82
+ const stats: Record<string, number | string> = {};
83
+ const fail = (assertion: string, detail: string): void => {
84
+ failures.push({ assertion, detail });
85
+ };
86
+ /** Declare that an assertion had nothing to check, and why. */
87
+ const skip = (assertion: string, reason: string): void => {
88
+ skipped.push({ assertion, reason });
89
+ };
90
+ const check = <T>(assertion: string, body: () => T): T | undefined => {
91
+ const kind = kinds[assertion];
92
+ if (!kind) throw new Error(`${who}: assertion "${assertion}" has no ASSERTION_KIND entry`);
93
+ if (!kindRunsUnder(kind, profile)) {
94
+ profileSkipped.push({ assertion, kind: kind === 'renderer' ? 'renderer' : 'archetype' });
95
+ return undefined;
96
+ }
97
+ const before = failures.length;
98
+ const skippedBefore = skipped.length;
99
+ let result: T | undefined;
100
+ try {
101
+ result = body();
102
+ } catch (err) {
103
+ fail(assertion, `threw: ${(err as Error).message}`);
104
+ }
105
+ if (failures.length === before && skipped.length === skippedBefore) passed.push(assertion);
106
+ return result;
107
+ };
108
+ return { failures, passed, skipped, profileSkipped, stats, fail, skip, check, verdicts: { fail, skip, stats } };
109
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What kind of rule each assertion is, and which profile runs which kind
3
+ * (issue #1025, step 4c of #380).
4
+ *
5
+ * The table lives here rather than in `src/validate.ts` because two harnesses
6
+ * read it now: `validate()`'s `check`, which runs an assertion over what
7
+ * spine-core loaded, and the model side's (`./harness.ts`), which runs the
8
+ * same body over the model document and the core. A profile has to exclude
9
+ * the same rules on both sides, and two copies of the table is how one of
10
+ * them would come to exclude a rule the other runs. This module links nothing
11
+ * from the runtime, which is the condition for the model side to read it.
12
+ */
13
+
14
+ /** The two rulebooks — `ValidateProfile` in `src/validate.ts`, where the choice between them is argued. */
15
+ export type AssertionProfile = 'spine' | 'spine-html';
16
+
17
+ /** The three kinds a rule can be; the table's own comment says what each means. */
18
+ export type AssertionKind = 'validity' | 'renderer' | 'archetype';
19
+
20
+ /**
21
+ * What kind of rule each assertion is. Every assertion has an entry, and
22
+ * `check()` throws on a name that has none — a new assertion must state its kind
23
+ * rather than defaulting into one, because the default would decide, silently,
24
+ * whether it runs on foreign data.
25
+ *
26
+ * validity — the file is wrong for any consumer. Runs under every profile.
27
+ * renderer — valid Spine that this project's renderer or frame budget refuses.
28
+ * archetype — a structural rule about rigc's own formations, meaningless to a
29
+ * skeleton rigc did not compile.
30
+ *
31
+ * Two assertions are MIXED and are marked `validity` here because their
32
+ * validity half must never stop running; their policy clauses are gated inside
33
+ * the assertion body against `profile`, and each such clause says so where it
34
+ * lives. They are A06 (size-vs-PNG and every region's rectangle inside the page
35
+ * it names are validity; pma / rotation / two regions over the same texels are
36
+ * policy — the rectangle moved across that line in issue #694, and the reason
37
+ * is stated where it now sits) and A20 (weight coherence is validity; requiring
38
+ * a mesh to be weighted at all is policy).
39
+ *
40
+ * A08 was the third until issue #574 retired its policy clause. It required a
41
+ * skin entry's placeholder to be spelled like the region it resolves to, under
42
+ * a renderer that resolves art by `path` and has never read a placeholder — so
43
+ * restating it as the renderer's own join made it a tautology over the validity
44
+ * half. The argument, with the renderer lines it is measured against, sits above
45
+ * `check('A08_…')`.
46
+ */
47
+ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
48
+ A00_ROUNDTRIP_PARSE: 'validity',
49
+ A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS: 'validity',
50
+ A02_NO_BONE_TRANSFORM_KEY: 'validity',
51
+ A03_REGION_WIDTH_HEIGHT_FINITE: 'validity',
52
+ A04_MESH_TRIANGLES_AND_ENCODING: 'validity',
53
+ A05_CURVE_ARRAY_LENGTH: 'validity',
54
+ A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
55
+ A07_ATLAS_TEXT_SHAPE: 'validity',
56
+ A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
57
+ A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
58
+ A10_NO_NAN_AFTER_STEPPING: 'validity',
59
+ A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
60
+ A12_NO_DARK_COLOR: 'renderer',
61
+ A13_MESH_BUDGET: 'renderer',
62
+ A14_NO_FULL_FRAME_MESH: 'renderer',
63
+ A15_IDLE_NO_MESH_BONE_KEYS: 'renderer',
64
+ A16_SKELETON_VERSION_4_3: 'validity',
65
+ A17_ATLAS_PAGE_FILES_EXIST: 'validity',
66
+ A18_DETERMINISTIC_EMIT: 'validity',
67
+ A19_OVERLAY_PNGS_HAVE_ALPHA: 'renderer',
68
+ A20_MESH_WEIGHTS_COHERENT: 'validity', // mixed — see above
69
+ A21_MESH_RIM_PINNED: 'archetype',
70
+ A22_MESH_UVS_IN_UNIT_RANGE: 'validity',
71
+ A23_PHYSICS_CONSTRAINT_EFFECTIVE: 'validity',
72
+ A24_AXIS_SPACE_STROKE: 'archetype',
73
+ A25_DETACHED_BONE_PARENTAGE: 'archetype',
74
+ A26_SLOT_DRAW_ORDER: 'archetype',
75
+ A27_REGION_NAME_MATCHES_PAGE_FILENAME: 'renderer',
76
+ A28_RIBBON_ROWS_SHARE_WEIGHTS: 'archetype',
77
+ A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
78
+ A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
79
+ A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
80
+ A32_EVENT_KEYS_RESOLVE: 'validity',
81
+ A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
82
+ A34_CONSTRAINT_TIMELINE_TARGETS: 'validity',
83
+ A35_DEFORM_KEYS_FIT_THE_ATTACHMENT: 'validity',
84
+ A36_PATH_CONSTRAINT_EFFECTIVE: 'validity',
85
+ A37_SLIDER_CONSTRAINT_EFFECTIVE: 'validity',
86
+ A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED: 'validity',
87
+ A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
88
+ A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
89
+ A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
90
+ A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
91
+ A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
92
+ A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
93
+ A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
94
+ A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
95
+ A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
96
+ A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
97
+ };
98
+
99
+ /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `spine-html` every kind. */
100
+ export function kindRunsUnder(kind: AssertionKind, profile: AssertionProfile): boolean {
101
+ return kind === 'validity' || profile === 'spine-html';
102
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The model side's supply of `AtlasPageFacts` (issue #1025, step 4c of #380):
3
+ * the document's `pages` section, which `build` spells from the atlas text it
4
+ * writes (`pagesOfAtlas` in `src/model.ts`), so the page names are the ones
5
+ * the written atlas names, in its order and trimmed as the parser trims them.
6
+ * A `rigc-compiled/1` document states no pages, and reads as no atlas.
7
+ *
8
+ * Links nothing from the runtime.
9
+ */
10
+ import type { AtlasPageFacts } from '../facts/atlas_pages.ts';
11
+ import type { ReadDocument } from './parse.ts';
12
+
13
+ export function modelAtlasPages(read: ReadDocument): AtlasPageFacts {
14
+ return { atlas: read.doc.pages === null ? null : { pages: read.doc.pages.map((page) => ({ name: page.name })) } };
15
+ }