spine-rigc 0.13.0 → 0.14.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
@@ -398,7 +398,7 @@ was verified, and what writing it cost. Repository material: a clone and
398
398
  | Example | Stars | What it is |
399
399
  | --- | --- | --- |
400
400
  | [`gallery/walk`](https://github.com/firejune/rigc/tree/main/gallery/walk) | `ik` constraints + **`ik` timelines** | Two two-bone leg chains solved to foot targets — the planted leg nailed down, the swinging one let go at the top of its lift |
401
- | [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms written out in the README |
401
+ | [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms the keys state rather than tabulate |
402
402
  | [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) | **`contour` meshes** | A swallow-tailed banner and a serrated leaf: four meshes traced off their own alpha, waved by bone timelines and rippled by a `deform` |
403
403
  | [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride) | `path` attachments + **path constraints** | A trolley coasting down a drawn rail and rolling back, driven by a `position` timeline, with `groups` + `stagger` keying the wheels and the ears |
404
404
 
@@ -491,7 +491,7 @@ that an agent authors a spineboy-scale rig from the brief alone in one run: the
491
491
  ladder has not demonstrated that, and each row records which of the two it is.
492
492
 
493
493
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
494
- see, every rung, the run viewer, the 39 assertions and the selftest behind them — is
494
+ see, every rung, the run viewer, the 40 assertions and the selftest behind them — is
495
495
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
496
496
  Live rung status is
497
497
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -58,10 +58,17 @@ import {
58
58
  } from './src/bonedist.ts';
59
59
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
60
60
  import { compile, CompileError, type CompileOptions } from './src/compile.ts';
61
+ import {
62
+ skeletonDataFromText,
63
+ surveyDeformKeys,
64
+ type DeformExtreme,
65
+ type DeformKeyMeasure,
66
+ } from './src/deformmeasure.ts';
61
67
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
62
68
  import { copyAtlasImages } from './src/emit.ts';
63
69
  import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
64
70
  import { parseJsonWithPosition } from './src/json-position.ts';
71
+ import { KEY_TIME_EPSILON } from './src/timelines.ts';
65
72
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
66
73
  import {
67
74
  DEFAULT_MAX_RESIDUAL,
@@ -105,7 +112,8 @@ import {
105
112
  type FrameSet,
106
113
  } from './src/render.ts';
107
114
  import { CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from './src/validate.ts';
108
- import type { CompileResult, MotionSpec } from './src/types.ts';
115
+ import { parseMotionSpec } from './src/motion.ts';
116
+ import type { CompileResult } from './src/types.ts';
109
117
 
110
118
  /**
111
119
  * One entry of a cuts.json, every path relative to the cuts.json file.
@@ -431,6 +439,264 @@ function meshBudget(rig: CompileResult['rig']): string {
431
439
  return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
432
440
  }
433
441
 
442
+ /**
443
+ * One extreme, as `x0.637306 tri 0`, or an em dash when no triangle on the key
444
+ * could carry the quantity.
445
+ *
446
+ * A dash rather than `x1.000000`: a key over a mesh whose every triangle is a
447
+ * hair has no ratio and no map, and printing the identity there would report a
448
+ * measurement that was never taken — this repository's favourite false green.
449
+ */
450
+ function deformExtreme(extreme: DeformExtreme | null): string {
451
+ return extreme === null ? '—'.padEnd(9) : `x${extreme.value.toFixed(6)} tri ${extreme.triangle}`;
452
+ }
453
+
454
+ /**
455
+ * The `MEMBER` report block — a group track's per-member values, side by side
456
+ * (issue #295).
457
+ *
458
+ * ## Why side by side is the whole point
459
+ *
460
+ * The complaint that filed #295 was not the line count. `gallery/portrait`'s
461
+ * held yaw put six sibling bones' `translatex` in six separate tracks, and the
462
+ * reason that is bad is that **nobody can see a wrong sign in a column that is
463
+ * eighty lines from its neighbours.** FACE §3 makes the same argument from the
464
+ * other side: a residual is 1–6 units where a total is 30–40, and the split is
465
+ * *an auditing decision before it is a rigging one*. So the report's job is to
466
+ * put the numbers in the arrangement the audit needs — one row per member, one
467
+ * block per key — which is exactly the arrangement the emitted format cannot
468
+ * have, because Spine keys one bone per timeline.
469
+ *
470
+ * ## It quotes; it does not re-derive
471
+ *
472
+ * The same rule as the `DEFORM` block. Every value here is the one the compiler
473
+ * **emitted**, carried on `result.trackDerivations`, so the block and the
474
+ * artifact cannot disagree. `derived` and `formula` are the model's own strings
475
+ * from `src/trackgen.ts`, so the block names the closed form the spec stated
476
+ * rather than a second reading of it.
477
+ *
478
+ * ## What it deliberately does not print
479
+ *
480
+ * **Tracks whose members all share one value** — the ordinary `groups` entry.
481
+ * There is one number there and the timelines above already show it on every
482
+ * member; a table of six identical rows would be a tautology, and the block
483
+ * exists to make a *difference* visible. `look_l`/`look_r` in the worked example
484
+ * are exactly that case and they are right to be absent from here.
485
+ *
486
+ * **`stagger`.** A per-member time offset is printed as it always was — on each
487
+ * member's own timeline, where the shifted key times are. Repeating it here
488
+ * would put one lag in two places.
489
+ */
490
+ function memberReportLines(result: CompileResult): string[] {
491
+ if (result.trackDerivations.length === 0) return [];
492
+ const out: string[] = [
493
+ '',
494
+ 'group members (the per-member values of one track, side by side — issue #295)',
495
+ ' .. a row per member and a block per key, because a wrong sign is visible in a column of six and',
496
+ ' .. invisible in six tracks. Values are the EMITTED ones, so this and the artifact cannot disagree',
497
+ ' .. a group whose members all share one value is not here: there is one number and the timelines',
498
+ ' .. above already carry it. `stagger` is not here either — the shifted key times are on those timelines',
499
+ ];
500
+ for (const entry of result.trackDerivations) {
501
+ const states =
502
+ entry.model === null
503
+ ? 'stated per member'
504
+ : `derive ${entry.model.kind} ${entry.model.stated} -> ${entry.model.projection === 'shift' ? 'the displacement' : 'the narrowing'}`;
505
+ out.push(
506
+ ` MEMBER ${entry.animation} ${entry.targetKind} "${entry.target}".${entry.property} ` +
507
+ `t=${entry.time.toFixed(6)} ${entry.members.length} member(s) ${states}`,
508
+ );
509
+ if (entry.model !== null) {
510
+ out.push(` ${entry.model.formula}`);
511
+ for (const line of entry.model.derived) out.push(` ${line}`);
512
+ }
513
+ const width = Math.max(6, ...entry.members.map((m) => m.member.length));
514
+ for (let i = 0; i < entry.members.length; i++) {
515
+ const m = entry.members[i];
516
+ const value = Array.isArray(m.value) ? m.value.join(', ') : JSON.stringify(m.value);
517
+ // The model's own row carries the two inputs that produced the value — the
518
+ // coordinate it read off the rig and the depth the spec stated — because
519
+ // "5.513" alone is a number a reader can only take on trust, and `−62` and
520
+ // `150` beside it are a claim they can check.
521
+ const from = entry.model === null ? '' : ` <- ${entry.model.members[i].at >= 0 ? ' ' : ''}${entry.model.members[i].at} at depth ${entry.model.members[i].depth}`;
522
+ out.push(` ${m.member.padEnd(width)} ${value.padStart(12)}${from}`);
523
+ }
524
+ }
525
+ return out;
526
+ }
527
+
528
+ /** `head/head key 1`, which is how A39's own message names a key. */
529
+ function deformKeyName(key: DeformKeyMeasure): string {
530
+ return `${key.slot}/${key.attachment} key ${key.key}`;
531
+ }
532
+
533
+ /**
534
+ * Does this compiled `transform` report belong to this loaded key?
535
+ *
536
+ * ⚠️ The two times are not the same number and cannot be compared with `===`.
537
+ * The report's is the spec's own `t`; the survey's came back through
538
+ * `Float32Array`, because that is what `spine-core` reads a timeline's frames
539
+ * into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
540
+ * is the compiler's own key-time grid plus one float32 ulp at this magnitude,
541
+ * which is narrower than any key spacing the format can hold and wide enough for
542
+ * both roundings.
543
+ */
544
+ function sameKeyTime(specTime: number, loaded: number): boolean {
545
+ return Math.abs(specTime - loaded) <= KEY_TIME_EPSILON + Math.abs(loaded) * 2 ** -23;
546
+ }
547
+
548
+ /**
549
+ * The `DEFORM` report block — what each deform key does to the geometry, per key
550
+ * and then per animation (issue #316).
551
+ *
552
+ * ## Why this is a report and not an assertion
553
+ *
554
+ * Because a 3× stretch is a real thing to author, for the same reason issue #277
555
+ * settled mesh coverage as a printed figure on authored geometry rather than a
556
+ * bar. The one deformed-geometry fault that has no legitimate counter-example is
557
+ * the fold, and that one already IS an assertion —
558
+ * `A39_DEFORM_KEEPS_TRIANGLE_WINDING`. What this block adds is **the approach to
559
+ * that wall**: FACE §4.2's table of ratios down to the fold at 31.37° was
560
+ * measured by rendering seven variants of `gallery/portrait` and looking at them,
561
+ * and `0.637` was a number an author derived from the closed form rather than one
562
+ * the tool printed.
563
+ *
564
+ * ## It quotes; it does not re-derive
565
+ *
566
+ * - the reversal and collapse counts are the **survey's**, which is A39's own
567
+ * survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
568
+ * two readers, so the block and the gate cannot disagree about a fold;
569
+ * - a key's model is the **compiler's** `transform` report (§4.11.1), so the
570
+ * block names the same `kind` and parameters the spec stated;
571
+ * - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
572
+ * grid, and a second copy of it printed beside a ratio would be a number that
573
+ * goes stale when somebody moves a column.
574
+ *
575
+ * ## And what it deliberately does not print
576
+ *
577
+ * **Deformed coverage**, which #296 asked for. The coverage figure is rasterised
578
+ * from the attachment's **uvs** against the part's alpha, and a deform moves
579
+ * positions and never uvs — so it is identical at every key by construction, and
580
+ * a `coverage 100.00% (setup 100.00%)` line would be a tautology wearing a
581
+ * measurement's clothes. The header line says so and points at `meshes`, because
582
+ * an author who came here asking whether their deform broke the coverage
583
+ * deserves the answer rather than a silence. What does move is the stretch.
584
+ */
585
+ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>): string[] {
586
+ const survey = surveyDeformKeys(skeletonDataFromText(result.skeletonText, result.atlasText));
587
+ if (survey.timelines === 0) return [];
588
+ const out: string[] = ['', 'deform (what each key does to the geometry — figures with names, never a bar; issue #316)'];
589
+ // The legend costs six lines and is worth them exactly once — on a report that
590
+ // has figures in it. A bounding box or a clipping polygon deformed and nothing
591
+ // else gets the reason it has no figures and no essay about them.
592
+ if (survey.keys.length) {
593
+ out.push(
594
+ ' .. every key measured at its OWN time against the same pose with the deform CLEARED, so the',
595
+ ' .. denominator is 1.000 by definition and a NEGATIVE area ratio IS a reversed triangle',
596
+ ' .. stretch is the two singular values of the map from the cleared triangle to the deformed one —',
597
+ ' .. the worst stretch and the worst squash the drawing takes there; their product is |area ratio|',
598
+ ' .. coverage is NOT here: it is rasterised from the uvs, which no deform moves, so the figure on',
599
+ ' .. the `meshes` line below is already the deformed one',
600
+ );
601
+ }
602
+ if (survey.notAMesh.length) {
603
+ out.push(` .. ${survey.notAMesh.join(', ')} deform an attachment with no triangles — nothing to measure`);
604
+ }
605
+ for (const key of survey.keys) {
606
+ const model = result.deformTransforms.find(
607
+ (g) =>
608
+ g.animation === key.animation &&
609
+ g.skin === key.skin &&
610
+ g.slot === key.slot &&
611
+ g.attachment === key.placeholder &&
612
+ sameKeyTime(g.time, key.time),
613
+ );
614
+ // A stated model is quoted rather than reduced to its results: `yaw
615
+ // radius=170 degrees=12` is what a reviewer checks the ratios against, and an
616
+ // authored table says so instead of saying nothing, because "no model here"
617
+ // is itself the thing a reader of a wrong ratio needs to know.
618
+ const states = model === undefined ? 'authored table' : `transform ${model.kind} ${model.stated}`;
619
+ out.push(
620
+ ` DEFORM ${key.animation} ${key.skin}/${key.slot}/${key.placeholder} key ${key.key} ` +
621
+ `t=${key.time.toFixed(6)} ${states}`,
622
+ );
623
+ // A key that moves nothing gets one line and no figures. `{ "t": 2.2 }` with
624
+ // no run is the format's own way of writing "back to the setup pose" (§4.11),
625
+ // and its geometry is bit-identical to the cleared pose it would be measured
626
+ // against — so `x1.000000` there is the definition and not a measurement, and
627
+ // four lines of it on every loop's opening and closing key is the noise that
628
+ // stops the block being read. It is still counted in the rollup below,
629
+ // because A39 measures it too.
630
+ if (key.moved === 0) {
631
+ out.push(
632
+ ` moved 0 of ${key.vertices} vertices — this key IS the setup pose, so every ` +
633
+ `figure is the identity (${key.triangles} triangles, all kept)`,
634
+ );
635
+ continue;
636
+ }
637
+ out.push(
638
+ ` moved ${key.moved} of ${key.vertices} vertices, ` +
639
+ `worst ${key.maxDisplacement.toFixed(4)}px at v${key.maxDisplacementVertex}`,
640
+ );
641
+ out.push(
642
+ ` area min ${deformExtreme(key.areaRatioMin)} max ${deformExtreme(key.areaRatioMax)} ` +
643
+ `(${key.triangles} triangles, ${key.degenerate} with no area at the cleared pose, band ${key.band.toFixed(6)}px²)`,
644
+ );
645
+ out.push(
646
+ ` stretch max ${deformExtreme(key.stretchMax)} min ${deformExtreme(key.stretchMin)}`,
647
+ );
648
+ // The marker has to know about the exemption, or it says the false half of
649
+ // the truth on the one build where it matters: a declared fold IS a fold and
650
+ // A39 does not refuse it — it SKIPs the slot entirely.
651
+ const exempted = exempt.has(key.slot);
652
+ const fold = key.reversed.length
653
+ ? exempted
654
+ ? ' <- a fold, and A39 does not gate it — see below'
655
+ : ' <- a fold: A39 refuses this key by name'
656
+ : '';
657
+ out.push(
658
+ ` winding ${key.triangles - key.reversed.length} of ${key.triangles} kept, ` +
659
+ `${key.collapsed} collapsed${fold}`,
660
+ );
661
+ if (exempted) {
662
+ out.push(
663
+ ` .. A39 is exempt on "${key.slot}" (invariants.deformMayFold), so nothing here is gated`,
664
+ );
665
+ }
666
+ }
667
+ // The rollup, per animation: the worst key by each quantity. A timeline's own
668
+ // eight keys are eight blocks above, and "which of them is the one to look at"
669
+ // is the question the sweep in issue #313's landing comment answered by hand.
670
+ for (const animation of [...new Set(survey.keys.map((k) => k.animation))]) {
671
+ const keys = survey.keys.filter((k) => k.animation === animation);
672
+ const worst = (
673
+ pick: (key: DeformKeyMeasure) => DeformExtreme | null,
674
+ better: (a: number, b: number) => boolean,
675
+ ): string => {
676
+ let best: { key: DeformKeyMeasure; extreme: DeformExtreme } | null = null;
677
+ for (const key of keys) {
678
+ const extreme = pick(key);
679
+ if (extreme === null) continue;
680
+ if (best === null || better(extreme.value, best.extreme.value)) best = { key, extreme };
681
+ }
682
+ return best === null ? '—' : `x${best.extreme.value.toFixed(6)} (${deformKeyName(best.key)} tri ${best.extreme.triangle})`;
683
+ };
684
+ const reversed = keys.reduce((n, k) => n + k.reversed.length, 0);
685
+ const collapsed = keys.reduce((n, k) => n + k.collapsed, 0);
686
+ const samples = keys.reduce((n, k) => n + k.triangles, 0);
687
+ out.push(
688
+ ` WORST ${animation} area ${worst((k) => k.areaRatioMin, (a, b) => a < b)} ` +
689
+ `stretch ${worst((k) => k.stretchMax, (a, b) => a > b)} ` +
690
+ `squash ${worst((k) => k.stretchMin, (a, b) => a < b)}`,
691
+ );
692
+ out.push(
693
+ ` .. ${''.padEnd(animation.length)} reversed ${reversed}, collapsed ${collapsed}, over ` +
694
+ `${keys.length} key(s) and ${samples} triangle sample(s) <- A39 reads the same two counts`,
695
+ );
696
+ }
697
+ return out;
698
+ }
699
+
434
700
  /**
435
701
  * Read one non-negative integer flag, or its default.
436
702
  *
@@ -1693,7 +1959,10 @@ function cmdExplain(flags: Record<string, string>): void {
1693
1959
  console.log(` .. rig ${opts.rigPath}`);
1694
1960
  console.log(` .. motion ${opts.motionPath}`);
1695
1961
  const result = compile(opts);
1696
- const motion = readJsonFile(opts.motionPath) as MotionSpec;
1962
+ // `compile` has already parsed this file, so the read below cannot fail — but
1963
+ // it goes through the same parser rather than a cast, because the cast was the
1964
+ // last one in the repository and issue #307 was about exactly that.
1965
+ const motion = parseMotionSpec(readJsonFile(opts.motionPath), opts.motionPath);
1697
1966
 
1698
1967
  console.log(`\nstage ${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height} (spine ${result.skeleton.skeleton.spine})`);
1699
1968
 
@@ -1813,6 +2082,30 @@ function cmdExplain(flags: Record<string, string>): void {
1813
2082
  : 'back to the setup pose';
1814
2083
  const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1815
2084
  console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
2085
+ // A generated key prints its MODEL and then every offset the model
2086
+ // produced (issue #294). Both halves are the point: the model is
2087
+ // what a reviewer checks a claim against, and the offsets are what
2088
+ // reaches the file — printing only the first would ask a reader to
2089
+ // trust an evaluation they cannot see, which is the gap FACE §9.3
2090
+ // records. The numbers are the emitted ones, not a second
2091
+ // evaluation, so this block and the artifact cannot disagree.
2092
+ const gen = result.deformTransforms.find(
2093
+ (g) => g.animation === animName && g.skin === skinName && g.slot === slotName && g.attachment === attName && g.time === key.time,
2094
+ );
2095
+ if (gen === undefined) continue;
2096
+ console.log(` transform ${gen.kind} ${gen.stated}`);
2097
+ console.log(` ${gen.formula}`);
2098
+ for (const line of gen.derived) console.log(` ${line}`);
2099
+ console.log(
2100
+ ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
2101
+ );
2102
+ for (let v = 0; v < gen.vertexCount; v += 4) {
2103
+ const pairs: string[] = [];
2104
+ for (let k = v; k < Math.min(v + 4, gen.vertexCount); k++) {
2105
+ pairs.push(`v${String(k).padStart(3)} (${gen.offsets[2 * k]}, ${gen.offsets[2 * k + 1]})`);
2106
+ }
2107
+ console.log(` ${pairs.join(' ')}`);
2108
+ }
1816
2109
  }
1817
2110
  }
1818
2111
  }
@@ -1834,6 +2127,16 @@ function cmdExplain(flags: Record<string, string>): void {
1834
2127
  }
1835
2128
  }
1836
2129
 
2130
+ // The `MEMBER` block sits beside the `DEFORM` one and for the same reason:
2131
+ // both re-print timelines the reader has just read, in the arrangement the
2132
+ // question needs rather than the one the format has.
2133
+ for (const line of memberReportLines(result)) console.log(line);
2134
+
2135
+ // The `DEFORM` block goes after the timelines and before the constraints,
2136
+ // because it is a measurement OF the deform timelines printed above — the keys
2137
+ // it names are the keys the reader has just read, by the same index.
2138
+ for (const line of deformReportLines(result, new Set(result.rig.deformMayFold))) console.log(line);
2139
+
1837
2140
  if (result.physics.length) {
1838
2141
  console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
1839
2142
  for (const ph of result.physics) {