spine-rigc 0.13.0 → 0.14.1

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
@@ -386,7 +386,7 @@ limits: [AUTHORING.md §11](docs/AUTHORING.md). The parts it refuses because
386
386
  something is drawn over them are `rigc chainfit`'s, once a candidate exists —
387
387
  [§12](docs/AUTHORING.md).
388
388
 
389
- ## The gallery — four complete rigs over art that ships with them
389
+ ## The gallery — five complete rigs over art that ships with them
390
390
 
391
391
  Each directory in [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) is
392
392
  one rig spec, one motion spec and the PNGs they name, small enough to read in one
@@ -398,9 +398,20 @@ was verified, and what writing it cost. Repository material: a clone and
398
398
  | Example | Stars | What it is |
399
399
  | --- | --- | --- |
400
400
  | [`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 |
401
- | [`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 |
401
+ | [`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 the keys state rather than tabulate |
402
402
  | [`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` |
403
403
  | [`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 |
404
+ | [`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait) | **deform `transform`** + `derive` group tracks | A 2.5D head turn: two meshes and six feature bones all keyed from one stated expression, `dx = x(cos t − 1) − z·sin t`, with the depths in the spec rather than a README |
405
+
406
+ <p align="center">
407
+ <img src="https://raw.githubusercontent.com/firejune/rigc/main/assets/rigc-scene.gif" alt="A portrait rig breathing, glancing aside, then turning its head in 2.5D — hair and features sliding at different depths" width="600" />
408
+ </p>
409
+
410
+ <p align="center"><em>The portrait rig playing its three animations in one take — the turn is
411
+ the shot: both silhouette edges move apart, which a flat slide cannot do, because every
412
+ feature carries its own depth. Scene direction of this kind was Live2D's territory; the
413
+ split was authoring cost, and the cost is now one stated expression per key. Compiled and
414
+ rendered entirely by the published package.</em></p>
404
415
 
405
416
  ## Commands
406
417
 
@@ -419,7 +430,7 @@ commands take it and what its default is.
419
430
  | `preview --candidate <dir>` | one self-contained `.html` that plays it |
420
431
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
421
432
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
422
- | `chainfit --candidate <dir> --images <dir> --frame <png>` | reads the parts `pose` refuses, through the candidate's own draw order and hierarchy: masked residuals over **visible** pixels, one hinge per child instead of four degrees of freedom, and the `rotate` key value each answer implies |
433
+ | `chainfit --candidate <dir> --images <dir> --frame <png>` | reads the parts `pose` refuses, through the candidate's own draw order and hierarchy: masked residuals over **visible** pixels, one hinge per child instead of four degrees of freedom, and the `rotate` key value each answer implies. A bone with two or more anchored descendants is **determined** rather than searched, and the residual that over-determination leaves is reported |
423
434
  | `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
424
435
  | `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
425
436
  | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
@@ -447,6 +458,7 @@ the art. Its shape is under
447
458
  | Document | For |
448
459
  | --- | --- |
449
460
  | 📘 **[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` |
461
+ | 🦴 **[docs/RIGGING.md](docs/RIGGING.md)** | **authoring the hierarchy.** Where a bone goes and why the art is pushed out on an offset, why a pivot in the wrong place looks like a search failure and what identifies one, moving a pivot and the child row that gets forgotten, gauges, siblings-not-a-chain, what a chain can reach and how many links it needs, why a local key is not a world key, duplicate art at mirrored pivots, and constraints as structure. Every section is a stumble the run records hold more than once, ranked by how often. Ships in the package too |
450
462
  | 🎞️ **[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 |
451
463
  | 🙂 **[docs/FACE.md](docs/FACE.md)** | **authoring a face.** A blink, a gaze and a 2.5D head turn on plain Spine data: the one line of yaw arithmetic every number in a turn comes from, depth as the parameter you are actually authoring, where to put a grid's columns and the angle at which any grid folds, what foreshortens and what does not, channel allocation before the first key, and the three cliffs with their angles. Also the deform audit gap, demonstrated — a folded mesh gates green — and the differential check that works today |
452
464
  | 📥 **[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 |
@@ -491,7 +503,7 @@ that an agent authors a spineboy-scale rig from the brief alone in one run: the
491
503
  ladder has not demonstrated that, and each row records which of the two it is.
492
504
 
493
505
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
494
- see, every rung, the run viewer, the 39 assertions and the selftest behind them — is
506
+ see, every rung, the run viewer, the 40 assertions and the selftest behind them — is
495
507
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
496
508
  Live rung status is
497
509
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -58,10 +58,17 @@ import {
58
58
  } from './src/bonedist.ts';
59
59
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
60
60
  import { compile, CompileError, type CompileOptions } from './src/compile.ts';
61
+ import {
62
+ skeletonDataFromText,
63
+ surveyDeformKeys,
64
+ type DeformExtreme,
65
+ type DeformKeyMeasure,
66
+ } from './src/deformmeasure.ts';
61
67
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
62
68
  import { copyAtlasImages } from './src/emit.ts';
63
69
  import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
64
70
  import { parseJsonWithPosition } from './src/json-position.ts';
71
+ import { KEY_TIME_EPSILON } from './src/timelines.ts';
65
72
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
66
73
  import {
67
74
  DEFAULT_MAX_RESIDUAL,
@@ -79,6 +86,7 @@ import {
79
86
  ChainFitError,
80
87
  DEFAULT_HINGE_MAX,
81
88
  DEFAULT_HINGE_MIN,
89
+ DEFAULT_MIN_LEVER_PX,
82
90
  DEFAULT_MIN_VISIBLE,
83
91
  DEFAULT_PASSES,
84
92
  estimateChainFit,
@@ -105,7 +113,8 @@ import {
105
113
  type FrameSet,
106
114
  } from './src/render.ts';
107
115
  import { CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
108
- import type { CompileResult, MotionSpec } from './src/types.ts';
116
+ import { parseMotionSpec } from './src/motion.ts';
117
+ import type { CompileResult } from './src/types.ts';
109
118
 
110
119
  /**
111
120
  * One entry of a cuts.json, every path relative to the cuts.json file.
@@ -172,8 +181,19 @@ function repositoryUrl(): string {
172
181
  * Listed by name rather than inferred from "the next argument looks like a
173
182
  * flag": inferring it would turn `--out --json report.json` — a real typo, a
174
183
  * missing value — into a silently accepted switch plus a stray positional.
184
+ *
185
+ * ⚠️ This set and `FLAG_VALUES` are two halves of one statement, and they are
186
+ * the halves a reader and the parser read separately: a flag absent from
187
+ * `FLAG_VALUES` is printed bare in every usage line and flag table, and a flag
188
+ * present here is the only kind the parser will accept bare. `all-bones` was in
189
+ * one half and not the other for two releases — documented bare in `bonedist`'s
190
+ * usage line, in the shared flag table, and in the hint `src/bonedist.ts` prints
191
+ * under a truncated bone table, while the parser fell through to the value
192
+ * branch and answered the caller who followed that hint with `rigc: --all-bones
193
+ * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
194
+ * halves together by reading `--help` rather than by naming a flag.
175
195
  */
176
- const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again', 'pack']);
196
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack']);
177
197
 
178
198
  /**
179
199
  * The flags a command is allowed to spell more than once.
@@ -431,6 +451,264 @@ function meshBudget(rig: CompileResult['rig']): string {
431
451
  return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
432
452
  }
433
453
 
454
+ /**
455
+ * One extreme, as `x0.637306 tri 0`, or an em dash when no triangle on the key
456
+ * could carry the quantity.
457
+ *
458
+ * A dash rather than `x1.000000`: a key over a mesh whose every triangle is a
459
+ * hair has no ratio and no map, and printing the identity there would report a
460
+ * measurement that was never taken — this repository's favourite false green.
461
+ */
462
+ function deformExtreme(extreme: DeformExtreme | null): string {
463
+ return extreme === null ? '—'.padEnd(9) : `x${extreme.value.toFixed(6)} tri ${extreme.triangle}`;
464
+ }
465
+
466
+ /**
467
+ * The `MEMBER` report block — a group track's per-member values, side by side
468
+ * (issue #295).
469
+ *
470
+ * ## Why side by side is the whole point
471
+ *
472
+ * The complaint that filed #295 was not the line count. `gallery/portrait`'s
473
+ * held yaw put six sibling bones' `translatex` in six separate tracks, and the
474
+ * reason that is bad is that **nobody can see a wrong sign in a column that is
475
+ * eighty lines from its neighbours.** FACE §3 makes the same argument from the
476
+ * other side: a residual is 1–6 units where a total is 30–40, and the split is
477
+ * *an auditing decision before it is a rigging one*. So the report's job is to
478
+ * put the numbers in the arrangement the audit needs — one row per member, one
479
+ * block per key — which is exactly the arrangement the emitted format cannot
480
+ * have, because Spine keys one bone per timeline.
481
+ *
482
+ * ## It quotes; it does not re-derive
483
+ *
484
+ * The same rule as the `DEFORM` block. Every value here is the one the compiler
485
+ * **emitted**, carried on `result.trackDerivations`, so the block and the
486
+ * artifact cannot disagree. `derived` and `formula` are the model's own strings
487
+ * from `src/trackgen.ts`, so the block names the closed form the spec stated
488
+ * rather than a second reading of it.
489
+ *
490
+ * ## What it deliberately does not print
491
+ *
492
+ * **Tracks whose members all share one value** — the ordinary `groups` entry.
493
+ * There is one number there and the timelines above already show it on every
494
+ * member; a table of six identical rows would be a tautology, and the block
495
+ * exists to make a *difference* visible. `look_l`/`look_r` in the worked example
496
+ * are exactly that case and they are right to be absent from here.
497
+ *
498
+ * **`stagger`.** A per-member time offset is printed as it always was — on each
499
+ * member's own timeline, where the shifted key times are. Repeating it here
500
+ * would put one lag in two places.
501
+ */
502
+ function memberReportLines(result: CompileResult): string[] {
503
+ if (result.trackDerivations.length === 0) return [];
504
+ const out: string[] = [
505
+ '',
506
+ 'group members (the per-member values of one track, side by side — issue #295)',
507
+ ' .. a row per member and a block per key, because a wrong sign is visible in a column of six and',
508
+ ' .. invisible in six tracks. Values are the EMITTED ones, so this and the artifact cannot disagree',
509
+ ' .. a group whose members all share one value is not here: there is one number and the timelines',
510
+ ' .. above already carry it. `stagger` is not here either — the shifted key times are on those timelines',
511
+ ];
512
+ for (const entry of result.trackDerivations) {
513
+ const states =
514
+ entry.model === null
515
+ ? 'stated per member'
516
+ : `derive ${entry.model.kind} ${entry.model.stated} -> ${entry.model.projection === 'shift' ? 'the displacement' : 'the narrowing'}`;
517
+ out.push(
518
+ ` MEMBER ${entry.animation} ${entry.targetKind} "${entry.target}".${entry.property} ` +
519
+ `t=${entry.time.toFixed(6)} ${entry.members.length} member(s) ${states}`,
520
+ );
521
+ if (entry.model !== null) {
522
+ out.push(` ${entry.model.formula}`);
523
+ for (const line of entry.model.derived) out.push(` ${line}`);
524
+ }
525
+ const width = Math.max(6, ...entry.members.map((m) => m.member.length));
526
+ for (let i = 0; i < entry.members.length; i++) {
527
+ const m = entry.members[i];
528
+ const value = Array.isArray(m.value) ? m.value.join(', ') : JSON.stringify(m.value);
529
+ // The model's own row carries the two inputs that produced the value — the
530
+ // coordinate it read off the rig and the depth the spec stated — because
531
+ // "5.513" alone is a number a reader can only take on trust, and `−62` and
532
+ // `150` beside it are a claim they can check.
533
+ const from = entry.model === null ? '' : ` <- ${entry.model.members[i].at >= 0 ? ' ' : ''}${entry.model.members[i].at} at depth ${entry.model.members[i].depth}`;
534
+ out.push(` ${m.member.padEnd(width)} ${value.padStart(12)}${from}`);
535
+ }
536
+ }
537
+ return out;
538
+ }
539
+
540
+ /** `head/head key 1`, which is how A39's own message names a key. */
541
+ function deformKeyName(key: DeformKeyMeasure): string {
542
+ return `${key.slot}/${key.attachment} key ${key.key}`;
543
+ }
544
+
545
+ /**
546
+ * Does this compiled `transform` report belong to this loaded key?
547
+ *
548
+ * ⚠️ The two times are not the same number and cannot be compared with `===`.
549
+ * The report's is the spec's own `t`; the survey's came back through
550
+ * `Float32Array`, because that is what `spine-core` reads a timeline's frames
551
+ * into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
552
+ * is the compiler's own key-time grid plus one float32 ulp at this magnitude,
553
+ * which is narrower than any key spacing the format can hold and wide enough for
554
+ * both roundings.
555
+ */
556
+ function sameKeyTime(specTime: number, loaded: number): boolean {
557
+ return Math.abs(specTime - loaded) <= KEY_TIME_EPSILON + Math.abs(loaded) * 2 ** -23;
558
+ }
559
+
560
+ /**
561
+ * The `DEFORM` report block — what each deform key does to the geometry, per key
562
+ * and then per animation (issue #316).
563
+ *
564
+ * ## Why this is a report and not an assertion
565
+ *
566
+ * Because a 3× stretch is a real thing to author, for the same reason issue #277
567
+ * settled mesh coverage as a printed figure on authored geometry rather than a
568
+ * bar. The one deformed-geometry fault that has no legitimate counter-example is
569
+ * the fold, and that one already IS an assertion —
570
+ * `A39_DEFORM_KEEPS_TRIANGLE_WINDING`. What this block adds is **the approach to
571
+ * that wall**: FACE §4.2's table of ratios down to the fold at 31.37° was
572
+ * measured by rendering seven variants of `gallery/portrait` and looking at them,
573
+ * and `0.637` was a number an author derived from the closed form rather than one
574
+ * the tool printed.
575
+ *
576
+ * ## It quotes; it does not re-derive
577
+ *
578
+ * - the reversal and collapse counts are the **survey's**, which is A39's own
579
+ * survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
580
+ * two readers, so the block and the gate cannot disagree about a fold;
581
+ * - a key's model is the **compiler's** `transform` report (§4.11.1), so the
582
+ * block names the same `kind` and parameters the spec stated;
583
+ * - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
584
+ * grid, and a second copy of it printed beside a ratio would be a number that
585
+ * goes stale when somebody moves a column.
586
+ *
587
+ * ## And what it deliberately does not print
588
+ *
589
+ * **Deformed coverage**, which #296 asked for. The coverage figure is rasterised
590
+ * from the attachment's **uvs** against the part's alpha, and a deform moves
591
+ * positions and never uvs — so it is identical at every key by construction, and
592
+ * a `coverage 100.00% (setup 100.00%)` line would be a tautology wearing a
593
+ * measurement's clothes. The header line says so and points at `meshes`, because
594
+ * an author who came here asking whether their deform broke the coverage
595
+ * deserves the answer rather than a silence. What does move is the stretch.
596
+ */
597
+ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>): string[] {
598
+ const survey = surveyDeformKeys(skeletonDataFromText(result.skeletonText, result.atlasText));
599
+ if (survey.timelines === 0) return [];
600
+ const out: string[] = ['', 'deform (what each key does to the geometry — figures with names, never a bar; issue #316)'];
601
+ // The legend costs six lines and is worth them exactly once — on a report that
602
+ // has figures in it. A bounding box or a clipping polygon deformed and nothing
603
+ // else gets the reason it has no figures and no essay about them.
604
+ if (survey.keys.length) {
605
+ out.push(
606
+ ' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
607
+ ' .. denominator is 1.000 by definition and a NEGATIVE area ratio IS a reversed triangle',
608
+ ' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
609
+ ' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
610
+ ' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
611
+ ' .. the `meshes` line below is already the deformed one',
612
+ );
613
+ }
614
+ if (survey.notAMesh.length) {
615
+ out.push(` .. ${survey.notAMesh.join(', ')} deform an attachment with no triangles — nothing to measure`);
616
+ }
617
+ for (const key of survey.keys) {
618
+ const model = result.deformTransforms.find(
619
+ (g) =>
620
+ g.animation === key.animation &&
621
+ g.skin === key.skin &&
622
+ g.slot === key.slot &&
623
+ g.attachment === key.placeholder &&
624
+ sameKeyTime(g.time, key.time),
625
+ );
626
+ // A stated model is quoted rather than reduced to its results: `yaw
627
+ // radius=170 degrees=12` is what a reviewer checks the ratios against, and an
628
+ // authored table says so instead of saying nothing, because "no model here"
629
+ // is itself the thing a reader of a wrong ratio needs to know.
630
+ const states = model === undefined ? 'authored table' : `transform ${model.kind} ${model.stated}`;
631
+ out.push(
632
+ ` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
633
+ `t=${key.time.toFixed(6)} ${states}`,
634
+ );
635
+ // A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
636
+ // no run is the format's own way of writing "back to the setup pose" (§4.11),
637
+ // and its geometry is bit-identical to the cleared pose it would be measured
638
+ // against — so `x1.000000` there is the definition and not a measurement, and
639
+ // four lines of it on every loop's opening and closing key is the noise that
640
+ // stops the block being read. It is still counted in the rollup below,
641
+ // because A39 measures it too.
642
+ if (key.moved === 0) {
643
+ out.push(
644
+ ` moved 0 of ${key.vertices} vertices — this key IS the setup pose, so every ` +
645
+ `figure is the identity (${key.triangles} triangles, all kept)`,
646
+ );
647
+ continue;
648
+ }
649
+ out.push(
650
+ ` moved ${key.moved} of ${key.vertices} vertices, ` +
651
+ `worst ${key.maxDisplacement.toFixed(4)}px at v${key.maxDisplacementVertex}`,
652
+ );
653
+ out.push(
654
+ ` area min ${deformExtreme(key.areaRatioMin)} max ${deformExtreme(key.areaRatioMax)} ` +
655
+ `(${key.triangles} triangles, ${key.degenerate} with no area at the cleared pose, band ${key.band.toFixed(6)}px²)`,
656
+ );
657
+ out.push(
658
+ ` stretch max ${deformExtreme(key.stretchMax)} min ${deformExtreme(key.stretchMin)}`,
659
+ );
660
+ // The marker has to know about the exemption, or it says the false half of
661
+ // the truth on the one build where it matters: a declared fold IS a fold and
662
+ // A39 does not refuse it — it SKIPs the slot entirely.
663
+ const exempted = exempt.has(key.slot);
664
+ const fold = key.reversed.length
665
+ ? exempted
666
+ ? ' <- a fold, and A39 does not gate it — see below'
667
+ : ' <- a fold: A39 refuses this key by name'
668
+ : '';
669
+ out.push(
670
+ ` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
671
+ `${key.collapsed} collapsed${fold}`,
672
+ );
673
+ if (exempted) {
674
+ out.push(
675
+ ` .. A39 is exempt on "${key.slot}" (invariants.deformMayFold), so nothing here is gated`,
676
+ );
677
+ }
678
+ }
679
+ // The rollup, per animation: the worst key by each quantity. A timeline's own
680
+ // eight keys are eight blocks above, and "which of them is the one to look at"
681
+ // is the question the sweep in issue #313's landing comment answered by hand.
682
+ for (const animation of [...new Set(survey.keys.map((k) => k.animation))]) {
683
+ const keys = survey.keys.filter((k) => k.animation === animation);
684
+ const worst = (
685
+ pick: (key: DeformKeyMeasure) => DeformExtreme | null,
686
+ better: (a: number, b: number) => boolean,
687
+ ): string => {
688
+ let best: { key: DeformKeyMeasure; extreme: DeformExtreme } | null = null;
689
+ for (const key of keys) {
690
+ const extreme = pick(key);
691
+ if (extreme === null) continue;
692
+ if (best === null || better(extreme.value, best.extreme.value)) best = { key, extreme };
693
+ }
694
+ return best === null ? '—' : `x${best.extreme.value.toFixed(6)} (${deformKeyName(best.key)} tri ${best.extreme.triangle})`;
695
+ };
696
+ const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
697
+ const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
698
+ const samples = keys.reduce((n, k) => n + k.triangles, 0);
699
+ out.push(
700
+ ` WORST ${animation} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
701
+ `stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
702
+ `squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
703
+ );
704
+ out.push(
705
+ ` .. ${''.padEnd(animation.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
706
+ `${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
707
+ );
708
+ }
709
+ return out;
710
+ }
711
+
434
712
  /**
435
713
  * Read one non-negative integer flag, or its default.
436
714
  *
@@ -1141,6 +1419,11 @@ function cmdChainFit(flags: Record<string, string>): void {
1141
1419
  if (!Number.isInteger(value) || value < 1 || value > 8) throw new UsageError('--passes must be a whole number in 1..8');
1142
1420
  options.passes = value;
1143
1421
  }
1422
+ if (flags['inward-lever'] !== undefined) {
1423
+ const value = Number(flags['inward-lever']);
1424
+ if (!Number.isFinite(value) || value < 0) throw new UsageError('--inward-lever must be a number of frame pixels, 0 or more');
1425
+ options.minLeverPx = value;
1426
+ }
1144
1427
  if (flags['anchor-residual'] !== undefined) {
1145
1428
  const value = Number(flags['anchor-residual']);
1146
1429
  if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--anchor-residual must be a number in (0, 1]');
@@ -1693,7 +1976,10 @@ function cmdExplain(flags: Record<string, string>): void {
1693
1976
  console.log(` .. rig ${opts.rigPath}`);
1694
1977
  console.log(` .. motion ${opts.motionPath}`);
1695
1978
  const result = compile(opts);
1696
- const motion = readJsonFile(opts.motionPath) as MotionSpec;
1979
+ // `compile` has already parsed this file, so the read below cannot fail — but
1980
+ // it goes through the same parser rather than a cast, because the cast was the
1981
+ // last one in the repository and issue #307 was about exactly that.
1982
+ const motion = parseMotionSpec(readJsonFile(opts.motionPath), opts.motionPath);
1697
1983
 
1698
1984
  console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
1699
1985
 
@@ -1813,6 +2099,30 @@ function cmdExplain(flags: Record<string, string>): void {
1813
2099
  : 'back to the setup pose';
1814
2100
  const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1815
2101
  console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
2102
+ // A generated key prints its MODEL and then every offset the model
2103
+ // produced (issue #294). Both halves are the point: the model is
2104
+ // what a reviewer checks a claim against, and the offsets are what
2105
+ // reaches the file — printing only the first would ask a reader to
2106
+ // trust an evaluation they cannot see, which is the gap FACE §9.3
2107
+ // records. The numbers are the emitted ones, not a second
2108
+ // evaluation, so this block and the artifact cannot disagree.
2109
+ const gen = result.deformTransforms.find(
2110
+ (g) => g.animation === animName && g.skin === skinName && g.slot === slotName && g.attachment === attName && g.time === key.time,
2111
+ );
2112
+ if (gen === undefined) continue;
2113
+ console.log(` transform ${gen.kind} ${gen.stated}`);
2114
+ console.log(` ${gen.formula}`);
2115
+ for (const line of gen.derived) console.log(` ${line}`);
2116
+ console.log(
2117
+ ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
2118
+ );
2119
+ for (let v = 0; v < gen.vertexCount; v += 4) {
2120
+ const pairs: string[] = [];
2121
+ for (let k = v; k < Math.min(v + 4, gen.vertexCount); k++) {
2122
+ pairs.push(`v${String(k).padStart(3)} (${gen.offsets[2 * k]}, ${gen.offsets[2 * k + 1]})`);
2123
+ }
2124
+ console.log(` ${pairs.join(' ')}`);
2125
+ }
1816
2126
  }
1817
2127
  }
1818
2128
  }
@@ -1834,6 +2144,16 @@ function cmdExplain(flags: Record<string, string>): void {
1834
2144
  }
1835
2145
  }
1836
2146
 
2147
+ // The `MEMBER` block sits beside the `DEFORM` one and for the same reason:
2148
+ // both re-print timelines the reader has just read, in the arrangement the
2149
+ // question needs rather than the one the format has.
2150
+ for (const line of memberReportLines(result)) console.log(line);
2151
+
2152
+ // The `DEFORM` block goes after the timelines and before the constraints,
2153
+ // because it is a measurement OF the deform timelines printed above — the keys
2154
+ // it names are the keys the reader has just read, by the same index.
2155
+ for (const line of deformReportLines(result, new Set(result.rig.deformMayFold))) console.log(line);
2156
+
1837
2157
  if (result.physics.length) {
1838
2158
  console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
1839
2159
  for (const ph of result.physics) {
@@ -1997,6 +2317,10 @@ const FLAG_MEANINGS: Record<string, string> = {
1997
2317
  'anchor-residual':
1998
2318
  `the residual a \`pose\` placement must be within to anchor a chain (default ${ANCHOR_MAX_RESIDUAL}, with ` +
1999
2319
  `unexplained ≤ ${ANCHOR_MAX_UNEXPLAINED} and unambiguous — the 2026-09-03 measurement run's own clean-frame criterion)`,
2320
+ 'inward-lever':
2321
+ `how far apart, in frame pixels, two anchored descendants have to sit before the rotation they determine is ` +
2322
+ `printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
2323
+ 'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
2000
2324
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
2001
2325
  max: 'longest side of a rendered frame, in pixels (default 256)',
2002
2326
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
@@ -2041,6 +2365,7 @@ const FLAG_VALUES: Record<string, string> = {
2041
2365
  'min-visible': '<0..1>',
2042
2366
  passes: '<n>',
2043
2367
  'anchor-residual': '<0..1>',
2368
+ 'inward-lever': '<px>',
2044
2369
  animation: '<name>',
2045
2370
  max: '<px>',
2046
2371
  record: '<result.json>',
@@ -2193,6 +2518,7 @@ const COMMANDS: CommandDoc[] = [
2193
2518
  'max-residual',
2194
2519
  'passes',
2195
2520
  'anchor-residual',
2521
+ 'inward-lever',
2196
2522
  'scale',
2197
2523
  'rotation',
2198
2524
  'out',