@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/build.ts ADDED
@@ -0,0 +1,1240 @@
1
+ /**
2
+ * The stages as callable units, and `build`, which runs three of them in a row.
3
+ *
4
+ * Each command in `cli.ts` — `assemble`, `rig`, `check`, `loop` — is a thin
5
+ * flag parser over one function here, and `build` calls the same functions in
6
+ * the same process. That is the whole reason this file exists: a driver that
7
+ * re-implemented a stage (or re-spawned the CLI and parsed its text) would be a
8
+ * second copy that could drift from the command it claims to run. What a
9
+ * stage prints, it prints through the `Log` it is handed, so `build` reads the
10
+ * very lines the command prints, under a `[stage]` prefix.
11
+ *
12
+ * build = assemble -> rig -> check [-> loop]
13
+ *
14
+ * `propose` is deliberately not a step. A config with bones is the input to
15
+ * `build`: the proposal is a starting point to be corrected against its
16
+ * overlay, and a driver that fed the proposal straight to `rig` would turn a
17
+ * draft into a rig with nobody having looked at it.
18
+ *
19
+ * `build --out <dir>` receives:
20
+ *
21
+ * | path | from |
22
+ * | --- | --- |
23
+ * | `parts/<name>.png`, `parts.json`, `recomposite_rig.png`, `recomposite_error_rig.png` | assemble — the loose parts, an intermediate, and the error map of their flat stack |
24
+ * | `rig/` (`rig.json`, `motion.json`, `mesh_report.json`, `images/`) | rig |
25
+ * | `check/` (`build/` with the packed atlas, both gate files, `idle_frames/`, `contact.png`, `motion_heat.png`, `check.json`) | check |
26
+ * | `idle.png` (lossless APNG), `idle-indexed.png` (indexed APNG), `idle.gif` | loop, with `--loop`, from `check/idle_frames/` |
27
+ *
28
+ * The artifact is `check/build/skeleton.json`, `.atlas` and the packed page
29
+ * (issue #2): the last lines of a green build are the pack line and those
30
+ * three paths.
31
+ *
32
+ * Emit only after green, stage by stage: each stage writes only after its own
33
+ * checks, and `build` stops at the first stage that refuses — printing that
34
+ * stage's own FAIL lines — so nothing downstream of a red is written. The paths
35
+ * `build` owns under `--out` (the table above, and nothing else) are removed
36
+ * before the first stage runs, so a file left by an earlier run cannot sit
37
+ * beside a later run's refusal looking current.
38
+ *
39
+ * Pure in the sense `src/` is held to: no clock, no randomness, no network and
40
+ * no child process. rig-c runs as a process, but the process is injected
41
+ * as a {@link RigcRunner}, exactly as `src/check.ts` takes it.
42
+ */
43
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
+ import { dirname, join, resolve } from 'node:path';
45
+ import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
+ import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
47
+ import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
+ import { residualClause } from './autoenvelope.ts';
49
+ import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
50
+ import {
51
+ bisectAccepted,
52
+ budgetProblem,
53
+ type CountedProbe,
54
+ finalVerdict,
55
+ gridFrameIds,
56
+ maxReplays,
57
+ MULTI_INTERVAL_RULE,
58
+ type MultiIntervalRow,
59
+ multiIntervalSearch,
60
+ noMultiReductionProblem,
61
+ noReductionProblem,
62
+ passingIntervals,
63
+ readingText,
64
+ REPLAY_RULE,
65
+ type ReplayProbe,
66
+ type ReplayRow,
67
+ roleReadings,
68
+ rolesProblem,
69
+ selectionSchedule,
70
+ splitSchedule,
71
+ untestedIntervals,
72
+ } from './autoreplay.ts';
73
+ import { BARS, besideIdleLine, causeLines, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
+ import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
+ import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
76
+ import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
77
+ import { encodeGif } from './gif.ts';
78
+ import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
+ import type { PaletteError } from './palette.ts';
80
+ import { type LayerSet, readLayers } from './layers.ts';
81
+ import { type PartRecord, readParts, writeParts } from './parts.ts';
82
+ import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
+ import type { Raster } from './raster/types.ts';
84
+ import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
+ import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
86
+
87
+ /** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
88
+ export type Log = (line: string) => void;
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // assemble
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** The painting, refused by name when it is missing or is not a PNG. */
95
+ export function readSource(path: string): Raster {
96
+ if (!existsSync(path)) {
97
+ throw new PartsError([{ code: 'ASSEMBLE_SOURCE_PRESENT', object: path, detail: 'no such file; the painting the two runs were made from is required' }]);
98
+ }
99
+ try {
100
+ return readPng(path);
101
+ } catch (err) {
102
+ throw new PartsError([{ code: 'ASSEMBLE_SOURCE_DECODES', object: path, detail: `${(err as Error).message}; a PNG is required` }]);
103
+ }
104
+ }
105
+
106
+ /** Read both runs, collecting the refusals of both before throwing. */
107
+ export function readRuns(full: string, head: string): { full: LayerSet; head: LayerSet } {
108
+ const problems: Problem[] = [];
109
+ const one = (path: string): LayerSet | null => {
110
+ try {
111
+ return readLayers(path);
112
+ } catch (err) {
113
+ if (err instanceof PartsError) {
114
+ problems.push(...err.problems);
115
+ return null;
116
+ }
117
+ throw err;
118
+ }
119
+ };
120
+ const f = one(full);
121
+ const h = one(head);
122
+ if (problems.length > 0) throw new PartsError(problems);
123
+ return { full: f as LayerSet, head: h as LayerSet };
124
+ }
125
+
126
+ export interface AssembleStageInput {
127
+ source: string;
128
+ full: string;
129
+ head: string;
130
+ config: string;
131
+ seam: SeamRule;
132
+ project: ProjectRule;
133
+ }
134
+
135
+ /** Where the assemble stage writes: `parts.json`, the directory of part PNGs, the recomposite, and its error map. */
136
+ export interface AssembleOutputs {
137
+ partsJson: string;
138
+ partsDir: string;
139
+ recomposite: string;
140
+ errorMap: string;
141
+ }
142
+
143
+ /** The error map's file name beside a recomposite: `recomposite_rig.png` -> `recomposite_error_rig.png`. */
144
+ export const ERROR_MAP_FILE = 'recomposite_error_rig.png';
145
+
146
+ /**
147
+ * One line per part whose fringe `pushBackFringe` cleared (issue #119), in
148
+ * draw order: ` fringe pushed back: "topwear" 723 px, where the painting
149
+ * shows "handwear_l" 460 px, "bottomwear" 257 px`. No line when no part had
150
+ * one, so a stack the rule did not touch prints what it printed before.
151
+ */
152
+ export function fringeLines(parts: readonly PartRecord[]): string[] {
153
+ return parts
154
+ .filter((p) => p.fringe_pushed_back !== undefined && p.fringe_pushed_back.length > 0)
155
+ .map((p) => {
156
+ const list = p.fringe_pushed_back ?? [];
157
+ const n = list.reduce((a, q) => a + q.px, 0);
158
+ return ` fringe pushed back: "${p.name}" ${n} px, where the painting shows ${list.map((q) => `"${q.part}" ${q.px} px`).join(', ')}`;
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Each cut's `under` image (issue #193), read from its path relative to the
164
+ * config's directory, null for a cut without one. Every cut is read before
165
+ * one refusal names them all: no such file (`ASSEMBLE_UNDER_PRESENT`), or a
166
+ * file that does not decode as a PNG (`ASSEMBLE_UNDER_DECODES`). The stage
167
+ * itself holds the image to the polygon's box and the removed pixels.
168
+ */
169
+ export function readUnders(configPath: string, cuts: readonly Cut[]): Array<Raster | null> {
170
+ const problems: Problem[] = [];
171
+ const out = cuts.map((q, i): Raster | null => {
172
+ if (q.under === undefined) return null;
173
+ const file = resolve(dirname(resolve(configPath)), q.under);
174
+ const object = `config.assemble.cuts[${i}].under (part "${q.into}")`;
175
+ if (!existsSync(file) || !statSync(file).isFile()) {
176
+ problems.push({ code: 'ASSEMBLE_UNDER_PRESENT', object, detail: `names ${file}, which is no such file; a PNG of what lies under the piece, its path relative to the config's directory, is required` });
177
+ return null;
178
+ }
179
+ try {
180
+ return readPng(file);
181
+ } catch (err) {
182
+ problems.push({ code: 'ASSEMBLE_UNDER_DECODES', object, detail: `${file}: ${(err as Error).message.split('\n')[0]}; a PNG is required` });
183
+ return null;
184
+ }
185
+ });
186
+ refuseIfAny(problems);
187
+ return out;
188
+ }
189
+
190
+ /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
191
+ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
192
+ const src = readSource(input.source);
193
+ const runs = readRuns(input.full, input.head);
194
+ // The early door: assemble runs before propose has drafted bones, meshes,
195
+ // regions and motion, so it requires only what it reads (issue #20).
196
+ const fields = stageFields(loadEarlyConfig(input.config, 'assemble'));
197
+ const under = readUnders(input.config, fields.cuts);
198
+ const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, seamRule: input.seam, projectRule: input.project });
199
+ // Emit only after green: every refusal above has already thrown.
200
+ mkdirSync(outs.partsDir, { recursive: true });
201
+ mkdirSync(dirname(outs.partsJson), { recursive: true });
202
+ mkdirSync(dirname(outs.recomposite), { recursive: true });
203
+ mkdirSync(dirname(outs.errorMap), { recursive: true });
204
+ writeParts(outs.partsJson, result.parts);
205
+ for (const { record, image } of result.images) writePng(join(outs.partsDir, `${record.name}.png`), image);
206
+ writePng(outs.recomposite, result.recomposite);
207
+ writePng(outs.errorMap, result.errorMap);
208
+ const [W, H] = result.parts.rig_size;
209
+ log(`rig-parts assemble: ${result.images.length} part(s) on a ${W}x${H} rig (${result.parts.scale_rig_per_source} rig px per source px), seam rule ${result.seamRule}, projection rule ${result.projectRule}`);
210
+ for (const p of result.parts.parts) {
211
+ log(
212
+ ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
213
+ `op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
214
+ `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} merged=${p.merged_px} seam=${p.seam_override_px}`,
215
+ );
216
+ }
217
+ // One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
218
+ for (const l of cutLines(result.cuts)) log(l);
219
+ for (const l of fringeLines(result.parts.parts)) log(l);
220
+ const total = (k: 'opaque_px' | 'visible_px' | 'occluded_px' | 'source_px_taken' | 'visible_not_projected_px'): number => result.parts.parts.reduce((a, p) => a + (p[k] ?? 0), 0);
221
+ const [op, vis, occ, taken, unproj] = (['opaque_px', 'visible_px', 'occluded_px', 'source_px_taken', 'visible_not_projected_px'] as const).map(total);
222
+ const pct = (n: number): string => `${((100 * n) / op).toFixed(1)}%`;
223
+ log(
224
+ ` pixels: opaque ${op} = visible ${vis} + occluded ${occ} (${pct(occ)}); taken from the painting ${taken} (${pct(taken)}); ` +
225
+ `visible but not projected ${unproj} (${pct(unproj)})`,
226
+ );
227
+ const ghosts = Object.values(result.parts.ghost_px).reduce((a, b) => a + b, 0);
228
+ log(` ghost px removed: ${ghosts} over ${Object.keys(result.parts.ghost_px).length} layer(s)`);
229
+ log(figuresLine(result.figures));
230
+ for (const l of holeLines(result.figures)) log(l);
231
+ log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
232
+ return result;
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // rig
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export interface GateRun {
240
+ label: string;
241
+ status: number;
242
+ /**
243
+ * rigc's FAIL lines, its assertion summary, the core entry's `here:` line and
244
+ * the one SKIP this stage's own declaration causes, as it printed them — or,
245
+ * for a run that exited non-zero with none of those, the lines
246
+ * {@link causeLines} says name why.
247
+ */
248
+ lines: string[];
249
+ }
250
+
251
+ /**
252
+ * `A15_IDLE_NO_MESH_BONE_KEYS`'s SKIP under `invariants.idleDrivesMeshes` —
253
+ * the line in which rigc states what `--idle-keys direct` declared and what it
254
+ * costs. It is the one SKIP the rig stage prints: every other SKIP is a check
255
+ * with nothing to measure, while this one is switched off by a field this
256
+ * stage wrote, so it is shown rather than folded into the skipped count.
257
+ */
258
+ const DECLARED_SKIP = /^ {2}SKIP {2}A15_IDLE_NO_MESH_BONE_KEYS: declared by the rig/;
259
+
260
+ /** The core entry's line after its summary (rig-c 2.0.0 and later): which rules ran, and that the round trip did not. */
261
+ const HERE_LINE = /^ {2}\.\. {4}here: /;
262
+
263
+ function gateRun(label: string, rigc: RigcRunner, args: string[]): GateRun {
264
+ const r = rigc(args);
265
+ const lines = r.out.split('\n').filter((l) => /^ {2}FAIL {2}/.test(l) || /assertions: \d+ measured/.test(l) || /^rigc compile error/.test(l) || DECLARED_SKIP.test(l) || HERE_LINE.test(l));
266
+ const failed = lines.some((l) => /^ {2}FAIL {2}/.test(l) || /^rigc compile error/.test(l));
267
+ return { label, status: r.status, lines: r.status !== 0 && !failed ? [...lines, ...causeLines(r.out)] : lines };
268
+ }
269
+
270
+ /**
271
+ * The motion gate's reference build (issue #135): rigc's `build` under the
272
+ * spine-html profile and nothing else — the same gate over the compile that
273
+ * the candidate's build runs, without `--pack`. Its model document is all the
274
+ * comparison reads, and it differs from a packed build's only in where the
275
+ * atlas puts each region (its `pages`), which rig-c's comparison
276
+ * allowlists as atlas layout; the pages a reference would pack are never
277
+ * written anywhere, so packing them was work nobody read. No render is asked
278
+ * of rigc by either build.
279
+ */
280
+ export const REFERENCE_BUILD_ARGS: readonly string[] = ['--profile', 'spine-html'];
281
+
282
+ /** The reference build's label, as the rig stage prints it. */
283
+ export const REFERENCE_BUILD_LABEL = `build ${REFERENCE_BUILD_ARGS.join(' ')}`;
284
+
285
+ /**
286
+ * rig-c's gate over the rig in a scratch directory: `build` under the
287
+ * spine-html profile with `--pack --page-edges <edges> --pack-shape <shape>`, which runs the gate
288
+ * once over the compile and once over the packed pages on disk. There is no
289
+ * second `validate --profile spine` run: spine-html holds every rule spine
290
+ * measures (selftest `CH09`). The files are staged exactly as `--out` will
291
+ * receive them, so what passed is what is written. The scratch directory is
292
+ * the caller's, and is emptied here before and after. `images` are the PNG
293
+ * files' bytes as `--out` will receive them: the rig stage's are its rasters
294
+ * encoded as `writePng` encodes them, `compose`'s the builds' own files.
295
+ * `mode` null is the motion gate's reference ({@link REFERENCE_BUILD_ARGS}):
296
+ * the same gate over the compile, nothing packed; its build directory sits
297
+ * where a packed one would, so every relative path in its model document is
298
+ * the candidate's.
299
+ */
300
+ export function gateThroughRigc(
301
+ images: ReadonlyArray<readonly [string, Uint8Array]>,
302
+ texts: ReadonlyArray<readonly [string, string]>,
303
+ rigc: RigcRunner,
304
+ scratch: string,
305
+ mode: PackMode | null,
306
+ onGreen?: (buildDir: string) => void,
307
+ ): GateRun[] {
308
+ rmSync(scratch, { recursive: true, force: true });
309
+ try {
310
+ mkdirSync(join(scratch, 'images'), { recursive: true });
311
+ for (const [file, bytes] of images) writeFileSync(join(scratch, 'images', file), bytes);
312
+ for (const [file, text] of texts) writeFileSync(join(scratch, file), text);
313
+ const build = join(scratch, 'build');
314
+ const label = mode === null ? REFERENCE_BUILD_LABEL : packedBuildLabel(mode);
315
+ const args = mode === null ? [...REFERENCE_BUILD_ARGS] : packedBuildArgs(mode);
316
+ const runs = [gateRun(label, rigc, ['build', '--rig', join(scratch, 'rig.json'), '--motion', join(scratch, 'motion.json'), '--out', build, ...args])];
317
+ // What the caller reads off a green build before the scratch is emptied (the motion gate's model document); nothing for a red one.
318
+ if (onGreen !== undefined && runs.every((g) => g.status === 0)) onGreen(build);
319
+ return runs;
320
+ } finally {
321
+ rmSync(scratch, { recursive: true, force: true });
322
+ }
323
+ }
324
+
325
+ /**
326
+ * What only this package can add to rigc's refusal of a two-bone ik a control
327
+ * splits (issue #103). Under `--idle-keys ctl` a chain link the idle keys is
328
+ * keyed through a same-origin `<link>_ctl` parent, so a config's ik over that
329
+ * link and the link above it names a pair whose second bone is not the
330
+ * first's child; rig-c 2.15.0's rig-spec parser refuses it by name
331
+ * (firejune/rigc#1205), with `("<link>_ctl" stands between)` in its sentence
332
+ * and a remedy a config cannot follow — a config cannot name the control.
333
+ *
334
+ * This reads rigc's verdict and checks no rule of its own: for each control
335
+ * the stage added, a rigc line saying that control, and only it, stands
336
+ * between gets one sentence naming the flag on the command that ran. The ik
337
+ * shape is rigc's to judge; a pair split by a control and by a bone the config
338
+ * declares (`"…_ctl", "…" stand between`) gets nothing, because
339
+ * `--idle-keys direct` would not make it a parent and its child. Should rigc
340
+ * reword its sentence, rigc's refusal still reaches the author and only this
341
+ * sentence is lost; the selftest holds it on the installed rigc (`RG56`).
342
+ */
343
+ export function ctlRemedies(lines: readonly string[], controls: readonly string[], command: RigCommand): string[] {
344
+ const out: string[] = [];
345
+ for (const bone of controls) {
346
+ const ctl = `${bone}${CONTROL_SUFFIX}`;
347
+ if (!lines.some((l) => l.includes(`("${ctl}" stands between)`))) continue;
348
+ out.push(`"${ctl}" is the control this stage keys "${bone}" through under --idle-keys ctl, and a config cannot name it: run \`${command} --idle-keys direct\`, which keys "${bone}" in place, so no control stands between "${bone}" and its parent`);
349
+ }
350
+ return out;
351
+ }
352
+
353
+ export interface RigStageInput {
354
+ config: string;
355
+ /** The directory holding parts.json and parts/<name>.png. */
356
+ parts: string;
357
+ out: string;
358
+ /** Where the idle's keys on mesh-driving bones go; `ctl` when absent. See `IDLE_KEYS` in `src/rig.ts`. */
359
+ idleKeys?: IdleKeys;
360
+ /** The gate build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. The pack is scratch here; the gate is what it is for. */
361
+ pageEdges?: PageEdges;
362
+ /** The gate build's `--pack-shape`; {@link DEFAULT_PACK_SHAPE} when absent. */
363
+ packShape?: PackShape;
364
+ /** The command running the stage, which {@link ctlRemedies} names beside the flag; `rig` when absent. It moves no byte written. */
365
+ command?: RigCommand;
366
+ /**
367
+ * How `buildRig` runs each automatic part's reduction (`src/automesh.ts`);
368
+ * `runReduction` when absent. A caller that already ran the reduction on the
369
+ * same input hands `reuseReductions` over it (`tools/auto_matrix.ts`), so the
370
+ * same `reduceMesh` call is not run twice (issue #135). It moves no byte
371
+ * written: the same input is the same result.
372
+ */
373
+ reduce?: Reducer;
374
+ }
375
+
376
+ /** One automatic part's motion gate, as the rig stage ran it (`src/automotion.ts`). */
377
+ export interface MotionGateRun {
378
+ part: string;
379
+ /** The reference build through rigc's gate — null when the part was refused before anything was compiled. */
380
+ reference: GateRun | null;
381
+ /** rig-c's `compare` report — null when the comparison did not run or refused its input. */
382
+ report: MeshQualityReport | null;
383
+ /** What refuses the part; empty when it is accepted. */
384
+ problems: Problem[];
385
+ /** Why no comparison ran, for the mesh line; null when one ran. */
386
+ notCompared: string | null;
387
+ /** The reference build's model document, read off its green build; null when it was not built or is red. Nothing writes it. */
388
+ referenceModel: string | null;
389
+ /**
390
+ * Only on a part with a declared stimulus (issue #196): the build of the written rig with the stimulus animations
391
+ * added (`stimulusTexts`), the candidate the comparison reads in place of the stage's own gate build. Absent otherwise.
392
+ */
393
+ candidate?: GateRun;
394
+ }
395
+
396
+ /**
397
+ * The texts a comparison build of one part stages: `texts` with `motion.json`
398
+ * carrying the part's declared stimulus animations (`stimulusMotion`,
399
+ * src/automotion.ts), or `texts` as given when the part declares none.
400
+ */
401
+ export function stimulusTexts(c: AutoMotionCase, texts: ReadonlyArray<readonly [string, string]>): ReadonlyArray<readonly [string, string]> | Problem {
402
+ if (c.stimulus === null) return texts;
403
+ const at = texts.find(([file]) => file === 'motion.json');
404
+ if (at === undefined) return { code: 'AUTO_MESH_MOTION_INPUT', object: `${c.object}.stimulus`, detail: 'the stage staged no motion.json to add the declared stimulus to; the comparison builds read the motion the stage writes' };
405
+ const m = stimulusMotion(c, JSON.parse(at[1]) as Record<string, unknown>);
406
+ if ('problem' in m) return m.problem;
407
+ const text = rigJsonText(m.motion);
408
+ return texts.map(([file, t]) => [file, file === 'motion.json' ? text : t] as const);
409
+ }
410
+
411
+ /** The model document a green gate build wrote, or null. */
412
+ function modelAt(buildDir: string): string | null {
413
+ const path = join(buildDir, RIGC_MODEL_DOCUMENT);
414
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
415
+ }
416
+
417
+ /**
418
+ * The motion gate of every automatic part (issue #126, item 3): for each, the
419
+ * reference rig — `rig` with that part's attachment swapped for its unreduced
420
+ * source and nothing else — through rigc's gate ({@link REFERENCE_BUILD_ARGS}:
421
+ * the stage's `build` under spine-html, without packing, issue #135), then
422
+ * rig-c's comparison of its `skeleton.model.json` with the candidate's
423
+ * (`candidateModel`, the stage's own gate build). A part refused before
424
+ * compiling (no `motion`, no stimulus) costs no build.
425
+ */
426
+ export function motionGates(
427
+ rig: RigOutput,
428
+ images: ReadonlyArray<readonly [string, Uint8Array]>,
429
+ texts: ReadonlyArray<readonly [string, string]>,
430
+ candidateModel: string | null,
431
+ rigc: RigcRunner,
432
+ scratch: string,
433
+ schedules: ReadonlyMap<string, MotionSchedule> = new Map(),
434
+ ): MotionGateRun[] {
435
+ return rig.autoMotion.map((c: AutoMotionCase): MotionGateRun => {
436
+ const out = (over: Partial<MotionGateRun>): MotionGateRun => ({ part: c.part, reference: null, report: null, problems: [], notCompared: null, referenceModel: null, ...over });
437
+ if (c.motion === undefined) {
438
+ return out({
439
+ problems: [{ code: 'CONFIG_FIELD_PRESENT', object: `${c.object}.motion`, detail: 'is absent and required; an automatic mesh is accepted only when its motion is measured and passes (issue #126 item 3)' }],
440
+ notCompared: 'no motion bounds',
441
+ });
442
+ }
443
+ const stim = motionStimulus(rig.rig.bones, rig.motion, c.boundBones, c.stimulus?.flatMap((x) => x.links) ?? []);
444
+ if (stim.by.length === 0) return out({ problems: [noStimulusProblem(c, stim.keyed)], notCompared: 'no stimulus' });
445
+ const staged = stimulusTexts(c, texts);
446
+ if ('code' in staged) return out({ problems: [staged], notCompared: 'the stimulus could not be staged' });
447
+ // issue #196: a declared stimulus is walked in both builds, so the candidate is the written rig built again with it.
448
+ let candidate: GateRun | undefined;
449
+ let compared = candidateModel;
450
+ if (c.stimulus !== null && compared !== null) {
451
+ let model: string | null = null;
452
+ [candidate] = gateThroughRigc(images, staged, rigc, scratch, null, (dir) => {
453
+ model = modelAt(dir);
454
+ });
455
+ if (candidate.status !== 0 || model === null) {
456
+ return out({
457
+ candidate,
458
+ problems: [
459
+ {
460
+ code: 'AUTO_MESH_MOTION_INPUT',
461
+ object: c.object,
462
+ detail: `the candidate with the declared stimulus (the rig as written, its motion carrying the stimulus animations) ${candidate.status !== 0 ? `is refused by rigc's gate, exit ${candidate.status}${candidate.lines.length > 0 ? `: ${candidate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
463
+ },
464
+ ],
465
+ notCompared: 'the stimulus candidate build is red',
466
+ });
467
+ }
468
+ compared = model;
469
+ }
470
+ if (compared === null) {
471
+ return out({
472
+ problems: [{ code: 'AUTO_MESH_MOTION_INPUT', object: c.object, detail: `the rig stage's gate build wrote no ${RIGC_MODEL_DOCUMENT}, so there is no candidate document to compare; rigc's build writes one beside every green build` }],
473
+ notCompared: `no candidate ${RIGC_MODEL_DOCUMENT}`,
474
+ });
475
+ }
476
+ const refRig = JSON.parse(rigJsonText(rig.rig)) as RigSpec;
477
+ refRig.skins.default[c.part] = { [c.part]: c.reference };
478
+ const refTexts = staged.map(([file, text]) => [file, file === 'rig.json' ? rigJsonText(refRig) : text] as const);
479
+ let refModel: string | null = null;
480
+ const [gate] = gateThroughRigc(images, refTexts, rigc, scratch, null, (dir) => {
481
+ refModel = modelAt(dir);
482
+ });
483
+ const withCandidate = candidate === undefined ? {} : { candidate };
484
+ if (gate.status !== 0 || refModel === null) {
485
+ return out({
486
+ ...withCandidate,
487
+ reference: gate,
488
+ problems: [
489
+ {
490
+ code: 'AUTO_MESH_MOTION_INPUT',
491
+ object: c.object,
492
+ detail: `the reference (the rig with "${c.part}" as its unreduced source mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a reference that is not gated is not evidence, and nothing is built in the part's place`,
493
+ },
494
+ ],
495
+ notCompared: 'the reference build is red',
496
+ });
497
+ }
498
+ const ran = runComparison(c.object, motionInput(c, c.motion, refModel, compared, withStimulus(schedules.get(c.part) ?? idleSchedule(), c.stimulus)));
499
+ if ('code' in ran) return out({ ...withCandidate, reference: gate, problems: [ran], notCompared: 'rig-c refused the input', referenceModel: refModel });
500
+ const verdict = motionVerdict(c.object, ran);
501
+ return out({ ...withCandidate, reference: gate, report: ran, problems: verdict === null ? [] : [verdict], referenceModel: refModel });
502
+ });
503
+ }
504
+
505
+ /** The mesh report as written: each automatic row with the motion gate's rows in place of "unmeasured", and its `compare` document after `quality_report`. */
506
+ export function withMotion(rows: readonly MeshReport[], runs: readonly MotionGateRun[], cases: readonly AutoMotionCase[], replays: ReadonlyMap<string, ReplayRow | MultiIntervalRow> = new Map()): MeshReport[] {
507
+ return rows.map((m) => {
508
+ if (!('mode' in m) || m.mode !== 'auto') return m;
509
+ const run = runs.find((r) => r.part === m.part);
510
+ const c = cases.find((x) => x.part === m.part);
511
+ if (run === undefined || run.report === null || c === undefined || c.motion === undefined) return m;
512
+ const replay = replays.get(m.part);
513
+ if (replay === undefined) {
514
+ const row: AutoMeshReport = { ...m, deformation: motionDeformation(c.motion, run.report), motion_report: motionDocument(run.report) };
515
+ return row;
516
+ }
517
+ // A replayed part's row carries `replay` just before `deformation`; every other key keeps its place.
518
+ const { deformation: _unmeasured, quality_report: quality, ...head } = m;
519
+ const row: AutoMeshReport = { ...head, replay, deformation: motionDeformation(c.motion, run.report), quality_report: quality, motion_report: motionDocument(run.report) };
520
+ return row;
521
+ });
522
+ }
523
+
524
+ /** One automatic part's acceptance loop after the gate refused its full result (`src/autoreplay.ts`): the step chosen, or the problem. */
525
+ export interface ReplaySearchRun {
526
+ part: string;
527
+ /** The replay to write, the grid frame ids it was chosen on and the search's figures; null when the part is refused. */
528
+ chosen: {
529
+ candidate: ReplayCandidate;
530
+ grid: string[];
531
+ row: Omit<ReplayRow, 'selection' | 'held_out'> | Omit<MultiIntervalRow, 'selection' | 'held_out'>;
532
+ /** The multi-interval search only (issue #148): every frame id any probe's comparison walked, for the final roles check. */
533
+ probeFrames?: string[];
534
+ } | null;
535
+ problems: Problem[];
536
+ /** What the stage prints about the search. */
537
+ lines: string[];
538
+ }
539
+
540
+ /**
541
+ * The search of one automatic part (`src/autoreplay.ts`, module header): the
542
+ * source compared with itself on the grid frames first — a source the gate
543
+ * would refuse leaves the part refused as the full result was, and no search
544
+ * runs — then a bisection over the removal steps of the full run's
545
+ * `acceptedAt`. Each probe replays a step (`AutoSearch.replay`, pure), puts it
546
+ * into `rig` alone, builds that rig through rigc's gate without packing (the
547
+ * reference's build, {@link REFERENCE_BUILD_ARGS}) and compares it with the
548
+ * reference on the grid frames alone ({@link selectionSchedule}). A replay
549
+ * that breaks rig-c's promise, a red build or a refused comparison refuses the
550
+ * part; a replay that keeps the promise and misses a geometry target is a probe
551
+ * that did not pass.
552
+ */
553
+ export function replaySearch(
554
+ rig: RigOutput,
555
+ c: AutoMotionCase,
556
+ run: MotionGateRun,
557
+ images: ReadonlyArray<readonly [string, Uint8Array]>,
558
+ texts: ReadonlyArray<readonly [string, string]>,
559
+ rigc: RigcRunner,
560
+ scratch: string,
561
+ ): ReplaySearchRun {
562
+ const lines: string[] = [];
563
+ const refuse = (p: Problem): ReplaySearchRun => ({ part: c.part, chosen: null, problems: [p], lines });
564
+ if (c.motion === undefined || run.report === null || run.referenceModel === null) return { part: c.part, chosen: null, problems: run.problems, lines };
565
+ const motion = c.motion;
566
+ const refModel = run.referenceModel;
567
+ const grid = gridFrameIds(run.report);
568
+ const N = c.search.acceptedAt.length;
569
+ const I = c.search.inserted;
570
+ const fullLocal = localRow(run.report);
571
+ const fullReading = fullLocal === null ? null : { value: fullLocal.value, state: fullLocal.state, frame: fullLocal.worst?.frame?.id ?? null };
572
+ const fullText = `reads ${failingRows(run.report).map(motionRowText).join('; ') || 'no failing row'}`;
573
+ // Step 0: the source against itself on the selection frames. It reads no motion; a geometry row it fails leaves the part refused as today.
574
+ // issue #196: a declared stimulus is walked in every probe too, and read to choose (`withStimulus`), as the grid is.
575
+ const probeSchedule = withStimulus(selectionSchedule(grid), c.stimulus);
576
+ const staged = stimulusTexts(c, texts);
577
+ if ('code' in staged) return refuse(staged);
578
+ const self = runComparison(c.object, motionInput(c, motion, refModel, refModel, probeSchedule));
579
+ if ('code' in self) return refuse(self);
580
+ if (motionVerdict(c.object, self) !== null) {
581
+ lines.push(`replay "${c.part}": the unreduced source is not accepted against itself on the grid frames; no search runs`);
582
+ return { part: c.part, chosen: null, problems: run.problems, lines };
583
+ }
584
+ const selection = motion.selection;
585
+ if (selection !== undefined && selection.maxProbes < maxReplays(N, I)) return refuse(budgetProblem(c.object, selection.maxProbes, N, I));
586
+ const kept = new Map<number, ReplayCandidate>();
587
+ const probeFrames: string[] = [];
588
+ let fatal: Problem | null = null;
589
+ let candidatesTried = 0;
590
+ const counted = (step: number): CountedProbe => {
591
+ const refused = (reason: string): CountedProbe => ({ step, verdict: 'refused', value: null, frame: null, reason, vertices: null });
592
+ if (fatal !== null) return refused('the search was refused at an earlier step');
593
+ const rep = c.search.replay(step);
594
+ if ('code' in rep) {
595
+ if (rep.code !== 'AUTO_MESH_ACCEPTED') fatal = rep;
596
+ return refused(rep.code);
597
+ }
598
+ candidatesTried += rep.candidatesTried;
599
+ const candRig = JSON.parse(rigJsonText(rig.rig)) as RigSpec;
600
+ candRig.skins.default[c.part] = { [c.part]: rep.attachment };
601
+ const candTexts = staged.map(([file, text]) => [file, file === 'rig.json' ? rigJsonText(candRig) : text] as const);
602
+ let model: string | null = null;
603
+ const [gate] = gateThroughRigc(images, candTexts, rigc, scratch, null, (dir) => {
604
+ model = modelAt(dir);
605
+ });
606
+ if (gate.status !== 0 || model === null) {
607
+ fatal = {
608
+ code: 'AUTO_MESH_MOTION_INPUT',
609
+ object: c.object,
610
+ detail: `the replay to accepted step ${step} (the rig with "${c.part}" as that step's mesh) ${gate.status !== 0 ? `is refused by rigc's gate, exit ${gate.status}${gate.lines.length > 0 ? `: ${gate.lines.map((l) => l.trim()).join(' | ')}` : ''}` : `built green and wrote no ${RIGC_MODEL_DOCUMENT}`}; a candidate that is not gated is not evidence, and nothing is built in the part's place`,
611
+ };
612
+ return refused(`rigc exit ${gate.status}`);
613
+ }
614
+ const cmp = runComparison(c.object, motionInput(c, motion, refModel, model, probeSchedule));
615
+ if ('code' in cmp) {
616
+ fatal = cmp;
617
+ return refused(cmp.code);
618
+ }
619
+ const r = localRow(cmp);
620
+ const pass = motionVerdict(c.object, cmp) === null;
621
+ if (pass) kept.set(step, rep);
622
+ for (const f of cmp.candidates[0]?.motion?.schedule.walked ?? []) probeFrames.push(f.id);
623
+ return { step, verdict: pass ? 'pass' : 'fail', value: r?.value ?? null, frame: r?.worst?.frame?.id ?? null, reason: null, vertices: rep.row.vertices };
624
+ };
625
+ if (selection !== undefined) return multiIntervalRun(c, selection.maxProbes, N, I, counted, () => fatal, kept, grid, probeFrames, fullReading, fullText, () => candidatesTried, lines);
626
+ // The bisection, the stage as it was before issue #148: a probe carries no vertex count.
627
+ const probe = (step: number): ReplayProbe => {
628
+ const { vertices: _counted, ...p } = counted(step);
629
+ return p;
630
+ };
631
+ const { chosen, probes } = bisectAccepted(I, N, probe);
632
+ lines.push(
633
+ `replay "${c.part}": bisection over the removal steps ${I + 1}..${N - 1} of ${N} accepted step(s), ${probes.length} replay(s) of at most ${maxReplays(N, I)}: ${probes.map((p) => `${p.step} ${p.verdict}${p.value === null ? '' : ` ${p.value}`}`).join(', ') || 'none'}`,
634
+ );
635
+ if (fatal !== null) return refuse(fatal);
636
+ const candidate = kept.get(chosen);
637
+ if (chosen <= I || candidate === undefined) return refuse(noReductionProblem(c.object, fullText, N, I, probes));
638
+ return {
639
+ part: c.part,
640
+ chosen: {
641
+ candidate,
642
+ grid,
643
+ row: { rule: REPLAY_RULE, accepted_steps: N, refinement_steps: I, chosen_step: chosen, replays: probes.length, max_replays: maxReplays(N, I), candidates_tried: candidatesTried, full: fullReading, probes },
644
+ },
645
+ problems: [],
646
+ lines,
647
+ };
648
+ }
649
+
650
+ /**
651
+ * The multi-interval search of one automatic part (issue #148; `src/autoreplay.ts`, `multiIntervalSearch`), run by
652
+ * {@link replaySearch} when the author wrote `motion.selection`: the same probe — a replay, an unpacked gated build, a
653
+ * comparison on the grid frames alone — called once per distinct step, up to `maxProbes` replays; the fewest-vertex
654
+ * candidate among the tested steps that passed is chosen, the lower step on a tie. The source (step I) and the full
655
+ * result (step N) bound the domain and are never written; the bisection's own result is among the tested.
656
+ */
657
+ function multiIntervalRun(
658
+ c: AutoMotionCase,
659
+ maxProbes: number,
660
+ N: number,
661
+ I: number,
662
+ probe: (step: number) => CountedProbe,
663
+ fatalOf: () => Problem | null,
664
+ kept: ReadonlyMap<number, ReplayCandidate>,
665
+ grid: string[],
666
+ probeFrames: string[],
667
+ full: MultiIntervalRow['full'],
668
+ fullText: string,
669
+ triedOf: () => number,
670
+ lines: string[],
671
+ ): ReplaySearchRun {
672
+ const run = multiIntervalSearch(I, N, maxProbes, probe, () => fatalOf() !== null);
673
+ const tested = [...new Set(run.probes.map((p) => p.step))].sort((a, b) => a - b);
674
+ const pick = run.chosen;
675
+ lines.push(
676
+ `replay "${c.part}": multi-interval selection over the removal steps ${I + 1}..${N - 1} of ${N} accepted step(s), ${run.probes.length} replay(s) of at most ${maxProbes} (maxProbes), ended ${run.termination}: ` +
677
+ `${run.probes.map((p) => `${p.step} ${p.verdict}${p.value === null ? '' : ` ${p.value}`}${p.vertices === null ? '' : ` v=${p.vertices}`}`).join(', ') || 'none'}; ` +
678
+ `the bisection kept ${run.bisection <= I ? 'none' : `step ${run.bisection}`}; ${pick === null ? 'no tested step passed' : `the fewest vertices among the tested passing steps: step ${pick.step}, ${pick.vertices} vertices`} (best among tested candidates)`,
679
+ );
680
+ const fatal = fatalOf();
681
+ if (fatal !== null) return { part: c.part, chosen: null, problems: [fatal], lines };
682
+ const candidate = pick === null ? undefined : kept.get(pick.step);
683
+ if (pick === null || candidate === undefined) return { part: c.part, chosen: null, problems: [noMultiReductionProblem(c.object, fullText, N, I, maxProbes, run.termination, run.probes)], lines };
684
+ const counts = candidate.row.result.counts;
685
+ return {
686
+ part: c.part,
687
+ chosen: {
688
+ candidate,
689
+ grid,
690
+ probeFrames,
691
+ row: {
692
+ rule: MULTI_INTERVAL_RULE,
693
+ policy: 'multi-interval',
694
+ max_probes: maxProbes,
695
+ accepted_steps: N,
696
+ refinement_steps: I,
697
+ chosen_step: pick.step,
698
+ bisection_step: run.bisection,
699
+ replays: run.probes.length,
700
+ candidates_tried: triedOf(),
701
+ termination: run.termination,
702
+ tested,
703
+ passing_intervals: passingIntervals(run.probes),
704
+ untested_intervals: untestedIntervals(I, N, new Set(tested)),
705
+ chosen: { vertices: candidate.row.vertices, boundary: counts.boundaryVertices, interior: counts.interiorVertices, triangles: candidate.row.triangles, bindings: counts.bindings },
706
+ full,
707
+ probes: run.probes,
708
+ roles: { selection: 'grid', held_out: 'irr', probe_frames: new Set(probeFrames).size },
709
+ },
710
+ },
711
+ problems: [],
712
+ lines,
713
+ };
714
+ }
715
+
716
+ /**
717
+ * Author the rig, gate it through rigc in `scratch`, and write `out` only when
718
+ * the gate is green — and, for every automatic mesh, only when its motion gate
719
+ * accepted it (issue #126 item 3; {@link motionGates}).
720
+ */
721
+ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string, log: Log): RigOutput {
722
+ const { config: cfg, animations: from } = loadConfigAndAnimations(input.config);
723
+ const parts = readParts(join(input.parts, 'parts.json'));
724
+ const images = new Map<string, Raster>();
725
+ for (const p of parts.parts) {
726
+ const png = join(input.parts, 'parts', `${p.name}.png`);
727
+ if (existsSync(png)) images.set(p.name, readPng(png));
728
+ }
729
+ const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
730
+ const textsOf = (r: RigOutput): Array<[string, string]> => [
731
+ ['rig.json', rigJsonText(r.rig)],
732
+ ['motion.json', rigJsonText(withAnimations(r.motion, from === null ? null : from.table))],
733
+ ['mesh_report.json', rigJsonText(r.meshReport)],
734
+ ];
735
+ const firstTexts = textsOf(first);
736
+ // The gate runs before the lines are printed, and its lines print where they always did: an automatic part's motion
737
+ // gate compares the model document of this very build, the one written (issue #126 item 3). mesh_report.json is staged
738
+ // as buildRig wrote it; rigc reads only rig.json, motion.json and images/.
739
+ const pngs = first.images.map(([file, img]) => [file, encodePngBytes(img)] as const);
740
+ const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
741
+ let firstModel: string | null = null;
742
+ const firstGate = gateThroughRigc(pngs, firstTexts, rigc, scratch, mode, first.autoMotion.length === 0 ? undefined : (dir) => (firstModel = modelAt(dir)));
743
+ const firstRed = firstGate.filter((g) => g.status !== 0);
744
+ const firstMotion = firstRed.length > 0 ? [] : motionGates(first, pngs, firstTexts, firstModel, rigc, scratch);
745
+ // The acceptance loop (src/autoreplay.ts): a part whose full result the motion gate refused, and only such a part, is
746
+ // searched for a replayed step. Where no part is, nothing below runs and the stage is the one it always was.
747
+ const searches = firstMotion
748
+ .filter((r) => r.report !== null && r.problems.some((p) => p.code === 'AUTO_MESH_MOTION'))
749
+ .map((r) => replaySearch(first, first.autoMotion.find((c) => c.part === r.part) as AutoMotionCase, r, pngs, firstTexts, rigc, scratch));
750
+ const searched = new Set(searches.map((s) => s.part));
751
+ const chosen = searches.flatMap((s) => (s.chosen === null ? [] : [{ part: s.part, ...s.chosen }]));
752
+ const settled = searches.length > 0 && chosen.length === searches.length && firstMotion.every((r) => searched.has(r.part) || r.problems.length === 0);
753
+ // Every part's search chose a step: the rig with those steps in place goes through the gate again, as written, and
754
+ // each automatic part through the motion gate again — a chosen one on the whole idle with its grid frames as selection.
755
+ let rig = first;
756
+ let texts = firstTexts;
757
+ let gate = firstGate;
758
+ let motion: MotionGateRun[] = firstMotion.map((r) => (searched.has(r.part) ? { ...r, problems: searches.find((s) => s.part === r.part)?.problems ?? r.problems } : r));
759
+ let finalGate: GateRun[] | null = null;
760
+ let finalMotion: MotionGateRun[] = [];
761
+ const replays = new Map<string, ReplayRow | MultiIntervalRow>();
762
+ if (settled) {
763
+ const swapped = JSON.parse(rigJsonText(first.rig)) as RigSpec;
764
+ for (const k of chosen) swapped.skins.default[k.part] = { [k.part]: k.candidate.attachment };
765
+ rig = { ...first, rig: swapped, meshReport: first.meshReport.map((m) => chosen.find((k) => k.part === m.part)?.candidate.row ?? m) };
766
+ texts = textsOf(rig);
767
+ let model: string | null = null;
768
+ finalGate = gateThroughRigc(pngs, texts, rigc, scratch, mode, (dir) => (model = modelAt(dir)));
769
+ gate = finalGate;
770
+ finalMotion = gate.some((g) => g.status !== 0) ? [] : motionGates(rig, pngs, texts, model, rigc, scratch, new Map(chosen.map((k) => [k.part, splitSchedule(k.grid)])));
771
+ motion = finalMotion.map((r) => {
772
+ const k = chosen.find((x) => x.part === r.part);
773
+ if (k === undefined || r.report === null) return r;
774
+ const roles = roleReadings(r.report);
775
+ replays.set(r.part, { ...k.row, selection: roles.selection, held_out: roles.held_out });
776
+ // issue #148: a multi-interval choice holds its roles — no probe read a frame the final comparison holds out.
777
+ const c = rig.autoMotion.find((x) => x.part === r.part) as AutoMotionCase;
778
+ const leaked = k.probeFrames === undefined ? null : rolesProblem(c.object, k.row.chosen_step, k.probeFrames, r.report);
779
+ if (leaked !== null) return { ...r, problems: [leaked] };
780
+ if (!r.problems.some((p) => p.code === 'AUTO_MESH_MOTION')) return r;
781
+ const held = finalVerdict(c.object, k.row.chosen_step, k.row.accepted_steps, r.report);
782
+ return { ...r, problems: held === null ? [] : [held] };
783
+ });
784
+ }
785
+ const red = gate.filter((g) => g.status !== 0);
786
+ log(`rig-parts rig: ${cfg.key}, rig ${parts.rig_size[0]}x${parts.rig_size[1]}, ${parts.parts.length} part(s)`);
787
+ let vertices = 0;
788
+ for (const m of rig.meshReport) {
789
+ vertices += m.vertices;
790
+ const head = ` mesh ${m.part.padEnd(12)} v=${String(m.vertices).padStart(4)} t=${String(m.triangles).padStart(4)} hull=${String(m.hull).padStart(3)} `;
791
+ const infl = `bones=${m.bones.length} infl max ${m.max_influences} mean ${m.mean_influences.toFixed(2)} cover ${m.art_coverage.toFixed(5)}`;
792
+ if ('mode' in m && m.mode === 'auto') {
793
+ const s = m.source.counts;
794
+ const r = m.result.counts;
795
+ const t = m.termination;
796
+ const tried = t.reason === 'no-further-valid-reduction' || t.reason === 'budget-exhausted' || t.reason === 'replayed-to-accepted-step' ? ` after ${t.candidatesTried} candidate(s)` : '';
797
+ const worst = m.worst_residual === null ? 'none declared' : `${m.worst_residual.code}${m.worst_residual.region === null ? '' : `[${m.worst_residual.region}]`} ${m.worst_residual.value} ${m.worst_residual.bound?.op} ${m.worst_residual.bound?.value}`;
798
+ const from = s === null ? 'unread' : `${s.boundaryVertices}+${s.interiorVertices}`;
799
+ const run = motion.find((x) => x.part === m.part);
800
+ const moved = run === undefined ? 'motion not compared (the gate is red)' : run.report !== null ? motionClause(run.report) : `motion not compared (${run.notCompared ?? 'refused'})`;
801
+ const rp = replays.get(m.part);
802
+ const replayed =
803
+ rp === undefined || run?.report === null || run === undefined
804
+ ? ''
805
+ : `; replayed to accepted step ${rp.chosen_step} of ${rp.accepted_steps} after ${rp.replays} replay(s); selection ${readingText(rp.selection, roleReadings(run.report).bound)}; held out ${readingText(rp.held_out, roleReadings(run.report).bound)}`;
806
+ log(`${head}auto ${from} -> ${r.boundaryVertices}+${r.interiorVertices} (hull+interior) bindings ${r.bindings} ${infl} ${t.reason}${tried}; worst ${worst}${unboundedClause(m.residuals, m.settings.targets.artFit)}${allocationClause(m.residuals)}${stageBClause(m.settings, m.result)}${residualClause(m.skinning_residual)}${strayClause(m.stray_cleared)}; ${moved}${replayed}`);
807
+ } else if ('mode' in m) {
808
+ const c = m.contour;
809
+ const stray = c.strayIslands === 0 ? '' : ` left out ${c.strayIslands} island(s), ${c.strayPixels} px`;
810
+ const regions = m.regions.map((rg) => ` region ${rg.name}->${rg.bone} reaches ${rg.reached} (${rg.whole} whole)`).join('');
811
+ log(`${head}contour tol=${m.params.tolerance} margin=${m.params.margin} spacing=${m.params.spacing} ${infl} overshoot ${c.overshoot} min angle ${c.smallestAngle.value}${stray}${regions}`);
812
+ } else log(`${head}grid=${m.grid} ${infl} loop passes ${rig.loopPasses[m.part]}`);
813
+ }
814
+ const regions = rig.rig.slots.length - rig.meshReport.length;
815
+ const tracks = rig.motion.animations.idle.tracks;
816
+ const keys = tracks.reduce((n, t) => n + t.keys.length, 0);
817
+ log(
818
+ ` bones ${rig.rig.bones.length} (${rig.controls.length} control) slots ${rig.rig.slots.length} meshes ${rig.meshReport.length} regions ${regions} vertices ${vertices}; idle ${rig.motion.animations.idle.duration} s, ${tracks.length} track(s), ${keys} key(s)`,
819
+ );
820
+ if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
821
+ log(
822
+ rig.idleKeys === 'ctl'
823
+ ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
824
+ : ` idle keys direct: ${rig.meshKeyed.length} mesh-driving bone(s) keyed in place, ${rig.rig.invariants === undefined ? 'so no invariants.idleDrivesMeshes is declared (it would switch nothing off)' : 'invariants.idleDrivesMeshes declared'}`,
825
+ );
826
+ for (const g of firstGate) {
827
+ log(` rigc ${g.label}: exit ${g.status}`);
828
+ for (const l of g.lines) log(` ${l.trim()}`);
829
+ }
830
+ for (const r of firstMotion) {
831
+ if (r.candidate !== undefined) {
832
+ log(` rigc ${r.candidate.label}, candidate "${r.part}" with its declared stimulus: exit ${r.candidate.status}`);
833
+ for (const l of r.candidate.lines) log(` ${l.trim()}`);
834
+ }
835
+ if (r.reference === null) continue;
836
+ log(` rigc ${r.reference.label}, reference "${r.part}" (its unreduced source mesh): exit ${r.reference.status}`);
837
+ for (const l of r.reference.lines) log(` ${l.trim()}`);
838
+ }
839
+ for (const s of searches) for (const l of s.lines) log(` ${l}`);
840
+ if (finalGate !== null) {
841
+ for (const g of finalGate) {
842
+ log(` rigc ${g.label}, the rig with its replayed step(s): exit ${g.status}`);
843
+ for (const l of g.lines) log(` ${l.trim()}`);
844
+ }
845
+ for (const r of finalMotion) {
846
+ if (r.candidate !== undefined) {
847
+ log(` rigc ${r.candidate.label}, candidate "${r.part}" with its declared stimulus and replayed step(s): exit ${r.candidate.status}`);
848
+ for (const l of r.candidate.lines) log(` ${l.trim()}`);
849
+ }
850
+ if (r.reference === null) continue;
851
+ log(` rigc ${r.reference.label}, reference "${r.part}" against the rig with its replayed step(s): exit ${r.reference.status}`);
852
+ for (const l of r.reference.lines) log(` ${l.trim()}`);
853
+ }
854
+ }
855
+ if (red.length > 0) {
856
+ const problems: Problem[] = red.map((g) => ({
857
+ code: 'RIG_RIGC_GREEN',
858
+ object: `rigc ${g.label}`,
859
+ detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}; ${ctlRemedies(g.lines, rig.controls, input.command ?? 'rig').map((s) => `${s}; `).join('')}exit 0 is required before anything is written, and nothing was`,
860
+ }));
861
+ throw new PartsError(problems);
862
+ }
863
+ // Emit only after green: a part the motion gate did not accept refuses the stage, every part's problem named at once.
864
+ refuseIfAny(motion.flatMap((r) => r.problems));
865
+ if (motion.length > 0) {
866
+ const rows = withMotion(rig.meshReport, motion, rig.autoMotion, replays);
867
+ rig.meshReport.splice(0, rig.meshReport.length, ...rows);
868
+ texts[2] = ['mesh_report.json', rigJsonText(rig.meshReport)];
869
+ }
870
+ mkdirSync(join(input.out, 'images'), { recursive: true });
871
+ for (const [file, img] of rig.images) writePng(join(input.out, 'images', file), img);
872
+ for (const [file, text] of texts) writeFileSync(join(input.out, file), text);
873
+ log(`rig-parts rig: wrote ${join(input.out, 'rig.json')}, motion.json, mesh_report.json and ${rig.images.length} image(s) under ${join(input.out, 'images')}`);
874
+ return rig;
875
+ }
876
+
877
+ // ---------------------------------------------------------------------------
878
+ // check
879
+ // ---------------------------------------------------------------------------
880
+
881
+ export interface CheckStageInput {
882
+ /** The directory holding rig.json and motion.json. */
883
+ rig: string;
884
+ /**
885
+ * The directory holding parts.json and parts/ (`--parts`). Absent, it is the
886
+ * rig directory, where a missing parts.json is measured without (issue #77);
887
+ * named, a missing one is refused.
888
+ */
889
+ parts?: string;
890
+ out: string;
891
+ /** The painting `--source` names (issue #77): adds the setup pose against it. */
892
+ source?: string;
893
+ /** The packed build's `--page-edges`; {@link DEFAULT_PAGE_EDGES} when absent. */
894
+ pageEdges?: PageEdges;
895
+ /** The packed build's `--pack-shape`; {@link DEFAULT_PACK_SHAPE} when absent. */
896
+ packShape?: PackShape;
897
+ /** The scene's declared requirements (`--requirements`, issue #93); absent, nothing is read, written or printed for them. */
898
+ requirements?: string;
899
+ /**
900
+ * `check --seam-pairs` (issue #202): split the seam's counted pixels by pair
901
+ * and draw each pair's picture even when the bar passes. A failing seam is
902
+ * always split; absent and passing, nothing is written or printed for it.
903
+ */
904
+ seamPairs?: boolean;
905
+ /** `check --keep-stills` (issue #209): leave the setup-pose still and its `frames.json` under {@link STILL_DIR}. */
906
+ keepStills?: boolean;
907
+ }
908
+
909
+ /**
910
+ * What a declared `source.stray` cleared, as the rig stage prints it on the part's mesh line (issue #172): empty when
911
+ * nothing was declared or nothing was left out, so such a line prints what it always printed.
912
+ */
913
+ export function strayClause(cleared: StrayCleared | undefined): string {
914
+ if (cleared === undefined || cleared.islands === 0) return '';
915
+ return `; stray cleared ${cleared.islands} island(s), ${cleared.pixels} of ${cleared.art_pixels} art px, from the art every reader of the mesh takes (the image is drawn as assembled)`;
916
+ }
917
+
918
+ /** The pack line as it is printed: rigc's own, then the page's opaque share beside the spineboy yardstick. */
919
+ export function packLines(r: CheckReport): string[] {
920
+ if (r.pack.length === 0) return ['pack: no pack line in the build output'];
921
+ return r.pack.map((p) => {
922
+ const op = r.packOpaque.find((o) => o.page === p.page);
923
+ return `${p.line}; page opaque ${op === undefined ? 'not measured (page not on disk)' : `${(op.share * 100).toFixed(1)}%`} (alpha > 0) — spineboy yardstick ${SPINEBOY_YARDSTICK}, a reference and not a bar`;
924
+ });
925
+ }
926
+
927
+ function showFigure(v: unknown): string {
928
+ if (Array.isArray(v)) return v.length === 0 ? 'none' : v.map(showFigure).join(' | ');
929
+ if (typeof v === 'object' && v !== null) return Object.entries(v).map(([k, x]) => `${k} ${showFigure(x)}`).join(', ');
930
+ return String(v);
931
+ }
932
+
933
+ /**
934
+ * How many of the {@link BARS} measured and how many said SKIP, by name — the
935
+ * summary's second half (issue #77): `PASS` reads only the bars that measured,
936
+ * so a run that measured one bar says so on the line that says PASS.
937
+ */
938
+ export function barsSummary(r: CheckReport): string {
939
+ const { measured, skipped } = r.bars;
940
+ return `${measured.length} of ${BARS.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
941
+ }
942
+
943
+ /** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
944
+ export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
945
+ if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
946
+ return `${name}: ${line.status} — ${Object.entries(line)
947
+ .filter(([k]) => k !== 'status')
948
+ .map(([k, v]) => `${k} ${showFigure(v)}`)
949
+ .join('; ')}`;
950
+ }
951
+
952
+ /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
953
+ export function requirementText(name: string, line: RequirementLine): string {
954
+ const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
955
+ return `${name}: ${line.status} — ${[...(line.status === 'NOT MEASURABLE' ? [String(line.reason)] : []), ...rest.map(([k, v]) => `${k} ${showFigure(v)}`)].join('; ')}`;
956
+ }
957
+
958
+ /**
959
+ * Run the check and print its report. Returns the report; `figures.PASS` says
960
+ * whether every bar was met, and each one that was not is printed as a FAIL
961
+ * line here. Throws a PartsError when the check could not measure at all.
962
+ */
963
+ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string, log: Log): CheckReport {
964
+ const v = rigc(['--version']);
965
+ log(`rig-parts check: ${input.rig} -> ${input.out}`);
966
+ const versionLines = v.out.trim().split('\n');
967
+ const entryLine = versionLines.find((l) => l.startsWith('entry:'));
968
+ log(` rigc ${versionLines[0]} at ${bin}${entryLine === undefined ? '' : `; ${entryLine}`}`);
969
+ const mode: PackMode = { pageEdges: input.pageEdges ?? DEFAULT_PAGE_EDGES, packShape: input.packShape ?? DEFAULT_PACK_SHAPE };
970
+ const r = runCheck(input.rig, input.out, rigc, input.parts, mode, input.source, input.requirements, input.seamPairs === true, input.keepStills === true);
971
+ log(` gate spine-html (rigc ${packedBuildLabel(mode)}), verbatim:`);
972
+ for (const l of r.gateHtml) log(l);
973
+ for (const l of packLines(r)) log(` ${l}`);
974
+ const fig = r.figures;
975
+ if (r.idle === null) log(` loop: SKIP — ${fig.skipped?.loop ?? ''}`);
976
+ else log(` loop: idle ${r.idle.frames} frame(s) at ${r.idle.fps} fps, f0000 vs f${String(r.idle.lastIndex).padStart(4, '0')} (t = ${r.idle.duration}s): max |d| ${fig.loop_max_diff} (0 required)`);
977
+ if (r.loopPhysics !== null) log(` ${r.loopPhysics}`);
978
+ const beside = besideIdleLine(r.besideIdle);
979
+ if (beside !== null) log(` ${beside}`);
980
+ if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
981
+ else {
982
+ log(
983
+ ` seam: setup pose at ${r.seamViewport.pixelWidth}x${r.seamViewport.pixelHeight}, scale ${r.seamViewport.scale.toFixed(4)}: mean |d| ${fig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
984
+ `${fig.seam_px_over_40} px over ${SEAM_PX_LEVEL}, ${fig.seam_frame_scale === null || fig.seam_px_over_40 === null ? '' : seamRigPxText(fig.seam_px_over_40, fig.seam_frame_scale)} rig px (the count / s²; <= ${SEAM_PX_BAR}), ${fig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
985
+ );
986
+ const pairs = fig.seam_pairs ?? [];
987
+ pairs.forEach((row, i) => log(` seam pair ${i + 1} of ${pairs.length}: ${seamPairText(row)}`));
988
+ }
989
+ for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
990
+ for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
991
+ const vsSource = fig[SOURCE_LINE];
992
+ if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
993
+ if (r.requirements !== null) {
994
+ for (const [name, line] of Object.entries(r.requirements.lines)) log(` ${requirementText(name, line)}`);
995
+ log(` ${summaryText(r.requirements.summary)}`);
996
+ }
997
+ log(` gate: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'} (${fig.rigc_entry.entry}${fig.rigc_entry.spine_core === null ? ', rigc\'s own validator' : `, the spine-core ${fig.rigc_entry.spine_core} round trip`})`);
998
+ log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/${r.idle === null ? '' : `, ${join(input.out, 'idle_frames')}/`}${r.requirements === null ? '' : `, ${join(input.out, REQUIREMENTS_DIR)}/`}${fig.seam_pairs === undefined ? '' : `, ${join(input.out, SEAM_DIR)}/`}${existsSync(join(input.out, STILL_DIR)) ? `, ${join(input.out, STILL_DIR)}/` : ''}`);
999
+ for (const p of r.problems) log(` FAIL ${problemLine(p)}`);
1000
+ const reqNotPass = r.requirements === null ? 0 : r.requirements.summary.declared - r.requirements.summary.pass;
1001
+ const notMet = r.requirements === null ? `${r.problems.length} bar(s) not met` : `${r.problems.length - reqNotPass} bar(s) not met, ${reqNotPass} declared requirement(s) not PASS`;
1002
+ log(`${fig.PASS ? 'check: PASS' : `check: FAIL — ${notMet}`}; ${barsSummary(r)}`);
1003
+ return r;
1004
+ }
1005
+
1006
+ // ---------------------------------------------------------------------------
1007
+ // loop
1008
+ // ---------------------------------------------------------------------------
1009
+
1010
+ function sameRgba(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
1011
+ if (a.length !== b.length) return false;
1012
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
1013
+ return true;
1014
+ }
1015
+
1016
+ /**
1017
+ * The loop file's format: `apng` is the lossless APNG, the exactness record;
1018
+ * `indexed` the colour-type-3 APNG with one shared palette, the README-sized
1019
+ * file; `gif` the GIF89a. The CLI picks it from the extension and `--palette`.
1020
+ */
1021
+ export type LoopFormat = 'apng' | 'indexed' | 'gif';
1022
+
1023
+ export interface LoopResult {
1024
+ bytes: number;
1025
+ /** The palette error over every encoded frame; null for the lossless APNG, which has none. */
1026
+ error: PaletteError | null;
1027
+ }
1028
+
1029
+ /**
1030
+ * Encode a rigc frame set as a looping APNG, indexed APNG or GIF and write it.
1031
+ * An encoder refusal is a PartsError coded `LOOP_ENCODE`, so it prints like
1032
+ * every other.
1033
+ */
1034
+ export function loopStage(frames: string, out: string, format: LoopFormat, log: Log): LoopResult {
1035
+ const set = readFrameSet(frames);
1036
+ const images = set.frames.map((fr) => fr.image);
1037
+ log(`rig-parts loop: ${set.dir} -> ${out}`);
1038
+ log(` ${images.length} frame(s) ${images[0]?.width ?? 0}x${images[0]?.height ?? 0} at ${set.fps} fps (from frames.json), animation ${set.animation ?? '(none)'}`);
1039
+ let used = images;
1040
+ const lastName = set.frames[set.frames.length - 1]?.name ?? '';
1041
+ if (images.length > 1 && sameRgba(images[0].data, images[images.length - 1].data)) {
1042
+ used = images.slice(0, -1);
1043
+ log(` ${lastName} equals f0000.png byte for byte, so it is dropped: the loop wraps onto frame 0, and showing it twice would hold that pose for two ticks`);
1044
+ } else if (images.length > 1) {
1045
+ log(` ${lastName} differs from f0000.png, so every frame is kept and the loop will jump at the wrap`);
1046
+ }
1047
+ const anim: AnimFrame[] = used.map((image) => ({ image, ticks: 1 }));
1048
+ log(` ${anim.length} frame(s) encoded, ${(anim.length / set.fps).toFixed(3)}s per loop, looping forever`);
1049
+ try {
1050
+ if (format === 'apng') {
1051
+ const { bytes, stats } = encodeApng(anim, set.fps);
1052
+ writeFileSync(out, bytes);
1053
+ log(` APNG: ${stats.frames} frame(s) after merging identical neighbours, ${stats.overFrames} of ${Math.max(0, stats.frames - 1)} later frame(s) blended OVER with unchanged pixels cleared, lossless`);
1054
+ log(` wrote ${out}: ${stats.bytes} bytes`);
1055
+ return { bytes: stats.bytes, error: null };
1056
+ }
1057
+ if (format === 'indexed') {
1058
+ const { bytes, stats } = encodeIndexedApng(anim, set.fps);
1059
+ writeFileSync(out, bytes);
1060
+ log(
1061
+ ` indexed APNG: ${stats.frames} frame(s) after merging, ${stats.overFrames} of ${Math.max(0, stats.frames - 1)} later frame(s) blended OVER; ${stats.entries} palette entries (1 transparent, ${stats.entries - 1} cut from ${stats.distinct} distinct), ${stats.bitDepth}-bit, ${INDEXED_DEFAULTS.dither ? 'Floyd–Steinberg dithering' : 'no dithering'}, filter ${INDEXED_DEFAULTS.adaptiveFilter ? 'adaptive' : 'None'}`,
1062
+ );
1063
+ log(` ${errorText(stats.all)}`);
1064
+ log(` wrote ${out}: ${stats.bytes} bytes`);
1065
+ return { bytes: stats.bytes, error: stats.all };
1066
+ }
1067
+ const { bytes, stats } = encodeGif(anim, set.fps);
1068
+ writeFileSync(out, bytes);
1069
+ log(` GIF: ${stats.frames} frame(s) after merging, ${stats.colours} palette colour(s) cut from ${stats.distinct} distinct, no dithering`);
1070
+ log(` palette error (per channel, of 255): frame 0 max ${stats.frame0.max}, mean ${stats.frame0.mean.toFixed(3)}; all frames max ${stats.all.max}, mean ${stats.all.mean.toFixed(3)}`);
1071
+ log(` wrote ${out}: ${stats.bytes} bytes`);
1072
+ return { bytes: stats.bytes, error: stats.all };
1073
+ } catch (err) {
1074
+ if (err instanceof EncodeError) throw new PartsError([{ code: 'LOOP_ENCODE', object: out, detail: err.message }]);
1075
+ throw err;
1076
+ }
1077
+ }
1078
+
1079
+ /** A palette error as the loop lines print it: R, G, B per channel over every frame, and alpha. */
1080
+ function errorText(e: PaletteError): string {
1081
+ return `palette error (per channel, of 255, all frames): max ${e.max}, mean ${e.mean.toFixed(3)}; alpha max ${e.alphaMax}`;
1082
+ }
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // build
1086
+ // ---------------------------------------------------------------------------
1087
+
1088
+ export interface BuildInput {
1089
+ config: string;
1090
+ source: string;
1091
+ full: string;
1092
+ head: string;
1093
+ out: string;
1094
+ seam: SeamRule;
1095
+ project: ProjectRule;
1096
+ loop: boolean;
1097
+ /** `--page-edges` for both packed builds, the rig stage's gate and the check's artifact. */
1098
+ pageEdges: PageEdges;
1099
+ /** `--pack-shape` for both packed builds, as `pageEdges`; {@link DEFAULT_PACK_SHAPE} when absent. */
1100
+ packShape?: PackShape;
1101
+ /** `--requirements`, forwarded to the check (issue #93): read before assemble runs, so a file check would refuse is refused first; absent, nothing moves. */
1102
+ requirements?: string;
1103
+ /** `--idle-keys`, forwarded to the rig stage as `rig` takes it (issue #95); {@link DEFAULT_IDLE_KEYS} when absent. */
1104
+ idleKeys?: IdleKeys;
1105
+ }
1106
+
1107
+ /**
1108
+ * The rigc process for each stage: `rig` gates through one, `check` measures
1109
+ * through the other. `cli.ts` hands both the same runner, the `rigc` binary
1110
+ * rig-c's launcher answers for; they are two fields so a caller can tell
1111
+ * the stages' calls apart (the selftest counts them).
1112
+ */
1113
+ export interface BuildRunners {
1114
+ rig: RigcRunner;
1115
+ check: RigcRunner;
1116
+ /** Where the check's rigc binary is, for its header line. */
1117
+ checkBin: string;
1118
+ /** A directory the rig stage may stage its gate in; emptied before and after. */
1119
+ scratch: string;
1120
+ }
1121
+
1122
+ /** Every path `build` writes under `--out`, relative — and so every path it clears first. */
1123
+ export const BUILD_OWNS: readonly string[] = ['parts', 'parts.json', 'recomposite_rig.png', ERROR_MAP_FILE, 'rig', 'check', 'idle.gif', 'idle.png', 'idle-indexed.png'];
1124
+
1125
+ export type BuildStage = 'assemble' | 'rig' | 'check' | 'loop' | 'artifact';
1126
+
1127
+ export interface BuildResult {
1128
+ /** The stage that refused, or null when the build is green. */
1129
+ stoppedAt: BuildStage | null;
1130
+ check: CheckReport | null;
1131
+ /** The artifact's paths, on green: skeleton JSON, atlas, then each packed page. */
1132
+ artifact: string[];
1133
+ }
1134
+
1135
+ /**
1136
+ * rigc's own record of the compiled rig, which `build` writes beside the
1137
+ * skeleton JSON and atlas (rig-c's AUTHORING, the `--out` row; written
1138
+ * since 1.6): what rigc's posing core reads, not a Spine file, so it is not the
1139
+ * artifact and is not counted as a second skeleton JSON.
1140
+ */
1141
+ export const RIGC_MODEL_DOCUMENT = 'skeleton.model.json';
1142
+
1143
+ /** The three artifact files of a packed build, each refused by name when absent. */
1144
+ export function artifactPaths(buildDir: string, pack: readonly PackLine[]): string[] {
1145
+ const problems: Problem[] = [];
1146
+ const names = existsSync(buildDir) ? readdirSync(buildDir).sort() : [];
1147
+ const json = names.filter((n) => n.endsWith('.json') && n !== RIGC_MODEL_DOCUMENT);
1148
+ const atlas = names.filter((n) => n.endsWith('.atlas'));
1149
+ if (json.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} skeleton JSON`, detail: `holds ${json.length} .json file(s) [${json.join(', ')}]; the packed build writes exactly one besides rigc's ${RIGC_MODEL_DOCUMENT}` });
1150
+ if (atlas.length !== 1) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} atlas`, detail: `holds ${atlas.length} .atlas file(s) [${atlas.join(', ')}]; the packed build writes exactly one` });
1151
+ if (pack.length === 0) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: 'rigc printed no pack line, so no packed page is named; the build runs with --pack and must print one' });
1152
+ for (const p of pack) {
1153
+ if (!names.includes(p.page)) problems.push({ code: 'BUILD_ARTIFACT_PRESENT', object: `${buildDir} packed page`, detail: `the pack line names ${p.page}, which is not on disk` });
1154
+ }
1155
+ if (problems.length > 0) throw new PartsError(problems);
1156
+ return [join(buildDir, json[0]), join(buildDir, atlas[0]), ...pack.map((p) => join(buildDir, p.page))];
1157
+ }
1158
+
1159
+ /**
1160
+ * assemble -> rig -> check [-> loop], each stage's lines under its prefix,
1161
+ * stopping at the first stage that refuses. Returns where it stopped; the
1162
+ * caller turns that into the exit status. A crash that is not a refusal is
1163
+ * rethrown: it is not a stage's message and must not be printed as one.
1164
+ */
1165
+ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResult {
1166
+ const prefixed = (stage: BuildStage): Log => (line) => log(`[${stage}] ${line}`);
1167
+ const refused = (stage: BuildStage, err: unknown, check: CheckReport | null = null): BuildResult => {
1168
+ if (!(err instanceof PartsError)) throw err;
1169
+ const say = prefixed(stage);
1170
+ for (const p of err.problems) say(` FAIL ${problemLine(p)}`);
1171
+ say(`refused: ${err.problems.length} problem(s)`);
1172
+ log(`build: stopped at ${stage}; no later stage ran`);
1173
+ return { stoppedAt: stage, check, artifact: [] };
1174
+ };
1175
+ const out = input.out;
1176
+ mkdirSync(out, { recursive: true });
1177
+ for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
1178
+ // The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
1179
+ const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
1180
+ log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? ', with the idle loop' : ''}`);
1181
+
1182
+ try {
1183
+ // build runs rig next, which needs the whole config, so the full loader is
1184
+ // asked first: a config rig would refuse is refused before assemble writes
1185
+ // anything, and under [assemble], as it was before the stage had its own
1186
+ // narrower door.
1187
+ loadConfig(input.config);
1188
+ // The same for the requirements file's own shape; its names resolve against the rig, in the check, before it builds.
1189
+ if (input.requirements !== undefined) readRequirements(input.requirements);
1190
+ assembleStage(
1191
+ { source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project },
1192
+ { partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
1193
+ prefixed('assemble'),
1194
+ );
1195
+ } catch (err) {
1196
+ return refused('assemble', err);
1197
+ }
1198
+
1199
+ try {
1200
+ rigStage({ config: input.config, parts: out, out: join(out, 'rig'), idleKeys, pageEdges: input.pageEdges, packShape: input.packShape, command: 'build' }, run.rig, run.scratch, prefixed('rig'));
1201
+ } catch (err) {
1202
+ return refused('rig', err);
1203
+ }
1204
+
1205
+ let report: CheckReport;
1206
+ try {
1207
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }) }, run.check, run.checkBin, prefixed('check'));
1208
+ } catch (err) {
1209
+ return refused('check', err);
1210
+ }
1211
+ if (!report.figures.PASS) {
1212
+ log(`build: stopped at check; ${report.problems.length} bar(s) not met, so no loop was encoded and no artifact is reported`);
1213
+ return { stoppedAt: 'check', check: report, artifact: [] };
1214
+ }
1215
+
1216
+ if (input.loop) {
1217
+ const frames = join(out, 'check', 'idle_frames');
1218
+ try {
1219
+ const say = prefixed('loop');
1220
+ const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say);
1221
+ const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say);
1222
+ const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say);
1223
+ const fig = (e: PaletteError | null): string => (e === null ? 'lossless' : `max ${e.max}, mean ${e.mean.toFixed(3)}`);
1224
+ say(`loop: idle.png ${lossless.bytes} B (${fig(lossless.error)}); idle-indexed.png ${indexed.bytes} B (${fig(indexed.error)}); idle.gif ${gif.bytes} B (${fig(gif.error)}) — palette error per channel over all frames`);
1225
+ } catch (err) {
1226
+ return refused('loop', err, report);
1227
+ }
1228
+ }
1229
+
1230
+ let artifact: string[];
1231
+ try {
1232
+ artifact = artifactPaths(join(out, 'check', 'build'), report.pack);
1233
+ } catch (err) {
1234
+ return refused('artifact', err, report);
1235
+ }
1236
+ log(`build: PASS — the packed atlas is the artifact; parts/ and rig/ are the intermediates it was made from`);
1237
+ for (const l of packLines(report)) log(` ${l}`);
1238
+ for (const a of artifact) log(` ${a}`);
1239
+ return { stoppedAt: null, check: report, artifact };
1240
+ }