spine-rigc 0.19.0 → 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
 
@@ -2670,7 +2736,7 @@ deform (what each key does to the geometry — figures with names, never a bar;
2670
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 |
2671
2737
  | `winding` | triangles whose winding survived, and how many the key pinched onto zero area. A fold says so and points at `A39` |
2672
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 |
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 |
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) |
2674
2740
 
2675
2741
  📌 **The frame is the posed one, and the denominator is 1.000 by definition.**
2676
2742
  Both sides of every comparison are taken at the key's own time with the animation
@@ -2939,6 +3005,21 @@ deformDialsDisagreed=1 deformDialDisagreed=dial|artifact:knob.x@2.321e-8|reaches
2939
3005
  - A **tie** never carries `probe:`, `reaches:` or `outside:`, and never counts as a
2940
3006
  disagreement. There is one belief there, not two.
2941
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
+
2942
3023
  ⛔ **None of it refuses a build**, and the reason is measured rather than chosen.
2943
3024
  The field the survey drives is the largest response the probe found, so its reach
2944
3025
  always *contains* the artifact's: a disagreement cannot make `A39` miss a frame the
package/docs/FACE.md CHANGED
@@ -364,12 +364,54 @@ geometry ([AUTHORING §3.4](AUTHORING.md)):
364
364
  ```
365
365
  turn ceiling yaw +31.41° / -32.01° pitch +32.01° / -31.41°
366
366
  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
367
- first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it
367
+ 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
368
368
  ```
369
369
 
370
370
  Read it as a fact about **the sheet**. If the number is too small, the fix is in
371
371
  the map — flatten it where the part curves away — and not in the lattice.
372
372
 
373
+ #### ⚠️ That rule presumes the map is continuous
374
+
375
+ "Flatten it where the part curves away" is an edit to a **surface**, and it
376
+ assumes there is one under the whole mesh. A depth sheet estimated from a
377
+ picture of cut-out art is not a surface: it is piecewise, with a **cliff at every
378
+ occlusion boundary** — figure against background at the silhouette, and one part
379
+ of the figure over another wherever they overlap. There is nothing to flatten
380
+ across a cliff, because the two sides are not two ends of a slope. They are two
381
+ different things at two different depths, and a 2.5D turn does not model
382
+ occlusion at all.
383
+
384
+ The ceiling reads the cliff, correctly, and the number it reports is real:
385
+ walked one degree at a time through the survey `A39` refuses from, a reported
386
+ 1.936° admits +1° and reverses 8 triangles at +2°. **The rig genuinely folds at
387
+ two degrees.** What is wrong is not the instrument and not the mesh — it is that
388
+ the question "how far can this turn" has no answer for an input with a
389
+ discontinuity in it, and the ceiling proves it by halving with every doubling of
390
+ the lattice: measured tangent ratios 2.02 / 1.95 / 2.02, `tan t ∝ h` exactly,
391
+ with no limit to converge to.
392
+
393
+ 🚨 **And the two figures beside the ceiling both call it healthy.** The 1st
394
+ percentile reads 1.02–2.17, which is a band reaching the limit together — and it
395
+ *is* a band, because an outline is long. The depth step reads 148–252 levels of
396
+ 255, far above the quantisation floor — and the sheet really did say that much,
397
+ in one step. Divided by the range, the same number says the opposite: the step
398
+ share pins at **0.92–0.99** where rigc's own gallery reads 0.112 and 0.468.
399
+
400
+ ⇒ Two ways out, and **neither of them is flattening**:
401
+
402
+ - **Mesh only what is continuous.** One face, one lock, one sleeve — a region
403
+ the sheet describes without a jump in it — rather than a lattice over a whole
404
+ figure. Whether a mask that tight gives a usable angle is not yet measured;
405
+ the mask has to be painted rather than thresholded, for the reason `soft`
406
+ is painted (§3.4's `soft` block).
407
+ - **State a sheet that was authored rather than estimated.** §2.2's raised
408
+ cosine holds 63–64° from 289 vertices to 32,761 because somebody drew its
409
+ slope. That is the input this whole section is about.
410
+
411
+ ⛔ rigc will not decide that your sheet is the wrong kind of thing. It has every
412
+ authority to say what it measured, and the step share is that
413
+ ([#448](https://github.com/firejune/rigc/issues/448)).
414
+
373
415
  📐 Method, harness and the full ladders live in the repository rather than in
374
416
  this package, as
375
417
  [`bench/studies/2026-09-05-density`](https://github.com/firejune/rigc/tree/main/bench/studies/2026-09-05-density) —
@@ -394,17 +436,22 @@ the range describes the same surface and reports 5.8° less of it. Method and
394
436
  ladders:
395
437
  [`bench/studies/2026-09-05-noise`](https://github.com/firejune/rigc/tree/main/bench/studies/2026-09-05-noise).
396
438
 
397
- ⭐ **And you do not have to guess which of the two you are looking at.** The two
398
- lines under the ceiling say it ([#412](https://github.com/firejune/rigc/issues/412)):
399
- the **1st percentile over the ceiling** is near 1 when a band of the mesh reaches
400
- the limit together, which is a form, and near 10 when one triangle does, which is
401
- a texel — 1.003 against 10.652 for the two sheets above. The **depth step across
402
- the triangle that folds first**, in levels, is the other half: below about 3 the
403
- ceiling is quantisation, and at 1 it is `atan(255·h / zScale)` and carries nothing
404
- about the form at all. Both are reports and neither moves the ceiling — ⛔ rigc
405
- will not filter a depth map, because a smoothed measurement would describe a
406
- surface the deform key is not built from and `A39` would go on refusing at the
407
- raw angle. [AUTHORING §3.4](AUTHORING.md) has the reading table.
439
+ ⭐ **And you do not have to guess which of the three you are looking at.** The
440
+ lines under the ceiling say it
441
+ ([#412](https://github.com/firejune/rigc/issues/412),
442
+ [#448](https://github.com/firejune/rigc/issues/448)): the **1st percentile over
443
+ the ceiling** is near 1 when a band of the mesh reaches the limit together and
444
+ near 10 when one triangle does, which is a texel — 1.003 against 10.652 for the
445
+ two sheets above. The **depth step across the triangle that folds first**, in
446
+ levels, is the second: below about 3 the ceiling is quantisation, and at 1 it is
447
+ `atan(255·h / zScale)` and carries nothing about the form at all. The **same step
448
+ over the range the mesh sampled** is the third, and it is the one that separates
449
+ a steep surface from a cliff — a form's halves with every doubling of the lattice
450
+ while its angle settles, a discontinuity's does not move while its angle halves.
451
+ All three are reports and none of them moves the ceiling — ⛔ rigc will not filter
452
+ a depth map, because a smoothed measurement would describe a surface the deform
453
+ key is not built from and `A39` would go on refusing at the raw angle.
454
+ [AUTHORING §3.4](AUTHORING.md) has the reading table.
408
455
 
409
456
  ---
410
457
 
@@ -872,7 +919,7 @@ anything:
872
919
  Three collisions survive there: `headroll` rotate in all three, `brows`
873
920
  translatey in two, and the locks' rotate in two. ⚠️ **On plain Spine the fixes
874
921
  are ordinary — `MixBlend.add` on the layered track, or splitting a bone into a
875
- stack (`headroll_idle` under `headroll_scene`) — but both are runtime or rig
922
+ stack (`headroll_idle` under `headroll_layer`) — but both are runtime or rig
876
923
  decisions the motion spec cannot express, so nothing warns an author that two of
877
924
  their animations will fight.**
878
925
 
@@ -1195,7 +1242,7 @@ by name, on both its keys, with the triangles listed:
1195
1242
  ```
1196
1243
  FAIL A39_DEFORM_KEEPS_TRIANGLE_WINDING: animation "turn" deform head/head key 1
1197
1244
  (t=0.6200000047683716s): 8 of 32 triangle(s) reverse winding — triangle 0
1198
- [0,5,6] 1890.000 -> -544.548px²; …
1245
+ [0,15,16] 1890.000 -> -544.548px²; …
1199
1246
  ```
1200
1247
 
1201
1248
  and builds (a) and the good one both still PASS it, because **an inverted band is
@@ -1,13 +1,6 @@
1
1
  # Spine 4.3 export-format surface vs. rigc coverage vs. the example ladder
2
2
 
3
- <!--
4
- The marker below is the machine half of the 🔼 note further down: this file's
5
- statements are a dated snapshot, not current state, so the selftest's currency
6
- gate (CUR01–CUR06, issue #360) leaves its numbers alone. Do NOT copy it into a
7
- document that describes how rigc behaves today — that is the whole class the
8
- gate exists to catch.
9
- -->
10
- <!-- currency: dated-record -->
3
+ **A dated record:** what this page states was measured on the date it carries and is not kept current, so the selftest's currency gate leaves its figures alone.
11
4
 
12
5
  Research note, 2026-08-22. A survey, not a plan: it establishes what the 4.3 export format can hold,
13
6
  what rigc emits today, and what the official example projects actually use — so the gap list is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -910,6 +910,21 @@ function partPlate(img: CompiledImage): Plate {
910
910
  return img.atlas === undefined ? page : extractRegion(page, img.atlas);
911
911
  }
912
912
 
913
+ /**
914
+ * A part's alpha channel on its own grid — where the drawing is, and where it
915
+ * is not.
916
+ *
917
+ * Two readers want exactly this and they used to be one inline loop and one
918
+ * absence: the contour generator traces it, and `sampleMeshDepth` counts the
919
+ * vertices whose depth came from outside it (issue #449). One function so the
920
+ * two cannot come to mean different things by "the part draws here".
921
+ */
922
+ function plateAlpha(plate: Plate): Uint8Array {
923
+ const alpha = new Uint8Array(plate.width * plate.height);
924
+ for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3];
925
+ return alpha;
926
+ }
927
+
913
928
  export function compile(opts: CompileOptions): CompileResult {
914
929
  const rigPath = resolve(opts.rigPath);
915
930
  const motionPath = resolve(opts.motionPath);
@@ -2711,9 +2726,40 @@ function buildGeneratedMesh(
2711
2726
  * sheet that carries an alpha channel is held to it, and the fix — dilate the
2712
2727
  * sheet past the mesh margin — is named in the message.
2713
2728
  *
2714
- * A sheet with no alpha channel covers its whole grid by construction and the
2715
- * third check has nothing to test; what the background level means is then the
2716
- * author's statement, and `range` in the report is where it shows up.
2729
+ * ## The count beside the third refusal, and why it is a count (issue #449)
2730
+ *
2731
+ * A sheet with no transparent texel anywhere covers its whole grid by
2732
+ * construction, so the third refusal has nothing to hold it to and skips. That
2733
+ * is the encoding a monocular depth estimator produces — a full-frame opaque
2734
+ * render with the background in it — and it is a legitimate statement, so
2735
+ * skipping the refusal is right. What was wrong is that nothing took its place:
2736
+ * measured on one cell and one depth field, the same 54 %-background mesh is a
2737
+ * named refusal when the sheet's alpha is cut to the art and a green build when
2738
+ * it is 255 everywhere.
2739
+ *
2740
+ * ⚠️ **`range` is not where that shows up**, and this header said it was until
2741
+ * #449 measured it. On the build above `range` reads `[0, 223.97]` of a stated
2742
+ * 224 — full, healthy — because the background is a legitimate depth value and
2743
+ * a map that is half background has exactly as full a range as one that is all
2744
+ * subject. "Covers its whole grid" is true and about the wrong grid: the
2745
+ * question was never coverage of the *sheet*, it was whether the mesh is
2746
+ * sampling **art**.
2747
+ *
2748
+ * ⇒ what shows it is `undrawn`: how many of the mesh's vertices take their
2749
+ * depth from a texel **the part image does not draw**, over the same four
2750
+ * bilinear taps the refusal walks. That is a measurement, not a guess, so it is
2751
+ * reported and never refused — a full-frame sheet is a statement rigc has no
2752
+ * authority to guess away.
2753
+ *
2754
+ * 🔸 It is a raw count with no reach subtracted from it, which is why a
2755
+ * `contour` mesh reports most or all of its vertices: its outline is pushed
2756
+ * `margin` pixels outside the silhouette by design, and out there the part
2757
+ * draws nothing. That is the true reading of that geometry rather than a defect
2758
+ * in the count — the rim's depth really did come from off the art, which is
2759
+ * benign on a sheet dilated past the margin and is the whole failure on a
2760
+ * full-frame estimate. Discounting the margin would be borrowing a number
2761
+ * authored for the trace to mean "close enough" for the sheet, and rigc does
2762
+ * not invent tolerances.
2717
2763
  */
2718
2764
  function sampleMeshDepth(
2719
2765
  spec: NonNullable<Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'contour' }>['depth']>,
@@ -2734,6 +2780,14 @@ function sampleMeshDepth(
2734
2780
  * units `z` is already in before it is taken.
2735
2781
  */
2736
2782
  toBind: (px: number, py: number) => readonly [number, number],
2783
+ /**
2784
+ * The PART's own alpha channel, in the same grid the sheet is sampled in —
2785
+ * what the drawing actually covers, as against what the sheet covers.
2786
+ *
2787
+ * The two are different questions and the header says why. This one has no
2788
+ * refusal behind it: it is counted, reported, and left to the author.
2789
+ */
2790
+ partAlpha: Uint8Array,
2737
2791
  partWidth: number,
2738
2792
  partHeight: number,
2739
2793
  where: string,
@@ -2771,19 +2825,32 @@ function sampleMeshDepth(
2771
2825
  // Every texel a bilinear tap touches has to be covered, not just the nearest
2772
2826
  // one: a vertex half a pixel outside the sheet blends real depth with the
2773
2827
  // background and lands somewhere neither states.
2828
+ //
2829
+ // 🔒 One walk, one footprint, two readings. `cover` is the SHEET's alpha and
2830
+ // decides a refusal; `partAlpha` is the PART's and decides a count. They are
2831
+ // deliberately the same four taps and the same clamp — the second is the
2832
+ // first's instinct applied to the other input (issue #449), and writing it as
2833
+ // a second loop is how the two would come to disagree about which texels a
2834
+ // vertex reads.
2835
+ const cx = (i: number): number => (i < 0 ? 0 : i > partWidth - 1 ? partWidth - 1 : i);
2836
+ const cy = (j: number): number => (j < 0 ? 0 : j > partHeight - 1 ? partHeight - 1 : j);
2774
2837
  const uncovered: number[] = [];
2775
- if (!opaqueEverywhere) {
2776
- const cx = (i: number): number => (i < 0 ? 0 : i > partWidth - 1 ? partWidth - 1 : i);
2777
- const cy = (j: number): number => (j < 0 ? 0 : j > partHeight - 1 ? partHeight - 1 : j);
2778
- for (let v = 0; v < points.length; v++) {
2779
- const x0 = Math.floor(points[v][0] - 0.5);
2780
- const y0 = Math.floor(points[v][1] - 0.5);
2781
- let covered = true;
2782
- for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) {
2783
- if (cover[cy(y0 + dy) * partWidth + cx(x0 + dx)] !== 255) covered = false;
2784
- }
2785
- if (!covered) uncovered.push(v);
2838
+ let undrawn = 0;
2839
+ for (let v = 0; v < points.length; v++) {
2840
+ const x0 = Math.floor(points[v][0] - 0.5);
2841
+ const y0 = Math.floor(points[v][1] - 0.5);
2842
+ let covered = true;
2843
+ let drawn = true;
2844
+ for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) {
2845
+ const at = cy(y0 + dy) * partWidth + cx(x0 + dx);
2846
+ if (cover[at] !== 255) covered = false;
2847
+ // Zero, not a threshold: "the part image draws nothing here" is a fact
2848
+ // about the file, and any other cut-off would be rigc deciding how faint
2849
+ // a texel has to be before it stops counting as art.
2850
+ if (partAlpha[at] === 0) drawn = false;
2786
2851
  }
2852
+ if (!opaqueEverywhere && !covered) uncovered.push(v);
2853
+ if (!drawn) undrawn++;
2787
2854
  }
2788
2855
  if (uncovered.length > 0) {
2789
2856
  const first = uncovered[0];
@@ -2825,6 +2892,7 @@ function sampleMeshDepth(
2825
2892
  zScale: spec.zScale,
2826
2893
  tone,
2827
2894
  range: [r6(lo), r6(hi)],
2895
+ undrawn,
2828
2896
  ceiling: turnCeiling(
2829
2897
  points.map(([px, py]) => toBind(px, py)),
2830
2898
  z,
@@ -3064,6 +3132,7 @@ function buildGridAttachment(
3064
3132
  geometry.points,
3065
3133
  geometry.triangles,
3066
3134
  (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
3135
+ plateAlpha(plate),
3067
3136
  plate.width,
3068
3137
  plate.height,
3069
3138
  where,
@@ -3160,8 +3229,7 @@ function buildContourAttachment(
3160
3229
  // none — so "this part has no silhouette to trace" is a question about pixels,
3161
3230
  // and `buildContourMesh` refuses it by counting them.
3162
3231
  const plate = partPlate(img);
3163
- const alpha = new Uint8Array(plate.width * plate.height);
3164
- for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3];
3232
+ const alpha = plateAlpha(plate);
3165
3233
 
3166
3234
  const margin = generator.margin ?? CONTOUR_DEFAULTS.margin;
3167
3235
  const maxVertices = generator.maxVertices ?? CONTOUR_DEFAULTS.maxVertices;
@@ -3219,6 +3287,7 @@ function buildContourAttachment(
3219
3287
  geometry.points,
3220
3288
  geometry.triangles,
3221
3289
  (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
3290
+ alpha,
3222
3291
  plate.width,
3223
3292
  plate.height,
3224
3293
  where,
package/src/depth.ts CHANGED
@@ -374,14 +374,20 @@ const CEILING_AREA_FLOOR = 1e-6;
374
374
  * Where one triangle turns inside out, which triangle that is — and, beside it,
375
375
  * what the REST of this axis and side's triangles do.
376
376
  *
377
- * The first four fields are about one triangle. `count` and `p1` are about the
378
- * population it is the minimum of, and they are here because the minimum alone
379
- * cannot answer the question an author actually has: `degrees` is the same
380
- * number whether a whole band of the mesh reaches the limit together or one
381
- * triangle does, and those are a form and a bad texel respectively
377
+ * Everything down to `stepShare` is about one triangle. `count` and `p1` are
378
+ * about the population it is the minimum of, and they are here because the
379
+ * minimum alone cannot answer the question an author actually has: `degrees` is
380
+ * the same number whether a whole band of the mesh reaches the limit together
381
+ * or one triangle does, and those are a form and a bad texel respectively
382
382
  * ([#412](https://github.com/firejune/rigc/issues/412),
383
383
  * `bench/studies/2026-09-05-noise` §6).
384
384
  *
385
+ * ⚠️ And a band is not sufficient evidence of a form either, which is what
386
+ * `stepShare` is here for: an OUTLINE is a band, so a mesh whose ceiling is set
387
+ * by the occlusion edge of a cut-out reads `p1/degrees` near 1 and a `depthStep`
388
+ * of most of the sheet's range — both of the older figures reading *healthy* on
389
+ * the same measurement ([#448](https://github.com/firejune/rigc/issues/448)).
390
+ *
385
391
  * ⛔ Neither figure changes `degrees`, and neither is a threshold. rigc does not
386
392
  * have the authority to guess its input away, so nothing here filters,
387
393
  * smooths or rejects a sample — the ceiling stays the raw sheet read through
@@ -407,6 +413,42 @@ export interface FoldLimit {
407
413
  * with no form left in it (`bench/studies/2026-09-05-noise` §3).
408
414
  */
409
415
  depthStep: number;
416
+ /**
417
+ * `depthStep` over the depth range this mesh actually sampled — what fraction
418
+ * of everything the sheet said across the whole part it said across the one
419
+ * triangle that folds first.
420
+ *
421
+ * ⭐ The figure that tells a form from a cliff, and it is the one reading the
422
+ * other two cannot give ([#448](https://github.com/firejune/rigc/issues/448)).
423
+ * A form has a slope, so refining the lattice halves the step and halves this
424
+ * with it while the angle converges. A **discontinuity has no slope**: the
425
+ * step stays the whole range however fine the lattice gets, this figure pins
426
+ * near 1, and the ceiling halves with every doubling instead of converging —
427
+ * `tan t ∝ h`, an angle that describes nothing at any density.
428
+ *
429
+ * ⚠️ **The ceiling is not wrong when this reads high; the input is not a
430
+ * surface.** Measured on estimated sheets: reported 1.936°, and the runtime
431
+ * admits +1° and reverses 8 triangles at +2°. The rig genuinely folds at two
432
+ * degrees. What a `depthStep` near the whole range means is an occlusion
433
+ * boundary — figure against background, or one part of a figure over
434
+ * another — and a 2.5D turn does not model occlusion at all, so **no angle is
435
+ * the right one to quote for it**. The fix is upstream of the ceiling: mesh
436
+ * only what is continuous, or state a sheet that was authored rather than
437
+ * estimated.
438
+ *
439
+ * 🔒 In (0, 1] by construction, never a division by zero. `depthStep` is a
440
+ * difference between two of this mesh's own `z` values, so the span over all
441
+ * of them is at least as large; and a fold only exists where the axis area
442
+ * with `z` substituted in is non-zero, which needs two vertices of the
443
+ * triangle at different depths — so a mesh with no span reports no fold and
444
+ * never reaches the divide.
445
+ *
446
+ * ⛔ A report and never a threshold. rigc does not decide that an author's
447
+ * sheet is the wrong kind of thing; nothing here filters, and nothing here
448
+ * moves a ceiling. What to read off the number is stated in
449
+ * `docs/AUTHORING.md` §3.4.
450
+ */
451
+ stepShare: number;
410
452
  /** How many triangles fold on this axis and side — the population below. */
411
453
  count: number;
412
454
  /**
@@ -509,9 +551,26 @@ export interface TurnCeiling {
509
551
  * triangle, which is a texel. A `depthStep` of one level is `atan(255·h/zScale)`
510
552
  * and says nothing about the form at all.
511
553
  *
512
- * ⛔ Both are reports. Nothing here filters the sheet, and nothing here moves a
513
- * ceiling: a smoothed measurement would describe a surface the deform key is
514
- * not built from, and would part company with the gate that reads the raw one.
554
+ * ## What a band cannot say either (issue #448)
555
+ *
556
+ * Both of those figures read *healthy* on an estimated depth sheet over cut-out
557
+ * art, and they do not merely stay silent — they affirm it. `p1/degrees` comes
558
+ * back at 1.02–2.17, which reads as a band; `depthStep` at 148–252 levels of
559
+ * 255, which reads as plenty said. It **is** a band, because an outline is long,
560
+ * and the sheet did say a great deal across that triangle — it said the whole
561
+ * distance from the figure to the background in one step.
562
+ *
563
+ * So each `FoldLimit` also carries `stepShare`, the same step divided by the
564
+ * range this mesh sampled. A form's halves per refinement while its angle
565
+ * converges; a discontinuity's pins near 1 while the angle halves. Measured:
566
+ * rigc's own gallery reads 0.112 and 0.468, a synthetic raised cosine 0.394
567
+ * falling to 0.027 under refinement, the same cosine with one planted cliff a
568
+ * flat 0.50, and estimated sheets 0.92–0.99.
569
+ *
570
+ * ⛔ All three are reports. Nothing here filters the sheet, and nothing here
571
+ * moves a ceiling: a smoothed measurement would describe a surface the deform
572
+ * key is not built from, and would part company with the gate that reads the
573
+ * raw one.
515
574
  *
516
575
  * @param points Vertices in the BIND space the deform offsets are authored in.
517
576
  * Areas are translation-invariant, so the origin does not matter; the scale
@@ -546,6 +605,18 @@ export function turnCeiling(
546
605
  }
547
606
  const floor = largest * CEILING_AREA_FLOOR;
548
607
 
608
+ // The denominator `stepShare` is taken against: the depth range this mesh
609
+ // sampled, which is `range` in the report read off the same array. Taken over
610
+ // the WHOLE mesh rather than per triangle, because the question the figure
611
+ // answers is how much of what the sheet said here one triangle said.
612
+ let zLo = Infinity;
613
+ let zHi = -Infinity;
614
+ for (const d of z) {
615
+ if (d < zLo) zLo = d;
616
+ if (d > zHi) zHi = d;
617
+ }
618
+ const zSpan = zHi - zLo;
619
+
549
620
  // One list per axis and side, so the ceiling can say whether it is the floor
550
621
  // of a BAND or of a single triangle. Nothing here filters: every measurable
551
622
  // triangle goes in exactly once, in triangle order, and the sort below is
@@ -591,7 +662,11 @@ export function turnCeiling(
591
662
  // `count` and `p1` are filled once the whole population is in; a minimum
592
663
  // cannot know its own percentile while it is still being found.
593
664
  if (held === null || degrees < held.degrees) {
594
- out[axis][side] = { degrees, triangle: n, ids, depthStep, count: 0, p1: null };
665
+ // `zSpan` cannot be zero here: `aAxis !== 0` needs two of this
666
+ // triangle's vertices at different depths, and the span over the whole
667
+ // mesh is at least that difference. A guard would be an unreachable
668
+ // branch, and an unreachable branch is not a control.
669
+ out[axis][side] = { degrees, triangle: n, ids, depthStep, stepShare: depthStep / zSpan, count: 0, p1: null };
595
670
  }
596
671
  }
597
672
  }
package/src/types.ts CHANGED
@@ -1057,15 +1057,6 @@ export interface CompileResult {
1057
1057
  * authored mesh was not traced.
1058
1058
  */
1059
1059
  holePixels?: number;
1060
- /**
1061
- * What a depth map put on this mesh's vertices, when one was named.
1062
- *
1063
- * The digest is over the levels rather than the file, so a re-encode of the
1064
- * same sheet reports the same provenance; `range` is what was actually
1065
- * sampled, which is the number that says whether the map covers the part or
1066
- * a corner of it. Absent when no map was named — never zeroes, which would
1067
- * read as "sampled and found flat".
1068
- */
1069
1060
  /**
1070
1061
  * The soft region a `soft` block carried to its own bone, when one was
1071
1062
  * named — the mask, its digest, and how many vertices it reached.
@@ -1075,6 +1066,18 @@ export interface CompileResult {
1075
1066
  * and a nose does not wobble.
1076
1067
  */
1077
1068
  soft?: { mask: string; digest: string; bone: string; carried: number; ramped: number };
1069
+ /**
1070
+ * What a depth map put on this mesh's vertices, when one was named.
1071
+ *
1072
+ * The digest is over the levels rather than the file, so a re-encode of the
1073
+ * same sheet reports the same provenance; `range` is what was actually
1074
+ * sampled. Absent when no map was named — never zeroes, which would read as
1075
+ * "sampled and found flat".
1076
+ *
1077
+ * ⚠️ This comment sat above `soft` rather than above the field it describes
1078
+ * until issue #449 came to add to it, which is the same drift `CUR07` was
1079
+ * built for one file over — nothing derives a doc comment's neighbour.
1080
+ */
1078
1081
  depth?: {
1079
1082
  /** The sheet, as written in the spec. */
1080
1083
  image: string;
@@ -1086,6 +1089,24 @@ export interface CompileResult {
1086
1089
  tone: { gamma: number; contrast: number; bias: number };
1087
1090
  /** Least and greatest `z` over the mesh's vertices, in attachment units. */
1088
1091
  range: [number, number];
1092
+ /**
1093
+ * How many of the mesh's vertices took their depth from a texel **the
1094
+ * part image does not draw** (issue #449).
1095
+ *
1096
+ * ⚠️ Not what `range` says, and this is the field that exists because
1097
+ * `range` was claimed to say it. A map that is half background has
1098
+ * exactly as full a range as one that is all subject, because a
1099
+ * background level is a legitimate depth — so a full-frame sheet over a
1100
+ * cut-out part reports a healthy `[0, 223.97]` of 224 with 54 % of the
1101
+ * mesh reading background.
1102
+ *
1103
+ * A count and never a refusal: the same defect is already a named refusal
1104
+ * when the sheet's alpha is cut to the art, and a sheet that is opaque
1105
+ * everywhere is a statement rigc has no authority to guess away. Zero is
1106
+ * a real answer here rather than an absence — every mesh that names a
1107
+ * depth map also names an image, so the measurement is always taken.
1108
+ */
1109
+ undrawn: number;
1089
1110
  /**
1090
1111
  * The turn this geometry takes on this sheet before a triangle reverses,
1091
1112
  * per axis and per direction — `src/depth.ts`'s `turnCeiling`.