spine-rigc 0.17.0 → 0.18.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
@@ -48,13 +48,30 @@ it cuts both ways:
48
48
 
49
49
  - The output loads in any Spine runtime, and it **imports into the Spine editor**.
50
50
  A compiled rig is a starting point on a timeline, not a finished shot — **an AI
51
- drafts, a human refines in the editor**. rigc is complementary to the editor. It
52
- is not a replacement for it, and it is not a way around one.
51
+ drafts, a human refines in the editor**. That hand-off is what emitting somebody
52
+ else's format buys, and `tools/editor_roundtrip.ts` measures that it survives
53
+ the trip in both directions.
53
54
  - rigc **links `@esotericsoftware/spine-core`** to validate what it emits — the
54
55
  round-trip through the official parser is the only reason its output can be
55
56
  trusted at all. So the [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
56
57
  applies to rigc exactly as it applies to any other runtime integration.
57
58
 
59
+ 🗓️ **One sentence left this section on 2026-09-05, and its removal is a decision
60
+ rather than a tidy-up.** It read *"rigc is complementary to the editor. It is not
61
+ a replacement for it, and it is not a way around one."* That was a claim about
62
+ what rigc would never become, and
63
+ [#380](https://github.com/firejune/rigc/issues/380) — a format-agnostic core with
64
+ its own format and web player, Spine kept as one backend among others — is a
65
+ course the claim cannot survive. A promise the roadmap contradicts is worth less
66
+ than no promise, so it is retired rather than reworded
67
+ ([#398](https://github.com/firejune/rigc/issues/398)). What a reader can check is
68
+ unchanged: rigc emits Spine skeleton data, links `spine-core`, validates through
69
+ it with no flag that turns that off, and carries the licence obligation the next
70
+ section states in full. A backend emitting something other than Spine data would
71
+ have no spine-core to check it against, and #380 treats an oracle of its own as
72
+ the prerequisite for shipping one — nothing here makes an unchecked emit path
73
+ acceptable.
74
+
58
75
  ### Licensing, stated plainly
59
76
 
60
77
  rigc's own code is MIT (see [LICENSE](LICENSE)). That says nothing about Spine, and
@@ -488,9 +505,9 @@ work on any frames you have, and `bench` is a repository workflow that needs a c
488
505
  and `bun run fetch-examples`. The reasoning behind all three is in
489
506
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
490
507
 
491
- `build` and `validate` both default to `--profile spine` — the 25 validity rules, which
508
+ `build` and `validate` both default to `--profile spine` — the 26 validity rules, which
492
509
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
493
- adds all 40: the other 15 are one renderer's policy and one canvas budget's, and they
510
+ adds all 41: the other 15 are one renderer's policy and one canvas budget's, and they
494
511
  fire on perfectly correct editor-produced Spine data, so reach for that profile when
495
512
  you are shipping into *that* project rather than to be thorough. A report always names
496
513
  the profile it ran and lists what that profile left out.
@@ -501,6 +518,50 @@ relative to the `cuts.json` file itself, so the table lives with the project tha
501
518
  the art. Its shape is under
502
519
  [Usage](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md#usage).
503
520
 
521
+ ### The editor round trip — for a licence holder, never in CI
522
+
523
+ `tools/editor_roundtrip.ts` drives the loop the output's whole premise rests on:
524
+ build → **import into the Spine editor** → export back to JSON → gate, `diff`,
525
+ `render` and `check` the export against the build it came from.
526
+
527
+ ```
528
+ bun cli.ts build --rig … --motion … --out build/ --copy-images
529
+ bun tools/editor_roundtrip.ts --build build/ --editor /Applications/Spine.app/Contents/MacOS/Spine
530
+ ```
531
+
532
+ It prints the import and export exit codes, the validator's verdict on the
533
+ export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
534
+ animation, and a field-by-field list of what the editor rewrote. On its first
535
+ run it found three emitter defects — [#368](https://github.com/firejune/rigc/issues/368),
536
+ [#369](https://github.com/firejune/rigc/issues/369),
537
+ [#370](https://github.com/firejune/rigc/issues/370) — and then showed that a
538
+ human edit made in the editor survives the trip back.
539
+
540
+ 🔒 **It requires a licensed Spine editor on the machine, by construction**, and
541
+ drives only the [documented command line](https://esotericsoftware.com/spine-command-line-interface)
542
+ — never the UI, and it produces nothing the editor did not produce. With no
543
+ editor present it refuses by name and exits non-zero, and so does the **trial**:
544
+ the trial cannot save projects or export animation data, so the refusal names
545
+ what it found — the executable, the bundle, the `CFBundleName` that bundle
546
+ declares, or the banner the binary prints about itself — rather than starting it
547
+ and failing downstream. Both refusals point at `--exported <file>`, which
548
+ measures an export the editor already made and is the half of this tool that
549
+ needs no editor at all.
550
+
551
+ ⛔ **The round trip is not, and must not become, a selftest control.** `bun run
552
+ selftest` is self-contained and CI has no editor; a control that needed one would
553
+ report SKIP for ever, which is how a gate comes to look kept while checking
554
+ nothing. Run the round trip by hand, on a machine that has the editor. Its
555
+ **refusals** are gated, because they are the half a machine with no editor can
556
+ answer for: the suite points the tool at stubs in a temp directory and reads what
557
+ comes back, including the case that must *not* be refused — an editor at an
558
+ unfamiliar path, which is who `--editor` exists for.
559
+
560
+ ⚠️ Build with `--copy-images`. An ordinary build's atlas names its pages by a
561
+ relative path back to the art directory, and the round trip copies that atlas to
562
+ a directory at another depth — the tool refuses such a build by name rather than
563
+ letting `A17` blame the editor for the harness's own doing.
564
+
504
565
  ## Documentation
505
566
 
506
567
  | Document | For |
@@ -512,7 +573,7 @@ the art. Its shape is under
512
573
  | 📥 **[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 |
513
574
  | 🤖 **[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 |
514
575
  | 🔬 **[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 |
515
- | 🎓 **[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 40 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 |
576
+ | 🎓 **[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 41 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 |
516
577
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
517
578
 
518
579
  ## Why you can trust the output
@@ -567,7 +628,7 @@ quality."* All six, with their verdicts, are in
567
628
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
568
629
 
569
630
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
570
- see, every rung, the run viewer, the 40 assertions and the selftest behind them — is
631
+ see, every rung, the run viewer, the 41 assertions and the selftest behind them — is
571
632
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
572
633
  Live rung status is
573
634
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -61,8 +61,10 @@ import { compile, CompileError, type CompileOptions } from './src/compile.ts';
61
61
  import {
62
62
  skeletonDataFromText,
63
63
  surveyDeformKeys,
64
+ unreachableWhy,
64
65
  type DeformExtreme,
65
66
  type DeformKeyMeasure,
67
+ type DeformSpan,
66
68
  } from './src/deformmeasure.ts';
67
69
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
68
70
  import { copyAtlasImages } from './src/emit.ts';
@@ -112,9 +114,16 @@ import {
112
114
  type FramesSidecar,
113
115
  type FrameSet,
114
116
  } from './src/render.ts';
115
- import { CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
117
+ import {
118
+ assertionCountForProfile,
119
+ CLI_DEFAULT_PROFILE,
120
+ reportLines,
121
+ validate,
122
+ VALIDATE_PROFILES,
123
+ type ValidateProfile,
124
+ } from './src/validate.ts';
116
125
  import { parseMotionSpec } from './src/motion.ts';
117
- import type { FoldLimit, TurnCeiling } from './src/depth.ts';
126
+ import { depthStepLevels, type FoldLimit, type TurnCeiling } from './src/depth.ts';
118
127
  import type { CompileResult } from './src/types.ts';
119
128
 
120
129
  /**
@@ -431,6 +440,39 @@ function ceilingPair(axis: { positive: FoldLimit | null; negative: FoldLimit | n
431
440
  return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
432
441
  }
433
442
 
443
+ /**
444
+ * The same axis's two 1st percentiles, each with its ratio to the ceiling above
445
+ * it and the population it came out of — `+64.80° x1.003 of 5988`.
446
+ *
447
+ * ⭐ The ratio is the whole point and it is printed rather than judged. A
448
+ * ceiling set by the FORM is the floor of a band: the steepest region of a
449
+ * smooth sheet has area, so the 1st percentile sits a fraction of a percent
450
+ * above the minimum. A ceiling set by one bad texel has 99 % of the mesh
451
+ * surviving to the form's angle while the reported number collapses — 64.58°
452
+ * against 6.08° for one texel of 160,000, with the percentile unmoved at 64.80°
453
+ * in both ([#412](https://github.com/firejune/rigc/issues/412),
454
+ * `bench/studies/2026-09-05-noise` §6).
455
+ *
456
+ * Three spellings, three different claims, for the reason `ceilingPair` prints
457
+ * `none` rather than 90: `+none` is a side nothing folds on at all, `+unranked
458
+ * of 36` is a side whose population is too small for a first percentile to be
459
+ * anything but the minimum itself, and a number is a measurement.
460
+ *
461
+ * ⛔ No threshold lives here. What ratio means what is in `docs/AUTHORING.md`
462
+ * §3.4, because a number rigc printed an adjective beside would be a policy the
463
+ * compiler invented out of a measurement — and `A39` would go on refusing at the
464
+ * raw angle either way.
465
+ */
466
+ function spreadPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
467
+ const one = (l: FoldLimit | null, sign: string) =>
468
+ l === null
469
+ ? `${sign}none`
470
+ : l.p1 === null
471
+ ? `${sign}unranked of ${l.count}`
472
+ : `${sign}${l.p1.toFixed(2)}° x${(l.p1 / l.degrees).toFixed(3)} of ${l.count}`;
473
+ return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
474
+ }
475
+
434
476
  /** The tightest of the four, so the line that names a triangle names the right one. */
435
477
  function tightestFold(c: TurnCeiling): { kind: string; sign: string; limit: FoldLimit } | null {
436
478
  const all = [
@@ -453,11 +495,15 @@ function meshDepthNote(m: CompileResult['meshes'][number]): string {
453
495
  const c = m.depth.ceiling;
454
496
  parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`);
455
497
  const worst = tightestFold(c);
498
+ if (worst !== null) {
499
+ parts.push(` 1st pct yaw ${spreadPair(c.yaw)} pitch ${spreadPair(c.pitch)}`);
500
+ }
456
501
  parts.push(
457
502
  worst === null
458
503
  ? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it`
459
504
  : ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` +
460
- `triangle ${worst.limit.triangle} [${worst.limit.ids.join(',')}]` +
505
+ `triangle ${worst.limit.triangle} [${worst.limit.ids.join(',')}], the sheet steps ` +
506
+ `${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it` +
461
507
  `${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`,
462
508
  );
463
509
  }
@@ -641,11 +687,27 @@ function sameKeyTime(specTime: number, loaded: number): boolean {
641
687
  * - the reversal and collapse counts are the **survey's**, which is A39's own
642
688
  * survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
643
689
  * two readers, so the block and the gate cannot disagree about a fold;
690
+ * - 🔒 and so is **the frame each key was posed in** (issue #407), which every
691
+ * `DEFORM` line now names: `on a track`, or the slider that applies the
692
+ * animation and the dial value its own mapping had to be inverted to. The
693
+ * derivation moved and the report had to move with it — a block that went on
694
+ * printing the same figures under a changed meaning would be worse than the
695
+ * red it replaced;
644
696
  * - a key's model is the **compiler's** `transform` report (§4.11.1), so the
645
697
  * block names the same `kind` and parameters the spec stated;
646
698
  * - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
647
699
  * grid, and a second copy of it printed beside a ratio would be a number that
648
- * goes stale when somebody moves a column.
700
+ * goes stale when somebody moves a column;
701
+ * - and a key the gate read **no winding** off — because the slot draws no pixels
702
+ * of the mesh at that key's own time (issue #401) — says so on a `skipped` line
703
+ * with the survey's own sentence, and is kept out of the rollup's counts,
704
+ * because that line ends by claiming A39 reads the same two;
705
+ * - the **spans** between the keys are the survey's too (issue #403). A `BETWEEN`
706
+ * line appears wherever the closed form found a fold at a time no key lands
707
+ * on, whether the gate refuses it or passes it over because nothing is drawn
708
+ * there — and a `spans` line says how many were scanned even when nothing was
709
+ * found, because a scan that ran and found nothing has to be distinguishable
710
+ * from a scan that never ran.
649
711
  *
650
712
  * ## And what it deliberately does not print
651
713
  *
@@ -668,6 +730,8 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
668
730
  out.push(
669
731
  ' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
670
732
  ' .. denominator is 1.000 by definition and a NEGATIVE area ratio IS a reversed triangle',
733
+ ' .. the FRAME is on each key line: on a track, or the slider that applies the animation with the',
734
+ ' .. dial value its mapping was inverted to — a slider picks the time, so the key\'s time IS it',
671
735
  ' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
672
736
  ' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
673
737
  ' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
@@ -695,6 +759,54 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
695
759
  ` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
696
760
  `t=${key.time.toFixed(6)} ${states}`,
697
761
  );
762
+ // 🔒 The frame, on every key, because the derivation is shared with A39 and
763
+ // this block is where a reader finds out which one it was (issue #407). A
764
+ // track frame says so in three words; a slider frame names the dial value
765
+ // its own mapping inverts this time to, which is the number an author sets.
766
+ out.push(
767
+ ` frame ${key.reach.label}` +
768
+ (key.dial === null
769
+ ? ''
770
+ : `, dial ${key.dial.value.toFixed(6)}` +
771
+ (key.reach.local || key.dial.driven === key.dial.value
772
+ ? ''
773
+ : ` (bone local ${key.dial.driven.toFixed(6)})`) +
774
+ ` -> t=${key.dial.applied.toFixed(6)}`),
775
+ );
776
+ // A key at a time no dial selects: the figures below are the frame the
777
+ // runtime DOES land on, which is some other time's geometry, so the line
778
+ // that says so comes before them and the gate reads none of them.
779
+ if (key.dial?.unreachable === true) {
780
+ out.push(
781
+ ` unreachable A39 gates nothing here: ${unreachableWhy(key)}. Every figure below is that other ` +
782
+ "frame's, not this key's",
783
+ );
784
+ }
785
+ // ⚠️ An exemption nobody can see is how a gate comes to look kept while
786
+ // checking nothing (issue #401). A key the gate passed over because the mesh
787
+ // draws no pixels there says so on its own line, in the survey's own words,
788
+ // whether or not it folds.
789
+ if (key.draw.blank !== null) {
790
+ out.push(
791
+ ` skipped A39 reads no winding off this key: ${key.draw.blank} — a triangle that draws no ` +
792
+ 'pixels cannot draw them backwards',
793
+ );
794
+ }
795
+ // And when the slot shows something else, the figures below would be a
796
+ // second falsehood rather than a caveat: the runtime applies no deform to a
797
+ // slot that is not showing the mesh (`DeformTimeline.applyToSlot`), so every
798
+ // figure would be the identity and `moved 0` would read as "this key is the
799
+ // setup pose" — which is exactly what the key is NOT.
800
+ if (!key.draw.showsThisMesh) {
801
+ out.push(
802
+ ` .. the slot shows ${key.draw.shown === null ? 'no attachment' : `"${key.draw.shown}"`} ` +
803
+ 'here, so the runtime applied no deform and there is no posed geometry to measure' +
804
+ (key.draw.blank === null
805
+ ? ' — but the mesh IS drawn in another slot this deform reaches (timelineSlots), so nothing here is exempt'
806
+ : ''),
807
+ );
808
+ continue;
809
+ }
698
810
  // A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
699
811
  // no run is the format's own way of writing "back to the setup pose" (§4.11),
700
812
  // and its geometry is bit-identical to the cleared pose it would be measured
@@ -725,9 +837,11 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
725
837
  // A39 does not refuse it — it SKIPs the slot entirely.
726
838
  const exempted = exempt.has(key.slot);
727
839
  const fold = key.reversed.length
728
- ? exempted
729
- ? ' <- a fold, and A39 does not gate it see below'
730
- : ' <- a fold: A39 refuses this key by name'
840
+ ? key.draw.blank !== null
841
+ ? ' <- a fold, and nothing gates it: this key draws no pixels (see above)'
842
+ : exempted
843
+ ? ' <- a fold, and A39 does not gate it — see below'
844
+ : ' <- a fold: A39 refuses this key by name'
731
845
  : '';
732
846
  out.push(
733
847
  ` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
@@ -739,11 +853,53 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
739
853
  );
740
854
  }
741
855
  }
856
+ // The folds at times no key lands on (issue #403), printed after the keys they
857
+ // lie between rather than interleaved: they are a different measurement — the
858
+ // closed form named the time and the runtime was posed there — and a reader
859
+ // needs to be able to tell the two apart at a glance.
860
+ for (const span of survey.spans) {
861
+ if (span.fold === null) continue;
862
+ const at = span.fold;
863
+ out.push(
864
+ ` BETWEEN ${span.animation}${span.reach.kind === 'slider' ? ` via ${span.reach.slider}` : ''} ` +
865
+ `${span.skin}/${span.slot}/${span.placeholder} key ${span.fromKey} -> ` +
866
+ `${span.toKey} t=${at.time.toFixed(6)} ${span.curve}` +
867
+ (span.curve === 'stepped' ? ' (held, not interpolated)' : ` ${(at.percent * 100).toFixed(1)}% of the way`),
868
+ );
869
+ out.push(
870
+ ` winding ${at.measure.triangles - at.measure.reversed.length} of ${at.measure.triangles} kept, ` +
871
+ `${at.measure.collapsed} collapsed <- a fold at a time no key lands on` +
872
+ (at.measure.draw.blank !== null
873
+ ? ', and nothing gates it: nothing is drawn there'
874
+ : exempt.has(span.slot)
875
+ ? ', and A39 does not gate it (invariants.deformMayFold)'
876
+ : `: A39 refuses this span by name, at alpha ${at.measure.draw.alpha.toFixed(4)}`),
877
+ );
878
+ }
742
879
  // The rollup, per animation: the worst key by each quantity. A timeline's own
743
880
  // eight keys are eight blocks above, and "which of them is the one to look at"
744
881
  // is the question the sweep in issue #313's landing comment answered by hand.
745
- for (const animation of [...new Set(survey.keys.map((k) => k.animation))]) {
746
- const keys = survey.keys.filter((k) => k.animation === animation);
882
+ //
883
+ // ⚠️ Per animation AND per frame (issue #407). Two sliders applying one
884
+ // animation are two frames and two rollups: merging them would average a fold
885
+ // one dial reaches into a run of keys another one is clean over, which is the
886
+ // hiding the two frames exist to prevent.
887
+ const rollups = new Map<string, { animation: string; label: string }>();
888
+ for (const key of survey.keys) {
889
+ rollups.set(`${key.animation}${key.reach.slider ?? ''}`, {
890
+ animation: key.animation,
891
+ label: key.reach.kind === 'slider' ? `${key.animation} via ${key.reach.slider}` : key.animation,
892
+ });
893
+ }
894
+ for (const [id, { animation, label }] of rollups) {
895
+ // Only the keys the gate ran on, because the line ends by claiming A39 reads
896
+ // the same two counts and A39 reads none of a key that draws nothing, nor of
897
+ // one at a time no dial selects. The ones it left out get their own line
898
+ // rather than a silence (issues #401, #407).
899
+ const mine = survey.keys.filter((k) => `${k.animation}${k.reach.slider ?? ''}` === id);
900
+ const unreachable = mine.filter((k) => k.dial?.unreachable === true);
901
+ const keys = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank === null);
902
+ const blank = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
747
903
  const worst = (
748
904
  pick: (key: DeformKeyMeasure) => DeformExtreme | null,
749
905
  better: (a: number, b: number) => boolean,
@@ -759,19 +915,123 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
759
915
  const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
760
916
  const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
761
917
  const samples = keys.reduce((n, k) => n + k.triangles, 0);
918
+ if (keys.length) {
919
+ out.push(
920
+ ` WORST ${label} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
921
+ `stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
922
+ `squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
923
+ );
924
+ out.push(
925
+ ` .. ${''.padEnd(label.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
926
+ `${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
927
+ );
928
+ }
929
+ if (blank.length) {
930
+ out.push(
931
+ ` .. ${keys.length ? ''.padEnd(label.length) : label} ${blank.length} key(s) draw no pixels ` +
932
+ `at their own time and are read for no winding, carrying ` +
933
+ `${blank.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates <- A39 counts ` +
934
+ 'them as deformKeysNotDrawn',
935
+ );
936
+ }
937
+ if (unreachable.length) {
938
+ out.push(
939
+ ` .. ${keys.length || blank.length ? ''.padEnd(label.length) : label} ${unreachable.length} key(s) ` +
940
+ 'at a time no dial selects, measured in the frame the runtime lands on instead and read for no winding, ' +
941
+ `carrying ${unreachable.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates ` +
942
+ ' <- A39 counts them as deformKeysUnreachable',
943
+ );
944
+ }
945
+ // ⚠️ Printed on a clean animation too. "The scan ran and found nothing" and
946
+ // "the scan never ran" are the two things a gate must never say the same
947
+ // way, and this line is the only place an author can tell them apart
948
+ // (issue #403).
949
+ const spans = survey.spans.filter((s) => `${s.animation} ${s.reach.slider ?? ''}` === id);
950
+ if (spans.length) {
951
+ out.push(
952
+ ` .. ${keys.length || blank.length || unreachable.length ? ''.padEnd(label.length) : label} ` +
953
+ `${spans.length} span(s) between consecutive keys scanned for a fold no key lands on: ` +
954
+ `${spanTally(spans)} <- A39 reads the same scan`,
955
+ );
956
+ }
957
+ }
958
+ // ⚠️ And the spans that were NOT scanned, once, because a scan that did not
959
+ // run has to be distinguishable from one that ran and found nothing — the same
960
+ // rule the line above keeps, on the other side of it (issue #407).
961
+ if (survey.spansNotScanned) {
762
962
  out.push(
763
- ` WORST ${animation} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
764
- `stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
765
- `squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
766
- );
767
- out.push(
768
- ` .. ${''.padEnd(animation.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
769
- `${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
963
+ ` .. ${survey.spansNotScanned} span(s) NOT scanned: one of the two keys bounding each is at a time no ` +
964
+ 'dial selects, so the interpolation between them is between two poses of some other time',
770
965
  );
771
966
  }
772
967
  return out;
773
968
  }
774
969
 
970
+ /** What the between-keys scan found, in one clause (issue #403). */
971
+ function spanTally(spans: readonly DeformSpan[]): string {
972
+ const folds = spans.filter((s) => s.fold !== null).length;
973
+ const notDrawn = spans.filter((s) => s.notDrawn > 0).length;
974
+ const unconfirmed = spans.filter((s) => s.unconfirmed).length;
975
+ const probes = spans.reduce((n, s) => n + s.probed.length, 0);
976
+ if (folds === 0 && notDrawn === 0 && unconfirmed === 0) {
977
+ return `none folds (the closed form flagged nothing, so no span cost a posed measurement)`;
978
+ }
979
+ return (
980
+ [
981
+ folds ? `${folds} fold(s)` : '',
982
+ notDrawn ? `${notDrawn} folding only where nothing is drawn` : '',
983
+ unconfirmed ? `${unconfirmed} predicted a fold no probe reproduced` : '',
984
+ ]
985
+ .filter(Boolean)
986
+ .join(', ') + `, at a cost of ${probes} posed measurement(s)`
987
+ );
988
+ }
989
+
990
+ /**
991
+ * The header the `scale` rows carry, because the figure beside them lies without
992
+ * it.
993
+ *
994
+ * ⛔ Three things it has to say, and each one is a way the number is wrong if
995
+ * taken at face value:
996
+ * - it is the key's OWN factor. A nonuniform parent shears its children, so
997
+ * the drawn area is not this product;
998
+ * - a key that moved only one axis has no product to state, and gets none
999
+ * rather than an invented 1 on the other;
1000
+ * - a uniform scale has a product too, and it is a zoom rather than a squash.
1001
+ */
1002
+ const SCALE_PRODUCT_NOTE =
1003
+ '.. x·y is the key\'s own local area factor: ~1.00 is the volume kept, and it is a READING, never a rule — ' +
1004
+ 'a nonuniform parent shears this, and a uniform scale has a product without being a squash';
1005
+
1006
+ /**
1007
+ * `x·y` for a `scale` key that states both, and nothing otherwise.
1008
+ *
1009
+ * ⭐ Why it is here at all: `explain` ALREADY prints this reading for the other
1010
+ * spelling of squash and stretch. A `transform: affine` deform key reports
1011
+ * `area x1.020800`, which is exactly its own `0.88 × 1.16` — so the author who
1012
+ * reaches for the advanced spelling is told whether the volume held and the
1013
+ * author who reaches for the cheap one is not, while `docs/MOTION.md` §7 points
1014
+ * a first candidate at the cheap one on purpose. That asymmetry is the defect;
1015
+ * this is not a new kind of number (issue #377).
1016
+ *
1017
+ * 🔒 A reading and never an assertion. `deformReportLines` states the test a
1018
+ * geometric figure has to pass to become a gate — no legitimate counter-example
1019
+ * — and this fails it in quantity: a shadow, a zoom, a cartoon squash that
1020
+ * gains mass on purpose. Volume preservation is a style commitment no spec can
1021
+ * declare, so a bar here would be one consumer's house style failing correct
1022
+ * foreign data. There is no honest SKIP either: an absent declaration is not
1023
+ * "nothing to measure", it is "no way to know what was meant".
1024
+ */
1025
+ function scaleProduct(timelineName: string, key: Record<string, unknown>): string {
1026
+ if (timelineName !== 'scale') return '';
1027
+ const x = key.x;
1028
+ const y = key.y;
1029
+ // Both axes, or nothing: a key that moved one axis has no area factor, and
1030
+ // defaulting the other to 1 would invent the very number being reported.
1031
+ if (typeof x !== 'number' || typeof y !== 'number') return '';
1032
+ return ` x·y=${(x * y).toFixed(4)}`;
1033
+ }
1034
+
775
1035
  /**
776
1036
  * Read one non-negative integer flag, or its default.
777
1037
  *
@@ -2083,6 +2343,7 @@ function cmdExplain(flags: Record<string, string>): void {
2083
2343
  const drives = result.meshBones.includes(boneName) ? ' <- drives a mesh' : '';
2084
2344
  for (const [timelineName, keys] of Object.entries(timelines)) {
2085
2345
  console.log(` ${boneName}.${timelineName} ${keys.length} key(s)${drives}`);
2346
+ if (timelineName === 'scale') console.log(` ${SCALE_PRODUCT_NOTE}`);
2086
2347
  for (const key of keys) {
2087
2348
  const fields = Object.entries(key)
2088
2349
  .filter(([k]) => k !== 'time' && k !== 'curve')
@@ -2093,7 +2354,7 @@ function cmdExplain(flags: Record<string, string>): void {
2093
2354
  : key.curve === 'stepped'
2094
2355
  ? 'stepped'
2095
2356
  : 'linear';
2096
- console.log(` t=${String(key.time).padEnd(7)} ${fields.padEnd(30)} ${curve}`);
2357
+ console.log(` t=${String(key.time).padEnd(7)} ${fields.padEnd(30)} ${curve}${scaleProduct(timelineName, key)}`);
2097
2358
  }
2098
2359
  }
2099
2360
  }
@@ -2191,6 +2452,24 @@ function cmdExplain(flags: Record<string, string>): void {
2191
2452
  }
2192
2453
  console.log(` ${pairs.join(' ')}`);
2193
2454
  }
2455
+ // On a multi-influence attachment those pairs are the model's
2456
+ // WORLD displacements, and the file holds one `Mᵢ⁻¹·D` pair per
2457
+ // influence instead (issue #389). Printing the first without the
2458
+ // second would put numbers on the screen that are nowhere in the
2459
+ // artifact — the exact gap this block exists to close.
2460
+ if (gen.expanded !== undefined) {
2461
+ console.log(
2462
+ ` written as ${gen.expanded.length / 2} per-influence pair(s), each vertex's D through ` +
2463
+ 'its own bone inverse',
2464
+ );
2465
+ for (let i = 0; i < gen.expanded.length / 2; i += 4) {
2466
+ const pairs: string[] = [];
2467
+ for (let k = i; k < Math.min(i + 4, gen.expanded.length / 2); k++) {
2468
+ pairs.push(`i${String(k).padStart(3)} (${gen.expanded[2 * k]}, ${gen.expanded[2 * k + 1]})`);
2469
+ }
2470
+ console.log(` ${pairs.join(' ')}`);
2471
+ }
2472
+ }
2194
2473
  }
2195
2474
  }
2196
2475
  }
@@ -2675,10 +2954,11 @@ const USAGE = [
2675
2954
  '',
2676
2955
  'build, validate and bench take --profile spine|spine-html:',
2677
2956
  ' spine is this valid Spine 4.3 that any runtime plays correctly?',
2678
- ' THE DEFAULT — 20 rules, and the question the output answers when',
2957
+ ` THE DEFAULT — ${assertionCountForProfile('spine')} rules, and the question the output answers when`,
2679
2958
  ' you import it into the Spine editor.',
2680
2959
  ' spine-html the above, plus this project\'s renderer and archetype policy:',
2681
- ' all 36 rules, opt-in. Those extra 14 fire on real, correct,',
2960
+ ` all ${assertionCountForProfile('spine-html')} rules, opt-in. Those extra ` +
2961
+ `${assertionCountForProfile('spine-html') - assertionCountForProfile('spine')} fire on real, correct,`,
2682
2962
  ' editor-produced Spine data, so they are somebody\'s policy rather',
2683
2963
  ' than anybody\'s validity.',
2684
2964
  '',