spine-rigc 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,8 @@ parser and a list of named assertions all come back green.
30
30
  | You have | You run | You get |
31
31
  | --- | --- | --- |
32
32
  | part PNGs, a rig spec and a motion spec | `rigc build` | `skeleton.json` + `skeleton.atlas` — or a failure named by rule, and **nothing on disk** |
33
+ | the same, and one texture instead of many | `rigc build --pack` | the parts arranged onto shared atlas pages, written beside the skeleton — losslessly, so the picture is the picture |
34
+ | a pack somebody already made | `rigc build --atlas-in` | the same skeleton, with every part resolved to a region of that atlas — or a named refusal, never a part that silently does not draw |
33
35
  | a compiled rig | `rigc render` | every animation as PNG frames, plus one labelled contact sheet of the whole shot |
34
36
  | a compiled rig | `rigc preview` | one self-contained `.html` that plays it in Spine's own web player |
35
37
  | two to four compiled rigs | `rigc vote` | one ballot page a human picks from, and the answer checked into a ledger |
@@ -81,10 +83,11 @@ run a list of named assertions, and **write nothing unless all of them are green
81
83
 
82
84
  ## Install
83
85
 
84
- 📦 **rigc measures loose PNGs directly one atlas page per image.** It is not an
85
- atlas packer: packing several regions onto one page is tracked as
86
- [issue #4](https://github.com/firejune/rigc/issues/4), not something the tool does
87
- today.
86
+ 📦 **rigc measures loose PNGs directly, and emits one atlas page per image unless
87
+ you ask otherwise.** `rigc build --pack` arranges every part onto shared pages and
88
+ writes them into `--out`; `--atlas-in` builds against a pack somebody else made.
89
+ Both are opt-in and both are narrow — no trimming, no rotation, no scaling — and
90
+ [AUTHORING §0.1–§0.2](docs/AUTHORING.md) states the limits before you hit them.
88
91
 
89
92
  rigc runs on [Bun](https://bun.sh). The package ships its TypeScript sources and
90
93
  Bun runs them, so there is no build step and no `dist/` that can drift from the
@@ -379,6 +382,8 @@ commands take it and what its default is.
379
382
  | Command | Does |
380
383
  | --- | --- |
381
384
  | `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained |
385
+ | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
386
+ | `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
382
387
  | `validate <dir>` | re-gates artifacts already on disk |
383
388
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
384
389
  | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
@@ -413,6 +418,7 @@ the art. Its shape is under
413
418
  | --- | --- |
414
419
  | 📘 **[docs/AUTHORING.md](docs/AUTHORING.md)** | **the format guide, and the one to read before writing a spec.** Both input files field by field with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build → read the report → fix → repeat loop, the map from every named failure to the file that has to change, and the features rigc refuses by name so you do not spend a loop discovering them. It travels **inside the npm package**, at `node_modules/spine-rigc/docs/AUTHORING.md` |
415
420
  | 🎞️ **[docs/MOTION.md](docs/MOTION.md)** | **the key-pose recipe.** How to get two poses, what a pair of poses does and does not fix, the in-betweening rules and where each comes from, and how to spread candidates so a ballot informs. Ships in the package too |
421
+ | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
416
422
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
417
423
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
418
424
  | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 36 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
@@ -424,21 +430,27 @@ rigc is measured against **Spine's own official example projects** — the
424
430
  `1-weight-and-mass` … `8-follow-through` series as a difficulty ladder, with spineboy
425
431
  as the graduation exam.
426
432
 
427
- 🎓 **The ladder was completed 2026-08-28.** All eight numbered rungs and the
428
- spineboy graduation exam cleared under gate v2.1 and held under **v2.2**, every clause PASS or SKIP:
433
+ 🎓 **The ladder is complete, 2026-08-28.** All eight numbered rungs and the
434
+ spineboy graduation exam are cleared and hold under the current gate, **v2.3**, every clause PASS or SKIP:
429
435
  worst attributable slot drift **5.55 px** against a 6.0 px bar, and **0 of 124**
430
436
  frame-change disagreements. Recompiling the same spec in a different session
431
437
  reproduced every field of the measurement record **to the digit**. The rungs stay
432
438
  in place as regression gates.
433
439
 
434
- ⚠️ **Current state, 2026-09-02: seven of the eight hold, and rung 7 is open.**
435
- `check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
436
- changed which box a set is measured in, and rung 7's stored candidate fails **G2**
440
+ 🗓️ **One rung's pass was withdrawn and restored on 2026-09-02, and both are dated
441
+ facts.** `check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
442
+ changed which box a set is measured in, and rung 7's stored candidate failed **G2**
437
443
  under it — one of its three slots draws in every set and is attributable in none,
438
- and no read-down kind survives the framing change. **Rungs 1–6 and 8 and the
439
- graduation exam are unaffected**: each reproduces its gated figures to the digit,
440
- and the 5.55 px and 0-of-124 figures above are among them. Verdict and reasoning
441
- in [docs/LADDER.md](docs/LADDER.md)'s *PR #254 instrument re-inspection*.
444
+ and no read-down ground survived the framing change. The gate then answered the two
445
+ clause questions that exposed, as **v2.3**: a read-down names the framing of its
446
+ evidence, and a slot whose attributability is **measured** to be capped below the bar
447
+ reads down when everything observable about it is independently verified strict. That
448
+ rung's third attempt clears on those grounds, on the candidate it already had.
449
+ **Rungs 1–6 and 8 and the graduation exam were unaffected throughout**: each reproduces
450
+ its gated figures to the digit, and the 5.55 px and 0-of-124 figures above are among
451
+ them. Both verdicts, and the sweep of every candidate under the new gate, are in
452
+ [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md)'s *PR #254 instrument re-inspection* and *gate-v2.3
453
+ re-inspection*.
442
454
 
443
455
  ⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
444
456
  protocol reach the bar across a bounded series of honest attempts, each residual
package/cli.ts CHANGED
@@ -48,10 +48,19 @@ import {
48
48
  type BallotCandidateInput,
49
49
  type BallotInput,
50
50
  } from './src/ballot.ts';
51
+ import {
52
+ boneDistance,
53
+ BONEDIST_SPEC,
54
+ boneDistLines,
55
+ BoneDistError,
56
+ IDENTITY_CORRESPONDENCE,
57
+ type BoneDistReport,
58
+ } from './src/bonedist.ts';
51
59
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
52
60
  import { compile, CompileError, type CompileOptions } from './src/compile.ts';
53
- import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
61
+ import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
54
62
  import { copyAtlasImages } from './src/emit.ts';
63
+ import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
55
64
  import { parseJsonWithPosition } from './src/json-position.ts';
56
65
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
57
66
  import {
@@ -152,7 +161,7 @@ function repositoryUrl(): string {
152
161
  * flag": inferring it would turn `--out --json report.json` — a real typo, a
153
162
  * missing value — into a silently accepted switch plus a stray positional.
154
163
  */
155
- const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again']);
164
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again', 'pack']);
156
165
 
157
166
  /**
158
167
  * The flags a command is allowed to spell more than once.
@@ -276,6 +285,7 @@ function resolveCut(flags: Record<string, string>): { label: string; opts: Compi
276
285
  };
277
286
  if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
278
287
  if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
288
+ if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
279
289
  return { label: flags.rig, opts };
280
290
  }
281
291
  if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
@@ -287,7 +297,13 @@ function resolveCut(flags: Record<string, string>): { label: string; opts: Compi
287
297
  `unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
288
298
  );
289
299
  }
290
- return { label: flags.cut, opts: entryToOptions(dir, flags.cut, entry) };
300
+ const opts = entryToOptions(dir, flags.cut, entry);
301
+ // `--atlas-in` is not part of the cuts table: a cut names its rig, motion and
302
+ // manifest, and where the pixels are delivered from is a property of the BUILD.
303
+ // Resolved against the working directory, like every other path on the command
304
+ // line, rather than against the table's directory.
305
+ if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
306
+ return { label: flags.cut, opts };
291
307
  }
292
308
 
293
309
  // ---------------------------------------------------------------------------
@@ -311,15 +327,35 @@ function readProfile(flags: Record<string, string>): ValidateProfile {
311
327
  return found;
312
328
  }
313
329
 
314
- function runGate(result: CompileResult, opts: CompileOptions, profile: ValidateProfile): number {
330
+ /**
331
+ * An atlas text to gate INSTEAD of the compile's own, with the second, independent
332
+ * emit A18 compares it against.
333
+ *
334
+ * `--pack` is the only caller. A packed build is gated twice on purpose — once as
335
+ * compiled (which is the gate that reads the loose PNGs, so `A06`'s size-vs-file
336
+ * clause still measures the art R5 measures) and once as packed (which is the pair
337
+ * that actually ships). Handing the second pass its texts rather than re-deriving
338
+ * them here keeps `runGate` ignorant of what a pack is.
339
+ */
340
+ interface AtlasOverride {
341
+ text: string;
342
+ again: string;
343
+ }
344
+
345
+ function runGate(
346
+ result: CompileResult,
347
+ opts: CompileOptions,
348
+ profile: ValidateProfile,
349
+ atlas?: AtlasOverride,
350
+ ): number {
315
351
  // The determinism check compares a second, independent compile.
316
352
  const again = compile(opts);
317
353
  const report = validate({
318
354
  skeletonText: result.skeletonText,
319
- atlasText: result.atlasText,
355
+ atlasText: atlas ? atlas.text : result.atlasText,
320
356
  atlasDir: opts.outDir,
321
357
  declaredDurations: result.declaredDurations,
322
- reEmit: { skeletonText: again.skeletonText, atlasText: again.atlasText },
358
+ reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText },
323
359
  rig: result.rig,
324
360
  profile,
325
361
  });
@@ -352,9 +388,51 @@ function meshFit(m: CompileResult['meshes'][number]): string {
352
388
  return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
353
389
  }
354
390
 
391
+ /**
392
+ * Read one non-negative integer flag, or its default.
393
+ *
394
+ * A usage error rather than a `NaN` that reaches the packer: `--padding two`
395
+ * would otherwise place every region at NaN and write a blank page, which is a
396
+ * green build and an empty picture.
397
+ */
398
+ function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
399
+ const raw = flags[name];
400
+ if (raw === undefined) return fallback;
401
+ if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
402
+ return Number(raw);
403
+ }
404
+
355
405
  function cmdBuild(flags: Record<string, string>): void {
356
406
  const { label, opts } = resolveCut(flags);
357
407
  const profile = readProfile(flags);
408
+ const packing = flags.pack !== undefined;
409
+ // Three combinations are refused rather than silently resolved, because in each
410
+ // one the two flags disagree about a single question and there is no answer
411
+ // that is not a guess about which the caller meant.
412
+ if (packing && opts.atlasInPath !== undefined) {
413
+ throw new UsageError(
414
+ '--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
415
+ 'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
416
+ );
417
+ }
418
+ if (packing && flags['copy-images'] !== undefined) {
419
+ throw new UsageError(
420
+ '--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
421
+ 'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
422
+ );
423
+ }
424
+ if (packing && profile === 'spine-html') {
425
+ throw new UsageError(
426
+ "--profile spine-html asserts one part per page (A06's full-page coverage clause), which is rigc's unpacked " +
427
+ 'convention and exactly what --pack stops being true. A packed atlas is valid Spine — build it under the ' +
428
+ 'default --profile spine',
429
+ );
430
+ }
431
+ if (!packing) {
432
+ for (const name of ['page-size', 'padding'] as const) {
433
+ if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
434
+ }
435
+ }
358
436
  console.log(`rigc build ${label}`);
359
437
  // Named explicitly and on their own lines rather than folded into the header
360
438
  // above: with two input files, a header that names only one of them (the rig,
@@ -364,12 +442,26 @@ function cmdBuild(flags: Record<string, string>): void {
364
442
  console.log(` .. motion ${opts.motionPath}`);
365
443
  const result = compile(opts);
366
444
 
445
+ if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
367
446
  console.log(` .. ${result.images.length} part page(s):`);
368
447
  for (const img of result.images) {
369
- console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${img.page}`);
448
+ // An imported part says where on the page it came from, because "resolved
449
+ // against a region" is the claim `--atlas-in` makes and a line that only
450
+ // repeated the page filename would look identical for all of them. A page
451
+ // that declares a `scale:` also says so and shows the texels it was read
452
+ // from: the size on the left is the DRAWING's and the rectangle is the
453
+ // pack's, and issue #267 is the report that printed the second as the first.
454
+ const where =
455
+ img.atlas === undefined
456
+ ? img.page
457
+ : `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
458
+ (img.atlasScale === undefined
459
+ ? ''
460
+ : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`);
461
+ console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
370
462
  }
371
463
  for (const d of result.droppedStates) {
372
- console.log(` DROP ${d.slot}/${d.state}: no PNG at ${d.path} (state not emitted)`);
464
+ console.log(` DROP ${d.slot}/${d.state}: ${d.why ?? `no PNG at ${d.path}`} (state not emitted)`);
373
465
  }
374
466
  // "The optional slots are optional" is a claim about this code path, so this
375
467
  // code path says which ones it left out rather than being silently right.
@@ -414,6 +506,64 @@ function cmdBuild(flags: Record<string, string>): void {
414
506
  }
415
507
  }
416
508
 
509
+ // `--pack`: the parts go onto shared pages, which are written here as real
510
+ // PNGs, so `--out` is self-contained by construction. The atlas above stays
511
+ // the one the gate just read — packing changes only the ARRANGEMENT of the
512
+ // bytes, and the sizes in `result.images` are still the ones measured off the
513
+ // loose PNGs (see src/atlas.ts's header).
514
+ if (packing) {
515
+ const packOpts = {
516
+ pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
517
+ padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
518
+ pageStem: 'skeleton',
519
+ };
520
+ const inputs = result.images.map((img) => ({
521
+ region: img.region,
522
+ absPath: img.absPath,
523
+ width: img.width,
524
+ height: img.height,
525
+ }));
526
+ const packed = packAtlas(inputs, packOpts);
527
+ atlasText = packed.atlasText;
528
+ for (const page of packed.pages) {
529
+ page.plate.writePng(join(opts.outDir, page.name));
530
+ console.log(
531
+ ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
532
+ `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
533
+ `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}`,
534
+ );
535
+ }
536
+ for (const place of packed.placements) {
537
+ console.log(
538
+ ` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
539
+ `${packed.pages[place.page].name} @ ${place.x},${place.y}`,
540
+ );
541
+ }
542
+ // The pages are on disk now, so the packed pair can be gated as an artifact
543
+ // rather than trusted as a construction: A17 stats every page, A06 reads its
544
+ // IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
545
+ // a region, and A18 compares a second independent compile+pack. Two gates on
546
+ // one build is the cost of shipping a second atlas shape.
547
+ console.log(' .. validate (packed atlas, pages on disk)');
548
+ const packAgain = packAtlas(
549
+ compile(opts).images.map((img) => ({
550
+ region: img.region,
551
+ absPath: img.absPath,
552
+ width: img.width,
553
+ height: img.height,
554
+ })),
555
+ packOpts,
556
+ );
557
+ const packFailures = runGate(result, opts, profile, { text: atlasText, again: packAgain.atlasText });
558
+ if (packFailures > 0) {
559
+ console.error(
560
+ `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
561
+ `${opts.outDir}, the skeleton/atlas pair was not`,
562
+ );
563
+ process.exit(1);
564
+ }
565
+ }
566
+
417
567
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
418
568
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
419
569
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
@@ -1172,6 +1322,38 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1172
1322
  diffs.push({ skeleton, reference: referencePath, report: diff });
1173
1323
  }
1174
1324
 
1325
+ // Stage 3, optional and behind a flag because the correspondence is an INPUT:
1326
+ // a candidate is entitled to its own bone names, so there is nothing sensible
1327
+ // to default to and a derived mapping would be a guess reported as a
1328
+ // measurement (issue #8). Nothing here gates, and without the flag the report
1329
+ // above is unchanged to the byte.
1330
+ const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
1331
+ if (flags.bones !== undefined) {
1332
+ for (const skeleton of rung.skeletons) {
1333
+ const referencePath = join(exportDir, skeleton.file);
1334
+ if (!existsSync(referencePath)) continue;
1335
+ console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
1336
+ const boneDist = boneDistance({
1337
+ candidateSkeleton: skeletonPath,
1338
+ candidateAtlas: atlasPath,
1339
+ candidateAtlasDir: dirname(atlasPath),
1340
+ referenceSkeleton: referencePath,
1341
+ referenceAtlas: join(exportDir, skeleton.atlas),
1342
+ referenceAtlasDir: exportDir,
1343
+ bones: flags.bones,
1344
+ // Deliberately NOT `flags.fps`. Inside `bench` that flag already means
1345
+ // "the rate this frame set was recorded at, for a set with no sidecar",
1346
+ // and one flag doing two unrelated things in one command is how a
1347
+ // reader ends up quoting a figure measured at a rate they did not ask
1348
+ // for. A run wanting another sampling rate calls `rigc bonedist`, where
1349
+ // `--fps` has exactly one meaning.
1350
+ });
1351
+ for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
1352
+ console.log('');
1353
+ boneDists.push({ skeleton, report: boneDist });
1354
+ }
1355
+ }
1356
+
1175
1357
  // Third, optional and third for a reason: is it the same MOTION? `diff`
1176
1358
  // compares structure, and a reversed easing is the same key count and the same
1177
1359
  // curve kind — so a row of this ladder carrying only `validate` and `diff`
@@ -1197,6 +1379,13 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1197
1379
  // name-agnostic figure printed beside — issue #21.
1198
1380
  const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
1199
1381
  if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
1382
+ // A third line, for the same reason the second one is not folded into the
1383
+ // first: the reported measures are unobservable by construction, so they
1384
+ // roll into no mean at all and cannot be shown as one. Each is named with
1385
+ // its own figure — a per-section digest would be the mean this block exists
1386
+ // to refuse.
1387
+ const reported = reportedFigures(d.report);
1388
+ if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
1200
1389
  }
1201
1390
  if (check) {
1202
1391
  // The framing goes first because it is upstream of every MAE below it: a
@@ -1271,6 +1460,19 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1271
1460
  console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
1272
1461
  console.log(' Without it this report says nothing about whether the ANIMATION is right.');
1273
1462
  }
1463
+ if (boneDists.length > 0) {
1464
+ for (const b of boneDists) {
1465
+ const w = b.report.worst;
1466
+ console.log(
1467
+ ` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
1468
+ `rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
1469
+ `over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
1470
+ );
1471
+ }
1472
+ } else {
1473
+ console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
1474
+ console.log(' bone world-transform distance. It reports and gates nothing.');
1475
+ }
1274
1476
  console.log(' Section figures are means of their own measures. There is no rung score:');
1275
1477
  console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
1276
1478
 
@@ -1298,6 +1500,12 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1298
1500
  ...d.report,
1299
1501
  })),
1300
1502
  check,
1503
+ // Absent rather than null when the flag was not passed: `bonedist: null`
1504
+ // in a stored record would read as "measured, nothing to report", and
1505
+ // that is the opposite of "not measured".
1506
+ ...(boneDists.length === 0
1507
+ ? {}
1508
+ : { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
1301
1509
  });
1302
1510
  }
1303
1511
 
@@ -1307,6 +1515,39 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1307
1515
  }
1308
1516
  }
1309
1517
 
1518
+ /**
1519
+ * bonedist — the ladder's stage 3, run on its own.
1520
+ *
1521
+ * ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
1522
+ * unlike `check`. Every convention behind every figure is printed above the
1523
+ * tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
1524
+ */
1525
+ function cmdBoneDist(flags: Record<string, string>): void {
1526
+ if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
1527
+ if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
1528
+ if (flags.bones === undefined) {
1529
+ throw new UsageError(
1530
+ `bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
1531
+ 'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
1532
+ );
1533
+ }
1534
+ const candidate = resolveArtifacts(flags.candidate, flags.atlas);
1535
+ const reference = resolveArtifacts(flags.reference, flags['reference-atlas']);
1536
+ const report = boneDistance({
1537
+ candidateSkeleton: candidate.skeletonPath,
1538
+ candidateAtlas: candidate.atlasPath,
1539
+ candidateAtlasDir: dirname(candidate.atlasPath),
1540
+ referenceSkeleton: reference.skeletonPath,
1541
+ referenceAtlas: reference.atlasPath,
1542
+ referenceAtlasDir: dirname(reference.atlasPath),
1543
+ bones: flags.bones,
1544
+ ...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
1545
+ });
1546
+ console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
1547
+ for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
1548
+ if (flags.json !== undefined) writeJson(flags.json, report);
1549
+ }
1550
+
1310
1551
  function cmdExplain(flags: Record<string, string>): void {
1311
1552
  const { label, opts } = resolveCut(flags);
1312
1553
  console.log(`rigc explain ${label}`);
@@ -1555,12 +1796,29 @@ const FLAG_MEANINGS: Record<string, string> = {
1555
1796
  'copy-images':
1556
1797
  'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
1557
1798
  'self-contained enough to zip or commit on its own (default: page paths still point at the source art)',
1799
+ pack: 'arrange every part PNG onto shared atlas page(s) written into --out as real PNGs, instead of one page ' +
1800
+ 'per part. Lossless: every region is a byte-for-byte copy and nothing is resampled, trimmed or rotated ' +
1801
+ '(default: one part, one page, pointing at the source art)',
1802
+ 'page-size': `largest page edge, --pack only (default ${DEFAULT_PAGE_SIZE}); pages are powers of two and the ` +
1803
+ 'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
1804
+ padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
1805
+ "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
1806
+ 'atlas-in':
1807
+ 'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
1808
+ 'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
1558
1809
  cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
1559
1810
  cuts: 'the cuts.json --cut names',
1560
1811
  profile:
1561
1812
  'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
1562
1813
  "correctly; spine-html = also this project's renderer/archetype policy",
1563
1814
  atlas: "the candidate's atlas, when it is not beside the skeleton",
1815
+ reference: 'the reference skeleton to pose beside the candidate — a directory or a skeleton.json path',
1816
+ 'reference-atlas': "the reference's atlas, when it is not beside the reference skeleton",
1817
+ bones: `a bone correspondence — { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": "<reference bone>" }, ` +
1818
+ '"animations"?: { … } } — or `identity` to state that the two skeletons use the same names. An INPUT, never ' +
1819
+ 'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
1820
+ 'as a measurement',
1821
+ 'all-bones': 'print every bone pair, not just the worst by position',
1564
1822
  'texture-from':
1565
1823
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1566
1824
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -1600,7 +1858,13 @@ const FLAG_VALUES: Record<string, string> = {
1600
1858
  cuts: '<path>',
1601
1859
  profile: 'spine|spine-html',
1602
1860
  atlas: '<path>',
1861
+ 'atlas-in': '<file.atlas>',
1862
+ 'page-size': '<px>',
1863
+ padding: '<px>',
1603
1864
  'texture-from': '<path>',
1865
+ reference: '<dir|skeleton.json>',
1866
+ 'reference-atlas': '<path>',
1867
+ bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
1604
1868
  candidate: '<dir|skeleton.json>',
1605
1869
  frames: '<dir>',
1606
1870
  fps: '<n>',
@@ -1646,9 +1910,25 @@ const COMMANDS: CommandDoc[] = [
1646
1910
  name: 'build',
1647
1911
  usage: [
1648
1912
  'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
1913
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] (parts onto shared pages, written into --out)`,
1914
+ 'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1649
1915
  'rigc build --cut <name> --cuts <cuts.json>',
1650
1916
  ],
1651
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'copy-images', 'cut', 'cuts', 'profile'],
1917
+ flags: [
1918
+ 'rig',
1919
+ 'motion',
1920
+ 'out',
1921
+ 'manifest',
1922
+ 'images',
1923
+ 'copy-images',
1924
+ 'pack',
1925
+ 'page-size',
1926
+ 'padding',
1927
+ 'atlas-in',
1928
+ 'cut',
1929
+ 'cuts',
1930
+ 'profile',
1931
+ ],
1652
1932
  },
1653
1933
  {
1654
1934
  name: 'explain',
@@ -1676,7 +1956,25 @@ const COMMANDS: CommandDoc[] = [
1676
1956
  {
1677
1957
  name: 'bench',
1678
1958
  usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
1679
- flags: ['candidate', 'atlas', 'frames', 'profile', 'all-frames', 'json'],
1959
+ flags: ['candidate', 'atlas', 'frames', 'profile', 'bones', 'all-frames', 'all-bones', 'json'],
1960
+ overrides: {
1961
+ bones: {
1962
+ meaning:
1963
+ 'also run the stage-3 per-frame bone world-transform distance against each of the rung\'s reference ' +
1964
+ `skeletons, with this correspondence (or \`identity\`), at ${PROTOCOL_FPS} fps. Reports; gates nothing — ` +
1965
+ 'for another sampling rate call `rigc bonedist` directly, where --fps means only that',
1966
+ },
1967
+ },
1968
+ },
1969
+ {
1970
+ name: 'bonedist',
1971
+ usage: [
1972
+ `rigc bonedist --candidate <dir | skeleton.json> --reference <dir | skeleton.json> --bones <path | ${IDENTITY_CORRESPONDENCE}> [--fps ${PROTOCOL_FPS}] [--all-bones] [--json <out>]`,
1973
+ ],
1974
+ flags: ['candidate', 'atlas', 'reference', 'reference-atlas', 'bones', 'fps', 'all-bones', 'json'],
1975
+ overrides: {
1976
+ fps: { meaning: `the rate both skeletons are sampled at, from t=0 over their own durations (default ${PROTOCOL_FPS})` },
1977
+ },
1680
1978
  },
1681
1979
  {
1682
1980
  name: 'render',
@@ -1843,6 +2141,7 @@ try {
1843
2141
  else if (command === 'diff') cmdDiff(flags, positional);
1844
2142
  else if (command === 'check') cmdCheck(flags);
1845
2143
  else if (command === 'bench') cmdBench(flags, positional);
2144
+ else if (command === 'bonedist') cmdBoneDist(flags);
1846
2145
  else if (command === 'render') cmdRender(flags);
1847
2146
  else if (command === 'preview') cmdPreview(flags);
1848
2147
  else if (command === 'pose') cmdPose(flags);
@@ -1866,6 +2165,10 @@ try {
1866
2165
  console.error(`rigc check error: ${err.message}`);
1867
2166
  process.exit(1);
1868
2167
  }
2168
+ if (err instanceof BoneDistError) {
2169
+ console.error(`rigc bonedist error: ${err.message}`);
2170
+ process.exit(1);
2171
+ }
1869
2172
  // Like a usage error in kind — a missing directory, an unreadable frame — but
1870
2173
  // its messages name a path and a reason, and reprinting the whole usage under
1871
2174
  // them buries that.