@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
package/src/check.ts ADDED
@@ -0,0 +1,2942 @@
1
+ /**
2
+ * The check stage: build a rig through rig-c, gate it under both
3
+ * profiles, render its idle, and measure the two things the gate cannot see —
4
+ * whether the setup pose IS the flat stack of parts (the seam), and whether the
5
+ * idle ends where it began (the loop).
6
+ *
7
+ * ⭐ **This is the one stage that drives a subprocess, and it does not own the
8
+ * spawn.** Every rigc step — build, validate, render — is rig-c's own CLI,
9
+ * never a re-implementation of it, because rigc's gate is the only oracle
10
+ * behind a skeleton (CLAUDE.md, "rig-c's gate is not optional here
11
+ * either"): the spine-core round trip where the runtime resolves beside rigc,
12
+ * rigc's own validator where it does not, and `check.json`'s `rigc_entry` says
13
+ * which ({@link readRigcEntry}). But `src/` is held to no child
14
+ * processes (`TY06`, with `src/comfy/` its one named exception), so the process
15
+ * is injected: `runCheck` takes a {@link RigcRunner}, `cli.ts` hands it one
16
+ * that spawns the binary {@link findRigc} located, and the selftest hands it
17
+ * the same. What this module does with the file system is read inputs and write
18
+ * the outputs listed below; it never reads the clock or the network.
19
+ *
20
+ * Input — a rig directory holding what the rig stage writes:
21
+ * `rig.json`, `motion.json` (with an `idle` animation), `parts.json`, and
22
+ * `parts/<name>.png` for every part (the last two may sit in a directory of
23
+ * their own, `partsHome`). It is a rig SPEC, which `rigc build` compiles, not
24
+ * a compiled skeleton. Since issue #77 the `idle` and `parts.json` may be
25
+ * missing — a rig merged from several rig-parts outputs has neither — and
26
+ * every line that reads the missing one says SKIP with the reason; the
27
+ * painting (`--source`) adds {@link SOURCE_LINE}. The inputs are read only;
28
+ * everything is written under the output directory (the idle's outputs only
29
+ * when there is an idle):
30
+ *
31
+ * | output | what |
32
+ * | --- | --- |
33
+ * | `build/` | `rigc build --profile spine-html --pack --page-edges free --pack-shape polygon` (or `pot`, `rect`, when the caller names them; {@link PAGE_EDGES}, {@link PACK_SHAPES}) — the packed atlas is the final artifact |
34
+ * | `gate_spine-html.txt` | that build's gate lines, verbatim — the one gate: rigc's `build` runs it once over the compile and once over the packed pages on disk, and `spine-html` holds every rule `spine` measures (selftest `CH09`) |
35
+ * | `idle_frames/` | `rigc render --animation idle --fps 12 --max 640 --geometry`: `frames.json` + `idle/f*.png` + `idle/geometry.json` (each mesh's skinned vertices, which `TEXTURE_STRETCH` reads, every attachment's posed triangles, which `TIP_OVER_ROOT` and both halves of `STILL_REGIONS_DARK` read since issues #118 and #123, and every bone's world transform, which the face half reads its head bone from) |
36
+ * | `contact.png` | rigc's own contact sheet of that render, copied out |
37
+ * | `motion_heat.png` | frame 0 in grey with each pixel's largest change across the idle in red |
38
+ * | `check.json` | the figures below and `PASS` |
39
+ * | `requirements/` | under `--requirements` only (issue #93): `as-declared/<animation>/`, and per follow `released/<name>/` and `full/<name>/` — rigc renders with `--geometry` ({@link measureRequirements}) |
40
+ *
41
+ * The acceptance bars are the reference implementation's (`check_rig.py`):
42
+ * both gate summaries `0 failed`; seam mean |d| <= 1.0 of 255 and at most 50
43
+ * pixels differing by more than 40; loop max |d| == 0.
44
+ *
45
+ * Beside them, six judgement lines (issue #11, and #31 for the sixth) measure
46
+ * what used to be left to an eye — `BREATH_VISIBLE`, `BLINK_NO_HOLE`,
47
+ * `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH` — from
48
+ * rigc renders of the idle (the whole rig, or a part alone through `--slot`),
49
+ * from the setup still with the blink held shut, from `motion.json` read as
50
+ * sines, or from the skinned mesh vertices the idle render's `--geometry`
51
+ * writes beside its frames (rig-c {@link RIGC_GEOMETRY_VERSION} or later,
52
+ * which `check` asks `rigc --version` for before it builds anything). Each reports SKIP,
53
+ * with the reason, when the rig has nothing for it to read. The instruments
54
+ * are `src/instruments.ts`; the bars are below and in AUTHORING §7. After
55
+ * them, one REPORTED line with no bar, `RECOMPOSITE_HOLES` (issue #25): the
56
+ * uncovered holes `assemble` recorded in `parts.json`'s `recomposite` block,
57
+ * copied into `check.json` because the seam cannot see them — it compares the
58
+ * setup pose with the flat stack of parts, and a pixel no part holds is
59
+ * missing from both. The
60
+ * part-alone renders and the shut-eye still are scratch (`_isolated/`,
61
+ * `_still/`), removed before the stage returns.
62
+ *
63
+ * Where this port deliberately differs from the reference, each written where
64
+ * it applies below: a gate summary is read with a pattern rather than the
65
+ * substring `"0 failed"` (which `"10 failed"` contains); a gate with no summary
66
+ * line is not green (Python's `all()` of nothing is `True`); the seam render's
67
+ * `--max` is the rig canvas's longest side rather than the literal 1216 (which
68
+ * was that for every rig it ever saw); the grey is read from rigc's
69
+ * `frames.json` rather than typed; the crop-to-world map comes from the rig's
70
+ * own stage box through `src/coords.ts` rather than an open-coded `-W/2`; and
71
+ * the last idle frame is refused unless it sits at t = duration.
72
+ */
73
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
74
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
75
+ import { measureRecomposite, type RecompositeFigures, recompositeRecord, sourceInRig } from './assemble.ts';
76
+ import { cropToSpineY } from './coords.ts';
77
+ import { PartsError, type Problem, refuseIfAny } from './errors.ts';
78
+ import { framesInside, IDLE_FPS } from './motion.ts';
79
+ import {
80
+ blinkFigures,
81
+ type GeometryPose,
82
+ type MeshRest,
83
+ blinkTracks,
84
+ type ArtMask,
85
+ type BoneTrack,
86
+ type BoneWorld,
87
+ boxLabel,
88
+ crossingOf,
89
+ type FrameMap,
90
+ chainFigures,
91
+ EYE_TAGS,
92
+ eyeBones,
93
+ FACE_FEATURE_TAGS,
94
+ FACE_TAGS,
95
+ FEET_TAGS,
96
+ frameBox,
97
+ heatField,
98
+ partsTagged,
99
+ type PixelBox,
100
+ type PosedTriangles,
101
+ regionHeat,
102
+ partWorldBox,
103
+ type RigRegion,
104
+ setupToFrame,
105
+ stillReading,
106
+ stillTolerance,
107
+ stretchFigures,
108
+ stretchSeverity,
109
+ sweptBox,
110
+ SWING_TAGS,
111
+ topmostIndex,
112
+ TORSO_TAGS,
113
+ halfTravels,
114
+ type WorldBox,
115
+ } from './instruments.ts';
116
+ import { type PartRecord, type PartsFile, readParts } from './parts.ts';
117
+ import {
118
+ aimLine,
119
+ contactLine,
120
+ declareConsumerDriven,
121
+ followLine,
122
+ forceMix,
123
+ mutedLine,
124
+ mutedOnly,
125
+ placeTargets,
126
+ type Poses,
127
+ rangeLine,
128
+ readRequirements,
129
+ type RequirementLine,
130
+ type RequirementsFile,
131
+ requirementProblem,
132
+ type RequirementsSummary,
133
+ requirementsSummary,
134
+ resolveRequirements,
135
+ rigFacts,
136
+ placedArt,
137
+ type PosedAttachment,
138
+ resolveSeams,
139
+ type SeamFrame,
140
+ seamLine,
141
+ type SeamPair,
142
+ stretchRequirementLine,
143
+ } from './requirements.ts';
144
+ import { alphaComposite } from './raster/composite.ts';
145
+ import { readPng, writePng } from './raster/png.ts';
146
+ import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
147
+ import type { BaseTag } from './tags.ts';
148
+ import { type ScaleTranslate, warpAffine } from './raster/warp.ts';
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // the bars
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /** Seam: mean max-channel |d| over the frame, in levels of 255, at most this. */
155
+ export const SEAM_MEAN_BAR = 1.0;
156
+ /**
157
+ * Seam: pixels whose max-channel |d| exceeds {@link SEAM_PX_LEVEL}, at most
158
+ * this many **rig** pixels — the frame count divided by s², s the frame scale
159
+ * the setup-pose still renders at (issue #216). The frame holds s² pixels per
160
+ * rig pixel, so a stack that parts from the render over a fixed area of the
161
+ * rig reads s² times as many frame pixels: on the public sample a part moved
162
+ * 2 rig px read 400, 891 and 1,560 frame px over 40 at s = 1.0, 1.5 and 2.0,
163
+ * and 400, 396 and 390 divided by s²; the uncut sample 3, 3 and 7 frame px
164
+ * [measured for issue #216, a scratch run, not tracked]. At s = 1 the bar is
165
+ * the reference's frame count.
166
+ */
167
+ export const SEAM_PX_BAR = 50;
168
+ export const SEAM_PX_LEVEL = 40;
169
+ /** Seam: the second, reported-only level. */
170
+ export const SEAM_PX_LEVEL_HIGH = 80;
171
+ /** Loop: frame 0 vs the frame at t = duration, largest |d|, exactly this. */
172
+ export const LOOP_MAX_BAR = 0;
173
+
174
+ /** The idle render the reference makes, and the one issue #1's loop is encoded from. Defined beside the blink it has to see (`src/motion.ts`). */
175
+ export { IDLE_FPS };
176
+ export const IDLE_MAX_PX = 640;
177
+ /** The throwaway setup-pose animation: one key at 0 and one at this time, rendered at this rate. */
178
+ const STILL_DURATION = 0.1;
179
+ const STILL_FPS = 10;
180
+
181
+ /**
182
+ * The judgement lines' bars (issue #11). Each is derived from the two public
183
+ * examples by one stated rule, and AUTHORING §7 quotes what each example
184
+ * measures beside it: a floor is half the weaker example's figure, a ceiling
185
+ * twice the worse one's, so the weaker example clears every bar by a factor of
186
+ * two; a bar the model itself fixes (a still part does not move, a lag is
187
+ * above 0, a hole is 0 pixels) is that value, not a margin. The still regions'
188
+ * bar is the model's too — nothing moves — read up to the arithmetic's own
189
+ * rounding (`stillTolerance` in `src/instruments.ts`, issue #123).
190
+ */
191
+ export const BREATH_TORSO_MEAN_FLOOR = 3.809;
192
+ export const BREATH_FEET_MAX_CEILING = 0;
193
+ export const BLINK_HOLE_BAR = 0;
194
+ /** The reported colour-patch level: the seam's own "differs by more than 40". */
195
+ export const BLINK_PATCH_LEVEL = SEAM_PX_LEVEL;
196
+ /** The smallest lag the reading resolves, in cycles: phases are written to three places. */
197
+ export const CHAIN_LAG_MIN_STEP = 0.001;
198
+ export const TIP_RATIO_FLOOR = 1.4725;
199
+ /**
200
+ * `TEXTURE_STRETCH`'s ceiling on max(ratio, 1/ratio) (issue #31): the worse
201
+ * example's figure (demo, 1.388) squared. The ceiling rule is "twice the worse
202
+ * one's", and it is applied to the quantity whose zero means "nothing
203
+ * happened", as it is for heat: for an edge ratio that is |ln r|, so twice
204
+ * |ln 1.388| is ln(1.388²). Doubling the ratio itself (2.776) was rejected —
205
+ * its zero is not at 1, and it would pass an edge drawn at 2.7 times its rest
206
+ * length, the mutant the line exists to catch included.
207
+ */
208
+ export const TEXTURE_STRETCH_CEILING = 1.926544;
209
+
210
+ /** The first rig-c whose `render` writes `geometry.json` (`--geometry`, rig-c 1.4.0's changelog). */
211
+ export const RIGC_GEOMETRY_VERSION = '1.4.0';
212
+ /** The geometry export's file name inside a frame set directory, and the format tag it carries. */
213
+ export const GEOMETRY_FILE = 'geometry.json';
214
+ export const GEOMETRY_SPEC = 'rigc-geometry/1';
215
+
216
+ /** Issue #2's yardstick, measured on the Spine example export `spineboy.png` (alpha > 0). */
217
+ export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha > 0)';
218
+
219
+ /**
220
+ * What a packed page's edges may be: rig-c's `build --pack --page-edges`,
221
+ * handed to it verbatim (the flag is in rig-c's CLI since 1.4.0, the
222
+ * floor `check` already requires; 1.5.0 made `free` search every width).
223
+ * `pot` is a power of two on both edges; `free` is the least-area page the
224
+ * placement needs, at the cost rigc's help states: region attachments sample
225
+ * within 1 LSB of the loose build rather than exactly.
226
+ *
227
+ * `free` is the default here although rigc's own default is `pot`: the packed
228
+ * page is this package's final artifact, and on the two public examples `free`
229
+ * takes the page from 1024x2048 to 967x1338 (covered 56.3 % -> 91.2 %) and
230
+ * from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1, 2.0.3, 2.1.3 and 2.10.1 (`rect`) alike. The cost was
231
+ * measured on the same two builds. Each of the 49 idle frames differs from the
232
+ * `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
233
+ * px over all 49). On sample that moves STILL_REGIONS_DARK's reported
234
+ * screen-space face mean from 11.475 to 11.472; no bar or status moves. The
235
+ * atlas rigc writes says `filter: Linear, Linear` and has no `repeat` line, so
236
+ * a consumer that mipmaps or repeats the page itself is the one that wants
237
+ * `pot`. AUTHORING §5, *Page edges*, holds the tables.
238
+ */
239
+ export const PAGE_EDGES = ['pot', 'free'] as const;
240
+ export type PageEdges = (typeof PAGE_EDGES)[number];
241
+ export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
242
+
243
+ /**
244
+ * What two packed rectangles may share: rig-c's `build --pack
245
+ * --pack-shape`, handed to it verbatim (in rig-c's CLI since 2.1.0; this
246
+ * package's range starts at 2.15.0). `rect` keeps every region's cell apart;
247
+ * `polygon` packs a region that only meshes draw by its emitted hull, so a
248
+ * neighbour may sit inside its rectangle where the hull is not, with the
249
+ * padding kept between footprints — a region attachment stays its rectangle
250
+ * (rigc's help).
251
+ *
252
+ * `polygon` is the default here although rigc's own default is `rect`, for the
253
+ * reason {@link DEFAULT_PAGE_EDGES} is `free`: the packed page is this
254
+ * package's final artifact, rigc gates the footprints on the pages on disk
255
+ * (`A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP`), and the cost is the class already
256
+ * accepted for `free`. Measured with rig-c 2.1.3, and again with 2.10.1,
257
+ * whose pages, atlases and idle frames are byte-identical to it (the full entry,
258
+ * spine-core 4.3.13 beside it), `rig-parts build` on the two public examples
259
+ * under `--page-edges free`: `polygon` takes demo's page from 967x1338 (91.2 %
260
+ * covered) to 922x1348 (95.0 %), 3.9 % less area, and sample's from 479x1166
261
+ * (93.4 %) to 477x1151 (95.0 %), 1.7 % less. Each of the 49 idle frames at 12
262
+ * fps differs from the `rect` build's by at most 1 level in any channel (demo
263
+ * 2,594 px, sample 740 px over all 49), and no figure in `check.json` but
264
+ * `pack_mode` moves. Under `--page-edges pot` both shapes give the same page
265
+ * size and the same frames. AUTHORING §5, *Pack shape*, holds the tables.
266
+ * `--pack-shape rect` writes the page and atlas rigc 2.0.3 wrote for 0.7.0
267
+ * (measured byte for byte on both examples; selftest `CK53` holds rect to
268
+ * rigc's own rect page).
269
+ */
270
+ export const PACK_SHAPES = ['rect', 'polygon'] as const;
271
+ export type PackShape = (typeof PACK_SHAPES)[number];
272
+ export const DEFAULT_PACK_SHAPE: PackShape = 'polygon';
273
+
274
+ /** How a packed build packs: its `--page-edges` and its `--pack-shape`, each handed to rigc verbatim. */
275
+ export interface PackMode {
276
+ pageEdges: PageEdges;
277
+ packShape: PackShape;
278
+ }
279
+
280
+ /** Both defaults, {@link DEFAULT_PAGE_EDGES} and {@link DEFAULT_PACK_SHAPE}. */
281
+ export const DEFAULT_PACK_MODE: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE };
282
+
283
+ /** The packed build's rigc arguments after `--out <dir>`, as every stage that packs passes them. */
284
+ export function packedBuildArgs(mode: PackMode): string[] {
285
+ return ['--profile', 'spine-html', '--pack', '--page-edges', mode.pageEdges, '--pack-shape', mode.packShape];
286
+ }
287
+
288
+ /** The packed build as a label: the rigc command line that was run, after `rigc`. */
289
+ export function packedBuildLabel(mode: PackMode): string {
290
+ return `build ${packedBuildArgs(mode).join(' ')}`;
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // the rigc process, injected
295
+ // ---------------------------------------------------------------------------
296
+
297
+ export interface RigcCall {
298
+ /** Exit status; a process that did not start is reported by the runner as a non-zero status with the reason in `out`. */
299
+ status: number;
300
+ /** stdout then stderr. */
301
+ out: string;
302
+ }
303
+
304
+ export type RigcRunner = (args: readonly string[]) => RigcCall;
305
+
306
+ /** A line in which a rigc run that stopped says why: Bun's own `error: …`, rigc's `rigc <command>: …` refusals, a `usage: …` line. */
307
+ const CAUSE = /^(error: |rigc[\w -]*: |usage: )/;
308
+
309
+ /**
310
+ * What a rigc run that exited non-zero said about why, for a refusal whose own
311
+ * filter (FAIL lines, the assertion summary) matched nothing: every line that
312
+ * names a cause, or, when none does, the last three non-empty lines. Measured
313
+ * against rig-c 2.0.3: its `cli.ts` run by path without
314
+ * `@esotericsoftware/spine-core` beside it dies at import with one
315
+ * `error: Cannot find module '@esotericsoftware/spine-core' from …` line, and its
316
+ * core entry refuses `validate` with one `rigc validate: …` line, neither of
317
+ * which a gate filter reads. The last lines and not the first are the fallback
318
+ * because `rigc build` prints its header and inputs before it can stop. Empty
319
+ * only when the run printed nothing at all.
320
+ */
321
+ export function causeLines(out: string): string[] {
322
+ const lines = out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
323
+ const named = lines.filter((l) => CAUSE.test(l));
324
+ return named.length > 0 ? named : lines.slice(-3);
325
+ }
326
+
327
+ /** The two entries rig-c's launcher (`bin/rigc.cjs`, rig-c 2.0.0 and later) runs. */
328
+ export const RIGC_ENTRIES = ['cli.ts', 'cli_core.ts'] as const;
329
+ export type RigcEntry = (typeof RIGC_ENTRIES)[number];
330
+
331
+ /** The first rig-c whose `--version` names the entry that ran (its launcher's stderr line; rig-c 2.0.0's changelog, issue #1061 there). */
332
+ export const RIGC_ENTRY_VERSION = '2.0.0';
333
+
334
+ /**
335
+ * Which rigc entry gated the build, as `check.json` records it: `cli.ts`, the
336
+ * spine-core round trip, with the runtime's version; `cli_core.ts`, rigc's own
337
+ * validator over its model document, with no runtime (`spine_core` null).
338
+ */
339
+ export interface RigcEntryRecord {
340
+ entry: RigcEntry;
341
+ spine_core: string | null;
342
+ }
343
+
344
+ /** The launcher's two `entry:` lines, whole, as rig-c 2.0.3's `bin/rigc.cjs` writes them. */
345
+ const ENTRY_FULL = /^entry: cli\.ts — @esotericsoftware\/spine-core (\S+) present$/;
346
+ const ENTRY_CORE = /^entry: cli_core\.ts — @esotericsoftware\/spine-core absent — /;
347
+
348
+ /**
349
+ * The entry a `rigc --version` run names. A run with no `entry:` line is a
350
+ * rigc older than {@link RIGC_ENTRY_VERSION}, or not rigc's launcher, and is
351
+ * refused `CHECK_RIGC_ENTRY`; an `entry:` line in neither form above is refused
352
+ * `CHECK_RIGC_ENTRY_READS`, naming the line — a third entry is not read as
353
+ * either of the two.
354
+ */
355
+ export function readRigcEntry(versionOut: string): RigcEntryRecord {
356
+ const lines = versionOut.split('\n').map((l) => l.trim());
357
+ const line = lines.find((l) => l.startsWith('entry:'));
358
+ if (line === undefined) {
359
+ refuseIfAny([
360
+ {
361
+ code: 'CHECK_RIGC_ENTRY',
362
+ object: '`rigc --version`',
363
+ detail: `printed ${JSON.stringify(versionOut.trim())} and no \`entry:\` line; rig-c ${RIGC_ENTRY_VERSION} or later is required — its launcher names the entry that ran, and check.json records which one gated the build (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
364
+ },
365
+ ]);
366
+ }
367
+ const at = line as string;
368
+ const full = ENTRY_FULL.exec(at);
369
+ if (full !== null) return { entry: 'cli.ts', spine_core: full[1] };
370
+ if (ENTRY_CORE.test(at)) return { entry: 'cli_core.ts', spine_core: null };
371
+ refuseIfAny([
372
+ {
373
+ code: 'CHECK_RIGC_ENTRY_READS',
374
+ object: `rigc's entry line ${JSON.stringify(at)}`,
375
+ detail: 'reads as neither "entry: cli.ts — @esotericsoftware/spine-core <version> present" nor "entry: cli_core.ts — @esotericsoftware/spine-core absent — …"; one of the two is required',
376
+ },
377
+ ]);
378
+ throw new Error('unreachable');
379
+ }
380
+
381
+ /**
382
+ * Where the `rigc` binary is: `node_modules/.bin/rigc` in `from` or any
383
+ * directory above it (a clone has it beside the package; an install has it in
384
+ * the consumer's `node_modules/.bin`), then every directory on `PATH`. A miss
385
+ * is refused naming every place looked.
386
+ */
387
+ export function findRigc(from: string, pathVar: string): string {
388
+ const looked: string[] = [];
389
+ let dir = resolve(from);
390
+ for (;;) {
391
+ const bin = join(dir, 'node_modules', '.bin', 'rigc');
392
+ looked.push(bin);
393
+ if (existsSync(bin)) return bin;
394
+ const up = dirname(dir);
395
+ if (up === dir) break;
396
+ dir = up;
397
+ }
398
+ for (const p of pathVar.split(delimiter).filter((s) => s !== '')) {
399
+ const bin = join(p, 'rigc');
400
+ if (existsSync(bin)) return bin;
401
+ }
402
+ refuseIfAny([
403
+ {
404
+ code: 'CHECK_RIGC_PRESENT',
405
+ object: 'the rig-c CLI `rigc`',
406
+ detail: `found at none of ${looked.length} node_modules/.bin/rigc path(s) from ${resolve(from)} up (nearest ${looked[0]}) nor on PATH; rig-c is this package's dependency — \`bun install\` puts it at node_modules/.bin/rigc`,
407
+ },
408
+ ]);
409
+ return '';
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // inputs
414
+ // ---------------------------------------------------------------------------
415
+
416
+ export interface StageBox {
417
+ x: number;
418
+ y: number;
419
+ width: number;
420
+ height: number;
421
+ }
422
+
423
+ /** What `--parts` is, said once for the refusals and the help. */
424
+ export const PARTS_HOME_SENTENCE = '--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build\'s --out, whose rig is <out>/rig); without --parts it is --rig';
425
+
426
+ /**
427
+ * What `check` does without `--parts` when the rig directory holds no
428
+ * `parts.json` (issue #77), said once for the help and the SKIP lines.
429
+ */
430
+ export const NO_PARTS_SENTENCE = 'without --parts, a rig directory holding no parts.json is measured without one: the seam and every line that reads parts.json say SKIP, by name — unless the directory above --rig holds parts.json (build\'s layout), which is refused naming it';
431
+
432
+ /**
433
+ * What the painting `--source` names is compared with (issue #77), for the
434
+ * refusals and the help: assemble's own resample of it onto the rig canvas.
435
+ */
436
+ export const SOURCE_SENTENCE = '--source names the painting the rig was made from: the stage size itself, or any size assemble takes to the stage (width and height each the painting\'s times one rig_scale, truncated — parts.json\'s scale_rig_per_source when parts.json is read), resampled onto the stage as assemble resamples it (lanczos3, alpha dropped)';
437
+
438
+ /** The painting `--source` names, read and checked against the stage before anything is built. */
439
+ export interface SourceInput {
440
+ path: string;
441
+ painting: Raster;
442
+ }
443
+
444
+ export interface CheckInputs {
445
+ rigDir: string;
446
+ rigPath: string;
447
+ motionPath: string;
448
+ partsPath: string;
449
+ partsDir: string;
450
+ rig: Record<string, unknown>;
451
+ motion: Record<string, unknown>;
452
+ /** Null when the rig directory holds no `parts.json` and no `--parts` was named (issue #77); {@link noParts} says why. */
453
+ parts: PartsFile | null;
454
+ /** Why `parts` is null, as the SKIP lines say it; null when `parts.json` was read. */
455
+ noParts: string | null;
456
+ stage: StageBox;
457
+ rootBone: string;
458
+ /** Null when `motion.json` declares no `idle` animation (issue #77); {@link noIdle} says why. */
459
+ idleDuration: number | null;
460
+ /** Why `idleDuration` is null, as the SKIP lines say it; null when there is an idle. */
461
+ noIdle: string | null;
462
+ /** The painting `--source` named, or null without `--source`. */
463
+ source: SourceInput | null;
464
+ }
465
+
466
+ /** The inputs every line that reads both the parts and the idle takes: both present. */
467
+ export type FullInputs = CheckInputs & { parts: PartsFile; idleDuration: number };
468
+
469
+ /**
470
+ * Whether a painting `w`x`h` can be the one assemble made a `W`x`H` rig canvas
471
+ * from: assemble's `checkGeometry` makes the canvas `trunc(w * S)` x
472
+ * `trunc(h * S)` for `rig_scale` S, so with S known (parts.json's
473
+ * `scale_rig_per_source`) both must come out; with S unknown some S must
474
+ * satisfy both, which is `W h < (H + 1) w` and `H w < (W + 1) h` in integers.
475
+ * The same size is S = 1. Null when it can; otherwise the refusal's detail.
476
+ */
477
+ export function sourceSizeProblem(w: number, h: number, stage: StageBox, scale: number | null): string | null {
478
+ const { width: W, height: H } = stage;
479
+ const whole = (v: number): boolean => Number.isInteger(v) && v > 0;
480
+ if (!whole(W) || !whole(H)) return `the stage (rig.json's "skeleton") is ${W}x${H}; whole positive pixel counts are required to compare it with a painting pixel for pixel`;
481
+ if (scale !== null) {
482
+ if (Math.trunc(w * scale) === W && Math.trunc(h * scale) === H) return null;
483
+ return `is ${w}x${h}, which parts.json's scale_rig_per_source ${scale} takes to ${Math.trunc(w * scale)}x${Math.trunc(h * scale)}; the stage is ${W}x${H}, so the painting assemble was given is required`;
484
+ }
485
+ if (W * h < (H + 1) * w && H * w < (W + 1) * h) return null;
486
+ return `is ${w}x${h}; no single rig_scale takes it to the ${W}x${H} stage (assemble truncates width and height times the same scale), so the painting is not this rig's — ${SOURCE_SENTENCE}`;
487
+ }
488
+
489
+ function isRecord(v: unknown): v is Record<string, unknown> {
490
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
491
+ }
492
+
493
+ function readJson(path: string, problems: Problem[]): Record<string, unknown> | null {
494
+ if (!existsSync(path)) {
495
+ problems.push({ code: 'CHECK_INPUT_PRESENT', object: path, detail: 'no such file; the rig directory holds rig.json, motion.json, parts.json and parts/' });
496
+ return null;
497
+ }
498
+ try {
499
+ const v: unknown = JSON.parse(readFileSync(path, 'utf8'));
500
+ if (isRecord(v)) return v;
501
+ problems.push({ code: 'CHECK_INPUT_IS_JSON', object: path, detail: `holds ${JSON.stringify(v)?.slice(0, 40)}; an object is required` });
502
+ } catch (err) {
503
+ problems.push({ code: 'CHECK_INPUT_IS_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` });
504
+ }
505
+ return null;
506
+ }
507
+
508
+ /**
509
+ * Read and cross-check the rig directory. Every problem is collected before
510
+ * one refusal. `partsHome` is the directory holding `parts.json` and `parts/`
511
+ * when they do not sit beside `rig.json` — `build` keeps the parts at the top
512
+ * of its output and the rig under `rig/`; absent, it is the rig directory, and
513
+ * there (issue #77) a `parts.json` may be missing: the rig is then measured
514
+ * without parts ({@link NO_PARTS_SENTENCE}). A named `partsHome` with no
515
+ * `parts.json` is refused, as is a missing one whose directory's parent holds
516
+ * `parts.json` — build's layout, where the miss is `--parts` left off. An
517
+ * `idle` may be missing too; an `idle` that is there must have a positive
518
+ * duration. `sourcePath` is `--source`, read and held to the stage here, so a
519
+ * painting that is not this rig's is refused before anything is built.
520
+ */
521
+ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string): CheckInputs {
522
+ const dir = resolve(rigDir);
523
+ const named = partsHome !== undefined;
524
+ const home = resolve(partsHome ?? rigDir);
525
+ const problems: Problem[] = [];
526
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
527
+ refuseIfAny([{ code: 'CHECK_INPUT_PRESENT', object: dir, detail: 'is not a directory; --rig names the directory holding rig.json and motion.json (and parts.json and parts/, or --parts names where they are)' }]);
528
+ }
529
+ const rigPath = join(dir, 'rig.json');
530
+ const motionPath = join(dir, 'motion.json');
531
+ const partsPath = join(home, 'parts.json');
532
+ const partsDir = join(home, 'parts');
533
+ if (home !== dir && (!existsSync(home) || !statSync(home).isDirectory())) {
534
+ refuseIfAny([{ code: 'CHECK_INPUT_PRESENT', object: home, detail: `is not a directory; ${PARTS_HOME_SENTENCE}` }]);
535
+ }
536
+ const rig = readJson(rigPath, problems);
537
+ const motion = readJson(motionPath, problems);
538
+ let parts: PartsFile | null = null;
539
+ let noParts: string | null = null;
540
+ if (!existsSync(partsPath)) {
541
+ // The usual miss is passing parts/ itself, or leaving --parts off after build; the parent is then named.
542
+ const parent = dirname(home);
543
+ const parentHolds = existsSync(join(parent, 'parts.json'));
544
+ if (named || parentHolds) {
545
+ const hint = parentHolds ? ` — ${parent} holds parts.json, so --parts ${parent} is the directory meant` : '';
546
+ problems.push({
547
+ code: 'CHECK_INPUT_PRESENT',
548
+ object: partsPath,
549
+ detail: `no such file; ${PARTS_HOME_SENTENCE} (the seam check composites the parts parts.json lists)${hint}`,
550
+ });
551
+ } else noParts = `no parts.json: ${partsPath} does not exist and no --parts was named, so there are no parts to composite and no See-through tags to choose regions by`;
552
+ }
553
+ else {
554
+ try {
555
+ parts = readParts(partsPath);
556
+ } catch (err) {
557
+ if (err instanceof PartsError) problems.push(...err.problems);
558
+ else throw err;
559
+ }
560
+ }
561
+ if (parts !== null) {
562
+ for (const p of parts.parts) {
563
+ const png = join(partsDir, `${p.name}.png`);
564
+ if (!existsSync(png)) {
565
+ problems.push({ code: 'CHECK_PART_PNG_PRESENT', object: `part "${p.name}"`, detail: `${png} does not exist; parts.json lists it` });
566
+ continue;
567
+ }
568
+ const im = readPng(png);
569
+ if (im.width !== p.w || im.height !== p.h) {
570
+ problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `part "${p.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${p.w}x${p.h}` });
571
+ }
572
+ }
573
+ }
574
+ let stage: StageBox | null = null;
575
+ let rootBone: string | null = null;
576
+ if (rig !== null) {
577
+ const s = rig.skeleton;
578
+ const nums = isRecord(s) && ['x', 'y', 'width', 'height'].every((k) => typeof s[k] === 'number' && Number.isFinite(s[k]));
579
+ if (!nums) {
580
+ problems.push({ code: 'CHECK_RIG_STAGE_PRESENT', object: `${rigPath} field "skeleton"`, detail: `is ${JSON.stringify(s)}; the stage box { x, y, width, height } is required — it is where parts.json's crop pixels sit in Spine's world` });
581
+ } else {
582
+ stage = { x: s.x as number, y: s.y as number, width: s.width as number, height: s.height as number };
583
+ if (parts !== null && (stage.width !== parts.rig_size[0] || stage.height !== parts.rig_size[1])) {
584
+ problems.push({
585
+ code: 'CHECK_RIG_STAGE_IS_THE_CANVAS',
586
+ object: `${rigPath} field "skeleton"`,
587
+ detail: `is ${stage.width}x${stage.height}; parts.json's rig_size is ${parts.rig_size[0]}x${parts.rig_size[1]}, and the seam compares the two pixel for pixel`,
588
+ });
589
+ }
590
+ }
591
+ const slots = new Set((Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name));
592
+ if (parts !== null) {
593
+ for (const p of parts.parts) {
594
+ if (!slots.has(p.name)) {
595
+ problems.push({
596
+ code: 'CHECK_PART_SLOT_PRESENT',
597
+ object: `part "${p.name}"`,
598
+ detail: `${rigPath} has no slot named "${p.name}"; the rig stage draws every part by the slot of its own name, and the judgement lines render a part alone by that slot`,
599
+ });
600
+ }
601
+ }
602
+ }
603
+ const bones = rig.bones;
604
+ const first: unknown = Array.isArray(bones) ? bones[0] : undefined;
605
+ if (isRecord(first) && typeof first.name === 'string') rootBone = first.name;
606
+ else problems.push({ code: 'CHECK_RIG_ROOT_BONE', object: `${rigPath} field "bones"`, detail: 'has no first bone with a name; the setup-pose render keys the root bone' });
607
+ }
608
+ let idleDuration: number | null = null;
609
+ let noIdle: string | null = null;
610
+ if (motion !== null) {
611
+ const anims = motion.animations;
612
+ const idle = isRecord(anims) ? anims.idle : undefined;
613
+ if (idle === undefined) {
614
+ noIdle = `no idle: ${motionPath} field "animations" holds ${isRecord(anims) ? `[${Object.keys(anims).join(', ')}]` : JSON.stringify(anims) ?? 'nothing'} and no "idle", so there is no loop to close and no idle frame to read`;
615
+ } else if (!isRecord(idle)) {
616
+ problems.push({ code: 'CHECK_IDLE_PRESENT', object: `${motionPath} animation "idle"`, detail: `is ${JSON.stringify(idle)}; an animation object is required — the loop is measured on it` });
617
+ } else if (!(typeof idle.duration === 'number' && idle.duration > 0)) {
618
+ problems.push({ code: 'CHECK_IDLE_PRESENT', object: `${motionPath} animation "idle" field "duration"`, detail: `is ${JSON.stringify(idle.duration)}; a positive number of seconds is required` });
619
+ } else idleDuration = idle.duration;
620
+ }
621
+ let source: SourceInput | null = null;
622
+ if (sourcePath !== undefined) {
623
+ const path = resolve(sourcePath);
624
+ if (!existsSync(path) || !statSync(path).isFile()) {
625
+ problems.push({ code: 'CHECK_INPUT_PRESENT', object: path, detail: `no such file; ${SOURCE_SENTENCE}` });
626
+ } else {
627
+ try {
628
+ source = { path, painting: readPng(path) };
629
+ } catch (err) {
630
+ problems.push({ code: 'CHECK_SOURCE_PNG', object: path, detail: `does not read as a PNG: ${(err as Error).message.split('\n')[0]}; ${SOURCE_SENTENCE}` });
631
+ }
632
+ }
633
+ if (source !== null && stage !== null) {
634
+ const why = sourceSizeProblem(source.painting.width, source.painting.height, stage, parts === null ? null : parts.scale_rig_per_source);
635
+ if (why !== null) problems.push({ code: 'CHECK_SOURCE_SIZE', object: `--source ${path}`, detail: why });
636
+ }
637
+ }
638
+ refuseIfAny(problems);
639
+ return {
640
+ rigDir: dir,
641
+ rigPath,
642
+ motionPath,
643
+ partsPath,
644
+ partsDir,
645
+ rig: rig as Record<string, unknown>,
646
+ motion: motion as Record<string, unknown>,
647
+ parts,
648
+ noParts,
649
+ stage: stage as StageBox,
650
+ rootBone: rootBone as string,
651
+ idleDuration,
652
+ noIdle,
653
+ source,
654
+ };
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // gate lines
659
+ // ---------------------------------------------------------------------------
660
+
661
+ /**
662
+ * The `here:` line rig-c's core entry (`cli_core.ts`, 2.0.0 and later)
663
+ * prints after its assertion summary, saying which rules ran and that the
664
+ * spine-core round trip did not; `cli.ts` prints none.
665
+ */
666
+ const HERE_LINE = /^\.\.\s+here: /;
667
+
668
+ /**
669
+ * `check_rig.py`'s filter for the build: summary, stats and pack lines, and
670
+ * anything that failed — and, under rigc's core entry, its `here:` line, so the
671
+ * gate file says which validator ran.
672
+ */
673
+ export function buildGateLines(out: string): string[] {
674
+ return out
675
+ .split('\n')
676
+ .filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:') || HERE_LINE.test(l.trim()))) || l.includes('FAIL') || l.includes('compile error'));
677
+ }
678
+
679
+ /**
680
+ * The rules a rigc run measured — every `PASS` and `FAIL` line's rule name, in
681
+ * the order printed, each once. A SKIP, a PROF line and a summary are not
682
+ * measurements. The selftest's `CH09` compares a build's set with
683
+ * `rigc validate --profile spine`'s on the same build.
684
+ */
685
+ export function measuredRules(out: string): string[] {
686
+ const seen: string[] = [];
687
+ for (const m of out.matchAll(/^ {2}(?:PASS|FAIL) {2}([A-Z][A-Z0-9_]*)/gm)) if (!seen.includes(m[1])) seen.push(m[1]);
688
+ return seen;
689
+ }
690
+
691
+ const SUMMARY = /\((\d+) passed, (\d+) failed\)/;
692
+
693
+ /**
694
+ * Green = rigc exited 0, at least one summary line, and every summary line says
695
+ * `0 failed`. Read with a pattern: the reference's substring test `"0 failed"`
696
+ * is also true of `"10 failed"`, and its `all()` over no summary line is true.
697
+ */
698
+ export function gateGreen(status: number, lines: readonly string[]): boolean {
699
+ const summaries = lines.filter((l) => l.includes(' assertions: '));
700
+ return status === 0 && summaries.length > 0 && summaries.every((l) => SUMMARY.exec(l)?.[2] === '0');
701
+ }
702
+
703
+ export interface PackLine {
704
+ line: string;
705
+ page: string;
706
+ width: number;
707
+ height: number;
708
+ regions: number;
709
+ coveredPct: number;
710
+ padding: number;
711
+ /** `free` when the line carries `, page edges free` after the padding, `pot` when it does not — rig-c prints the clause exactly when it packed under `--page-edges free` (its `src/cli/shared.ts`, the pack line). */
712
+ pageEdges: PageEdges;
713
+ /**
714
+ * The line's last clause, `, shape rect` or `, shape polygon`: rig-c
715
+ * 2.1 names the `--pack-shape` it packed under on every pack line, its
716
+ * default included. A line with no shape clause is the 1.5.1–2.0.3 form,
717
+ * from a rigc that had no other shape, and reads as `rect`.
718
+ */
719
+ packShape: PackShape;
720
+ }
721
+
722
+ /** rigc's pack line, whole: the line is this and nothing else, or it is refused. */
723
+ const PACK = /^pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), (\d+(?:\.\d+)?)% covered, padding (\d+)(, page edges free)?(?:, shape (rect|polygon))?$/;
724
+
725
+ /** A pack line that names its shape: the rig-c 2.1 form. */
726
+ const PACK_SHAPE_CLAUSE = /, shape (?:rect|polygon)$/;
727
+
728
+ /**
729
+ * Every pack line among the gate lines — a line that reads `pack:` once its
730
+ * `..` gutter is taken off. Two forms are read, each by name: rig-c
731
+ * 2.1's, which ends `, shape rect` or `, shape polygon`, and the 1.5.1–2.0.3
732
+ * form, which has no shape clause and reads as `rect`; in both,
733
+ * `, page edges free` sits between the padding and the shape or is absent. Any
734
+ * other line (an unknown shape, an unknown suffix, a missing field, the clauses
735
+ * out of order) is refused, `CHECK_PACK_LINE_READS`, naming the line and the
736
+ * form required: a line half-read would lose the field that changed.
737
+ */
738
+ export function parsePackLines(lines: readonly string[]): PackLine[] {
739
+ const out: PackLine[] = [];
740
+ const problems: Problem[] = [];
741
+ for (const l of lines) {
742
+ const text = l.trim().replace(/^\.\.\s+/, '');
743
+ if (!text.startsWith('pack:')) continue;
744
+ const m = PACK.exec(text);
745
+ if (m === null) {
746
+ problems.push({
747
+ code: 'CHECK_PACK_LINE_READS',
748
+ object: `rigc's pack line ${JSON.stringify(text)}`,
749
+ detail: 'does not read as "pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>", then ", page edges free" or nothing, then ", shape rect", ", shape polygon" or nothing; that form is required',
750
+ });
751
+ continue;
752
+ }
753
+ out.push({
754
+ line: text,
755
+ page: m[1],
756
+ width: Number(m[2]),
757
+ height: Number(m[3]),
758
+ regions: Number(m[4]),
759
+ coveredPct: Number(m[5]),
760
+ padding: Number(m[6]),
761
+ pageEdges: m[7] === undefined ? 'pot' : 'free',
762
+ packShape: m[8] === 'polygon' ? 'polygon' : 'rect',
763
+ });
764
+ }
765
+ refuseIfAny(problems);
766
+ return out;
767
+ }
768
+
769
+ function powerOfTwo(n: number): boolean {
770
+ return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;
771
+ }
772
+
773
+ /**
774
+ * Where a pack line disagrees with the pack mode the build was given. The
775
+ * line's own page-edges clause must say the `--page-edges` passed, and a `pot`
776
+ * page must be a power of two on both edges, as rigc's help defines it,
777
+ * `CHECK_PACK_PAGE_EDGES`. By the same rule the line's shape must be the
778
+ * `--pack-shape` passed, `CHECK_PACK_SHAPE` — a line with no shape clause
779
+ * reads as `rect`, so under `polygon` it disagrees. That is the case that
780
+ * matters: rigc 2.0.3 takes `--pack-shape` without a word, packs by
781
+ * rectangles and prints the form with no shape clause (measured), so a rigc
782
+ * older than 2.1.0 first on `PATH` is refused here rather than shipping a
783
+ * rect page as a polygon one. One problem per disagreement; empty is
784
+ * agreement.
785
+ */
786
+ export function packEdgeProblems(pack: readonly PackLine[], mode: PackMode): Problem[] {
787
+ const edges = mode.pageEdges;
788
+ const problems: Problem[] = [];
789
+ for (const p of pack) {
790
+ const object = `packed page ${p.page}`;
791
+ if (p.pageEdges !== edges) {
792
+ problems.push({
793
+ code: 'CHECK_PACK_PAGE_EDGES',
794
+ object,
795
+ detail: `rigc's pack line says page edges ${p.pageEdges} (${p.pageEdges === 'free' ? 'it carries ", page edges free"' : 'no ", page edges free" clause'}); the build was run with --page-edges ${edges}, so page edges ${edges} is required`,
796
+ });
797
+ } else if (edges === 'pot' && !(powerOfTwo(p.width) && powerOfTwo(p.height))) {
798
+ problems.push({ code: 'CHECK_PACK_PAGE_EDGES', object, detail: `is ${p.width}x${p.height}; the build was run with --page-edges pot, so a power of two on both edges is required` });
799
+ }
800
+ if (p.packShape !== mode.packShape) {
801
+ problems.push({
802
+ code: 'CHECK_PACK_SHAPE',
803
+ object,
804
+ detail: `rigc's pack line says shape ${p.packShape} (${PACK_SHAPE_CLAUSE.test(p.line) ? `it ends ", shape ${p.packShape}"` : 'no ", shape" clause: the 1.5.1-2.0.3 form, read as rect'}); the build was run with --pack-shape ${mode.packShape}, so shape ${mode.packShape} is required`,
805
+ });
806
+ }
807
+ }
808
+ return problems;
809
+ }
810
+
811
+ /** Share of a page's pixels with alpha above 0 — the measure issue #2's spineboy figure is in. rigc's "covered" is region rectangles, not pixels. */
812
+ export function opaqueShare(r: Raster): number {
813
+ let n = 0;
814
+ for (let i = 3; i < r.data.length; i += 4) if (r.data[i] > 0) n++;
815
+ return n / (r.width * r.height);
816
+ }
817
+
818
+ // ---------------------------------------------------------------------------
819
+ // rigc's frames
820
+ // ---------------------------------------------------------------------------
821
+
822
+ export interface Viewport {
823
+ x: number;
824
+ y: number;
825
+ width: number;
826
+ height: number;
827
+ scale: number;
828
+ pixelWidth: number;
829
+ pixelHeight: number;
830
+ }
831
+
832
+ export interface FrameSet {
833
+ /** The directory holding the frames. */
834
+ dir: string;
835
+ animation: string | null;
836
+ fps: number;
837
+ sampled: number;
838
+ written: number;
839
+ stride: number;
840
+ /** The last sampled frame's time, as rigc records it. */
841
+ duration: number;
842
+ background: [number, number, number, number];
843
+ viewport: Viewport;
844
+ /** `f<index>.png`, in index order. */
845
+ frames: Array<{ name: string; index: number; image: Raster }>;
846
+ }
847
+
848
+ function sidecarProblem(object: string, detail: string): never {
849
+ refuseIfAny([{ code: 'FRAMES_SIDECAR', object, detail }]);
850
+ throw new Error('unreachable');
851
+ }
852
+
853
+ /**
854
+ * A frame set as `rigc render` wrote it: either the `--out` directory
855
+ * (`frames.json` plus one set directory) or a set directory whose parent holds
856
+ * `frames.json`. Every figure is read off the sidecar; none is assumed.
857
+ */
858
+ export function readFrameSet(path: string): FrameSet {
859
+ const dir = resolve(path);
860
+ let sidecarPath: string;
861
+ let setName: string | null;
862
+ if (existsSync(join(dir, 'frames.json'))) {
863
+ sidecarPath = join(dir, 'frames.json');
864
+ setName = null;
865
+ } else if (existsSync(join(dirname(dir), 'frames.json'))) {
866
+ sidecarPath = join(dirname(dir), 'frames.json');
867
+ setName = basename(dir);
868
+ } else {
869
+ return sidecarProblem(dir, `neither ${join(dir, 'frames.json')} nor ${join(dirname(dir), 'frames.json')} exists; the fps and the frame count are read from the frames.json \`rigc render\` writes`);
870
+ }
871
+ let raw: unknown;
872
+ try {
873
+ raw = JSON.parse(readFileSync(sidecarPath, 'utf8'));
874
+ } catch (err) {
875
+ return sidecarProblem(sidecarPath, `does not parse as JSON: ${(err as Error).message}`);
876
+ }
877
+ if (!isRecord(raw) || !Array.isArray(raw.sets) || !isRecord(raw.viewport) || !Array.isArray(raw.background)) {
878
+ return sidecarProblem(sidecarPath, 'lacks "sets", "viewport" or "background"; a rigc-frames/1 sidecar carries all three');
879
+ }
880
+ const sets = raw.sets.filter(isRecord);
881
+ let set: Record<string, unknown> | undefined;
882
+ if (setName === null) {
883
+ if (sets.length !== 1) return sidecarProblem(sidecarPath, `holds ${sets.length} set(s) [${sets.map((s) => String(s.dir)).join(', ')}]; name one set directory when there is not exactly one`);
884
+ set = sets[0];
885
+ } else {
886
+ set = sets.find((s) => s.dir === setName);
887
+ if (set === undefined) return sidecarProblem(sidecarPath, `has no set "${setName}"; it has [${sets.map((s) => String(s.dir)).join(', ')}]`);
888
+ }
889
+ const num = (o: Record<string, unknown>, k: string, at: string): number => {
890
+ const v = o[k];
891
+ if (typeof v !== 'number' || !Number.isFinite(v)) return sidecarProblem(sidecarPath, `${at} field "${k}" is ${JSON.stringify(v)}; a number is required`);
892
+ return v;
893
+ };
894
+ const vpRaw = raw.viewport;
895
+ const viewport: Viewport = {
896
+ x: num(vpRaw, 'x', 'viewport'),
897
+ y: num(vpRaw, 'y', 'viewport'),
898
+ width: num(vpRaw, 'width', 'viewport'),
899
+ height: num(vpRaw, 'height', 'viewport'),
900
+ scale: num(vpRaw, 'scale', 'viewport'),
901
+ pixelWidth: num(vpRaw, 'pixelWidth', 'viewport'),
902
+ pixelHeight: num(vpRaw, 'pixelHeight', 'viewport'),
903
+ };
904
+ const bg = raw.background;
905
+ if (bg.length !== 4 || !bg.every((v) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 255)) {
906
+ return sidecarProblem(sidecarPath, `"background" is ${JSON.stringify(bg)}; four 8-bit channels are required`);
907
+ }
908
+ const setDir = join(dirname(sidecarPath), String(set.dir));
909
+ if (!existsSync(setDir)) return sidecarProblem(setDir, 'does not exist; frames.json names it');
910
+ const frames = readdirSync(setDir)
911
+ .map((name) => ({ name, m: /^f(\d+)\.png$/.exec(name) }))
912
+ .filter((e) => e.m !== null)
913
+ .map((e) => ({ name: e.name, index: Number((e.m as RegExpExecArray)[1]) }))
914
+ .sort((a, b) => a.index - b.index)
915
+ .map((e) => ({ ...e, image: readPng(join(setDir, e.name)) }));
916
+ const written = num(set, 'written', `set "${String(set.dir)}"`);
917
+ if (frames.length !== written) return sidecarProblem(setDir, `holds ${frames.length} f*.png frame(s); frames.json says ${written} were written`);
918
+ return {
919
+ dir: setDir,
920
+ animation: typeof set.animation === 'string' ? set.animation : null,
921
+ fps: num(set, 'fps', `set "${String(set.dir)}"`),
922
+ sampled: num(set, 'sampled', `set "${String(set.dir)}"`),
923
+ written,
924
+ stride: num(set, 'stride', `set "${String(set.dir)}"`),
925
+ duration: num(set, 'duration', `set "${String(set.dir)}"`),
926
+ background: bg as [number, number, number, number],
927
+ viewport,
928
+ frames,
929
+ };
930
+ }
931
+
932
+ // ---------------------------------------------------------------------------
933
+ // the keyed loop: physics left out (issue #183)
934
+ // ---------------------------------------------------------------------------
935
+
936
+ /** A physics constraint the loop's frames leave out, as the `loop:` physics line names it. */
937
+ export interface LoopPhysics {
938
+ name: string;
939
+ bone: string;
940
+ /** Where it was declared: rig.json's `constraints`, or motion.json's `physics` table. */
941
+ in: 'rig.json' | 'motion.json';
942
+ }
943
+
944
+ /**
945
+ * The rig spec and motion spec with every physics constraint left out, and
946
+ * the constraints it left out; null when there is none, so a rig without one
947
+ * is measured from the very build and render it always was.
948
+ *
949
+ * rig-c's render resets physics at frame 0 and steps it by 1/fps (its
950
+ * `spinePoser`), so a constraint still moving at the idle's duration makes
951
+ * the last frame differ from the first by what the simulation is doing, not by
952
+ * what the keys say. `CHECK_LOOP_CLOSES` measures the keys: a rig-c
953
+ * `constraints` entry of `type` `"physics"` and every entry of motion.json's
954
+ * `physics` table are dropped, with what names them only to activate or key
955
+ * them — a skin's `physics` list, and an animation track whose target is a
956
+ * `physics` constraint. Nothing else is touched, and nothing here judges the
957
+ * constraints: the line that names them reports.
958
+ */
959
+ export function withoutPhysics(rig: Record<string, unknown>, motion: Record<string, unknown>): { rig: Record<string, unknown>; motion: Record<string, unknown>; physics: LoopPhysics[] } | null {
960
+ const physics: LoopPhysics[] = [];
961
+ const constraints = Array.isArray(rig.constraints) ? rig.constraints : null;
962
+ if (constraints !== null) {
963
+ for (const c of constraints) if (isRecord(c) && c.type === 'physics') physics.push({ name: String(c.name), bone: String(c.bone), in: 'rig.json' });
964
+ }
965
+ const table = isRecord(motion.physics) ? motion.physics : null;
966
+ if (table !== null) for (const [name, e] of Object.entries(table)) physics.push({ name, bone: isRecord(e) ? String(e.bone) : '', in: 'motion.json' });
967
+ if (physics.length === 0) return null;
968
+ const r: Record<string, unknown> = { ...rig };
969
+ if (constraints !== null) {
970
+ const kept = constraints.filter((c) => !(isRecord(c) && c.type === 'physics'));
971
+ if (kept.length > 0) r.constraints = kept;
972
+ else delete r.constraints;
973
+ }
974
+ if (isRecord(rig.skins)) {
975
+ r.skins = Object.fromEntries(Object.entries(rig.skins).map(([skin, entry]) => {
976
+ if (!isRecord(entry) || !('physics' in entry)) return [skin, entry];
977
+ return [skin, Object.fromEntries(Object.entries(entry).filter(([k]) => k !== 'physics'))];
978
+ }));
979
+ }
980
+ const m: Record<string, unknown> = { ...motion };
981
+ delete m.physics;
982
+ if (isRecord(motion.animations)) {
983
+ m.animations = Object.fromEntries(Object.entries(motion.animations).map(([name, a]) => {
984
+ if (!isRecord(a) || !Array.isArray(a.tracks)) return [name, a];
985
+ return [name, { ...a, tracks: a.tracks.filter((t) => !(isRecord(t) && 'physics' in t)) }];
986
+ }));
987
+ }
988
+ return { rig: r, motion: m, physics };
989
+ }
990
+
991
+ /** The one line naming what {@link withoutPhysics} left out of the loop's frames: reported, not judged. */
992
+ export function loopPhysicsLine(physics: readonly LoopPhysics[]): string {
993
+ return `loop physics: ${physics.length} physics constraint(s) left out of the loop's frames, reported, not judged — ${physics.map((p) => `"${p.name}" on bone "${p.bone}" (${p.in})`).join(', ')}`;
994
+ }
995
+
996
+ // ---------------------------------------------------------------------------
997
+ // pixel measurements
998
+ // ---------------------------------------------------------------------------
999
+
1000
+ /** Largest per-channel |a - b| over RGB (alpha dropped, as PIL's `convert("RGB")` does), and the first pixel where it is. */
1001
+ export function maxRgbDiff(a: Raster, b: Raster): { max: number; x: number; y: number } {
1002
+ let max = 0;
1003
+ let at = 0;
1004
+ for (let p = 0; p < a.width * a.height; p++) {
1005
+ for (let c = 0; c < 3; c++) {
1006
+ const d = Math.abs(a.data[p * 4 + c] - b.data[p * 4 + c]);
1007
+ if (d > max) {
1008
+ max = d;
1009
+ at = p;
1010
+ }
1011
+ }
1012
+ }
1013
+ return { max, x: at % a.width, y: Math.floor(at / a.width) };
1014
+ }
1015
+
1016
+ /**
1017
+ * The motion heat map: frame 0 as grey, blended 0.6 toward a red channel
1018
+ * holding each pixel's largest per-channel change from frame 0 across all
1019
+ * frames. The grey is PIL's `convert("L")` (`(19595 R + 38470 G + 7471 B +
1020
+ * 0x8000) >> 16`) and the blend is PIL's `Image.blend` (`in1 + alpha * (in2 -
1021
+ * in1)` with alpha as float32, fused, truncated) — the reference's two calls,
1022
+ * measured byte-exact against the reference's own heat maps (ORACLE_check.md).
1023
+ */
1024
+ export function motionHeat(frames: readonly Raster[]): Raster {
1025
+ const f0 = frames[0];
1026
+ const n = f0.width * f0.height;
1027
+ const d = heatField(frames);
1028
+ const alpha = Math.fround(0.6);
1029
+ // One rounding, to float32, of the exact `in1 + alpha * (in2 - in1)`: the
1030
+ // build of Pillow 12.2.0 measured here contracts the expression to a fused
1031
+ // multiply-add. Rounding the product first (two roundings) disagreed with it
1032
+ // on 33 of 256 values of in1 at in2 = 0; this form agrees on all 65,536
1033
+ // (in1, in2) pairs. The product and sum are exact in float64, so one fround
1034
+ // IS the fused result.
1035
+ const blend = (a: number, b: number): number => Math.trunc(Math.fround(a + alpha * (b - a)));
1036
+ const out = newRaster(f0.width, f0.height);
1037
+ for (let p = 0; p < n; p++) {
1038
+ const l = (f0.data[p * 4] * 19595 + f0.data[p * 4 + 1] * 38470 + f0.data[p * 4 + 2] * 7471 + 0x8000) >>> 16;
1039
+ out.data[p * 4] = blend(l, d[p]);
1040
+ out.data[p * 4 + 1] = blend(l, 0);
1041
+ out.data[p * 4 + 2] = blend(l, 0);
1042
+ out.data[p * 4 + 3] = 255;
1043
+ }
1044
+ return out;
1045
+ }
1046
+
1047
+ export interface SeamFigures {
1048
+ mean: number;
1049
+ over40: number;
1050
+ over80: number;
1051
+ /** The frame pixels {@link over40} counts, by index into the frame, ascending; each one's max-channel |d| is in {@link countedDiff}. */
1052
+ counted: number[];
1053
+ countedDiff: number[];
1054
+ /** The rig-to-frame map the composite was warped by ({@link rigToFrameMap}): frame = (sx u + tx, sy v + ty), pixel index to pixel index, as float32 values. */
1055
+ map: { sx: number; sy: number; tx: number; ty: number };
1056
+ }
1057
+
1058
+ /**
1059
+ * The flat stack of parts over the background, in rig pixels: PIL's
1060
+ * `alpha_composite` of every part at its `x, y`, in parts.json order, over an
1061
+ * opaque canvas of the render's own background colour.
1062
+ */
1063
+ export function flatComposite(parts: PartsFile, partsDir: string, background: readonly [number, number, number, number]): Raster {
1064
+ const [W, H] = parts.rig_size;
1065
+ let canvas = newRaster(W, H);
1066
+ for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
1067
+ for (const p of parts.parts) canvas = alphaComposite(canvas, readPng(join(partsDir, `${p.name}.png`)), p.x, p.y);
1068
+ return canvas;
1069
+ }
1070
+
1071
+ /**
1072
+ * The map from rig (crop) pixel indices to frame pixel indices that the seam
1073
+ * and the source line warp by: frame = (sx u + tx, sy v + ty).
1074
+ *
1075
+ * Crop pixel (u, v) covers world x from stage.x + u to stage.x + u + 1 (y
1076
+ * through `cropToSpineY`, the one door), and world (wx, wy) is the frame's
1077
+ * continuous position ((wx - vp.x) s, (vp.y + vp.height - wy) s). rig-c's
1078
+ * renderer samples at pixel centres (`x + 0.5`), and cv2's `warpAffine` (which
1079
+ * `src/raster/warp.ts` reproduces) maps index to index with no half-pixel
1080
+ * offset, so the centre of crop pixel u, at continuous u + 0.5, lands at frame
1081
+ * index s (u + 0.5) + t - 0.5 = s u + t + 0.5 (s - 1) (issue #209). Measured
1082
+ * through rig-c's own `render`: a one-pixel column and row on a rig rendered
1083
+ * at s = 1.0069, 1.3889, 1.5046 and 2.0023 sit within 0.042 frame px of this
1084
+ * map on both axes and 0.499 px off the map without the 0.5 (s - 1) term at
1085
+ * s = 2.0023 [measured for issue #209, a scratch rig, not tracked]. At s = 1
1086
+ * the term is 0 and the map is the reference's.
1087
+ *
1088
+ * The reference hands cv2 the matrix as float32 (`np.float32([[...]])`), and
1089
+ * that quantisation moves a sample row across a 1/32-pixel step: in float64 one
1090
+ * row of one rig's 1216-row frame (C1 in ORACLE_check.md) sampled differently
1091
+ * and the unrounded mean drifted 3.1e-4. The map is rounded to float32 the same
1092
+ * way. ⚠️ The reference has no centre term, so from issue #209 this map
1093
+ * mirrors the reference — and ORACLE_check.md's figures are matched — at
1094
+ * s = 1 only; above or below it the reference laid the composite 0.5 (s - 1)
1095
+ * frame px off the render on each axis.
1096
+ *
1097
+ * `centres` false drops the term: the reference's map, kept so the `check`
1098
+ * suite can plant it (issue #209). Every caller in `src/` leaves it true.
1099
+ */
1100
+ export function rigToFrameMap(stage: StageBox, rigH: number, vp: Viewport, centres = true): ScaleTranslate {
1101
+ const s = vp.scale;
1102
+ const f = Math.fround;
1103
+ const c = centres ? 0.5 * (s - 1) : 0;
1104
+ return { sx: f(s), sy: f(s), tx: f((stage.x - vp.x) * s + c), ty: f((vp.y + vp.height - (stage.y + cropToSpineY(0, rigH))) * s + c) };
1105
+ }
1106
+
1107
+ /**
1108
+ * {@link rigToFrameMap} turned round, from frame pixel indices to rig pixel
1109
+ * indices: u = (frame - t - 0.5 (s - 1)) / s, rounded to float32 as the
1110
+ * forward map is. The source line warps the frame onto the stage by it.
1111
+ */
1112
+ export function frameToRigMap(stage: StageBox, rigH: number, vp: Viewport): ScaleTranslate {
1113
+ const s = vp.scale;
1114
+ const f = Math.fround;
1115
+ const c = 0.5 * (s - 1);
1116
+ return { sx: f(1 / s), sy: f(1 / s), tx: f(vp.x - stage.x - c / s), ty: f(stage.y + cropToSpineY(0, rigH) - (vp.y + vp.height) - c / s) };
1117
+ }
1118
+
1119
+ /**
1120
+ * The seam figures: the setup-pose frame vs the flat composite carried onto the
1121
+ * frame's pixel grid.
1122
+ *
1123
+ * The map is {@link rigToFrameMap}: a scale by s and a translate, from rig
1124
+ * pixel indices to frame pixel indices, centre to centre. It is warped with
1125
+ * `src/raster/warp.ts` (cv2's bilinear `warpAffine`, which is also what the
1126
+ * reference's `INTER_AREA` flag gets from cv2 — see that file) in float32.
1127
+ * cv2's `borderValue` is the background; this warp's border is 0, so the
1128
+ * composite is warped as `(pixel - background)` and the background added back,
1129
+ * which is the same linear sum. The per-pixel figure is the largest channel
1130
+ * |d|, as in the reference.
1131
+ *
1132
+ * `map` is for the controls alone (the `check` suite plants the map without
1133
+ * the centre term, issue #209); every caller in `src/` leaves it to the default.
1134
+ */
1135
+ export function seamFigures(
1136
+ composite: Raster,
1137
+ frame: Raster,
1138
+ stage: StageBox,
1139
+ vp: Viewport,
1140
+ background: readonly [number, number, number, number],
1141
+ map: ScaleTranslate = rigToFrameMap(stage, composite.height, vp),
1142
+ ): SeamFigures {
1143
+ const [W, H] = [composite.width, composite.height];
1144
+ const src = newFloatImage(W, H, 3);
1145
+ for (let p = 0; p < W * H; p++) for (let c = 0; c < 3; c++) src.data[p * 3 + c] = composite.data[p * 4 + c] - background[c];
1146
+ const warped = warpAffine(src, map, vp.pixelWidth, vp.pixelHeight, 'bilinear');
1147
+ if (frame.width !== vp.pixelWidth || frame.height !== vp.pixelHeight) {
1148
+ refuseIfAny([{ code: 'CHECK_SEAM_FRAME_SIZE', object: 'the setup-pose frame', detail: `is ${frame.width}x${frame.height}; frames.json's viewport is ${vp.pixelWidth}x${vp.pixelHeight}` }]);
1149
+ }
1150
+ let sum = 0;
1151
+ let over40 = 0;
1152
+ let over80 = 0;
1153
+ const counted: number[] = [];
1154
+ const countedDiff: number[] = [];
1155
+ const n = vp.pixelWidth * vp.pixelHeight;
1156
+ for (let p = 0; p < n; p++) {
1157
+ let m = 0;
1158
+ for (let c = 0; c < 3; c++) {
1159
+ const w = Math.fround(warped.data[p * 3 + c] + background[c]);
1160
+ const d = Math.abs(w - frame.data[p * 4 + c]);
1161
+ if (d > m) m = d;
1162
+ }
1163
+ sum += m;
1164
+ if (m > SEAM_PX_LEVEL) {
1165
+ over40++;
1166
+ counted.push(p);
1167
+ countedDiff.push(m);
1168
+ }
1169
+ if (m > SEAM_PX_LEVEL_HIGH) over80++;
1170
+ }
1171
+ return { mean: sum / n, over40, over80, counted, countedDiff, map };
1172
+ }
1173
+
1174
+ /**
1175
+ * Where `check --keep-stills` leaves the setup-pose still under `--out`
1176
+ * (issue #209): rigc's own render directory as `render` wrote it —
1177
+ * `frames.json` (the viewport the seam and the source line map by, and the
1178
+ * background) and the set directory beside it — so a reader compares against
1179
+ * the frame the bars read rather than rebuilding it. Removed at the start of
1180
+ * every run, so it is on disk only after a run that asked for it.
1181
+ */
1182
+ export const STILL_DIR = 'setup_still';
1183
+
1184
+ /** Where `check` writes the seam pictures, under `--out` (issue #202): one per row of {@link SeamPairRow}, named by its rank and its two parts. */
1185
+ export const SEAM_DIR = 'seam';
1186
+
1187
+ /** What a seam row names in place of a part when the counted pixel's sample reads fewer than two parts' art. */
1188
+ export const SEAM_NO_ART = '(no art)';
1189
+
1190
+ /**
1191
+ * One row of `check.json`'s `seam_pairs` (issue #202): the pixels the seam bar
1192
+ * counts (max-channel |d| over {@link SEAM_PX_LEVEL}) that one pair of parts
1193
+ * answers for, on the setup-pose still — the one frame the bar reads.
1194
+ *
1195
+ * A counted frame pixel is answered for by the parts whose art (alpha above
1196
+ * 0) lies under the composite's own sample of it: the frame pixel's centre
1197
+ * carried back to rig pixels by the inverse of {@link SeamFigures.map}, and
1198
+ * the 2x2 rig pixels the bilinear warp reads there (`floor(u)`, `floor(u) +
1199
+ * 1` by `floor(v)`, `floor(v) + 1`, those inside the rig). Of those parts the
1200
+ * two drawn last (parts.json's order is the draw order) are the pair, lower
1201
+ * first; where only one part's art lies there it is named first and the
1202
+ * second is {@link SEAM_NO_ART}, and where none does both are.
1203
+ * `frame_box` is the counted pixels' bounding box in frame pixels, inclusive,
1204
+ * `[x0, y0, x1, y1]`; `rig_box` the bounding box of the rig pixels those
1205
+ * samples read, in the rig pixels of `parts.json`, `cuts[].polygon` and the
1206
+ * patches.
1207
+ *
1208
+ * `cut` is written only when the two parts come from one layer (the same
1209
+ * parts.json `from`, as a cut's base and piece do, `assemble.cuts`): `from`
1210
+ * is that layer, and `differing_copies_px` how many of the row's counted
1211
+ * pixels have, among the rig pixels their sample reads, one that both parts
1212
+ * hold (alpha above 0 in each — a cut's band) in a different colour (any of
1213
+ * R, G, B unequal). It is what the seam bar sees of a cut's band (issue #202):
1214
+ * the band is not excluded, because the two copies are not one drawing — the
1215
+ * piece shows the painting where it is seen and the base keeps the layer's
1216
+ * colour under it — and the upper part's resampled edge shows the lower
1217
+ * part's copy. The pair's other counted pixels on a cut line are the same
1218
+ * resampling where the art changes colour across the line; this count does
1219
+ * not name them.
1220
+ */
1221
+ export interface SeamPairRow {
1222
+ parts: [string, string];
1223
+ px_over_40: number;
1224
+ px_over_80: number;
1225
+ frame_box: [number, number, number, number];
1226
+ rig_box: [number, number, number, number];
1227
+ cut?: { from: string; differing_copies_px: number };
1228
+ picture: string;
1229
+ }
1230
+
1231
+ /** A part's art in rig pixels: where it is, its image and the layer it comes from (parts.json's `from`). */
1232
+ export interface PlacedAlpha {
1233
+ name: string;
1234
+ from: string;
1235
+ x: number;
1236
+ y: number;
1237
+ image: Raster;
1238
+ }
1239
+
1240
+ /**
1241
+ * Split the seam's counted pixels by the pair of parts that answers for each
1242
+ * ({@link SeamPairRow}), worst first: by `px_over_40` descending, ties by the
1243
+ * pair's names in draw order. `parts` is parts.json's list in its order, each
1244
+ * with its image as assembled.
1245
+ */
1246
+ export function seamPairRows(seam: SeamFigures, parts: readonly PlacedAlpha[], rigSize: readonly [number, number], vp: Viewport): Array<{ row: SeamPairRow; pixels: number[] }> {
1247
+ const [W, H] = rigSize;
1248
+ // Each rig pixel's two topmost parts with art there (-1 for none): the top two of a union of pixels are among each pixel's own top two.
1249
+ const top = new Int32Array(W * H).fill(-1);
1250
+ const next = new Int32Array(W * H).fill(-1);
1251
+ parts.forEach((part, k) => {
1252
+ const img = part.image;
1253
+ for (let j = 0; j < img.height; j++) {
1254
+ const v = part.y + j;
1255
+ if (v < 0 || v >= H) continue;
1256
+ for (let i = 0; i < img.width; i++) {
1257
+ const u = part.x + i;
1258
+ if (u < 0 || u >= W || img.data[(j * img.width + i) * 4 + 3] === 0) continue;
1259
+ const q = v * W + u;
1260
+ next[q] = top[q];
1261
+ top[q] = k;
1262
+ }
1263
+ }
1264
+ });
1265
+ const { sx, sy, tx, ty } = seam.map;
1266
+ // A part's pixel at rig (u, v) as an offset into its image, or -1 where it holds no art.
1267
+ const at = (k: number, u: number, v: number): number => {
1268
+ const part = parts[k];
1269
+ const i = u - part.x;
1270
+ const j = v - part.y;
1271
+ if (i < 0 || j < 0 || i >= part.image.width || j >= part.image.height) return -1;
1272
+ const o = (j * part.image.width + i) * 4;
1273
+ return part.image.data[o + 3] === 0 ? -1 : o;
1274
+ };
1275
+ const differ = (a: number, b: number, u: number, v: number): boolean => {
1276
+ const oa = at(a, u, v);
1277
+ const ob = at(b, u, v);
1278
+ if (oa < 0 || ob < 0) return false;
1279
+ const da = parts[a].image.data;
1280
+ const db = parts[b].image.data;
1281
+ return da[oa] !== db[ob] || da[oa + 1] !== db[ob + 1] || da[oa + 2] !== db[ob + 2];
1282
+ };
1283
+ interface Acc { a: number; b: number; over40: number; over80: number; f: [number, number, number, number]; r: [number, number, number, number]; pixels: number[]; differing: number }
1284
+ const byPair = new Map<string, Acc>();
1285
+ seam.counted.forEach((p, idx) => {
1286
+ const fx = p % vp.pixelWidth;
1287
+ const fy = Math.floor(p / vp.pixelWidth);
1288
+ const u0 = Math.floor((fx - tx) / sx);
1289
+ const v0 = Math.floor((fy - ty) / sy);
1290
+ let first = -1;
1291
+ let second = -1;
1292
+ const read: Array<[number, number]> = [];
1293
+ let rx0 = Infinity;
1294
+ let ry0 = Infinity;
1295
+ let rx1 = -Infinity;
1296
+ let ry1 = -Infinity;
1297
+ for (let dv = 0; dv < 2; dv++) {
1298
+ for (let du = 0; du < 2; du++) {
1299
+ const u = u0 + du;
1300
+ const v = v0 + dv;
1301
+ if (u < 0 || u >= W || v < 0 || v >= H) continue;
1302
+ read.push([u, v]);
1303
+ rx0 = Math.min(rx0, u);
1304
+ ry0 = Math.min(ry0, v);
1305
+ rx1 = Math.max(rx1, u);
1306
+ ry1 = Math.max(ry1, v);
1307
+ for (const k of [top[v * W + u], next[v * W + u]]) {
1308
+ if (k < 0 || k === first || k === second) continue;
1309
+ if (k > first) {
1310
+ second = first;
1311
+ first = k;
1312
+ } else if (k > second) second = k;
1313
+ }
1314
+ }
1315
+ }
1316
+ // A sample wholly outside the rig reads no rig pixel; its rig box is the nearest rig pixel.
1317
+ if (rx1 < rx0) {
1318
+ rx0 = rx1 = Math.min(Math.max(u0, 0), W - 1);
1319
+ ry0 = ry1 = Math.min(Math.max(v0, 0), H - 1);
1320
+ }
1321
+ const a = second;
1322
+ const b = first;
1323
+ const key = `${a},${b}`;
1324
+ let acc = byPair.get(key);
1325
+ if (acc === undefined) {
1326
+ acc = { a, b, over40: 0, over80: 0, f: [fx, fy, fx, fy], r: [rx0, ry0, rx1, ry1], pixels: [], differing: 0 };
1327
+ byPair.set(key, acc);
1328
+ }
1329
+ if (a >= 0 && parts[a].from === parts[b].from && read.some(([u, v]) => differ(a, b, u, v))) acc.differing++;
1330
+ acc.over40++;
1331
+ acc.pixels.push(p);
1332
+ if (seam.countedDiff[idx] > SEAM_PX_LEVEL_HIGH) acc.over80++;
1333
+ acc.f = [Math.min(acc.f[0], fx), Math.min(acc.f[1], fy), Math.max(acc.f[2], fx), Math.max(acc.f[3], fy)];
1334
+ acc.r = [Math.min(acc.r[0], rx0), Math.min(acc.r[1], ry0), Math.max(acc.r[2], rx1), Math.max(acc.r[3], ry1)];
1335
+ });
1336
+ const name = (k: number): string => (k < 0 ? SEAM_NO_ART : parts[k].name);
1337
+ const sorted = [...byPair.values()].sort((x, y) => y.over40 - x.over40 || x.b - y.b || x.a - y.a);
1338
+ const width = String(sorted.length).length;
1339
+ return sorted.map((acc, i) => {
1340
+ // A part over no other art is named first, then SEAM_NO_ART.
1341
+ const pair: [string, string] = acc.a < 0 ? [name(acc.b), SEAM_NO_ART] : [name(acc.a), name(acc.b)];
1342
+ const file = `${String(i + 1).padStart(Math.max(2, width), '0')}_${pair.map((n) => (n === SEAM_NO_ART ? 'no-art' : n)).join('_')}.png`;
1343
+ const cut = acc.a >= 0 && parts[acc.a].from === parts[acc.b].from ? { cut: { from: parts[acc.a].from, differing_copies_px: acc.differing } } : {};
1344
+ return { row: { parts: pair, px_over_40: acc.over40, px_over_80: acc.over80, frame_box: acc.f, rig_box: acc.r, ...cut, picture: `${SEAM_DIR}/${file}` }, pixels: acc.pixels };
1345
+ });
1346
+ }
1347
+
1348
+ /** The marks a seam picture draws: the counted pixels of its pair, and the box round them. */
1349
+ export const SEAM_MARK: readonly [number, number, number] = [255, 0, 255];
1350
+ export const SEAM_BOX: readonly [number, number, number] = [0, 255, 255];
1351
+
1352
+ /**
1353
+ * A seam row's picture: the setup-pose frame with the pair's counted pixels
1354
+ * painted {@link SEAM_MARK} and the rectangle one pixel outside its
1355
+ * `frame_box` drawn {@link SEAM_BOX} where it falls inside the frame. Every
1356
+ * other pixel is the frame's own, opaque. `pixels` are the row's counted
1357
+ * pixels, by index into the frame.
1358
+ */
1359
+ export function seamPicture(frame: Raster, row: SeamPairRow, pixels: readonly number[]): Raster {
1360
+ const out = newRaster(frame.width, frame.height);
1361
+ out.data.set(frame.data);
1362
+ for (let p = 0; p < frame.width * frame.height; p++) out.data[p * 4 + 3] = 255;
1363
+ const paint = (x: number, y: number, rgb: readonly [number, number, number]): void => {
1364
+ if (x < 0 || y < 0 || x >= frame.width || y >= frame.height) return;
1365
+ out.data.set(rgb, (y * frame.width + x) * 4);
1366
+ };
1367
+ const [x0, y0, x1, y1] = row.frame_box;
1368
+ for (let x = x0 - 1; x <= x1 + 1; x++) {
1369
+ paint(x, y0 - 1, SEAM_BOX);
1370
+ paint(x, y1 + 1, SEAM_BOX);
1371
+ }
1372
+ for (let y = y0 - 1; y <= y1 + 1; y++) {
1373
+ paint(x0 - 1, y, SEAM_BOX);
1374
+ paint(x1 + 1, y, SEAM_BOX);
1375
+ }
1376
+ for (const p of pixels) paint(p % frame.width, Math.floor(p / frame.width), SEAM_MARK);
1377
+ return out;
1378
+ }
1379
+
1380
+ /** Python's `round(x, 3)` for the figures written: to three places, on the value's exact decimal expansion. */
1381
+ function round3(x: number): number {
1382
+ return Number(x.toFixed(3));
1383
+ }
1384
+
1385
+ /** Python's `round(x, 6)` for the frame scale written: to six places, on the value's exact decimal expansion. */
1386
+ function round6(x: number): number {
1387
+ return Number(x.toFixed(6));
1388
+ }
1389
+
1390
+ /** The seam's count in rig pixels (issue #216): the frame pixels over {@link SEAM_PX_LEVEL} divided by the square of the frame scale. */
1391
+ export function seamRigPx(over40: number, scale: number): number {
1392
+ return over40 / (scale * scale);
1393
+ }
1394
+
1395
+ /** {@link seamRigPx} as the console and the refusal print it, to two places. */
1396
+ export function seamRigPxText(over40: number, scale: number): string {
1397
+ return seamRigPx(over40, scale).toFixed(2);
1398
+ }
1399
+
1400
+ /**
1401
+ * The seam bar, on the figures as check.json writes them: the mean (rounded
1402
+ * to three places) over {@link SEAM_MEAN_BAR}, or more than
1403
+ * {@link SEAM_PX_BAR} rig pixels over {@link SEAM_PX_LEVEL} — the frame count
1404
+ * over the square of the written frame scale (issue #216).
1405
+ */
1406
+ function seamFails(mean: number, over40: number, scale: number): boolean {
1407
+ return mean > SEAM_MEAN_BAR || seamRigPx(over40, scale) > SEAM_PX_BAR;
1408
+ }
1409
+
1410
+ /** A seam row as the console prints it: the pair, the frame, the counts, both boxes and the picture. */
1411
+ export function seamPairText(row: SeamPairRow): string {
1412
+ const q = (n: string): string => (n === SEAM_NO_ART ? n : `"${n}"`);
1413
+ const box = (b: readonly number[]): string => `x ${b[0]}..${b[2]}, y ${b[1]}..${b[3]}`;
1414
+ const cut =
1415
+ row.cut === undefined
1416
+ ? ''
1417
+ : `; one layer cut in two (both from ${row.cut.from}): ${row.cut.differing_copies_px} of these px read a pixel both parts hold in different colours. Along a cut line the render draws the upper part's resampled edge over the lower part's own resampled copy, so it parts from the flat stack where the two copies differ and where the art changes colour across the line; a wider cuts[].overlap moves neither, a polygon edge through art of one colour on both sides, in both copies, does`;
1418
+ return `${q(row.parts[0])} / ${q(row.parts[1])} at the setup pose: ${row.px_over_40} px over ${SEAM_PX_LEVEL} (${row.px_over_80} over ${SEAM_PX_LEVEL_HIGH}), frame box ${box(row.frame_box)}, rig box ${box(row.rig_box)}, picture ${row.picture}${cut}`;
1419
+ }
1420
+
1421
+ // ---------------------------------------------------------------------------
1422
+ // the setup pose against the painting (issue #77)
1423
+ // ---------------------------------------------------------------------------
1424
+
1425
+ /** The line `--source` adds: the setup pose against the painting, with assemble's recomposite figures. */
1426
+ export const SOURCE_LINE = 'SETUP_POSE_VS_SOURCE';
1427
+
1428
+ /** The name the setup pose's coverage goes by where assemble's measure names a part. */
1429
+ export const SETUP_POSE_COVER = '(setup pose)';
1430
+
1431
+ /** The throwaway rig whose every slot is tinted black, rendered beside the setup-pose still: the coverage the opaque render does not carry. */
1432
+ const BLACK_RIG = 'rig_black.json';
1433
+
1434
+ /**
1435
+ * The rig with every slot tinted black (`color` `000000` with the slot's own
1436
+ * alpha kept, and `dark` `000000` where the slot has one), so the setup pose
1437
+ * drawn from it is the render's background times what the art lets through.
1438
+ */
1439
+ export function blackRig(rig: Record<string, unknown>): Record<string, unknown> {
1440
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).map((s) => {
1441
+ if (!isRecord(s)) return s;
1442
+ const alpha = typeof s.color === 'string' && s.color.length === 8 ? s.color.slice(6) : 'ff';
1443
+ return { ...s, color: `000000${alpha}`, ...(s.dark === undefined ? {} : { dark: '000000' }) };
1444
+ });
1445
+ return { ...rig, slots };
1446
+ }
1447
+
1448
+ /**
1449
+ * The setup pose against the painting with `assemble`'s recomposite figures,
1450
+ * by `assemble`'s own function (`measureRecomposite`): mean |d| and the share
1451
+ * within 8 over the mean channel, error pixels over 40 by the max channel, the
1452
+ * uncovered ones where nothing has alpha above 128, and their 8-connected
1453
+ * holes — over the stage's W x H pixels, which is assemble's population.
1454
+ *
1455
+ * What stands in for assemble's two inputs, because the setup pose is what is
1456
+ * measured and the parts are not read:
1457
+ * - **The recomposite.** assemble composites onto opaque white; rigc renders
1458
+ * onto its opaque `background` (232 grey, rig-c's `BACKGROUND`), so the
1459
+ * frame carries no alpha. A second still of the same pose, every slot tinted
1460
+ * black ({@link blackRig}), is the background times what the art lets
1461
+ * through, channel by channel: `black = bg (1 - a)`. The pose over white is
1462
+ * then `open + (255 - bg) black / bg` — exact for every slot drawn with the
1463
+ * normal blend, which is every slot rig-parts writes.
1464
+ * - **The coverage.** assemble's "a part has alpha above 128" becomes "the
1465
+ * setup pose has alpha above 128", the alpha read as `255 (1 - black / bg)`
1466
+ * on the channel that lets most through. Where two translucent parts overlap
1467
+ * the stack's alpha exceeds either part's, so a pixel uncovered here is
1468
+ * uncovered in assemble's reading too, never the reverse.
1469
+ *
1470
+ * Both are carried from the frame's grid onto the stage's with the seam's map
1471
+ * turned round ({@link frameToRigMap}, pixel centre to pixel centre, issue
1472
+ * #209), through the same bilinear `warpAffine` in float32, the border reading
1473
+ * as no art; then rounded to 8 bits, as assemble's composite is.
1474
+ */
1475
+ export function sourceFigures(open: Raster, black: Raster, vp: Viewport, background: readonly [number, number, number, number], stage: StageBox, srcr: Raster): RecompositeFigures {
1476
+ const problems: Problem[] = [];
1477
+ for (const [label, f] of [['the setup-pose frame', open], ['the black-tinted setup-pose frame', black]] as const) {
1478
+ if (f.width !== vp.pixelWidth || f.height !== vp.pixelHeight) problems.push({ code: 'CHECK_SOURCE_GRID', object: label, detail: `is ${f.width}x${f.height}; frames.json's viewport is ${vp.pixelWidth}x${vp.pixelHeight}` });
1479
+ }
1480
+ if (background[3] !== 255 || background.slice(0, 3).some((c) => c === 0)) {
1481
+ problems.push({ code: 'CHECK_SOURCE_GRID', object: 'the render background', detail: `is ${JSON.stringify(background)}; an opaque background with no channel at 0 is required — the coverage is read as how much of it the black-tinted pose lets through` });
1482
+ }
1483
+ if (srcr.width !== stage.width || srcr.height !== stage.height) problems.push({ code: 'CHECK_SOURCE_GRID', object: 'the painting on the rig canvas', detail: `is ${srcr.width}x${srcr.height}; the stage is ${stage.width}x${stage.height}` });
1484
+ refuseIfAny(problems);
1485
+ const [W, H] = [stage.width, stage.height];
1486
+ const n = vp.pixelWidth * vp.pixelHeight;
1487
+ // Ink = 255 - the pose over white, so the warp's border 0 reads as white paper; channel 3 is the coverage alpha.
1488
+ const ink = newFloatImage(vp.pixelWidth, vp.pixelHeight, 4);
1489
+ for (let p = 0; p < n; p++) {
1490
+ let through = 0;
1491
+ for (let c = 0; c < 3; c++) {
1492
+ const t = black.data[p * 4 + c] / background[c];
1493
+ if (t > through) through = t;
1494
+ ink.data[p * 4 + c] = 255 - (open.data[p * 4 + c] + (255 - background[c]) * t);
1495
+ }
1496
+ ink.data[p * 4 + 3] = 255 * (1 - Math.min(1, through));
1497
+ }
1498
+ // The seam's map turned round, centre to centre (issue #209).
1499
+ const warped = warpAffine(ink, frameToRigMap(stage, H, vp), W, H, 'bilinear');
1500
+ const to8 = (v: number): number => Math.min(255, Math.max(0, Math.round(v)));
1501
+ const can = newRaster(W, H);
1502
+ const cover = newRaster(W, H);
1503
+ for (let p = 0; p < W * H; p++) {
1504
+ for (let c = 0; c < 3; c++) can.data[p * 4 + c] = to8(255 - warped.data[p * 4 + c]);
1505
+ can.data[p * 4 + 3] = 255;
1506
+ cover.data[p * 4 + 3] = to8(warped.data[p * 4 + 3]);
1507
+ }
1508
+ const record: PartRecord = { name: SETUP_POSE_COVER, from: SETUP_POSE_COVER, x: 0, y: 0, w: W, h: H, opaque_px: 0, projected_core_px: 0, source_px_taken: 0, refused_drift_px: 0, merged_px: 0, seam_override_px: 0 };
1509
+ return measureRecomposite(can, srcr, [{ record, image: cover }]);
1510
+ }
1511
+
1512
+ /** {@link sourceFigures} as the line `check.json` carries: assemble's `recomposite` block's figures and limits, the largest hole's box, and what was compared. */
1513
+ export function sourceLine(fig: RecompositeFigures, painting: Raster, stage: StageBox, vp: Viewport): ReportedLine {
1514
+ const r = recompositeRecord(fig);
1515
+ return {
1516
+ status: 'REPORTED',
1517
+ painting_px: `${painting.width}x${painting.height}`,
1518
+ stage_px: `${stage.width}x${stage.height}`,
1519
+ render_px: `${vp.pixelWidth}x${vp.pixelHeight}`,
1520
+ render_scale: Number(vp.scale.toFixed(4)),
1521
+ mean_abs: r.mean_abs,
1522
+ within_limit: r.within_limit,
1523
+ within_share: r.within_share,
1524
+ error_limit: r.error_limit,
1525
+ error_px: r.error_px,
1526
+ covered_alpha: r.covered_alpha,
1527
+ uncovered_error_px: r.uncovered_error_px,
1528
+ hole_count: r.hole_count,
1529
+ largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]) },
1530
+ };
1531
+ }
1532
+
1533
+ // ---------------------------------------------------------------------------
1534
+ // the stage
1535
+ // ---------------------------------------------------------------------------
1536
+
1537
+ /** One judgement line (issue #11): its status, the figures it measured, and the bars it held them to — or why it measured nothing. */
1538
+ export type JudgementLine = { status: 'PASS' | 'FAIL'; [figure: string]: unknown } | { status: 'SKIP'; reason: string };
1539
+
1540
+ /** The judgement lines, in the order check.json and the console carry them. */
1541
+ export const JUDGEMENT_LINES = ['BREATH_VISIBLE', 'BLINK_NO_HOLE', 'CHAIN_LAG', 'TIP_OVER_ROOT', 'STILL_REGIONS_DARK', 'TEXTURE_STRETCH'] as const;
1542
+ export type JudgementName = (typeof JUDGEMENT_LINES)[number];
1543
+
1544
+ /**
1545
+ * A line with no bar (issue #25): it reports figures and can never be a FAIL,
1546
+ * so `PASS` does not read it. SKIP, with the reason, when there is nothing to
1547
+ * report from.
1548
+ */
1549
+ export type ReportedLine = { status: 'REPORTED'; [figure: string]: unknown } | { status: 'SKIP'; reason: string };
1550
+
1551
+ /** The reported lines, after the judgement lines, in the order check.json and the console carry them. */
1552
+ export const REPORTED_LINES = ['RECOMPOSITE_HOLES'] as const;
1553
+
1554
+ /** check.json — the reference's fields in its order, then the judgement lines, then PASS. */
1555
+ export interface CheckFigures {
1556
+ gate_spine_html_green: boolean;
1557
+ /** Which rigc entry gated the build ({@link readRigcEntry}): provenance, not a bar. */
1558
+ rigc_entry: RigcEntryRecord;
1559
+ /** How the artifact's page was packed: the `--page-edges` and `--pack-shape` rigc was handed, which its pack line was read to agree with ({@link packEdgeProblems}). Provenance, not a bar. */
1560
+ pack_mode: { page_edges: PageEdges; pack_shape: PackShape };
1561
+ /** Null when the rig has no idle (issue #77); `skipped.loop` then says why. */
1562
+ loop_max_diff: number | null;
1563
+ /**
1564
+ * Written only when the rig declares a physics constraint (issue #183):
1565
+ * each one {@link withoutPhysics} left out of the frames `loop_max_diff` was
1566
+ * read from. Reported, not judged; a rig without one writes the keys it always wrote.
1567
+ */
1568
+ loop_physics?: LoopPhysics[];
1569
+ /** The seam figures: null when there is no parts.json (issue #77); `skipped.seam` then says why. The two counts are frame pixels. */
1570
+ seam_mean: number | null;
1571
+ seam_px_over_40: number | null;
1572
+ seam_px_over_80: number | null;
1573
+ /**
1574
+ * The frame scale s the setup-pose still rendered at (rigc's viewport
1575
+ * scale, to six places), issue #216: the seam bar reads `seam_px_over_40`
1576
+ * divided by its square ({@link seamRigPx}). Null with the other three.
1577
+ */
1578
+ seam_frame_scale: number | null;
1579
+ /**
1580
+ * Written only when the seam bar fails, or when it was asked for (`check
1581
+ * --seam-pairs`), issue #202: the counted pixels split by the pair of parts
1582
+ * that answers for each, worst first ({@link SeamPairRow}), each with its
1583
+ * picture under {@link SEAM_DIR}. A passing run not asked for it writes the
1584
+ * keys it always wrote.
1585
+ */
1586
+ seam_pairs?: SeamPairRow[];
1587
+ BREATH_VISIBLE: JudgementLine;
1588
+ BLINK_NO_HOLE: JudgementLine;
1589
+ CHAIN_LAG: JudgementLine;
1590
+ TIP_OVER_ROOT: JudgementLine;
1591
+ STILL_REGIONS_DARK: JudgementLine;
1592
+ TEXTURE_STRETCH: JudgementLine;
1593
+ RECOMPOSITE_HOLES: ReportedLine;
1594
+ /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1595
+ SETUP_POSE_VS_SOURCE?: ReportedLine;
1596
+ /**
1597
+ * Written only when the loop or the seam measured nothing (issue #77): the
1598
+ * reason for each, by the name its console line goes by. The judgement and
1599
+ * reported lines carry their own SKIP; these two bars have figures, not a
1600
+ * status, so their reasons sit here, and a run that measured both writes
1601
+ * the keys it always wrote.
1602
+ */
1603
+ skipped?: { loop?: string; seam?: string };
1604
+ /** Written only under `--requirements` (issue #93): the file's fps, the summary, and one line per declared requirement, by name. */
1605
+ requirements?: RequirementsBlock;
1606
+ PASS: boolean;
1607
+ }
1608
+
1609
+ /** `check.json`'s `requirements` block: the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
1610
+ export interface RequirementsBlock {
1611
+ fps: number;
1612
+ summary: RequirementsSummary;
1613
+ lines: Record<string, RequirementLine>;
1614
+ }
1615
+
1616
+ /** The bars `PASS` reads, in the order the summary counts them: the gate, the reference's two, then the judgement lines. */
1617
+ export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
1618
+
1619
+ /**
1620
+ * The bars a character's own check may have failed and `compose --accept`
1621
+ * still take (issue #203): every bar of {@link BARS} but the gate, which is
1622
+ * rig-c's and never accepted.
1623
+ */
1624
+ export const ACCEPTABLE_BARS: readonly string[] = BARS.filter((b) => b !== 'gate');
1625
+
1626
+ /**
1627
+ * The bars a `check.json`, as `check` writes it, says were not met, in
1628
+ * {@link BARS} order: the gate when `gate_spine_html_green` is not true, the
1629
+ * loop when `loop_max_diff` is a number other than {@link LOOP_MAX_BAR}, the
1630
+ * seam when its figures fail {@link seamFails}, and each judgement line whose
1631
+ * status is `FAIL`. A bar that measured nothing (null figures, `SKIP`) is not
1632
+ * one of them. Read off the file rather than its problems, because the file is
1633
+ * all a finished build leaves (issue #203).
1634
+ */
1635
+ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1636
+ const out: string[] = [];
1637
+ if (fig.gate_spine_html_green !== true) out.push('gate');
1638
+ if (typeof fig.loop_max_diff === 'number' && fig.loop_max_diff !== LOOP_MAX_BAR) out.push('loop');
1639
+ // A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
1640
+ const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1641
+ if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
1642
+ for (const n of JUDGEMENT_LINES) {
1643
+ const l = fig[n];
1644
+ if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
1645
+ }
1646
+ return out;
1647
+ }
1648
+
1649
+ /** The declared requirements a `check.json` says were not PASS (FAIL or NOT MEASURABLE), by name, in the file's order; none without a `requirements` block. */
1650
+ export function requirementsNotPass(fig: Readonly<Record<string, unknown>>): string[] {
1651
+ const block = fig.requirements;
1652
+ if (typeof block !== 'object' || block === null) return [];
1653
+ const lines = (block as { lines?: unknown }).lines;
1654
+ if (typeof lines !== 'object' || lines === null) return [];
1655
+ return Object.entries(lines as Record<string, unknown>)
1656
+ .filter(([, l]) => typeof l !== 'object' || l === null || (l as { status?: unknown }).status !== 'PASS')
1657
+ .map(([n]) => n);
1658
+ }
1659
+
1660
+ export interface CheckReport {
1661
+ figures: CheckFigures;
1662
+ gateHtml: string[];
1663
+ pack: PackLine[];
1664
+ /** Opaque share (alpha > 0) of each packed page, by page file name. */
1665
+ packOpaque: Array<{ page: string; share: number }>;
1666
+ /** Null when the rig has no idle. */
1667
+ idle: { frames: number; fps: number; duration: number; lastIndex: number; loopAt: { x: number; y: number } } | null;
1668
+ /** The physics line ({@link loopPhysicsLine}); null when the rig declares no physics constraint or has no idle. */
1669
+ loopPhysics: string | null;
1670
+ /** The setup-pose still's grid; null when nothing read the still (no parts.json and no `--source`). */
1671
+ seamViewport: Viewport | null;
1672
+ /** Every bar in {@link BARS} that measured, and every one that did not with its reason. */
1673
+ bars: { measured: string[]; skipped: Array<{ bar: string; reason: string }> };
1674
+ /** Every bar that was not met, one problem each; empty on PASS. */
1675
+ problems: Problem[];
1676
+ written: string[];
1677
+ /** Under `--requirements` only (issue #93): what `check.json`'s `requirements` block holds; null without the flag. */
1678
+ requirements: RequirementsBlock | null;
1679
+ /**
1680
+ * The animations `motion.json` holds beside the idle (issue #183), in its
1681
+ * order: built and gated with the rig, measured by nothing here — every bar
1682
+ * reads the idle. Empty when the idle is alone.
1683
+ */
1684
+ besideIdle: string[];
1685
+ }
1686
+
1687
+ /**
1688
+ * The one line `check` prints when `motion.json` holds animations beside the
1689
+ * idle (issue #183), or null when it holds none, so a rig of the idle alone
1690
+ * prints what it printed before.
1691
+ */
1692
+ export function besideIdleLine(names: readonly string[]): string | null {
1693
+ if (names.length === 0) return null;
1694
+ return `beside the idle: ${names.length} animation(s), ${names.join(', ')} — built and gated by rigc with the rig, not measured: every bar here reads the idle`;
1695
+ }
1696
+
1697
+ /**
1698
+ * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
1699
+ * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
1700
+ * A run that printed nothing is still one problem, never none.
1701
+ */
1702
+ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
1703
+ const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
1704
+ const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
1705
+ const object = `\`rigc ${what}\``;
1706
+ if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing` }];
1707
+ return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}` }));
1708
+ }
1709
+
1710
+ /**
1711
+ * Run the whole check. Refuses (throws a PartsError) when the inputs are
1712
+ * unreadable or a rigc step that everything after it depends on is red — the
1713
+ * gate file is written first, so the refusal's evidence is on disk. Otherwise
1714
+ * it measures everything it can read, writes every output including
1715
+ * `check.json`, and returns the report; `report.problems` names each bar that
1716
+ * was not met, and `figures.PASS` is true exactly when it is empty. `mode`
1717
+ * reaches rigc's `--page-edges` and `--pack-shape` verbatim, a pack line that
1718
+ * disagrees with either is refused (`CHECK_PACK_PAGE_EDGES`,
1719
+ * `CHECK_PACK_SHAPE`), and `check.json` records it as `pack_mode`.
1720
+ *
1721
+ * What it can read (issue #77): the gate always runs, on whatever the spec
1722
+ * declares. Without `parts.json` ({@link readCheckInputs}) the seam, the four
1723
+ * judgement lines that choose regions by tag and `RECOMPOSITE_HOLES` say SKIP
1724
+ * with the reason; without an `idle` the loop and every line that reads idle
1725
+ * frames say SKIP, and no idle is rendered (no `idle_frames/`, `contact.png`
1726
+ * or `motion_heat.png`). `source` (`--source`) adds {@link SOURCE_LINE}.
1727
+ *
1728
+ * `requirements` (`--requirements`, issue #93) is read with the rig directory
1729
+ * and resolved against it before anything is built ({@link readRequirements},
1730
+ * {@link resolveRequirements}); after every line above, each declared
1731
+ * requirement is measured ({@link measureRequirements}) into `requirements/`,
1732
+ * `check.json` gains its `requirements` block, and every FAIL and NOT
1733
+ * MEASURABLE is a problem. Without it nothing here reads, writes or prints
1734
+ * anything it did not before.
1735
+ */
1736
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false): CheckReport {
1737
+ let inp: CheckInputs;
1738
+ let reqFile: RequirementsFile | null = null;
1739
+ let seams: Map<string, SeamPair[]> | null = null;
1740
+ if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source);
1741
+ else {
1742
+ // Both readers collect every problem; one refusal names both sets.
1743
+ const refused: Problem[] = [];
1744
+ const take = <T>(read: () => T): T | null => {
1745
+ try {
1746
+ return read();
1747
+ } catch (err) {
1748
+ if (!(err instanceof PartsError)) throw err;
1749
+ refused.push(...err.problems);
1750
+ return null;
1751
+ }
1752
+ };
1753
+ const got = take(() => readCheckInputs(rigDir, partsHome, source));
1754
+ reqFile = take(() => readRequirements(requirements));
1755
+ refuseIfAny(refused);
1756
+ inp = got as CheckInputs;
1757
+ const file = reqFile as RequirementsFile;
1758
+ const ready = inp;
1759
+ take(() => resolveRequirements(file, ready.rig, ready.motion, ready.rootBone));
1760
+ // A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
1761
+ if (file.requirements.some((r) => r.kind === 'seam')) {
1762
+ const parts = ready.parts;
1763
+ seams = take(() => resolveSeams(file, parts === null ? null : parts.parts.map((p) => p.name), ready.noParts, (n) => {
1764
+ const rec = (parts as PartsFile).parts.find((p) => p.name === n) as PartRecord;
1765
+ return placedArt(rec.x, rec.y, readPng(join(ready.partsDir, `${n}.png`)));
1766
+ }));
1767
+ }
1768
+ refuseIfAny(refused);
1769
+ }
1770
+ const rigcEntry = readRigcEntry(requireRigcVersion(rigc));
1771
+ const out = resolve(outDir);
1772
+ mkdirSync(out, { recursive: true });
1773
+ const buildDir = join(out, 'build');
1774
+ const idleDir = join(out, 'idle_frames');
1775
+ const stillDir = join(out, '_still');
1776
+ const isoDir = join(out, '_isolated');
1777
+ const loopDir = join(out, '_loop');
1778
+ const seamDir = join(out, SEAM_DIR);
1779
+ const keptDir = join(out, STILL_DIR);
1780
+ for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir, seamDir, keptDir]) rmSync(d, { recursive: true, force: true });
1781
+ const written: string[] = [];
1782
+ const write = (name: string, data: string): void => {
1783
+ writeFileSync(join(out, name), data);
1784
+ written.push(name);
1785
+ };
1786
+
1787
+ // 1. build, packed, under the policy profile
1788
+ const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, ...packedBuildArgs(mode)]);
1789
+ const gateHtml = buildGateLines(build.out);
1790
+ write('gate_spine-html.txt', `${gateHtml.join('\n')}\n`);
1791
+ if (build.status !== 0) refuseIfAny(rigcFailed(packedBuildLabel(mode), build, gateHtml));
1792
+ const pack = parsePackLines(gateHtml);
1793
+ refuseIfAny(packEdgeProblems(pack, mode));
1794
+ const packOpaque = pack.filter((p) => existsSync(join(buildDir, p.page))).map((p) => ({ page: p.page, share: opaqueShare(readPng(join(buildDir, p.page))) }));
1795
+
1796
+ // 2. no second gate: `rigc validate --profile spine` of this build measured no rule the build's own two passes
1797
+ // had not (14 of the build's 23 on the demo, rigc 1.5.1 and 2.0.3 alike; selftest CH09 holds it), and on rigc's
1798
+ // core entry — an install without spine-core — `validate` is refused outright
1799
+ const htmlGreen = gateGreen(build.status, gateHtml);
1800
+
1801
+ // What each line reads, and why it cannot when it cannot (issue #77).
1802
+ const full: FullInputs | null = inp.parts !== null && inp.idleDuration !== null ? { ...inp, parts: inp.parts, idleDuration: inp.idleDuration } : null;
1803
+ const lacking = [inp.noParts, inp.noIdle].filter((r): r is string => r !== null).join('; ');
1804
+ const problems: Problem[] = [];
1805
+
1806
+ // 3. the idle, the loop, the heat — only when there is an idle
1807
+ let idle: FrameSet | null = null;
1808
+ let loop: { max: number; x: number; y: number } | null = null;
1809
+ let loopSet: FrameSet | null = null;
1810
+ let loopPhysics: LoopPhysics[] | null = null;
1811
+ if (inp.idleDuration !== null) {
1812
+ const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
1813
+ if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1814
+ idle = readFrameSet(idleDir);
1815
+ const last = idle.frames[idle.frames.length - 1];
1816
+ if (idle.stride !== 1 || idle.written !== idle.sampled || idle.duration !== inp.idleDuration || last.index !== idle.sampled - 1) {
1817
+ refuseIfAny([
1818
+ {
1819
+ code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
1820
+ object: `idle frame ${last.name}`,
1821
+ detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks and every frame written are required`,
1822
+ },
1823
+ ]);
1824
+ }
1825
+ const contact = join(idle.dir, 'contact.png');
1826
+ if (existsSync(contact)) {
1827
+ copyFileSync(contact, join(out, 'contact.png'));
1828
+ written.push('contact.png');
1829
+ }
1830
+ loop = maxRgbDiff(idle.frames[0].image, last.image);
1831
+ loopSet = idle;
1832
+ // The keyed loop (issue #183): with a physics constraint, the same rig and motion with every one left out, built
1833
+ // the same way and rendered the same way, is what frame 0 and the frame at the duration are read from.
1834
+ const stripped = withoutPhysics(inp.rig, inp.motion);
1835
+ if (stripped !== null) {
1836
+ loopPhysics = stripped.physics;
1837
+ try {
1838
+ mkdirSync(loopDir, { recursive: true });
1839
+ const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
1840
+ writeFileSync(join(loopDir, 'rig.json'), JSON.stringify({ ...stripped.rig, images }));
1841
+ writeFileSync(join(loopDir, 'motion.json'), JSON.stringify(stripped.motion));
1842
+ const what = 'the idle with its physics constraint(s) left out';
1843
+ const lb = rigc(['build', '--rig', join(loopDir, 'rig.json'), '--motion', join(loopDir, 'motion.json'), '--out', join(loopDir, 'build'), ...packedBuildArgs(mode)]);
1844
+ if (lb.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, lb, buildGateLines(lb.out)));
1845
+ const lr = rigc(['render', '--candidate', join(loopDir, 'build'), '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(loopDir, 'frames')]);
1846
+ if (lr.status !== 0) refuseIfAny(rigcFailed(`render --animation idle (${what})`, lr, lr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1847
+ const kept = readFrameSet(join(loopDir, 'frames'));
1848
+ const keptLast = kept.frames[kept.frames.length - 1];
1849
+ if (kept.stride !== 1 || kept.written !== kept.sampled || keptLast.index !== last.index) {
1850
+ refuseIfAny([
1851
+ {
1852
+ code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
1853
+ object: `idle frame ${keptLast.name} (${what})`,
1854
+ detail: `rigc sampled ${kept.sampled} frame(s), wrote ${kept.written} at stride ${kept.stride}; the idle as written ends at ${last.name}, and the loop compares frame 0 with that frame`,
1855
+ },
1856
+ ]);
1857
+ }
1858
+ loop = maxRgbDiff(kept.frames[0].image, keptLast.image);
1859
+ loopSet = kept;
1860
+ } finally {
1861
+ rmSync(loopDir, { recursive: true, force: true });
1862
+ }
1863
+ }
1864
+ const idleImages = idle.frames.map((f) => f.image);
1865
+ writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
1866
+ written.push('motion_heat.png');
1867
+ }
1868
+
1869
+ // 3b. the parts a judgement reads alone: each rendered by its own slots, on the whole rig's grid
1870
+ const isolated = (label: string, ps: readonly PartRecord[]): FrameSet => {
1871
+ const dir = join(isoDir, label);
1872
+ const r = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--slot', ps.map((p) => p.name).join(','), '--out', dir]);
1873
+ if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation idle --slot ${ps.map((p) => p.name).join(',')}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
1874
+ return readFrameSet(dir);
1875
+ };
1876
+ let breath: JudgementLine = skip(lacking);
1877
+ let tip: JudgementLine = skip(lacking);
1878
+ if (full !== null) {
1879
+ try {
1880
+ breath = breathLine(full, isolated, problems);
1881
+ if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems);
1882
+ } finally {
1883
+ rmSync(isoDir, { recursive: true, force: true });
1884
+ }
1885
+ }
1886
+ let chain: JudgementLine = skip(inp.noIdle ?? '');
1887
+ let stretch: JudgementLine = skip(inp.noIdle ?? '');
1888
+ let still: JudgementLine = skip(lacking);
1889
+ if (idle !== null) {
1890
+ chain = chainLine(inp, problems);
1891
+ // One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
1892
+ const geometryPath = join(idle.dir, GEOMETRY_FILE);
1893
+ const geometry = readGeometry(geometryPath, idle.written);
1894
+ stretch = stretchLine(geometry, geometryPath, problems);
1895
+ if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems);
1896
+ }
1897
+
1898
+ // 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
1899
+ // the painting — and, beside it, the same pose with the eyes shut
1900
+ let seam: SeamFigures | null = null;
1901
+ let seamPairs: SeamPairRow[] | null = null;
1902
+ let seamViewport: Viewport | null = null;
1903
+ let blink: JudgementLine = skip(lacking);
1904
+ let sourceRead: ReportedLine | null = null;
1905
+ if (inp.parts !== null || inp.source !== null) {
1906
+ try {
1907
+ mkdirSync(stillDir, { recursive: true });
1908
+ const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
1909
+ writeFileSync(join(stillDir, 'rig.json'), JSON.stringify({ ...inp.rig, images }));
1910
+ const eyes = full === null ? [] : partsTagged(full.parts, EYE_TAGS);
1911
+ const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), String(sl.bone)]));
1912
+ const shut = full === null ? [] : blinkTracks(inp.motion, eyeBones(inp.rig, eyes.map((p) => slotBone.get(p.name) ?? '')));
1913
+ const animations: Record<string, unknown> = {
1914
+ still: { duration: STILL_DURATION, loop: false, tracks: [{ bone: inp.rootBone, property: 'rotate', keys: [{ t: 0, v: [0] }, { t: STILL_DURATION, v: [0] }] }] },
1915
+ };
1916
+ if (eyes.length > 0 && shut.length > 0) {
1917
+ animations[BLINK_ANIMATION] = {
1918
+ duration: STILL_DURATION,
1919
+ loop: false,
1920
+ tracks: shut.map((b) => ({ ...b.target, property: 'scaley', keys: [{ t: 0, v: [b.closed] }, { t: STILL_DURATION, v: [b.closed] }] })),
1921
+ };
1922
+ }
1923
+ writeFileSync(join(stillDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations }));
1924
+ const sb = rigc(['build', '--rig', join(stillDir, 'rig.json'), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'build'), '--profile', 'spine']);
1925
+ if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
1926
+ // The stage is the parts' canvas wherever parts.json is read (CHECK_RIG_STAGE_IS_THE_CANVAS), so this is its longest side.
1927
+ const maxSide = Math.ceil(Math.max(inp.stage.width, inp.stage.height));
1928
+ const sr = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'render')]);
1929
+ if (sr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still)', sr, sr.out.split('\n').filter((l) => l.includes('FAIL'))));
1930
+ const stillSet = readFrameSet(join(stillDir, 'render'));
1931
+ seamViewport = stillSet.viewport;
1932
+ if (keepStills) cpSync(join(stillDir, 'render'), keptDir, { recursive: true });
1933
+ if (inp.parts !== null) {
1934
+ const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background);
1935
+ seam = seamFigures(composite, stillSet.frames[0].image, inp.stage, stillSet.viewport, stillSet.background);
1936
+ if (seamPairsAsked || seamFails(round3(seam.mean), seam.over40, round6(stillSet.viewport.scale))) {
1937
+ const placed = inp.parts.parts.map((p) => ({ name: p.name, from: p.from, x: p.x, y: p.y, image: readPng(join(inp.partsDir, `${p.name}.png`)) }));
1938
+ const split = seamPairRows(seam, placed, inp.parts.rig_size, stillSet.viewport);
1939
+ mkdirSync(seamDir, { recursive: true });
1940
+ for (const { row, pixels } of split) writePng(join(out, row.picture), seamPicture(stillSet.frames[0].image, row, pixels));
1941
+ seamPairs = split.map((x) => x.row);
1942
+ }
1943
+ }
1944
+ if (inp.source !== null) {
1945
+ // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
1946
+ writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig({ ...inp.rig, images })));
1947
+ const kb = rigc(['build', '--rig', join(stillDir, BLACK_RIG), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'black_build'), '--profile', 'spine']);
1948
+ if (kb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still, every slot tinted black)', kb, buildGateLines(kb.out)));
1949
+ const kr = rigc(['render', '--candidate', join(stillDir, 'black_build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'black')]);
1950
+ if (kr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still, every slot tinted black)', kr, kr.out.split('\n').filter((l) => l.includes('FAIL'))));
1951
+ const blackSet = readFrameSet(join(stillDir, 'black'));
1952
+ if (!sameViewport(stillSet.viewport, blackSet.viewport) || stillSet.background.join(',') !== blackSet.background.join(',')) {
1953
+ refuseIfAny([
1954
+ {
1955
+ code: 'CHECK_SOURCE_GRID',
1956
+ object: 'the black-tinted setup-pose still',
1957
+ detail: `is on ${JSON.stringify(blackSet.viewport)} over ${JSON.stringify(blackSet.background)}; the setup-pose still is on ${JSON.stringify(stillSet.viewport)} over ${JSON.stringify(stillSet.background)}, and the coverage is read pixel for pixel between the two`,
1958
+ },
1959
+ ]);
1960
+ }
1961
+ const srcr = sourceInRig(inp.source.painting, inp.stage.width, inp.stage.height);
1962
+ const fig = sourceFigures(stillSet.frames[0].image, blackSet.frames[0].image, stillSet.viewport, stillSet.background, inp.stage, srcr);
1963
+ sourceRead = sourceLine(fig, inp.source.painting, inp.stage, stillSet.viewport);
1964
+ }
1965
+ if (full !== null) {
1966
+ const H = full.parts.rig_size[1];
1967
+ if (eyes.length === 0) blink = skip(`no part comes from a See-through ${tagWords(EYE_TAGS)} layer, so there is no eye to look behind`);
1968
+ else if (shut.length === 0) blink = skip(`no idle "scaley" track on the bones of ${quoteParts(eyes)} goes below its first key, so the idle has no blink`);
1969
+ else {
1970
+ const br = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', BLINK_ANIMATION, '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'blink')]);
1971
+ if (br.status !== 0) refuseIfAny(rigcFailed(`render (the setup pose with the eyes shut, "${BLINK_ANIMATION}")`, br, br.out.split('\n').filter((l) => l.includes('FAIL'))));
1972
+ blink = blinkLine(full, eyes, shut, stillSet, readFrameSet(join(stillDir, 'blink')), idle as FrameSet, H, problems);
1973
+ }
1974
+ }
1975
+ } finally {
1976
+ rmSync(stillDir, { recursive: true, force: true });
1977
+ }
1978
+ }
1979
+
1980
+ // 5. the scene's declared requirements (issue #93), only under --requirements
1981
+ const reqProblems: Problem[] = [];
1982
+ const reqBlock = reqFile === null ? null : measureRequirements(inp, reqFile, buildDir, out, rigc, reqProblems, seams ?? new Map());
1983
+
1984
+ const skipped: { loop?: string; seam?: string } = {};
1985
+ if (inp.noIdle !== null) skipped.loop = inp.noIdle;
1986
+ if (inp.noParts !== null) skipped.seam = inp.noParts;
1987
+ const figures: CheckFigures = {
1988
+ gate_spine_html_green: htmlGreen,
1989
+ rigc_entry: rigcEntry,
1990
+ pack_mode: { page_edges: mode.pageEdges, pack_shape: mode.packShape },
1991
+ loop_max_diff: loop === null ? null : loop.max,
1992
+ ...(loopPhysics === null ? {} : { loop_physics: loopPhysics }),
1993
+ seam_mean: seam === null ? null : round3(seam.mean),
1994
+ seam_px_over_40: seam === null ? null : seam.over40,
1995
+ seam_px_over_80: seam === null ? null : seam.over80,
1996
+ seam_frame_scale: seam === null || seamViewport === null ? null : round6(seamViewport.scale),
1997
+ ...(seamPairs === null ? {} : { seam_pairs: seamPairs }),
1998
+ BREATH_VISIBLE: breath,
1999
+ BLINK_NO_HOLE: blink,
2000
+ CHAIN_LAG: chain,
2001
+ TIP_OVER_ROOT: tip,
2002
+ STILL_REGIONS_DARK: still,
2003
+ TEXTURE_STRETCH: stretch,
2004
+ RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
2005
+ ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
2006
+ ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
2007
+ ...(reqBlock === null ? {} : { requirements: reqBlock }),
2008
+ PASS: false,
2009
+ };
2010
+ const barProblems: Problem[] = [];
2011
+ if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(mode), build, gateHtml));
2012
+ if (loop !== null && idle !== null && loopSet !== null && loop.max !== LOOP_MAX_BAR) {
2013
+ const last = idle.frames[idle.frames.length - 1];
2014
+ barProblems.push({
2015
+ code: 'CHECK_LOOP_CLOSES',
2016
+ object: `idle frame f0000.png vs ${last.name} (t = ${idle.duration}s)${loopPhysics === null ? '' : ', its physics constraint(s) left out'}`,
2017
+ detail: `max |d| ${loop.max}/255, first at pixel ${loop.x},${loop.y} of ${loopSet.viewport.pixelWidth}x${loopSet.viewport.pixelHeight}; ${LOOP_MAX_BAR} is required — the idle's last key must equal its first`,
2018
+ });
2019
+ }
2020
+ if (figures.seam_mean !== null && figures.seam_px_over_40 !== null && figures.seam_frame_scale !== null && seamFails(figures.seam_mean, figures.seam_px_over_40, figures.seam_frame_scale)) {
2021
+ const worst = seamPairs === null || seamPairs.length === 0 ? null : seamPairs[0];
2022
+ barProblems.push({
2023
+ code: 'CHECK_SEAM_WITHIN_BAR',
2024
+ object: 'the setup-pose render vs the flat composite of parts/',
2025
+ detail:
2026
+ `mean |d| ${figures.seam_mean}/255 and ${figures.seam_px_over_40} frame px over ${SEAM_PX_LEVEL} (${figures.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${figures.seam_frame_scale}, ${seamRigPxText(figures.seam_px_over_40, figures.seam_frame_scale)} rig px (the count / s²); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required` +
2027
+ (worst === null
2028
+ ? `; no pixel is over ${SEAM_PX_LEVEL}, so the mean alone fails and there is no pair to name`
2029
+ : `; worst of ${(seamPairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)} — every pair is a \`seam pair\` line above and a row of check.json's seam_pairs`),
2030
+ });
2031
+ }
2032
+ const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
2033
+ problems.sort((a, b) => rank(a) - rank(b));
2034
+ problems.unshift(...barProblems);
2035
+ problems.push(...reqProblems);
2036
+ figures.PASS = problems.length === 0;
2037
+ write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
2038
+ const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
2039
+ const tally = (bar: string, reason: string | null): void => {
2040
+ if (reason === null) bars.measured.push(bar);
2041
+ else bars.skipped.push({ bar, reason });
2042
+ };
2043
+ tally('loop', skipped.loop ?? null);
2044
+ tally('seam', skipped.seam ?? null);
2045
+ for (const n of JUDGEMENT_LINES) {
2046
+ const l = figures[n];
2047
+ tally(n, l.status === 'SKIP' ? l.reason : null);
2048
+ }
2049
+ return {
2050
+ figures,
2051
+ gateHtml,
2052
+ pack,
2053
+ packOpaque,
2054
+ idle: idle === null || loop === null ? null : { frames: idle.frames.length, fps: idle.fps, duration: idle.duration, lastIndex: idle.frames[idle.frames.length - 1].index, loopAt: { x: loop.x, y: loop.y } },
2055
+ loopPhysics: loopPhysics === null ? null : loopPhysicsLine(loopPhysics),
2056
+ seamViewport,
2057
+ bars,
2058
+ problems,
2059
+ written,
2060
+ requirements: reqBlock,
2061
+ besideIdle: isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [],
2062
+ };
2063
+ }
2064
+
2065
+ // ---------------------------------------------------------------------------
2066
+ // the scene's declared requirements (issue #93)
2067
+ // ---------------------------------------------------------------------------
2068
+
2069
+ /** Where `check` writes the requirement renders, under `--out`: `as-declared/<animation>/`, `released/<name>/`, `full/<name>/`. */
2070
+ export const REQUIREMENTS_DIR = 'requirements';
2071
+
2072
+ /** The throwaway copies' specs and builds, under `--out`; removed before the stage returns. */
2073
+ const REQUIREMENTS_SCRATCH = '_requirements';
2074
+
2075
+ /**
2076
+ * Measure every requirement of a file {@link resolveRequirements} has passed.
2077
+ * Every pose is rig-c's: each animation a requirement names is rendered
2078
+ * once with `--geometry` at the file's fps, from the rig under test's own
2079
+ * build — or, when the file places scene targets for it, from a copy with
2080
+ * them written in ({@link placeTargets}, built `--profile spine`, as the setup
2081
+ * still is) — into `requirements/as-declared/<animation>/`. Each `follow` adds
2082
+ * two copies, released and full ({@link forceMix}), the scene targets placed
2083
+ * on both, rendered into `requirements/released/<name>/` and
2084
+ * `requirements/full/<name>/`. The released copy's constraint is muted
2085
+ * throughout, which rig-c's A47 (ik) or A48 (transform) refuses unless
2086
+ * the rig declares the mix consumer-driven: when the gate refuses that copy
2087
+ * for that constraint and nothing else ({@link mutedOnly}), the copy — never
2088
+ * the rig under test — declares it in `invariants.consumerDrivenMix`, rigc's
2089
+ * own door, and the line says so. A copy rigc refuses otherwise is refused,
2090
+ * `CHECK_RIGC_GREEN`, quoting rigc. Lines go into the returned block; each
2091
+ * FAIL and NOT MEASURABLE into `problems`.
2092
+ */
2093
+ export function measureRequirements(inp: CheckInputs, file: RequirementsFile, buildDir: string, out: string, rigc: RigcRunner, problems: Problem[], seams: ReadonlyMap<string, readonly SeamPair[]> = new Map()): RequirementsBlock {
2094
+ const reqDir = join(out, REQUIREMENTS_DIR);
2095
+ const scratch = join(out, REQUIREMENTS_SCRATCH);
2096
+ for (const d of [reqDir, scratch]) rmSync(d, { recursive: true, force: true });
2097
+ mkdirSync(reqDir, { recursive: true });
2098
+ mkdirSync(scratch, { recursive: true });
2099
+ const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
2100
+ const base = { ...inp.rig, images };
2101
+ let copies = 0;
2102
+ const buildCopy = (rig: Record<string, unknown>, motion: Record<string, unknown>): { dir: string; call: RigcCall } => {
2103
+ const dir = join(scratch, `copy${copies++}`);
2104
+ mkdirSync(dir, { recursive: true });
2105
+ writeFileSync(join(dir, 'rig.json'), JSON.stringify(rig));
2106
+ writeFileSync(join(dir, 'motion.json'), JSON.stringify(motion));
2107
+ return { dir: join(dir, 'build'), call: rigc(['build', '--rig', join(dir, 'rig.json'), '--motion', join(dir, 'motion.json'), '--out', join(dir, 'build'), '--profile', 'spine']) };
2108
+ };
2109
+ const green = (b: { dir: string; call: RigcCall }, what: string): string => {
2110
+ if (b.call.status !== 0) refuseIfAny(rigcFailed(`build (${what})`, b.call, buildGateLines(b.call.out)));
2111
+ return b.dir;
2112
+ };
2113
+ const posesOf = (candidate: string, animation: string, dir: string, label: string): { poses: Poses; geo: IdleGeometry; path: string } => {
2114
+ const r = rigc(['render', '--candidate', candidate, '--animation', animation, '--fps', String(file.fps), '--max', String(IDLE_MAX_PX), '--geometry', '--out', dir]);
2115
+ if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation ${animation} --geometry (${label})`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
2116
+ const set = readFrameSet(dir);
2117
+ if (set.written !== set.sampled || set.stride !== 1) {
2118
+ refuseIfAny([{ code: 'CHECK_REQUIREMENTS_FRAMES', object: `${dir} (${label})`, detail: `rigc sampled ${set.sampled} frame(s) and wrote ${set.written} at stride ${set.stride}; every sampled frame is required, since a requirement holds over all of them` }]);
2119
+ }
2120
+ const path = join(set.dir, GEOMETRY_FILE);
2121
+ const geo = readGeometry(path, set.written);
2122
+ if (geo === null) return geometryProblem(path, 'does not exist; `rigc render --geometry` writes it, and every requirement is measured from it');
2123
+ const cache = new Map<string, { setup: BoneWorld; frames: BoneWorld[] }>();
2124
+ const bone = (name: string): { setup: BoneWorld; frames: BoneWorld[] } => {
2125
+ const hit = cache.get(name);
2126
+ if (hit !== undefined) return hit;
2127
+ const t = boneTrackOf(geo, path, set, name);
2128
+ if (typeof t === 'string') return geometryProblem(path, t);
2129
+ const v = { setup: t.setup, frames: t.frames };
2130
+ cache.set(name, v);
2131
+ return v;
2132
+ };
2133
+ const root = boneTrackOf(geo, path, set, inp.rootBone);
2134
+ if (typeof root === 'string') return geometryProblem(path, root);
2135
+ return { poses: { label, indices: geo.frames.map((f) => f.index), times: root.times, bone }, geo, path };
2136
+ };
2137
+
2138
+ const lines: Record<string, RequirementLine> = {};
2139
+ try {
2140
+ const placed = new Map<string, { rig: Record<string, unknown>; motion: Record<string, unknown> }>();
2141
+ const placedFor = (animation: string): { rig: Record<string, unknown>; motion: Record<string, unknown> } => {
2142
+ let p = placed.get(animation);
2143
+ if (p === undefined) {
2144
+ p = placeTargets(base, inp.motion, file.targets, animation, inp.stage);
2145
+ placed.set(animation, p);
2146
+ }
2147
+ return p;
2148
+ };
2149
+ const declared = new Map<string, { poses: Poses; geo: IdleGeometry; path: string }>();
2150
+ for (const r of file.requirements) {
2151
+ if (declared.has(r.animation)) continue;
2152
+ const scene = file.targets.some((t) => t.animation === r.animation);
2153
+ const label = scene ? `as declared, the scene targets of "${r.animation}" placed` : 'as declared';
2154
+ const candidate = scene ? green(buildCopy(placedFor(r.animation).rig, placedFor(r.animation).motion), `the rig ${label}`) : buildDir;
2155
+ declared.set(r.animation, posesOf(candidate, r.animation, join(reqDir, 'as-declared', r.animation), label));
2156
+ }
2157
+ for (const r of file.requirements) {
2158
+ const d = declared.get(r.animation) as { poses: Poses; geo: IdleGeometry; path: string };
2159
+ const scene = placedFor(r.animation);
2160
+ const facts = rigFacts(scene.rig, inp.stage);
2161
+ let line: RequirementLine;
2162
+ if (r.kind === 'contact') line = contactLine(r, d.poses, facts);
2163
+ else if (r.kind === 'aim') line = aimLine(r, d.poses, facts);
2164
+ else if (r.kind === 'range') line = rangeLine(r, d.poses, facts);
2165
+ else if (r.kind === 'stretch') line = stretchRequirementLine(r, d.geo.meshes, d.geo.frames, d.poses);
2166
+ else if (r.kind === 'seam') {
2167
+ const shown = attachmentPoses(d.path, d.poses.indices.length);
2168
+ const frames: SeamFrame[] = shown.map((f) => ({ part: f.get(r.part) ?? null, neighbour: f.get(r.neighbour) ?? null }));
2169
+ line = seamLine(r, seams.get(r.name) ?? [], frames, d.poses, inp.stage);
2170
+ } else {
2171
+ const rel = forceMix(scene.rig, scene.motion, r, 0);
2172
+ let relBuild = buildCopy(rel.rig, rel.motion);
2173
+ let door: string | null = null;
2174
+ if (relBuild.call.status !== 0 && mutedOnly(buildGateLines(relBuild.call.out).filter((l) => l.includes('FAIL')).map((l) => l.trim()), r)) {
2175
+ relBuild = buildCopy(declareConsumerDriven(rel.rig, r), rel.motion);
2176
+ door = `${mutedLine(r).rule} refused the released copy (its mix is 0 throughout), so that copy alone declares ${r.constraint_type} constraint "${r.constraint}" in invariants.consumerDrivenMix`;
2177
+ }
2178
+ const released = posesOf(green(relBuild, `the released copy of "${r.name}": ${r.constraint_type} constraint "${r.constraint}" ${r.property} mix forced to 0`), r.animation, join(reqDir, 'released', r.name), 'released');
2179
+ const fullCopy = forceMix(scene.rig, scene.motion, r, 1);
2180
+ const full = posesOf(green(buildCopy(fullCopy.rig, fullCopy.motion), `the full copy of "${r.name}": ${r.constraint_type} constraint "${r.constraint}" ${r.property} mix forced to 1`), r.animation, join(reqDir, 'full', r.name), 'full');
2181
+ const same = (a: Poses, b: Poses): boolean => a.times.length === b.times.length && a.times.every((t, i) => t === b.times[i] && a.indices[i] === b.indices[i]);
2182
+ if (!same(d.poses, released.poses) || !same(d.poses, full.poses)) {
2183
+ refuseIfAny([{ code: 'CHECK_REQUIREMENTS_FRAMES', object: `requirement "${r.name}"`, detail: `the as-declared, released and full renders of "${r.animation}" sample frames at [${d.poses.times.join(', ')}], [${released.poses.times.join(', ')}] and [${full.poses.times.join(', ')}] s; the follow is read frame by frame across the three, so the same frames are required` }]);
2184
+ }
2185
+ line = { ...followLine(r, d.poses, released.poses, full.poses), released_copy: door ?? 'built as forced: rig-c\'s gate passed it with no further declaration' };
2186
+ }
2187
+ lines[r.name] = line;
2188
+ const p = requirementProblem(r, line);
2189
+ if (p !== null) problems.push(p);
2190
+ }
2191
+ } finally {
2192
+ rmSync(scratch, { recursive: true, force: true });
2193
+ }
2194
+ return { fps: file.fps, summary: requirementsSummary(file, new Map(Object.entries(lines))), lines };
2195
+ }
2196
+
2197
+ // ---------------------------------------------------------------------------
2198
+ // the judgement lines (issue #11)
2199
+ // ---------------------------------------------------------------------------
2200
+
2201
+ /**
2202
+ * `RECOMPOSITE_HOLES`: `parts.json`'s `recomposite` block as a line — the
2203
+ * uncovered error pixels, how many holes they make, and the largest hole's box
2204
+ * and bordering parts. Read, not measured: the block is what assemble measured
2205
+ * on the painting, which check never sees. REPORTED, never FAIL, because no
2206
+ * bar for a hole is derivable by the rule the judgement bars follow: both
2207
+ * public examples hold hundreds of holes of a few dozen pixels along part
2208
+ * edges (453 on demo, the largest 70 px; 259 on sample, the largest 94 px),
2209
+ * and whether a hole matters depends on where it is — which the box and the
2210
+ * bordering parts say and a count cannot (AUTHORING §7).
2211
+ */
2212
+ export function holesLine(parts: PartsFile): ReportedLine {
2213
+ const r = parts.recomposite;
2214
+ if (r === undefined) return { status: 'SKIP', reason: 'parts.json has no "recomposite" block (a parts.json the reference wrote has none); re-run assemble, which writes it' };
2215
+ return {
2216
+ status: 'REPORTED',
2217
+ error_px: r.error_px,
2218
+ uncovered_error_px: r.uncovered_error_px,
2219
+ hole_count: r.hole_count,
2220
+ largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]), borders: r.holes[0].borders.map((b) => `${b.part} ${b.px} px`) },
2221
+ };
2222
+ }
2223
+
2224
+ function boxText(h: { x: number; y: number; w: number; h: number }): string {
2225
+ return `${h.x},${h.y} ${h.w}x${h.h}`;
2226
+ }
2227
+
2228
+ /** The throwaway animation that holds the blink tracks at their closed value, rendered beside the setup-pose still. */
2229
+ export const BLINK_ANIMATION = 'blink_shut';
2230
+
2231
+ function skip(reason: string): JudgementLine {
2232
+ return { status: 'SKIP', reason };
2233
+ }
2234
+
2235
+ function tagWords(tags: readonly BaseTag[]): string {
2236
+ return tags.map((t) => `"${t}"`).join(' or ');
2237
+ }
2238
+
2239
+ function quoteParts(ps: readonly PartRecord[]): string {
2240
+ return ps.map((p) => `"${p.name}"`).join(', ');
2241
+ }
2242
+
2243
+ type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
2244
+
2245
+ /** (1) The torso moves while the feet do not: each rendered alone, heat over its own box. */
2246
+ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): JudgementLine {
2247
+ const torso = partsTagged(inp.parts, TORSO_TAGS);
2248
+ const feet = partsTagged(inp.parts, FEET_TAGS);
2249
+ if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`);
2250
+ if (feet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
2251
+ const H = inp.parts.rig_size[1];
2252
+ const ts = isolated('torso', torso);
2253
+ const fs = isolated('feet', feet);
2254
+ const tb = frameBox(torso, H, inp.stage, ts.viewport);
2255
+ const fb = frameBox(feet, H, inp.stage, fs.viewport);
2256
+ if (tb === null || fb === null) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
2257
+ const th = regionHeat(heatField(ts.frames.map((f) => f.image)), ts.viewport.pixelWidth, tb);
2258
+ const fh = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
2259
+ const mean = round3(th.mean);
2260
+ const ok = mean >= BREATH_TORSO_MEAN_FLOOR && fh.max <= BREATH_FEET_MAX_CEILING;
2261
+ if (mean < BREATH_TORSO_MEAN_FLOOR) {
2262
+ problems.push({
2263
+ code: 'CHECK_BREATH_VISIBLE',
2264
+ object: `torso ${quoteParts(torso)} rendered alone, frame box ${boxLabel(tb)}`,
2265
+ detail: `heat mean ${mean}/255 over the idle; >= ${BREATH_TORSO_MEAN_FLOOR} is required — the chest's breath track moves too little, or the torso is not weighted to the bone it keys`,
2266
+ });
2267
+ }
2268
+ if (fh.max > BREATH_FEET_MAX_CEILING) {
2269
+ problems.push({
2270
+ code: 'CHECK_BREATH_VISIBLE',
2271
+ object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}`,
2272
+ detail: `heat max ${fh.max}/255 over the idle; <= ${BREATH_FEET_MAX_CEILING} is required — the feet move: their slot rides, or their mesh is weighted to, a bone the idle keys`,
2273
+ });
2274
+ }
2275
+ return {
2276
+ status: ok ? 'PASS' : 'FAIL',
2277
+ torso_parts: torso.map((p) => p.name),
2278
+ torso_heat_mean: mean,
2279
+ torso_floor: BREATH_TORSO_MEAN_FLOOR,
2280
+ feet_parts: feet.map((p) => p.name),
2281
+ feet_heat_max: fh.max,
2282
+ feet_ceiling: BREATH_FEET_MAX_CEILING,
2283
+ };
2284
+ }
2285
+
2286
+ /**
2287
+ * What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
2288
+ * the idle's posed geometry, not a render of the part. Until #118 the line
2289
+ * read rendered pixels — figures in frame px under `root_px`/`tip_px` — so an
2290
+ * old and a new check.json are told apart by this key and by the units in
2291
+ * the field names.
2292
+ */
2293
+ export const TIP_INSTRUMENT = 'geometry';
2294
+
2295
+ /**
2296
+ * (4) Each swinging part's tip travels further than its root: how far the art
2297
+ * in the lower half of the part's box travels against the upper half — the
2298
+ * centroid of the art's area inside each fixed half, through the posed mesh
2299
+ * ({@link halfTravels}) — read off the idle's `geometry.json`. Until issue
2300
+ * #118 the halves were read off the part rendered alone at `--max 640`, a
2301
+ * binary-coverage centroid per half; every root half on the three examples
2302
+ * travels under a frame pixel, where that centroid scatters by up to 0.3 px
2303
+ * with the grid, so the ratio was set by the grid.
2304
+ */
2305
+ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[]): JudgementLine {
2306
+ const swing = partsTagged(inp.parts, SWING_TAGS);
2307
+ if (swing.length === 0) return skip(`no part comes from a See-through ${tagWords(SWING_TAGS)} layer, so there is no hem or sleeve to swing`);
2308
+ const poses = attachmentPoses(geometryPath, frames);
2309
+ const rows: Array<Record<string, unknown>> = [];
2310
+ let measured = 0;
2311
+ let failed = false;
2312
+ for (const p of swing) {
2313
+ const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
2314
+ const shown = poses.map((f) => f.get(p.name) ?? null);
2315
+ const t = halfTravels(art, p, inp.stage, shown);
2316
+ if (t.root === null || t.tip === null) {
2317
+ rows.push({ part: p.name, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? 'upper' : 'lower'} half of its box holds no art its attachment draws` });
2318
+ continue;
2319
+ }
2320
+ measured++;
2321
+ const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
2322
+ const ok = ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
2323
+ rows.push({ part: p.name, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
2324
+ if (!ok) {
2325
+ failed = true;
2326
+ problems.push({
2327
+ code: 'CHECK_TIP_OVER_ROOT',
2328
+ object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry`,
2329
+ detail: `the art in its lower half travels ${round3(t.tip)} rig px and in its upper half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
2330
+ });
2331
+ }
2332
+ }
2333
+ if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
2334
+ return { status: failed ? 'FAIL' : 'PASS', instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
2335
+ }
2336
+
2337
+ /**
2338
+ * The bone the face is measured in the frame of (issue #33): the bone every
2339
+ * `face`-tagged part's slot rides in rig.json — which is the bone the config's
2340
+ * `regions.<part>` (or, for a mesh, its first segment) names for it, carried
2341
+ * into the rig spec by the rig stage. Never a bone found by its name.
2342
+ */
2343
+ export type HeadBone = { bone: string } | { none: string };
2344
+
2345
+ export function headBoneOf(inp: Pick<FullInputs, 'rig' | 'parts'>): HeadBone {
2346
+ const face = partsTagged(inp.parts, FACE_TAGS);
2347
+ if (face.length === 0) return { none: `no part comes from a See-through ${tagWords(FACE_TAGS)} layer` };
2348
+ const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), typeof sl.bone === 'string' ? sl.bone : null]));
2349
+ const bones = [...new Set(face.map((p) => slotBone.get(p.name) ?? null))];
2350
+ if (bones.length === 1 && bones[0] !== null) return { bone: bones[0] };
2351
+ return { none: `the face parts ride ${face.map((p) => `"${p.name}" on ${JSON.stringify(slotBone.get(p.name) ?? null)}`).join(', ')}; the head frame is one bone's, so they must share a slot bone` };
2352
+ }
2353
+
2354
+ /**
2355
+ * One bone's world transform at the setup pose and on every frame of a set,
2356
+ * off the geometry {@link readGeometry} read beside those frames. A string
2357
+ * (the reason) when the set has no geometry file; a `CHECK_GEOMETRY_FILE`
2358
+ * refusal when the file disagrees with the frames it sits beside in what the
2359
+ * face half needs and the stretch does not read: the viewport (the transforms
2360
+ * would be carried onto another grid), the frame indices, the bone on the
2361
+ * setup pose and on every frame with six finite numbers, and each frame's time.
2362
+ */
2363
+ export function boneTrackOf(geo: IdleGeometry | null, path: string, set: FrameSet, bone: string): BoneTrack | string {
2364
+ if (geo === null) return `${path} does not exist, so no bone's per-frame transform is known (rigc render writes it with --geometry)`;
2365
+ const gv = geo.viewport;
2366
+ const vp = set.viewport;
2367
+ const keys: Array<keyof Viewport> = ['x', 'y', 'width', 'height', 'scale', 'pixelWidth', 'pixelHeight'];
2368
+ const off = keys.filter((k) => gv?.[k] !== vp[k]);
2369
+ if (off.length > 0) return geometryProblem(path, `viewport ${off.map((k) => `${k} ${JSON.stringify(gv?.[k])}`).join(', ')}; the frames.json beside it says ${off.map((k) => `${k} ${vp[k]}`).join(', ')} — the transforms would be carried onto another grid`);
2370
+ const world = (bones: unknown, at: string): BoneWorld => {
2371
+ const b = Array.isArray(bones) ? bones.filter(isRecord).find((x) => x.name === bone) : undefined;
2372
+ if (b === undefined) return geometryProblem(path, `${at} has no bone "${bone}"`);
2373
+ const f = ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const;
2374
+ const bad = f.filter((k) => typeof b[k] !== 'number' || !Number.isFinite(b[k]));
2375
+ if (bad.length > 0) return geometryProblem(path, `${at} bone "${bone}" field(s) ${bad.map((k) => `"${k}" ${JSON.stringify(b[k])}`).join(', ')}; finite numbers are required`);
2376
+ return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
2377
+ };
2378
+ if (geo.frames.length !== set.frames.length || geo.frames.some((f, i) => f.index !== set.frames[i].index)) {
2379
+ return geometryProblem(path, `holds frame(s) [${geo.frames.map((f) => String(f.index)).join(', ')}]; the set holds [${set.frames.map((f) => f.index).join(', ')}]`);
2380
+ }
2381
+ const badTime = geo.times.findIndex((t) => typeof t !== 'number' || !Number.isFinite(t));
2382
+ if (badTime >= 0) return geometryProblem(path, `frame ${String(geo.frames[badTime].index)} field "time" is ${JSON.stringify(geo.times[badTime])}; a number of seconds is required`);
2383
+ return { bone, setup: world(geo.setupBones, 'setup'), frames: geo.frameBones.map((bs, i) => world(bs, `frame ${String(geo.frames[i].index)}`)), times: geo.times as number[] };
2384
+ }
2385
+
2386
+ /** {@link readGeometry} then {@link boneTrackOf}, for a frame set's own `geometry.json`. */
2387
+ export function readBoneTrack(set: FrameSet, bone: string): BoneTrack | string {
2388
+ const path = join(set.dir, GEOMETRY_FILE);
2389
+ return boneTrackOf(readGeometry(path, set.written), path, set, bone);
2390
+ }
2391
+
2392
+ /**
2393
+ * What `STILL_REGIONS_DARK` is measured with, written into each half (issue
2394
+ * #123): the idle's posed geometry, not its frames. Until #123 the face half
2395
+ * compared a heat mean in the head's frame with twice the resampler's own and
2396
+ * the feet half a screen-space heat mean with a ceiling, both read off the
2397
+ * idle's pixels, so an old and a new check.json are told apart by this key.
2398
+ */
2399
+ export const STILL_INSTRUMENT = 'geometry';
2400
+
2401
+ /** Four significant figures: a figure that may be a rounding residue near 1e-12 keeps its size rather than reading 0. */
2402
+ function sig4(x: number): number {
2403
+ return x === 0 ? 0 : Number(x.toPrecision(4));
2404
+ }
2405
+
2406
+ /**
2407
+ * The slots the idle moves on purpose, by slot rather than by bone: every slot
2408
+ * a `deform` or `sequence` entry or a slot-targeted track of the idle names.
2409
+ * A still-set candidate rides the head bone alone, so a bone track cannot move
2410
+ * it relative to that bone; what can is a key on the slot itself.
2411
+ */
2412
+ function idleDrivenSlots(motion: Record<string, unknown>): Set<string> {
2413
+ const anims = isRecord(motion.animations) ? motion.animations : {};
2414
+ const idle = isRecord(anims.idle) ? anims.idle : {};
2415
+ const named = new Set<string>();
2416
+ for (const key of ['deform', 'sequence', 'tracks']) {
2417
+ const list = idle[key];
2418
+ if (!Array.isArray(list)) continue;
2419
+ for (const t of list) if (isRecord(t) && typeof t.slot === 'string') named.add(t.slot);
2420
+ }
2421
+ return named;
2422
+ }
2423
+
2424
+ /**
2425
+ * Whether every attachment the default skin gives a slot is carried by one
2426
+ * bone alone: a region or an unweighted mesh on a slot of that bone, or a mesh
2427
+ * whose every weight names it.
2428
+ */
2429
+ function ridesOnly(rig: Record<string, unknown>, slot: string, slotBone: string | undefined, bone: string): boolean {
2430
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2431
+ const skin = isRecord(skins.default) ? skins.default : {};
2432
+ const atts = isRecord(skin[slot]) ? Object.values(skin[slot]) : [];
2433
+ if (atts.length === 0) return false;
2434
+ return atts.every((a) => {
2435
+ if (!isRecord(a)) return false;
2436
+ const type = a.type ?? 'region';
2437
+ if (type === 'region') return slotBone === bone;
2438
+ if (type !== 'mesh') return false;
2439
+ if (!Array.isArray(a.weights)) return slotBone === bone;
2440
+ return a.weights.every((v) => Array.isArray(v) && v.length > 0 && v.every((w) => isRecord(w) && w.bone === bone));
2441
+ });
2442
+ }
2443
+
2444
+ /** One half's figures (issue #123): the still set's displacement in the reference frame, its tolerance, and the reported crossings. */
2445
+ interface StillHalf {
2446
+ figures: Record<string, unknown>;
2447
+ over: Array<{ slot: string; max: number; frame: number }>;
2448
+ rms: number;
2449
+ tolerance: number;
2450
+ }
2451
+
2452
+ /**
2453
+ * (7) Nothing moves that should hold still (issue #123), read off the idle's
2454
+ * posed geometry. The face half asks whether the face and the parts drawn
2455
+ * over it that the head carries keep their place relative to the head bone —
2456
+ * the bone the face's slot rides — over the idle; the feet half whether the
2457
+ * feet keep theirs on the screen (they ride the root, which the idle does not
2458
+ * key, so the screen is their own frame).
2459
+ *
2460
+ * The face's still set, derived from the rig and the motion, never from a
2461
+ * list: the `face` slots, and every slot drawn after the first of them whose
2462
+ * art overlaps a face part's art at the setup pose, whose every attachment the
2463
+ * head bone alone carries ({@link ridesOnly}), and which the idle does not key
2464
+ * by slot ({@link idleDrivenSlots}, reported as `driven_slots`). The features
2465
+ * the blink and the brows move ride bones of their own, so they are not in it.
2466
+ * The feet's still set is the `footwear` slots.
2467
+ *
2468
+ * Each still slot's art is carried through its posed attachment back into the
2469
+ * reference frame on every idle frame and compared with its setup position
2470
+ * ({@link stillReading}); the figure is the largest displacement, with the RMS
2471
+ * beside it, and the bar is the arithmetic's own tolerance
2472
+ * ({@link stillTolerance}) — nothing may move. How far another slot's art
2473
+ * swings into the region (`face` on top at the setup pose, clear of where the
2474
+ * features go; `footwear` on top) is reported per slot, `crossing`, with no
2475
+ * bar ({@link crossingOf}). No render grid enters either half.
2476
+ */
2477
+ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[]): JudgementLine {
2478
+ const face = partsTagged(inp.parts, FACE_TAGS);
2479
+ const feet = partsTagged(inp.parts, FEET_TAGS);
2480
+ if (face.length === 0 && feet.length === 0) return skip(`no part comes from a See-through ${tagWords([...FACE_TAGS, ...FEET_TAGS])} layer, so there is no still region to read`);
2481
+ if (geo === null) return skip(`${geometryPath} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the still regions are read off it`);
2482
+ const H = inp.parts.rig_size[1];
2483
+ const W = inp.parts.rig_size[0];
2484
+ const poses = attachmentPoses(geometryPath, idle.written);
2485
+ const top = topmostIndex(inp.parts, (i) => readPng(join(inp.partsDir, `${inp.parts.parts[i].name}.png`)));
2486
+ const index = new Map(inp.parts.parts.map((p, i) => [p.name, i]));
2487
+ const record = new Map(inp.parts.parts.map((p) => [p.name, p]));
2488
+ const slots = (Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => ({ name: String(sl.name), bone: typeof sl.bone === 'string' ? sl.bone : undefined }));
2489
+ const slotBone = new Map(slots.map((sl) => [sl.name, sl.bone]));
2490
+ const arts = new Map<string, ArtMask>();
2491
+ const artOf = (name: string): ArtMask => {
2492
+ let a = arts.get(name);
2493
+ if (a === undefined) {
2494
+ const p = record.get(name) as PartRecord;
2495
+ a = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${name}.png`)));
2496
+ arts.set(name, a);
2497
+ }
2498
+ return a;
2499
+ };
2500
+ const shownOf = (slot: string): Array<PosedTriangles | null> => poses.map((f) => f.get(slot) ?? null);
2501
+ const frameIndex = (k: number | null): number | null => (k === null ? null : idle.frames[k].index);
2502
+
2503
+ /** The still set measured in one frame, and every later slot's crossing into `region`. */
2504
+ const half = (still: readonly string[], firstSlot: number, maps: readonly FrameMap[], entryMax: number, coordFloor: number, region: RigRegion): StillHalf => {
2505
+ const rows: Array<Record<string, unknown>> = [];
2506
+ const over: StillHalf['over'] = [];
2507
+ let worst: { slot: string; max: number; frame: number | null } | null = null;
2508
+ let sumSq = 0;
2509
+ let weight = 0;
2510
+ let coordMax = coordFloor;
2511
+ const readings = still.map((slot) => ({ slot, r: stillReading(artOf(slot), inp.stage, shownOf(slot), maps) }));
2512
+ for (const { r } of readings) coordMax = Math.max(coordMax, r.coordMax);
2513
+ const tolerance = stillTolerance(coordMax, entryMax);
2514
+ for (const { slot, r } of readings) {
2515
+ if (r.max === null) {
2516
+ rows.push({ slot, unmeasured: 'no idle frame shows it' });
2517
+ continue;
2518
+ }
2519
+ sumSq += r.sumSq;
2520
+ weight += r.weight;
2521
+ rows.push({ slot, max_rig_px: sig4(r.max), rms_rig_px: sig4(r.weight > 0 ? Math.sqrt(r.sumSq / r.weight) : 0) });
2522
+ if (worst === null || r.max > worst.max) worst = { slot, max: r.max, frame: frameIndex(r.frame) };
2523
+ if (r.max > tolerance) over.push({ slot, max: r.max, frame: frameIndex(r.frame) as number });
2524
+ }
2525
+ const crossing: Array<Record<string, unknown>> = [];
2526
+ for (const sl of slots.slice(firstSlot + 1)) {
2527
+ if (still.includes(sl.name) || !record.has(sl.name)) continue;
2528
+ const c = crossingOf(artOf(sl.name), inp.stage, shownOf(sl.name), maps, region);
2529
+ // Art that only touches the region within the arithmetic's tolerance has not entered it.
2530
+ if (c !== null && c.depth > tolerance) crossing.push({ slot: sl.name, depth_rig_px: round3(c.depth), area_rig_px2: round3(c.area), frame: frameIndex(c.frame) });
2531
+ }
2532
+ const rms = weight > 0 ? Math.sqrt(sumSq / weight) : 0;
2533
+ return {
2534
+ figures: {
2535
+ instrument: STILL_INSTRUMENT,
2536
+ still_slots: rows,
2537
+ max_rig_px: worst === null ? null : sig4(worst.max),
2538
+ // Where the largest displacement is, only when it is a motion: below the tolerance it is rounding, and its place is noise.
2539
+ at: worst === null || worst.max <= tolerance ? null : { slot: worst.slot, frame: worst.frame },
2540
+ rms_rig_px: sig4(rms),
2541
+ tolerance_rig_px: sig4(tolerance),
2542
+ crossing,
2543
+ },
2544
+ over,
2545
+ rms,
2546
+ tolerance,
2547
+ };
2548
+ };
2549
+ const regionOf = (idx: ReadonlySet<number>, exclude: readonly WorldBox[]): RigRegion => {
2550
+ const data = new Uint8Array(W * H);
2551
+ for (let v = 0; v < H; v++) {
2552
+ for (let u = 0; u < W; u++) {
2553
+ if (!idx.has(top[v * W + u])) continue;
2554
+ const x0 = inp.stage.x + u;
2555
+ const y1 = inp.stage.y + cropToSpineY(v, H);
2556
+ if (exclude.some((e) => x0 + 1 > e.x0 && x0 < e.x1 && y1 > e.y0 && y1 - 1 < e.y1)) continue;
2557
+ data[v * W + u] = 1;
2558
+ }
2559
+ }
2560
+ return { width: W, height: H, data };
2561
+ };
2562
+ const firstOf = (names: readonly string[]): number => Math.min(...names.map((n) => slots.findIndex((sl) => sl.name === n)).filter((i) => i >= 0));
2563
+
2564
+ const faceHalf = (): Record<string, unknown> | string => {
2565
+ if (face.length === 0) return `no part comes from a See-through ${tagWords(FACE_TAGS)} layer`;
2566
+ const head = headBoneOf(inp);
2567
+ if (!('bone' in head)) return `no head bone to measure it in: ${head.none}`;
2568
+ const track = boneTrackOf(geo, geometryPath, idle, head.bone);
2569
+ if (typeof track === 'string') return track;
2570
+ const maps: FrameMap[] = [];
2571
+ for (const f of track.frames) {
2572
+ const m = setupToFrame(track.setup, f);
2573
+ if (m === null) return `the head bone "${head.bone}" has a singular setup transform, so there is no head frame to carry the face into`;
2574
+ maps.push(m);
2575
+ }
2576
+ const entries = [track.setup, ...track.frames];
2577
+ const entryMax = Math.max(...entries.map((b) => Math.max(Math.abs(b.a), Math.abs(b.b), Math.abs(b.c), Math.abs(b.d))));
2578
+ const coordFloor = Math.max(...entries.map((b) => Math.max(Math.abs(b.worldX), Math.abs(b.worldY))));
2579
+ // The features are left out of the region where they go in the head's frame, not only where they sit at rest.
2580
+ const features = partsTagged(inp.parts, FACE_FEATURE_TAGS);
2581
+ const swept: WorldBox[] = [];
2582
+ for (const p of features) {
2583
+ const ft = boneTrackOf(geo, geometryPath, idle, slotBone.get(p.name) ?? '');
2584
+ if (typeof ft === 'string') return `no track for feature "${p.name}"'s bone: ${ft}`;
2585
+ const sb = sweptBox(partWorldBox(p, H, inp.stage), ft, track);
2586
+ if (sb === null) return `feature "${p.name}"'s bone or the head bone "${head.bone}" has a singular transform on a frame, so where the feature goes in the head's frame is not known`;
2587
+ swept.push(sb);
2588
+ }
2589
+ const faceNames = face.map((p) => p.name);
2590
+ const first = firstOf(faceNames);
2591
+ const faceArt = faceNames.map(artOf);
2592
+ const overFace = (name: string): boolean => {
2593
+ const a = artOf(name);
2594
+ return faceArt.some((f) => {
2595
+ for (let y = Math.max(a.y, f.y); y < Math.min(a.y + a.height, f.y + f.height); y++) {
2596
+ for (let x = Math.max(a.x, f.x); x < Math.min(a.x + a.width, f.x + f.width); x++) if (a.data[(y - a.y) * a.width + x - a.x] === 1 && f.data[(y - f.y) * f.width + x - f.x] === 1) return true;
2597
+ }
2598
+ return false;
2599
+ });
2600
+ };
2601
+ const drivenNames = idleDrivenSlots(inp.motion);
2602
+ const driven: string[] = [];
2603
+ const still = [...faceNames];
2604
+ for (const sl of slots.slice(first + 1)) {
2605
+ if (still.includes(sl.name) || !record.has(sl.name) || !ridesOnly(inp.rig, sl.name, sl.bone, head.bone) || !overFace(sl.name)) continue;
2606
+ if (drivenNames.has(sl.name)) driven.push(sl.name);
2607
+ else still.push(sl.name);
2608
+ }
2609
+ const h = half(still, first, maps, entryMax, coordFloor, regionOf(new Set(face.map((p) => index.get(p.name) as number)), swept));
2610
+ for (const o of h.over) {
2611
+ problems.push({
2612
+ code: 'CHECK_STILL_REGIONS_DARK',
2613
+ object: `slot "${o.slot}" of the face's still set (${still.map((n) => `"${n}"`).join(', ')}), in the frame of its head bone "${head.bone}"`,
2614
+ detail: `its art moves ${sig4(o.max)} rig px relative to that bone at idle frame ${o.frame} (RMS over the still set ${sig4(h.rms)}); at most ${sig4(h.tolerance)} rig px is required — the arithmetic's own rounding, so nothing may move — a mesh over the face weighted to a bone the head does not carry, or the face mesh deformed by another bone`,
2615
+ });
2616
+ }
2617
+ return { parts: faceNames, head_bone: head.bone, ...h.figures, driven_slots: driven };
2618
+ };
2619
+
2620
+ const feetHalf = (): Record<string, unknown> | string => {
2621
+ if (feet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
2622
+ const names = feet.map((p) => p.name);
2623
+ const h = half(names, firstOf(names), poses.map(() => null), 1, 0, regionOf(new Set(feet.map((p) => index.get(p.name) as number)), []));
2624
+ for (const o of h.over) {
2625
+ problems.push({
2626
+ code: 'CHECK_STILL_REGIONS_DARK',
2627
+ object: `slot "${o.slot}" of the feet (${names.map((n) => `"${n}"`).join(', ')}), in screen space`,
2628
+ detail: `its art moves ${sig4(o.max)} rig px at idle frame ${o.frame} (RMS over the feet ${sig4(h.rms)}); at most ${sig4(h.tolerance)} rig px is required — the arithmetic's own rounding — something that should hold still moves: a mesh weighted to a swinging bone, or a part that rides the wrong bone`,
2629
+ });
2630
+ }
2631
+ return { parts: names, ...h.figures };
2632
+ };
2633
+
2634
+ const f = faceHalf();
2635
+ const t = feetHalf();
2636
+ if (typeof f === 'string' && typeof t === 'string') return skip(`face: ${f}; feet: ${t}`);
2637
+ const failed = problems.some((q) => q.code === 'CHECK_STILL_REGIONS_DARK');
2638
+ return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { unmeasured: t } : t };
2639
+ }
2640
+
2641
+ function fmt3(x: number): string {
2642
+ return x.toFixed(3);
2643
+ }
2644
+
2645
+ /** (3) Down every chain the phase lags and the amplitude does not shrink, read off motion.json's rotate tracks. */
2646
+ export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Problem[]): JudgementLine {
2647
+ const cf = chainFigures(inp.rig, inp.motion);
2648
+ const long = cf.chains.filter((c) => c.length >= 2);
2649
+ if (cf.edges.length === 0 && long.length === 0) {
2650
+ return skip(`no idle "rotate" track sits under another at the same period${cf.unread.length > 0 ? ` (unread, not sampled sines: ${cf.unread.join(', ')})` : ''}, so there is no chain to lag`);
2651
+ }
2652
+ const violations: string[] = [];
2653
+ for (const e of cf.edges) {
2654
+ if (round3(e.step) < CHAIN_LAG_MIN_STEP) {
2655
+ const v = `"${e.child}" follows its keyed ancestor "${e.parent}" by ${fmt3(e.step)} cycle(s)`;
2656
+ violations.push(v);
2657
+ problems.push({ code: 'CHECK_CHAIN_LAG', object: `bone "${e.child}" under "${e.parent}"`, detail: `phase step ${fmt3(e.step)} cycle(s); >= ${CHAIN_LAG_MIN_STEP} is required — a link must lag the one above it (a larger "phase", or a positive "lag" on the chain track)` });
2658
+ }
2659
+ }
2660
+ for (const c of long) {
2661
+ for (let i = 1; i < c.length; i++) {
2662
+ const a = round3(c[i - 1].reading.amp);
2663
+ const b = round3(c[i].reading.amp);
2664
+ if (b < a) {
2665
+ violations.push(`"${c[i].bone}" swings ${fmt3(b)} under "${c[i - 1].bone}"'s ${fmt3(a)}`);
2666
+ problems.push({ code: 'CHECK_CHAIN_LAG', object: `bone "${c[i].bone}" in the chain from "${c[0].bone}"`, detail: `amplitude ${fmt3(b)} below the link above's ${fmt3(a)}; non-decreasing down the chain is required — "amps" grow toward the tip` });
2667
+ }
2668
+ }
2669
+ }
2670
+ const minStep = cf.edges.length === 0 ? null : round3(Math.min(...cf.edges.map((e) => e.step)));
2671
+ return {
2672
+ status: violations.length === 0 ? 'PASS' : 'FAIL',
2673
+ chains: long.map((c) => `${c.map((l) => l.bone).join(' > ')}: period ${round3(c[0].reading.period)}s, phase ${c.map((l) => fmt3(l.reading.phase)).join(' ')}, amp ${c.map((l) => fmt3(l.reading.amp)).join(' ')}`),
2674
+ lags: cf.edges.map((e) => `${e.parent} > ${e.child} ${e.step >= 0 ? '+' : ''}${fmt3(e.step)}`),
2675
+ min_step: minStep,
2676
+ step_floor: CHAIN_LAG_MIN_STEP,
2677
+ other_period: cf.otherPeriod.map((e) => `${e.parent} > ${e.child}`),
2678
+ unread: cf.unread,
2679
+ first_violation: violations[0] ?? null,
2680
+ };
2681
+ }
2682
+
2683
+ function sameViewport(a: Viewport, b: Viewport): boolean {
2684
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height && a.scale === b.scale && a.pixelWidth === b.pixelWidth && a.pixelHeight === b.pixelHeight;
2685
+ }
2686
+
2687
+ /** (2) The setup pose with the blink tracks held at their closed value, against the setup pose, inside the eye box. */
2688
+ function blinkLine(
2689
+ inp: CheckInputs,
2690
+ eyes: readonly PartRecord[],
2691
+ shut: ReturnType<typeof blinkTracks>,
2692
+ open: FrameSet,
2693
+ closed: FrameSet,
2694
+ idle: FrameSet,
2695
+ H: number,
2696
+ problems: Problem[],
2697
+ ): JudgementLine {
2698
+ if (!sameViewport(open.viewport, closed.viewport)) return skip(`the closed-eye render's grid ${JSON.stringify(closed.viewport)} is not the setup still's ${JSON.stringify(open.viewport)}, so the two cannot be compared pixel for pixel`);
2699
+ const box = frameBox(eyes, H, inp.stage, open.viewport);
2700
+ if (box === null) return skip('the eye box falls outside the setup-pose frame');
2701
+ const b = blinkFigures(open.frames[0].image, closed.frames[0].image, open.background, box, BLINK_PATCH_LEVEL);
2702
+ const from = Math.max(...shut.map((s) => s.window[0]));
2703
+ const to = Math.min(...shut.map((s) => s.window[1]));
2704
+ const frames = framesInside(from, to, idle.fps, idle.sampled);
2705
+ if (b.holePx > BLINK_HOLE_BAR) {
2706
+ problems.push({
2707
+ code: 'CHECK_BLINK_NO_HOLE',
2708
+ object: `the eye box ${boxLabel(box)} (${quoteParts(eyes)}) with the eyes shut`,
2709
+ detail: `${b.holePx} px show the background where the open eye had art; ${BLINK_HOLE_BAR} is required — the layer under the eye has no art there (See-through left it empty), so a closed eye opens a hole`,
2710
+ });
2711
+ }
2712
+ return {
2713
+ status: b.holePx > BLINK_HOLE_BAR ? 'FAIL' : 'PASS',
2714
+ eye_parts: eyes.map((p) => p.name),
2715
+ closed: shut.map((s) => `${'bone' in s.target ? `bone ${s.target.bone}` : `group ${s.target.group}`} scaley ${s.closed} from ${s.window[0]}s to ${s.window[1]}s`),
2716
+ idle_frames_closed: frames,
2717
+ box_px: b.px,
2718
+ hole_px: b.holePx,
2719
+ hole_bar: BLINK_HOLE_BAR,
2720
+ patch_max: b.patchMax,
2721
+ patch_px_over_40: b.patchOver,
2722
+ };
2723
+ }
2724
+
2725
+ // ---------------------------------------------------------------------------
2726
+ // texture stretch (issue #31): rigc's geometry export
2727
+ // ---------------------------------------------------------------------------
2728
+
2729
+ /** `major.minor.patch` of a `rigc --version` line, or null when it holds none. */
2730
+ export function parseRigcVersion(out: string): [number, number, number] | null {
2731
+ const m = /^\s*(\d+)\.(\d+)\.(\d+)/.exec(out);
2732
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
2733
+ }
2734
+
2735
+ /**
2736
+ * Refuse, before anything is built, a rigc older than
2737
+ * {@link RIGC_GEOMETRY_VERSION}: its `render` has no `--geometry`, so the idle
2738
+ * render would fail on the flag after the build had run, and
2739
+ * the refusal would name a flag rather than the version that lacks it.
2740
+ * Returns what `--version` printed, stdout then stderr, which is where
2741
+ * {@link readRigcEntry} reads the entry.
2742
+ */
2743
+ export function requireRigcVersion(rigc: RigcRunner): string {
2744
+ const v = rigc(['--version']);
2745
+ const got = v.status === 0 ? parseRigcVersion(v.out) : null;
2746
+ const need = parseRigcVersion(RIGC_GEOMETRY_VERSION) as [number, number, number];
2747
+ const below = got !== null && (got[0] - need[0] || got[1] - need[1] || got[2] - need[2]) < 0;
2748
+ if (got === null || below) {
2749
+ refuseIfAny([
2750
+ {
2751
+ code: 'CHECK_RIGC_VERSION',
2752
+ object: '`rigc --version`',
2753
+ detail: `${got === null ? `exit ${v.status}, printed ${JSON.stringify(v.out.trim().split('\n')[0] ?? '')} — no version` : `is ${got.join('.')}`}; rig-c ${RIGC_GEOMETRY_VERSION} or later is required — its \`render --geometry\` writes the skinned mesh vertices TEXTURE_STRETCH is measured from (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
2754
+ },
2755
+ ]);
2756
+ }
2757
+ return v.out;
2758
+ }
2759
+
2760
+ export interface IdleGeometry {
2761
+ meshes: MeshRest[];
2762
+ frames: GeometryPose[];
2763
+ /** The rest, carried as read for {@link boneTrackOf}, which checks what it uses: the viewport, the setup pose's and each frame's `bones`, each frame's `time`. */
2764
+ viewport: Record<string, unknown> | null;
2765
+ setupBones: unknown;
2766
+ frameBones: unknown[];
2767
+ times: unknown[];
2768
+ }
2769
+
2770
+ function geometryProblem(object: string, detail: string): never {
2771
+ refuseIfAny([{ code: 'CHECK_GEOMETRY_FILE', object, detail }]);
2772
+ throw new Error('unreachable');
2773
+ }
2774
+
2775
+ function numberArray(v: unknown): v is number[] {
2776
+ return Array.isArray(v) && v.every((x) => typeof x === 'number' && Number.isFinite(x));
2777
+ }
2778
+
2779
+ /**
2780
+ * The mesh half of a `rigc-geometry/1` file: every `rest` entry of kind
2781
+ * `mesh`, and each frame's attachments. Null when the file does not exist
2782
+ * (the line then says SKIP, naming it); a file that exists and does not hold
2783
+ * `frames` frames of well-formed vertices is refused naming what is wrong,
2784
+ * because a figure read off a half-written export would be a wrong number
2785
+ * printed as a measurement.
2786
+ */
2787
+ export function readGeometry(path: string, frames: number): IdleGeometry | null {
2788
+ if (!existsSync(path)) return null;
2789
+ let raw: unknown;
2790
+ try {
2791
+ raw = JSON.parse(readFileSync(path, 'utf8'));
2792
+ } catch (err) {
2793
+ return geometryProblem(path, `does not parse as JSON: ${(err as Error).message}`);
2794
+ }
2795
+ if (!isRecord(raw) || raw.spec !== GEOMETRY_SPEC) return geometryProblem(path, `"spec" is ${JSON.stringify(isRecord(raw) ? raw.spec : raw)?.slice(0, 40)}; ${JSON.stringify(GEOMETRY_SPEC)} is required`);
2796
+ if (!Array.isArray(raw.rest) || !Array.isArray(raw.frames)) return geometryProblem(path, 'lacks "rest" or "frames"; a rigc-geometry/1 file carries both');
2797
+ if (raw.frames.length !== frames) return geometryProblem(path, `holds ${raw.frames.length} frame(s); the frame set beside it wrote ${frames}`);
2798
+ const meshes: MeshRest[] = [];
2799
+ for (const [i, r] of raw.rest.entries()) {
2800
+ if (!isRecord(r) || r.kind !== 'mesh') continue;
2801
+ const at = `rest[${i}] (slot ${JSON.stringify(r.slot)}, attachment ${JSON.stringify(r.attachment)})`;
2802
+ if (typeof r.slot !== 'string' || typeof r.attachment !== 'string') return geometryProblem(path, `${at} has no string "slot" and "attachment"`);
2803
+ if (!numberArray(r.vertices) || r.vertices.length % 2 !== 0) return geometryProblem(path, `${at} "vertices" is not an even-length array of finite numbers`);
2804
+ const n = r.vertices.length / 2;
2805
+ const tri = r.triangles;
2806
+ if (!numberArray(tri) || tri.length % 3 !== 0 || !tri.every((x) => Number.isInteger(x) && x >= 0 && x < n)) return geometryProblem(path, `${at} "triangles" is not index triplets into its ${n} vertices`);
2807
+ meshes.push({ slot: r.slot, attachment: r.attachment, vertices: r.vertices, triangles: tri });
2808
+ }
2809
+ const want = new Map(meshes.map((m) => [`${m.slot}\u0000${m.attachment}`, m.vertices.length]));
2810
+ const poses: GeometryPose[] = [];
2811
+ for (const [i, f] of raw.frames.entries()) {
2812
+ if (!isRecord(f) || typeof f.index !== 'number' || !Array.isArray(f.attachments)) return geometryProblem(path, `frames[${i}] has no "index" or "attachments"`);
2813
+ const attachments: GeometryPose['attachments'][number][] = [];
2814
+ for (const a of f.attachments) {
2815
+ if (!isRecord(a) || typeof a.slot !== 'string' || typeof a.attachment !== 'string') continue;
2816
+ const n = want.get(`${a.slot}\u0000${a.attachment}`);
2817
+ if (n === undefined) continue;
2818
+ if (!numberArray(a.vertices) || a.vertices.length !== n) {
2819
+ return geometryProblem(path, `frame ${f.index} mesh "${a.slot}" attachment "${a.attachment}" has ${Array.isArray(a.vertices) ? a.vertices.length : 'no'} vertex number(s); its rest entry has ${n}`);
2820
+ }
2821
+ attachments.push({ slot: a.slot, attachment: a.attachment, vertices: a.vertices });
2822
+ }
2823
+ poses.push({ index: f.index, attachments });
2824
+ }
2825
+ return {
2826
+ meshes,
2827
+ frames: poses,
2828
+ viewport: isRecord(raw.viewport) ? raw.viewport : null,
2829
+ setupBones: isRecord(raw.setup) ? raw.setup.bones : undefined,
2830
+ frameBones: raw.frames.map((f) => (isRecord(f) ? f.bones : undefined)),
2831
+ times: raw.frames.map((f) => (isRecord(f) ? f.time : undefined)),
2832
+ };
2833
+ }
2834
+
2835
+ /**
2836
+ * Every attachment a `rigc-geometry/1` file shows, frame by frame, keyed by
2837
+ * its slot: the posed vertices with the rest vertices and triangles of the
2838
+ * same slot and attachment — regions and meshes alike, which a seam
2839
+ * requirement reads (issue #111). Read only for a `seam` requirement, so a
2840
+ * run without one reads the file exactly as before ({@link readGeometry}).
2841
+ * A file that is not a whole export of `frames` frames — a rest entry without
2842
+ * well-formed vertices and triangles, a frame showing an attachment with no
2843
+ * rest entry or another vertex count — is refused naming what is wrong.
2844
+ */
2845
+ export function attachmentPoses(path: string, frames: number): Array<Map<string, PosedAttachment>> {
2846
+ let raw: unknown;
2847
+ try {
2848
+ raw = JSON.parse(readFileSync(path, 'utf8'));
2849
+ } catch (err) {
2850
+ return geometryProblem(path, `does not read as JSON: ${(err as Error).message}`);
2851
+ }
2852
+ if (!isRecord(raw) || !Array.isArray(raw.rest) || !Array.isArray(raw.frames)) return geometryProblem(path, 'lacks "rest" or "frames"; a rigc-geometry/1 file carries both');
2853
+ if (raw.frames.length !== frames) return geometryProblem(path, `holds ${raw.frames.length} frame(s); the frame set beside it wrote ${frames}`);
2854
+ const rest = new Map<string, { vertices: number[]; triangles: number[] }>();
2855
+ for (const [i, r] of raw.rest.entries()) {
2856
+ if (!isRecord(r) || typeof r.slot !== 'string' || typeof r.attachment !== 'string') return geometryProblem(path, `rest[${i}] has no string "slot" and "attachment"`);
2857
+ const at = `rest[${i}] (slot ${JSON.stringify(r.slot)}, attachment ${JSON.stringify(r.attachment)})`;
2858
+ if (!numberArray(r.vertices) || r.vertices.length % 2 !== 0) return geometryProblem(path, `${at} "vertices" is not an even-length array of finite numbers`);
2859
+ const n = r.vertices.length / 2;
2860
+ const tri = r.triangles;
2861
+ if (!numberArray(tri) || tri.length % 3 !== 0 || !tri.every((x) => Number.isInteger(x) && x >= 0 && x < n)) return geometryProblem(path, `${at} "triangles" is not index triplets into its ${n} vertices`);
2862
+ rest.set(`${r.slot}\u0000${r.attachment}`, { vertices: r.vertices, triangles: tri });
2863
+ }
2864
+ return raw.frames.map((f, i) => {
2865
+ if (!isRecord(f) || !Array.isArray(f.attachments)) return geometryProblem(path, `frames[${i}] has no "attachments"`);
2866
+ const shown = new Map<string, PosedAttachment>();
2867
+ for (const a of f.attachments) {
2868
+ if (!isRecord(a) || typeof a.slot !== 'string' || typeof a.attachment !== 'string') return geometryProblem(path, `frames[${i}] holds an attachment with no string "slot" and "attachment"`);
2869
+ const r = rest.get(`${a.slot}\u0000${a.attachment}`);
2870
+ if (r === undefined) return geometryProblem(path, `frames[${i}] shows slot "${a.slot}" attachment "${a.attachment}", which "rest" has no entry for`);
2871
+ if (!numberArray(a.vertices) || a.vertices.length !== r.vertices.length) return geometryProblem(path, `frames[${i}] slot "${a.slot}" attachment "${a.attachment}" has ${Array.isArray(a.vertices) ? a.vertices.length : 'no'} vertex number(s); its rest entry has ${r.vertices.length}`);
2872
+ shown.set(a.slot, { rest: r.vertices, posed: a.vertices, triangles: r.triangles });
2873
+ }
2874
+ return shown;
2875
+ });
2876
+ }
2877
+
2878
+ function stretchAtText(s: { triangle: number; vertices: readonly number[]; edge: readonly number[]; frame: number }): string {
2879
+ return `triangle ${s.triangle} (vertices ${s.vertices.join(' ')}), edge ${s.edge.join('-')}, idle frame ${s.frame}`;
2880
+ }
2881
+
2882
+ /**
2883
+ * (6) No visible texture stretch: for every mesh triangle, each edge's length
2884
+ * in each idle frame over its rest length (the setup pose's bones, no deform —
2885
+ * which on both examples is the art's own proportions, the attachment's `uvs`
2886
+ * times its size, to 2.4e-5). The figure is the rig's worst max(ratio,
2887
+ * 1/ratio); each mesh's largest and smallest ratio, and where, are the detail.
2888
+ * SKIP when the render wrote no geometry file or the rig draws no mesh.
2889
+ */
2890
+ export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[]): JudgementLine {
2891
+ if (geo === null) return skip(`${path} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the skinned mesh vertices are read from it`);
2892
+ if (geo.meshes.length === 0) return skip('the rig draws no mesh attachment (every slot is a region), so there is no triangle to stretch');
2893
+ const figures = stretchFigures(geo.meshes, geo.frames);
2894
+ const rows: Array<Record<string, unknown>> = [];
2895
+ let worst: { mesh: (typeof figures)[number]; at: NonNullable<(typeof figures)[number]['max']>; severity: number } | null = null;
2896
+ let maxAll: number | null = null;
2897
+ let minAll: number | null = null;
2898
+ let failed = false;
2899
+ for (const m of figures) {
2900
+ if (m.degenerate.length > 0) {
2901
+ failed = true;
2902
+ problems.push({
2903
+ code: 'CHECK_TEXTURE_STRETCH',
2904
+ object: `mesh "${m.slot}" attachment "${m.attachment}"`,
2905
+ detail: `${m.degenerate.length} rest edge(s) of length 0, the first triangle ${m.degenerate[0].triangle} edge ${m.degenerate[0].edge.join('-')}; no stretch ratio can be read off an edge with no rest length — two of the mesh's vertices coincide at the setup pose`,
2906
+ });
2907
+ }
2908
+ if (m.max === null || m.min === null) {
2909
+ rows.push({ slot: m.slot, triangles: m.triangles, unmeasured: 'no idle frame shows this attachment' });
2910
+ continue;
2911
+ }
2912
+ const sMax = stretchSeverity(m.max.ratio);
2913
+ const sMin = stretchSeverity(m.min.ratio);
2914
+ const at = sMin > sMax ? m.min : m.max;
2915
+ const severity = round3(Math.max(sMax, sMin));
2916
+ if (worst === null || severity > worst.severity) worst = { mesh: m, at, severity };
2917
+ maxAll = maxAll === null ? m.max.ratio : Math.max(maxAll, m.max.ratio);
2918
+ minAll = minAll === null ? m.min.ratio : Math.min(minAll, m.min.ratio);
2919
+ rows.push({ slot: m.slot, triangles: m.triangles, max_ratio: round3(m.max.ratio), max_at: stretchAtText(m.max), min_ratio: round3(m.min.ratio), min_at: stretchAtText(m.min), severity });
2920
+ if (severity > TEXTURE_STRETCH_CEILING) {
2921
+ failed = true;
2922
+ problems.push({
2923
+ code: 'CHECK_TEXTURE_STRETCH',
2924
+ object: `mesh "${m.slot}" ${stretchAtText(at)}`,
2925
+ detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
2926
+ });
2927
+ }
2928
+ }
2929
+ if (worst === null) return { status: 'SKIP', reason: `no idle frame shows any of the rig's ${figures.length} mesh(es): ${figures.map((m) => `"${m.slot}"`).join(', ')}` };
2930
+ return {
2931
+ status: failed ? 'FAIL' : 'PASS',
2932
+ meshes: figures.length,
2933
+ triangles: figures.reduce((n, m) => n + m.triangles, 0),
2934
+ frames: geo.frames.length,
2935
+ severity: worst.severity,
2936
+ severity_ceiling: TEXTURE_STRETCH_CEILING,
2937
+ worst: { slot: worst.mesh.slot, triangle: worst.at.triangle, vertices: worst.at.vertices, edge: worst.at.edge, frame: worst.at.frame, ratio: round3(worst.at.ratio) },
2938
+ max_ratio: round3(maxAll as number),
2939
+ min_ratio: round3(minAll as number),
2940
+ per_mesh: rows,
2941
+ };
2942
+ }