spine-rigc 1.9.2 → 1.9.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 (49) hide show
  1. package/cli.ts +28 -4397
  2. package/docs/AUTHORING.md +4 -4
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a06.ts +53 -3
  5. package/src/assertions/bodies/a13.ts +3 -3
  6. package/src/assertions/bodies/a14.ts +3 -3
  7. package/src/assertions/bodies/a15.ts +5 -5
  8. package/src/assertions/bodies/a19.ts +44 -2
  9. package/src/assertions/bodies/a22.ts +3 -3
  10. package/src/assertions/facts/animated_bones.ts +4 -2
  11. package/src/assertions/facts/mesh_attachments.ts +33 -4
  12. package/src/assertions/model/animated_bones.ts +13 -5
  13. package/src/assertions/model/bone_timelines.ts +5 -3
  14. package/src/assertions/model/declared.ts +127 -0
  15. package/src/assertions/model/index.ts +63 -40
  16. package/src/assertions/model/mesh_attachments.ts +31 -2
  17. package/src/assertions/model/parse.ts +9 -1
  18. package/src/assertions/model/runtime_timelines.ts +78 -0
  19. package/src/assertions/model/slider_composition.ts +10 -8
  20. package/src/assertions/model/stepped_poses.ts +3 -2
  21. package/src/bonedist.ts +4 -5
  22. package/src/check.ts +3 -2
  23. package/src/checkpics.ts +1 -1
  24. package/src/cli/core_commands.ts +1532 -0
  25. package/src/cli/shared.ts +1935 -0
  26. package/src/cli/spine_commands.ts +1141 -0
  27. package/src/core/additive.ts +52 -62
  28. package/src/core/constraints.ts +26 -3
  29. package/src/core/constraints_physics.ts +11 -1
  30. package/src/core/constraints_slider.ts +136 -6
  31. package/src/core/index.ts +27 -3
  32. package/src/core/skins.ts +28 -3
  33. package/src/core/walk.ts +9 -36
  34. package/src/correspondence.ts +15 -0
  35. package/src/deformbuild.ts +60 -0
  36. package/src/deformmeasure.ts +20 -48
  37. package/src/deformreport.ts +2 -2
  38. package/src/framing.ts +1 -1
  39. package/src/pose.ts +1 -1
  40. package/src/preview.ts +1 -1
  41. package/src/region_joins.ts +54 -0
  42. package/src/render.ts +206 -2836
  43. package/src/render_core.ts +19 -36
  44. package/src/render_shared.ts +2887 -0
  45. package/src/slots.ts +1 -1
  46. package/src/spine_side.ts +123 -0
  47. package/src/validate.ts +11 -91
  48. package/src/assertions/facts/skin_meshes.ts +0 -49
  49. package/src/assertions/model/skin_meshes.ts +0 -90
@@ -0,0 +1,1141 @@
1
+ /**
2
+ * The bodies of the commands that run through spine-core — `build`,
3
+ * `validate` and `bench` (the gate is the round trip), `bonedist` (it poses
4
+ * both skeletons through the runtime by design), `preview` and `vote` (the
5
+ * page names its atlas pages through the runtime's reader, and its gate line
6
+ * is the round trip's) — moved here unchanged from `cli.ts` (issue #1052, step
7
+ * 4e of #380), with the helpers only they call. Only `cli.ts` imports this
8
+ * module (`SPINE_COMMAND_RUNS`); an entry that does not links nothing of the
9
+ * runtime, and refuses these commands naming what each needs it for.
10
+ */
11
+ import { type AtlasRegion, DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE, packAtlas, PAGE_EDGES, type PageEdges, pageFootprint } from '../atlas.ts';
12
+ import { type BallotCandidateInput, type BallotInput, buildBallot, ledgerLineText, MAX_CANDIDATES, MIN_CANDIDATES, parseLedger, readBallotManifest, resultFilename, TIE, verifyResult, VOTE_RULES } from '../ballot.ts';
13
+ import { boneDistance, boneDistLines, type BoneDistReport } from '../bonedist.ts';
14
+ import { IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
15
+ import { checkLines, type CheckReport } from '../check.ts';
16
+ import { compile, type CompileOptions } from '../compile.ts';
17
+ import { diffLines, type DiffReport, diffSkeletons, reportedFigures, sectionFigures } from '../diff.ts';
18
+ import { copyAtlasPages, plannedPageCopies } from '../emit.ts';
19
+ import { findRung, RUNG_IDS, type RungSkeleton } from '../ladder.ts';
20
+ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
21
+ import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from '../preview.ts';
22
+ import { atlasPageNames } from '../render.ts';
23
+ import { BoneDistError } from '../bonedist.ts';
24
+ import { type CompileResult } from '../types.ts';
25
+ import { assertionCountForProfile, CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from '../validate.ts';
26
+ import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, dropLine, PACKAGE_ROOT, DEFAULT_LEDGER, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveArtifacts, resolveCut, resolveViewable, runCheck, STAGELESS_FRAMING, UsageError, writeJson } from './shared.ts';
27
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
28
+ import { basename, dirname, join, resolve } from 'node:path';
29
+
30
+
31
+ function repositoryUrl(): string {
32
+ const repo = readPackageMeta()?.repository;
33
+ const url = typeof repo === 'string' ? repo : repo?.url;
34
+ return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // commands
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Read `--profile`, defaulting to `spine` — see `CLI_DEFAULT_PROFILE`.
43
+ *
44
+ * An unknown name is a usage error rather than a silent fallback, and that
45
+ * matters in both directions: a typo used to re-apply the strictest rulebook to
46
+ * data the caller was trying to exempt, and it would now drop the policy layer
47
+ * from a caller who typed `--profile spine-htlm` and believes they asked for it.
48
+ * Neither is something to discover from a green.
49
+ */
50
+ function readProfile(flags: Record<string, string>): ValidateProfile {
51
+ const raw = flags.profile;
52
+ if (raw === undefined) return CLI_DEFAULT_PROFILE;
53
+ const found = VALIDATE_PROFILES.find((p) => p === raw);
54
+ if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
55
+ return found;
56
+ }
57
+
58
+ /**
59
+ * An atlas text to gate INSTEAD of the compile's own, with the second, independent
60
+ * emit A18 compares it against.
61
+ *
62
+ * `--pack` is the only caller. A packed build is gated twice on purpose — once as
63
+ * compiled (which is the gate that reads the loose PNGs, so `A06`'s size-vs-file
64
+ * clause still measures the art R5 measures) and once as packed (which is the pair
65
+ * that actually ships). Handing the second pass its texts rather than re-deriving
66
+ * them here keeps `runGate` ignorant of what a pack is.
67
+ */
68
+ interface AtlasOverride {
69
+ text: string;
70
+ again: string;
71
+ }
72
+
73
+ function runGate(
74
+ result: CompileResult,
75
+ modelText: string,
76
+ opts: CompileOptions,
77
+ profile: ValidateProfile,
78
+ /** The atlas text `build` writes for a compile's own — `--copy-images` renames the pages — which the second compile's document is spelled from, as `modelText` was (issue #1016). */
79
+ written: (atlasText: string) => string,
80
+ atlas?: AtlasOverride,
81
+ ): number {
82
+ // The determinism check compares a second, independent compile — its model
83
+ // document included, which is the text `build` writes beside the pair, and
84
+ // which states where each region sits in the atlas written with it (`pages`,
85
+ // issue #1016): so the second document is spelled from the second compile's
86
+ // atlas as it would be written, or from the second, independent pack.
87
+ const again = compile(opts);
88
+ const report = validate({
89
+ skeletonText: result.skeletonText,
90
+ atlasText: atlas ? atlas.text : result.atlasText,
91
+ atlasDir: opts.outDir,
92
+ declaredDurations: result.declaredDurations,
93
+ modelText,
94
+ reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
95
+ rig: result.rig,
96
+ profile,
97
+ });
98
+ for (const line of reportLines(report)) console.log(line);
99
+ console.log(
100
+ ` .. ${Object.entries(report.stats)
101
+ .map(([k, v]) => `${k}=${v}`)
102
+ .join(' ')}`,
103
+ );
104
+ return report.failures.length;
105
+ }
106
+
107
+ /**
108
+ * Read one non-negative integer flag, or its default.
109
+ *
110
+ * A usage error rather than a `NaN` that reaches the packer: `--padding two`
111
+ * would otherwise place every region at NaN and write a blank page, which is a
112
+ * green build and an empty picture.
113
+ */
114
+ function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
115
+ const raw = flags[name];
116
+ if (raw === undefined) return fallback;
117
+ if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
118
+ return Number(raw);
119
+ }
120
+
121
+ /**
122
+ * Read `--page-edges`, or its default — `pot`.
123
+ *
124
+ * An unknown value is a usage error for `readProfile`'s reason: a typo that fell
125
+ * back to `pot` would hand the caller who asked for the smaller page the bigger
126
+ * one, green, and say nothing.
127
+ */
128
+ function readPageEdges(flags: Record<string, string>): PageEdges {
129
+ const raw = flags['page-edges'];
130
+ if (raw === undefined) return DEFAULT_PAGE_EDGES;
131
+ const found = PAGE_EDGES.find((e) => e === raw);
132
+ if (!found) throw new UsageError(`--page-edges ${JSON.stringify(raw)}; known values: ${PAGE_EDGES.join(', ')}`);
133
+ return found;
134
+ }
135
+
136
+ /**
137
+ * The rectangle a region occupies **on its page**, for a line that has already
138
+ * said where the region is — and the empty string where the page rectangle is
139
+ * the one `bounds:` already states.
140
+ *
141
+ * ## The fact no surface an author reads carried (issue #718)
142
+ *
143
+ * The atlas line beside this clause prints the DRAWING's size, because that is
144
+ * what an attachment's width and height mean. A packer that turned the drawing a
145
+ * quarter to fit it wrote `bounds:` in the drawing's orientation too. So an
146
+ * author holding the pack and the build report had neither end of the rectangle
147
+ * they have to cut out of the page to measure a part against a rendered frame —
148
+ * and the one place rigc printed it was `A06`'s overlap text, reachable only
149
+ * under `--profile spine-html`. The knowledge was in the tree the whole time:
150
+ * `pageFootprint` has derived this rectangle for every reader of it since issue
151
+ * #579, and nothing an author reads said it.
152
+ *
153
+ * ⚠️ **The condition is `pageFootprint`'s own answer, not a second reading of
154
+ * `degrees`.** Re-spelling that predicate here is the exact duplication #579 was
155
+ * filed on — four readers derived this rectangle and two derived it wrongly — so
156
+ * the clause asks the function whether its answer differs from the `bounds:`
157
+ * line, and prints only then.
158
+ *
159
+ * 🔸 A consequence worth stating rather than leaving to be discovered: a region
160
+ * whose KEPT rectangle is square is silent here, because a quarter turn leaves
161
+ * its footprint the same two numbers and there is nothing the pack does not
162
+ * already say. The general rule — `bounds` is the unturned size, the footprint
163
+ * is its transpose at `rotate: 90` and `rotate: 270`, and which way to turn the
164
+ * rectangle to recover the drawing — belongs to an author's own reading and is
165
+ * stated in `docs/AUTHORING.md` §0.2, which holds for every region including
166
+ * that one.
167
+ */
168
+ function pageRectangle(region: AtlasRegion): string {
169
+ const foot = pageFootprint(region);
170
+ return foot.width === region.width && foot.height === region.height
171
+ ? ''
172
+ : `, occupies ${foot.width}x${foot.height}`;
173
+ }
174
+
175
+ export function cmdBuild(flags: Record<string, string>): void {
176
+ const { label, opts } = resolveCut(flags);
177
+ const profile = readProfile(flags);
178
+ const packing = flags.pack !== undefined;
179
+ // Two combinations are refused rather than silently resolved, because in each
180
+ // one the two flags disagree about a single question and there is no answer
181
+ // that is not a guess about which the caller meant. (There were three until
182
+ // issue #266 — see the note below the second.)
183
+ if (packing && opts.atlasInPath !== undefined) {
184
+ throw new UsageError(
185
+ '--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
186
+ 'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
187
+ );
188
+ }
189
+ if (packing && flags['copy-images'] !== undefined) {
190
+ throw new UsageError(
191
+ '--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
192
+ 'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
193
+ );
194
+ }
195
+ // The copy itself happens after the gate (below). The header has to know NOW,
196
+ // because the skeleton text the gate reads is the skeleton text that is written
197
+ // — `skeleton.images` says where the parts will be (issue #370).
198
+ if (flags['copy-images'] !== undefined) opts.copyImages = true;
199
+ // `--pack --profile spine-html` used to be the third refusal here, because
200
+ // A06's coverage clause was "one part per page" flat and a legitimate pack
201
+ // arrived at the gate reading as a defect. Since issue #266's second follow-up
202
+ // that clause is "one part per page OR a tiling page", so the combination is
203
+ // now a build like any other — and it is the only one that puts the renderer's
204
+ // own rulebook over shared-page sampling.
205
+ if (!packing) {
206
+ for (const name of ['page-size', 'padding', 'page-edges'] as const) {
207
+ if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
208
+ }
209
+ }
210
+ console.log(`rigc build ${label}`);
211
+ // Named explicitly and on their own lines rather than folded into the header
212
+ // above: with two input files, a header that names only one of them (the rig,
213
+ // historically) reads as though it were the one at fault whenever the error
214
+ // that follows actually comes from the other.
215
+ console.log(` .. rig ${opts.rigPath}`);
216
+ console.log(` .. motion ${opts.motionPath}`);
217
+ const result = compile(opts);
218
+
219
+ if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
220
+ console.log(` .. ${result.images.length} part page(s):`);
221
+ for (const img of result.images) {
222
+ // An imported part says where on the page it came from, because "resolved
223
+ // against a region" is the claim `--atlas-in` makes and a line that only
224
+ // repeated the page filename would look identical for all of them. A page
225
+ // that declares a `scale:` also says so and shows the texels it was read
226
+ // from: the size on the left is the DRAWING's and the rectangle is the
227
+ // pack's, and issue #267 is the report that printed the second as the first.
228
+ //
229
+ // `pageRectangle` closes the line's last silence (issue #718), and it is
230
+ // placed LAST rather than beside the turn it follows from, which is where
231
+ // the card put it. The two clauses collide nowhere else, and the collision
232
+ // is real: `scale 0.5 (373x106 texels)` is itself a size, so
233
+ // `rotate 90, occupies 106x373 scale 0.5 (…)` reads as though the footprint
234
+ // were the scaled quantity. As a trailing clause of the whole location
235
+ // phrase it is unambiguous with a `scale:` line and identical to the card's
236
+ // wording without one, which is every pack that has no `scale:` to state.
237
+ const where =
238
+ img.atlas === undefined
239
+ ? img.page
240
+ : `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
241
+ (img.atlasScale === undefined
242
+ ? ''
243
+ : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`) +
244
+ pageRectangle(img.atlas);
245
+ console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
246
+ }
247
+ for (const d of result.droppedStates) console.log(dropLine(d));
248
+ // "The optional slots are optional" is a claim about this code path, so this
249
+ // code path says which ones it left out rather than being silently right.
250
+ for (const a of result.absentParts) {
251
+ console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
252
+ }
253
+ for (const m of result.meshes) {
254
+ console.log(
255
+ ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
256
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
257
+ meshDepthNote(m) +
258
+ meshInfluenceNote(m),
259
+ );
260
+ }
261
+ for (const ph of result.physics) {
262
+ console.log(
263
+ ` PHYS ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] ` +
264
+ `mix=${ph.mix}${ph.drivesMesh ? ' <- drives a mesh: its canvas re-rasterises while the spring settles' : ''}`,
265
+ );
266
+ }
267
+
268
+ // The model's document is spelled before the gate, so the text A18 compares
269
+ // is the text written, and a model the document cannot carry is refused
270
+ // before anything is (issue #922). It states where each region sits in the
271
+ // atlas written beside it (`pages`, issue #1016), so it is spelled from that
272
+ // atlas: under `--copy-images` the text with the copies' page names, planned
273
+ // here from the text alone (`plannedPageCopies`) and copied after the gate.
274
+ // Under `--pack` the pages move again after this gate, and the document
275
+ // written is the one the packed gate below spells and compares.
276
+ const copying = flags['copy-images'] !== undefined;
277
+ const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
278
+ let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
279
+ console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
280
+ const failures = runGate(result, modelText, opts, profile, writtenAtlas);
281
+ if (failures > 0) {
282
+ console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
283
+ process.exit(1);
284
+ }
285
+
286
+ mkdirSync(opts.outDir, { recursive: true });
287
+
288
+ // `--copy-images`: `--out` is otherwise NOT self-contained — a page's default
289
+ // path is relative to the source art (often `../parts/foo.png`), which is
290
+ // correct for a build sitting beside the project it came from and breaks the
291
+ // moment the directory is zipped, committed or moved on its own (issue #217).
292
+ // Opt-in only: the default stays exactly what it has always been.
293
+ //
294
+ // What is copied is what the ATLAS names, not what the image list holds: under
295
+ // `--atlas-in` the two are different lists, and rebuilding the text from the
296
+ // second wrote a file the pack never contained — zero bytes for a rig that
297
+ // declares no parts, one fabricated page per part for a rig that does, both of
298
+ // them green here because the gate above had already read the compile's own
299
+ // text (issue #693, `src/emit.ts`).
300
+ let atlasText = result.atlasText;
301
+ if (flags['copy-images'] !== undefined) {
302
+ const copied = copyAtlasPages(atlasText, opts.outDir);
303
+ // The document's pages were spelled from the plan; the copy is held to it before anything of the pair is written.
304
+ if (copied.atlasText !== writtenAtlas(result.atlasText)) {
305
+ throw new Error(`internal: --copy-images wrote an atlas whose page names are not the ones ${MODEL_DOCUMENT_FILE} was spelled with — nothing of the pair was written`);
306
+ }
307
+ atlasText = copied.atlasText;
308
+ console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
309
+ for (const p of copied.pages) {
310
+ const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
311
+ console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
312
+ }
313
+ }
314
+
315
+ // `--pack`: the parts go onto shared pages, which are written here as real
316
+ // PNGs, so `--out` is self-contained by construction. The atlas above stays
317
+ // the one the gate just read — packing changes only the ARRANGEMENT of the
318
+ // bytes, and the sizes in `result.images` are still the ones measured off the
319
+ // loose PNGs (see src/atlas.ts's header).
320
+ if (packing) {
321
+ const packOpts = {
322
+ pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
323
+ padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
324
+ pageEdges: readPageEdges(flags),
325
+ pageStem: 'skeleton',
326
+ };
327
+ const inputs = result.images.map((img) => ({
328
+ region: img.region,
329
+ absPath: img.absPath,
330
+ width: img.width,
331
+ height: img.height,
332
+ }));
333
+ const packed = packAtlas(inputs, packOpts);
334
+ atlasText = packed.atlasText;
335
+ for (const page of packed.pages) {
336
+ page.plate.writePng(join(opts.outDir, page.name));
337
+ console.log(
338
+ ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
339
+ `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
340
+ `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
341
+ (packOpts.pageEdges === 'free' ? ', page edges free' : ''),
342
+ );
343
+ }
344
+ for (const place of packed.placements) {
345
+ console.log(
346
+ ` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
347
+ `${packed.pages[place.page].name} @ ${place.x},${place.y}`,
348
+ );
349
+ }
350
+ // The pages are on disk now, so the packed pair can be gated as an artifact
351
+ // rather than trusted as a construction: A17 stats every page, A06 reads its
352
+ // IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
353
+ // a region, and A18 compares a second independent compile+pack. Two gates on
354
+ // one build is the cost of shipping a second atlas shape.
355
+ console.log(' .. validate (packed atlas, pages on disk)');
356
+ const packAgain = packAtlas(
357
+ compile(opts).images.map((img) => ({
358
+ region: img.region,
359
+ absPath: img.absPath,
360
+ width: img.width,
361
+ height: img.height,
362
+ })),
363
+ packOpts,
364
+ );
365
+ // The document written is spelled from the packed atlas, and this gate's A18 compares it with a
366
+ // second compile's spelled from the second, independent pack (issue #1016).
367
+ modelText = modelDocument(result.model, result.skeletonText, atlasText);
368
+ const packFailures = runGate(result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
369
+ if (packFailures > 0) {
370
+ console.error(
371
+ `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
372
+ `${opts.outDir}, the skeleton/atlas pair was not`,
373
+ );
374
+ process.exit(1);
375
+ }
376
+ }
377
+
378
+ writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
379
+ writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
380
+ // rigc's own record of the compiled rig (`rigc-compiled/3`, issue #922;
381
+ // its `pages` the atlas just written, issue #1016), written with the pair
382
+ // and only after the same gate — under `--pack`, the packed one. rigc's own posing core
383
+ // reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
384
+ writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
385
+ console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
386
+ console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
387
+ console.log(`rigc: wrote ${join(opts.outDir, MODEL_DOCUMENT_FILE)}`);
388
+ // The next command is part of the message (issue #837). A green build is the
389
+ // moment somebody wants to see what came out, and the one page rigc writes
390
+ // for that is `preview` of exactly this directory — so the line names it,
391
+ // with the path already resolved. Printed only here: a red build wrote
392
+ // nothing, so there is nothing to look at and no line.
393
+ console.log(`rigc: look at it: rigc preview --candidate ${opts.outDir}`);
394
+ }
395
+
396
+ export function cmdValidate(flags: Record<string, string>, positional: string[]): void {
397
+ // A bare directory validates what is on disk. Naming the cut as well lets the
398
+ // gate re-derive the declared durations and the structural expectations, which
399
+ // a directory alone cannot supply — and the report says which it had.
400
+ const named = flags.cut !== undefined || flags.rig !== undefined;
401
+ const profile = readProfile(flags);
402
+ const derivedOpts = named ? resolveCut(flags).opts : null;
403
+ const { skeletonPath, atlasPath } = resolveArtifacts(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas);
404
+ console.log(`rigc validate ${skeletonPath}`);
405
+ console.log(` .. atlas ${atlasPath}`);
406
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
407
+ const atlasText = readFileSync(atlasPath, 'utf8');
408
+ const derived = derivedOpts ? compile(derivedOpts) : null;
409
+
410
+ const report = validate({
411
+ skeletonText,
412
+ atlasText,
413
+ atlasDir: dirname(atlasPath),
414
+ declaredDurations: derived?.declaredDurations,
415
+ rig: derived?.rig,
416
+ profile,
417
+ });
418
+ for (const line of reportLines(report)) console.log(line);
419
+ if (report.failures.length > 0) {
420
+ console.error(`rigc: ${report.failures.length} assertion(s) failed`);
421
+ process.exit(1);
422
+ }
423
+ console.log('rigc: green');
424
+ }
425
+
426
+ /**
427
+ * Does this skeleton declare a setup stage — a numeric width AND height?
428
+ *
429
+ * Read off the file's header for `preview`, which never loads one; `render`
430
+ * reads the same statement off its candidate (`SkeletonFacts.declaresStage`:
431
+ * the header again on a rigc build the core poses, the loaded `SkeletonData`
432
+ * on an export). Both are the same two fields, because
433
+ * `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
434
+ * so a header that omits them leaves `undefined` on a field typed `number`.
435
+ */
436
+ function declaresSetupStage(header: { width?: unknown; height?: unknown }): boolean {
437
+ return typeof header.width === 'number' && typeof header.height === 'number';
438
+ }
439
+
440
+ /** The `skeleton` block of a skeleton file's text, or an empty one where it has none. */
441
+ function skeletonHeaderOf(skeletonText: string): { width?: unknown; height?: unknown } {
442
+ const root = JSON.parse(skeletonText) as { skeleton?: { width?: unknown; height?: unknown } };
443
+ return root.skeleton ?? {};
444
+ }
445
+
446
+ /** The animation names an emitted skeleton carries, in the order it lists them. */
447
+ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
448
+ const parsed = parseJsonNamed(skeletonText, path);
449
+ if (typeof parsed !== 'object' || parsed === null) throw new UsageError(`${path} is not a skeleton object`);
450
+ const animations = (parsed as { animations?: unknown }).animations;
451
+ if (animations === undefined) return [];
452
+ if (typeof animations !== 'object' || animations === null || Array.isArray(animations)) {
453
+ throw new UsageError(`${path} has an "animations" field that is not an object`);
454
+ }
455
+ return Object.keys(animations);
456
+ }
457
+
458
+ /**
459
+ * The gate's reading of one candidate, taken the way `rigc validate <dir>`
460
+ * takes it (issue #837).
461
+ *
462
+ * ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
463
+ * atlas's own directory, the default profile, and nothing a directory cannot
464
+ * supply — no rig spec, no declared durations, no second compile. So `A09` and
465
+ * `A18` report SKIP here exactly as they do there, and the line is the line
466
+ * that command prints for these files, not the one `build` printed for the
467
+ * compile that wrote them. Measured on every run, because the page must not
468
+ * carry a figure this run did not measure.
469
+ */
470
+ function previewGate(skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
471
+ const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, profile: CLI_DEFAULT_PROFILE }));
472
+ const refusal = lines.find((line) => line.startsWith(' FAIL '));
473
+ return {
474
+ // `reportLines` ends on the summary by construction; the gutter is the
475
+ // report's layout, not part of what the gate said.
476
+ summary: lines[lines.length - 1].replace(/^ {2}\.\. {4}/, ''),
477
+ refusal: refusal === undefined ? null : refusal.trimStart(),
478
+ };
479
+ }
480
+
481
+ /**
482
+ * preview — the artifact playing in Esoteric's own web player, as one file.
483
+ *
484
+ * ⚠️ Nothing is rasterised here and nothing is decoded. The pages go into the
485
+ * page as the bytes they are on disk, so a preview works for any PNG a BROWSER
486
+ * can draw rather than for the ones our own decoder reads — which is the right
487
+ * direction for the command whose whole job is "just show me".
488
+ */
489
+ export function cmdPreview(flags: Record<string, string>, candidates: string[]): void {
490
+ // Refused rather than ignored: a preview asked to hide `head` that plays the
491
+ // whole rig is a picture that answers a question it was not asked (issue #835).
492
+ for (const flag of ['slot', 'hide'] as const) {
493
+ if (flags[flag] !== undefined) {
494
+ throw new UsageError(
495
+ `preview takes no --${flag}: the Spine Web Player draws what the skeleton draws. A subset of the slots is ` +
496
+ `\`rigc render --${flag} ${flags[flag]}\`, on the whole rig's grid`,
497
+ );
498
+ }
499
+ }
500
+ const several = candidates.length > 1;
501
+ // `--atlas` names ONE atlas, and with several skeletons there is no
502
+ // unambiguous thing it could mean — the refusal `vote` makes, for the reason
503
+ // it makes it.
504
+ if (several && flags.atlas !== undefined) {
505
+ throw new UsageError(
506
+ `--atlas names one atlas and ${candidates.length} --candidate were given; each candidate's atlas has to sit ` +
507
+ 'beside its skeleton, which is what `build --out` leaves behind',
508
+ );
509
+ }
510
+ const found = several
511
+ ? candidates.map((target) => {
512
+ const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
513
+ for (const path of [skeletonPath, atlasPath]) {
514
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
515
+ }
516
+ return { target, skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
517
+ })
518
+ : [{ target: flags.candidate, ...resolveViewable(flags) }];
519
+ // ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
520
+ // `build/skeleton.json` are two spellings of one candidate, and so are
521
+ // `/tmp/x` and `/private/tmp/x` on a machine where one is a link to the other
522
+ // — `resolve` alone left that pair unrefused, measured on macOS. A page
523
+ // showing one skeleton twice is two panes that look like a comparison of
524
+ // nothing. (`vote` accepts a repeat today, exit 0; that is its own card.)
525
+ const identities = found.map((f) => realpathSync(f.skeletonPath));
526
+ for (let i = 1; i < found.length; i++) {
527
+ const first = identities.indexOf(identities[i]);
528
+ if (first < i) {
529
+ throw new UsageError(
530
+ `--candidate ${JSON.stringify(found[i].target)} is ${identities[i]}, which --candidate ` +
531
+ `${JSON.stringify(found[first].target)} already names (candidates ${first + 1} and ${i + 1}); a pane per ` +
532
+ 'candidate would show the same skeleton twice',
533
+ );
534
+ }
535
+ }
536
+
537
+ // Every candidate's texts and animation are read before a line is printed,
538
+ // so a refusal about any of them comes before the report, as it always has.
539
+ const loaded = found.map((f, i) => {
540
+ const skeletonText = readFileSync(f.skeletonPath, 'utf8');
541
+ const atlasText = readFileSync(f.atlasPath, 'utf8');
542
+ const animations = skeletonAnimationNames(skeletonText, f.skeletonPath);
543
+ let chosen: string | undefined;
544
+ try {
545
+ chosen = readAnimationFlag(flags, animations);
546
+ } catch (err) {
547
+ // With several candidates the refusal has to say WHICH one lacks it.
548
+ if (several && err instanceof UsageError) {
549
+ throw new UsageError(`candidate ${i + 1} (${f.skeletonPath}): ${err.message}`);
550
+ }
551
+ throw err;
552
+ }
553
+ return { ...f, skeletonText, atlasText, animations, chosen };
554
+ });
555
+
556
+ // A directory for --out is taken as "put the default name in here", because
557
+ // `--out render/` is what the sibling command means by the same flag and a
558
+ // preview written OVER a directory is not a recoverable mistake.
559
+ const target = resolve(flags.out ?? 'preview.html');
560
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
561
+ const version = readVersion();
562
+
563
+ console.log('rigc preview');
564
+ const inputs: PreviewInput[] = loaded.map((candidate, i) => {
565
+ const { skeletonPath, atlasPath, atlasDir, skeletonText, atlasText, animations, chosen } = candidate;
566
+ if (several) console.log(` .. pane ${i + 1} of ${loaded.length}`);
567
+ console.log(` .. skeleton ${skeletonPath}`);
568
+ console.log(` .. atlas ${atlasPath}`);
569
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
570
+ const path = join(atlasDir, name);
571
+ if (!existsSync(path)) {
572
+ throw new UsageError(
573
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
574
+ 'a page a preview cannot embed is a page the player could not have loaded either',
575
+ );
576
+ }
577
+ return { name, bytes: readFileSync(path) };
578
+ });
579
+ for (const page of pages) {
580
+ console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
581
+ }
582
+ if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
583
+ const gate = previewGate(skeletonText, atlasText, atlasDir);
584
+ console.log(` .. gate ${gate.summary}`);
585
+ if (gate.refusal !== null) {
586
+ console.log(
587
+ ` .. gate refused — ${gate.refusal}. Previewed anyway: looking at a red build is what preview is ` +
588
+ 'for, and the page header says the same',
589
+ );
590
+ }
591
+ return {
592
+ skeletonText,
593
+ atlasText,
594
+ pages,
595
+ animation: chosen ?? animations[0] ?? null,
596
+ animations,
597
+ label: skeletonPath,
598
+ version,
599
+ gate,
600
+ };
601
+ });
602
+
603
+ const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
604
+ mkdirSync(dirname(out), { recursive: true });
605
+ writeFileSync(out, html);
606
+ const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
607
+ console.log(
608
+ several
609
+ ? ` .. embedded ${pageCount} page(s) + ${inputs.length} skeletons and atlases as data URIs, one pane each; ` +
610
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`
611
+ : ` .. embedded ${pageCount} page(s) + the skeleton and atlas as data URIs; ` +
612
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
613
+ );
614
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
615
+ }
616
+
617
+ /** Load one candidate off disk in the shape a ballot needs. */
618
+ function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
619
+ const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
620
+ for (const path of [skeletonPath, atlasPath]) {
621
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
622
+ }
623
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
624
+ const atlasText = readFileSync(atlasPath, 'utf8');
625
+ const atlasDir = dirname(atlasPath);
626
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
627
+ const path = join(atlasDir, name);
628
+ if (!existsSync(path)) {
629
+ throw new UsageError(
630
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
631
+ 'a page a ballot cannot embed is a page the player could not have loaded either',
632
+ );
633
+ }
634
+ return { name, bytes: readFileSync(path) };
635
+ });
636
+ return {
637
+ candidate: { source: skeletonPath, skeletonText, atlasText, pages },
638
+ animations: skeletonAnimationNames(skeletonText, skeletonPath),
639
+ };
640
+ }
641
+
642
+ /**
643
+ * The one animation every candidate plays.
644
+ *
645
+ * ⚠️ Refused rather than resolved per candidate. Two panes running two
646
+ * different animations look like a comparison and are not one, and a voter has
647
+ * no way to see that it happened — the labels are `A` and `B`, which is the
648
+ * whole point, so nothing on the screen would say so.
649
+ */
650
+ function commonAnimation(
651
+ flags: Record<string, string>,
652
+ loaded: { animations: string[] }[],
653
+ ): string | null {
654
+ const asked = flags.animation;
655
+ if (asked === undefined) {
656
+ const first = loaded[0].animations[0];
657
+ if (first === undefined) {
658
+ const withAny = loaded.findIndex((l) => l.animations.length > 0);
659
+ if (withAny !== -1) {
660
+ throw new UsageError(
661
+ `candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
662
+ 'a ballot plays one animation in every pane, so there is nothing to compare here',
663
+ );
664
+ }
665
+ return null;
666
+ }
667
+ const missing = loaded.findIndex((l) => !l.animations.includes(first));
668
+ if (missing !== -1) {
669
+ throw new UsageError(
670
+ `the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
671
+ `have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
672
+ );
673
+ }
674
+ return first;
675
+ }
676
+ const missing = loaded.findIndex((l) => !l.animations.includes(asked));
677
+ if (missing !== -1) {
678
+ throw new UsageError(
679
+ `no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
680
+ `[${loaded[missing].animations.join(', ') || 'none'}]`,
681
+ );
682
+ }
683
+ return asked;
684
+ }
685
+
686
+ /** vote (ballot mode) — write the page a human opens. */
687
+ function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
688
+ if (candidates.length < MIN_CANDIDATES) {
689
+ throw new UsageError(
690
+ `a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
691
+ 'was given — one candidate on its own is `rigc preview`',
692
+ );
693
+ }
694
+ if (candidates.length > MAX_CANDIDATES) {
695
+ throw new UsageError(
696
+ `${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
697
+ 'on one screen, and a comparison that needs scrolling is not a comparison',
698
+ );
699
+ }
700
+ // `--atlas` names ONE atlas and there are several skeletons here, so there is
701
+ // no unambiguous thing it could mean. Each candidate's atlas has to sit beside
702
+ // its skeleton, which is what `build --out` leaves behind.
703
+ if (flags.atlas !== undefined) {
704
+ throw new UsageError(
705
+ '--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
706
+ );
707
+ }
708
+
709
+ const loaded = candidates.map((target) => loadBallotCandidate(target));
710
+ const animation = commonAnimation(flags, loaded);
711
+
712
+ const target = resolve(flags.out ?? DEFAULT_BALLOT);
713
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
714
+
715
+ const input: BallotInput = {
716
+ candidates: loaded.map((l) => l.candidate),
717
+ animation,
718
+ version: readVersion(),
719
+ };
720
+ const { html, manifest } = buildBallot(input);
721
+
722
+ console.log('rigc vote');
723
+ console.log(` .. ballot ${manifest.ballot}`);
724
+ console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
725
+ for (let i = 0; i < manifest.candidates.length; i++) {
726
+ const entry = manifest.candidates[i];
727
+ const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
728
+ console.log(
729
+ ` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
730
+ `${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
731
+ );
732
+ }
733
+ mkdirSync(dirname(out), { recursive: true });
734
+ writeFileSync(out, html);
735
+ console.log(
736
+ ` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
737
+ 'in its manifest, never on the screen',
738
+ );
739
+ console.log(
740
+ ` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
741
+ `unpkg (@${PLAYER_LINE}), so the first open needs a network`,
742
+ );
743
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
744
+ console.log(
745
+ `rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
746
+ );
747
+ }
748
+
749
+ /** vote (record mode) — check one saved vote and append it to the ledger. */
750
+ function cmdVoteRecord(flags: Record<string, string>): void {
751
+ for (const key of ['candidate', 'out'] as const) {
752
+ if (flags[key] !== undefined) {
753
+ throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
754
+ }
755
+ }
756
+ const resultPath = resolve(flags.record);
757
+ const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
758
+ const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
759
+ for (const [what, path] of [
760
+ ['result', resultPath],
761
+ ['ballot', ballotPath],
762
+ ] as const) {
763
+ if (!existsSync(path)) {
764
+ throw new UsageError(
765
+ `no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
766
+ );
767
+ }
768
+ }
769
+
770
+ console.log('rigc vote --record');
771
+ console.log(` .. result ${resultPath}`);
772
+ console.log(` .. ballot ${ballotPath}`);
773
+ console.log(` .. ledger ${ledgerPath}`);
774
+
775
+ const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
776
+ const result = readJsonFile(resultPath);
777
+ const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
778
+ const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
779
+ const again = flags.again !== undefined;
780
+
781
+ const { refusals, line } = verifyResult(manifest, result, { attempts, again });
782
+ if (line === null) {
783
+ for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
784
+ console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
785
+ process.exit(1);
786
+ }
787
+ for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
788
+
789
+ line.seq = existing.length + 1;
790
+ mkdirSync(dirname(ledgerPath), { recursive: true });
791
+ appendFileSync(ledgerPath, ledgerLineText(line));
792
+ console.log(
793
+ ` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
794
+ `reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
795
+ );
796
+ console.log(
797
+ ` .. coverage ${line.coverage.length} candidate(s): ` +
798
+ line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
799
+ );
800
+ console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
801
+ }
802
+
803
+ export function cmdVote(flags: Record<string, string>, candidates: string[]): void {
804
+ if (flags.record !== undefined) cmdVoteRecord(flags);
805
+ else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
806
+ else {
807
+ throw new UsageError(
808
+ 'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
809
+ 'record one that came back',
810
+ );
811
+ }
812
+ }
813
+
814
+ /**
815
+ * bench — run one rung of the benchmark ladder against a candidate rig.
816
+ *
817
+ * Two questions, asked in this order and never merged:
818
+ *
819
+ * 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
820
+ * and it is the only part with a pass/fail. The profile is pinned here, not
821
+ * inherited from the CLI default: the thing being reproduced is an editor
822
+ * export, and holding it to this project's renderer policy would fail rungs
823
+ * for reasons the rung is not about.
824
+ * 2. How close is it, structurally, to the reference? That is `diff`, and it
825
+ * has no threshold at all. There is no score to pass, on purpose — see
826
+ * `src/diff.ts`. A rung is called cleared by a human reading the measures,
827
+ * and `docs/LADDER.md` records that judgement.
828
+ *
829
+ * ⚠️ The candidate is validated against the SPINE profile and compared against
830
+ * the reference; the reference is never validated here. It is editor output and
831
+ * is the definition of correct for this exercise, so gating it would be gating
832
+ * the yardstick with the ruler.
833
+ */
834
+ export function cmdBench(flags: Record<string, string>, positional: string[]): void {
835
+ const rungId = positional[0];
836
+ if (!rungId) throw new UsageError(`bench takes a rung: ${RUNG_IDS.join(' | ')}`);
837
+ const rung = findRung(rungId);
838
+ if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
839
+ if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
840
+
841
+ // bench judges a reproduction of editor output, so `spine` is PINNED here
842
+ // rather than inherited. It reads the same as the CLI default today (#221) and
843
+ // is kept as its own statement anyway: the ladder's stage-1 gate is defined by
844
+ // `docs/GATE.md` as `validate --profile spine`, and a bench run must go on
845
+ // meaning that whatever a later release decides the default should be.
846
+ const profile: ValidateProfile = flags.profile === undefined ? 'spine' : readProfile(flags);
847
+ const exportDir = resolve(PACKAGE_ROOT, 'examples', rung.example, 'export');
848
+ if (!existsSync(exportDir)) {
849
+ // `bun run fetch-examples` runs `scripts/fetch-examples.sh`, and `scripts/`
850
+ // is not in package.json's `files` — an npm install has no such script to
851
+ // run. Its presence is what tells the two contexts apart, so the remedy
852
+ // named here is one that actually exists in whichever context this is.
853
+ const remedy = existsSync(resolve(PACKAGE_ROOT, 'scripts', 'fetch-examples.sh'))
854
+ ? 'run `bun run fetch-examples` first'
855
+ : `bench needs a checkout of ${repositoryUrl()} — its \`fetch-examples\` script is not part of the installed package`;
856
+ throw new UsageError(`no example corpus at ${exportDir} — ${remedy} (examples/ is gitignored, not shipped)`);
857
+ }
858
+
859
+ // Both files there and the skeleton JSON before the first line is printed, refused by name as `render` refuses
860
+ // them (issue #1042): a missing file and a skeleton that is not JSON surfaced as an ENOENT or a SyntaxError and a
861
+ // stack, the second only after the validate block had printed.
862
+ const { skeletonPath, atlasPath } = resolveViewable({ candidate: flags.candidate, ...(flags.atlas === undefined ? {} : { atlas: flags.atlas }) });
863
+ const skeletonText = readSkeletonText(skeletonPath);
864
+ const atlasText = readFileSync(atlasPath, 'utf8');
865
+
866
+ console.log(`rigc bench rung ${rung.id} — ${rung.example}`);
867
+ console.log(` gates ${rung.gates}`);
868
+ console.log(` candidate ${skeletonPath}`);
869
+ console.log(` atlas ${atlasPath}`);
870
+ console.log('');
871
+
872
+ console.log(` ── validate (profile ${profile}) ──`);
873
+ const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), profile });
874
+ for (const line of reportLines(report)) console.log(` ${line}`);
875
+ console.log('');
876
+
877
+ const candidateJson: unknown = JSON.parse(skeletonText);
878
+ const diffs: Array<{ skeleton: RungSkeleton; reference: string; report: DiffReport }> = [];
879
+ for (const skeleton of rung.skeletons) {
880
+ const referencePath = join(exportDir, skeleton.file);
881
+ if (!existsSync(referencePath)) {
882
+ console.error(` MISSING ${referencePath} — re-run \`bun run fetch-examples\``);
883
+ continue;
884
+ }
885
+ const role = skeleton.role === 'stretch' ? ' (stretch — reported, does not count)' : '';
886
+ console.log(` ── diff vs ${rung.example}/${skeleton.label}${role} ──`);
887
+ const diff = diffSkeletons(candidateJson, JSON.parse(readFileSync(referencePath, 'utf8')));
888
+ for (const line of diffLines(diff, { candidate: skeletonPath, reference: referencePath })) console.log(` ${line}`);
889
+ console.log('');
890
+ diffs.push({ skeleton, reference: referencePath, report: diff });
891
+ }
892
+
893
+ // Stage 3, optional and behind a flag because the correspondence is an INPUT:
894
+ // a candidate is entitled to its own bone names, so there is nothing sensible
895
+ // to default to and a derived mapping would be a guess reported as a
896
+ // measurement (issue #8). Nothing here gates, and without the flag the report
897
+ // above is unchanged to the byte.
898
+ const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
899
+ if (flags.bones !== undefined) {
900
+ for (const skeleton of rung.skeletons) {
901
+ const referencePath = join(exportDir, skeleton.file);
902
+ if (!existsSync(referencePath)) continue;
903
+ console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
904
+ const boneDist = boneDistance({
905
+ candidateSkeleton: skeletonPath,
906
+ candidateAtlas: atlasPath,
907
+ referenceSkeleton: referencePath,
908
+ referenceAtlas: join(exportDir, skeleton.atlas),
909
+ bones: flags.bones,
910
+ // Deliberately NOT `flags.fps`. Inside `bench` that flag already means
911
+ // "the rate this frame set was recorded at, for a set with no sidecar",
912
+ // and one flag doing two unrelated things in one command is how a
913
+ // reader ends up quoting a figure measured at a rate they did not ask
914
+ // for. A run wanting another sampling rate calls `rigc bonedist`, where
915
+ // `--fps` has exactly one meaning.
916
+ });
917
+ for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
918
+ console.log('');
919
+ boneDists.push({ skeleton, report: boneDist });
920
+ }
921
+ }
922
+
923
+ // Third, optional and third for a reason: is it the same MOTION? `diff`
924
+ // compares structure, and a reversed easing is the same key count and the same
925
+ // curve kind — so a row of this ladder carrying only `validate` and `diff`
926
+ // records a rig that could be animated backwards. `--frames` folds `check`'s
927
+ // table into the report so a future row carries both.
928
+ let check: CheckReport | null = null;
929
+ if (flags.frames !== undefined) {
930
+ console.log(` ── check vs frames ${resolve(flags.frames)} ──`);
931
+ check = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
932
+ for (const line of checkLines(check, { allFrames: flags['all-frames'] !== undefined })) console.log(` ${line}`);
933
+ console.log('');
934
+ }
935
+
936
+ console.log(' ── summary ──');
937
+ console.log(` validate ${report.failures.length === 0 ? 'green' : `${report.failures.length} FAILED`} (profile ${profile})`);
938
+ for (const d of diffs) {
939
+ const means = d.report.sections.map((s) => `${s.name}=${s.ratio.toFixed(3)}`).join(' ');
940
+ console.log(` ${d.skeleton.label.padEnd(10)} ${means}${d.skeleton.role === 'stretch' ? ' [stretch]' : ''}`);
941
+ // Second line, not folded into the first: the figures above are the ones
942
+ // every bench.json on disk already carries, and a ladder record is worth
943
+ // less the moment its headline stops meaning what the older ones meant.
944
+ // The sections whose measures are dominated by name-keyed ones get their
945
+ // name-agnostic figure printed beside — issue #21.
946
+ const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
947
+ if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
948
+ // A third line, for the same reason the second one is not folded into the
949
+ // first: the reported measures are unobservable by construction, so they
950
+ // roll into no mean at all and cannot be shown as one. Each is named with
951
+ // its own figure — a per-section digest would be the mean this block exists
952
+ // to refuse.
953
+ const reported = reportedFigures(d.report);
954
+ if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
955
+ }
956
+ if (check) {
957
+ // The framing goes first because it is upstream of every MAE below it: a
958
+ // summary that reported those numbers without saying how the two shots were
959
+ // put on each other is how issue #34 stayed invisible for two ladder runs.
960
+ const framing = check.framingFit;
961
+ if (!framing && check.sharedFraming) {
962
+ const f = check.sharedFraming.fit;
963
+ console.log(
964
+ ` framing one per set (${check.animations.length}); one shared box leaves ` +
965
+ `x${f.scale.toFixed(6)}, rms ${f.rms.toFixed(2)}px — see the check table above for each set's own`,
966
+ );
967
+ }
968
+ if (framing) {
969
+ const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
970
+ const how = !framing.applied
971
+ ? 'measured only — --viewport pinned'
972
+ : framing.source === 'declared'
973
+ ? `frames.json's own box, the candidate measured into it`
974
+ : `fitted to the candidate's pixels, ${framing.passes} pass(es)${framing.settled ? '' : framing.cycled ? ', cycling' : ', unsettled'}`;
975
+ // The MAE-refined offset belongs on this line rather than only in `check`'s
976
+ // own table: it moved the box every figure below was measured in, so a row
977
+ // that quoted the figures without it would not say what they were measured
978
+ // against — issue #146's own version of the #34 lesson above.
979
+ const r = framing.refinement;
980
+ const refined =
981
+ r === null || !r.applied
982
+ ? ''
983
+ : ` MAE-refined ${signed(r.dx)}, ${signed(r.dy)}px (${r.before.toFixed(2)} → ${r.after.toFixed(2)} ref)`;
984
+ console.log(
985
+ ` framing fit x${framing.fit.scale.toFixed(6)} rms ${framing.fit.rms.toFixed(2)}px union residual ` +
986
+ `${signed(framing.fit.residualWidth)} x ${signed(framing.fit.residualHeight)}px (${how})${refined}`,
987
+ );
988
+ }
989
+ for (const anim of check.animations) {
990
+ const attributed = anim.compared - anim.framesWithoutDrift;
991
+ const drift =
992
+ anim.worstDriftFrame < 0
993
+ ? 'no slot attributable in any of them'
994
+ : `worst slot drift ${anim.worstDrift.toFixed(1)}px, attributed in ${attributed}`;
995
+ // The per-frame change count is carried here and not only in `check`'s own
996
+ // table because it is the one figure a flat MAE cannot imply: a shot can be
997
+ // right at every frame and still hold or blink at the wrong moments.
998
+ const change =
999
+ anim.changeDisagreements === 0
1000
+ ? ''
1001
+ : `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
1002
+ // Which of the candidate's own bone chains the error is in — one name, so a
1003
+ // loop between builds reads a unit to fix rather than a verdict on the shot.
1004
+ // The full table is in `check`'s own report; this is its headline.
1005
+ const worstChain = [...anim.chains].sort((a, b) => b.maeShare - a.maeShare)[0];
1006
+ const chain =
1007
+ worstChain === undefined ? '' : `, ${worstChain.chain} carries ${(worstChain.maeShare * 100).toFixed(0)}%`;
1008
+ // `ref=` is the same difference over the reference's own drawn pixels. It is
1009
+ // carried here and not only in `check`'s own table because this is the line a
1010
+ // loop reads between builds, and `mean=` has a denominator the candidate can
1011
+ // grow — see `FrameCheck.maeReference`.
1012
+ // ...and the contact sheet, when the set ships one: a row reading "over 2
1013
+ // frame(s)" for a 311-frame shot is the hole issue #36 closed, and the whole
1014
+ // -shot figure is the one that says the frames between the stills were seen.
1015
+ const sheet =
1016
+ anim.sheet === null
1017
+ ? ''
1018
+ : `, sheet ${anim.sheet.compared} tile(s) mean=${anim.sheet.meanMae.toFixed(2)} ` +
1019
+ `worst=${anim.sheet.worstMae.toFixed(2)}`;
1020
+ console.log(
1021
+ ` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
1022
+ `ref=${anim.meanMaeReference.toFixed(2)} over ${anim.compared} frame(s) ${drift}${change}${chain}${sheet}`,
1023
+ );
1024
+ }
1025
+ } else {
1026
+ console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
1027
+ console.log(' Without it this report says nothing about whether the ANIMATION is right.');
1028
+ }
1029
+ if (boneDists.length > 0) {
1030
+ for (const b of boneDists) {
1031
+ const w = b.report.worst;
1032
+ console.log(
1033
+ ` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
1034
+ `rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
1035
+ `over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
1036
+ );
1037
+ }
1038
+ } else {
1039
+ console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
1040
+ console.log(' bone world-transform distance. It reports and gates nothing.');
1041
+ }
1042
+ console.log(' Section figures are means of their own measures. There is no rung score:');
1043
+ console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
1044
+
1045
+ if (flags.json !== undefined) {
1046
+ // No `gates` field, deliberately. The rung's gate string names its features
1047
+ // and its per-skeleton counts, which `bench/runs/README.md` forbids a run
1048
+ // from reading — and this report is one of the six files the run protocol
1049
+ // requires committing, so a copy of it here would sit inside every future
1050
+ // run's directory, which is exactly where the next author looks for process
1051
+ // notes. `rung` identifies the rung and carries nothing (issue #137). The
1052
+ // console block above still prints the gate string: that is for the person
1053
+ // reading the run, not a file the protocol commits.
1054
+ writeJson(flags.json, {
1055
+ rung: rung.id,
1056
+ example: rung.example,
1057
+ profile,
1058
+ candidate: { skeleton: skeletonPath, atlas: atlasPath },
1059
+ validate: report,
1060
+ // `referencePath`, not `reference`: a DiffReport already has a
1061
+ // `reference` of its own (the raw counts), and the spread wins.
1062
+ diffs: diffs.map((d) => ({
1063
+ label: d.skeleton.label,
1064
+ role: d.skeleton.role,
1065
+ referencePath: d.reference,
1066
+ ...d.report,
1067
+ })),
1068
+ check,
1069
+ // Absent rather than null when the flag was not passed: `bonedist: null`
1070
+ // in a stored record would read as "measured, nothing to report", and
1071
+ // that is the opposite of "not measured".
1072
+ ...(boneDists.length === 0
1073
+ ? {}
1074
+ : { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
1075
+ });
1076
+ }
1077
+
1078
+ if (report.failures.length > 0) {
1079
+ console.error(`rigc: candidate is not valid Spine — ${report.failures.length} assertion(s) failed`);
1080
+ process.exit(1);
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * bonedist — the ladder's stage 3, run on its own.
1086
+ *
1087
+ * ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
1088
+ * unlike `check`. Every convention behind every figure is printed above the
1089
+ * tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
1090
+ */
1091
+ export function cmdBoneDist(flags: Record<string, string>): void {
1092
+ if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
1093
+ if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
1094
+ if (flags.bones === undefined) {
1095
+ throw new UsageError(
1096
+ `bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
1097
+ 'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
1098
+ );
1099
+ }
1100
+ // Each side's files there and its skeleton JSON, refused by name as `render` refuses them (issue #1042); a pair
1101
+ // that does not load is `boneDistance`'s refusal (`CandidatePairError`, exit 2 below).
1102
+ const sideOf = (target: string, atlas: string | undefined): { skeletonPath: string; atlasPath: string } => {
1103
+ const side = resolveViewable({ candidate: target, ...(atlas === undefined ? {} : { atlas }) });
1104
+ readSkeletonText(side.skeletonPath);
1105
+ return side;
1106
+ };
1107
+ const candidate = sideOf(flags.candidate, flags.atlas);
1108
+ const reference = sideOf(flags.reference, flags['reference-atlas']);
1109
+ const report = boneDistance({
1110
+ candidateSkeleton: candidate.skeletonPath,
1111
+ candidateAtlas: candidate.atlasPath,
1112
+ referenceSkeleton: reference.skeletonPath,
1113
+ referenceAtlas: reference.atlasPath,
1114
+ bones: flags.bones,
1115
+ ...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
1116
+ });
1117
+ console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
1118
+ for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
1119
+ if (flags.json !== undefined) writeJson(flags.json, report);
1120
+ }
1121
+
1122
+ /**
1123
+ * The bodies of every command whose `runtime` is not `false` (`COMMANDS`), by
1124
+ * name — what only `cli.ts` registers, beside `./core_commands.ts`'s.
1125
+ */
1126
+ export const SPINE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
1127
+ build: ({ flags }) => cmdBuild(flags),
1128
+ validate: ({ flags, positional }) => cmdValidate(flags, positional),
1129
+ bench: ({ flags, positional }) => cmdBench(flags, positional),
1130
+ bonedist: ({ flags }) => cmdBoneDist(flags),
1131
+ preview: ({ flags, lists }) => cmdPreview(flags, lists.candidate ?? []),
1132
+ vote: ({ flags, lists }) => cmdVote(flags, lists.candidate ?? []),
1133
+ };
1134
+
1135
+ /** `bonedist`'s refusal, whose class lives in the module that poses through spine-core (`CliEntry.refusals`). */
1136
+ export const SPINE_COMMAND_REFUSALS: readonly CliRefusal[] = [
1137
+ { is: (err) => err instanceof BoneDistError, prefix: 'rigc bonedist error: ', status: 1 },
1138
+ ];
1139
+
1140
+ /** Each profile's rule count — the validator's, which the usage's `--profile` paragraph states. */
1141
+ export const PROFILE_RULES = (profile: 'spine' | 'spine-html'): number => assertionCountForProfile(profile);