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.
- package/cli.ts +166 -60
- package/docs/AUTHORING.md +25 -16
- package/package.json +1 -1
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/skin_entries.ts +35 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/kinds.ts +102 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/index.ts +130 -0
- package/src/assertions/model/parse.ts +136 -0
- package/src/assertions/model/skin_entries.ts +58 -0
- package/src/assertions/model/slot_colour.ts +80 -0
- package/src/assertions/reasons.ts +127 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +26 -16
- package/src/check.ts +70 -47
- package/src/compile.ts +118 -93
- package/src/core/constraints_path.ts +60 -38
- package/src/core/constraints_physics.ts +22 -4
- package/src/core/index.ts +102 -15
- package/src/core/uvs.ts +25 -5
- package/src/core/world.ts +80 -24
- package/src/deformmeasure.ts +383 -157
- package/src/deformreport.ts +3 -0
- package/src/deformstructure.ts +335 -0
- package/src/emit.ts +17 -6
- package/src/ingest.ts +18 -13
- package/src/keyorder.ts +2 -2
- package/src/model.ts +114 -13
- package/src/render.ts +520 -59
- package/src/render_core.ts +178 -21
- package/src/transform.ts +113 -128
- package/src/types.ts +6 -5
- 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
|
+
}
|