spine-rigc 0.9.0 → 0.11.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
@@ -370,6 +373,22 @@ A part that matches nowhere is refused by name, two near-equal placements are re
370
373
  as both, and nothing it prints is a score. Fields, the coordinate contract and the
371
374
  limits: [AUTHORING.md §11](docs/AUTHORING.md).
372
375
 
376
+ ## The gallery — four complete rigs over art that ships with them
377
+
378
+ Each directory in [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) is
379
+ one rig spec, one motion spec and the PNGs they name, small enough to read in one
380
+ sitting. Each stars a single feature, so *how do I do X* has a working answer rather
381
+ than a field table, and each README carries the frame rate it was authored at, what
382
+ was verified, and what writing it cost. Repository material: a clone and
383
+ `bun install` runs them.
384
+
385
+ | Example | Stars | What it is |
386
+ | --- | --- | --- |
387
+ | [`gallery/walk`](https://github.com/firejune/rigc/tree/main/gallery/walk) | `ik` constraints + **`ik` timelines** | Two two-bone leg chains solved to foot targets — the planted leg nailed down, the swinging one let go at the top of its lift |
388
+ | [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms written out in the README |
389
+ | [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) | **`contour` meshes** | A swallow-tailed banner and a serrated leaf: four meshes traced off their own alpha, waved by bone timelines and rippled by a `deform` |
390
+ | [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride) | `path` attachments + **path constraints** | A trolley coasting down a drawn rail and rolling back, driven by a `position` timeline, with `groups` + `stagger` keying the wheels and the ears |
391
+
373
392
  ## Commands
374
393
 
375
394
  Every command takes its paths explicitly. `rigc <command> --help` prints its flags, and
@@ -379,6 +398,8 @@ commands take it and what its default is.
379
398
  | Command | Does |
380
399
  | --- | --- |
381
400
  | `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 |
401
+ | `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 |
402
+ | `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
403
  | `validate <dir>` | re-gates artifacts already on disk |
383
404
  | `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
405
  | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
@@ -413,6 +434,7 @@ the art. Its shape is under
413
434
  | --- | --- |
414
435
  | 📘 **[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
436
  | 🎞️ **[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 |
437
+ | 📥 **[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
438
  | 🤖 **[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
439
  | 🔬 **[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
440
  | 🎓 **[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 +446,27 @@ rigc is measured against **Spine's own official example projects** — the
424
446
  `1-weight-and-mass` … `8-follow-through` series as a difficulty ladder, with spineboy
425
447
  as the graduation exam.
426
448
 
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:
449
+ 🎓 **The ladder is complete, 2026-08-28.** All eight numbered rungs and the
450
+ spineboy graduation exam are cleared and hold under the current gate, **v2.3**, every clause PASS or SKIP:
429
451
  worst attributable slot drift **5.55 px** against a 6.0 px bar, and **0 of 124**
430
452
  frame-change disagreements. Recompiling the same spec in a different session
431
453
  reproduced every field of the measurement record **to the digit**. The rungs stay
432
454
  in place as regression gates.
433
455
 
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**
456
+ 🗓️ **One rung's pass was withdrawn and restored on 2026-09-02, and both are dated
457
+ facts.** `check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
458
+ changed which box a set is measured in, and rung 7's stored candidate failed **G2**
437
459
  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*.
460
+ and no read-down ground survived the framing change. The gate then answered the two
461
+ clause questions that exposed, as **v2.3**: a read-down names the framing of its
462
+ evidence, and a slot whose attributability is **measured** to be capped below the bar
463
+ reads down when everything observable about it is independently verified strict. That
464
+ rung's third attempt clears on those grounds, on the candidate it already had.
465
+ **Rungs 1–6 and 8 and the graduation exam were unaffected throughout**: each reproduces
466
+ its gated figures to the digit, and the 5.55 px and 0-of-124 figures above are among
467
+ them. Both verdicts, and the sweep of every candidate under the new gate, are in
468
+ [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md)'s *PR #254 instrument re-inspection* and *gate-v2.3
469
+ re-inspection*.
442
470
 
443
471
  ⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
444
472
  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
  });
@@ -346,15 +382,88 @@ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> =
346
382
  authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
347
383
  };
348
384
 
349
- /** What a contour mesh measured about its own fit, or nothing for the others. */
385
+ /**
386
+ * What a mesh measured about its own fit against the art it names, or nothing
387
+ * for a mesh with no art to measure against.
388
+ *
389
+ * Printed for authored geometry as well as for a `contour` (issue #277): the
390
+ * figure is a measurement between the emitted triangles and the PNG, so it means
391
+ * the same thing whoever drew the vertices, and the silence was the defect —
392
+ * an octagon rim placed on a round part's silhouette clips its own ink outline
393
+ * at 94.31% and used to print nothing at all.
394
+ *
395
+ * The hole is appended only when there is one, so the common line is unchanged.
396
+ * It is the one figure in the report that a hole moves: `coverage` and
397
+ * `overshoot` are both measured against the FILLED silhouette, so spanning an
398
+ * interior hole is neither missing coverage nor reaching past anything, and an
399
+ * unintentional hole — a gap in the art, a stroke that failed to join — bought
400
+ * fill over transparent pixels with nothing anywhere saying so (issue #275).
401
+ */
350
402
  function meshFit(m: CompileResult['meshes'][number]): string {
351
403
  if (m.coverage === undefined) return '';
352
- return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
404
+ const hole = m.holePixels ? `, enclosing ${m.holePixels}px of hole` : '';
405
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${hole}`;
406
+ }
407
+
408
+ /**
409
+ * The triangle budget a `MESH` line is read against: the rig's, or nothing.
410
+ *
411
+ * 📐 It used to be the literal `80`, which was nobody's budget — the rig quoted
412
+ * in issue #275 declared 64, `A13_MESH_BUDGET` measured against that 64
413
+ * correctly, and the line an author actually reads printed 80. Under the default
414
+ * `--profile spine` `A13` is `PROF`, so the printed number is the only budget
415
+ * figure in the output and it has to be the declared one. A rig that declares
416
+ * none says so in the same words `A13` SKIPs in, rather than being given a wall.
417
+ */
418
+ function meshBudget(rig: CompileResult['rig']): string {
419
+ return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
420
+ }
421
+
422
+ /**
423
+ * Read one non-negative integer flag, or its default.
424
+ *
425
+ * A usage error rather than a `NaN` that reaches the packer: `--padding two`
426
+ * would otherwise place every region at NaN and write a blank page, which is a
427
+ * green build and an empty picture.
428
+ */
429
+ function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
430
+ const raw = flags[name];
431
+ if (raw === undefined) return fallback;
432
+ if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
433
+ return Number(raw);
353
434
  }
354
435
 
355
436
  function cmdBuild(flags: Record<string, string>): void {
356
437
  const { label, opts } = resolveCut(flags);
357
438
  const profile = readProfile(flags);
439
+ const packing = flags.pack !== undefined;
440
+ // Three combinations are refused rather than silently resolved, because in each
441
+ // one the two flags disagree about a single question and there is no answer
442
+ // that is not a guess about which the caller meant.
443
+ if (packing && opts.atlasInPath !== undefined) {
444
+ throw new UsageError(
445
+ '--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
446
+ 'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
447
+ );
448
+ }
449
+ if (packing && flags['copy-images'] !== undefined) {
450
+ throw new UsageError(
451
+ '--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
452
+ 'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
453
+ );
454
+ }
455
+ if (packing && profile === 'spine-html') {
456
+ throw new UsageError(
457
+ "--profile spine-html asserts one part per page (A06's full-page coverage clause), which is rigc's unpacked " +
458
+ 'convention and exactly what --pack stops being true. A packed atlas is valid Spine — build it under the ' +
459
+ 'default --profile spine',
460
+ );
461
+ }
462
+ if (!packing) {
463
+ for (const name of ['page-size', 'padding'] as const) {
464
+ if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
465
+ }
466
+ }
358
467
  console.log(`rigc build ${label}`);
359
468
  // Named explicitly and on their own lines rather than folded into the header
360
469
  // above: with two input files, a header that names only one of them (the rig,
@@ -364,12 +473,26 @@ function cmdBuild(flags: Record<string, string>): void {
364
473
  console.log(` .. motion ${opts.motionPath}`);
365
474
  const result = compile(opts);
366
475
 
476
+ if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
367
477
  console.log(` .. ${result.images.length} part page(s):`);
368
478
  for (const img of result.images) {
369
- console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${img.page}`);
479
+ // An imported part says where on the page it came from, because "resolved
480
+ // against a region" is the claim `--atlas-in` makes and a line that only
481
+ // repeated the page filename would look identical for all of them. A page
482
+ // that declares a `scale:` also says so and shows the texels it was read
483
+ // from: the size on the left is the DRAWING's and the rectangle is the
484
+ // pack's, and issue #267 is the report that printed the second as the first.
485
+ const where =
486
+ img.atlas === undefined
487
+ ? img.page
488
+ : `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
489
+ (img.atlasScale === undefined
490
+ ? ''
491
+ : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`);
492
+ console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
370
493
  }
371
494
  for (const d of result.droppedStates) {
372
- console.log(` DROP ${d.slot}/${d.state}: no PNG at ${d.path} (state not emitted)`);
495
+ console.log(` DROP ${d.slot}/${d.state}: ${d.why ?? `no PNG at ${d.path}`} (state not emitted)`);
373
496
  }
374
497
  // "The optional slots are optional" is a claim about this code path, so this
375
498
  // code path says which ones it left out rather than being silently right.
@@ -379,7 +502,7 @@ function cmdBuild(flags: Record<string, string>): void {
379
502
  for (const m of result.meshes) {
380
503
  console.log(
381
504
  ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
382
- `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
505
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
383
506
  );
384
507
  }
385
508
  for (const ph of result.physics) {
@@ -414,6 +537,64 @@ function cmdBuild(flags: Record<string, string>): void {
414
537
  }
415
538
  }
416
539
 
540
+ // `--pack`: the parts go onto shared pages, which are written here as real
541
+ // PNGs, so `--out` is self-contained by construction. The atlas above stays
542
+ // the one the gate just read — packing changes only the ARRANGEMENT of the
543
+ // bytes, and the sizes in `result.images` are still the ones measured off the
544
+ // loose PNGs (see src/atlas.ts's header).
545
+ if (packing) {
546
+ const packOpts = {
547
+ pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
548
+ padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
549
+ pageStem: 'skeleton',
550
+ };
551
+ const inputs = result.images.map((img) => ({
552
+ region: img.region,
553
+ absPath: img.absPath,
554
+ width: img.width,
555
+ height: img.height,
556
+ }));
557
+ const packed = packAtlas(inputs, packOpts);
558
+ atlasText = packed.atlasText;
559
+ for (const page of packed.pages) {
560
+ page.plate.writePng(join(opts.outDir, page.name));
561
+ console.log(
562
+ ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
563
+ `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
564
+ `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}`,
565
+ );
566
+ }
567
+ for (const place of packed.placements) {
568
+ console.log(
569
+ ` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
570
+ `${packed.pages[place.page].name} @ ${place.x},${place.y}`,
571
+ );
572
+ }
573
+ // The pages are on disk now, so the packed pair can be gated as an artifact
574
+ // rather than trusted as a construction: A17 stats every page, A06 reads its
575
+ // IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
576
+ // a region, and A18 compares a second independent compile+pack. Two gates on
577
+ // one build is the cost of shipping a second atlas shape.
578
+ console.log(' .. validate (packed atlas, pages on disk)');
579
+ const packAgain = packAtlas(
580
+ compile(opts).images.map((img) => ({
581
+ region: img.region,
582
+ absPath: img.absPath,
583
+ width: img.width,
584
+ height: img.height,
585
+ })),
586
+ packOpts,
587
+ );
588
+ const packFailures = runGate(result, opts, profile, { text: atlasText, again: packAgain.atlasText });
589
+ if (packFailures > 0) {
590
+ console.error(
591
+ `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
592
+ `${opts.outDir}, the skeleton/atlas pair was not`,
593
+ );
594
+ process.exit(1);
595
+ }
596
+ }
597
+
417
598
  writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
418
599
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
419
600
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
@@ -1172,6 +1353,38 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1172
1353
  diffs.push({ skeleton, reference: referencePath, report: diff });
1173
1354
  }
1174
1355
 
1356
+ // Stage 3, optional and behind a flag because the correspondence is an INPUT:
1357
+ // a candidate is entitled to its own bone names, so there is nothing sensible
1358
+ // to default to and a derived mapping would be a guess reported as a
1359
+ // measurement (issue #8). Nothing here gates, and without the flag the report
1360
+ // above is unchanged to the byte.
1361
+ const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
1362
+ if (flags.bones !== undefined) {
1363
+ for (const skeleton of rung.skeletons) {
1364
+ const referencePath = join(exportDir, skeleton.file);
1365
+ if (!existsSync(referencePath)) continue;
1366
+ console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
1367
+ const boneDist = boneDistance({
1368
+ candidateSkeleton: skeletonPath,
1369
+ candidateAtlas: atlasPath,
1370
+ candidateAtlasDir: dirname(atlasPath),
1371
+ referenceSkeleton: referencePath,
1372
+ referenceAtlas: join(exportDir, skeleton.atlas),
1373
+ referenceAtlasDir: exportDir,
1374
+ bones: flags.bones,
1375
+ // Deliberately NOT `flags.fps`. Inside `bench` that flag already means
1376
+ // "the rate this frame set was recorded at, for a set with no sidecar",
1377
+ // and one flag doing two unrelated things in one command is how a
1378
+ // reader ends up quoting a figure measured at a rate they did not ask
1379
+ // for. A run wanting another sampling rate calls `rigc bonedist`, where
1380
+ // `--fps` has exactly one meaning.
1381
+ });
1382
+ for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
1383
+ console.log('');
1384
+ boneDists.push({ skeleton, report: boneDist });
1385
+ }
1386
+ }
1387
+
1175
1388
  // Third, optional and third for a reason: is it the same MOTION? `diff`
1176
1389
  // compares structure, and a reversed easing is the same key count and the same
1177
1390
  // curve kind — so a row of this ladder carrying only `validate` and `diff`
@@ -1197,6 +1410,13 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1197
1410
  // name-agnostic figure printed beside — issue #21.
1198
1411
  const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
1199
1412
  if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
1413
+ // A third line, for the same reason the second one is not folded into the
1414
+ // first: the reported measures are unobservable by construction, so they
1415
+ // roll into no mean at all and cannot be shown as one. Each is named with
1416
+ // its own figure — a per-section digest would be the mean this block exists
1417
+ // to refuse.
1418
+ const reported = reportedFigures(d.report);
1419
+ if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
1200
1420
  }
1201
1421
  if (check) {
1202
1422
  // The framing goes first because it is upstream of every MAE below it: a
@@ -1271,6 +1491,19 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1271
1491
  console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
1272
1492
  console.log(' Without it this report says nothing about whether the ANIMATION is right.');
1273
1493
  }
1494
+ if (boneDists.length > 0) {
1495
+ for (const b of boneDists) {
1496
+ const w = b.report.worst;
1497
+ console.log(
1498
+ ` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
1499
+ `rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
1500
+ `over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
1501
+ );
1502
+ }
1503
+ } else {
1504
+ console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
1505
+ console.log(' bone world-transform distance. It reports and gates nothing.');
1506
+ }
1274
1507
  console.log(' Section figures are means of their own measures. There is no rung score:');
1275
1508
  console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
1276
1509
 
@@ -1298,6 +1531,12 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1298
1531
  ...d.report,
1299
1532
  })),
1300
1533
  check,
1534
+ // Absent rather than null when the flag was not passed: `bonedist: null`
1535
+ // in a stored record would read as "measured, nothing to report", and
1536
+ // that is the opposite of "not measured".
1537
+ ...(boneDists.length === 0
1538
+ ? {}
1539
+ : { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
1301
1540
  });
1302
1541
  }
1303
1542
 
@@ -1307,6 +1546,39 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
1307
1546
  }
1308
1547
  }
1309
1548
 
1549
+ /**
1550
+ * bonedist — the ladder's stage 3, run on its own.
1551
+ *
1552
+ * ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
1553
+ * unlike `check`. Every convention behind every figure is printed above the
1554
+ * tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
1555
+ */
1556
+ function cmdBoneDist(flags: Record<string, string>): void {
1557
+ if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
1558
+ if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
1559
+ if (flags.bones === undefined) {
1560
+ throw new UsageError(
1561
+ `bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
1562
+ 'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
1563
+ );
1564
+ }
1565
+ const candidate = resolveArtifacts(flags.candidate, flags.atlas);
1566
+ const reference = resolveArtifacts(flags.reference, flags['reference-atlas']);
1567
+ const report = boneDistance({
1568
+ candidateSkeleton: candidate.skeletonPath,
1569
+ candidateAtlas: candidate.atlasPath,
1570
+ candidateAtlasDir: dirname(candidate.atlasPath),
1571
+ referenceSkeleton: reference.skeletonPath,
1572
+ referenceAtlas: reference.atlasPath,
1573
+ referenceAtlasDir: dirname(reference.atlasPath),
1574
+ bones: flags.bones,
1575
+ ...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
1576
+ });
1577
+ console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
1578
+ for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
1579
+ if (flags.json !== undefined) writeJson(flags.json, report);
1580
+ }
1581
+
1310
1582
  function cmdExplain(flags: Record<string, string>): void {
1311
1583
  const { label, opts } = resolveCut(flags);
1312
1584
  console.log(`rigc explain ${label}`);
@@ -1522,7 +1794,8 @@ function cmdExplain(flags: Record<string, string>): void {
1522
1794
  for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
1523
1795
  for (const m of result.meshes) {
1524
1796
  console.log(
1525
- ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1797
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1798
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1526
1799
  );
1527
1800
  }
1528
1801
  }
@@ -1555,12 +1828,29 @@ const FLAG_MEANINGS: Record<string, string> = {
1555
1828
  'copy-images':
1556
1829
  'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
1557
1830
  'self-contained enough to zip or commit on its own (default: page paths still point at the source art)',
1831
+ pack: 'arrange every part PNG onto shared atlas page(s) written into --out as real PNGs, instead of one page ' +
1832
+ 'per part. Lossless: every region is a byte-for-byte copy and nothing is resampled, trimmed or rotated ' +
1833
+ '(default: one part, one page, pointing at the source art)',
1834
+ 'page-size': `largest page edge, --pack only (default ${DEFAULT_PAGE_SIZE}); pages are powers of two and the ` +
1835
+ 'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
1836
+ padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
1837
+ "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
1838
+ 'atlas-in':
1839
+ 'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
1840
+ 'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
1558
1841
  cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
1559
1842
  cuts: 'the cuts.json --cut names',
1560
1843
  profile:
1561
1844
  'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
1562
1845
  "correctly; spine-html = also this project's renderer/archetype policy",
1563
1846
  atlas: "the candidate's atlas, when it is not beside the skeleton",
1847
+ reference: 'the reference skeleton to pose beside the candidate — a directory or a skeleton.json path',
1848
+ 'reference-atlas': "the reference's atlas, when it is not beside the reference skeleton",
1849
+ bones: `a bone correspondence — { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": "<reference bone>" }, ` +
1850
+ '"animations"?: { … } } — or `identity` to state that the two skeletons use the same names. An INPUT, never ' +
1851
+ 'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
1852
+ 'as a measurement',
1853
+ 'all-bones': 'print every bone pair, not just the worst by position',
1564
1854
  'texture-from':
1565
1855
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1566
1856
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -1600,7 +1890,13 @@ const FLAG_VALUES: Record<string, string> = {
1600
1890
  cuts: '<path>',
1601
1891
  profile: 'spine|spine-html',
1602
1892
  atlas: '<path>',
1893
+ 'atlas-in': '<file.atlas>',
1894
+ 'page-size': '<px>',
1895
+ padding: '<px>',
1603
1896
  'texture-from': '<path>',
1897
+ reference: '<dir|skeleton.json>',
1898
+ 'reference-atlas': '<path>',
1899
+ bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
1604
1900
  candidate: '<dir|skeleton.json>',
1605
1901
  frames: '<dir>',
1606
1902
  fps: '<n>',
@@ -1646,9 +1942,25 @@ const COMMANDS: CommandDoc[] = [
1646
1942
  name: 'build',
1647
1943
  usage: [
1648
1944
  'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
1945
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] (parts onto shared pages, written into --out)`,
1946
+ 'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1649
1947
  'rigc build --cut <name> --cuts <cuts.json>',
1650
1948
  ],
1651
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'copy-images', 'cut', 'cuts', 'profile'],
1949
+ flags: [
1950
+ 'rig',
1951
+ 'motion',
1952
+ 'out',
1953
+ 'manifest',
1954
+ 'images',
1955
+ 'copy-images',
1956
+ 'pack',
1957
+ 'page-size',
1958
+ 'padding',
1959
+ 'atlas-in',
1960
+ 'cut',
1961
+ 'cuts',
1962
+ 'profile',
1963
+ ],
1652
1964
  },
1653
1965
  {
1654
1966
  name: 'explain',
@@ -1676,7 +1988,25 @@ const COMMANDS: CommandDoc[] = [
1676
1988
  {
1677
1989
  name: 'bench',
1678
1990
  usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
1679
- flags: ['candidate', 'atlas', 'frames', 'profile', 'all-frames', 'json'],
1991
+ flags: ['candidate', 'atlas', 'frames', 'profile', 'bones', 'all-frames', 'all-bones', 'json'],
1992
+ overrides: {
1993
+ bones: {
1994
+ meaning:
1995
+ 'also run the stage-3 per-frame bone world-transform distance against each of the rung\'s reference ' +
1996
+ `skeletons, with this correspondence (or \`identity\`), at ${PROTOCOL_FPS} fps. Reports; gates nothing — ` +
1997
+ 'for another sampling rate call `rigc bonedist` directly, where --fps means only that',
1998
+ },
1999
+ },
2000
+ },
2001
+ {
2002
+ name: 'bonedist',
2003
+ usage: [
2004
+ `rigc bonedist --candidate <dir | skeleton.json> --reference <dir | skeleton.json> --bones <path | ${IDENTITY_CORRESPONDENCE}> [--fps ${PROTOCOL_FPS}] [--all-bones] [--json <out>]`,
2005
+ ],
2006
+ flags: ['candidate', 'atlas', 'reference', 'reference-atlas', 'bones', 'fps', 'all-bones', 'json'],
2007
+ overrides: {
2008
+ fps: { meaning: `the rate both skeletons are sampled at, from t=0 over their own durations (default ${PROTOCOL_FPS})` },
2009
+ },
1680
2010
  },
1681
2011
  {
1682
2012
  name: 'render',
@@ -1843,6 +2173,7 @@ try {
1843
2173
  else if (command === 'diff') cmdDiff(flags, positional);
1844
2174
  else if (command === 'check') cmdCheck(flags);
1845
2175
  else if (command === 'bench') cmdBench(flags, positional);
2176
+ else if (command === 'bonedist') cmdBoneDist(flags);
1846
2177
  else if (command === 'render') cmdRender(flags);
1847
2178
  else if (command === 'preview') cmdPreview(flags);
1848
2179
  else if (command === 'pose') cmdPose(flags);
@@ -1866,6 +2197,10 @@ try {
1866
2197
  console.error(`rigc check error: ${err.message}`);
1867
2198
  process.exit(1);
1868
2199
  }
2200
+ if (err instanceof BoneDistError) {
2201
+ console.error(`rigc bonedist error: ${err.message}`);
2202
+ process.exit(1);
2203
+ }
1869
2204
  // Like a usage error in kind — a missing directory, an unreadable frame — but
1870
2205
  // its messages name a path and a reason, and reprinting the whole usage under
1871
2206
  // them buries that.