@rig-c/parts 0.0.0-stage → 1.5.4

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,1240 @@
1
+ /**
2
+ * What a scene requires of a rig's motion, declared in a file `check` is given
3
+ * (`--requirements`, issue #93, increment 2 of #87) and measured from the
4
+ * world transforms rig-c's `render --geometry` writes.
5
+ *
6
+ * ⭐ **The definitions are #87's, settled before this was written** (its
7
+ * comment "Settled before implementation"); nothing here chooses a bar, a
8
+ * default or a solver. Every bar is the author's, a missing one is refused by
9
+ * name, and every pose is rig-c's.
10
+ *
11
+ * Four outcomes, never merged:
12
+ *
13
+ * | outcome | when | what it does |
14
+ * | --- | --- | --- |
15
+ * | not declared | the file states no requirement of that kind | the summary names the kind; not a pass, not a skip |
16
+ * | refused | a bone, constraint, mesh or animation does not resolve, a field or bar is missing, a `follow` names a property its constraint does not drive | refused by name before anything is built ({@link readRequirements}, {@link resolveRequirements}) |
17
+ * | not measurable | it reads and the rig builds, and the quantity is undefined in the frames | `NOT MEASURABLE — <why>`, with the figures; the run does not PASS |
18
+ * | measured | otherwise | `PASS` or `FAIL` against the author's bar, with the figures and the worst frame |
19
+ *
20
+ * The five kinds and what each measures, per sampled frame of the named
21
+ * animation at the file's `fps` (Spine world units are stage px: the stage
22
+ * box maps to the world by a translation and the y flip alone, `src/coords.ts`):
23
+ *
24
+ * - `contact` — the distance between a bone's tip or origin and a target (a
25
+ * bone's tip or origin, or a stage point); the largest, against `within_px`.
26
+ * - `follow` — #87's three-pose measurement ({@link followLine}).
27
+ * - `aim` — the angle between a bone's axis and the line from its origin to
28
+ * a target; the largest, against `within_degrees`. The distance is not judged.
29
+ * - `range` — the bone's world rotation less its parent's (the world's, for the
30
+ * root), less the same at the setup pose, as a signed shortest angle; the
31
+ * least and the greatest, against `[lo_degrees, hi_degrees]`.
32
+ * - `stretch` — `TEXTURE_STRETCH`'s own per-mesh measure ({@link stretchFigures},
33
+ * imported, not rewritten): the mesh's worst max(ratio, 1/ratio) over the
34
+ * frames, against `within_ratio`.
35
+ * - `seam` (issue #111) — the seam between two parts' art: the pairs of
36
+ * 4-adjacent stage pixels where an art pixel of `part` meets an art pixel of
37
+ * `neighbour` that is not `part`'s ({@link seamPairs}, read off `parts.json`'s
38
+ * art at the setup pose), and per frame each pair's opening, the distance
39
+ * between the two sides' displacements from rest, each read off its
40
+ * attachment's rest and posed geometry ({@link seamLine}); the largest, its
41
+ * pair and its frame, against `within_px`.
42
+ *
43
+ * This module is pure: it reads the file, resolves it against the rig spec and
44
+ * motion it is handed, writes the throwaway copies' specs as values, and
45
+ * measures tracks it is handed. `src/check.ts` builds and renders the copies
46
+ * through rig-c and reads their `geometry.json`.
47
+ */
48
+ import { existsSync, readFileSync, statSync } from 'node:fs';
49
+ import { resolve } from 'node:path';
50
+ import { CompileError } from 'rig-c/src/errors.ts';
51
+ import { splitRigSkin, type RigSkin } from 'rig-c/src/rig.ts';
52
+ import { cropToSpineY } from './coords.ts';
53
+ import type { Problem } from './errors.ts';
54
+ import { refuseIfAny } from './errors.ts';
55
+ import { type BoneWorld, type GeometryPose, type MeshRest, stretchFigures, stretchSeverity } from './instruments.ts';
56
+ import { ART_ALPHA } from './mesh.ts';
57
+ import { alphaAbove, type Raster } from './raster/types.ts';
58
+
59
+ /** The file's `spec`. */
60
+ export const REQUIREMENTS_SPEC = 'spine-parts-requirements/1';
61
+
62
+ /** The five kinds, in the order the summary names them. */
63
+ export const REQUIREMENT_KINDS = ['contact', 'follow', 'aim', 'range', 'stretch', 'seam'] as const;
64
+ export type RequirementKind = (typeof REQUIREMENT_KINDS)[number];
65
+
66
+ /** A line's status: the three outcomes a declared requirement can have once it is read. */
67
+ export type RequirementStatus = 'PASS' | 'FAIL' | 'NOT MEASURABLE';
68
+
69
+ /** Which point of a bone: its origin, or its tip (the origin plus `length` along the bone's x axis). */
70
+ export type BonePoint = 'origin' | 'tip';
71
+
72
+ /** What a contact or an aim is held against: a point of a bone, or a fixed stage point (stage px, y down). */
73
+ export type TargetRef = { bone: string; point: BonePoint } | { stage: [number, number] };
74
+
75
+ interface RequirementCommon {
76
+ name: string;
77
+ animation: string;
78
+ }
79
+
80
+ export interface ContactRequirement extends RequirementCommon {
81
+ kind: 'contact';
82
+ bone: string;
83
+ point: BonePoint;
84
+ target: TargetRef;
85
+ within_px: number;
86
+ }
87
+
88
+ export interface FollowRequirement extends RequirementCommon {
89
+ kind: 'follow';
90
+ constraint: string;
91
+ constraint_type: 'ik' | 'transform';
92
+ bone: string;
93
+ property: 'rotate' | 'translate';
94
+ fraction: number;
95
+ tolerance: number;
96
+ /** In the property's unit: degrees for `rotate`, stage px for `translate`. */
97
+ least_drive: number;
98
+ }
99
+
100
+ export interface AimRequirement extends RequirementCommon {
101
+ kind: 'aim';
102
+ bone: string;
103
+ target: TargetRef;
104
+ within_degrees: number;
105
+ }
106
+
107
+ export interface RangeRequirement extends RequirementCommon {
108
+ kind: 'range';
109
+ bone: string;
110
+ lo_degrees: number;
111
+ hi_degrees: number;
112
+ }
113
+
114
+ export interface StretchRequirement extends RequirementCommon {
115
+ kind: 'stretch';
116
+ slot: string;
117
+ attachment: string;
118
+ within_ratio: number;
119
+ }
120
+
121
+ /** Issue #111: the seam between two parts' art stays within `within_px` over the animation ({@link seamLine}). `part` and `neighbour` are part names, which are their slots' names. */
122
+ export interface SeamRequirement extends RequirementCommon {
123
+ kind: 'seam';
124
+ part: string;
125
+ neighbour: string;
126
+ within_px: number;
127
+ }
128
+
129
+ export type Requirement = ContactRequirement | FollowRequirement | AimRequirement | RangeRequirement | StretchRequirement | SeamRequirement;
130
+
131
+ /** One stage point of a scene target, at a time in seconds (null for a fixed point). */
132
+ export interface TargetPoint {
133
+ t: number | null;
134
+ x: number;
135
+ y: number;
136
+ }
137
+
138
+ /** A scene target: a root-parented bone placed at a stage point, or at stage points at stated times, while one animation is measured. */
139
+ export interface SceneTarget {
140
+ bone: string;
141
+ animation: string;
142
+ points: TargetPoint[];
143
+ }
144
+
145
+ export interface RequirementsFile {
146
+ path: string;
147
+ fps: number;
148
+ targets: SceneTarget[];
149
+ requirements: Requirement[];
150
+ }
151
+
152
+ /** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
153
+ export const REQUIREMENTS_SENTENCE = `--requirements names a ${REQUIREMENTS_SPEC} file: the scene's declared requirements of the rig's motion (contact, follow, aim, range, stretch, seam), each with its animation and the author's own bar, measured from rig-c's render --geometry at the file's fps`;
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // reading the file
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function isRecord(v: unknown): v is Record<string, unknown> {
160
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
161
+ }
162
+
163
+ function finite(v: unknown): v is number {
164
+ return typeof v === 'number' && Number.isFinite(v);
165
+ }
166
+
167
+ /** An annotation (`note`, `*_note`, a string) or a project's record (`x-…`, any value): read by nothing, as in the config. */
168
+ function freeKey(key: string, value: unknown): boolean {
169
+ if (key.length > 2 && key.startsWith('x-')) return true;
170
+ return (key === 'note' || key.endsWith('_note')) && typeof value === 'string';
171
+ }
172
+
173
+ /** The fields each kind declares, in the order they are read; every one is required. */
174
+ const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
175
+ contact: ['bone', 'point', 'target', 'within_px'],
176
+ follow: ['constraint', 'constraint_type', 'bone', 'property', 'fraction', 'tolerance', 'least_drive'],
177
+ aim: ['bone', 'target', 'within_degrees'],
178
+ range: ['bone', 'lo_degrees', 'hi_degrees'],
179
+ stretch: ['slot', 'attachment', 'within_ratio'],
180
+ seam: ['part', 'neighbour', 'within_px'],
181
+ };
182
+
183
+ const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
184
+
185
+ /**
186
+ * Read and shape-check a requirements file. Every problem is collected before
187
+ * one refusal: the file, `spec`, `fps` (required, a positive number: no
188
+ * default), `targets` (optional) and `requirements` (required, possibly empty),
189
+ * each entry's every field and bar by name, a key the format does not read,
190
+ * and a name used twice. Resolving the names against the rig is
191
+ * {@link resolveRequirements}'s.
192
+ */
193
+ export function readRequirements(path: string): RequirementsFile {
194
+ const at = resolve(path);
195
+ const problems: Problem[] = [];
196
+ const fileProblem = (detail: string): never => {
197
+ refuseIfAny([{ code: 'REQUIREMENTS_FILE', object: at, detail }]);
198
+ throw new Error('unreachable');
199
+ };
200
+ if (!existsSync(at) || !statSync(at).isFile()) return fileProblem(`no such file; ${REQUIREMENTS_SENTENCE}`);
201
+ let raw: unknown;
202
+ try {
203
+ raw = JSON.parse(readFileSync(at, 'utf8'));
204
+ } catch (err) {
205
+ return fileProblem(`does not parse as JSON: ${(err as Error).message}`);
206
+ }
207
+ if (!isRecord(raw)) return fileProblem(`holds ${JSON.stringify(raw)?.slice(0, 40)}; an object is required`);
208
+ const field = (object: string, detail: string): void => {
209
+ problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
210
+ };
211
+ if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
212
+ for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements; note or *_note as a string; x-<name> as a record)');
213
+ let fps = NaN;
214
+ if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
215
+ else fps = raw.fps;
216
+
217
+ const requirements: Requirement[] = [];
218
+ if (!Array.isArray(raw.requirements)) field(`${at} field "requirements"`, `is ${JSON.stringify(raw.requirements)}; an array of requirements is required (an empty one declares none)`);
219
+ else {
220
+ const seen = new Set<string>();
221
+ raw.requirements.forEach((r, i) => {
222
+ const where = `requirements[${i}]`;
223
+ if (!isRecord(r)) {
224
+ field(where, `is ${JSON.stringify(r)?.slice(0, 40)}; an object is required`);
225
+ return;
226
+ }
227
+ const name = typeof r.name === 'string' && NAME.test(r.name) ? r.name : null;
228
+ const label = name === null ? where : `${where} "${name}"`;
229
+ if (name === null) field(`${where} field "name"`, `is ${JSON.stringify(r.name)}; a name of letters, digits and underscores, starting with a letter, is required — it names the line`);
230
+ else if (seen.has(name)) field(`${where} field "name"`, `"${name}" is used by an earlier requirement; each line is named once`);
231
+ else seen.add(name);
232
+ const kind = (REQUIREMENT_KINDS as readonly string[]).includes(r.kind as string) ? (r.kind as RequirementKind) : null;
233
+ if (kind === null) field(`${label} field "kind"`, `is ${JSON.stringify(r.kind)}; one of ${REQUIREMENT_KINDS.join(', ')} is required`);
234
+ if (typeof r.animation !== 'string' || r.animation.length === 0) field(`${label} field "animation"`, `is ${JSON.stringify(r.animation)}; the name of the animation it is measured over is required`);
235
+ if (kind === null) return;
236
+ const known = ['name', 'kind', 'animation', ...KIND_FIELDS[kind]];
237
+ for (const k of Object.keys(r)) if (!known.includes(k) && !freeKey(k, r[k])) field(`${label} field "${k}"`, `is not a field a ${kind} requirement reads (${KIND_FIELDS[kind].join(', ')})`);
238
+ const before = problems.length;
239
+ const str = (k: string): string => {
240
+ const v = r[k];
241
+ if (typeof v !== 'string' || v.length === 0) field(`${label} field "${k}"`, `is ${JSON.stringify(v)}; a name is required`);
242
+ return typeof v === 'string' ? v : '';
243
+ };
244
+ const bar = (k: string, rule: (v: number) => boolean, what: string): number => {
245
+ const v = r[k];
246
+ if (!finite(v) || !rule(v)) field(`${label} field "${k}"`, `is ${JSON.stringify(v)}; ${what} is required — the bar is the author's, and there is no default`);
247
+ return finite(v) ? v : NaN;
248
+ };
249
+ const point = (k: string, v: unknown): BonePoint => {
250
+ if (v !== 'origin' && v !== 'tip') field(`${label} field "${k}"`, `is ${JSON.stringify(v)}; "origin" or "tip" is required`);
251
+ return v === 'tip' ? 'tip' : 'origin';
252
+ };
253
+ const target = (): TargetRef => {
254
+ const t = r.target;
255
+ if (isRecord(t) && 'stage' in t && !('bone' in t)) {
256
+ const s = t.stage;
257
+ if (!(Array.isArray(s) && s.length === 2 && s.every(finite)) || Object.keys(t).length !== 1) field(`${label} field "target"`, `is ${JSON.stringify(t)}; { "stage": [x, y] } in stage px (y down) is required for a stage point`);
258
+ return { stage: Array.isArray(s) && s.length === 2 && s.every(finite) ? [s[0] as number, s[1] as number] : [NaN, NaN] };
259
+ }
260
+ if (isRecord(t) && typeof t.bone === 'string' && t.bone.length > 0 && Object.keys(t).every((k) => k === 'bone' || k === 'point')) return { bone: t.bone, point: point('target.point', t.point) };
261
+ field(`${label} field "target"`, `is ${JSON.stringify(t)}; { "bone": <name>, "point": "origin" | "tip" } or { "stage": [x, y] } is required`);
262
+ return { stage: [NaN, NaN] };
263
+ };
264
+ const common = { name: name ?? '', animation: typeof r.animation === 'string' ? r.animation : '' };
265
+ let req: Requirement;
266
+ if (kind === 'contact') {
267
+ req = { ...common, kind, bone: str('bone'), point: point('point', r.point), target: target(), within_px: bar('within_px', (v) => v >= 0, 'a distance in stage px, 0 or more,') };
268
+ } else if (kind === 'follow') {
269
+ const type = r.constraint_type;
270
+ if (type !== 'ik' && type !== 'transform') field(`${label} field "constraint_type"`, `is ${JSON.stringify(type)}; "ik" or "transform" is required — a constraint resolves by name AND type, as in rig-c`);
271
+ const prop = r.property;
272
+ if (prop !== 'rotate' && prop !== 'translate') field(`${label} field "property"`, `is ${JSON.stringify(prop)}; "rotate" or "translate" is required`);
273
+ req = {
274
+ ...common,
275
+ kind,
276
+ constraint: str('constraint'),
277
+ constraint_type: type === 'transform' ? 'transform' : 'ik',
278
+ bone: str('bone'),
279
+ property: prop === 'translate' ? 'translate' : 'rotate',
280
+ fraction: bar('fraction', () => true, 'a number, the fraction of the drive the bone takes,'),
281
+ tolerance: bar('tolerance', (v) => v >= 0, 'a number 0 or more'),
282
+ least_drive: bar('least_drive', (v) => v > 0, 'a number above 0 (degrees for rotate, stage px for translate): at 0 a frame where the constraint asks nothing would count'),
283
+ };
284
+ } else if (kind === 'aim') {
285
+ req = { ...common, kind, bone: str('bone'), target: target(), within_degrees: bar('within_degrees', (v) => v >= 0 && v <= 180, 'an angle in degrees from 0 to 180') };
286
+ } else if (kind === 'range') {
287
+ const lo = bar('lo_degrees', () => true, 'an angle in degrees');
288
+ const hi = bar('hi_degrees', () => true, 'an angle in degrees');
289
+ if (finite(lo) && finite(hi) && lo > hi) field(`${label} fields "lo_degrees", "hi_degrees"`, `are ${lo} and ${hi}; lo_degrees <= hi_degrees is required`);
290
+ req = { ...common, kind, bone: str('bone'), lo_degrees: lo, hi_degrees: hi };
291
+ } else if (kind === 'stretch') {
292
+ req = { ...common, kind, slot: str('slot'), attachment: str('attachment'), within_ratio: bar('within_ratio', (v) => v >= 1, 'a ratio 1 or more (the figure is max(ratio, 1/ratio), never below 1)') };
293
+ } else {
294
+ const part = str('part');
295
+ const neighbour = str('neighbour');
296
+ if (part.length > 0 && part === neighbour) field(`${label} fields "part", "neighbour"`, `both name "${part}"; two different parts are required — a seam is where one part's art meets another's`);
297
+ req = { ...common, kind, part, neighbour, within_px: bar('within_px', (v) => v >= 0, 'a distance in stage px, 0 or more,') };
298
+ }
299
+ if (problems.length === before && name !== null) requirements.push(req);
300
+ });
301
+ }
302
+
303
+ const targets: SceneTarget[] = [];
304
+ if (raw.targets !== undefined) {
305
+ if (!Array.isArray(raw.targets)) field(`${at} field "targets"`, `is ${JSON.stringify(raw.targets)}; an array of scene targets is required when the field is given`);
306
+ else {
307
+ raw.targets.forEach((t, i) => {
308
+ const where = `targets[${i}]`;
309
+ if (!isRecord(t)) {
310
+ field(where, `is ${JSON.stringify(t)?.slice(0, 40)}; an object is required`);
311
+ return;
312
+ }
313
+ const before = problems.length;
314
+ for (const k of Object.keys(t)) if (!['bone', 'animation', 'at', 'keys'].includes(k) && !freeKey(k, t[k])) field(`${where} field "${k}"`, 'is not a field a scene target reads (bone, animation, and at or keys)');
315
+ if (typeof t.bone !== 'string' || t.bone.length === 0) field(`${where} field "bone"`, `is ${JSON.stringify(t.bone)}; the name of a bone whose parent is the root is required`);
316
+ if (typeof t.animation !== 'string' || t.animation.length === 0) field(`${where} field "animation"`, `is ${JSON.stringify(t.animation)}; the animation it is placed for is required`);
317
+ const pair = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(finite);
318
+ const points: TargetPoint[] = [];
319
+ if (('at' in t) === ('keys' in t)) field(where, `has ${'at' in t ? 'both "at" and "keys"' : 'neither "at" nor "keys"'}; exactly one is required — "at": [x, y], a fixed stage point, or "keys": [{ "t": <s>, "at": [x, y] }, …]`);
320
+ else if ('at' in t) {
321
+ if (!pair(t.at)) field(`${where} field "at"`, `is ${JSON.stringify(t.at)}; [x, y] in stage px (y down) is required`);
322
+ else points.push({ t: null, x: t.at[0], y: t.at[1] });
323
+ } else if (!Array.isArray(t.keys) || t.keys.length === 0) field(`${where} field "keys"`, `is ${JSON.stringify(t.keys)}; at least one { "t": <s>, "at": [x, y] } is required`);
324
+ else {
325
+ t.keys.forEach((k, j) => {
326
+ if (!isRecord(k) || !finite(k.t) || !pair(k.at) || Object.keys(k).length !== 2) {
327
+ field(`${where} field "keys[${j}]"`, `is ${JSON.stringify(k)}; { "t": <seconds>, "at": [x, y] } is required`);
328
+ return;
329
+ }
330
+ const prev = points[points.length - 1];
331
+ if (prev !== undefined && !((k.t as number) > (prev.t as number))) field(`${where} field "keys[${j}].t"`, `is ${k.t}, not after the key before it (${prev.t}); times strictly increasing are required`);
332
+ points.push({ t: k.t as number, x: k.at[0], y: k.at[1] });
333
+ });
334
+ }
335
+ if (problems.length === before) targets.push({ bone: t.bone as string, animation: t.animation as string, points });
336
+ });
337
+ }
338
+ }
339
+ refuseIfAny(problems);
340
+ return { path: at, fps, targets, requirements };
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // resolving against the rig
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /** A file-system-safe animation name: it names a directory under `<out>/requirements/`. */
348
+ function safeDirName(n: string): boolean {
349
+ return n.length > 0 && n !== '.' && n !== '..' && !/[/\\\0]/.test(n);
350
+ }
351
+
352
+ function bonesOf(rig: Record<string, unknown>): Array<Record<string, unknown>> {
353
+ return (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
354
+ }
355
+
356
+ function constraintsOf(rig: Record<string, unknown>): Array<Record<string, unknown>> {
357
+ return (Array.isArray(rig.constraints) ? rig.constraints : []).filter(isRecord);
358
+ }
359
+
360
+ function animationsOf(motion: Record<string, unknown>): Record<string, unknown> {
361
+ return isRecord(motion.animations) ? motion.animations : {};
362
+ }
363
+
364
+ /** The `to` property names a transform constraint declares, over every `properties` entry. */
365
+ function transformDrives(c: Record<string, unknown>): Set<string> {
366
+ const out = new Set<string>();
367
+ if (isRecord(c.properties)) for (const p of Object.values(c.properties)) if (isRecord(p) && isRecord(p.to)) for (const k of Object.keys(p.to)) out.add(k);
368
+ return out;
369
+ }
370
+
371
+ /** The property names a bone track moves the bone's translation by. */
372
+ const TRANSLATE_PROPERTIES = ['translate', 'translatex', 'translatey'];
373
+
374
+ /**
375
+ * Resolve every name the file uses against the rig spec and its motion,
376
+ * collecting every problem before one refusal — this runs before anything is
377
+ * built. Refused, by name: an animation the motion does not declare (or whose
378
+ * name cannot be a directory); a bone, a constraint of the declared type, a
379
+ * slot or a mesh attachment the rig does not declare; a `follow` whose bone the
380
+ * constraint does not constrain or whose property it does not drive (an `ik`
381
+ * drives `rotate` only; a `transform` drives `rotate` when a `to` names
382
+ * `rotate`, `translate` when one names `x` or `y`); a scene target whose bone
383
+ * is the root or is not parented to it (a stage point is not a local offset
384
+ * under a moving parent), whose root is not at rest at the origin or is keyed
385
+ * in that animation, whose bone a group translates there, whose keys fall
386
+ * outside the animation, that names a bone and animation twice, or that no
387
+ * requirement measures.
388
+ */
389
+ export function resolveRequirements(file: RequirementsFile, rig: Record<string, unknown>, motion: Record<string, unknown>, rootBone: string): void {
390
+ const problems: Problem[] = [];
391
+ const unresolved = (object: string, detail: string): void => {
392
+ problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail });
393
+ };
394
+ const bones = bonesOf(rig);
395
+ const boneNames = bones.map((b) => b.name).filter((n): n is string => typeof n === 'string');
396
+ const anims = animationsOf(motion);
397
+ const animNames = Object.keys(anims);
398
+ const listed = (xs: readonly string[]): string => (xs.length === 0 ? 'none' : xs.map((x) => `"${x}"`).join(', '));
399
+ const animation = (object: string, a: string): boolean => {
400
+ if (!(a in anims) || !isRecord(anims[a])) {
401
+ unresolved(object, `names animation "${a}", which motion.json does not declare; it declares ${listed(animNames)}`);
402
+ return false;
403
+ }
404
+ if (!safeDirName(a)) {
405
+ unresolved(object, `names animation ${JSON.stringify(a)}, whose name cannot name the directory its frames are written into (no "/", "\\", ".", "..")`);
406
+ return false;
407
+ }
408
+ return true;
409
+ };
410
+ const bone = (object: string, b: string): boolean => {
411
+ if (boneNames.includes(b)) return true;
412
+ unresolved(object, `names bone "${b}", which rig.json does not declare; it declares ${listed(boneNames)}`);
413
+ return false;
414
+ };
415
+ const target = (object: string, t: TargetRef): void => {
416
+ if ('bone' in t) bone(`${object} target`, t.bone);
417
+ };
418
+ for (const r of file.requirements) {
419
+ const object = `requirement "${r.name}" (${r.kind})`;
420
+ animation(object, r.animation);
421
+ if (r.kind === 'contact') {
422
+ bone(object, r.bone);
423
+ target(object, r.target);
424
+ } else if (r.kind === 'aim') {
425
+ bone(object, r.bone);
426
+ target(object, r.target);
427
+ } else if (r.kind === 'range') bone(object, r.bone);
428
+ else if (r.kind === 'seam') {
429
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
430
+ for (const [field, name] of [['part', r.part], ['neighbour', r.neighbour]] as const) {
431
+ if (!slots.includes(name)) unresolved(object, `names ${field} "${name}", which rig.json declares no slot for; a part is drawn by the slot of its name`);
432
+ }
433
+ } else if (r.kind === 'follow') {
434
+ bone(object, r.bone);
435
+ const c = constraintsOf(rig).find((x) => x.name === r.constraint && x.type === r.constraint_type);
436
+ if (c === undefined) {
437
+ const declared = constraintsOf(rig)
438
+ .filter((x) => x.type === 'ik' || x.type === 'transform')
439
+ .map((x) => `${String(x.type)} "${String(x.name)}"`);
440
+ unresolved(object, `names ${r.constraint_type} constraint "${r.constraint}", which rig.json does not declare; it declares ${declared.length === 0 ? 'no ik or transform constraint' : declared.join(', ')}`);
441
+ continue;
442
+ }
443
+ const constrained = (Array.isArray(c.bones) ? c.bones : []).filter((b): b is string => typeof b === 'string');
444
+ if (!constrained.includes(r.bone)) {
445
+ problems.push({ code: 'REQUIREMENTS_CONSTRAINT_DRIVES', object, detail: `names bone "${r.bone}", which ${r.constraint_type} constraint "${r.constraint}" does not constrain (its bones: ${listed(constrained)}); a follow measures what the constraint asks of one of its own bones` });
446
+ }
447
+ if (r.constraint_type === 'ik' && r.property !== 'rotate') {
448
+ problems.push({ code: 'REQUIREMENTS_CONSTRAINT_DRIVES', object, detail: `names property "${r.property}" of ik constraint "${r.constraint}"; an ik drives rotate only` });
449
+ }
450
+ if (r.constraint_type === 'transform') {
451
+ const drives = transformDrives(c);
452
+ const ok = r.property === 'rotate' ? drives.has('rotate') : drives.has('x') || drives.has('y');
453
+ if (!ok) {
454
+ problems.push({
455
+ code: 'REQUIREMENTS_CONSTRAINT_DRIVES',
456
+ object,
457
+ detail: `names property "${r.property}" of transform constraint "${r.constraint}", whose properties drive ${listed([...drives].sort())}; ${r.property === 'rotate' ? 'a "to" naming rotate' : 'a "to" naming x or y'} is required — a mix is read only for a property the constraint drives`,
458
+ });
459
+ }
460
+ }
461
+ } else {
462
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
463
+ if (!slots.includes(r.slot)) {
464
+ unresolved(object, `names slot "${r.slot}", which rig.json does not declare`);
465
+ continue;
466
+ }
467
+ const meshes = meshAttachmentsOf(rig, r.slot);
468
+ if (typeof meshes === 'string') problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: meshes });
469
+ else if (!meshes.includes(r.attachment)) unresolved(object, `names mesh attachment "${r.attachment}" on slot "${r.slot}", which no skin in rig.json declares; its mesh attachments are ${listed(meshes)}`);
470
+ }
471
+ }
472
+
473
+ const root = bones.find((b) => b.name === rootBone);
474
+ const measured = new Set(file.requirements.map((r) => r.animation));
475
+ const placed = new Set<string>();
476
+ file.targets.forEach((t, i) => {
477
+ const object = `targets[${i}] (bone "${t.bone}", animation "${t.animation}")`;
478
+ const okBone = bone(object, t.bone);
479
+ const okAnim = animation(object, t.animation);
480
+ const key = `${t.bone}\u0000${t.animation}`;
481
+ if (placed.has(key)) problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: 'places the same bone for the same animation as an earlier target; one placement per bone and animation is required' });
482
+ placed.add(key);
483
+ if (okAnim && !measured.has(t.animation)) problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: `no requirement is measured over animation "${t.animation}", so the placement would move nothing that is read` });
484
+ if (!okBone) return;
485
+ const b = bones.find((x) => x.name === t.bone) as Record<string, unknown>;
486
+ if (t.bone === rootBone) problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `is the root; a scene target is a bone whose parent is the root "${rootBone}"` });
487
+ else if (b.parent !== rootBone) {
488
+ problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `has parent ${JSON.stringify(b.parent)}; the root "${rootBone}" is required — a stage point is not a local offset under a moving parent` });
489
+ }
490
+ const rest: Record<string, number> = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, shearX: 0, shearY: 0 };
491
+ const off = root === undefined ? [] : Object.keys(rest).filter((k) => k in root && root[k] !== rest[k]);
492
+ if (off.length > 0) {
493
+ problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `the root "${rootBone}" is not at rest at the origin (${off.map((k) => `${k} ${JSON.stringify(root?.[k])}`).join(', ')}); a stage point is placed as a root-local position only under a root at rest` });
494
+ }
495
+ if (!okAnim) return;
496
+ const a = anims[t.animation] as Record<string, unknown>;
497
+ const tracks = (Array.isArray(a.tracks) ? a.tracks : []).filter(isRecord);
498
+ if (tracks.some((tr) => tr.bone === rootBone)) problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `animation "${t.animation}" keys the root "${rootBone}"; a stage point is placed as a root-local position only under a root the animation does not move` });
499
+ const groups = isRecord(motion.groups) ? motion.groups : {};
500
+ for (const tr of tracks) {
501
+ if (typeof tr.group !== 'string' || !TRANSLATE_PROPERTIES.includes(String(tr.property))) continue;
502
+ const members = groups[tr.group];
503
+ if (Array.isArray(members) && members.includes(t.bone)) {
504
+ problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: `animation "${t.animation}" moves it by group "${tr.group}" ${String(tr.property)}; a placed target's translation is the scene's, so a group track over it is required not to key it` });
505
+ }
506
+ }
507
+ const duration = a.duration;
508
+ for (const p of t.points) {
509
+ if (p.t !== null && finite(duration) && (p.t < 0 || p.t > duration)) problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: `has a key at t = ${p.t}s; animation "${t.animation}" runs from 0 to ${duration}s, and a key inside it is required` });
510
+ }
511
+ });
512
+ refuseIfAny(problems);
513
+ }
514
+
515
+ /** The mesh attachment names a slot holds in any skin, through rig-c's own skin reader; a string (the refusal's detail) when a skin does not read. */
516
+ function meshAttachmentsOf(rig: Record<string, unknown>, slot: string): string[] | string {
517
+ const out = new Set<string>();
518
+ const skins = isRecord(rig.skins) ? rig.skins : {};
519
+ for (const [name, skin] of Object.entries(skins)) {
520
+ let atts: Record<string, Record<string, unknown>>;
521
+ try {
522
+ atts = splitRigSkin(skin as RigSkin, `rig.json skin "${name}"`).attachments as Record<string, Record<string, unknown>>;
523
+ } catch (err) {
524
+ if (err instanceof CompileError) return `rig-c's skin reader refuses rig.json skin "${name}": ${err.message}`;
525
+ throw err;
526
+ }
527
+ const held = atts[slot];
528
+ if (!isRecord(held)) continue;
529
+ for (const [att, v] of Object.entries(held)) if (isRecord(v) && v.type === 'mesh') out.add(att);
530
+ }
531
+ return [...out].sort();
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // the throwaway copies
536
+ // ---------------------------------------------------------------------------
537
+
538
+ function clone<T>(v: T): T {
539
+ return JSON.parse(JSON.stringify(v)) as T;
540
+ }
541
+
542
+ /** A stage point (stage px, y down) as a Spine world point, through the stage box and the one y door. */
543
+ export function stageToWorld(p: readonly [number, number], stage: { x: number; y: number; height: number }): [number, number] {
544
+ return [stage.x + p[0], stage.y + cropToSpineY(p[1], stage.height)];
545
+ }
546
+
547
+ /**
548
+ * The rig spec and motion with each scene target of `animation` written in:
549
+ * the bone's setup position is its first point (a root at rest at the origin,
550
+ * which {@link resolveRequirements} holds, makes a root-local position the
551
+ * world point), every track of that animation that keys its translation
552
+ * directly is dropped, and, for timed points, one `translate` track keys every
553
+ * point relative to the first — linear between keys, and outside them the
554
+ * first point (the setup) before and the last after. The inputs are not
555
+ * altered.
556
+ */
557
+ export function placeTargets(rig: Record<string, unknown>, motion: Record<string, unknown>, targets: readonly SceneTarget[], animation: string, stage: { x: number; y: number; height: number }): { rig: Record<string, unknown>; motion: Record<string, unknown> } {
558
+ const r = clone(rig);
559
+ const m = clone(motion);
560
+ const mine = targets.filter((t) => t.animation === animation);
561
+ if (mine.length === 0) return { rig: r, motion: m };
562
+ const a = animationsOf(m)[animation] as Record<string, unknown>;
563
+ let tracks = (Array.isArray(a.tracks) ? a.tracks : []).filter(isRecord);
564
+ for (const t of mine) {
565
+ const [x0, y0] = stageToWorld([t.points[0].x, t.points[0].y], stage);
566
+ const b = bonesOf(r).find((x) => x.name === t.bone) as Record<string, unknown>;
567
+ b.x = x0;
568
+ b.y = y0;
569
+ tracks = tracks.filter((tr) => !(tr.bone === t.bone && TRANSLATE_PROPERTIES.includes(String(tr.property))));
570
+ if (t.points[0].t !== null) {
571
+ tracks.push({
572
+ bone: t.bone,
573
+ property: 'translate',
574
+ keys: t.points.map((p) => {
575
+ const [x, y] = stageToWorld([p.x, p.y], stage);
576
+ return { t: p.t, v: [x - x0, y - y0] };
577
+ }),
578
+ });
579
+ }
580
+ }
581
+ a.tracks = tracks;
582
+ return { rig: r, motion: m };
583
+ }
584
+
585
+ /** The mix fields of a follow's property and the curve channel each sits in: an ik's `mix` (channel 0); a transform's `mixRotate` (0), or `mixX` and `mixY` (1, 2). */
586
+ export function mixFields(f: Pick<FollowRequirement, 'constraint_type' | 'property'>): Array<{ field: string; channel: number }> {
587
+ if (f.constraint_type === 'ik') return [{ field: 'mix', channel: 0 }];
588
+ return f.property === 'rotate'
589
+ ? [{ field: 'mixRotate', channel: 0 }]
590
+ : [
591
+ { field: 'mixX', channel: 1 },
592
+ { field: 'mixY', channel: 2 },
593
+ ];
594
+ }
595
+
596
+ /**
597
+ * #87's released (`value` 0) or full (`value` 1) copy of a follow's rig and
598
+ * motion: the constraint's mix for that property forced to `value` in the rig
599
+ * spec, and every key of that mix in the animation replaced by `value`, its
600
+ * curve channel's two value numbers with it — a timeline that holds `value`
601
+ * throughout, which is what removing the keys under a setup mix of `value`
602
+ * poses, while every other field those keys state (softness, bend, the other
603
+ * mixes) stays as written, so the copy differs from the rig in that one mix
604
+ * only. Other animations are not touched. The full copy also drops the
605
+ * constraint from `invariants.consumerDrivenMix` if the rig declares it there:
606
+ * at 1 it rests live, and rig-c refuses a declaration that exempts nothing.
607
+ */
608
+ export function forceMix(rig: Record<string, unknown>, motion: Record<string, unknown>, f: FollowRequirement, value: 0 | 1): { rig: Record<string, unknown>; motion: Record<string, unknown> } {
609
+ const r = clone(rig);
610
+ const m = clone(motion);
611
+ const fields = mixFields(f);
612
+ const c = constraintsOf(r).find((x) => x.name === f.constraint && x.type === f.constraint_type) as Record<string, unknown>;
613
+ for (const { field } of fields) c[field] = value;
614
+ const a = animationsOf(m)[f.animation];
615
+ if (isRecord(a)) {
616
+ const group = f.constraint_type === 'ik' ? a.ik : a.transform;
617
+ for (const track of (Array.isArray(group) ? group : []).filter(isRecord)) {
618
+ if (track.constraint !== f.constraint) continue;
619
+ for (const key of (Array.isArray(track.keys) ? track.keys : []).filter(isRecord)) {
620
+ for (const { field, channel } of fields) {
621
+ key[field] = value;
622
+ if (Array.isArray(key.curve)) {
623
+ if (4 * channel + 3 < key.curve.length) {
624
+ key.curve[4 * channel + 1] = value;
625
+ key.curve[4 * channel + 3] = value;
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+ }
632
+ if (value === 1 && isRecord(r.invariants) && Array.isArray(r.invariants.consumerDrivenMix)) {
633
+ const kept = r.invariants.consumerDrivenMix.filter((e) => !(isRecord(e) && e.constraint === f.constraint && e.type === f.constraint_type));
634
+ if (kept.length === 0) delete r.invariants.consumerDrivenMix;
635
+ else r.invariants.consumerDrivenMix = kept;
636
+ }
637
+ return { rig: r, motion: m };
638
+ }
639
+
640
+ /** Why the released copy declares its constraint consumer-driven, written into the copy's `invariants.consumerDrivenMix`. */
641
+ export function releasedWhy(f: FollowRequirement): string {
642
+ return `rig-parts check --requirements: the released pose of follow requirement "${f.name}" forces this mix to 0 on a throwaway copy; the rig under test is not altered`;
643
+ }
644
+
645
+ /**
646
+ * The released copy with its constraint declared consumer-driven — rig-c's
647
+ * door for a constraint muted throughout (`A47`/`A48`), which the released pose
648
+ * is by construction. Used only when rigc's gate refused the released copy for
649
+ * that constraint alone ({@link mutedOnly}).
650
+ */
651
+ export function declareConsumerDriven(rig: Record<string, unknown>, f: FollowRequirement): Record<string, unknown> {
652
+ const r = clone(rig);
653
+ const inv = isRecord(r.invariants) ? r.invariants : {};
654
+ const list = Array.isArray(inv.consumerDrivenMix) ? inv.consumerDrivenMix : [];
655
+ r.invariants = { ...inv, consumerDrivenMix: [...list, { constraint: f.constraint, type: f.constraint_type, why: releasedWhy(f) }] };
656
+ return r;
657
+ }
658
+
659
+ /** rig-c's rule a muted-throughout constraint of each type fails, and the words its line names the constraint with. */
660
+ export function mutedLine(f: Pick<FollowRequirement, 'constraint' | 'constraint_type'>): { rule: string; names: string } {
661
+ return f.constraint_type === 'ik'
662
+ ? { rule: 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', names: `ik constraint "${f.constraint}" has mix` }
663
+ : { rule: 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT', names: `transform constraint "${f.constraint}" drives` };
664
+ }
665
+
666
+ /** Whether a red build's FAIL lines are all, and only, the muted-throughout refusal of the follow's own constraint. */
667
+ export function mutedOnly(failLines: readonly string[], f: Pick<FollowRequirement, 'constraint' | 'constraint_type'>): boolean {
668
+ const { rule, names } = mutedLine(f);
669
+ return failLines.length > 0 && failLines.every((l) => l.includes(`${rule}: ${names}`));
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // measuring
674
+ // ---------------------------------------------------------------------------
675
+
676
+ /** One posed render of an animation: each frame's index and time, and a bone's world transform by name (setup and per frame). */
677
+ export interface Poses {
678
+ /** What it is, for the lines: "as declared", "released", "full". */
679
+ label: string;
680
+ indices: number[];
681
+ times: number[];
682
+ bone: (name: string) => { setup: BoneWorld; frames: BoneWorld[] };
683
+ }
684
+
685
+ /** What a measure reads beside the poses: each bone's `length` and parent from the rig spec, and the stage box. */
686
+ export interface RigFacts {
687
+ lengths: Map<string, number>;
688
+ parents: Map<string, string | null>;
689
+ stage: { x: number; y: number; height: number };
690
+ }
691
+
692
+ /** The bone facts a measure reads, off a rig spec: `length` (absent is 0, as Spine reads it) and `parent`. */
693
+ export function rigFacts(rig: Record<string, unknown>, stage: { x: number; y: number; height: number }): RigFacts {
694
+ const lengths = new Map<string, number>();
695
+ const parents = new Map<string, string | null>();
696
+ for (const b of bonesOf(rig)) {
697
+ if (typeof b.name !== 'string') continue;
698
+ lengths.set(b.name, finite(b.length) ? b.length : 0);
699
+ parents.set(b.name, typeof b.parent === 'string' ? b.parent : null);
700
+ }
701
+ return { lengths, parents, stage };
702
+ }
703
+
704
+ /** One requirement's line: its kind, animation and status, then its figures (and, NOT MEASURABLE, the reason first). */
705
+ export type RequirementLine = { kind: RequirementKind; animation: string; status: RequirementStatus; [figure: string]: unknown };
706
+
707
+ const DEG = 180 / Math.PI;
708
+
709
+ /** Six decimals: what a line prints and check.json holds. The comparison with the bar reads the unrounded figure. */
710
+ export function round6(x: number): number {
711
+ const r = Math.round(x * 1e6) / 1e6;
712
+ return r === 0 ? 0 : r;
713
+ }
714
+
715
+ /** `a − b` in degrees as the signed shortest angle, in (−180, 180]. */
716
+ export function shortestDegrees(a: number, b: number): number {
717
+ let d = (a - b) % 360;
718
+ if (d > 180) d -= 360;
719
+ if (d <= -180) d += 360;
720
+ return d;
721
+ }
722
+
723
+ /** A bone's world rotation, `atan2(c, a)`, in degrees. */
724
+ export function worldRotation(w: BoneWorld): number {
725
+ return Math.atan2(w.c, w.a) * DEG;
726
+ }
727
+
728
+ function frameText(p: Poses, i: number): string {
729
+ return `frame ${p.indices[i]} (t = ${round6(p.times[i])}s)`;
730
+ }
731
+
732
+ function pointOf(w: BoneWorld, point: BonePoint, length: number): [number, number] {
733
+ return point === 'origin' ? [w.worldX, w.worldY] : [w.worldX + w.a * length, w.worldY + w.c * length];
734
+ }
735
+
736
+ function targetText(t: TargetRef): string {
737
+ return 'bone' in t ? `bone "${t.bone}" ${t.point}` : `stage point ${t.stage[0]},${t.stage[1]}`;
738
+ }
739
+
740
+ /** Where a target is on frame `i`, or the reason it has no point (a tip on a bone of length 0). */
741
+ function targetAt(t: TargetRef, p: Poses, facts: RigFacts): ((i: number) => [number, number]) | string {
742
+ if ('stage' in t) {
743
+ const w = stageToWorld(t.stage, facts.stage);
744
+ return () => w;
745
+ }
746
+ const len = facts.lengths.get(t.bone) ?? 0;
747
+ if (t.point === 'tip' && len === 0) return `the target is the tip of bone "${t.bone}", whose length is 0, so it has no tip`;
748
+ const track = p.bone(t.bone);
749
+ return (i) => pointOf(track.frames[i], t.point, len);
750
+ }
751
+
752
+ function notMeasurable(kind: RequirementKind, animation: string, reason: string, figures: Record<string, unknown> = {}): RequirementLine {
753
+ return { kind, animation, status: 'NOT MEASURABLE', reason, ...figures };
754
+ }
755
+
756
+ /** `contact`: the largest distance between the bone's point and the target over the frames, and its frame. */
757
+ export function contactLine(r: ContactRequirement, p: Poses, facts: RigFacts): RequirementLine {
758
+ const len = facts.lengths.get(r.bone) ?? 0;
759
+ if (r.point === 'tip' && len === 0) return notMeasurable('contact', r.animation, `bone "${r.bone}" has length 0, so it has no tip`, { length: 0 });
760
+ const at = targetAt(r.target, p, facts);
761
+ if (typeof at === 'string') return notMeasurable('contact', r.animation, at);
762
+ const track = p.bone(r.bone);
763
+ let worst = -1;
764
+ let wi = 0;
765
+ for (let i = 0; i < p.times.length; i++) {
766
+ const a = pointOf(track.frames[i], r.point, len);
767
+ const b = at(i);
768
+ const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
769
+ if (d > worst) {
770
+ worst = d;
771
+ wi = i;
772
+ }
773
+ }
774
+ return {
775
+ kind: 'contact',
776
+ animation: r.animation,
777
+ status: worst <= r.within_px ? 'PASS' : 'FAIL',
778
+ of: `bone "${r.bone}" ${r.point}`,
779
+ against: targetText(r.target),
780
+ frames: p.times.length,
781
+ largest_px: round6(worst),
782
+ at: frameText(p, wi),
783
+ within_px: r.within_px,
784
+ };
785
+ }
786
+
787
+ /** `aim`: the angle between the bone's axis and the line from its origin to the target, the largest over the frames that have a line. */
788
+ export function aimLine(r: AimRequirement, p: Poses, facts: RigFacts): RequirementLine {
789
+ const len = facts.lengths.get(r.bone) ?? 0;
790
+ if (len === 0) return notMeasurable('aim', r.animation, `bone "${r.bone}" has length 0, so it has no axis to aim`, { length: 0 });
791
+ const at = targetAt(r.target, p, facts);
792
+ if (typeof at === 'string') return notMeasurable('aim', r.animation, at);
793
+ const track = p.bone(r.bone);
794
+ let worst = -1;
795
+ let wi = 0;
796
+ const unmeasured: number[] = [];
797
+ for (let i = 0; i < p.times.length; i++) {
798
+ const w = track.frames[i];
799
+ const b = at(i);
800
+ const lx = b[0] - w.worldX;
801
+ const ly = b[1] - w.worldY;
802
+ if ((lx === 0 && ly === 0) || (w.a === 0 && w.c === 0)) {
803
+ unmeasured.push(p.indices[i]);
804
+ continue;
805
+ }
806
+ const angle = Math.atan2(Math.abs(w.a * ly - w.c * lx), w.a * lx + w.c * ly) * DEG;
807
+ if (angle > worst) {
808
+ worst = angle;
809
+ wi = i;
810
+ }
811
+ }
812
+ if (worst < 0) return notMeasurable('aim', r.animation, `on every one of the ${p.times.length} frame(s) the target sits on the bone's origin (or the bone has no axis), so there is no line to aim along`, { frames: p.times.length });
813
+ return {
814
+ kind: 'aim',
815
+ animation: r.animation,
816
+ status: worst <= r.within_degrees ? 'PASS' : 'FAIL',
817
+ of: `bone "${r.bone}" axis`,
818
+ against: targetText(r.target),
819
+ frames: p.times.length - unmeasured.length,
820
+ frames_not_measurable: unmeasured,
821
+ largest_degrees: round6(worst),
822
+ at: frameText(p, wi),
823
+ within_degrees: r.within_degrees,
824
+ };
825
+ }
826
+
827
+ /** `range`: the bone's rotation relative to its parent (the world, for the root) less the same at the setup pose, signed shortest, the least and the greatest. */
828
+ export function rangeLine(r: RangeRequirement, p: Poses, facts: RigFacts): RequirementLine {
829
+ const parent = facts.parents.get(r.bone) ?? null;
830
+ const track = p.bone(r.bone);
831
+ const ptrack = parent === null ? null : p.bone(parent);
832
+ const rel = (w: BoneWorld, pw: BoneWorld | null): number => (pw === null ? worldRotation(w) : shortestDegrees(worldRotation(w), worldRotation(pw)));
833
+ const setup = rel(track.setup, ptrack?.setup ?? null);
834
+ let lo = Infinity;
835
+ let hi = -Infinity;
836
+ let li = 0;
837
+ let hiI = 0;
838
+ for (let i = 0; i < p.times.length; i++) {
839
+ const v = shortestDegrees(rel(track.frames[i], ptrack?.frames[i] ?? null), setup);
840
+ if (v < lo) {
841
+ lo = v;
842
+ li = i;
843
+ }
844
+ if (v > hi) {
845
+ hi = v;
846
+ hiI = i;
847
+ }
848
+ }
849
+ return {
850
+ kind: 'range',
851
+ animation: r.animation,
852
+ status: lo >= r.lo_degrees && hi <= r.hi_degrees ? 'PASS' : 'FAIL',
853
+ of: `bone "${r.bone}" relative to ${parent === null ? 'the world' : `its parent "${parent}"`}, from its setup pose`,
854
+ frames: p.times.length,
855
+ least_degrees: round6(lo),
856
+ least_at: frameText(p, li),
857
+ greatest_degrees: round6(hi),
858
+ greatest_at: frameText(p, hiI),
859
+ range_degrees: [r.lo_degrees, r.hi_degrees],
860
+ };
861
+ }
862
+
863
+ /**
864
+ * `follow`, #87's definition to the letter. `declared`, `released` and `full`
865
+ * are the same frames posed three ways (the rig as written; the constraint's
866
+ * mix for the property forced to 0, its keys removed; forced to 1, keys
867
+ * removed). For `translate` A is the bone's world origin; for `rotate`,
868
+ * `atan2(c, a)` in degrees, every difference a signed shortest angle. Per
869
+ * frame the drive is d = A1 − A0; a frame counts when |d| ≥ `least_drive`;
870
+ * F = Σ⟨A − A0, d⟩ / Σ|d|² over the frames that count; PASS when
871
+ * |F − fraction| ≤ tolerance. The line carries the frames that counted, the
872
+ * largest drive and its frame, the largest residual |A − A0 − F·d| and its
873
+ * frame, and, for rotate, a note when the largest drive exceeds 90°. NOT
874
+ * MEASURABLE when no frame reaches the least drive, printing the largest drive
875
+ * there was.
876
+ */
877
+ export function followLine(r: FollowRequirement, declared: Poses, released: Poses, full: Poses): RequirementLine {
878
+ const A = declared.bone(r.bone).frames;
879
+ const A0 = released.bone(r.bone).frames;
880
+ const A1 = full.bone(r.bone).frames;
881
+ const rotate = r.property === 'rotate';
882
+ const diff = (x: BoneWorld, y: BoneWorld): number[] => (rotate ? [shortestDegrees(worldRotation(x), worldRotation(y))] : [x.worldX - y.worldX, x.worldY - y.worldY]);
883
+ const dot = (u: number[], v: number[]): number => u.reduce((s, x, i) => s + x * v[i], 0);
884
+ const n = declared.times.length;
885
+ const drive: number[][] = [];
886
+ const took: number[][] = [];
887
+ let maxDrive = -1;
888
+ let maxDriveAt = 0;
889
+ for (let i = 0; i < n; i++) {
890
+ const d = diff(A1[i], A0[i]);
891
+ drive.push(d);
892
+ took.push(diff(A[i], A0[i]));
893
+ const m = Math.sqrt(dot(d, d));
894
+ if (m > maxDrive) {
895
+ maxDrive = m;
896
+ maxDriveAt = i;
897
+ }
898
+ }
899
+ const counted = drive.map((d, i) => (Math.sqrt(dot(d, d)) >= r.least_drive ? i : -1)).filter((i) => i >= 0);
900
+ const unit = rotate ? 'degrees' : 'px';
901
+ const what = { of: `bone "${r.bone}" ${r.property} under ${r.constraint_type} constraint "${r.constraint}"`, frames: n };
902
+ const driveFig = { [`largest_drive_${unit}`]: round6(maxDrive), largest_drive_at: frameText(declared, maxDriveAt) };
903
+ const note = rotate && maxDrive > 90 ? { note: `the largest drive exceeds 90 degrees: every difference is the signed shortest angle, so a drive beyond 180 degrees is not told from its complement` } : {};
904
+ if (counted.length === 0) {
905
+ return notMeasurable('follow', r.animation, `no frame's drive reaches the least drive ${r.least_drive} ${unit}: the constraint never asks the bone for that much`, { ...what, ...driveFig, [`least_drive_${unit}`]: r.least_drive, ...note });
906
+ }
907
+ let num = 0;
908
+ let den = 0;
909
+ for (const i of counted) {
910
+ num += dot(took[i], drive[i]);
911
+ den += dot(drive[i], drive[i]);
912
+ }
913
+ const F = num / den;
914
+ let maxRes = -1;
915
+ let maxResAt = counted[0];
916
+ for (const i of counted) {
917
+ const e = took[i].map((x, k) => x - F * drive[i][k]);
918
+ const res = Math.sqrt(dot(e, e));
919
+ if (res > maxRes) {
920
+ maxRes = res;
921
+ maxResAt = i;
922
+ }
923
+ }
924
+ return {
925
+ kind: 'follow',
926
+ animation: r.animation,
927
+ status: Math.abs(F - r.fraction) <= r.tolerance ? 'PASS' : 'FAIL',
928
+ ...what,
929
+ frames_counted: counted.length,
930
+ fraction_measured: round6(F),
931
+ fraction: r.fraction,
932
+ tolerance: r.tolerance,
933
+ [`least_drive_${unit}`]: r.least_drive,
934
+ ...driveFig,
935
+ [`largest_residual_${unit}`]: round6(maxRes),
936
+ largest_residual_at: frameText(declared, maxResAt),
937
+ ...note,
938
+ };
939
+ }
940
+
941
+ /** `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst max(ratio, 1/ratio) and where. */
942
+ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses): RequirementLine {
943
+ const mesh = meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment);
944
+ if (mesh === undefined) return notMeasurable('stretch', r.animation, `geometry.json's rest pose holds no mesh "${r.attachment}" on slot "${r.slot}", so no frame shows it`);
945
+ const [m] = stretchFigures([mesh], frames);
946
+ if (m.degenerate.length > 0) {
947
+ return notMeasurable('stretch', r.animation, `${m.degenerate.length} rest edge(s) of length 0, the first triangle ${m.degenerate[0].triangle} edge ${m.degenerate[0].edge.join('-')}: no ratio can be read off an edge with no rest length`, { triangles: m.triangles });
948
+ }
949
+ if (m.max === null || m.min === null) return notMeasurable('stretch', r.animation, `none of the ${p.times.length} frame(s) shows mesh "${r.attachment}" on slot "${r.slot}"`, { triangles: m.triangles });
950
+ const sMax = stretchSeverity(m.max.ratio);
951
+ const sMin = stretchSeverity(m.min.ratio);
952
+ const at = sMin > sMax ? m.min : m.max;
953
+ const severity = Math.max(sMax, sMin);
954
+ const fi = p.indices.indexOf(at.frame);
955
+ return {
956
+ kind: 'stretch',
957
+ animation: r.animation,
958
+ status: severity <= r.within_ratio ? 'PASS' : 'FAIL',
959
+ of: `mesh "${r.attachment}" on slot "${r.slot}"`,
960
+ frames: m.frames,
961
+ triangles: m.triangles,
962
+ severity: round6(severity),
963
+ worst: `triangle ${at.triangle} (vertices ${at.vertices.join(' ')}), edge ${at.edge.join('-')}, ratio ${round6(at.ratio)}`,
964
+ at: fi < 0 ? `frame ${at.frame}` : frameText(p, fi),
965
+ max_ratio: round6(m.max.ratio),
966
+ min_ratio: round6(m.min.ratio),
967
+ within_ratio: r.within_ratio,
968
+ };
969
+ }
970
+
971
+ // ---------------------------------------------------------------------------
972
+ // the seam between two parts (issue #111)
973
+ // ---------------------------------------------------------------------------
974
+
975
+ /** A part's art on the stage: its box's top-left in stage px and, per pixel of the box, 1 where its alpha is above {@link ART_ALPHA}. */
976
+ export interface PlacedArt {
977
+ x: number;
978
+ y: number;
979
+ width: number;
980
+ height: number;
981
+ data: Uint8Array;
982
+ }
983
+
984
+ /** The art of a part image placed at `x, y`: alpha above {@link ART_ALPHA}, the threshold every mesh is built from (`src/mesh.ts`). */
985
+ export function placedArt(x: number, y: number, img: Raster): PlacedArt {
986
+ const m = alphaAbove(img, ART_ALPHA);
987
+ return { x, y, width: m.width, height: m.height, data: m.data };
988
+ }
989
+
990
+ function artAt(m: PlacedArt, sx: number, sy: number): boolean {
991
+ const x = sx - m.x;
992
+ const y = sy - m.y;
993
+ return x >= 0 && y >= 0 && x < m.width && y < m.height && m.data[y * m.width + x] === 1;
994
+ }
995
+
996
+ /** One seam pair: `[ax, ay, bx, by]`, stage px — a an art pixel of the part, b of the neighbour. */
997
+ export type SeamPair = readonly [number, number, number, number];
998
+
999
+ /**
1000
+ * The seam between `part` and `neighbour` at the setup pose: every pair
1001
+ * (a, b) of 4-adjacent stage pixels with a an art pixel of `part` and b an art
1002
+ * pixel of `neighbour` that is not an art pixel of `part`. Where the two
1003
+ * overlap, the pixels both hold are not a seam: the part covers them. Order:
1004
+ * a row-major over the part's box, then right, down, left, up. This is the
1005
+ * definition `tools/real_compare.ts` measured #109's seam with (`seamPairs`).
1006
+ */
1007
+ export function seamPairs(part: PlacedArt, neighbour: PlacedArt): SeamPair[] {
1008
+ const out: SeamPair[] = [];
1009
+ const steps: ReadonlyArray<readonly [number, number]> = [
1010
+ [1, 0],
1011
+ [0, 1],
1012
+ [-1, 0],
1013
+ [0, -1],
1014
+ ];
1015
+ for (let y = 0; y < part.height; y++) {
1016
+ for (let x = 0; x < part.width; x++) {
1017
+ if (part.data[y * part.width + x] !== 1) continue;
1018
+ const ax = part.x + x;
1019
+ const ay = part.y + y;
1020
+ for (const [dx, dy] of steps) {
1021
+ if (artAt(neighbour, ax + dx, ay + dy) && !artAt(part, ax + dx, ay + dy)) out.push([ax, ay, ax + dx, ay + dy]);
1022
+ }
1023
+ }
1024
+ }
1025
+ return out;
1026
+ }
1027
+
1028
+ /**
1029
+ * The seam pairs of every `seam` requirement, keyed by its name, or one
1030
+ * refusal naming every problem, before anything is built. The pairs are read
1031
+ * off `parts.json`'s art, so with no `parts.json` there is no seam to read
1032
+ * (`REQUIREMENTS_SEAM`, quoting why there is none); a part `parts.json` does not
1033
+ * list is unresolved (`REQUIREMENTS_RESOLVES`); and two parts whose art does
1034
+ * not meet at the setup pose share no seam (`REQUIREMENTS_SEAM`). `art` is
1035
+ * asked only for parts `parts.json` lists.
1036
+ */
1037
+ export function resolveSeams(file: RequirementsFile, partNames: readonly string[] | null, noParts: string | null, art: (part: string) => PlacedArt): Map<string, SeamPair[]> {
1038
+ const problems: Problem[] = [];
1039
+ const out = new Map<string, SeamPair[]>();
1040
+ const cache = new Map<string, PlacedArt>();
1041
+ const artOf = (n: string): PlacedArt => {
1042
+ const hit = cache.get(n);
1043
+ if (hit !== undefined) return hit;
1044
+ const a = art(n);
1045
+ cache.set(n, a);
1046
+ return a;
1047
+ };
1048
+ for (const r of file.requirements) {
1049
+ if (r.kind !== 'seam') continue;
1050
+ const object = `requirement "${r.name}" (seam)`;
1051
+ if (partNames === null) {
1052
+ problems.push({ code: 'REQUIREMENTS_SEAM', object, detail: `a seam's pixels are read off parts.json's art at the setup pose, and none was read: ${noParts ?? 'no parts.json'}` });
1053
+ continue;
1054
+ }
1055
+ const missing = [r.part, r.neighbour].filter((n) => !partNames.includes(n));
1056
+ for (const n of missing) problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${n}", which parts.json does not list; it lists ${partNames.map((x) => `"${x}"`).join(', ') || 'none'}` });
1057
+ if (missing.length > 0) continue;
1058
+ const pairs = seamPairs(artOf(r.part), artOf(r.neighbour));
1059
+ if (pairs.length === 0) {
1060
+ problems.push({
1061
+ code: 'REQUIREMENTS_SEAM',
1062
+ object,
1063
+ detail: `no art pixel of "${r.part}" is 4-adjacent to an art pixel of "${r.neighbour}" that is not "${r.part}"'s (alpha above ${ART_ALPHA}, at the setup pose): the two parts share no seam to hold`,
1064
+ });
1065
+ continue;
1066
+ }
1067
+ out.set(r.name, pairs);
1068
+ }
1069
+ refuseIfAny(problems);
1070
+ return out;
1071
+ }
1072
+
1073
+ /** One attachment on one frame: its rest vertices (the setup pose's bones, no deform), its posed vertices (world, y up) and its triangles — what `rigc-geometry/1` writes for a region (two triangles) and a mesh alike. */
1074
+ export interface PosedAttachment {
1075
+ rest: readonly number[];
1076
+ posed: readonly number[];
1077
+ triangles: readonly number[];
1078
+ }
1079
+
1080
+ /**
1081
+ * A displacement reader for one attachment: a world point's displacement from
1082
+ * rest — the rest triangle holding it (the first in triangle order where two
1083
+ * share an edge) and the same barycentric mix of that triangle's posed
1084
+ * vertices, less the point — or null when no rest triangle holds it.
1085
+ */
1086
+ export function displacementOf(a: PosedAttachment): (w: readonly [number, number]) => [number, number] | null {
1087
+ const cell = 8;
1088
+ const buckets = new Map<string, number[]>();
1089
+ const X = (i: number): number => a.rest[2 * i];
1090
+ const Y = (i: number): number => a.rest[2 * i + 1];
1091
+ for (let t = 0; t < a.triangles.length / 3; t++) {
1092
+ const ix = [a.triangles[3 * t], a.triangles[3 * t + 1], a.triangles[3 * t + 2]];
1093
+ const [i0, i1] = [Math.floor(Math.min(...ix.map(X)) / cell), Math.floor(Math.max(...ix.map(X)) / cell)];
1094
+ const [j0, j1] = [Math.floor(Math.min(...ix.map(Y)) / cell), Math.floor(Math.max(...ix.map(Y)) / cell)];
1095
+ for (let j = j0; j <= j1; j++) {
1096
+ for (let i = i0; i <= i1; i++) {
1097
+ const list = buckets.get(`${i},${j}`);
1098
+ if (list === undefined) buckets.set(`${i},${j}`, [t]);
1099
+ else list.push(t);
1100
+ }
1101
+ }
1102
+ }
1103
+ // (v - u) x (w - u): twice the signed area of u, v, w.
1104
+ const cross = (ux: number, uy: number, vx: number, vy: number, wx: number, wy: number): number => (vx - ux) * (wy - uy) - (vy - uy) * (wx - ux);
1105
+ return (w) => {
1106
+ for (const t of buckets.get(`${Math.floor(w[0] / cell)},${Math.floor(w[1] / cell)}`) ?? []) {
1107
+ const [p, q, r] = [a.triangles[3 * t], a.triangles[3 * t + 1], a.triangles[3 * t + 2]];
1108
+ const area = cross(X(p), Y(p), X(q), Y(q), X(r), Y(r));
1109
+ const lp = cross(X(q), Y(q), X(r), Y(r), w[0], w[1]) / area;
1110
+ const lq = cross(X(r), Y(r), X(p), Y(p), w[0], w[1]) / area;
1111
+ const lr = cross(X(p), Y(p), X(q), Y(q), w[0], w[1]) / area;
1112
+ if (lp >= 0 && lq >= 0 && lr >= 0) {
1113
+ const x = lp * a.posed[2 * p] + lq * a.posed[2 * q] + lr * a.posed[2 * r];
1114
+ const y = lp * a.posed[2 * p + 1] + lq * a.posed[2 * q + 1] + lr * a.posed[2 * r + 1];
1115
+ return [x - w[0], y - w[1]];
1116
+ }
1117
+ }
1118
+ return null;
1119
+ };
1120
+ }
1121
+
1122
+ /** One frame of a seam: the attachment the part's slot and the neighbour's slot show on it, or null where the slot shows none. */
1123
+ export interface SeamFrame {
1124
+ part: PosedAttachment | null;
1125
+ neighbour: PosedAttachment | null;
1126
+ }
1127
+
1128
+ /**
1129
+ * `seam` (issue #111): per frame, each seam pair's opening — the distance
1130
+ * between the part's displacement at a's centre and the neighbour's at b's
1131
+ * centre ({@link displacementOf}), stage px: how far the two sides of the seam
1132
+ * moved apart or across — and the largest over every pair and frame, with its
1133
+ * pair and its frame. A pair is not read on a frame where either side's pixel
1134
+ * lies outside its attachment's rest geometry or its slot shows nothing
1135
+ * (counted, never read as 0); a frame on which no pair reads is not
1136
+ * measurable, and with none left the requirement is. The opening is an upper
1137
+ * bound on any gap: what shows there depends on what is drawn under the seam.
1138
+ * Stage pixel centres go to the world through the stage box and the one y door
1139
+ * ({@link stageToWorld}).
1140
+ */
1141
+ export function seamLine(r: SeamRequirement, pairs: readonly SeamPair[], frames: readonly SeamFrame[], p: Poses, stage: { x: number; y: number; height: number }): RequirementLine {
1142
+ const wa = pairs.map(([ax, ay]) => stageToWorld([ax + 0.5, ay + 0.5], stage));
1143
+ const wb = pairs.map(([, , bx, by]) => stageToWorld([bx + 0.5, by + 0.5], stage));
1144
+ let worst = -1;
1145
+ let wi = 0;
1146
+ let wp = 0;
1147
+ let unread = 0;
1148
+ const unmeasured: number[] = [];
1149
+ for (let i = 0; i < p.times.length; i++) {
1150
+ const f = frames[i];
1151
+ const dP = f.part === null ? null : displacementOf(f.part);
1152
+ const dN = f.neighbour === null ? null : displacementOf(f.neighbour);
1153
+ let read = 0;
1154
+ for (let k = 0; k < pairs.length; k++) {
1155
+ const da = dP === null ? null : dP(wa[k]);
1156
+ const db = da === null || dN === null ? null : dN(wb[k]);
1157
+ if (da === null || db === null) {
1158
+ unread++;
1159
+ continue;
1160
+ }
1161
+ read++;
1162
+ const o = Math.hypot(da[0] - db[0], da[1] - db[1]);
1163
+ if (o > worst) {
1164
+ worst = o;
1165
+ wi = i;
1166
+ wp = k;
1167
+ }
1168
+ }
1169
+ if (read === 0) unmeasured.push(p.indices[i]);
1170
+ }
1171
+ const of = `the seam between part "${r.part}" and part "${r.neighbour}"`;
1172
+ if (worst < 0) {
1173
+ return notMeasurable('seam', r.animation, `on every one of the ${p.times.length} frame(s) no seam pair reads: each pair's pixel lies outside its attachment's rest geometry, or its slot shows nothing`, { of, pairs: pairs.length, frames: p.times.length });
1174
+ }
1175
+ const [ax, ay, bx, by] = pairs[wp];
1176
+ return {
1177
+ kind: 'seam',
1178
+ animation: r.animation,
1179
+ status: worst <= r.within_px ? 'PASS' : 'FAIL',
1180
+ of,
1181
+ pairs: pairs.length,
1182
+ frames: p.times.length - unmeasured.length,
1183
+ frames_not_measurable: unmeasured,
1184
+ pair_frames_unread: unread,
1185
+ largest_px: round6(worst),
1186
+ at: frameText(p, wi),
1187
+ pair: `"${r.part}" pixel ${ax},${ay} against "${r.neighbour}" pixel ${bx},${by}`,
1188
+ within_px: r.within_px,
1189
+ };
1190
+ }
1191
+
1192
+ /** The bar a line was held to, as its FAIL detail states it. */
1193
+ function barText(r: Requirement): string {
1194
+ if (r.kind === 'contact' || r.kind === 'seam') return `<= ${r.within_px} px is required`;
1195
+ if (r.kind === 'aim') return `<= ${r.within_degrees} degrees is required`;
1196
+ if (r.kind === 'range') return `[${r.lo_degrees}, ${r.hi_degrees}] degrees is required`;
1197
+ if (r.kind === 'stretch') return `<= ${r.within_ratio} is required`;
1198
+ return `${r.fraction} ± ${r.tolerance} is required`;
1199
+ }
1200
+
1201
+ /** The figures a FAIL detail quotes, before its bar. */
1202
+ function failText(r: Requirement, l: RequirementLine): string {
1203
+ if (r.kind === 'contact') return `largest distance ${String(l.largest_px)} px at ${String(l.at)}, between ${String(l.of)} and ${String(l.against)}`;
1204
+ if (r.kind === 'aim') return `largest angle ${String(l.largest_degrees)} degrees at ${String(l.at)}, between ${String(l.of)} and the line to ${String(l.against)}`;
1205
+ if (r.kind === 'range') return `least ${String(l.least_degrees)} degrees at ${String(l.least_at)}, greatest ${String(l.greatest_degrees)} at ${String(l.greatest_at)}`;
1206
+ if (r.kind === 'stretch') return `max(ratio, 1/ratio) ${String(l.severity)} at ${String(l.at)}, ${String(l.worst)}`;
1207
+ if (r.kind === 'seam') return `largest opening ${String(l.largest_px)} px at ${String(l.at)}, ${String(l.pair)}`;
1208
+ return `measured fraction ${String(l.fraction_measured)} over ${String(l.frames_counted)} frame(s) that reach the least drive`;
1209
+ }
1210
+
1211
+ /** The problem a FAIL or a NOT MEASURABLE line puts in the report; null for PASS. */
1212
+ export function requirementProblem(r: Requirement, l: RequirementLine): Problem | null {
1213
+ const object = `requirement "${r.name}" (${r.kind}, animation "${r.animation}")`;
1214
+ if (l.status === 'PASS') return null;
1215
+ if (l.status === 'NOT MEASURABLE') return { code: 'CHECK_REQUIREMENT_MEASURABLE', object, detail: `NOT MEASURABLE — ${String(l.reason)}; a declared requirement that was not measured is not green` };
1216
+ return { code: 'CHECK_REQUIREMENT_MET', object, detail: `${failText(r, l)}; ${barText(r)}` };
1217
+ }
1218
+
1219
+ /** The summary: how many were declared, measured (PASS, FAIL) and not measurable, and the kinds the file did not declare. */
1220
+ export interface RequirementsSummary {
1221
+ declared: number;
1222
+ measured: number;
1223
+ pass: number;
1224
+ fail: number;
1225
+ not_measurable: number;
1226
+ not_declared: RequirementKind[];
1227
+ }
1228
+
1229
+ export function requirementsSummary(file: RequirementsFile, lines: ReadonlyMap<string, RequirementLine>): RequirementsSummary {
1230
+ const all = [...lines.values()];
1231
+ const pass = all.filter((l) => l.status === 'PASS').length;
1232
+ const fail = all.filter((l) => l.status === 'FAIL').length;
1233
+ const declaredKinds = new Set(file.requirements.map((r) => r.kind));
1234
+ return { declared: file.requirements.length, measured: pass + fail, pass, fail, not_measurable: all.length - pass - fail, not_declared: REQUIREMENT_KINDS.filter((k) => !declaredKinds.has(k)) };
1235
+ }
1236
+
1237
+ /** The summary as the console prints it. */
1238
+ export function summaryText(s: RequirementsSummary): string {
1239
+ return `requirements: ${s.declared} declared — ${s.measured} measured (${s.pass} PASS, ${s.fail} FAIL), ${s.not_measurable} NOT MEASURABLE; not declared: ${s.not_declared.length === 0 ? 'none' : s.not_declared.join(', ')}`;
1240
+ }