spine-rigc 1.7.0 → 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.
@@ -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
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The model side of the validator (issue #1025, step 4c of #380): the
3
+ * assertions that moved out of `src/validate.ts`, run over the model document
4
+ * and rigc's core instead of over what spine-core loaded.
5
+ *
6
+ * ⭐ **One body, two suppliers.** Every moved assertion is one function under
7
+ * `../bodies/`, written against a fact interface under `../facts/`.
8
+ * `validate()` supplies the facts from spine-core's loaded objects and calls
9
+ * the body inside its own `check`; this entry supplies them from the document
10
+ * (`./parse.ts`, `./skin_entries.ts`, `./atlas_pages.ts`, `./slot_colour.ts`)
11
+ * and calls the same body inside the same harness (`../harness.ts`). The
12
+ * selftest holds the two to the same lines on every call it makes with a
13
+ * model in hand, and `tools/verdict_gate.ts` on every recipe.
14
+ *
15
+ * 🔸 **What the entry is given.** The document's text, the directory its
16
+ * pages resolve against, and the profile — and nothing read off a Spine file:
17
+ * a model-side verdict that read `skeleton.json` would be a second reading of
18
+ * the encoding, which is the round trip's subject and not the rig's. The stage
19
+ * and the atlas's `pma`, which the document does not hold, are the caller's to
20
+ * give once an assertion that reads them moves (a later cut, the census's §2).
21
+ *
22
+ * ⛔ **Not wired into any command.** `build` keeps the round trip and its
23
+ * forty-nine lines; this entry is what the selftest and the instrument call
24
+ * until every moved assertion is on it (the card's design, point 7).
25
+ *
26
+ * Links nothing from the runtime, directly or through what it imports — held
27
+ * by the selftest's `VF` controls.
28
+ */
29
+ import { ASSERTION_KIND, type AssertionProfile } from '../kinds.ts';
30
+ import { verdictHarness, type VerdictHarness, type Verdicts, type VerdictLists } from '../harness.ts';
31
+ import { SKIP_NO_ATLAS, SKIP_NO_MODEL } from '../reasons.ts';
32
+ import { a03RegionWidthHeightFinite } from '../bodies/a03.ts';
33
+ import { a11NoClippingAttachments } from '../bodies/a11.ts';
34
+ import { a17AtlasPageFilesExist } from '../bodies/a17.ts';
35
+ import { a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten } from '../bodies/a45.ts';
36
+ import { A00_MODEL_READ, A00_MODEL_REGIONS_ON_PAGES, MODEL_PARSE_KIND, modelRead, modelRegionsOnPages, SKIP_NO_MODEL_DOCUMENT, type ReadDocument } from './parse.ts';
37
+ import { modelSkinEntries } from './skin_entries.ts';
38
+ import { modelAtlasPages } from './atlas_pages.ts';
39
+ import { modelSlotColour } from './slot_colour.ts';
40
+ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
41
+ import type { AtlasPageFacts } from '../facts/atlas_pages.ts';
42
+ import type { SlotColourFacts } from '../facts/slot_colour.ts';
43
+
44
+ /** What the model side is given. */
45
+ export interface ModelValidateInput {
46
+ /** The document `build` writes beside the Spine pair (`skeleton.model.json`). */
47
+ modelText: string;
48
+ /** The directory the document's page names resolve against — the build's `--out`. */
49
+ atlasDir: string;
50
+ profile: AssertionProfile;
51
+ }
52
+
53
+ /** The model side's report: `validate()`'s shape, over the moved assertions and the two parse rules. */
54
+ export interface ModelReport extends VerdictLists {
55
+ profile: AssertionProfile;
56
+ }
57
+
58
+ /**
59
+ * The fact families, composed: one supplier per family under `./`, each a
60
+ * function of the read document. A body names the family it reads, so a later
61
+ * cut that moves an assertion over an existing family adds a body file and a
62
+ * row below, and one over a new family adds a facts file, a supplier file and
63
+ * a field here.
64
+ */
65
+ export interface ModelSupply {
66
+ skinEntries: (read: ReadDocument) => SkinEntryFacts;
67
+ atlasPages: (read: ReadDocument) => AtlasPageFacts;
68
+ slotColour: (read: ReadDocument) => SlotColourFacts;
69
+ }
70
+
71
+ /** The suppliers the model side runs on. */
72
+ export const MODEL_SUPPLY: ModelSupply = { skinEntries: modelSkinEntries, atlasPages: modelAtlasPages, slotColour: modelSlotColour };
73
+
74
+ /**
75
+ * One moved assertion: its code, and how the model side runs its body once the
76
+ * document is read. The list is the registry of what has moved — each later
77
+ * cut appends its rows — and the selftest reads it for the codes it compares.
78
+ */
79
+ export interface MovedAssertion {
80
+ code: string;
81
+ /** The body, over the document. Not called when the model side read no document. */
82
+ run: (verdicts: Verdicts, read: ReadDocument, input: ModelValidateInput, supply: ModelSupply) => void;
83
+ /** What the body SKIPs with when there is no document — the model side's counterpart of the reason `validate()` gives when the round trip produced nothing. */
84
+ unread: string;
85
+ }
86
+
87
+ /**
88
+ * The moved assertions, in `validate()`'s report order. `SKIP_NO_ATLAS` for
89
+ * A17 rather than `SKIP_NO_MODEL` because that is what A17 says over a missing
90
+ * atlas on either side: its body reads only the pages, and on this side no
91
+ * document means no pages.
92
+ */
93
+ export const MOVED_ASSERTIONS: readonly MovedAssertion[] = [
94
+ { code: 'A03_REGION_WIDTH_HEIGHT_FINITE', run: (v, read, _input, supply) => a03RegionWidthHeightFinite(v, supply.skinEntries(read)), unread: SKIP_NO_MODEL },
95
+ { code: 'A11_NO_CLIPPING_ATTACHMENTS', run: (v, read, _input, supply) => a11NoClippingAttachments(v, supply.skinEntries(read)), unread: SKIP_NO_MODEL },
96
+ { code: 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', run: (v, read, _input, supply) => a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten(v, supply.slotColour(read)), unread: SKIP_NO_MODEL },
97
+ { code: 'A17_ATLAS_PAGE_FILES_EXIST', run: (v, read, input, supply) => a17AtlasPageFilesExist(v, supply.atlasPages(read), input), unread: SKIP_NO_ATLAS },
98
+ ];
99
+
100
+ /** The codes the model side prints: its two parse rules, then the moved assertions. */
101
+ export const MODEL_SIDE_CODES: readonly string[] = [A00_MODEL_READ, A00_MODEL_REGIONS_ON_PAGES, ...MOVED_ASSERTIONS.map((m) => m.code)];
102
+
103
+ /** The parse: the reader, then the region rule. Returns the document when both held. */
104
+ function parse(h: VerdictHarness, modelText: string): ReadDocument | null {
105
+ const read = h.check(A00_MODEL_READ, () => modelRead(h.verdicts, modelText)) ?? null;
106
+ if (read === null) {
107
+ h.check(A00_MODEL_REGIONS_ON_PAGES, () => h.skip(A00_MODEL_REGIONS_ON_PAGES, SKIP_NO_MODEL_DOCUMENT));
108
+ return null;
109
+ }
110
+ return h.check(A00_MODEL_REGIONS_ON_PAGES, () => modelRegionsOnPages(h.verdicts, read)) === true ? read : null;
111
+ }
112
+
113
+ /**
114
+ * Run the model side: the parse, then every moved assertion — each over the
115
+ * document, or skipped naming the parse when there is none — and return the
116
+ * report in `validate()`'s shape, which `reportLines` in `src/validate.ts`
117
+ * prints. `plant` replaces a family's supplier; only the selftest's plants
118
+ * pass one.
119
+ */
120
+ export function validateModel(input: ModelValidateInput, plant: Partial<ModelSupply> = {}): ModelReport {
121
+ const supply: ModelSupply = { ...MODEL_SUPPLY, ...plant };
122
+ const h = verdictHarness(input.profile, { ...ASSERTION_KIND, ...MODEL_PARSE_KIND }, 'the model side');
123
+ const read = parse(h, input.modelText);
124
+ for (const moved of MOVED_ASSERTIONS) {
125
+ h.check(moved.code, () => (read === null ? h.skip(moved.code, moved.unread) : moved.run(h.verdicts, read, input, supply)));
126
+ }
127
+ const { failures, passed, skipped, profileSkipped, stats } = h;
128
+ return { failures, passed, skipped, profileSkipped, stats, profile: input.profile };
129
+ }
130
+
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The model side's parse (issue #1025, step 4c of #380): what stands where
3
+ * `A00_ROUNDTRIP_PARSE` stands when there is no spine-core object to load.
4
+ *
5
+ * Two rules, each a named line:
6
+ *
7
+ * - **`A00_MODEL_READ`** — `readModel` (`src/core/index.ts`) read the document.
8
+ * Its refusals are the parse: every one names the path of what it refused,
9
+ * and the line carries the reader's own sentence. A document it refuses is
10
+ * one whose wrongness has no readable model to live in — a size that is
11
+ * absent, a colour that is not hex, key times that do not increase (the
12
+ * census found four of the slice's mutants there) — and that refusal is the
13
+ * model side's verdict on it.
14
+ * - **`A00_MODEL_REGIONS_ON_PAGES`** — every region a record resolves through
15
+ * is a region of the document's `pages`, and no region record states an
16
+ * `atlas` of `null`. Measured by the census before this rule existed: 23
17
+ * cases fail `A00_ROUNDTRIP_PARSE` in a selftest run, and 14 of them are
18
+ * documents `readModel` accepts — a record's path naming no region (five
19
+ * forged), and nine builds whose model holds `atlas: null` because the build
20
+ * had no art or its pack lacked the region. spine-core refuses all fourteen
21
+ * at load (`Region not found in atlas`), and the core refused them only when
22
+ * it posed. This rule refuses them where the parse does, by the record's
23
+ * address and the region's name.
24
+ *
25
+ * The region a record resolves through is the core's rule, called rather than
26
+ * restated: its name and path are `shownRow`'s (path, else name, else the
27
+ * placeholder), a series' frame names are `frameRegionName` over the series
28
+ * `readUvSequences` reads, and a name is looked up as `documentPageLookup`
29
+ * looks it up — the first region of that exact spelling, pages in order.
30
+ *
31
+ * ⛔ Rejected: the codes `M00_…` (`M` plus two digits names a mutant row in the
32
+ * selftest — `M03`, `M12` — and a reader of a report could not tell a rule
33
+ * from a plant), and a new assertion number (`A50`): the gate's registry would
34
+ * then demand the row of every `validate()` report, and these two rules have no
35
+ * meaning there. `A00_` because they stand in A00's place, and `_MODEL_`
36
+ * because they are the model side's.
37
+ *
38
+ * Links nothing from the runtime.
39
+ */
40
+ import { CORE_REGION_KINDS, CoreInputError, readModel, shownRow, type CompiledDocument } from '../../core/index.ts';
41
+ import { documentPageLookup, frameRegionName, readUvSequences } from '../../core/uvs.ts';
42
+ import type { AssertionKind } from '../kinds.ts';
43
+ import type { Verdicts } from '../harness.ts';
44
+
45
+ /** The reader's refusal, as a line. */
46
+ export const A00_MODEL_READ = 'A00_MODEL_READ';
47
+ /** The region rule, as a line. */
48
+ export const A00_MODEL_REGIONS_ON_PAGES = 'A00_MODEL_REGIONS_ON_PAGES';
49
+
50
+ /** The two rules' kinds: both are validity — a document either is one a build could pose, or it is not. */
51
+ export const MODEL_PARSE_KIND: Readonly<Record<string, AssertionKind>> = {
52
+ [A00_MODEL_READ]: 'validity',
53
+ [A00_MODEL_REGIONS_ON_PAGES]: 'validity',
54
+ };
55
+
56
+ /** What the region rule SKIPs with when the reader refused the document. */
57
+ export const SKIP_NO_MODEL_DOCUMENT = `the reader refused the document, so there is no record to resolve (${A00_MODEL_READ} owns that failure)`;
58
+ /** What the region rule SKIPs with when no record resolves through a region. */
59
+ export const SKIP_NO_REGION_RECORD = 'no record of the document resolves through an atlas region';
60
+
61
+ /** The document as both readings hold it: `readModel`'s, and the JSON the suppliers read spellings from. */
62
+ export interface ReadDocument {
63
+ doc: CompiledDocument;
64
+ json: Record<string, unknown>;
65
+ }
66
+
67
+ /** `A00_MODEL_READ`'s body: the document read, or the reader's refusal as a FAIL. Returns the read document or null. */
68
+ export function modelRead({ fail }: Verdicts, modelText: string): ReadDocument | null {
69
+ let doc: CompiledDocument;
70
+ try {
71
+ doc = readModel(modelText);
72
+ } catch (err) {
73
+ if (!(err instanceof CoreInputError)) throw err;
74
+ fail(A00_MODEL_READ, err.message);
75
+ return null;
76
+ }
77
+ return { doc, json: JSON.parse(modelText) as Record<string, unknown> };
78
+ }
79
+
80
+ /**
81
+ * Every region name each record resolves through, by the record's address —
82
+ * the census's rule R-region, read with the core's own lookups. A record of a
83
+ * kind that draws no region (clipping, path, bounding box) resolves through
84
+ * none.
85
+ */
86
+ export function regionNamesOf(read: ReadDocument): Array<{ where: string; names: string[]; atlasNull: boolean }> {
87
+ const sequences = readUvSequences(read.json);
88
+ const out: Array<{ where: string; names: string[]; atlasNull: boolean }> = [];
89
+ for (const skin of read.doc.skins) {
90
+ for (const [slot, table] of Object.entries(skin.attachments)) {
91
+ for (const [placeholder, record] of Object.entries(table)) {
92
+ if (!CORE_REGION_KINDS.has(record.kind)) continue;
93
+ const path = shownRow({ skin: skin.name, placeholder, record }).path as string;
94
+ const series = sequences.get(`${skin.name}/${slot}/${placeholder}`);
95
+ const names = series === undefined ? [path] : Array.from({ length: series.count }, (_, i) => frameRegionName(path, series, i));
96
+ out.push({ where: `skin "${skin.name}" slot "${slot}" placeholder "${placeholder}"`, names, atlasNull: record.kind === 'region' && record.atlas === null });
97
+ }
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** `A00_MODEL_REGIONS_ON_PAGES`'s body. Returns whether the document passed it. */
104
+ export function modelRegionsOnPages({ fail, skip }: Verdicts, read: ReadDocument): boolean {
105
+ let records: ReturnType<typeof regionNamesOf>;
106
+ try {
107
+ records = regionNamesOf(read);
108
+ } catch (err) {
109
+ // A series `readModel` keeps only the count of: `readUvSequences` refuses its `start` or `digits` by path.
110
+ if (!(err instanceof CoreInputError)) throw err;
111
+ fail(A00_MODEL_REGIONS_ON_PAGES, err.message);
112
+ return false;
113
+ }
114
+ if (records.length === 0) {
115
+ skip(A00_MODEL_REGIONS_ON_PAGES, SKIP_NO_REGION_RECORD);
116
+ return true;
117
+ }
118
+ if (read.doc.pages === null) {
119
+ fail(A00_MODEL_REGIONS_ON_PAGES, `the document is ${read.doc.spec}, which states no pages, so none of the ${records.length} record(s) that resolve through a region can be found on one`);
120
+ return false;
121
+ }
122
+ const lookup = documentPageLookup(read.doc.pages);
123
+ let held = true;
124
+ for (const { where, names, atlasNull } of records) {
125
+ if (atlasNull) {
126
+ fail(A00_MODEL_REGIONS_ON_PAGES, `${where}: the region record's atlas is null — the build had no rectangle for "${names[0]}", and a runtime loading it finds no region`);
127
+ held = false;
128
+ }
129
+ for (const name of names) {
130
+ if (lookup(name) !== null) continue;
131
+ fail(A00_MODEL_REGIONS_ON_PAGES, `${where}: resolves through the region ${JSON.stringify(name)}, which no page of the document holds`);
132
+ held = false;
133
+ }
134
+ }
135
+ return held;
136
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The model side's supply of `SkinEntryFacts` (issue #1025, step 4c of #380):
3
+ * the skins' region attachments and clipping count, read off the document.
4
+ *
5
+ * ⭐ **In the file's order, by the emitter's own rule.** The document holds the
6
+ * skins in the spec's order and the Spine file holds them in the editor's
7
+ * (`default` first, the rest by the editor's comparator), so a walk of the
8
+ * document in its own order lists the same entries in another order on a rig
9
+ * whose skins the comparator moves — the census measured that on 3 of 52
10
+ * multi-skin compiles. The skins are therefore put in the file's order by
11
+ * calling `editorSkinOrder` from `src/compile.ts`, the function the emitter is
12
+ * handed (`emitSkins`), not by a copy of its rule. Within a skin the entries go
13
+ * slot by slot in the skeleton's slot order — the document's `slots`, which is
14
+ * the emitted array's order — and a slot's entries in its table's order, which
15
+ * the emitter writes unchanged; the selftest measures this walk equal to
16
+ * spine-core's on every multi-skin build it makes.
17
+ *
18
+ * A region's name is the record's `name`, else its placeholder — `shownRow`'s
19
+ * rule in the core — and its size the record's `width` and `height`, the
20
+ * numbers the file states (`readModel` refuses a size that is not a finite
21
+ * number, so a size the runtime would load as NaN has no document to arrive in).
22
+ *
23
+ * Links nothing from the runtime.
24
+ */
25
+ import { editorSkinOrder } from '../../compile.ts';
26
+ import { shownRow } from '../../core/index.ts';
27
+ import type { RegionEntry, SkinEntryFacts } from '../facts/skin_entries.ts';
28
+ import type { ReadDocument } from './parse.ts';
29
+
30
+ /** How the skins are put in the file's order: the emitter's rule. A parameter only so the selftest can show what the document's own order would print (`VF05`). */
31
+ export type SkinOrder = <T extends { name: string }>(skins: readonly T[]) => T[];
32
+
33
+ /** The skins' entries in the file's walk order, each with its skin, slot and placeholder. */
34
+ export function fileOrderedEntries(read: ReadDocument, order: SkinOrder = editorSkinOrder): Array<{ skin: string; slot: string; placeholder: string; record: ReadDocument['doc']['skins'][number]['attachments'][string][string] }> {
35
+ const out: ReturnType<typeof fileOrderedEntries> = [];
36
+ for (const skin of order(read.doc.skins)) {
37
+ for (const { name: slot } of read.doc.slots) {
38
+ const table = skin.attachments[slot];
39
+ if (table === undefined) continue;
40
+ for (const [placeholder, record] of Object.entries(table)) out.push({ skin: skin.name, slot, placeholder, record });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ export function modelSkinEntries(read: ReadDocument, order: SkinOrder = editorSkinOrder): SkinEntryFacts {
47
+ const regionAttachments: RegionEntry[] = [];
48
+ let clippingCount = 0;
49
+ for (const entry of fileOrderedEntries(read, order)) {
50
+ if (entry.record.kind === 'clipping') clippingCount++;
51
+ if (entry.record.kind !== 'region') continue;
52
+ const geometry = entry.record.geometry;
53
+ // `readModel` reads every region record's geometry or refuses the document, so a record without one is this module's defect.
54
+ if (geometry?.kind !== 'region') throw new Error(`internal: skin "${entry.skin}" slot "${entry.slot}" placeholder "${entry.placeholder}" is a region record the reader returned without its geometry`);
55
+ regionAttachments.push({ name: shownRow(entry).name, width: geometry.region.width, height: geometry.region.height });
56
+ }
57
+ return { regionAttachments, clippingCount };
58
+ }