spine-rigc 0.19.0 → 0.20.1

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
@@ -467,10 +467,12 @@ was verified, and what writing it cost. Repository material: a clone and
467
467
 
468
468
  <p align="center"><em>The portrait rig playing its three animations in one take — the turn is
469
469
  the shot: both silhouette edges move apart, which a flat slide cannot do, because every
470
- feature carries its own depth. Scene direction of this kind is authorable on plain Spine
471
- 4.3 — no plugin, no runtime patch — and the split was authoring cost rather than runtime
472
- capability; the cost is now one stated expression per key. Compiled and
473
- rendered entirely by the published package.</em></p>
470
+ feature carries its own depth. The rig guarantees the seams — <code>idle</code> loops
471
+ while <code>gaze</code> and <code>turn</code> return to rest, so the hand-offs meet at 0
472
+ differing pixels — and the composing is the consumer's. Authorable on plain Spine 4.3, no
473
+ plugin, no runtime patch; the split was authoring cost rather than runtime capability, and
474
+ the cost is now one stated expression per key. Compiled and rendered entirely by the
475
+ published package.</em></p>
474
476
 
475
477
  🎞️ **How the three films on this page were made** is kept with them, one directory per
476
478
  film in [`films/`](https://github.com/firejune/rigc/tree/main/films) — a `run.sh` that
@@ -575,6 +577,7 @@ letting `A17` blame the editor for the harness's own doing.
575
577
  | 🔬 **[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 |
576
578
  | 🎓 **[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 |
577
579
  | 📋 [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 |
580
+ | 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 has to mean before the number is claimed — conditions rather than a feature list, because direction here comes from what users hit |
578
581
 
579
582
  ## Why you can trust the output
580
583
 
package/cli.ts CHANGED
@@ -492,6 +492,30 @@ function meshDepthNote(m: CompileResult['meshes'][number]): string {
492
492
  `depth "${m.depth.image}" ${m.depth.digest} near=${m.depth.near} zScale=${m.depth.zScale} ` +
493
493
  `z=[${m.depth.range[0]}, ${m.depth.range[1]}]`,
494
494
  );
495
+ // Directly under the sheet's own line, because it is the other half of
496
+ // "what did this mesh read": `z=[…]` says how much of the map's range
497
+ // reached the vertices, and this says how many of them read a texel that
498
+ // draws nothing (issue #449). Reported only when there is something to
499
+ // report, so a mesh whose every vertex sits on drawn art gains no line.
500
+ //
501
+ // ⭐ A `contour` gets the OTHER half of the sentence, because rigc built
502
+ // that outline and knows what it is: `buildContourMesh` returns
503
+ // `hullVertices: points.length` over `offsetPolygon(simplified, margin)`,
504
+ // so every vertex of a contour mesh is the traced silhouette pushed out by
505
+ // the margin — there are no interior vertices for the count to be about.
506
+ // Nothing is derived, inferred or thresholded to say so; it is what the
507
+ // generator returns, and `generatedHullAndEdges` already cross-checks that
508
+ // hull against the triangulation's own outline. Without it the line reads
509
+ // as a fault on every correct contour rig, which is a diagnostic authors
510
+ // learn to ignore.
511
+ if (m.depth.undrawn > 0) {
512
+ parts.push(
513
+ `${m.depth.undrawn} of ${m.vertices} vertices sample a texel the part image does not draw — ` +
514
+ (m.kind === 'contour'
515
+ ? 'a contour\'s vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet'
516
+ : 'their z is the sheet\'s reading of somewhere the part is not'),
517
+ );
518
+ }
495
519
  const c = m.depth.ceiling;
496
520
  parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`);
497
521
  const worst = tightestFold(c);
@@ -503,7 +527,13 @@ function meshDepthNote(m: CompileResult['meshes'][number]): string {
503
527
  ? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it`
504
528
  : ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` +
505
529
  `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` +
530
+ `${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it, ` +
531
+ // The same step over the range the mesh sampled (issue #448). A
532
+ // suffix and not a line of its own: it is an apposition on the step
533
+ // beside it, and the reading that matters is the two together — a
534
+ // discontinuity says "plenty of levels" and "nearly all of them" at
535
+ // once, and they have to be read in one breath to say the opposite.
536
+ `which is ${worst.limit.stepShare.toFixed(3)} of the range this mesh sampled` +
507
537
  `${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`,
508
538
  );
509
539
  }
@@ -887,9 +917,37 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
887
917
  // animation are two frames and two rollups: merging them would average a fold
888
918
  // one dial reaches into a run of keys another one is clean over, which is the
889
919
  // hiding the two frames exist to prevent.
920
+ // 🔒 ONE derivation of a rollup's identity, read by every filter below.
921
+ //
922
+ // ⚠️ It was spelled three times and one of them drifted. The two key filters
923
+ // separate animation from slider with a NUL; the span filter used a SPACE, so
924
+ // its `=== id` never matched on any rig and `spans` was always empty — the
925
+ // "N span(s) … scanned" line silently stopped printing everywhere, taking with
926
+ // it the one thing issue #403 added it to say: that the scan RAN and found
927
+ // nothing, as opposed to never having run. A dead branch is the same silence
928
+ // this tool exists to convert into a named failure, and it survived because
929
+ // the identity was a literal at each site rather than a derivation (#440).
930
+ const rollupId = (animation: string, slider: string | null): string => `${animation}\u0000${slider ?? ''}`;
931
+ /**
932
+ * The readings A39 puts on its stats line for the three things this rollup
933
+ * reports — each spelled ONCE here and nowhere else in this file.
934
+ *
935
+ * 🔒 Checked rather than derived, and the difference is forced: `src/validate.ts`
936
+ * sets these on a `Record<string, number | string>`, so there is no type to take
937
+ * a name off and no constant to import. So they are spelled here and a control
938
+ * compiles a rig that triggers each one, then asserts the breadcrumb names a
939
+ * reading A39 really printed — two independent derivations compared, which is
940
+ * the shape `CUR07` uses for the same reason.
941
+ */
942
+ const A39_COUNTS = {
943
+ notDrawn: 'deformKeysNotDrawn',
944
+ unreachable: 'deformKeysUnreachable',
945
+ dialsDisagreed: 'deformDialsDisagreed',
946
+ dialDisagreed: 'deformDialDisagreed',
947
+ } as const;
890
948
  const rollups = new Map<string, { animation: string; label: string }>();
891
949
  for (const key of survey.keys) {
892
- rollups.set(`${key.animation}${key.reach.slider ?? ''}`, {
950
+ rollups.set(rollupId(key.animation, key.reach.slider), {
893
951
  animation: key.animation,
894
952
  label: key.reach.kind === 'slider' ? `${key.animation} via ${key.reach.slider}` : key.animation,
895
953
  });
@@ -899,7 +957,7 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
899
957
  // the same two counts and A39 reads none of a key that draws nothing, nor of
900
958
  // one at a time no dial selects. The ones it left out get their own line
901
959
  // rather than a silence (issues #401, #407).
902
- const mine = survey.keys.filter((k) => `${k.animation}${k.reach.slider ?? ''}` === id);
960
+ const mine = survey.keys.filter((k) => rollupId(k.animation, k.reach.slider) === id);
903
961
  const unreachable = mine.filter((k) => k.dial?.unreachable === true);
904
962
  const keys = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank === null);
905
963
  const blank = mine.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
@@ -934,7 +992,7 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
934
992
  ` .. ${keys.length ? ''.padEnd(label.length) : label} ${blank.length} key(s) draw no pixels ` +
935
993
  `at their own time and are read for no winding, carrying ` +
936
994
  `${blank.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates <- A39 counts ` +
937
- 'them as deformKeysNotDrawn',
995
+ `them as ${A39_COUNTS.notDrawn}`,
938
996
  );
939
997
  }
940
998
  if (unreachable.length) {
@@ -942,17 +1000,42 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
942
1000
  ` .. ${keys.length || blank.length ? ''.padEnd(label.length) : label} ${unreachable.length} key(s) ` +
943
1001
  'at a time no dial selects, measured in the frame the runtime lands on instead and read for no winding, ' +
944
1002
  `carrying ${unreachable.reduce((n, k) => n + k.reversed.length, 0)} reversed triangle(s) nothing gates ` +
945
- ' <- A39 counts them as deformKeysUnreachable',
1003
+ ` <- A39 counts them as ${A39_COUNTS.unreachable}`,
1004
+ );
1005
+ }
1006
+ // ⚠️ The dial rigc's two halves disagree about (issues #427, #440). It is a
1007
+ // property of the SLIDER and not of any one key, so it is placed by the
1008
+ // rollup's own identity rather than by a key filter — and it is REPORTED,
1009
+ // never gated: A39 refuses nothing for it, because a disagreement can pose
1010
+ // every frame correctly. Both readings are named because they differ by one
1011
+ // letter, and a breadcrumb that named only one of `deformDialsDisagreed` /
1012
+ // `deformDialDisagreed` would send a reader to grep for the other.
1013
+ const disputes = survey.dialDisputes.filter((dispute) => rollupId(animation, dispute.slider) === id);
1014
+ if (disputes.length) {
1015
+ out.push(
1016
+ ` .. ${keys.length || blank.length || unreachable.length ? ''.padEnd(label.length) : label} ` +
1017
+ `${disputes.length} dial(s) the skeleton and the probe disagree about: ` +
1018
+ disputes
1019
+ .map(
1020
+ (dispute) =>
1021
+ `the skeleton reads ${dispute.bone}.${dispute.stated} and the probe drives ` +
1022
+ `${dispute.bone}.${dispute.drive}, ` +
1023
+ (dispute.outside.length === 0
1024
+ ? 'and both answers pose the same frames'
1025
+ : `${dispute.outside.length} key time(s) outside what the skeleton's own field reaches`),
1026
+ )
1027
+ .join('; ') +
1028
+ ` <- A39 counts them as ${A39_COUNTS.dialsDisagreed} and spells them out as ${A39_COUNTS.dialDisagreed}`,
946
1029
  );
947
1030
  }
948
1031
  // ⚠️ Printed on a clean animation too. "The scan ran and found nothing" and
949
1032
  // "the scan never ran" are the two things a gate must never say the same
950
1033
  // way, and this line is the only place an author can tell them apart
951
1034
  // (issue #403).
952
- const spans = survey.spans.filter((s) => `${s.animation} ${s.reach.slider ?? ''}` === id);
1035
+ const spans = survey.spans.filter((s) => rollupId(s.animation, s.reach.slider) === id);
953
1036
  if (spans.length) {
954
1037
  out.push(
955
- ` .. ${keys.length || blank.length || unreachable.length ? ''.padEnd(label.length) : label} ` +
1038
+ ` .. ${keys.length || blank.length || unreachable.length || disputes.length ? ''.padEnd(label.length) : label} ` +
956
1039
  `${spans.length} span(s) between consecutive keys scanned for a fold no key lands on: ` +
957
1040
  `${spanTally(spans)} <- A39 reads the same scan`,
958
1041
  );
package/docs/AUTHORING.md CHANGED
@@ -773,8 +773,13 @@ between two things it has in front of it: the emitted triangles, and the PNG the
773
773
  attachment names with `image`. So any mesh that names one gets the figure on its
774
774
  `MESH` line, authored or generated:
775
775
 
776
+ ```bash
777
+ bun cli.ts build --rig gallery/squash/rig.json \
778
+ --motion gallery/squash/motion.json --out /tmp/squash
779
+ ```
780
+
776
781
  ```
777
- MESH ball authored 9 vertices / 8 triangles (budget 8) bones=[ball] attachments=[ball] covers 94.31% of the art, reaching 2.50px past it
782
+ MESH ball authored 9 vertices / 8 triangles (budget 8) bones=[ball] attachments=[ball] covers 100.00% of the art, reaching 15.00px past it
778
783
  ```
779
784
 
780
785
  **A number, not a bar.** A `contour` under 99.5% is *refused* because rigc
@@ -782,11 +787,17 @@ generated that geometry as a claim about the art; an authored mesh that sits ins
782
787
  its art is a legitimate thing to draw — a soft feather, a trimmed hull, a mesh
783
788
  meant to bend a core while its edges stretch — so the figure informs and the
784
789
  decision stays with the author. A mesh with no `image` reports nothing, because
785
- there is nothing to measure it against. The silence was worth closing: the line
786
- above is a round part meshed as a centre vertex plus 8 rim vertices placed on the
787
- silhouette, and an octagon's sides pass `R · cos(π/8)` from its centre, so 5.7% of
788
- the drawing — its whole ink outline, between the spokes — was not going to be
789
- drawn, and every assertion passed (issue #277).
790
+ there is nothing to measure it against.
791
+
792
+ **The silence was worth closing, and that example is where it was found.** The
793
+ ball is a centre vertex plus 8 rim vertices, and the first version placed them
794
+ *on* the silhouette — but an octagon's sides pass `R · cos(π/8)` from its centre,
795
+ so its whole ink outline between the spokes was not going to be drawn, and every
796
+ assertion passed (issue #277). That is why the command above prints 100.00%
797
+ rather than the figure it was filed over:
798
+ [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash)'s
799
+ README carries the inradius arithmetic, both coverage readings, and the rim move
800
+ that settled it.
790
801
 
791
802
  The generators are `ring`, `ribbon`, `contour` and `grid` (see
792
803
  [`src/mesh.ts`](../src/mesh.ts)); the first two encode a deformation model rather
@@ -1004,7 +1015,7 @@ number that says whether the map covers the part or a corner of it.
1004
1015
  depth "face_depth.png" f552a2f50d21 near=white zScale=60 z=[0, 60]
1005
1016
  turn ceiling yaw +31.41° / -32.01° pitch +32.01° / -31.41°
1006
1017
  1st pct yaw +31.55° x1.004 of 1004 / -32.10° x1.003 of 1044 pitch +32.10° x1.003 of 1044 / -31.55° x1.004 of 1004
1007
- first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it
1018
+ first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it, which is 0.049 of the range this mesh sampled
1008
1019
  ```
1009
1020
 
1010
1021
  Past that angle a triangle turns inside out and `A39` refuses the build by name.
@@ -1012,9 +1023,10 @@ The loop this replaces is *pick an angle, build, read the refusal, guess again*.
1012
1023
 
1013
1024
  #### Is the ceiling describing the form, or the sheet's grain?
1014
1025
 
1015
- The second and third lines answer that, and they are **reports only** — nothing
1026
+ The lines under the ceiling answer that, and they are **reports only** — nothing
1016
1027
  in them refuses a build or moves a ceiling
1017
- ([#412](https://github.com/firejune/rigc/issues/412)).
1028
+ ([#412](https://github.com/firejune/rigc/issues/412),
1029
+ [#448](https://github.com/firejune/rigc/issues/448)).
1018
1030
 
1019
1031
  The ceiling is the **minimum** of the per-triangle fold angles, and a minimum
1020
1032
  cannot say whether it is the floor of a band or one bad pixel. Measured: a clean
@@ -1025,38 +1037,66 @@ triangle does not, and nothing on the first line says so.
1025
1037
 
1026
1038
  | the figure | how to read it |
1027
1039
  | --- | --- |
1028
- | `x1.003` — the **1st percentile over the ceiling** | near 1 means a *band* of the mesh reaches the limit together, which is what a smooth form looks like: its steepest region has area. Near 10 means **one triangle** does, which is what a bad texel looks like. The clean and stray sheets above read `x1.003` and `x10.652` |
1040
+ | `x1.003` — the **1st percentile over the ceiling** | near 1 means a *band* of the mesh reaches the limit together, and near 10 means **one triangle** does, which is what a bad texel looks like. The clean and stray sheets above read `x1.003` and `x10.652`. ⚠️ A band is **not** sufficient evidence of a form, which is the third case: **an outline is a band**. A depth sheet estimated over cut-out art has a cliff along the whole silhouette, so its ceiling is a band too and this figure reads 1.02–2.17 — healthy — on a mesh whose angle means nothing. The row below is what tells those two apart |
1029
1041
  | `of 1004` — the **population** that percentile came out of | it is the nearest rank, so below **51** folding triangles there is no percentile to take and the line says `unranked of 36` instead of printing the minimum twice. Just over 51 it is the *second*-smallest angle, and a limit two triangles share is not yet a band |
1030
1042
  | `12.52 level(s)` — the **depth step across the triangle that folds first** | how much of the sheet's 0–255 range that triangle actually read. **Below about 3 the ceiling is quantisation rather than form**, and at exactly 1 it is `atan(255·h / zScale)` for cell size `h` — arithmetic about the encoding, with no form left in it at any density |
1043
+ | `which is 0.049 of the range this mesh sampled` — the **same step, over the range this mesh sampled** | how much of everything the sheet said across the whole part it said across that one triangle. A form's slope is bounded, so this **halves every time you double the lattice** while the angle settles. Near 1 it is a **cliff**: a step with no slope in it, whose angle halves with the lattice instead and describes nothing at any density. Measured: `gallery/look` reads 0.112 and 0.468, a synthetic raised cosine 0.394 falling to 0.027 under refinement, the same cosine with one planted cliff a flat 0.50, and estimated depth sheets over cut-out art **0.92–0.99** |
1031
1044
  | `+none` | on the ceiling line, nothing folds on that side at all, at any angle. On the percentile line it is the same statement — there is no population, because there is nothing to take a percentile of |
1032
1045
 
1033
- A real one rather than the illustration above — `bun cli.ts build --rig
1034
- gallery/look/rig.json --motion gallery/look/motion.json --images
1035
- gallery/look/parts --out <dir>`, whose two meshes happen to print two of the
1036
- three spellings:
1046
+ A real one rather than the illustration above — `gallery/look`, whose two meshes
1047
+ happen to print two of the three spellings:
1048
+
1049
+ ```bash
1050
+ bun cli.ts build --rig gallery/look/rig.json \
1051
+ --motion gallery/look/motion.json \
1052
+ --images gallery/look/parts --out /tmp/look
1053
+ ```
1037
1054
 
1038
1055
  ```
1039
1056
  MESH head grid 189 vertices / 320 triangles (budget 320) bones=[head] attachments=[head]
1040
1057
  depth "face_depth.png" bf156ea0cfc970a3 near=white zScale=194 z=[0, 194]
1058
+ 80 of 189 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not
1041
1059
  turn ceiling yaw +19.32° / -19.32° pitch +22.92° / -26.94°
1042
1060
  1st pct yaw +19.32° x1.000 of 80 / -19.32° x1.000 of 80 pitch +22.92° x1.000 of 102 / -26.94° x1.000 of 130
1043
- first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it
1061
+ first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it, which is 0.112 of the range this mesh sampled
1044
1062
  MESH hair_lock_l grid 39 vertices / 48 triangles (budget 320) bones=[lock_l] attachments=[hair_lock_l]
1045
1063
  depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.874511]
1064
+ 32 of 39 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not
1046
1065
  turn ceiling yaw +17.04° / -45.80° pitch +none / -none
1047
1066
  1st pct yaw +unranked of 12 / -unranked of 36 pitch +none / -none
1048
- first to fold: yaw + at 17.04°, triangle 2 [1,28,29], the sheet steps 78.00 level(s) across it
1067
+ first to fold: yaw + at 17.04°, triangle 2 [1,28,29], the sheet steps 78.00 level(s) across it, which is 0.468 of the range this mesh sampled
1049
1068
  ```
1050
1069
 
1051
1070
  ⭐ Both of those sheets are **form**, and the figures say so from opposite ends:
1052
1071
  the head's 320 triangles put the percentile exactly on the ceiling, and the
1053
1072
  lock's 48 are too few to rank at all — but at 28.50 and 78.00 levels across the
1054
- folding triangle, neither ceiling is anywhere near the encoding.
1055
-
1056
- ⇒ **A small ceiling with a ratio near 1 and a step well above 3 is a steep
1057
- surface: flatten the map.** A small ceiling with a large ratio, or with a step
1058
- near 1, is a *sheet* problem: the grain, the 8-bit rounding, or a stray pixel.
1059
- [`docs/FACE.md` §2.2](FACE.md) has the amplitudes and what each one costs.
1073
+ folding triangle, neither ceiling is anywhere near the encoding, and at 0.112
1074
+ and 0.468 of their own range neither step is a cliff.
1075
+
1076
+ 🔸 The line under each `depth "…"` is the other question, and it is not about
1077
+ the ceiling. A `grid` spans the whole part window and a head is not a rectangle, so
1078
+ some of the lattice lands where `head.png` draws nothing and takes its depth
1079
+ from the sheet out there. That is a **count and not a complaint** — read it
1080
+ against the mesh: 80 of 189 is the border of a lattice over a cut-out and the
1081
+ ceiling is still a form, while a count approaching the whole mesh with a step
1082
+ share near 1 beside it is a mesh reading background.
1083
+
1084
+ ⇒ **A small ceiling with a ratio near 1, a step well above 3 and a step share
1085
+ well under 1 is a steep surface: flatten the map.** A small ceiling with a large
1086
+ ratio, or with a step near 1, is a *sheet* problem: the grain, the 8-bit
1087
+ rounding, or a stray pixel. A small ceiling with a step share **near 1** is
1088
+ neither — it is a **discontinuity**, and no angle is the right one to quote for
1089
+ it. [`docs/FACE.md` §2.2](FACE.md) has the amplitudes, what each one costs, and
1090
+ why flattening does not apply to the third case.
1091
+
1092
+ 🚨 **How to tell a discontinuity from a steep surface in one move: refine the
1093
+ lattice and build again.** A form's ceiling settles and its step share halves; a
1094
+ cliff's ceiling **halves** and its step share does not move. Measured on a
1095
+ synthetic raised cosine against the same cosine with one column of cliff planted
1096
+ in it, over three doublings: the form goes 0.358 → 0.195 → 0.100 while its
1097
+ ceiling moves 41.99° → 39.59° → 38.84°, and the cliff goes 0.460 → 0.489 → 0.497
1098
+ while its ceiling goes 37.07° → 18.59° → 9.24°. The second one is not converging
1099
+ on anything.
1060
1100
 
1061
1101
  ⛔ **rigc will not filter the sheet for you, and you should not want it to.** A
1062
1102
  smoothed measurement would describe a surface the deform key is not built from:
@@ -1123,9 +1163,50 @@ refuses it instead:
1123
1163
  | `gamma` or `contrast` at or below 0 | `collapses the range onto the midpoint … so the map would describe a flat part` |
1124
1164
  | a `near` that is neither | `it is "white" or "black"` |
1125
1165
 
1126
- A sheet with **no alpha channel** covers its whole grid by construction and the
1127
- coverage check has nothing to test; what its background level means is then your
1128
- statement, and the reported range is where it shows up.
1166
+ A sheet that is **opaque everywhere** — a full-frame render with the background
1167
+ in it, which is what monocular depth estimation produces — covers its whole grid
1168
+ by construction, so the coverage refusal has nothing to hold it to and skips.
1169
+ That is right: a full-frame sheet is a legitimate statement and rigc has no
1170
+ authority to guess an input away. But the defect the refusal exists to catch is
1171
+ still reachable in that encoding, so the report counts it instead
1172
+ ([#449](https://github.com/firejune/rigc/issues/449)):
1173
+
1174
+ ```
1175
+ 80 of 189 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not
1176
+ ```
1177
+
1178
+ ⚠️ **The reported range is NOT where this shows up**, and this guide said it was
1179
+ until it was measured. A background level is a legitimate depth value, so a map
1180
+ half of which is background reports exactly as full a range as one that is all
1181
+ subject: on the measured build, `z=[0, 223.97]` of a stated 224 — healthy — with
1182
+ 54 % of the mesh reading background. "Covers its whole grid" is true and about
1183
+ the wrong grid; the question was never coverage of the *sheet*, it was whether
1184
+ the mesh is sampling **art**.
1185
+
1186
+ | The input | What you get |
1187
+ | --- | --- |
1188
+ | a sheet cut to the art's alpha, over a mesh that reaches past it | **refused** — `does not cover N of the mesh's V vertices`, with the fix for your topology named |
1189
+ | the same field stored opaque everywhere, over the same mesh | **compiled**, with `N of V vertices sample a texel the part image does not draw` in the report. The same N |
1190
+ | a mesh every one of whose vertices sits on drawn art | nothing — no line, and no refusal |
1191
+
1192
+ 🔸 It is a raw count with **no reach subtracted from it**, so a `contour` mesh
1193
+ reports most or all of its vertices: its outline is pushed `margin` pixels
1194
+ outside the silhouette by design, and out there the part draws nothing.
1195
+ Discounting the margin would mean borrowing a number authored for the trace to
1196
+ mean "close enough" for the sheet, and rigc does not invent tolerances.
1197
+
1198
+ ⭐ **So the line says why instead**, because rigc built that outline and knows
1199
+ what it is:
1200
+
1201
+ ```
1202
+ 15 of 15 vertices sample a texel the part image does not draw — a contour's vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet
1203
+ ```
1204
+
1205
+ The attribution is on a `contour` and **nowhere else**. A `grid`'s border is
1206
+ where your `us`/`vs` put it, not where a margin did, so the same sentence there
1207
+ would be a lie and the count stands alone. What that leaves true either way: the
1208
+ rim's depth really did come from off the art, which is harmless on a sheet
1209
+ dilated past the margin and is the whole failure on a full-frame estimate.
1129
1210
 
1130
1211
  ##### `soft` — which part is soft, and which bone carries it
1131
1212
 
@@ -2016,6 +2097,7 @@ is not the arrangement the format has. Spine keys one bone per timeline, so the
2016
2097
  six numbers of a head turn are eighty lines apart in the artifact and nobody can
2017
2098
  see a wrong sign in them.
2018
2099
 
2100
+ **No run reproduces this:** the legend and one group's record lifted out of one `explain` run, which prints the two `bone "faceshift"` records between them
2019
2101
  ```
2020
2102
  group members (the per-member values of one track, side by side — issue #295)
2021
2103
  .. a row per member and a block per key, because a wrong sign is visible in a column of six and
@@ -2595,10 +2677,15 @@ makes against them.
2595
2677
 
2596
2678
  **It is auditable.** `explain` prints the model, the scalars the closed form
2597
2679
  derived from it, and every offset it produced — the emitted ones, not a second
2598
- evaluation:
2680
+ evaluation. This is the `t=0.62` key of the spec above, whole:
2681
+
2682
+ ```bash
2683
+ bun cli.ts explain --rig gallery/portrait/rig.json \
2684
+ --motion gallery/portrait/motion.json --out /tmp/explain
2685
+ ```
2599
2686
 
2600
2687
  ```
2601
- t=0.62 deform[0..50] 25 pair(s) bezier[4]
2688
+ t=0.62 deform[0..50] 25 pair(s) stepped
2602
2689
  transform yaw radius=170 degrees=12
2603
2690
  dx = (x−about)·(cos t − 1) − z·sin t, z = √(radius² − (x−about)²)
2604
2691
  t = 0.20944 rad
@@ -2607,9 +2694,19 @@ evaluation:
2607
2694
  centre shift = −radius·sin t = -35.344987
2608
2695
  25 vertices, largest offset 35.344987px at vertex 2
2609
2696
  v 0 (-7.17493, 0) v 1 (-22.413595, 0) v 2 (-35.344987, 0) v 3 (-27.658171, 0)
2610
- …five more lines
2697
+ v 4 (-14.255108, 0) v 5 (-14.255108, 0) v 6 (-14.255108, 0) v 7 (-14.255108, 0)
2698
+ v 8 (-14.255108, 0) v 9 (-27.658171, 0) v 10 (-35.344987, 0) v 11 (-22.413595, 0)
2699
+ v 12 (-7.17493, 0) v 13 (-7.17493, 0) v 14 (-7.17493, 0) v 15 (-7.17493, 0)
2700
+ v 16 (-22.413595, 0) v 17 (-35.344987, 0) v 18 (-27.658171, 0) v 19 (-22.413595, 0)
2701
+ v 20 (-35.344987, 0) v 21 (-27.658171, 0) v 22 (-22.413595, 0) v 23 (-35.344987, 0)
2702
+ v 24 (-27.658171, 0)
2611
2703
  ```
2612
2704
 
2705
+ The curve reads `stepped` where the spec says `"ease": "swell"`, and that is
2706
+ §4.5's hold rule rather than a discrepancy: the next key emits these same 25
2707
+ offsets, so the segment between them would draw nothing and is written the way
2708
+ the editor writes it.
2709
+
2613
2710
  📌 **Float behaviour, stated.** The closed forms are evaluated in float64 and
2614
2711
  quantised to six decimals like every other emitted number, so the same spec emits
2615
2712
  the same bytes and `A18_DETERMINISTIC_EMIT` proves it on a second compile. The
@@ -2635,6 +2732,7 @@ triangles. It is a report and it never gates: `explain` takes no `--profile` and
2635
2732
  exits 0 on a rig `build` would refuse, so the figures are readable on the build
2636
2733
  that is failing.
2637
2734
 
2735
+ **No run reproduces this:** abridged — the `WORST` rollup follows key 1 here, where the run prints `head/head`'s other two keys and all four of `hair_bang/hair_bang` between them
2638
2736
  ```
2639
2737
  deform (what each key does to the geometry — figures with names, never a bar; issue #316)
2640
2738
  .. every key measured at its OWN time against the same pose with the deform CLEARED, so the
@@ -2670,7 +2768,7 @@ deform (what each key does to the geometry — figures with names, never a bar;
2670
2768
  | `stretch` | the two singular values of the map from the cleared triangle to the deformed one — the worst stretch and the worst squash the **drawing** takes. `σ₁·σ₂ = \|area ratio\|`, so the two rows are two readings of one map and cannot disagree |
2671
2769
  | `winding` | triangles whose winding survived, and how many the key pinched onto zero area. A fold says so and points at `A39` |
2672
2770
  | the `BETWEEN` line | a fold at a time **no key lands on** (§4.11.3): the two keys it lies between, the time, the segment's curve kind, and how far along the interpolation it is. Present only where one was found |
2673
- | `WORST` | per animation: the worst key by each quantity, then the reversal and collapse totals over every key, then how many spans between keys were scanned and what the scan found |
2771
+ | `WORST` | per animation **and per frame**: the worst key by each quantity, then the reversal and collapse totals over every key, then how many spans between keys were scanned and what the scan found. Each further line ends with a breadcrumb naming the `A39` reading that counts it — keys that draw no pixels (`deformKeysNotDrawn`, below), keys at a time no dial selects (`deformKeysUnreachable`, §4.11.4), and a dial the skeleton and the probe disagree about (`deformDialsDisagreed`, §4.11.4) |
2674
2772
 
2675
2773
  📌 **The frame is the posed one, and the denominator is 1.000 by definition.**
2676
2774
  Both sides of every comparison are taken at the key's own time with the animation
@@ -2731,8 +2829,12 @@ clothes. The quantity that does move — how much art each drawn pixel now carri
2731
2829
  📘 **[FACE.md](FACE.md) §9.2** is this block on real art, as three builds of
2732
2830
  `gallery/portrait`: the good one, one with a band inverted, and one folded. The
2733
2831
  inverted build is the case worth reading — `A39` passes it (correctly: nothing
2734
- reverses), and the block is what says `x1.362834` where the model's own table
2735
- says `x1.319121`, with no reference render anywhere.
2832
+ reverses), and the block is what says `x1.362834` on the band the model's own key
2833
+ reports as `x0.637174`, with no reference render anywhere. ⭐ The comparison is
2834
+ per **triangle** and that is what makes it a reading: the block names one beside
2835
+ every ratio, so the two blocks can be lined up band against band instead of worst
2836
+ against worst — which on this build would have paired the inverted band with an
2837
+ untouched one and called the difference mild.
2736
2838
 
2737
2839
  📘 **[`gallery/nod`](https://github.com/firejune/rigc/tree/main/gallery/nod)'s
2738
2840
  README is a second reading of the same block** (repository material, hence the
@@ -2757,15 +2859,23 @@ the frames just before it are drawn, nearly folded, and land on no key at all. O
2757
2859
  the turn probe that is **8 reversed triangles at alpha 0.20, gating green**.
2758
2860
 
2759
2861
  ⇒ `A39` now scans every interval between two consecutive deform keys as well, and
2760
- refuses one with its own sentence:
2761
-
2762
- ```
2763
- FAIL A39_DEFORM_KEEPS_TRIANGLE_WINDING: animation "turn" deform head/head BETWEEN key 0
2764
- (t=0s) and key 1 (t=0.5s), at t=0.444089s — 88.8% of the way from one to the other:
2765
- 8 of 32 triangle(s) reverse winding — triangle 0 [0,15,16] 1890.001 -> -272.314px²; …
2766
- NO KEY LANDS THERE: the runtime interpolates between the two keys, and the mesh is
2767
- inside out for part of the way, drawing its texture backwards at alpha 0.1118 …
2768
- ```
2862
+ refuses one with its own sentence. No spec this repository ships produces one — the
2863
+ rig it was written from is a probe `selftest.ts` generates and nothing else can
2864
+ invoke — so the sentence is described here rather than transcribed.
2865
+
2866
+ **What it carries**, in the order it says it: `BETWEEN key <i> (t=…s) and key <j>
2867
+ (t=…s)` where a key refusal puts one index; the time the closed form solved for, and
2868
+ how far along the segment that is — or `(a stepped segment)` instead, which
2869
+ interpolates nothing and holds the earlier key's geometry across the span; the
2870
+ reversed count out of the triangle total, with the first four named and each one's
2871
+ vertex ids and its signed area before and after; `NO KEY LANDS THERE` in those words,
2872
+ then whether the runtime interpolates across the span or holds it; the alpha read at
2873
+ that same instant, present only where it is not 1; and the ways out — for an
2874
+ interpolating span the four the table below gives, the fade one among them only
2875
+ where the alpha is not 1, and for a stepped one the key it holds instead, with
2876
+ `invariants.deformMayFold` the last resort either way.
2877
+ [`src/validate.ts`](../src/validate.ts) builds it, beside the key sentence §4.11.2
2878
+ quotes.
2769
2879
 
2770
2880
  **What to change when you see it**, in the order worth trying:
2771
2881
 
@@ -2833,7 +2943,13 @@ value = from + (time − to) / scale what A39 sets the dial to
2833
2943
 
2834
2944
  🔒 **The `DEFORM` block prints the frame on every key**, because the derivation
2835
2945
  changed and a block that went on printing the same figures under a changed meaning
2836
- would be worse than the red it replaced:
2946
+ would be worse than the red it replaced. `gallery/look`'s `turn` is the animation
2947
+ a slider applies, and this is one of its keys:
2948
+
2949
+ ```bash
2950
+ bun cli.ts explain --rig gallery/look/rig.json \
2951
+ --motion gallery/look/motion.json --out /tmp/explain-look
2952
+ ```
2837
2953
 
2838
2954
  ```
2839
2955
  DEFORM turn default/head/head key 6 t=1.900000 transform yaw depth=true degrees=19
@@ -2939,6 +3055,21 @@ deformDialsDisagreed=1 deformDialDisagreed=dial|artifact:knob.x@2.321e-8|reaches
2939
3055
  - A **tie** never carries `probe:`, `reaches:` or `outside:`, and never counts as a
2940
3056
  disagreement. There is one belief there, not two.
2941
3057
 
3058
+ **And `explain`'s rollup carries it too, with the breadcrumb its neighbours have**
3059
+ ([#440](https://github.com/firejune/rigc/issues/440)). The per-key `frame` lines
3060
+ say it once each; the rollup says it once per animation, and ends by naming the
3061
+ two readings a `build` prints it under — because they differ by one letter, and a
3062
+ breadcrumb naming only one would send a reader to grep for the other:
3063
+
3064
+ ```
3065
+ .. 1 dial(s) the skeleton and the probe disagree about: the skeleton reads knob.x and the probe drives knob.y, 2 key time(s) outside what the skeleton's own field reaches <- A39 counts them as deformDialsDisagreed and spells them out as deformDialDisagreed
3066
+ ```
3067
+
3068
+ The count after `outside` is the length of the stats line's own `outside:` list,
3069
+ and `deformDialsDisagreed` / `deformDialDisagreed` are checked against the keys
3070
+ `A39` really printed rather than restated — `DW34`. An agreed dial adds no line
3071
+ (`DW35`) and a **tie** is never rolled up as a disagreement (`DW36`).
3072
+
2942
3073
  ⛔ **None of it refuses a build**, and the reason is measured rather than chosen.
2943
3074
  The field the survey drives is the largest response the probe found, so its reach
2944
3075
  always *contains* the artifact's: a disagreement cannot make `A39` miss a frame the
@@ -3140,6 +3271,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3140
3271
 
3141
3272
  The report prints one line per assertion:
3142
3273
 
3274
+ **No run reproduces this:** assembled — one line of each verdict kind; the `PASS` and the `PROF` are verbatim from a `build` this page states, the `SKIP` is cut at the ellipsis and only `--profile spine-html` prints it, and the `FAIL` is invented, because a green build prints none
3143
3275
  ```
3144
3276
  PASS A08_REGION_NAMES_MATCH_ATTACHMENTS
3145
3277
  SKIP A21_MESH_RIM_PINNED: the skeleton has no weighted mesh attachment, …