spine-rigc 0.12.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
 
@@ -448,6 +448,7 @@ the art. Its shape is under
448
448
  | --- | --- |
449
449
  | ๐Ÿ“˜ **[docs/AUTHORING.md](docs/AUTHORING.md)** | **the format guide, and the one to read before writing a spec.** Both input files field by field with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build โ†’ read the report โ†’ fix โ†’ repeat loop, the map from every named failure to the file that has to change, and the features rigc refuses by name so you do not spend a loop discovering them. It travels **inside the npm package**, at `node_modules/spine-rigc/docs/AUTHORING.md` |
450
450
  | ๐ŸŽž๏ธ **[docs/MOTION.md](docs/MOTION.md)** | **the key-pose recipe.** How to get two poses, what a pair of poses does and does not fix, the in-betweening rules and where each comes from, and how to spread candidates so a ballot informs. Ships in the package too |
451
+ | ๐Ÿ™‚ **[docs/FACE.md](docs/FACE.md)** | **authoring a face.** A blink, a gaze and a 2.5D head turn on plain Spine data: the one line of yaw arithmetic every number in a turn comes from, depth as the parameter you are actually authoring, where to put a grid's columns and the angle at which any grid folds, what foreshortens and what does not, channel allocation before the first key, and the three cliffs with their angles. Also the deform audit gap, demonstrated โ€” a folded mesh gates green โ€” and the differential check that works today |
451
452
  | ๐Ÿ“ฅ **[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 |
452
453
  | ๐Ÿค– **[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 |
453
454
  | ๐Ÿ”ฌ **[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 |
@@ -490,7 +491,7 @@ that an agent authors a spineboy-scale rig from the brief alone in one run: the
490
491
  ladder has not demonstrated that, and each row records which of the two it is.
491
492
 
492
493
  The whole dossier โ€” the yardstick, `diff` and `check` and what neither of them can
493
- 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
494
495
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
495
496
  Live rung status is
496
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) {