rig-c 0.0.0-stage → 2.20.4

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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
package/src/depth.ts ADDED
@@ -0,0 +1,784 @@
1
+ /**
2
+ * A **depth map** as a mesh input: a greyscale sheet, in the part's own pixel
3
+ * grid, that says how far in front of the turn axis each pixel sits.
4
+ *
5
+ * ## Why this is an input and not a measurement
6
+ *
7
+ * `yaw` and `pitch` (`src/deformgen.ts`) turn a part by treating it as painted
8
+ * on a cylinder: a vertex at `u` off the axis gets `z = √(radius² − u²)`, and
9
+ * the key is that rotation projected back to the screen. One radius per
10
+ * attachment is a whole model of a shape, and it is the right model for a
11
+ * fringe or a plate that really does bend like a barrel.
12
+ *
13
+ * It is the wrong model for a face. A nose is not on the skull's cylinder, an
14
+ * ear is behind it, and no single radius puts both where they are — the
15
+ * cylinder answers "how far off the axis is this column" when the question is
16
+ * "how far forward is this pixel". A depth map answers the second question
17
+ * directly, per vertex, and the arithmetic downstream does not otherwise
18
+ * change: the same closed form runs, with `z` read instead of derived.
19
+ *
20
+ * ⚠️ **The map is relative and rigc does not make it absolute.** 8 bits of
21
+ * level say nothing about world units, so `zScale` — how far apart level 0 and
22
+ * level 255 are — is an authored number, exactly like `radius` was. rigc
23
+ * refuses to guess it, records the map's digest so a claim can name which sheet
24
+ * produced it, and reports the range it actually sampled. What it will not do
25
+ * is measure a plate and invent a depth from it.
26
+ *
27
+ * ## The tone curve, and why it lives here
28
+ *
29
+ * A consumer that renders the same sheet in a shader applies a tone curve
30
+ * before displacing anything — gamma, contrast, bias. If rigc sampled the raw
31
+ * level and the shader sampled a curved one, the mesh and the shader would be
32
+ * two different surfaces and the cross-check between them (#382's positive
33
+ * control) would compare nothing. So the curve is stated in the spec, applied
34
+ * here, and written into the report in the form the consumer can compare
35
+ * against.
36
+ *
37
+ * Order of operations, fixed and stated because every one of them is a place
38
+ * two implementations can silently disagree:
39
+ *
40
+ * 1. **bilinear sample of the RAW level**, at pixel centres — this is what a
41
+ * GPU's linear filter does, and doing it after the curve would filter a
42
+ * different function;
43
+ * 2. **`near`**, which turns a level into a nearness in 0..1;
44
+ * 3. **the tone curve**, clamped back into 0..1;
45
+ * 4. **`zScale`**, which is the only step carrying units.
46
+ */
47
+ import { createHash } from 'node:crypto';
48
+
49
+ /** Which end of the range is closest to the viewer. */
50
+ export type DepthNear = 'white' | 'black';
51
+
52
+ /**
53
+ * The curve applied between "nearness in 0..1" and the value `zScale` multiplies.
54
+ *
55
+ * Stated in full rather than left partly defaulted, so the report can print the
56
+ * curve a consumer has to match without a reader having to know which fields
57
+ * were written and which were filled in.
58
+ */
59
+ export interface DepthTone {
60
+ /** Applied to the 0..1 nearness. 1 is a straight line. */
61
+ gamma: number;
62
+ /** Fanned about 0.5. 1 leaves the range alone. */
63
+ contrast: number;
64
+ /** Added after the fan. 0 leaves the midpoint alone. */
65
+ bias: number;
66
+ }
67
+
68
+ /** The curve that changes nothing — what an unstated tone block means. */
69
+ export const DEPTH_TONE_IDENTITY: DepthTone = { gamma: 1, contrast: 1, bias: 0 };
70
+
71
+ export interface DepthMap {
72
+ width: number;
73
+ height: number;
74
+ /** One level per pixel, row major, y down, 0..255. */
75
+ level: Uint8Array;
76
+ }
77
+
78
+ export class DepthError extends Error {}
79
+
80
+ /**
81
+ * Refuse a tone block that cannot describe a curve.
82
+ *
83
+ * `gamma` and `contrast` at or below 0 are the two that matter: a gamma of 0
84
+ * maps every level to 1 and a contrast of 0 maps every level to the midpoint,
85
+ * so both turn a depth map into a constant — a flat part with a file behind it,
86
+ * which is precisely the silence this feature exists to remove.
87
+ */
88
+ export function checkTone(tone: DepthTone, where: string): void {
89
+ const finite = (n: number, field: string): void => {
90
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
91
+ throw new DepthError(`${where}: depth tone "${field}" is ${JSON.stringify(n)}; it is a finite number`);
92
+ }
93
+ };
94
+ finite(tone.gamma, 'gamma');
95
+ finite(tone.contrast, 'contrast');
96
+ finite(tone.bias, 'bias');
97
+ if (tone.gamma <= 0) {
98
+ throw new DepthError(
99
+ `${where}: depth tone "gamma" is ${tone.gamma}; a gamma at or below 0 maps every level to the same nearness, ` +
100
+ 'so the map would describe a flat part. It is a positive number, and 1 is the straight line.',
101
+ );
102
+ }
103
+ if (tone.contrast <= 0) {
104
+ throw new DepthError(
105
+ `${where}: depth tone "contrast" is ${tone.contrast}; a contrast at or below 0 collapses the range onto the ` +
106
+ 'midpoint (or turns it inside out), so the map would describe a flat part. It is a positive number, and 1 ' +
107
+ 'leaves the range alone.',
108
+ );
109
+ }
110
+ }
111
+
112
+ /** Refuse a scale that carries no units. */
113
+ export function checkZScale(zScale: number, where: string): void {
114
+ if (typeof zScale !== 'number' || !Number.isFinite(zScale)) {
115
+ throw new DepthError(`${where}: "zScale" is ${JSON.stringify(zScale)}; it is a finite number of world units`);
116
+ }
117
+ if (zScale <= 0) {
118
+ throw new DepthError(
119
+ `${where}: "zScale" is ${zScale}; it is how many units the map's full range spans, so a positive number. ` +
120
+ 'To put the near end at the back, say "near": "black" — a negative scale states the same thing twice and ' +
121
+ 'the two can then disagree.',
122
+ );
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Bilinear sample of the raw level at a part-local pixel position, y down.
128
+ *
129
+ * Pixel `i` covers `[i, i+1)` and its centre is at `i + 0.5`, so a position is
130
+ * converted to centre space before interpolating — sampling at `i` without that
131
+ * shift reads a value half a pixel off, which is invisible on a smooth sheet
132
+ * and wrong at every edge. Positions outside the map clamp to the edge texel,
133
+ * the same as a GPU's clamp-to-edge; a vertex out there is a separate refusal
134
+ * the caller makes, and clamping here keeps this function total.
135
+ */
136
+ export function sampleLevel(map: DepthMap, x: number, y: number): number {
137
+ const { width: w, height: h, level } = map;
138
+ const u = x - 0.5;
139
+ const v = y - 0.5;
140
+ const x0 = Math.floor(u);
141
+ const y0 = Math.floor(v);
142
+ const fx = u - x0;
143
+ const fy = v - y0;
144
+ const cx = (i: number): number => (i < 0 ? 0 : i > w - 1 ? w - 1 : i);
145
+ const cy = (j: number): number => (j < 0 ? 0 : j > h - 1 ? h - 1 : j);
146
+ const x0c = cx(x0);
147
+ const x1c = cx(x0 + 1);
148
+ const y0c = cy(y0);
149
+ const y1c = cy(y0 + 1);
150
+ const l00 = level[y0c * w + x0c];
151
+ const l10 = level[y0c * w + x1c];
152
+ const l01 = level[y1c * w + x0c];
153
+ const l11 = level[y1c * w + x1c];
154
+ const top = l00 + (l10 - l00) * fx;
155
+ const bottom = l01 + (l11 - l01) * fx;
156
+ return top + (bottom - top) * fy;
157
+ }
158
+
159
+ /** A raw level in 0..255 to a nearness in 0..1, `near` applied and the curve run. */
160
+ export function toneLevel(rawLevel: number, near: DepthNear, tone: DepthTone): number {
161
+ const nearness = near === 'white' ? rawLevel / 255 : 1 - rawLevel / 255;
162
+ const curved = (Math.pow(nearness, tone.gamma) - 0.5) * tone.contrast + 0.5 + tone.bias;
163
+ return curved < 0 ? 0 : curved > 1 ? 1 : curved;
164
+ }
165
+
166
+ /**
167
+ * The whole chain at one position: sample, `near`, curve, scale.
168
+ *
169
+ * The four steps are the four places two implementations of one model can drift
170
+ * apart, which is why the module header fixes their order and this function is
171
+ * the only thing that runs them.
172
+ */
173
+ export function sampleDepth(
174
+ map: DepthMap,
175
+ x: number,
176
+ y: number,
177
+ near: DepthNear,
178
+ tone: DepthTone,
179
+ zScale: number,
180
+ ): number {
181
+ return toneLevel(sampleLevel(map, x, y), near, tone) * zScale;
182
+ }
183
+
184
+ /**
185
+ * A digest of the map's pixels, so a claim can name the sheet it was made from.
186
+ *
187
+ * Over the levels alone, not the PNG file: the same depth re-encoded at a
188
+ * different compression level is the same map, and a digest that changed with
189
+ * the encoder would make provenance unfalsifiable in the direction that
190
+ * matters — two runs the reader believes differ when they do not.
191
+ */
192
+ export function depthDigest(map: DepthMap): string {
193
+ const h = createHash('sha256');
194
+ const header = new Uint8Array(8);
195
+ new DataView(header.buffer).setUint32(0, map.width);
196
+ new DataView(header.buffer).setUint32(4, map.height);
197
+ h.update(header);
198
+ h.update(map.level);
199
+ return h.digest('hex').slice(0, 16);
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Two evaluations of one turn, compared
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * The 2.5D turn's displacement along the driving axis, for one point.
208
+ *
209
+ * The same closed form `evaluateDeformTransform` runs, written once more here
210
+ * because this file compares two ways of EVALUATING it and neither may quietly
211
+ * be a different model. `u` is the point's offset from the axis; `z` is how far
212
+ * in front of that axis it sits.
213
+ */
214
+ export function turnDisplacement(u: number, z: number, radians: number): number {
215
+ return u * (Math.cos(radians) - 1) - z * Math.sin(radians);
216
+ }
217
+
218
+ /** What a field comparison measured. Pixels, in the part's own grid. */
219
+ export interface FieldAgreement {
220
+ /** Pixels compared: inside the art and inside some triangle. */
221
+ samples: number;
222
+ /** Pixels the mesh covers that the art does not reach, or vice versa. */
223
+ skipped: number;
224
+ /** Mean |mesh − continuous| displacement, in part pixels. */
225
+ mean: number;
226
+ /** Worst |mesh − continuous| displacement, in part pixels. */
227
+ worst: number;
228
+ }
229
+
230
+ /**
231
+ * How closely a mesh's piecewise-linear turn reproduces the continuous one.
232
+ *
233
+ * ## What this measures, and what it does not
234
+ *
235
+ * ⚠️ It is **not** a check of the sampler. Both sides read the same sheet
236
+ * through `sampleDepth`, deliberately — a comparison where the two sides
237
+ * disagreed about the depth would be measuring the wrong thing. `DP01`–`DP03`
238
+ * in `selftest.ts` are what hold the sampler honest.
239
+ *
240
+ * What differs is the **evaluation**. The continuous side gives every pixel its
241
+ * own depth and displaces it by that; the mesh side gives depth to its vertices
242
+ * only and interpolates linearly across each triangle. That is the whole
243
+ * approximation a mesh IS, and this puts a number on it: the two agree where
244
+ * the depth field is locally flat across a cell, and part where it curves.
245
+ *
246
+ * ⭐ So the figure to read is not the absolute error but **how it falls as the
247
+ * lattice refines**. A mesh that is evaluating the same model converges on it;
248
+ * one that is evaluating something else does not, however dense it gets.
249
+ *
250
+ * 🚨 And it is a different quantity from FACE §4.2's fold angle, which gets
251
+ * WORSE as the columns refine. Both are true and they are not in tension:
252
+ * refining the lattice buys fidelity to the model and costs the angle at which
253
+ * a column pair inverts. This measures the first; `A39` refuses the second.
254
+ */
255
+ export function compareTurnFields(input: {
256
+ map: DepthMap;
257
+ near: DepthNear;
258
+ tone: DepthTone;
259
+ zScale: number;
260
+ /** One byte per pixel over the same grid; a pixel under `threshold` is not art. */
261
+ alpha: Uint8Array;
262
+ threshold: number;
263
+ /** Mesh vertices in part-local pixels, y down. */
264
+ points: ReadonlyArray<readonly [number, number]>;
265
+ triangles: ReadonlyArray<number>;
266
+ degrees: number;
267
+ /** Where the axis crosses the driving coordinate, in part pixels. Default 0. */
268
+ about?: number;
269
+ /** 'yaw' reads x and displaces x; 'pitch' reads y and displaces y. */
270
+ kind?: 'yaw' | 'pitch';
271
+ /**
272
+ * The mesh side's per-vertex `z`, when it should NOT come from the map.
273
+ *
274
+ * Two uses, and the second is the important one. A caller that has the
275
+ * compiler's own sampled depths can pass them, so the comparison is against
276
+ * what was actually emitted rather than against a re-sampling. And a NEGATIVE
277
+ * control can pass depths from a different model entirely — a cylinder's, say
278
+ * — which is what makes the convergence claim mean anything: a mesh
279
+ * evaluating the same model converges on it, and one evaluating another does
280
+ * not, however dense it gets.
281
+ */
282
+ vertexDepths?: readonly number[];
283
+ }): FieldAgreement {
284
+ const { map, near, tone, zScale, alpha, threshold, points, triangles, degrees } = input;
285
+ const about = input.about ?? 0;
286
+ const along = (input.kind ?? 'yaw') === 'yaw' ? 0 : 1;
287
+ const rad = (degrees * Math.PI) / 180;
288
+
289
+ if (input.vertexDepths !== undefined && input.vertexDepths.length !== points.length) {
290
+ throw new DepthError(
291
+ `the mesh has ${points.length} vertices and ${input.vertexDepths.length} depths were supplied for it`,
292
+ );
293
+ }
294
+ // The mesh side, per vertex, once.
295
+ const vertexShift = points.map((p, v) =>
296
+ turnDisplacement(
297
+ p[along] - about,
298
+ input.vertexDepths === undefined ? sampleDepth(map, p[0], p[1], near, tone, zScale) : input.vertexDepths[v],
299
+ rad,
300
+ ),
301
+ );
302
+
303
+ const { width: w, height: h } = map;
304
+ // Which pixels a triangle covered, so a pixel in two triangles is counted
305
+ // once and the untouched remainder can be reported rather than ignored.
306
+ const seen = new Uint8Array(w * h);
307
+ let samples = 0;
308
+ let total = 0;
309
+ let worst = 0;
310
+
311
+ for (let t = 0; t < triangles.length; t += 3) {
312
+ const [ia, ib, ic] = [triangles[t], triangles[t + 1], triangles[t + 2]];
313
+ const [ax, ay] = points[ia];
314
+ const [bx, by] = points[ib];
315
+ const [cx, cy] = points[ic];
316
+ const den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
317
+ if (den === 0) continue; // a degenerate triangle covers nothing
318
+ const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
319
+ const x1 = Math.min(w - 1, Math.ceil(Math.max(ax, bx, cx)));
320
+ const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy)));
321
+ const y1 = Math.min(h - 1, Math.ceil(Math.max(ay, by, cy)));
322
+ for (let py = y0; py <= y1; py++) {
323
+ for (let px = x0; px <= x1; px++) {
324
+ const i = py * w + px;
325
+ if (seen[i] || alpha[i] < threshold) continue;
326
+ // Pixel centres, the same convention the sampler uses.
327
+ const sx = px + 0.5;
328
+ const sy = py + 0.5;
329
+ const l0 = ((by - cy) * (sx - cx) + (cx - bx) * (sy - cy)) / den;
330
+ const l1 = ((cy - ay) * (sx - cx) + (ax - cx) * (sy - cy)) / den;
331
+ const l2 = 1 - l0 - l1;
332
+ if (l0 < 0 || l1 < 0 || l2 < 0) continue;
333
+ seen[i] = 1;
334
+ const meshShift = l0 * vertexShift[ia] + l1 * vertexShift[ib] + l2 * vertexShift[ic];
335
+ const u = (along === 0 ? sx : sy) - about;
336
+ const exact = turnDisplacement(u, sampleDepth(map, sx, sy, near, tone, zScale), rad);
337
+ const d = Math.abs(meshShift - exact);
338
+ samples++;
339
+ total += d;
340
+ if (d > worst) worst = d;
341
+ }
342
+ }
343
+ }
344
+
345
+ let skipped = 0;
346
+ for (let i = 0; i < alpha.length; i++) if (alpha[i] >= threshold && !seen[i]) skipped++;
347
+ return { samples, skipped, mean: samples === 0 ? 0 : total / samples, worst };
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // The turn a mesh can take before it folds
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * The relative floor under a triangle's setup area, below which no ceiling is
356
+ * quoted for it.
357
+ *
358
+ * ⚠️ **Deliberately not `deformmeasure.ts`'s `DEFORM_AREA_EPSILON`, and the two
359
+ * must not be merged.** That one is a *shape band* on a measured reversal,
360
+ * combined with a float32 noise bound, and it decides whether a triangle the
361
+ * artifact already holds has turned over. This one guards a DIVISION: the
362
+ * ceiling below is `A0 / A_axis`, and a setup triangle with no area to speak of
363
+ * gives an angle of nearly zero that says nothing about the sheet.
364
+ *
365
+ * 🔒 What keeps them from drifting is not a shared constant — the compiler
366
+ * cannot import that file without linking the runtime — but a control:
367
+ * `TC01` in `selftest.ts` requires the ceiling reported here to be the angle
368
+ * `A39` actually fires at, on the triangle it actually names. A disagreement
369
+ * between these two numbers is a red test, not a silent difference.
370
+ */
371
+ const CEILING_AREA_FLOOR = 1e-6;
372
+
373
+ /**
374
+ * A measured figure of the fold report on the tree's six-decimal grid — the
375
+ * rule `src/mesh.ts`'s `r6` states for the generator's measured figures, and
376
+ * never "-0".
377
+ *
378
+ * ⭐ **Why the report is rounded at all (issue #942).** Every other number the
379
+ * model document spells is on the float32 grid the Spine file is written on,
380
+ * or on this one; these four were the only full doubles, and the one place a
381
+ * platform's libm reached the document. `gallery/look`'s document differed
382
+ * between macOS and the Linux runner by exactly two leaves,
383
+ * `/meshes/0/depth/ceiling/pitch/negative/{degrees,p1}`, `26.935130523311`
384
+ * against `26.935130523311003`: macOS's `Math.atan` returned 0.512 ulp below
385
+ * the exact arctangent and Linux the correctly rounded double above it.
386
+ * Measured on that rig, the nearest six-decimal boundary is at least 2.33e-8°
387
+ * from any of its ceiling angles against a one-ulp step of about 3.6e-15°, so
388
+ * a one-ulp difference no longer reaches a byte. What a grid cannot absorb is
389
+ * stated rather than hidden: a value within one ulp of a rounding boundary
390
+ * still moves. Which triangle is the minimum no longer rides on the last ulp
391
+ * (issue #949): the choice is made on this grid too, by `foldPrecedes`.
392
+ *
393
+ * 🔒 Applied to the figures after the minimum is chosen and the percentile
394
+ * ranked; `TC01` still requires the named triangle, at ±0.01°, to be one `A39`
395
+ * fires on, and `TB03` that it is the FIRST one A39 names. A share or
396
+ * a step is a positive number and r6 only returns 0 for one under 5e-7; `TC07`
397
+ * reads every share it builds as `> 0`.
398
+ */
399
+ function r6(n: number): number {
400
+ const v = Math.round(n * 1e6) / 1e6;
401
+ return v === 0 ? 0 : v;
402
+ }
403
+
404
+ /**
405
+ * Whether fold `a` is the tighter of two on one axis and side: its angle is
406
+ * smaller ON THE SIX-DECIMAL GRID THE REPORT SPELLS IT ON, or equal there and
407
+ * `a` is the lower triangle ordinal. `degrees` are the full doubles; the grid
408
+ * is applied here, so a caller cannot compare the doubles by accident.
409
+ *
410
+ * ⭐ Why the grid and not the doubles (issue #949). A minimum chosen on full
411
+ * doubles is chosen by the platform's libm whenever two triangles fold within
412
+ * a few ulps of each other, and a fold names its triangle with that
413
+ * triangle's own `ids`, `depthStep` and `stepShare`, so the document moves by
414
+ * whole fields while the angle it prints does not. Measured on
415
+ * `gallery/look`, with every libm-backed `Math` result moved one ulp through a
416
+ * `--preload`: mesh 0's `pitch.negative` minimum is triangles 49 and 50, both
417
+ * at exactly `26.935130523311` unperturbed (the doubles are EQUAL, and the
418
+ * first-found rule named 49); `Math.pow` +1 ulp made 50 the smaller by one ulp
419
+ * (`26.935130523310995`) and −1 ulp made 49 the larger (`26.935130523311003`),
420
+ * and the document named 50, `ids` `[60,…,80]`, `depthStep` 90.533309 for
421
+ * 95.668608. `pow` −1 ulp also swapped `yaw.positive`'s triangles 174 and 215
422
+ * (`19.316350434748518`, both; 7.1e-15° apart once perturbed). 5 and 10 leaves
423
+ * moved; with this rule, 0 under `atan`, `pow` or all sixteen functions
424
+ * together, either direction, on all seven gallery rows.
425
+ *
426
+ * ⭐ Why the LOWEST ordinal, and not some other tie-break: it is the order
427
+ * `A39` names triangles in. The gate lists the triangles a key reverses by
428
+ * ascending ordinal (`deformmeasure.ts`, `reversed`), so turned just past a
429
+ * tied ceiling every tied triangle reverses and the first one the refusal
430
+ * names is the lowest — the one this reports. `TB03` holds the two to it on a
431
+ * planted tie; `TB01` holds the choice unmoved by which of two tied triangles
432
+ * carries the larger double.
433
+ *
434
+ * ⚠️ What a grid cannot absorb, stated rather than hidden: two angles one ulp
435
+ * apart that straddle a six-decimal rounding boundary are two different
436
+ * reported values, and a one-ulp change can still pick the other one. That is
437
+ * the value moving, not the choice, and it is the residual `r6` already names.
438
+ */
439
+ export function foldPrecedes(
440
+ a: { readonly degrees: number; readonly triangle: number },
441
+ b: { readonly degrees: number; readonly triangle: number },
442
+ ): boolean {
443
+ const ra = r6(a.degrees);
444
+ const rb = r6(b.degrees);
445
+ return ra < rb || (ra === rb && a.triangle < b.triangle);
446
+ }
447
+
448
+ /**
449
+ * Where one triangle turns inside out, which triangle that is — and, beside it,
450
+ * what the REST of this axis and side's triangles do.
451
+ *
452
+ * Everything down to `stepShare` is about one triangle. `count` and `p1` are
453
+ * about the population it is the minimum of, and they are here because the
454
+ * minimum alone cannot answer the question an author actually has: `degrees` is
455
+ * the same number whether a whole band of the mesh reaches the limit together
456
+ * or one triangle does, and those are a form and a bad texel respectively
457
+ * ([#412](https://github.com/firejune/rigc/issues/412),
458
+ * `bench/studies/2026-09-05-noise` §6).
459
+ *
460
+ * ⚠️ And a band is not sufficient evidence of a form either, which is what
461
+ * `stepShare` is here for: an OUTLINE is a band, so a mesh whose ceiling is set
462
+ * by the occlusion edge of a cut-out reads `p1/degrees` near 1 and a `depthStep`
463
+ * of most of the sheet's range — both of the older figures reading *healthy* on
464
+ * the same measurement ([#448](https://github.com/firejune/rigc/issues/448)).
465
+ *
466
+ * ⛔ Neither figure changes `degrees`, and neither is a threshold. rigc does not
467
+ * have the authority to guess its input away, so nothing here filters,
468
+ * smooths or rejects a sample — the ceiling stays the raw sheet read through
469
+ * the mesh, which is the only thing `A39` will agree with. What to read off the
470
+ * two numbers is stated in `docs/AUTHORING.md` §3.4, not decided here.
471
+ *
472
+ * 🔸 `degrees`, `depthStep`, `stepShare` and `p1` are reported on the
473
+ * six-decimal grid (`r6` above, issue #942), and the choice among triangles is
474
+ * made on that grid with the lowest ordinal winning a tie (`foldPrecedes`,
475
+ * issue #949).
476
+ */
477
+ export interface FoldLimit {
478
+ /** Degrees from setup, in (0, 90). */
479
+ degrees: number;
480
+ /** Which triangle: its ordinal in the triangle list, not an index into it. */
481
+ triangle: number;
482
+ /** Its three vertex indices, so a message can name them. */
483
+ ids: [number, number, number];
484
+ /**
485
+ * The largest depth difference between two of THIS triangle's vertices, in
486
+ * the units `z` was supplied in.
487
+ *
488
+ * A reading of the sheet and not a restatement of the angle: pass it through
489
+ * `depthStepLevels` and it is the step in encoding levels, which is what says
490
+ * whether the sheet had anything to say across this triangle at all. One
491
+ * level is the smallest step an 8-bit sheet can carry, and at one level the
492
+ * ceiling is exactly `atan(255·h / zScale)` — arithmetic about the encoding
493
+ * with no form left in it (`bench/studies/2026-09-05-noise` §3).
494
+ */
495
+ depthStep: number;
496
+ /**
497
+ * `depthStep` over the depth range this mesh actually sampled — what fraction
498
+ * of everything the sheet said across the whole part it said across the one
499
+ * triangle that folds first.
500
+ *
501
+ * ⭐ The figure that tells a form from a cliff, and it is the one reading the
502
+ * other two cannot give ([#448](https://github.com/firejune/rigc/issues/448)).
503
+ * A form has a slope, so refining the lattice halves the step and halves this
504
+ * with it while the angle converges. A **discontinuity has no slope**: the
505
+ * step stays the whole range however fine the lattice gets, this figure pins
506
+ * near 1, and the ceiling halves with every doubling instead of converging —
507
+ * `tan t ∝ h`, an angle that describes nothing at any density.
508
+ *
509
+ * ⚠️ **The ceiling is not wrong when this reads high; the input is not a
510
+ * surface.** Measured on estimated sheets: reported 1.936°, and the runtime
511
+ * admits +1° and reverses 8 triangles at +2°. The rig genuinely folds at two
512
+ * degrees. What a `depthStep` near the whole range means is an occlusion
513
+ * boundary — figure against background, or one part of a figure over
514
+ * another — and a 2.5D turn does not model occlusion at all, so **no angle is
515
+ * the right one to quote for it**. The fix is upstream of the ceiling: mesh
516
+ * only what is continuous, or state a sheet that was authored rather than
517
+ * estimated.
518
+ *
519
+ * 🔒 In (0, 1] by construction, never a division by zero. `depthStep` is a
520
+ * difference between two of this mesh's own `z` values, so the span over all
521
+ * of them is at least as large; and a fold only exists where the axis area
522
+ * with `z` substituted in is non-zero, which needs two vertices of the
523
+ * triangle at different depths — so a mesh with no span reports no fold and
524
+ * never reaches the divide.
525
+ *
526
+ * ⛔ A report and never a threshold. rigc does not decide that an author's
527
+ * sheet is the wrong kind of thing; nothing here filters, and nothing here
528
+ * moves a ceiling. What to read off the number is stated in
529
+ * `docs/AUTHORING.md` §3.4.
530
+ */
531
+ stepShare: number;
532
+ /** How many triangles fold on this axis and side — the population below. */
533
+ count: number;
534
+ /**
535
+ * The 1st percentile of those triangles' fold angles in degrees, or `null`
536
+ * where the population is too small to have one.
537
+ *
538
+ * Nearest-rank on the ascending angles, the same definition the noise study
539
+ * measured its distribution tables with: index `round(0.01·(count−1))`. That
540
+ * index is **zero for every `count` below 51**, so on a small mesh the first
541
+ * percentile is arithmetically the minimum and a ratio of exactly 1.000 would
542
+ * be printed for a sheet with one bad texel in it as readily as for a form.
543
+ *
544
+ * ⭐ `null` rather than that number, for the reason `A21` needed a third
545
+ * `meshKinds` state: a default is how "nothing to measure" quietly becomes a
546
+ * measurement of the wrong thing. `count` is reported beside it so a reader
547
+ * can see how thin a population a printed percentile came from — at 51 it is
548
+ * the second-smallest angle, and a limit two triangles share is not yet a
549
+ * band.
550
+ */
551
+ p1: number | null;
552
+ }
553
+
554
+ /**
555
+ * A depth step in the units `z` was supplied in, restated in ENCODING LEVELS.
556
+ *
557
+ * One arithmetic in one place: `zScale` spans the sheet's full 0..255 range, so
558
+ * one level is `zScale/255` world units. The CLI prints this and the controls
559
+ * assert on it, and neither writes the division out again.
560
+ */
561
+ export function depthStepLevels(depthStep: number, zScale: number): number {
562
+ return (depthStep * 255) / zScale;
563
+ }
564
+
565
+ /**
566
+ * Which index of an ascending list of `n` the nearest-rank `q` percentile is.
567
+ *
568
+ * The same rule `bench/studies/2026-09-05-noise` measured its distribution
569
+ * tables with, so the figure the compiler prints and the figure that study
570
+ * reports are one statistic. Round-half-up makes it deterministic for every
571
+ * length, which `A18` requires. **Zero is a real answer and the caller has to
572
+ * read it as one** — it means the percentile of this population is its own
573
+ * minimum, which is a fact about the population and not a percentile.
574
+ */
575
+ function nearestRankIndex(n: number, q: number): number {
576
+ return Math.min(n - 1, Math.max(0, Math.round(q * (n - 1))));
577
+ }
578
+
579
+ /**
580
+ * What a mesh's own geometry says about the turn it can take, per axis and per
581
+ * direction. `null` where nothing in the mesh folds short of 90°.
582
+ */
583
+ export interface TurnCeiling {
584
+ yaw: { positive: FoldLimit | null; negative: FoldLimit | null };
585
+ pitch: { positive: FoldLimit | null; negative: FoldLimit | null };
586
+ /** Triangles with enough setup area to give an answer. */
587
+ measured: number;
588
+ /** Triangles already flat in setup, which no angle makes worse. */
589
+ degenerate: number;
590
+ }
591
+
592
+ /**
593
+ * The largest turn this mesh takes on this depth before a triangle reverses.
594
+ *
595
+ * ## The arithmetic, in full, because it is three lines
596
+ *
597
+ * A `yaw` moves each vertex to `x' = u·cos t − z·sin t` and leaves `y` alone, so
598
+ * a triangle's doubled signed area is *linear in the two trig terms*:
599
+ *
600
+ * 2A(t) = cos t · [Δu_b·Δy_c − Δu_c·Δy_b] − sin t · [Δz_b·Δy_c − Δz_c·Δy_b]
601
+ * = 2A₀·cos t − 2A_yaw·sin t
602
+ *
603
+ * where `A_yaw` is the setup area with **z substituted for u**. It crosses zero
604
+ * at `tan t = A₀ / A_yaw` — exactly, with no search and no iteration. A `pitch`
605
+ * is the same statement with the substitution in the other slot.
606
+ *
607
+ * ⭐ **The sign of that ratio picks the direction.** A positive ratio folds at
608
+ * `+atan(ratio)` and a negative one at `−atan|ratio|`, so every triangle folds
609
+ * in exactly ONE direction and a part's two ceilings are generally different.
610
+ * Reporting one number for both would be quoting the tighter of two answers as
611
+ * if it were the only one — a face that turns 30° left and 18° right is the
612
+ * ordinary case, not an anomaly.
613
+ *
614
+ * ## Why this is a report and not a refusal
615
+ *
616
+ * `A39` already refuses a key that folds, from the artifact, through the
617
+ * runtime. This measures the same wall from the other side and *before* a key
618
+ * is written, which is the whole of its value: the loop it replaces is "pick an
619
+ * angle, build, read the refusal, guess again". Adding a second refusal here
620
+ * would be the compiler inventing a policy out of a measurement.
621
+ *
622
+ * ## What the minimum alone cannot say (issue #412)
623
+ *
624
+ * The four angles are correct and, on a noisy sheet, useless on their own. A
625
+ * clean 8-bit sheet read at 4,225 vertices reports 64.58°; move ONE texel of
626
+ * 160,000 by 245 levels and the same sheet reports 6.08° — and `A39` refuses at
627
+ * both, measured to 0°. So each `FoldLimit` also carries `p1`, the 1st
628
+ * percentile of its own side's fold angles, and `depthStep`, what the sheet
629
+ * changed across the triangle that goes first. A `p1/degrees` near 1 is a band
630
+ * of the mesh reaching the limit together, which is a form; near 10 it is one
631
+ * triangle, which is a texel. A `depthStep` of one level is `atan(255·h/zScale)`
632
+ * and says nothing about the form at all.
633
+ *
634
+ * ## What a band cannot say either (issue #448)
635
+ *
636
+ * Both of those figures read *healthy* on an estimated depth sheet over cut-out
637
+ * art, and they do not merely stay silent — they affirm it. `p1/degrees` comes
638
+ * back at 1.02–2.17, which reads as a band; `depthStep` at 148–252 levels of
639
+ * 255, which reads as plenty said. It **is** a band, because an outline is long,
640
+ * and the sheet did say a great deal across that triangle — it said the whole
641
+ * distance from the figure to the background in one step.
642
+ *
643
+ * So each `FoldLimit` also carries `stepShare`, the same step divided by the
644
+ * range this mesh sampled. A form's halves per refinement while its angle
645
+ * converges; a discontinuity's pins near 1 while the angle halves. Measured:
646
+ * rigc's own gallery reads 0.112 and 0.468, a synthetic raised cosine 0.394
647
+ * falling to 0.027 under refinement, the same cosine with one planted cliff a
648
+ * flat 0.50, and estimated sheets 0.92–0.99.
649
+ *
650
+ * ⛔ All three are reports. Nothing here filters the sheet, and nothing here
651
+ * moves a ceiling: a smoothed measurement would describe a surface the deform
652
+ * key is not built from, and would part company with the gate that reads the
653
+ * raw one.
654
+ *
655
+ * ## Which triangle, when two fold at the same angle (issue #949)
656
+ *
657
+ * The minimum is chosen on the six-decimal grid the angle is reported on, and
658
+ * a tie there goes to the LOWEST triangle ordinal — the triangle `A39` names
659
+ * first when a key turns just past the ceiling (`foldPrecedes`). Chosen on the
660
+ * full doubles, it was the platform's libm that broke a tie: on
661
+ * `gallery/look`, triangles 49 and 50 fold at exactly `26.935130523311` and a
662
+ * one-ulp `Math.pow` perturbation (through the depth tone, into every `z`)
663
+ * named 50 instead of 49, moving 5 leaves of the model document in one
664
+ * direction and 10 in the other while no printed angle moved. With the rule,
665
+ * a ±1 ulp perturbation of `atan`, `pow`, or sixteen libm functions at once
666
+ * moves no leaf of any gallery row's document (`TB02` re-runs it in process).
667
+ *
668
+ * @param points Vertices in the BIND space the deform offsets are authored in.
669
+ * Areas are translation-invariant, so the origin does not matter; the scale
670
+ * and the axis directions do. A y flip alone leaves a `yaw` answer alone and
671
+ * SWAPS a `pitch`'s two directions, which is why the caller composes the
672
+ * emitter's own mapping rather than approximating it.
673
+ * @param z One depth per vertex, in those same units.
674
+ */
675
+ export function turnCeiling(
676
+ points: ReadonlyArray<readonly [number, number]>,
677
+ z: readonly number[],
678
+ triangles: ReadonlyArray<number>,
679
+ ): TurnCeiling {
680
+ if (z.length !== points.length) {
681
+ throw new DepthError(`the mesh has ${points.length} vertices and ${z.length} depths were supplied for it`);
682
+ }
683
+ const out: TurnCeiling = {
684
+ yaw: { positive: null, negative: null },
685
+ pitch: { positive: null, negative: null },
686
+ measured: 0,
687
+ degenerate: 0,
688
+ };
689
+ // The floor is relative, so it needs the mesh's own scale first.
690
+ let largest = 0;
691
+ const areas: number[] = [];
692
+ for (let t = 0; t < triangles.length; t += 3) {
693
+ const [ia, ib, ic] = [triangles[t], triangles[t + 1], triangles[t + 2]];
694
+ const a = (points[ib][0] - points[ia][0]) * (points[ic][1] - points[ia][1])
695
+ - (points[ic][0] - points[ia][0]) * (points[ib][1] - points[ia][1]);
696
+ areas.push(a);
697
+ if (Math.abs(a) > largest) largest = Math.abs(a);
698
+ }
699
+ const floor = largest * CEILING_AREA_FLOOR;
700
+
701
+ // The denominator `stepShare` is taken against: the depth range this mesh
702
+ // sampled, which is `range` in the report read off the same array. Taken over
703
+ // the WHOLE mesh rather than per triangle, because the question the figure
704
+ // answers is how much of what the sheet said here one triangle said.
705
+ let zLo = Infinity;
706
+ let zHi = -Infinity;
707
+ for (const d of z) {
708
+ if (d < zLo) zLo = d;
709
+ if (d > zHi) zHi = d;
710
+ }
711
+ const zSpan = zHi - zLo;
712
+
713
+ // One list per axis and side, so the ceiling can say whether it is the floor
714
+ // of a BAND or of a single triangle. Nothing here filters: every measurable
715
+ // triangle goes in exactly once, in triangle order, and the sort below is
716
+ // numeric — `A18` compares a second compile byte for byte.
717
+ const angles = {
718
+ yaw: { positive: [] as number[], negative: [] as number[] },
719
+ pitch: { positive: [] as number[], negative: [] as number[] },
720
+ };
721
+
722
+ for (let t = 0, n = 0; t < triangles.length; t += 3, n++) {
723
+ const [ia, ib, ic] = [triangles[t], triangles[t + 1], triangles[t + 2]];
724
+ const a0 = areas[n];
725
+ if (Math.abs(a0) <= floor) {
726
+ out.degenerate++;
727
+ continue;
728
+ }
729
+ out.measured++;
730
+ const dyb = points[ib][1] - points[ia][1];
731
+ const dyc = points[ic][1] - points[ia][1];
732
+ const dxb = points[ib][0] - points[ia][0];
733
+ const dxc = points[ic][0] - points[ia][0];
734
+ const dzb = z[ib] - z[ia];
735
+ const dzc = z[ic] - z[ia];
736
+ const ids: [number, number, number] = [ia, ib, ic];
737
+ // The sheet read through THIS triangle, in the units `z` came in. It is the
738
+ // step, not the angle: a triangle whose three vertices sample one level
739
+ // apart has nothing but the encoding to say, whatever angle that works out
740
+ // to. Reported per fold below, and never used to change one.
741
+ const depthStep = Math.max(Math.abs(dzb), Math.abs(dzc), Math.abs(dzc - dzb));
742
+ // z in the driven axis's slot: x for a yaw, y for a pitch.
743
+ for (const [axis, aAxis] of [
744
+ ['yaw', dzb * dyc - dzc * dyb],
745
+ ['pitch', dxb * dzc - dxc * dzb],
746
+ ] as const) {
747
+ // A zero here is a triangle the axis cannot fold at all: its area stays
748
+ // `A₀·cos t`, which only reaches zero at a right angle.
749
+ if (aAxis === 0) continue;
750
+ const ratio = a0 / aAxis;
751
+ const degrees = (Math.atan(Math.abs(ratio)) * 180) / Math.PI;
752
+ const side = ratio > 0 ? 'positive' : 'negative';
753
+ angles[axis][side].push(degrees);
754
+ const held = out[axis][side];
755
+ // `count` and `p1` are filled once the whole population is in; a minimum
756
+ // cannot know its own percentile while it is still being found.
757
+ // On the reported grid, lowest ordinal first — `foldPrecedes` above. The
758
+ // walk is in ordinal order, so the ordinal clause never decides here; it is
759
+ // stated so the rule does not depend on the walk.
760
+ if (held === null || foldPrecedes({ degrees, triangle: n }, held)) {
761
+ // `zSpan` cannot be zero here: `aAxis !== 0` needs two of this
762
+ // triangle's vertices at different depths, and the span over the whole
763
+ // mesh is at least that difference. A guard would be an unreachable
764
+ // branch, and an unreachable branch is not a control.
765
+ out[axis][side] = { degrees, triangle: n, ids, depthStep, stepShare: depthStep / zSpan, count: 0, p1: null };
766
+ }
767
+ }
768
+ }
769
+
770
+ for (const axis of ['yaw', 'pitch'] as const) {
771
+ for (const side of ['positive', 'negative'] as const) {
772
+ const held = out[axis][side];
773
+ if (held === null) continue;
774
+ const sorted = angles[axis][side].slice().sort((a, b) => a - b);
775
+ const rank = nearestRankIndex(sorted.length, 0.01);
776
+ held.count = sorted.length;
777
+ held.p1 = rank === 0 ? null : r6(sorted[rank]);
778
+ held.degrees = r6(held.degrees);
779
+ held.depthStep = r6(held.depthStep);
780
+ held.stepShare = r6(held.stepShare);
781
+ }
782
+ }
783
+ return out;
784
+ }