spine-rigc 0.16.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 +368 -19
- package/docs/AUTHORING.md +570 -99
- package/docs/FACE.md +256 -0
- package/docs/MOTION.md +52 -0
- package/package.json +2 -1
- package/skills/motion/SKILL.md +1 -1
- package/src/compile.ts +770 -41
- package/src/deformgen.ts +125 -18
- package/src/deformmeasure.ts +1469 -138
- package/src/depth.ts +610 -0
- package/src/mesh.ts +104 -1
- package/src/rig.ts +124 -0
- package/src/transform.ts +24 -0
- package/src/types.ts +66 -3
- package/src/validate.ts +464 -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,8 +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';
|
|
126
|
+
import { depthStepLevels, type FoldLimit, type TurnCeiling } from './src/depth.ts';
|
|
117
127
|
import type { CompileResult } from './src/types.ts';
|
|
118
128
|
|
|
119
129
|
/**
|
|
@@ -407,10 +417,109 @@ function runGate(
|
|
|
407
417
|
* unconditionally, so a build whose only mesh was a ribbon or a contour got a
|
|
408
418
|
* sentence about a rim ring and a seam it does not have.
|
|
409
419
|
*/
|
|
420
|
+
/**
|
|
421
|
+
* What a depth map and a soft region put on a mesh, when it named either.
|
|
422
|
+
*
|
|
423
|
+
* The digests are the reason this prints at all: a claim about a rig can name
|
|
424
|
+
* WHICH sheet produced it, and two runs a reader believes differ can be shown to
|
|
425
|
+
* have read the same pixels. The ranges and counts are what say the input
|
|
426
|
+
* reached the geometry rather than merely being resolved — a `carried 0` never
|
|
427
|
+
* gets here (it is refused) and a `ramped 0` is a hard-edged mask, which is
|
|
428
|
+
* legal and usually not what somebody meant.
|
|
429
|
+
*/
|
|
430
|
+
/**
|
|
431
|
+
* One axis's two ceilings, as `+31.41 / -18.03`, or what is unbounded on it.
|
|
432
|
+
*
|
|
433
|
+
* ⚠️ `none` and a number are different claims and are printed differently. A
|
|
434
|
+
* sheet with no gradient along an axis cannot fold anything on it AT ANY ANGLE,
|
|
435
|
+
* which is a fact about the sheet worth reading; printing `90` for it would be
|
|
436
|
+
* a limit nothing measured.
|
|
437
|
+
*/
|
|
438
|
+
function ceilingPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
|
|
439
|
+
const one = (l: FoldLimit | null, sign: string) => (l === null ? `${sign}none` : `${sign}${l.degrees.toFixed(2)}°`);
|
|
440
|
+
return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
|
|
441
|
+
}
|
|
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
|
+
|
|
476
|
+
/** The tightest of the four, so the line that names a triangle names the right one. */
|
|
477
|
+
function tightestFold(c: TurnCeiling): { kind: string; sign: string; limit: FoldLimit } | null {
|
|
478
|
+
const all = [
|
|
479
|
+
{ kind: 'yaw', sign: '+', limit: c.yaw.positive },
|
|
480
|
+
{ kind: 'yaw', sign: '-', limit: c.yaw.negative },
|
|
481
|
+
{ kind: 'pitch', sign: '+', limit: c.pitch.positive },
|
|
482
|
+
{ kind: 'pitch', sign: '-', limit: c.pitch.negative },
|
|
483
|
+
].filter((e): e is { kind: string; sign: string; limit: FoldLimit } => e.limit !== null);
|
|
484
|
+
if (all.length === 0) return null;
|
|
485
|
+
return all.reduce((best, e) => (e.limit.degrees < best.limit.degrees ? e : best));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function meshDepthNote(m: CompileResult['meshes'][number]): string {
|
|
489
|
+
const parts: string[] = [];
|
|
490
|
+
if (m.depth) {
|
|
491
|
+
parts.push(
|
|
492
|
+
`depth "${m.depth.image}" ${m.depth.digest} near=${m.depth.near} zScale=${m.depth.zScale} ` +
|
|
493
|
+
`z=[${m.depth.range[0]}, ${m.depth.range[1]}]`,
|
|
494
|
+
);
|
|
495
|
+
const c = m.depth.ceiling;
|
|
496
|
+
parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`);
|
|
497
|
+
const worst = tightestFold(c);
|
|
498
|
+
if (worst !== null) {
|
|
499
|
+
parts.push(` 1st pct yaw ${spreadPair(c.yaw)} pitch ${spreadPair(c.pitch)}`);
|
|
500
|
+
}
|
|
501
|
+
parts.push(
|
|
502
|
+
worst === null
|
|
503
|
+
? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it`
|
|
504
|
+
: ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` +
|
|
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` +
|
|
507
|
+
`${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (m.soft) {
|
|
511
|
+
parts.push(
|
|
512
|
+
`soft "${m.soft.mask}" ${m.soft.digest} -> ${m.soft.bone}, ${m.soft.carried} carried / ${m.soft.ramped} in the falloff`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return parts.length === 0 ? '' : `\n ${parts.join('\n ')}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
410
518
|
const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> = {
|
|
411
519
|
ring: 'ring rim ring pinned on the window edge, seam ring pinned on the mask contour, aperture moves',
|
|
412
520
|
ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
|
|
413
521
|
contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
|
|
522
|
+
grid: 'grid a lattice over the part window at stated column and row positions, every vertex pinned to the slot bone',
|
|
414
523
|
authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
|
|
415
524
|
};
|
|
416
525
|
|
|
@@ -578,11 +687,27 @@ function sameKeyTime(specTime: number, loaded: number): boolean {
|
|
|
578
687
|
* - the reversal and collapse counts are the **survey's**, which is A39's own
|
|
579
688
|
* survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
|
|
580
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;
|
|
581
696
|
* - a key's model is the **compiler's** `transform` report (§4.11.1), so the
|
|
582
697
|
* block names the same `kind` and parameters the spec stated;
|
|
583
698
|
* - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
|
|
584
699
|
* grid, and a second copy of it printed beside a ratio would be a number that
|
|
585
|
-
* 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.
|
|
586
711
|
*
|
|
587
712
|
* ## And what it deliberately does not print
|
|
588
713
|
*
|
|
@@ -605,6 +730,8 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
|
|
|
605
730
|
out.push(
|
|
606
731
|
' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
|
|
607
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',
|
|
608
735
|
' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
|
|
609
736
|
' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
|
|
610
737
|
' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
|
|
@@ -632,6 +759,54 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
|
|
|
632
759
|
` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
|
|
633
760
|
`t=${key.time.toFixed(6)} ${states}`,
|
|
634
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
|
+
}
|
|
635
810
|
// A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
|
|
636
811
|
// no run is the format's own way of writing "back to the setup pose" (§4.11),
|
|
637
812
|
// and its geometry is bit-identical to the cleared pose it would be measured
|
|
@@ -662,9 +837,11 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
|
|
|
662
837
|
// A39 does not refuse it — it SKIPs the slot entirely.
|
|
663
838
|
const exempted = exempt.has(key.slot);
|
|
664
839
|
const fold = key.reversed.length
|
|
665
|
-
?
|
|
666
|
-
? ' <- a fold, and
|
|
667
|
-
:
|
|
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'
|
|
668
845
|
: '';
|
|
669
846
|
out.push(
|
|
670
847
|
` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
|
|
@@ -676,11 +853,53 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
|
|
|
676
853
|
);
|
|
677
854
|
}
|
|
678
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
|
+
}
|
|
679
879
|
// The rollup, per animation: the worst key by each quantity. A timeline's own
|
|
680
880
|
// eight keys are eight blocks above, and "which of them is the one to look at"
|
|
681
881
|
// is the question the sweep in issue #313's landing comment answered by hand.
|
|
682
|
-
|
|
683
|
-
|
|
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);
|
|
684
903
|
const worst = (
|
|
685
904
|
pick: (key: DeformKeyMeasure) => DeformExtreme | null,
|
|
686
905
|
better: (a: number, b: number) => boolean,
|
|
@@ -696,19 +915,123 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
|
|
|
696
915
|
const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
|
|
697
916
|
const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
|
|
698
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) {
|
|
699
962
|
out.push(
|
|
700
|
-
`
|
|
701
|
-
|
|
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`,
|
|
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',
|
|
707
965
|
);
|
|
708
966
|
}
|
|
709
967
|
return out;
|
|
710
968
|
}
|
|
711
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
|
+
|
|
712
1035
|
/**
|
|
713
1036
|
* Read one non-negative integer flag, or its default.
|
|
714
1037
|
*
|
|
@@ -796,7 +1119,8 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
796
1119
|
for (const m of result.meshes) {
|
|
797
1120
|
console.log(
|
|
798
1121
|
` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
|
|
799
|
-
`${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}
|
|
1122
|
+
`${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
|
|
1123
|
+
meshDepthNote(m),
|
|
800
1124
|
);
|
|
801
1125
|
}
|
|
802
1126
|
for (const ph of result.physics) {
|
|
@@ -2019,6 +2343,7 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
2019
2343
|
const drives = result.meshBones.includes(boneName) ? ' <- drives a mesh' : '';
|
|
2020
2344
|
for (const [timelineName, keys] of Object.entries(timelines)) {
|
|
2021
2345
|
console.log(` ${boneName}.${timelineName} ${keys.length} key(s)${drives}`);
|
|
2346
|
+
if (timelineName === 'scale') console.log(` ${SCALE_PRODUCT_NOTE}`);
|
|
2022
2347
|
for (const key of keys) {
|
|
2023
2348
|
const fields = Object.entries(key)
|
|
2024
2349
|
.filter(([k]) => k !== 'time' && k !== 'curve')
|
|
@@ -2029,7 +2354,7 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
2029
2354
|
: key.curve === 'stepped'
|
|
2030
2355
|
? 'stepped'
|
|
2031
2356
|
: 'linear';
|
|
2032
|
-
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)}`);
|
|
2033
2358
|
}
|
|
2034
2359
|
}
|
|
2035
2360
|
}
|
|
@@ -2127,6 +2452,24 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
2127
2452
|
}
|
|
2128
2453
|
console.log(` ${pairs.join(' ')}`);
|
|
2129
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
|
+
}
|
|
2130
2473
|
}
|
|
2131
2474
|
}
|
|
2132
2475
|
}
|
|
@@ -2223,9 +2566,14 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
2223
2566
|
console.log('\nmeshes');
|
|
2224
2567
|
for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
|
|
2225
2568
|
for (const m of result.meshes) {
|
|
2569
|
+
// The depth block belongs here more than it belongs in `build`: `explain`
|
|
2570
|
+
// is the command that says what a spec MEANS, and the turn ceiling is the
|
|
2571
|
+
// number an author needs before writing a key rather than after a refusal.
|
|
2572
|
+
// It was absent, while `docs/AUTHORING.md` said both commands printed it.
|
|
2226
2573
|
console.log(
|
|
2227
2574
|
` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
|
|
2228
|
-
`${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}
|
|
2575
|
+
`${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}` +
|
|
2576
|
+
meshDepthNote(m),
|
|
2229
2577
|
);
|
|
2230
2578
|
}
|
|
2231
2579
|
}
|
|
@@ -2606,10 +2954,11 @@ const USAGE = [
|
|
|
2606
2954
|
'',
|
|
2607
2955
|
'build, validate and bench take --profile spine|spine-html:',
|
|
2608
2956
|
' spine is this valid Spine 4.3 that any runtime plays correctly?',
|
|
2609
|
-
|
|
2957
|
+
` THE DEFAULT — ${assertionCountForProfile('spine')} rules, and the question the output answers when`,
|
|
2610
2958
|
' you import it into the Spine editor.',
|
|
2611
2959
|
' spine-html the above, plus this project\'s renderer and archetype policy:',
|
|
2612
|
-
|
|
2960
|
+
` all ${assertionCountForProfile('spine-html')} rules, opt-in. Those extra ` +
|
|
2961
|
+
`${assertionCountForProfile('spine-html') - assertionCountForProfile('spine')} fire on real, correct,`,
|
|
2613
2962
|
' editor-produced Spine data, so they are somebody\'s policy rather',
|
|
2614
2963
|
' than anybody\'s validity.',
|
|
2615
2964
|
'',
|