spine-rigc 0.18.1 → 0.20.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
@@ -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
@@ -1004,7 +1004,7 @@ number that says whether the map covers the part or a corner of it.
1004
1004
  depth "face_depth.png" f552a2f50d21 near=white zScale=60 z=[0, 60]
1005
1005
  turn ceiling yaw +31.41° / -32.01° pitch +32.01° / -31.41°
1006
1006
  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
1007
+ 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
1008
  ```
1009
1009
 
1010
1010
  Past that angle a triangle turns inside out and `A39` refuses the build by name.
@@ -1012,9 +1012,10 @@ The loop this replaces is *pick an angle, build, read the refusal, guess again*.
1012
1012
 
1013
1013
  #### Is the ceiling describing the form, or the sheet's grain?
1014
1014
 
1015
- The second and third lines answer that, and they are **reports only** — nothing
1015
+ The lines under the ceiling answer that, and they are **reports only** — nothing
1016
1016
  in them refuses a build or moves a ceiling
1017
- ([#412](https://github.com/firejune/rigc/issues/412)).
1017
+ ([#412](https://github.com/firejune/rigc/issues/412),
1018
+ [#448](https://github.com/firejune/rigc/issues/448)).
1018
1019
 
1019
1020
  The ceiling is the **minimum** of the per-triangle fold angles, and a minimum
1020
1021
  cannot say whether it is the floor of a band or one bad pixel. Measured: a clean
@@ -1025,9 +1026,10 @@ triangle does not, and nothing on the first line says so.
1025
1026
 
1026
1027
  | the figure | how to read it |
1027
1028
  | --- | --- |
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` |
1029
+ | `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
1030
  | `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
1031
  | `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 |
1032
+ | `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
1033
  | `+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
1034
 
1033
1035
  A real one rather than the illustration above — `bun cli.ts build --rig
@@ -1038,25 +1040,48 @@ three spellings:
1038
1040
  ```
1039
1041
  MESH head grid 189 vertices / 320 triangles (budget 320) bones=[head] attachments=[head]
1040
1042
  depth "face_depth.png" bf156ea0cfc970a3 near=white zScale=194 z=[0, 194]
1043
+ 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
1044
  turn ceiling yaw +19.32° / -19.32° pitch +22.92° / -26.94°
1042
1045
  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
1046
+ 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
1047
  MESH hair_lock_l grid 39 vertices / 48 triangles (budget 320) bones=[lock_l] attachments=[hair_lock_l]
1045
1048
  depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.874511]
1049
+ 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
1050
  turn ceiling yaw +17.04° / -45.80° pitch +none / -none
1047
1051
  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
1052
+ 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
1053
  ```
1050
1054
 
1051
1055
  ⭐ Both of those sheets are **form**, and the figures say so from opposite ends:
1052
1056
  the head's 320 triangles put the percentile exactly on the ceiling, and the
1053
1057
  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.
1058
+ folding triangle, neither ceiling is anywhere near the encoding, and at 0.112
1059
+ and 0.468 of their own range neither step is a cliff.
1060
+
1061
+ 🔸 The line under each `depth "…"` is the other question, and it is not about
1062
+ the ceiling. A `grid` spans the whole part window and a head is not a rectangle, so
1063
+ some of the lattice lands where `head.png` draws nothing and takes its depth
1064
+ from the sheet out there. That is a **count and not a complaint** — read it
1065
+ against the mesh: 80 of 189 is the border of a lattice over a cut-out and the
1066
+ ceiling is still a form, while a count approaching the whole mesh with a step
1067
+ share near 1 beside it is a mesh reading background.
1068
+
1069
+ ⇒ **A small ceiling with a ratio near 1, a step well above 3 and a step share
1070
+ well under 1 is a steep surface: flatten the map.** A small ceiling with a large
1071
+ ratio, or with a step near 1, is a *sheet* problem: the grain, the 8-bit
1072
+ rounding, or a stray pixel. A small ceiling with a step share **near 1** is
1073
+ neither — it is a **discontinuity**, and no angle is the right one to quote for
1074
+ it. [`docs/FACE.md` §2.2](FACE.md) has the amplitudes, what each one costs, and
1075
+ why flattening does not apply to the third case.
1076
+
1077
+ 🚨 **How to tell a discontinuity from a steep surface in one move: refine the
1078
+ lattice and build again.** A form's ceiling settles and its step share halves; a
1079
+ cliff's ceiling **halves** and its step share does not move. Measured on a
1080
+ synthetic raised cosine against the same cosine with one column of cliff planted
1081
+ in it, over three doublings: the form goes 0.358 → 0.195 → 0.100 while its
1082
+ ceiling moves 41.99° → 39.59° → 38.84°, and the cliff goes 0.460 → 0.489 → 0.497
1083
+ while its ceiling goes 37.07° → 18.59° → 9.24°. The second one is not converging
1084
+ on anything.
1060
1085
 
1061
1086
  ⛔ **rigc will not filter the sheet for you, and you should not want it to.** A
1062
1087
  smoothed measurement would describe a surface the deform key is not built from:
@@ -1123,9 +1148,50 @@ refuses it instead:
1123
1148
  | `gamma` or `contrast` at or below 0 | `collapses the range onto the midpoint … so the map would describe a flat part` |
1124
1149
  | a `near` that is neither | `it is "white" or "black"` |
1125
1150
 
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.
1151
+ A sheet that is **opaque everywhere** — a full-frame render with the background
1152
+ in it, which is what monocular depth estimation produces — covers its whole grid
1153
+ by construction, so the coverage refusal has nothing to hold it to and skips.
1154
+ That is right: a full-frame sheet is a legitimate statement and rigc has no
1155
+ authority to guess an input away. But the defect the refusal exists to catch is
1156
+ still reachable in that encoding, so the report counts it instead
1157
+ ([#449](https://github.com/firejune/rigc/issues/449)):
1158
+
1159
+ ```
1160
+ 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
1161
+ ```
1162
+
1163
+ ⚠️ **The reported range is NOT where this shows up**, and this guide said it was
1164
+ until it was measured. A background level is a legitimate depth value, so a map
1165
+ half of which is background reports exactly as full a range as one that is all
1166
+ subject: on the measured build, `z=[0, 223.97]` of a stated 224 — healthy — with
1167
+ 54 % of the mesh reading background. "Covers its whole grid" is true and about
1168
+ the wrong grid; the question was never coverage of the *sheet*, it was whether
1169
+ the mesh is sampling **art**.
1170
+
1171
+ | The input | What you get |
1172
+ | --- | --- |
1173
+ | 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 |
1174
+ | 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 |
1175
+ | a mesh every one of whose vertices sits on drawn art | nothing — no line, and no refusal |
1176
+
1177
+ 🔸 It is a raw count with **no reach subtracted from it**, so a `contour` mesh
1178
+ reports most or all of its vertices: its outline is pushed `margin` pixels
1179
+ outside the silhouette by design, and out there the part draws nothing.
1180
+ Discounting the margin would mean borrowing a number authored for the trace to
1181
+ mean "close enough" for the sheet, and rigc does not invent tolerances.
1182
+
1183
+ ⭐ **So the line says why instead**, because rigc built that outline and knows
1184
+ what it is:
1185
+
1186
+ ```
1187
+ 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
1188
+ ```
1189
+
1190
+ The attribution is on a `contour` and **nowhere else**. A `grid`'s border is
1191
+ where your `us`/`vs` put it, not where a margin did, so the same sentence there
1192
+ would be a lie and the count stands alone. What that leaves true either way: the
1193
+ rim's depth really did come from off the art, which is harmless on a sheet
1194
+ dilated past the margin and is the whole failure on a full-frame estimate.
1129
1195
 
1130
1196
  ##### `soft` — which part is soft, and which bone carries it
1131
1197
 
@@ -1406,11 +1472,76 @@ sharing one of those overwrite each other whatever you write. A40 names that cas
1406
1472
  separately, because the fix is different: key such a property from one slider
1407
1473
  only, or move both edits into the single animation one slider applies.
1408
1474
 
1475
+ #### 3.5.2.1 What each `property` can actually be read AS
1476
+
1477
+ **A `property` under `local: false` is read through the world transform, and four
1478
+ of those readings are bounded.** A range that names values the reader cannot
1479
+ return is dead there: the dial moves, the reading does not follow, and nothing at
1480
+ runtime says so. [measured] against `spine-core` 4.3.13, one reader at a time —
1481
+ `bench/studies/2026-09-06-readers`:
1482
+
1483
+ | `property` | `"local"` | Reads | Producible floor | Producible ceiling |
1484
+ | --- | --- | --- | --- | --- |
1485
+ | `rotate` | `false` | `FromRotate`, world | `0`, reached | `360`, reached |
1486
+ | `rotate` | `true` | `FromRotate`, local | none | none |
1487
+ | `x` | `false` | `FromX`, world | none | none |
1488
+ | `x` | `true` | `FromX`, local | none | none |
1489
+ | `y` | `false` | `FromY`, world | none | none |
1490
+ | `y` | `true` | `FromY`, local | none | none |
1491
+ | `scaleX` | `false` | `FromScaleX`, world | `0`, reached | none |
1492
+ | `scaleX` | `true` | `FromScaleX`, local | none | none |
1493
+ | `scaleY` | `false` | `FromScaleY`, world | `0`, reached | none |
1494
+ | `scaleY` | `true` | `FromScaleY`, local | none | none |
1495
+ | `shearY` | `false` | `FromShearY`, world | `-449.99999468`, reached | `269.99999468`, reached |
1496
+ | `shearY` | `true` | `FromShearY`, local | none | none |
1497
+
1498
+ - **`none` is not "very large"** — those readers are `source.<field> + offset` and
1499
+ the field is whatever the animation wrote, so there is nothing there to bound.
1500
+ - **Every world row assumes the offsets are zero**, and a slider cannot make them
1501
+ anything else: `Slider.offsets` is a private all-zero array. The same six
1502
+ classes serve a transform constraint, which passes its own — there the scale
1503
+ floors move to the offset and the `shearY` window slides by it.
1504
+ - **`scaleX` / `scaleY` under `local: false` lose the sign.** The reader is
1505
+ `Math.sqrt(a² + c²)`, so a bone at `scaleX: −1` reads **`+1`**, not `−1`: a
1506
+ squash axis driven through negative scale gets the mirror of the dial you wrote.
1507
+ The floor `0` is *reached*, not approached — a bone whose own scale or whose
1508
+ parent's is 0 reads exactly 0 — so a range whose bottom is exactly 0 is fine and
1509
+ one that dips below it is dead.
1510
+ - **`shearY` under `local: false` wraps like `rotate` does, and worse.** It is a
1511
+ difference of two `atan2` calls, so at any one bone orientation the readable
1512
+ window is 360° wide — `(−270 − θx, 90 − θx]`, where `θx` is the bone's world
1513
+ x-axis angle. The bound in the table is the union over every orientation. ⇒ the
1514
+ seam is **not at a fixed value of the driven field**; it is wherever the bone is
1515
+ pointing. Prefer `local: true` for a shear axis.
1516
+ - **The bounds are not round numbers because `MathUtils.PI` is `3.1415927`** — the
1517
+ float32 π of the reference runtime. Every degree in spine-core passes through
1518
+ `180 / 3.1415927`, so a full turn converts as `359.99999468178214` and a bone at
1519
+ 360° reads 5.3e-6° rather than 0°. `shearY`'s two ends are `±2π · radDeg − 90`.
1520
+ - 🔸 A negative `skeleton.scaleX` / `scaleY` — how a consumer mirrors a character —
1521
+ changes **nothing**: every world reader divides the same factor back out, and
1522
+ [measured] the readings are identical to the digit at (1,1), (−1,1), (1,−1),
1523
+ (−1,−1), (2,0.5) and (−0.5,3). A `skeleton` scale of **zero** makes every world
1524
+ reader `NaN`, and `Math.max(0, NaN)` is NaN — but nothing in skeleton data sets
1525
+ that field, so it is the consumer's to avoid.
1526
+
1527
+ ⚠️ **`local: true` reads the number you authored only on a bone nothing else
1528
+ drives.** `Slider.update` calls `bone.appliedPose.validateLocalTransform` first,
1529
+ and on a bone a constraint moved that recomputes the local pose *from the world
1530
+ matrix* — `atan2Deg` for the angles and `Math.sqrt` for `scaleX`. [measured] on
1531
+ one rig, the same slider: a free bone at `rotation: −500` reads `−500` and the
1532
+ same bone inside a transform constraint's `bones` reads `−140.000006`; at
1533
+ `scaleX: −2` the free bone reads `−2` and the constrained one reads `+2`. The
1534
+ producible *set* is unbounded either way — the free case is in it — but if
1535
+ `local: true` is your repair for a world reader's floor, check that the driving
1536
+ bone is not itself constrained.
1537
+
1538
+ #### 3.5.2.2 The circle a `rotate` world dial has to stay inside
1539
+
1409
1540
  🚨 **A `rotate`-driven slider with `local: false` has to stay inside the circle
1410
1541
  `[0, 360]`.** `local: false` reads the bone's **world** rotation through
1411
1542
  `FromRotate.value`, which is a `Math.atan2` — so `(−180, 180]` — with
1412
1543
  `if (value < 0) value += 360` on the end, and the `offsets` a slider hands it are
1413
- all zero. `[0, 360)` is therefore the whole set of values that reader can ever
1544
+ all zero. `[0, 360]` is therefore the whole set of values that reader can ever
1414
1545
  return, and a range leaving it on either side is a wall:
1415
1546
 
1416
1547
  - **Below 0°.** A yaw axis authored the natural way — neutral at 0°, range
@@ -1431,12 +1562,66 @@ Both are refused at compile, each with its own arithmetic in the message and its
1431
1562
  own repair — *"move the range so it does not cross 0°"* and *"move the range so it
1432
1563
  does not run past 360°"*.
1433
1564
 
1565
+ ⭐ **The reading in that message is a modulo, not one turn** — the wrap the bone's
1566
+ matrix has already applied by the time `atan2` reads it, so a range that leaves
1567
+ the circle by *more* than 360° is folded all the way back into `[0, 360)`. The two
1568
+ examples above each sit within one turn, where a single ±360 gives the same
1569
+ answer; past that only the modulo does. [measured] through spine-core, a bone
1570
+ parked at **−500°** drives the slider to **3.600000 s**, which is exactly where a
1571
+ bone parked at **220°** drives it — so the reading is 220°, not −140°, and a
1572
+ refusal naming −140° would be naming a value that reader cannot return at all
1573
+ (issue [#431](https://github.com/firejune/rigc/issues/431)). The same on the other
1574
+ side: **900°** drives it to **0.200000 s**, the time a bone at **180°** selects.
1575
+
1576
+ 📐 **The consequence in that message is computed, not described.** Both refusals
1577
+ end on two numbers read off `[0, 360]` met with the driving values that reach the
1578
+ animation — the same two the message has already printed:
1579
+
1580
+ ```
1581
+ reachable = { to + (v − from) × scale : v ∈ [0, 360] } ∩ [0, duration]
1582
+ held = { v ∈ [0, 360] : the mapped time falls outside [0, duration] }
1583
+ ```
1584
+
1585
+ so the 300°..500° dial above is refused with *"This dial reaches only
1586
+ 0.000s..0.300s of the animation's 1s, and 83.3% of the circle — every reading
1587
+ below 300.000° — is held on the frame at 0.000s"*, and the −15°..15° one with
1588
+ *"…only 0.500s..1.000s of the animation's 1s, and 95.8% of the circle — every
1589
+ reading above 15.000° — is held on the frame at 1.000s"*. [measured] the first of
1590
+ those reproduces a 0.1° sweep of the rig through `spine-core` to **3.2e-8 s**,
1591
+ which is the reader's own `atan2` noise.
1592
+
1593
+ ⚠️ **`loop: true` gets a different sentence, because it is a different runtime.**
1594
+ `Slider.js:63-66` is `p.time = duration + (p.time % duration)` when the slider
1595
+ loops and `Math.max(0, p.time)` when it does not, so nothing is held on a looping
1596
+ slider — [measured] the same 300°..500° rig pins 5⁄6 of the circle to frame 0 at
1597
+ the default and pins *nothing* under `loop: true`. The refusal says so: *"Nothing
1598
+ is held: `"loop": true` wraps the time as `duration + (time % duration)`, so the
1599
+ 140.000° of the range past 360° selects nothing a reading inside the circle does
1600
+ not already select."* The range is still refused either way — a bone cannot be
1601
+ read at 500°, whatever happens to the time afterwards.
1602
+
1603
+ ⭐ **The degrees in that sentence are a *width*** — how much of `lowest..highest`
1604
+ lies outside `[0, 360]` — and not the reach from the boundary to the far end. The
1605
+ two are the same number for a range that *straddles* a boundary, as `300°..500°`
1606
+ does. A range lying **wholly** outside is told its own width instead: `400°..500°`
1607
+ reads *"the 100.000° of the range past 360°"*, and `-500°..-300°` *"the 200.000°
1608
+ of the range below 0°"* (issue
1609
+ [#434](https://github.com/firejune/rigc/issues/434) — both used to print the
1610
+ reach, which on the first of those was 140.000°, wider than the 100°-wide range
1611
+ it was describing).
1612
+
1434
1613
  ⭐ **A range ending exactly on 360° is legal**, and that is the whole turn: a
1435
1614
  wheel, a turntable, a head that goes all the way round, written `from: 0` with a
1436
- `scale` that puts 360° on the last frame. It misses exactly one value — its own
1437
- supremum — and that value is not a dial position: a bone at 360° *is* a bone at
1438
- 0°, and [measured] it poses the skeleton to within **4e-7°** of it, an `atan2`
1439
- artefact rather than a frame. Swept at 0.1° over the circle, `from: 0,
1615
+ `scale` that puts 360° on the last frame. It misses **nothing**: [measured] the
1616
+ wrap `value += 360` on a reading a hair below zero *rounds*, and bisecting the
1617
+ runtime's own `v + 360 === 360` puts the threshold at exactly half an ulp of 360 —
1618
+ so every reading in `[-2.842170943040401e-14°, 0°)` is read as exactly `360`, and
1619
+ the top of that range is reached rather than approached. Nor is 360 a separate
1620
+ dial position: a bone at 360° *is* a bone at 0°, and [measured] it poses the
1621
+ skeleton to within **4e-7°** of it, an `atan2` artefact rather than a frame. (The
1622
+ other half of that same `atan2` leaves a 5.3e-6°-wide hole at 180°, between
1623
+ `179.99999734…` and `180.00000265…` — measured, reported for completeness, and
1624
+ narrower than any dial anybody writes.) Swept at 0.1° over the circle, `from: 0,
1440
1625
  scale: 0.0025` on a 0.9 s animation lands every reading within **1.7e-8 s** of the
1441
1626
  time the mapping asks for; on `loop: true` the endpoint is not even distinct,
1442
1627
  closing on **0.900000 s** exactly. Use `loop: true` for a dial that really does go
@@ -2551,7 +2736,7 @@ deform (what each key does to the geometry — figures with names, never a bar;
2551
2736
  | `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 |
2552
2737
  | `winding` | triangles whose winding survived, and how many the key pinched onto zero area. A fold says so and points at `A39` |
2553
2738
  | 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 |
2554
- | `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 |
2739
+ | `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) |
2555
2740
 
2556
2741
  📌 **The frame is the posed one, and the denominator is 1.000 by definition.**
2557
2742
  Both sides of every comparison are taken at the key's own time with the animation
@@ -2740,7 +2925,7 @@ dial and not reached at all through another.
2740
2925
  - ⚠️ **A key at a time no dial can select is named, not passed.** The cause is
2741
2926
  [#405](https://github.com/firejune/rigc/issues/405)'s wrap: `FromRotate.value`
2742
2927
  under `local: false` is an `atan2` ending `if (value < 0) value += 360`, so
2743
- **`[0, 360)` is the whole of its range** and a mapping needing anything outside
2928
+ **`[0, 360]` is the whole of its range** and a mapping needing anything outside
2744
2929
  it selects nothing. 🚨 A **rig spec** can no longer ask for one — the compiler
2745
2930
  refuses both ends of that circle (§3.5.2), the low one since #405 and the high
2746
2931
  one since [#417](https://github.com/firejune/rigc/issues/417) — but an
@@ -2795,9 +2980,60 @@ the line says so rather than picking one silently.**
2795
2980
  tie. ⛔ Neither case is a refusal and neither is guessed past — an ambiguous
2796
2981
  discovery is a thing to report.
2797
2982
 
2798
- None of this needs anything from you unless a `frame` line carries one of those
2799
- clauses. If one does, it is telling you the dial bone's parent transform is doing
2800
- something you may not have intended.
2983
+ **And a `build` says it too, on `A39`'s stats line**
2984
+ ([#427](https://github.com/firejune/rigc/issues/427)) — because `explain` is not
2985
+ the loop you run, and until this the whole finding lived on a line only `explain`
2986
+ prints. Nothing appears on a rig where the two answers agreed, which is every
2987
+ `local: true` slider and every gallery example:
2988
+
2989
+ ```
2990
+ deformDialsTied=1 deformDialTied=dial|artifact:knob.x@7.071e-1|tied:knob.y@7.071e-1
2991
+ deformDialsDisagreed=1 deformDialDisagreed=dial|artifact:knob.x@2.321e-8|reaches:0.000000..0.003893s|probe:knob.y@1.000e+0|reaches:0.000000..1.000000s|outside:0.500000s+1.000000s
2992
+ ```
2993
+
2994
+ - `artifact:` is the field the **skeleton** names and what one unit of it moves the
2995
+ reading by; `probe:` is the field that **measurably** moves it and by how much.
2996
+ - `reaches:` is the part of that animation's own `0..duration` each of them can
2997
+ select. It is bounded by the same `±16777216` the dial figure is: a field that
2998
+ barely moves the reading needs an unsettable value to move it a whole second.
2999
+ - `outside:` is the key times this survey posed through `probe:` that **no
3000
+ settable value of the field the skeleton names reaches**. Each one is posed
3001
+ through that field and the runtime is asked where it landed, so the list is a
3002
+ measurement. ⭐ `outside:none` is a reading, not an absence — it says both
3003
+ answers select every frame that was measured, so the disagreement changed
3004
+ nothing about what `A39` looked at.
3005
+ - A **tie** never carries `probe:`, `reaches:` or `outside:`, and never counts as a
3006
+ disagreement. There is one belief there, not two.
3007
+
3008
+ **And `explain`'s rollup carries it too, with the breadcrumb its neighbours have**
3009
+ ([#440](https://github.com/firejune/rigc/issues/440)). The per-key `frame` lines
3010
+ say it once each; the rollup says it once per animation, and ends by naming the
3011
+ two readings a `build` prints it under — because they differ by one letter, and a
3012
+ breadcrumb naming only one would send a reader to grep for the other:
3013
+
3014
+ ```
3015
+ .. 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
3016
+ ```
3017
+
3018
+ The count after `outside` is the length of the stats line's own `outside:` list,
3019
+ and `deformDialsDisagreed` / `deformDialDisagreed` are checked against the keys
3020
+ `A39` really printed rather than restated — `DW34`. An agreed dial adds no line
3021
+ (`DW35`) and a **tie** is never rolled up as a disagreement (`DW36`).
3022
+
3023
+ ⛔ **None of it refuses a build**, and the reason is measured rather than chosen.
3024
+ The field the survey drives is the largest response the probe found, so its reach
3025
+ always *contains* the artifact's: a disagreement cannot make `A39` miss a frame the
3026
+ runtime reaches. And every frame it does pose is checked against `SliderPose.time`
3027
+ by spine-core itself, so it cannot make `A39` pose one that never happens either.
3028
+ What is left is a rig naming a property no settable value of turns far enough —
3029
+ which the line above tells you, and which no edit rigc could demand would fix,
3030
+ because the rig may be perfectly correct and driven through the other field.
3031
+
3032
+ None of this needs anything from you unless a `frame` line or one of those stats
3033
+ readings appears. If one does, it is telling you the dial bone's parent transform
3034
+ is doing something you may not have intended — and if `outside:` names times, it is
3035
+ telling you the dial cannot be turned to them through the property your rig spec
3036
+ declares.
2801
3037
 
2802
3038
  ⚠️ **What the artifact cannot say, and rigc therefore does not:** whether a
2803
3039
  slider's animation is *also* played on a track somewhere. Nothing in skeleton data
@@ -2973,8 +3209,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
2973
3209
  | `rig constraint "X": applies animation "Y", which the motion spec does not declare (it declares: …)` | §3.5.2 — fix the slider's `animation`, or add it to the motion spec |
2974
3210
  | `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
2975
3211
  | `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
2976
- | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° … the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360)` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
2977
- | `… run from 300.000° to 500.000° … the whole part of the range past 360° is dead` | §3.5.2 — the same wall at the other end, and the same first repair: `"local": true`, or move the range so it does not run past 360°. `[0, 360)` is the whole of what that reader returns, so a bone turned to 500° is read as 140° and selects a time far from the one the range asked for. Ending *exactly* on 360° is fine — that is the full turn, and the only value it misses is a supremum no dial can be parked at separately |
3212
+ | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° … the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360]` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
3213
+ | `… run from 300.000° to 500.000° … the whole part of the range past 360° is dead` | §3.5.2 — the same wall at the other end, and the same first repair: `"local": true`, or move the range so it does not run past 360°. `[0, 360]` is the whole of what that reader returns, so a bone turned to 500° is read as 140° and selects a time far from the one the range asked for. Ending *exactly* on 360° is fine — that is the full turn, and it misses nothing: the wrap rounds, so a bone a hair below 0° is read as exactly 360 |
2978
3214
  | `skin "S" activates bone "B", but that bone does not declare \`"skin": true\`` | §3.4.1 — the list and the flag are one switch; add the flag or drop the list |
2979
3215
  | `bone "B" declares \`"skin": true\` but no skin activates it` | §3.4.1 — the other half: list it in the skin it belongs to, or drop the flag |
2980
3216
  | `skin "S": uses the long form … and also has a key "X"` | §3.4.1 — move the slot inside `attachments` |