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 +67 -6
- package/cli.ts +299 -19
- package/docs/AUTHORING.md +374 -20
- package/docs/FACE.md +124 -1
- package/docs/MOTION.md +52 -0
- package/package.json +2 -1
- package/skills/motion/SKILL.md +1 -1
- package/src/compile.ts +260 -40
- package/src/deformgen.ts +18 -6
- package/src/deformmeasure.ts +1469 -138
- package/src/depth.ts +126 -9
- package/src/transform.ts +24 -0
- package/src/types.ts +15 -3
- package/src/validate.ts +417 -8
- package/tools/editor_roundtrip.ts +557 -0
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**.
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
?
|
|
729
|
-
? ' <- a fold, and
|
|
730
|
-
:
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
`
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
'',
|