spine-rigc 0.8.1 → 0.9.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
@@ -394,9 +394,9 @@ work on any frames you have, and `bench` is a repository workflow that needs a c
394
394
  and `bun run fetch-examples`. The reasoning behind all three is in
395
395
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
396
396
 
397
- `build` and `validate` both default to `--profile spine` — the 22 validity rules, which
397
+ `build` and `validate` both default to `--profile spine` — the 25 validity rules, which
398
398
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
399
- adds all 36: the other 14 are one renderer's policy and one canvas budget's, and they
399
+ adds all 39: the other 14 are one renderer's policy and one canvas budget's, and they
400
400
  fire on perfectly correct editor-produced Spine data, so reach for that profile when
401
401
  you are shipping into *that* project rather than to be thorough. A report always names
402
402
  the profile it ran and lists what that profile left out.
@@ -424,13 +424,22 @@ rigc is measured against **Spine's own official example projects** — the
424
424
  `1-weight-and-mass` … `8-follow-through` series as a difficulty ladder, with spineboy
425
425
  as the graduation exam.
426
426
 
427
- 🎓 **The ladder is complete, 2026-08-28.** All eight numbered rungs and the
428
- spineboy graduation exam are cleared under gate v2.1 and hold under **v2.2**, every clause PASS or SKIP:
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:
429
429
  worst attributable slot drift **5.55 px** against a 6.0 px bar, and **0 of 124**
430
430
  frame-change disagreements. Recompiling the same spec in a different session
431
431
  reproduced every field of the measurement record **to the digit**. The rungs stay
432
432
  in place as regression gates.
433
433
 
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**
437
+ 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*.
442
+
434
443
  ⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
435
444
  protocol reach the bar across a bounded series of honest attempts, each residual
436
445
  diagnosed and fixed** — spineboy took five, and the last inherited its
@@ -439,7 +448,7 @@ that an agent authors a spineboy-scale rig from the brief alone in one run: the
439
448
  ladder has not demonstrated that, and each row records which of the two it is.
440
449
 
441
450
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
442
- see, every rung, the run viewer, the 36 assertions and the selftest behind them — is
451
+ see, every rung, the run viewer, the 39 assertions and the selftest behind them — is
443
452
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
444
453
  Live rung status is
445
454
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -332,6 +332,26 @@ function runGate(result: CompileResult, opts: CompileOptions, profile: ValidateP
332
332
  return report.failures.length;
333
333
  }
334
334
 
335
+ /**
336
+ * One line per mesh kind on this cut, printed above the table.
337
+ *
338
+ * A legend rather than a heading: the heading used to describe the ring tier
339
+ * unconditionally, so a build whose only mesh was a ribbon or a contour got a
340
+ * sentence about a rim ring and a seam it does not have.
341
+ */
342
+ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> = {
343
+ ring: 'ring rim ring pinned on the window edge, seam ring pinned on the mask contour, aperture moves',
344
+ ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
345
+ contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
346
+ authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
347
+ };
348
+
349
+ /** What a contour mesh measured about its own fit, or nothing for the others. */
350
+ function meshFit(m: CompileResult['meshes'][number]): string {
351
+ if (m.coverage === undefined) return '';
352
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
353
+ }
354
+
335
355
  function cmdBuild(flags: Record<string, string>): void {
336
356
  const { label, opts } = resolveCut(flags);
337
357
  const profile = readProfile(flags);
@@ -358,8 +378,8 @@ function cmdBuild(flags: Record<string, string>): void {
358
378
  }
359
379
  for (const m of result.meshes) {
360
380
  console.log(
361
- ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles ` +
362
- `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]`,
381
+ ` 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)}`,
363
383
  );
364
384
  }
365
385
  for (const ph of result.physics) {
@@ -508,8 +528,10 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
508
528
  *
509
529
  * There is no pass mark, for the same reason `diff` has none.
510
530
  */
511
- function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing'> {
512
- const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing'> = {};
531
+ function readCheckFlags(
532
+ flags: Record<string, string>,
533
+ ): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom'> {
534
+ const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom'> = {};
513
535
  if (flags.framing !== undefined) {
514
536
  if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
515
537
  throw new UsageError('--framing takes per-shot (the default) or shared');
@@ -530,6 +552,16 @@ function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps'
530
552
  out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
531
553
  }
532
554
  if (flags.as !== undefined) out.as = flags.as;
555
+ if (flags['texture-from'] !== undefined) {
556
+ const path = resolve(flags['texture-from']);
557
+ if (!existsSync(path)) {
558
+ throw new UsageError(
559
+ `--texture-from ${path} is not a file. It takes the ATLAS the reference frames were rendered through — the ` +
560
+ "example's own .atlas — so check can measure how much of the MAE is texture resampling.",
561
+ );
562
+ }
563
+ out.textureFrom = { atlasText: readFileSync(path, 'utf8'), atlasDir: dirname(path), label: flags['texture-from'] };
564
+ }
533
565
  return out;
534
566
  }
535
567
 
@@ -1368,6 +1400,24 @@ function cmdExplain(flags: Record<string, string>): void {
1368
1400
  }
1369
1401
  }
1370
1402
  }
1403
+ // The other two constraint groups. These DO carry a timeline name under the
1404
+ // constraint (`path.<name>.position`), which is the physics shape rather
1405
+ // than the ik/transform one, so the name printed is both.
1406
+ for (const group of ['path', 'slider'] as const) {
1407
+ for (const [name, timelines] of Object.entries(anim[group] ?? {})) {
1408
+ for (const [timelineName, keys] of Object.entries(timelines)) {
1409
+ console.log(` ${group}.${name}.${timelineName} ${keys.length} key(s)`);
1410
+ for (const key of keys) {
1411
+ const fields = Object.entries(key)
1412
+ .filter(([k]) => k !== 'time' && k !== 'curve')
1413
+ .map(([k, v]) => `${k}=${String(v)}`)
1414
+ .join(' ');
1415
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1416
+ console.log(` t=${String(key.time).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
1417
+ }
1418
+ }
1419
+ }
1420
+ }
1371
1421
  // Deform timelines are keyed on a skin/slot/attachment triple, and the run
1372
1422
  // is printed as its span rather than its numbers: `offset` plus a length is
1373
1423
  // what tells a reader whether the key lands where they meant, and a hundred
@@ -1413,11 +1463,66 @@ function cmdExplain(flags: Record<string, string>): void {
1413
1463
  }
1414
1464
  }
1415
1465
 
1466
+ // Path constraints, with the curve each one follows MEASURED — its length and
1467
+ // its curve count are the two numbers an author cannot get from the spec, and
1468
+ // `position` means nothing without the first of them under `positionMode:
1469
+ // "percent"`. Read off the emitted attachment rather than recomputed here.
1470
+ const constraintsOf = (type: string) => (result.skeleton.constraints ?? []).filter((c) => c.type === type);
1471
+ const pathConstraints = constraintsOf('path');
1472
+ if (pathConstraints.length) {
1473
+ console.log('\npath constraints (position is a fraction of the measured length under positionMode "percent")');
1474
+ for (const c of pathConstraints) {
1475
+ const slot = String(c.slot);
1476
+ const attachments = result.skeleton.skins.flatMap((skin) => Object.values(skin.attachments[slot] ?? {}));
1477
+ const curve = attachments.find((att) => (att as { type?: string }).type === 'path') as
1478
+ | { lengths?: number[]; closed?: boolean; constantSpeed?: boolean }
1479
+ | undefined;
1480
+ const lengths = curve?.lengths ?? [];
1481
+ console.log(
1482
+ ` ${c.name.padEnd(12)} slot=${slot.padEnd(12)} bones=[${(c.bones as string[]).join(', ')}] ` +
1483
+ `position=${c.position ?? 0} ${String(c.positionMode ?? 'percent')}/${String(c.spacingMode ?? 'length')}/${String(c.rotateMode ?? 'tangent')}`,
1484
+ );
1485
+ console.log(
1486
+ ` ${''.padEnd(12)} curve: ${lengths.length} curve(s), ${lengths[lengths.length - 1] ?? 0} long, ` +
1487
+ `${curve?.closed ? 'closed' : 'open'}, constantSpeed=${curve?.constantSpeed ?? true}`,
1488
+ );
1489
+ }
1490
+ }
1491
+
1492
+ const sliders = constraintsOf('slider');
1493
+ if (sliders.length) {
1494
+ console.log('\nsliders (each applies one animation at a time it chooses)');
1495
+ for (const c of sliders) {
1496
+ const driver = c.bone === undefined ? `time=${c.time ?? 0} (keyed by slider.${c.name}.time)` : `bone=${String(c.bone)}.${String(c.property)}`;
1497
+ console.log(
1498
+ ` ${c.name.padEnd(12)} applies=${String(c.animation).padEnd(14)} ${driver} ` +
1499
+ `mix=${c.mix ?? 1} loop=${c.loop ?? false} additive=${c.additive ?? false}`,
1500
+ );
1501
+ }
1502
+ }
1503
+
1504
+ // Which bones and constraints a skin switches on. Printed because the pairing
1505
+ // with `skin: true` is invisible in the emitted file: a member list and a
1506
+ // skinRequired flag are two keys in two places, and only together do they mean
1507
+ // "this bone belongs to this skin".
1508
+ const skinMembers = result.skeleton.skins.filter((skin) => skin.bones?.length || skin.ik?.length || skin.transform?.length || skin.path?.length || skin.physics?.length || skin.slider?.length);
1509
+ if (skinMembers.length) {
1510
+ console.log('\nskin members (skinRequired bones and constraints, active only under their own skin)');
1511
+ for (const skin of skinMembers) {
1512
+ const lists = (['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const)
1513
+ .filter((key) => skin[key]?.length)
1514
+ .map((key) => `${key}=[${skin[key]!.join(', ')}]`)
1515
+ .join(' ');
1516
+ console.log(` ${skin.name.padEnd(12)} ${lists}`);
1517
+ }
1518
+ }
1519
+
1416
1520
  if (result.meshes.length) {
1417
- console.log('\nmeshes (ring tier: rim ring pinned on the window edge, seam ring pinned on the mask contour)');
1521
+ console.log('\nmeshes');
1522
+ for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
1418
1523
  for (const m of result.meshes) {
1419
1524
  console.log(
1420
- ` ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]`,
1525
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1421
1526
  );
1422
1527
  }
1423
1528
  }
@@ -1456,6 +1561,11 @@ const FLAG_MEANINGS: Record<string, string> = {
1456
1561
  'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
1457
1562
  "correctly; spine-html = also this project's renderer/archetype policy",
1458
1563
  atlas: "the candidate's atlas, when it is not beside the skeleton",
1564
+ 'texture-from':
1565
+ "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1566
+ 'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
1567
+ 'through. ⚠️ NOT --atlas: that one names the candidate\'s own atlas and loading a foreign one there re-seats ' +
1568
+ 'every region attachment on its packing, so a rotated or trimmed pack moves the geometry too',
1459
1569
  candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
1460
1570
  frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
1461
1571
  fps: 'frame rate, only for a frame set with no frames.json sidecar',
@@ -1490,6 +1600,7 @@ const FLAG_VALUES: Record<string, string> = {
1490
1600
  cuts: '<path>',
1491
1601
  profile: 'spine|spine-html',
1492
1602
  atlas: '<path>',
1603
+ 'texture-from': '<path>',
1493
1604
  candidate: '<dir|skeleton.json>',
1494
1605
  frames: '<dir>',
1495
1606
  fps: '<n>',
@@ -1560,7 +1671,7 @@ const COMMANDS: CommandDoc[] = [
1560
1671
  {
1561
1672
  name: 'check',
1562
1673
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
1563
- flags: ['candidate', 'frames', 'atlas', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
1674
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
1564
1675
  },
1565
1676
  {
1566
1677
  name: 'bench',