rig-c 0.0.0-stage → 2.21.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.
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 +1191 -0
  185. package/src/meshquality.ts +2051 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1444 -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
@@ -0,0 +1,2051 @@
1
+ /**
2
+ * Mesh quality, measured without posing anything — the geometry half of the
3
+ * contract in docs/MESH_REDUCTION.md (issue #1221, stage B1: issue #1224).
4
+ *
5
+ * One operation lives here today, `measureMeshQuality`: a mesh, the art one
6
+ * attachment draws, and the bounds the caller declared, measured into one
7
+ * `mesh-quality-report/1` document (`writeMeshQualityReport` writes its text).
8
+ * Every type the contract declares is declared here too, including the ones
9
+ * only the reduction (`reduceMesh`, stage B2, in `src/meshreduce.ts`) and the
10
+ * motion comparison (stage C) fill, so that those stages build on one set of
11
+ * types rather than restating them.
12
+ *
13
+ * ## What it is for
14
+ *
15
+ * An agent that cannot see a mesh cannot tell a mesh that drops art from one
16
+ * that does not, a spanned hole from overshoot, or a sliver from a fold. Each of
17
+ * those is a row here with its value, where the worst value was taken, the bound
18
+ * the caller declared, and a state that keeps "measured and failed" apart from
19
+ * "nothing was declared" and from "could not be measured". The five states are
20
+ * §2's; nothing folds one into another, and an informational row never counts
21
+ * towards a pass.
22
+ *
23
+ * ## What it never does
24
+ *
25
+ * - **Invent a bound.** A row whose bound the caller did not declare is
26
+ * `undeclared`: measured, reported, and out of the pass count. The two rows
27
+ * with a bound of their own — `MQ_ORIENTATION` and `MQ_DEGENERATE`, both 0 —
28
+ * take it from the input's own definition (`SourceMesh.triangles` is
29
+ * counter-clockwise in Spine world, and a triangle whose sign the A39 band
30
+ * cannot read cannot be shown to be), not from a guess.
31
+ * - **Exchange thresholds.** Every raster row records the threshold it was
32
+ * taken at (`MeasureRow.art.threshold`); a measurement claims nothing at any
33
+ * other.
34
+ * - **Change the legacy fit.** `measureAuthoredMeshFit` (`src/mesh.ts`) keeps
35
+ * its 4-connected fill for every existing caller. The rows here fill with the
36
+ * tracer's 8-connected background flood over all art (P12), and where the two
37
+ * fills differ — a diagonal pinch — both labelled results are rows.
38
+ * - **Pose, or link the runtime.** Pure: no clock, no randomness, no network,
39
+ * no spine-core, nothing from the compiler.
40
+ *
41
+ * ## Coordinates
42
+ *
43
+ * Every caller distance is in the drawing's part-local pixels, y down (P3). The
44
+ * mask is read on its own grid — the plate's pixels, or a `scale:` page's
45
+ * texels — so a point is carried to the grid as `px × pageScale`, and a raster
46
+ * distance comes back as `texels / pageScale`. A pixel named in a `worst` is a
47
+ * cell of the mask's grid, `[x, y]`, y down. Orientation is read in Spine world,
48
+ * through `cropToSpineY` (`src/transform.ts`), the one place that conversion
49
+ * lives.
50
+ *
51
+ * ⚠️ `src/mesh.ts` re-exports this module and this module imports helpers from
52
+ * it, which is an import cycle. It is safe only because nothing at the top
53
+ * level of either reads the other's bindings: every use is inside a function.
54
+ * `MeshReductionError` is defined in `src/mesh.ts` for exactly that reason.
55
+ */
56
+ import {
57
+ checkHullOrder,
58
+ distanceToSegment,
59
+ findSelfIntersection,
60
+ MeshError,
61
+ MeshReductionError,
62
+ meshEdges,
63
+ r6,
64
+ rasteriseTriangles,
65
+ segmentsMeet,
66
+ squaredDistanceToSet,
67
+ traceOutline,
68
+ type AlphaMask,
69
+ type MeshOutline,
70
+ } from './mesh.ts';
71
+ import { areaBand, triangleAreas } from './areaband.ts';
72
+ import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
73
+ import { cropToSpineY } from './transform.ts';
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // §1 — inputs
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** The art one attachment is measured against. Correction 1: one per attachment, never shared. */
80
+ export interface ArtInput {
81
+ /** The art, on the grid it is read off: the plate's pixels, or a `scale:` page's texels. */
82
+ mask: AlphaMask;
83
+ /** Art is `alpha >= threshold`. Required, a whole number in 1..255. Never exchanged. */
84
+ threshold: number;
85
+ /** The frame the caller's distances are in — P3. Echoed in the report, never inferred. */
86
+ frame: SourceFrame;
87
+ }
88
+
89
+ /** P3: the caller authors in drawing pixels, part-local, y down. */
90
+ export interface SourceFrame {
91
+ space: 'part-local-drawing-px-y-down';
92
+ /** The part window in drawing pixels. */
93
+ width: number;
94
+ height: number;
95
+ /** The `scale:` the page states (1 when none) — the meaning of `ContourSpecInput.pageScale`. */
96
+ pageScale: number;
97
+ /** The conversion applied, stated rather than implied: texels = drawing px × pageScale. */
98
+ conversion: 'texels = px * pageScale';
99
+ }
100
+
101
+ export interface AttachmentRef {
102
+ skin: string | null;
103
+ slot: string;
104
+ attachment: string;
105
+ }
106
+
107
+ export interface SourceMesh {
108
+ /** Part-local pixels of the drawing, y down. */
109
+ points: Array<[number, number]>;
110
+ /** Region UVs, 0..1, `v` from the top — one pair per point. */
111
+ uvs: number[];
112
+ /** Counter-clockwise in Spine world; hull first, as `checkHullOrder` requires. */
113
+ triangles: number[];
114
+ hull: number;
115
+ /** Per vertex, by bone NAME (`ModelBinding` without the bind coordinates), or null for an unweighted mesh. */
116
+ weights: Array<Array<{ bone: string; weight: number }>> | null;
117
+ }
118
+
119
+ /**
120
+ * All distances in the drawing's pixels; converted to texels by `pageScale`.
121
+ *
122
+ * `maxOvershoot` and `maxUndercut` may be `null` — a bound **declared absent**
123
+ * (issue #1254): the row is measured and reported `undeclared`, with its value
124
+ * and worst sample, and never passes, fails, blocks a step or refuses a source.
125
+ * A field left out (`undefined`) is still refused by name: an omission cannot be
126
+ * told from a caller that forgot, and only the explicit `null` says "measured,
127
+ * not bounded". `minCoverage` has no such form and is always required.
128
+ */
129
+ export interface ArtFitBounds {
130
+ /** Share of art pixel centres the triangles cover, 0..1. */
131
+ minCoverage: number;
132
+ /** Furthest a covered pixel may sit outside the filled silhouette, px; null = declared absent. */
133
+ maxOvershoot: number | null;
134
+ /** Furthest an uncovered art pixel may sit from the covered set, px; null = declared absent. */
135
+ maxUndercut: number | null;
136
+ }
137
+
138
+ /** What a reduction's RESULT must satisfy (correction 3). Read by `reduceMesh`, stage B2. */
139
+ export interface ReductionTargets {
140
+ artFit: ArtFitBounds;
141
+ /** P13: largest Hausdorff distance between the reduced and the source hull polygons, px. */
142
+ maxBoundaryDeviation: number;
143
+ /** Optional: smallest interior angle any triangle may have, degrees. Undeclared = reported, not gated. */
144
+ minAngle?: number;
145
+ /** Local density requirements — §5. */
146
+ regions: RefinementRegion[];
147
+ }
148
+
149
+ /** P16/P17: a local density requirement over a closed polygon. */
150
+ export interface RefinementRegion {
151
+ /** Caller's name, echoed in the report; rigc reads nothing into it. */
152
+ name: string;
153
+ /** Closed polygon, part-local drawing pixels, y down — resolved by the caller (P17). */
154
+ polygon: Array<[number, number]>;
155
+ /** L0: every edge that intersects the closed polygon is at most this long, px. */
156
+ maxEdgeLength: number;
157
+ /** Width of the band outside the polygon over which the bound relaxes, px. Finite, >= 0; 0 is a hard edge. */
158
+ transition: number;
159
+ /** In the band, L(d) = L0 + grade × d, d the distance from the polygon, px per px. Finite, >= 0. */
160
+ grade: number;
161
+ /** P17: when the caller approximated another shape by this polygon, how — echoed, never read. */
162
+ approximation: { from: string; policy: string; maxError: number } | null;
163
+ }
164
+
165
+ /** §6, with P19/P20 folded in. Echoed by `measureMeshQuality`; read by `reduceMesh`, stage B2. */
166
+ export interface ProtectedFeatures {
167
+ /** P20: keep every source hull vertex. Required — no default inside the operation. */
168
+ hull: boolean;
169
+ /** Source vertex indices that must survive. */
170
+ vertices: number[];
171
+ /** P20: source edges (pairs of source indices) that must survive as edges. */
172
+ edges: Array<[number, number]>;
173
+ /** Region polygons whose boundary vertices must survive. */
174
+ regionBoundaries: string[];
175
+ /** L1 weight-vector difference above which an edge is protected (correction 3). Null = no such protection, reported. */
176
+ weightJump: number | null;
177
+ /** Bones whose binding may not be pruned from any vertex that carries it (P19). */
178
+ influences: string[];
179
+ }
180
+
181
+ /** P19: stated on every weighted call, never inherited. */
182
+ export interface InfluenceLimits {
183
+ /** The cap on bindings per vertex. */
184
+ maxInfluences: number;
185
+ /** Shares under this are dropped; 0 drops only shares that are zero on the weight grid. */
186
+ minWeight: number;
187
+ }
188
+
189
+ /**
190
+ * One deform key of a timeline the reduced attachment is keyed under (§6, P18),
191
+ * in the form the compiler emits it: a `vertices` run is the emitted `offset`
192
+ * (an index into the deform array — two numbers per vertex unweighted, two per
193
+ * influence weighted, AUTHORING §4.11) and the numbers copied in from there; a
194
+ * `transform` key is a model the compile evaluates over the attachment's own
195
+ * geometry (§4.11.1), so it has no run to remap; a `setup` key carries no run.
196
+ */
197
+ export type DeformKeyInput =
198
+ | { time: number; kind: 'setup' }
199
+ | { time: number; kind: 'vertices'; offset: number; vertices: number[] }
200
+ | { time: number; kind: 'transform' };
201
+
202
+ /** One deform timeline: the animation, the attachment it is keyed on, and its keys in order. */
203
+ export interface DeformTimelineInput {
204
+ animation: string;
205
+ /** The reduced attachment itself, or one of `MeshReductionInput.linkedMeshes`. */
206
+ attachment: AttachmentRef;
207
+ keys: DeformKeyInput[];
208
+ }
209
+
210
+ /** Everything a reduction reads (stage B2, `reduceMesh` in `src/meshreduce.ts`). No field has a default inside the operation. */
211
+ export interface MeshReductionInput {
212
+ attachment: AttachmentRef;
213
+ art: ArtInput;
214
+ /** P8: the unreduced, independently gated source. */
215
+ source: SourceMesh;
216
+ /** Correction 3: what the SOURCE must already satisfy to be admissible. */
217
+ sourceBounds: ArtFitBounds;
218
+ /** Correction 3: what the RESULT must satisfy. */
219
+ targets: ReductionTargets;
220
+ protect: ProtectedFeatures;
221
+ /** Required when `source.weights` is non-null — §6, P19. */
222
+ influences: InfluenceLimits | null;
223
+ /** Correction 1: the skeleton's bone order, used for every weight tie-break. Required when weighted. */
224
+ boneOrder: string[] | null;
225
+ /** P5: the preset the caller expanded, if any, echoed and never read. */
226
+ preset: { name: string; version: string } | null;
227
+ /** Work bound — *Termination reasons*. Every refinement insertion and every removal attempted counts one. */
228
+ budget: { maxCandidates: number };
229
+ /** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
230
+ minArtSamples: number;
231
+ /** P9: one floor per region, by region name — every region named once. */
232
+ regionArtSamples: Array<{ region: string; minArtSamples: number }>;
233
+ /** P18: every deform timeline keyed on the attachment or on one of its linked meshes; empty when none. */
234
+ deform: DeformTimelineInput[];
235
+ /** Every linked mesh of the source (they inherit the new topology); empty when none. */
236
+ linkedMeshes: AttachmentRef[];
237
+ }
238
+
239
+ /**
240
+ * What a measurement is held to. `ReductionTargets` with every bound allowed to
241
+ * be undeclared: a measurement is also asked of meshes nobody has set a target
242
+ * for, and a null bound is a row reported `undeclared`, never one given a value.
243
+ */
244
+ export interface MeasureTargets {
245
+ artFit: ArtFitBounds | null;
246
+ maxBoundaryDeviation: number | null;
247
+ minAngle?: number;
248
+ regions: RefinementRegion[];
249
+ }
250
+
251
+ /**
252
+ * What `measureMeshQuality` reads: the part of `MeshReductionInput` a
253
+ * measurement needs, plus the three things only a measurement is handed — the
254
+ * caller's id for the mesh, the hull polygon `MQ_BOUNDARY_DEVIATION` is taken
255
+ * against, and the art sample floors (P9). Every field is required; a field the
256
+ * caller has nothing for is `null` (or an empty list), never left out.
257
+ */
258
+ export interface MeshMeasureInput {
259
+ /** The caller's id for the mesh measured, echoed as the candidate's id. */
260
+ id: string;
261
+ attachment: AttachmentRef;
262
+ art: ArtInput;
263
+ /** The mesh measured. */
264
+ source: SourceMesh;
265
+ targets: MeasureTargets;
266
+ /** The hull polygon to measure boundary deviation against, drawing px; null makes that row `not-measurable`. */
267
+ referenceHull: Array<[number, number]> | null;
268
+ /** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
269
+ minArtSamples: number;
270
+ /** P9: one floor per region, by region name — every region named once. */
271
+ regionArtSamples: Array<{ region: string; minArtSamples: number }>;
272
+ protect: ProtectedFeatures | null;
273
+ influences: InfluenceLimits | null;
274
+ boneOrder: string[] | null;
275
+ preset: { name: string; version: string } | null;
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // §2 — the report
280
+ // ---------------------------------------------------------------------------
281
+
282
+ export const MESH_QUALITY_REPORT_SPEC = 'mesh-quality-report/1';
283
+
284
+ export type MeasureState = 'pass' | 'fail' | 'undeclared' | 'refused' | 'not-measurable';
285
+
286
+ export interface MeasureRow {
287
+ /** Stable code, e.g. `MQ_COVERAGE`. */
288
+ code: string;
289
+ /** The object measured: attachment always; region when the row is a region's. */
290
+ object: { attachment: AttachmentRef; region: string | null };
291
+ state: MeasureState;
292
+ /** The measured value: present on pass, fail and undeclared; null otherwise. */
293
+ value: number | null;
294
+ /** The declared bound, inclusive: present on pass and fail; null on undeclared, refused, not-measurable. */
295
+ bound: { op: '<=' | '>='; value: number } | null;
296
+ unit: 'px' | 'world' | 'fraction' | 'degrees' | 'ratio' | 'count';
297
+ /** Where the worst value was taken: present whenever `value` is. `{ at: {} }` when nothing is worse than ideal. */
298
+ worst: WorstSample | null;
299
+ /** Required on refused and not-measurable: a sentence naming what was missing. */
300
+ reason: string | null;
301
+ /** Raster and sampled rows: the art the row was taken against — never read across thresholds. */
302
+ art?: { threshold: number; connectivity: 4 | 8 | null; samples: number };
303
+ /** Raster rows only — correction 2. */
304
+ raster?: RasterSensitivity;
305
+ /** Sampled rows — correction 2. */
306
+ sampling?: { domain: string; count: number };
307
+ /** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
308
+ motion?: MotionRowDetail;
309
+ }
310
+
311
+ /** Correction 2: the spatial grid and the value's own granularity are two fields. */
312
+ export interface RasterSensitivity {
313
+ grid: { width: number; height: number; pageScale: number };
314
+ /** One texel, in the drawing's pixels: 1 / pageScale. */
315
+ spatialQuantum: number;
316
+ /** The smallest step the VALUE can take, in the row's own unit. */
317
+ valueIncrement: number;
318
+ /** A diagnostic: it changes no verdict and bounds no error. */
319
+ nearBound: 'at-bound' | 'within-increment' | 'clear';
320
+ }
321
+
322
+ export interface WorstSample {
323
+ at: { pixel?: [number, number]; triangle?: number; edge?: [number, number]; vertex?: number; uv?: [number, number] };
324
+ /** Motion rows only — P7, P11. */
325
+ frame?: FrameRef;
326
+ }
327
+
328
+ /** P7/P11: a frame named by a stable id carrying animation, phase and time. Stage C. */
329
+ export interface FrameRef {
330
+ id: string;
331
+ animation: string | null;
332
+ phase: 'grid' | 'irr' | null;
333
+ time: number | null;
334
+ index: number | null;
335
+ role: 'selection' | 'held-out' | 'baseline';
336
+ }
337
+
338
+ export interface EvidenceSection {
339
+ rows: MeasureRow[];
340
+ /** Counts per state. `measured` = pass + fail. Never summed across sections. */
341
+ summary: { pass: number; fail: number; undeclared: number; refused: number; notMeasurable: number; measured: number };
342
+ /** pass only when every REQUIRED row is pass AND at least one row is pass. */
343
+ verdict: 'pass' | 'fail' | 'not-measured';
344
+ }
345
+
346
+ /** §3's motion bounds. Stage C. */
347
+ export interface MotionBounds {
348
+ maxLocalDeformation: number;
349
+ maxStretch?: number;
350
+ minStretch?: number;
351
+ }
352
+
353
+ /** §3's schedule. Stage C. */
354
+ export interface MotionSchedule {
355
+ frames: Array<'setup' | { animation: string; times: number[] } | { animation: string; fps: number }>;
356
+ phases: Array<'grid' | 'irr'>;
357
+ physics: { mode: 'none' } | { mode: 'step'; dt: number; warmupSteps: 0 };
358
+ selection: string[];
359
+ }
360
+
361
+ /**
362
+ * The schedule a motion section was measured under. The contract names this
363
+ * type without defining it; stage C (issue #1230) defines it as the schedule as
364
+ * given plus what was walked from it (`ScheduleWalked`, at the end of this file).
365
+ */
366
+ export type ScheduleUsed = MotionSchedule & ScheduleWalked;
367
+
368
+ /** One candidate's evidence — correction 1. `reduce` and `measure` carry exactly one. */
369
+ export interface CandidateReport {
370
+ /** The caller's id for the candidate, or `result` for a `reduce`. */
371
+ id: string;
372
+ counts: MeshCounts | null;
373
+ /** Null when no mesh exists to measure (correction 3). */
374
+ geometry: EvidenceSection | null;
375
+ /** Null when no motion was asked for — never an empty PASS (P6). */
376
+ motion: (EvidenceSection & { schedule: ScheduleUsed }) | null;
377
+ /** P6 — declared-contract acceptance. */
378
+ accepted: boolean;
379
+ /** Opt-in (P7): every row's value at every frame. Absent unless asked for. */
380
+ perFrame?: Array<{ code: string; frame: string; value: number | null }>;
381
+ /** A `reduce` whose result exists: what the operation changed. Absent on a `measure` and when no mesh is returned. */
382
+ changes?: ReductionChanges;
383
+ }
384
+
385
+ /**
386
+ * What a reduction changed, counted rather than described — the figures the
387
+ * contract says are reported (§6, P18 and P19) and that no geometry row carries.
388
+ */
389
+ export interface ReductionChanges {
390
+ /** Source vertices the reduction removed. */
391
+ removedVertices: number;
392
+ /** Vertices the refinement inserted (§5). */
393
+ insertedVertices: number;
394
+ /** P19: positive interpolated shares that are 0 on the 6-decimal weight grid, dropped rather than written as 0. */
395
+ sharesDroppedOnGrid: number;
396
+ /** Interpolated shares pruned by `InfluenceLimits` — over `maxInfluences`, or under a nonzero `minWeight`. */
397
+ sharesPruned: number;
398
+ /** P18: `vertices` keys remapped, each with the source vertices whose offsets were dropped. */
399
+ deformRemapped: Array<{ animation: string; attachment: AttachmentRef; key: number; droppedVertices: number[] }>;
400
+ /** P18: `transform` keys, re-evaluated over the new geometry at compile rather than remapped. */
401
+ deformReevaluated: Array<{ animation: string; attachment: AttachmentRef; key: number }>;
402
+ /** Every linked mesh of the source: each inherits the new topology. */
403
+ linkedMeshes: AttachmentRef[];
404
+ }
405
+
406
+ export interface MeshQualityReport {
407
+ spec: typeof MESH_QUALITY_REPORT_SPEC;
408
+ operation: 'reduce' | 'measure' | 'compare';
409
+ /** Correction 1: the inputs echoed with their structure. */
410
+ effective: EffectiveSettings;
411
+ /** P2: which poser ran and its version. Null on geometry-only operations. */
412
+ poser: { kind: 'core'; rigcVersion: string } | null;
413
+ /** Whether the caller required motion evidence (P6). */
414
+ motionRequired: boolean;
415
+ /** Null when the source could not be read as a mesh (correction 3: counts are never invented). */
416
+ sourceCounts: MeshCounts | null;
417
+ reference: CandidateReport | null;
418
+ candidates: CandidateReport[];
419
+ termination: Termination | null;
420
+ }
421
+
422
+ /** Correction 1: every input the text requires, echoed field for field. */
423
+ export interface EffectiveSettings {
424
+ preset: { name: string; version: string } | null;
425
+ attachments: Array<{
426
+ attachment: AttachmentRef;
427
+ threshold: number;
428
+ finalThreshold: number;
429
+ frame: SourceFrame;
430
+ maskSize: [number, number];
431
+ minArtSamples: number;
432
+ /** P9: each region's own floor, in the order the regions were given — and, on a `compare`, its polygon. */
433
+ regions: Array<{ name: string; minArtSamples: number; polygon?: Array<[number, number]> }>;
434
+ }>;
435
+ sourceBounds: ArtFitBounds | null;
436
+ referenceArtFit: ArtFitBounds | null;
437
+ candidateArtFit: ArtFitBounds | null;
438
+ targets: ReductionTargets | MeasureTargets | null;
439
+ /** The hull polygon `MQ_BOUNDARY_DEVIATION` was taken against on a `measure`; null when none was given. */
440
+ referenceHull: Array<[number, number]> | null;
441
+ motionBounds: MotionBounds | null;
442
+ protect: ProtectedFeatures | null;
443
+ influences: InfluenceLimits | null;
444
+ boneOrder: string[] | null;
445
+ schedule: MotionSchedule | null;
446
+ budget: { maxCandidates: number } | null;
447
+ }
448
+
449
+ export interface MeshCounts {
450
+ boundaryVertices: number;
451
+ interiorVertices: number;
452
+ triangles: number;
453
+ /** Total `{ bone, weight }` entries over all vertices; 0 on an unweighted mesh. */
454
+ bindings: number;
455
+ /** The most entries any one vertex carries; 0 on an unweighted mesh. */
456
+ maxInfluences: number;
457
+ }
458
+
459
+ /** *Termination reasons*. Every `reduce` report carries exactly one; a `measure` carries one only when it could not read the mesh. */
460
+ export type Termination =
461
+ | { reason: 'no-further-valid-reduction'; candidatesTried: number; blockingConstraint: string }
462
+ | { reason: 'budget-exhausted'; candidatesTried: number; budget: number; result: 'best-meeting-every-bound' | 'none-met-the-targets' }
463
+ | { reason: 'invalid-input'; code: string; detail: string }
464
+ | { reason: 'unsupported-topology'; code: string; detail: string };
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // fixed tolerances
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /** A22's slack on a region UV (`src/assertions/bodies/a22.ts`): outside 0..1 by more than this is refused. */
471
+ const UV_RANGE_SLACK = 1e-6;
472
+
473
+ /**
474
+ * How close the two-sided Hausdorff search brings its lower and upper bounds
475
+ * before it stops, in drawing px. The value reported is then put on the `r6`
476
+ * grid, six decimals, so the search is exact to three orders below what is
477
+ * printed.
478
+ */
479
+ const HAUSDORFF_TOLERANCE = 1e-9;
480
+
481
+ /** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
482
+ const ON_BOUNDARY = 1e-9;
483
+
484
+ /**
485
+ * How near a region's band's outer boundary, in drawing px, an edge's nearest
486
+ * point has to come to count as touching it rather than entering the band —
487
+ * the precision of "a single point on the outer boundary" in
488
+ * `edgeIsHeldByRegion`, ten units of the `r6` grid. The refinement puts the
489
+ * vertex it splits an edge at half of this inside the outer boundary and then
490
+ * on the grid, which moves it by at most √2 × 5e-7 px, so the piece beyond it
491
+ * lands inside this tolerance from either side.
492
+ */
493
+ export const BAND_CONTACT_TOLERANCE = 1e-5;
494
+
495
+ /** Halvings of the parameter interval in the convex searches along an edge (`edgeIsHeldByRegion`): 2^-60 of an edge is below a double's resolution of it. */
496
+ const EDGE_SEARCH_STEPS = 60;
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // refusals
500
+ // ---------------------------------------------------------------------------
501
+
502
+ /** `skin/slot/attachment`, the way every message names the attachment. */
503
+ function nameOf(ref: AttachmentRef): string {
504
+ return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
505
+ }
506
+
507
+ function refuse(code: string, message: string): never {
508
+ throw new MeshReductionError(code, message);
509
+ }
510
+
511
+ function isObject(value: unknown): value is Record<string, unknown> {
512
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
513
+ }
514
+
515
+ function isFiniteNumber(value: unknown): value is number {
516
+ return typeof value === 'number' && Number.isFinite(value);
517
+ }
518
+
519
+ function isPoint(value: unknown): value is [number, number] {
520
+ return Array.isArray(value) && value.length === 2 && isFiniteNumber(value[0]) && isFiniteNumber(value[1]);
521
+ }
522
+
523
+ /** A field that has to be present: `undefined` is a refusal naming it. */
524
+ function present(owner: string, field: string, value: unknown, required: string): void {
525
+ if (value === undefined) refuse('REDUCE_INPUT_MISSING', `${owner}: ${field} is missing; required ${required} — no field has a default`);
526
+ }
527
+
528
+ /**
529
+ * Everything §1 refuses before a pixel is read, by code. The order is the order
530
+ * a fix has to happen in: the attachment's identity, then the art, then the
531
+ * mesh, then the bounds.
532
+ */
533
+ function validateInput(input: MeshMeasureInput): void {
534
+ if (!isObject(input)) refuse('REDUCE_INPUT_MISSING', `the input is ${JSON.stringify(input)}; required a MeshMeasureInput object`);
535
+ const ref = input.attachment;
536
+ if (!isObject(ref) || typeof ref.slot !== 'string' || ref.slot === '' || typeof ref.attachment !== 'string' || ref.attachment === '' || !(ref.skin === null || typeof ref.skin === 'string')) {
537
+ refuse('REDUCE_INPUT_MISSING', `attachment is ${JSON.stringify(ref)}; required { skin: string | null, slot: string, attachment: string } with a slot and an attachment name`);
538
+ }
539
+ const who = `attachment ${nameOf(ref)}`;
540
+ if (typeof input.id !== 'string' || input.id === '') refuse('REDUCE_INPUT_MISSING', `${who}: id is ${JSON.stringify(input.id)}; required a non-empty string naming the mesh measured`);
541
+
542
+ // The art.
543
+ const art = input.art;
544
+ present(who, 'art', art, '{ mask, threshold, frame }');
545
+ if (!isObject(art)) refuse('REDUCE_INPUT_MISSING', `${who}: art is ${JSON.stringify(art)}; required { mask, threshold, frame }`);
546
+ present(who, 'art.threshold', art.threshold, 'a whole number in 1..255');
547
+ if (!Number.isInteger(art.threshold) || art.threshold < 1 || art.threshold > 255) {
548
+ refuse('REDUCE_THRESHOLD_RANGE', `${who}: art.threshold is ${JSON.stringify(art.threshold)}; required a whole number in 1..255 (art is alpha >= threshold)`);
549
+ }
550
+ const frame = art.frame;
551
+ present(who, 'art.frame', frame, '{ space, width, height, pageScale, conversion }');
552
+ if (!isObject(frame)) refuse('REDUCE_INPUT_MISSING', `${who}: art.frame is ${JSON.stringify(frame)}; required { space, width, height, pageScale, conversion }`);
553
+ if (frame.space !== 'part-local-drawing-px-y-down') refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.space is ${JSON.stringify(frame.space)}; required "part-local-drawing-px-y-down"`);
554
+ if (frame.conversion !== 'texels = px * pageScale') refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.conversion is ${JSON.stringify(frame.conversion)}; required "texels = px * pageScale"`);
555
+ for (const field of ['width', 'height', 'pageScale'] as const) {
556
+ const v = frame[field];
557
+ if (!isFiniteNumber(v) || v <= 0) refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.${field} is ${JSON.stringify(v)}; required a finite number above 0`);
558
+ }
559
+ const mask = art.mask;
560
+ present(who, 'art.mask', mask, '{ width, height, alpha }');
561
+ if (!isObject(mask) || !(mask.alpha instanceof Uint8Array)) refuse('REDUCE_INPUT_MISSING', `${who}: art.mask is not { width, height, alpha: Uint8Array }; required an AlphaMask`);
562
+ if (!Number.isInteger(mask.width) || !Number.isInteger(mask.height) || mask.width < 1 || mask.height < 1) {
563
+ refuse('REDUCE_MASK_SIZE', `${who}: the mask is ${mask.width}x${mask.height}; required whole dimensions of at least 1x1`);
564
+ }
565
+ if (mask.alpha.length !== mask.width * mask.height) {
566
+ refuse('REDUCE_MASK_SIZE', `${who}: the mask holds ${mask.alpha.length} bytes for ${mask.width}x${mask.height}; required width × height = ${mask.width * mask.height}`);
567
+ }
568
+ // Correction 1: the mask has to be THIS attachment's — its grid is the frame's, by the stated scale.
569
+ const wantW = r6(frame.width * frame.pageScale);
570
+ const wantH = r6(frame.height * frame.pageScale);
571
+ if (mask.width !== wantW || mask.height !== wantH) {
572
+ refuse(
573
+ 'REDUCE_MASK_SIZE',
574
+ `${who}: the mask is ${mask.width}x${mask.height} and the frame is ${frame.width}x${frame.height} px at pageScale ${frame.pageScale}; ` +
575
+ `required a ${wantW}x${wantH} mask (frame × pageScale) — a mask is one attachment's and is never shared with another of a different size`,
576
+ );
577
+ }
578
+
579
+ // The mesh.
580
+ const src = input.source;
581
+ present(who, 'source', src, '{ points, uvs, triangles, hull, weights }');
582
+ if (!isObject(src) || !Array.isArray(src.points) || !Array.isArray(src.uvs) || !Array.isArray(src.triangles)) {
583
+ refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights } with three arrays`);
584
+ }
585
+ src.points.forEach((p, i) => {
586
+ if (!isPoint(p)) refuse('REDUCE_INPUT_MISSING', `${who}: source.points[${i}] is ${JSON.stringify(p)}; required a pair of finite numbers`);
587
+ });
588
+ if (src.uvs.length !== 2 * src.points.length) {
589
+ refuse('REDUCE_INPUT_MISSING', `${who}: source.uvs holds ${src.uvs.length} numbers for ${src.points.length} points; required one u, v pair per point (${2 * src.points.length})`);
590
+ }
591
+ src.uvs.forEach((u, i) => {
592
+ if (!isFiniteNumber(u) || u < -UV_RANGE_SLACK || u > 1 + UV_RANGE_SLACK) {
593
+ refuse('REDUCE_UV_RANGE', `${who}: source.uvs[${i}] (vertex ${Math.floor(i / 2)}, ${i % 2 === 0 ? 'u' : 'v'}) is ${u}; required 0..1 within ${UV_RANGE_SLACK} (A22)`);
594
+ }
595
+ });
596
+ if (!Number.isInteger(src.hull)) refuse('REDUCE_INPUT_MISSING', `${who}: source.hull is ${JSON.stringify(src.hull)}; required the whole number of hull vertices`);
597
+ if (src.weights !== null) {
598
+ if (!Array.isArray(src.weights) || src.weights.length !== src.points.length) {
599
+ refuse('REDUCE_INPUT_MISSING', `${who}: source.weights is not one binding list per point (${src.points.length}); required that, or null for an unweighted mesh`);
600
+ }
601
+ src.weights.forEach((vertex, i) => {
602
+ if (!Array.isArray(vertex) || vertex.some((b) => !isObject(b) || typeof b.bone !== 'string' || !isFiniteNumber(b.weight))) {
603
+ refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] is not a list of { bone, weight }; required that`);
604
+ }
605
+ });
606
+ }
607
+
608
+ // The bounds.
609
+ const targets = input.targets;
610
+ present(who, 'targets', targets, '{ artFit, maxBoundaryDeviation, regions }');
611
+ if (!isObject(targets)) refuse('REDUCE_INPUT_MISSING', `${who}: targets is ${JSON.stringify(targets)}; required { artFit, maxBoundaryDeviation, regions }`);
612
+ present(who, 'targets.artFit', targets.artFit, 'an ArtFitBounds or null');
613
+ if (targets.artFit !== null) {
614
+ const fit = targets.artFit;
615
+ const coverage = fit.minCoverage;
616
+ if (!isFiniteNumber(coverage) || coverage < 0 || coverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.minCoverage is ${JSON.stringify(coverage)}; required a fraction in 0..1`);
617
+ for (const field of ['maxOvershoot', 'maxUndercut'] as const) {
618
+ const v = fit[field];
619
+ if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
620
+ }
621
+ }
622
+ present(who, 'targets.maxBoundaryDeviation', targets.maxBoundaryDeviation, 'a number of px or null');
623
+ if (targets.maxBoundaryDeviation !== null && (!isFiniteNumber(targets.maxBoundaryDeviation) || targets.maxBoundaryDeviation < 0)) {
624
+ refuse('REDUCE_INPUT_MISSING', `${who}: targets.maxBoundaryDeviation is ${JSON.stringify(targets.maxBoundaryDeviation)}; required a finite number of px, 0 or more, or null`);
625
+ }
626
+ if (targets.minAngle !== undefined && (!isFiniteNumber(targets.minAngle) || targets.minAngle < 0)) {
627
+ refuse('REDUCE_INPUT_MISSING', `${who}: targets.minAngle is ${JSON.stringify(targets.minAngle)}; required a finite number of degrees, 0 or more, or the field left out`);
628
+ }
629
+ present(who, 'targets.regions', targets.regions, 'a list of regions (empty when none)');
630
+ if (!Array.isArray(targets.regions)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions is ${JSON.stringify(targets.regions)}; required a list (empty when none)`);
631
+ present(who, 'referenceHull', input.referenceHull, 'a polygon or null');
632
+ if (input.referenceHull !== null) {
633
+ if (!Array.isArray(input.referenceHull) || input.referenceHull.length < 3 || !input.referenceHull.every(isPoint)) {
634
+ refuse('REDUCE_INPUT_MISSING', `${who}: referenceHull is not a polygon of at least 3 finite points; required that, or null`);
635
+ }
636
+ }
637
+
638
+ // P9: the sample floors.
639
+ present(who, 'minArtSamples', input.minArtSamples, 'a whole number >= 1');
640
+ if (!Number.isInteger(input.minArtSamples) || input.minArtSamples < 1) {
641
+ refuse('REDUCE_INPUT_MISSING', `${who}: minArtSamples is ${JSON.stringify(input.minArtSamples)}; required a whole number >= 1 (P9, no hidden constant)`);
642
+ }
643
+ present(who, 'regionArtSamples', input.regionArtSamples, 'one { region, minArtSamples } per region');
644
+ if (!Array.isArray(input.regionArtSamples)) refuse('REDUCE_INPUT_MISSING', `${who}: regionArtSamples is not a list; required one { region, minArtSamples } per region`);
645
+ const names = new Set<string>();
646
+ targets.regions.forEach((region, i) => {
647
+ if (!isObject(region) || typeof region.name !== 'string' || region.name === '') {
648
+ refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions[${i}] has no name; required a non-empty name, which every row of the region is keyed by`);
649
+ }
650
+ if (names.has(region.name)) refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" is named twice; required one name per region, which its rows and its sample floor are keyed by`);
651
+ names.add(region.name);
652
+ if (!Array.isArray(region.polygon) || region.polygon.length < 3) {
653
+ refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" has a polygon of ${Array.isArray(region.polygon) ? region.polygon.length : 'no'} vertices; required at least 3`);
654
+ }
655
+ for (const field of ['maxEdgeLength', 'transition', 'grade'] as const) present(`${who}, region "${region.name}"`, field, region[field], 'a finite number');
656
+ present(`${who}, region "${region.name}"`, 'approximation', region.approximation, 'an approximation or null');
657
+ const floors = input.regionArtSamples.filter((f) => isObject(f) && f.region === region.name);
658
+ if (floors.length !== 1 || !Number.isInteger(floors[0].minArtSamples) || floors[0].minArtSamples < 1) {
659
+ refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" has ${floors.length} sample floor(s) in regionArtSamples; required exactly one, a whole number >= 1 (P9)`);
660
+ }
661
+ });
662
+ input.regionArtSamples.forEach((f, i) => {
663
+ if (!isObject(f) || !names.has(f.region)) refuse('REDUCE_INPUT_MISSING', `${who}: regionArtSamples[${i}] names ${JSON.stringify(isObject(f) ? f.region : f)}, which no region is; required a floor for a declared region`);
664
+ });
665
+
666
+ // Echoed, never read — but present, since nothing has a default.
667
+ for (const field of ['protect', 'influences', 'boneOrder', 'preset'] as const) present(who, field, input[field], 'a value or null');
668
+ if (input.protect !== null) {
669
+ const p = input.protect;
670
+ const ok =
671
+ isObject(p) &&
672
+ typeof p.hull === 'boolean' &&
673
+ Array.isArray(p.vertices) &&
674
+ Array.isArray(p.edges) &&
675
+ Array.isArray(p.regionBoundaries) &&
676
+ (p.weightJump === null || isFiniteNumber(p.weightJump)) &&
677
+ Array.isArray(p.influences);
678
+ if (!ok) refuse('REDUCE_INPUT_MISSING', `${who}: protect is not a ProtectedFeatures with every field; required { hull, vertices, edges, regionBoundaries, weightJump, influences }, or null`);
679
+ }
680
+ if (input.influences !== null && (!isObject(input.influences) || !isFiniteNumber(input.influences.maxInfluences) || !isFiniteNumber(input.influences.minWeight))) {
681
+ refuse('REDUCE_INPUT_MISSING', `${who}: influences is not { maxInfluences, minWeight }; required that, or null`);
682
+ }
683
+ if (input.boneOrder !== null && (!Array.isArray(input.boneOrder) || input.boneOrder.some((b) => typeof b !== 'string'))) {
684
+ refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder is not a list of bone names; required that, or null`);
685
+ }
686
+ if (input.preset !== null && (!isObject(input.preset) || typeof input.preset.name !== 'string' || typeof input.preset.version !== 'string')) {
687
+ refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
688
+ }
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // plane geometry
693
+ // ---------------------------------------------------------------------------
694
+
695
+ type Pt = readonly [number, number];
696
+
697
+ /** Is `p` inside or on the closed polygon? On the boundary within `ON_BOUNDARY` counts as inside. */
698
+ function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
699
+ const n = poly.length;
700
+ for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
701
+ let inside = false;
702
+ for (let i = 0, j = n - 1; i < n; j = i++) {
703
+ const [xi, yi] = poly[i];
704
+ const [xj, yj] = poly[j];
705
+ if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
706
+ }
707
+ return inside;
708
+ }
709
+
710
+ /** Does the segment `a`–`b` meet the closed polygon — an endpoint inside or on it, or any crossing or touch? */
711
+ function segmentMeetsPolygon(a: Pt, b: Pt, poly: readonly Pt[]): boolean {
712
+ if (inClosedPolygon(a, poly) || inClosedPolygon(b, poly)) return true;
713
+ const n = poly.length;
714
+ for (let i = 0; i < n; i++) if (segmentsMeet(a, b, poly[i], poly[(i + 1) % n])) return true;
715
+ return false;
716
+ }
717
+
718
+ /** Distance between two segments that do not meet: the nearest of the four endpoint-to-segment distances. */
719
+ function segmentDistance(a: Pt, b: Pt, c: Pt, d: Pt): number {
720
+ if (segmentsMeet(a, b, c, d)) return 0;
721
+ return Math.min(distanceToSegment(a, c, d), distanceToSegment(b, c, d), distanceToSegment(c, a, b), distanceToSegment(d, a, b));
722
+ }
723
+
724
+ /** Distance from a segment to a closed polygon's region: 0 when they meet, else to its nearest edge. */
725
+ function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
726
+ if (segmentMeetsPolygon(a, b, poly)) return 0;
727
+ let best = Infinity;
728
+ const n = poly.length;
729
+ for (let i = 0; i < n; i++) best = Math.min(best, segmentDistance(a, b, poly[i], poly[(i + 1) % n]));
730
+ return best;
731
+ }
732
+
733
+ /**
734
+ * The parameter in `[0, 1]` along `a`–`b` nearest which `c`–`d` lies — the
735
+ * minimum of the distance from `a + t(b − a)` to the segment `c`–`d`, which is
736
+ * convex in `t`, found by golden-section search. A flat minimum (parallel
737
+ * segments) returns some point of it; the callers never read one there.
738
+ */
739
+ function nearestParameter(a: Pt, b: Pt, c: Pt, d: Pt): number {
740
+ const at = (t: number): number => distanceToSegment([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], c, d);
741
+ const g = (Math.sqrt(5) - 1) / 2;
742
+ let lo = 0;
743
+ let hi = 1;
744
+ for (let i = 0; i < EDGE_SEARCH_STEPS; i++) {
745
+ const p = hi - g * (hi - lo);
746
+ const q = lo + g * (hi - lo);
747
+ if (at(p) <= at(q)) hi = q;
748
+ else lo = p;
749
+ }
750
+ return (lo + hi) / 2;
751
+ }
752
+
753
+ /**
754
+ * Does `a`–`b` run along the outer boundary of a band of width `transition`
755
+ * for a positive length — some polygon edge it overlaps in projection by more
756
+ * than `BAND_CONTACT_TOLERANCE`, at that edge's perpendicular distance
757
+ * `transition` (within the tolerance) at both ends of the overlap, so at every
758
+ * point between them? The caller has already found the edge's nearest approach
759
+ * to the polygon within the tolerance of `transition`.
760
+ */
761
+ function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: number): boolean {
762
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
763
+ const n = poly.length;
764
+ for (let i = 0; i < n; i++) {
765
+ const c = poly[i];
766
+ const e = poly[(i + 1) % n];
767
+ const side = Math.hypot(e[0] - c[0], e[1] - c[1]);
768
+ if (side === 0) continue;
769
+ const ux = (e[0] - c[0]) / side;
770
+ const uy = (e[1] - c[1]) / side;
771
+ const sa = (a[0] - c[0]) * ux + (a[1] - c[1]) * uy;
772
+ const sb = (b[0] - c[0]) * ux + (b[1] - c[1]) * uy;
773
+ let t0 = 0;
774
+ let t1 = 1;
775
+ if (sa === sb) {
776
+ if (sa < 0 || sa > side) continue;
777
+ } else {
778
+ const p = (0 - sa) / (sb - sa);
779
+ const q = (side - sa) / (sb - sa);
780
+ t0 = Math.max(0, Math.min(p, q));
781
+ t1 = Math.min(1, Math.max(p, q));
782
+ }
783
+ if ((t1 - t0) * length <= BAND_CONTACT_TOLERANCE) continue;
784
+ const off = (t: number): number => Math.abs(ux * (a[1] + (b[1] - a[1]) * t - c[1]) - uy * (a[0] + (b[0] - a[0]) * t - c[0]));
785
+ if (Math.abs(off(t0) - transition) <= BAND_CONTACT_TOLERANCE && Math.abs(off(t1) - transition) <= BAND_CONTACT_TOLERANCE) return true;
786
+ }
787
+ return false;
788
+ }
789
+
790
+ /**
791
+ * [agreed, spine-parts#126] Is the edge `a`–`b` held by `region` — does the
792
+ * region's density bound (`MQ_MAX_EDGE` or `MQ_TRANSITION`) apply to it? The
793
+ * one definition both `measureMeshQuality` and `reduceMesh`'s refinement read.
794
+ *
795
+ * The region's active domain is its closed polygon and, when `transition > 0`,
796
+ * the band of that width outside it. An edge is held when it meets that domain,
797
+ * except for one case: its intersection with the domain is a **single point on
798
+ * the band's outer boundary** and the rest of the edge lies outside — an edge
799
+ * that only touches the band from outside is exempt.
800
+ *
801
+ * - An edge that meets the closed polygon is held, whatever the band.
802
+ * - With `transition: 0` there is no outer band, and a contact with the
803
+ * authored boundary is held under the closed-region rule — never exempt.
804
+ * - A positive-length intersection is held, including a segment lying along
805
+ * the band's outer boundary.
806
+ * - Two separate touches of the outer boundary are two points, not one, and
807
+ * the edge is held.
808
+ *
809
+ * "On the outer boundary" is within `BAND_CONTACT_TOLERANCE`. Each region is
810
+ * read on its own: an exemption from one never removes another's bound.
811
+ */
812
+ export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [number, number], region: RefinementRegion): boolean {
813
+ const poly: readonly Pt[] = region.polygon;
814
+ if (segmentMeetsPolygon(a, b, poly)) return true;
815
+ const transition = region.transition;
816
+ if (!(transition > 0)) return false;
817
+ const d = segmentToPolygon(a, b, poly);
818
+ if (d > transition) return false;
819
+ if (d < transition - BAND_CONTACT_TOLERANCE) return true;
820
+ if (runsAlongOuterBoundary(a, b, poly, transition)) return true;
821
+ // Where on the edge each polygon side is touched: one point, or several apart.
822
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
823
+ let first = Infinity;
824
+ let last = -Infinity;
825
+ const n = poly.length;
826
+ for (let i = 0; i < n; i++) {
827
+ const c = poly[i];
828
+ const e = poly[(i + 1) % n];
829
+ if (segmentDistance(a, b, c, e) > transition) continue;
830
+ const t = nearestParameter(a, b, c, e);
831
+ first = Math.min(first, t);
832
+ last = Math.max(last, t);
833
+ }
834
+ return (last - first) * length > BAND_CONTACT_TOLERANCE;
835
+ }
836
+
837
+ /** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
838
+ function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
839
+ let d = Infinity;
840
+ let edge = 0;
841
+ const n = poly.length;
842
+ for (let i = 0; i < n; i++) {
843
+ const v = distanceToSegment(p, poly[i], poly[(i + 1) % n]);
844
+ if (v < d) {
845
+ d = v;
846
+ edge = i;
847
+ }
848
+ }
849
+ return { d, edge };
850
+ }
851
+
852
+ /**
853
+ * The directed Hausdorff distance from the boundary of `a` to the boundary of
854
+ * `b` — the furthest any point of `a`'s outline sits from `b`'s — and where.
855
+ *
856
+ * Exact up to `HAUSDORFF_TOLERANCE`, by branch and bound along each edge of
857
+ * `a`. The distance to one edge of `b` is convex along a segment, so on any
858
+ * piece of an edge of `a` it is at most the larger of its two end values; the
859
+ * distance to the whole of `b` is the minimum over `b`'s edges, so the smallest
860
+ * of those maxima bounds it from above. A piece whose bound cannot beat the best
861
+ * value already found is dropped; the rest are halved. No sampling density is
862
+ * chosen, so nothing here can miss a maximum between two samples.
863
+ */
864
+ function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt) => Float64Array) | null = null): { d: number; edge: number; at: Pt } {
865
+ const m = b.length;
866
+ const toEach =
867
+ carried ??
868
+ ((p: Pt): Float64Array => {
869
+ const out = new Float64Array(m);
870
+ for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
871
+ return out;
872
+ });
873
+ const minOf = (g: Float64Array): number => {
874
+ let v = Infinity;
875
+ for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
876
+ return v;
877
+ };
878
+ let best = -1;
879
+ let bestEdge = 0;
880
+ let bestAt: Pt = a[0];
881
+ const n = a.length;
882
+ for (let i = 0; i < n; i++) {
883
+ const p0 = a[i];
884
+ const p1 = a[(i + 1) % n];
885
+ const at = (t: number): Pt => [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t];
886
+ const consider = (f: number, t: number): void => {
887
+ if (f > best) {
888
+ best = f;
889
+ bestEdge = i;
890
+ bestAt = at(t);
891
+ }
892
+ };
893
+ const g0 = toEach(p0);
894
+ const g1 = toEach(p1);
895
+ consider(minOf(g0), 0);
896
+ consider(minOf(g1), 1);
897
+ const stack: Array<{ t0: number; t1: number; g0: Float64Array; g1: Float64Array; depth: number }> = [{ t0: 0, t1: 1, g0, g1, depth: 0 }];
898
+ while (stack.length > 0) {
899
+ const piece = stack.pop()!;
900
+ let upper = Infinity;
901
+ for (let j = 0; j < m; j++) upper = Math.min(upper, Math.max(piece.g0[j], piece.g1[j]));
902
+ if (upper <= best + HAUSDORFF_TOLERANCE || piece.depth >= 64) continue;
903
+ const tm = (piece.t0 + piece.t1) / 2;
904
+ const gm = toEach(at(tm));
905
+ consider(minOf(gm), tm);
906
+ stack.push({ t0: piece.t0, t1: tm, g0: piece.g0, g1: gm, depth: piece.depth + 1 });
907
+ stack.push({ t0: tm, t1: piece.t1, g0: gm, g1: piece.g1, depth: piece.depth + 1 });
908
+ }
909
+ }
910
+ return { d: Math.max(best, 0), edge: bestEdge, at: bestAt };
911
+ }
912
+
913
+ /**
914
+ * The symmetric Hausdorff distance between two closed polygons' boundaries,
915
+ * with the candidate hull edge the worst value belongs to: the edge it was
916
+ * taken on when the worst point is the candidate's, else the candidate edge
917
+ * nearest the reference's worst point.
918
+ */
919
+ function hausdorff(candidate: readonly Pt[], reference: readonly Pt[], carried: { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array } | null = null): { d: number; candidateEdge: number } {
920
+ const forward = directedHausdorff(candidate, reference, carried?.forward ?? null);
921
+ const backward = directedHausdorff(reference, candidate, carried?.backward ?? null);
922
+ if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
923
+ return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
924
+ }
925
+
926
+ /** A point as a key: both coordinates' shortest round-trip decimals, a negative zero told from a positive one. */
927
+ function pointKey(p: Pt): string {
928
+ const k = (v: number): string => (v === 0 && 1 / v < 0 ? '-0' : String(v));
929
+ return `${k(p[0])},${k(p[1])}`;
930
+ }
931
+
932
+ /**
933
+ * `hausdorff`'s point-to-edge distance lists for one outline slot of a
934
+ * reduction, carried from the slot's last reading (`OutlineMemo`, issue #1246).
935
+ * Every list is the one `directedHausdorff` computes — `distanceToSegment(p,
936
+ * b[j], b[(j + 1) % m])` for every edge `j` — had in one of three ways, each
937
+ * the same function of the same input:
938
+ *
939
+ * - **toward the polygon read against** (fixed in a reduction): the list
940
+ * kept for the same point, handed back whole while that polygon is equal;
941
+ * - **toward the outline**, which a step changes: the last reading's list for
942
+ * the same point, each distance copied when its edge — both ends, exactly —
943
+ * is an edge of the last outline, and computed when it is not;
944
+ * - anything else: computed.
945
+ */
946
+ function carriedDistances(
947
+ memo: OutlineMemo,
948
+ outline: readonly Pt[],
949
+ against: readonly Pt[],
950
+ steps: StepRasters,
951
+ ): { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array; commit: () => void } {
952
+ const { tally, plant } = steps;
953
+ memo.readings++;
954
+ const reading = memo.readings;
955
+ // The polygon read against: keep the lists while it is equal, coordinate by coordinate.
956
+ const againstFlat = new Float64Array(against.length * 2);
957
+ against.forEach((p, i) => {
958
+ againstFlat[i * 2] = p[0];
959
+ againstFlat[i * 2 + 1] = p[1];
960
+ });
961
+ const sameAgainst = memo.against !== null && memo.against.length === againstFlat.length && memo.against.every((v, i) => Object.is(v, againstFlat[i]));
962
+ if (!sameAgainst) {
963
+ memo.toAgainst.clear();
964
+ memo.against = againstFlat;
965
+ }
966
+ const ma = against.length;
967
+ const forward = (p: Pt): Float64Array => {
968
+ tally.distanceLists++;
969
+ const key = pointKey(p);
970
+ const kept = memo.toAgainst.get(key);
971
+ if (kept !== undefined) {
972
+ kept.used = reading;
973
+ tally.distanceListsReused++;
974
+ return kept.list;
975
+ }
976
+ const list = new Float64Array(ma);
977
+ for (let j = 0; j < ma; j++) list[j] = distanceToSegment(p, against[j], against[(j + 1) % ma]);
978
+ tally.edgesComputed += ma;
979
+ memo.toAgainst.set(key, { list, used: reading });
980
+ return list;
981
+ };
982
+ // The outline's edges, and where each sat in the last outline.
983
+ const mo = outline.length;
984
+ const edges = new Map<string, number>();
985
+ const from = new Int32Array(mo);
986
+ const lastEdges = memo.outlineEdges;
987
+ let plantPending = plant === 'skip-an-edge';
988
+ for (let j = 0; j < mo; j++) {
989
+ const key = `${pointKey(outline[j])};${pointKey(outline[(j + 1) % mo])}`;
990
+ edges.set(key, j);
991
+ const was = lastEdges?.get(key);
992
+ from[j] = was ?? -1;
993
+ if (was === undefined && plantPending && lastEdges !== null && j < lastEdges.size) {
994
+ // The plant: one new edge read as if it were the last outline's edge at the same index.
995
+ plantPending = false;
996
+ from[j] = j;
997
+ }
998
+ }
999
+ const last = memo.toOutline;
1000
+ const next = new Map<string, Float64Array>();
1001
+ const backward = (p: Pt): Float64Array => {
1002
+ tally.distanceLists++;
1003
+ const key = pointKey(p);
1004
+ const done = next.get(key);
1005
+ if (done !== undefined) {
1006
+ tally.distanceListsReused++;
1007
+ return done;
1008
+ }
1009
+ const before = last.get(key);
1010
+ const list = new Float64Array(mo);
1011
+ if (before === undefined) {
1012
+ for (let j = 0; j < mo; j++) list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
1013
+ tally.edgesComputed += mo;
1014
+ } else {
1015
+ tally.distanceListsCarried++;
1016
+ for (let j = 0; j < mo; j++) {
1017
+ if (from[j] >= 0) {
1018
+ list[j] = before[from[j]];
1019
+ tally.edgesCarried++;
1020
+ } else {
1021
+ list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
1022
+ tally.edgesComputed++;
1023
+ }
1024
+ }
1025
+ }
1026
+ next.set(key, list);
1027
+ return list;
1028
+ };
1029
+ return {
1030
+ forward,
1031
+ backward,
1032
+ commit: () => {
1033
+ // Keep this reading's outline lists for the next one; drop the lists toward the fixed polygon the last two readings did not ask for.
1034
+ memo.toOutline = next;
1035
+ memo.outlineEdges = edges;
1036
+ for (const [key, entry] of memo.toAgainst) if (entry.used < reading - 1) memo.toAgainst.delete(key);
1037
+ },
1038
+ };
1039
+ }
1040
+
1041
+ /** `hausdorff` through a reduction's carried distances (`carriedDistances`), dropping what the last two readings of the slot did not ask for. */
1042
+ function hausdorffCarried(memo: OutlineMemo, candidate: readonly Pt[], reference: readonly Pt[], steps: StepRasters): { d: number; candidateEdge: number } {
1043
+ const carried = carriedDistances(memo, candidate, reference, steps);
1044
+ const value = hausdorff(candidate, reference, carried);
1045
+ carried.commit();
1046
+ return value;
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------------------
1050
+ // rows
1051
+ // ---------------------------------------------------------------------------
1052
+
1053
+ /** A row as it is being built: the report's row, and whether the caller's declared contract requires it (P6). */
1054
+ interface Built {
1055
+ row: MeasureRow;
1056
+ required: boolean;
1057
+ }
1058
+
1059
+ function nearBoundOf(value: number | null, bound: MeasureRow['bound'], increment: number): RasterSensitivity['nearBound'] {
1060
+ if (value === null || bound === null) return 'clear';
1061
+ const gap = Math.abs(value - bound.value);
1062
+ if (gap === 0) return 'at-bound';
1063
+ return gap <= increment ? 'within-increment' : 'clear';
1064
+ }
1065
+
1066
+ /** Pass or fail against an inclusive bound — equality passes. */
1067
+ function judged(value: number, bound: { op: '<=' | '>='; value: number }): 'pass' | 'fail' {
1068
+ return (bound.op === '<=' ? value <= bound.value : value >= bound.value) ? 'pass' : 'fail';
1069
+ }
1070
+
1071
+ const NOTHING_WORSE: WorstSample = { at: {} };
1072
+
1073
+ interface RowSpec {
1074
+ code: string;
1075
+ region: string | null;
1076
+ unit: MeasureRow['unit'];
1077
+ attachment: AttachmentRef;
1078
+ }
1079
+
1080
+ function measuredRow(spec: RowSpec, value: number, bound: MeasureRow['bound'], worst: WorstSample, required: boolean): Built {
1081
+ return {
1082
+ row: {
1083
+ code: spec.code,
1084
+ object: { attachment: spec.attachment, region: spec.region },
1085
+ state: bound === null ? 'undeclared' : judged(value, bound),
1086
+ value,
1087
+ bound,
1088
+ unit: spec.unit,
1089
+ worst,
1090
+ reason: null,
1091
+ },
1092
+ required: required && bound !== null,
1093
+ };
1094
+ }
1095
+
1096
+ function unmeasuredRow(spec: RowSpec, state: 'refused' | 'not-measurable', reason: string, required: boolean): Built {
1097
+ return {
1098
+ row: { code: spec.code, object: { attachment: spec.attachment, region: spec.region }, state, value: null, bound: null, unit: spec.unit, worst: null, reason },
1099
+ required,
1100
+ };
1101
+ }
1102
+
1103
+ /** The raster fields of a row: its art, and the grid and increment its value is quantised on (correction 2). */
1104
+ function withRaster(built: Built, art: NonNullable<MeasureRow['art']>, grid: RasterSensitivity['grid'], increment: number): Built {
1105
+ const row = built.row;
1106
+ row.art = art;
1107
+ row.raster = {
1108
+ grid,
1109
+ spatialQuantum: 1 / grid.pageScale,
1110
+ valueIncrement: increment,
1111
+ nearBound: nearBoundOf(row.value, row.bound, increment),
1112
+ };
1113
+ return built;
1114
+ }
1115
+
1116
+ /** Row order (§2): code, then region (the attachment's own row first), then the gated fill before the labelled legacy one. */
1117
+ function compareRows(a: MeasureRow, b: MeasureRow): number {
1118
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
1119
+ const ra = a.object.region;
1120
+ const rb = b.object.region;
1121
+ if (ra !== rb) {
1122
+ if (ra === null) return -1;
1123
+ if (rb === null) return 1;
1124
+ return ra < rb ? -1 : 1;
1125
+ }
1126
+ const ca = a.art?.connectivity ?? 0;
1127
+ const cb = b.art?.connectivity ?? 0;
1128
+ return cb - ca;
1129
+ }
1130
+
1131
+ // ---------------------------------------------------------------------------
1132
+ // the operation
1133
+ // ---------------------------------------------------------------------------
1134
+
1135
+ /**
1136
+ * Measure one mesh against the art its attachment draws and the bounds the
1137
+ * caller declared — §4's geometry rows, no motion. Throws a
1138
+ * `MeshReductionError` for an input §1 refuses; reports a source whose
1139
+ * triangles are not one closed loop rather than throwing, with `sourceCounts`
1140
+ * null and an `unsupported-topology` termination saying why (correction 3: a
1141
+ * count is never invented).
1142
+ */
1143
+ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1144
+ validateInput(input);
1145
+ return measureValidated(input, artRastersOf(input.art), null);
1146
+ }
1147
+
1148
+ /**
1149
+ * `measureMeshQuality` over art rasters the caller already holds
1150
+ * (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
1151
+ * one call, so the quantities of the art alone are computed once per call
1152
+ * rather than once per step (issue #1240). The report is the one
1153
+ * `measureMeshQuality` returns for the same input, byte for byte; rasters
1154
+ * taken from another art are refused (`REDUCE_ART_RASTERS_MISMATCH`), never
1155
+ * read.
1156
+ *
1157
+ * Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
1158
+ * module with `export *`, and a symbol that is merely exported is not promised
1159
+ * (RELEASING.md, *The import surface*).
1160
+ */
1161
+ export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1162
+ validateInput(input);
1163
+ checkArtRasters(input, rasters);
1164
+ return measureValidated(input, rasters, null);
1165
+ }
1166
+
1167
+ /**
1168
+ * `measureMeshQualityWith` carried from the last measurement the same
1169
+ * `StepRasters` made (`src/meshrasters.ts`, issue #1246) — what `reduceMesh`
1170
+ * calls for each step, so a step redraws only the triangles its removal
1171
+ * changed, redoes the distance transform only where the coverage flipped, and
1172
+ * reads the outline rows again only when the outline moved. The report is the
1173
+ * one `measureMeshQuality` returns for the same input, byte for byte; rasters
1174
+ * taken from another art are refused as `measureMeshQualityWith` refuses them.
1175
+ *
1176
+ * Internal, as `measureMeshQualityWith` is.
1177
+ */
1178
+ export function measureMeshQualityStep(input: MeshMeasureInput, steps: StepRasters): MeshQualityReport {
1179
+ validateInput(input);
1180
+ checkArtRasters(input, steps.rasters);
1181
+ return measureValidated(input, steps.rasters, steps);
1182
+ }
1183
+
1184
+ /**
1185
+ * The coverage reading of a triangle set measured on its own — the full path:
1186
+ * every triangle drawn (`rasteriseTriangles`), the whole distance transform,
1187
+ * and the covered art and islands counted over every pixel.
1188
+ */
1189
+ function coverageOf(onGrid: Array<[number, number]>, triangles: number[], w: number, h: number, rasters: ArtRasters): CoverageReading {
1190
+ const artBits = rasters.artBits();
1191
+ const covered = rasteriseTriangles(onGrid, triangles, w, h);
1192
+ let coveredArt = 0;
1193
+ for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
1194
+ const toCovered = squaredDistanceToSet(covered, w, h);
1195
+ const anyCovered = covered.some((c) => c === 1);
1196
+ let undercutAt = -1;
1197
+ let undercutSq = 0;
1198
+ for (let i = 0; i < artBits.length; i++) {
1199
+ if (!artBits[i] || covered[i]) continue;
1200
+ if (undercutAt === -1 || toCovered[i] > undercutSq) {
1201
+ undercutAt = i;
1202
+ undercutSq = toCovered[i];
1203
+ }
1204
+ }
1205
+ const { label } = rasters.islands();
1206
+ const touched = new Set<number>();
1207
+ for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
1208
+ const secondIsland = touched.size >= 2 ? [...touched].sort((p, q) => p - q)[1] : 0;
1209
+ const silhouette = (connectivity: 4 | 8): SilhouetteReading => {
1210
+ const fills = rasters.fills();
1211
+ const filled = connectivity === 8 ? fills.fill8 : fills.fill4;
1212
+ const toFilled = rasters.toFilled(connectivity);
1213
+ let overAt = -1;
1214
+ let overSq = 0;
1215
+ let holes = 0;
1216
+ let holeAt = -1;
1217
+ for (let i = 0; i < covered.length; i++) {
1218
+ if (!covered[i]) continue;
1219
+ if (!filled[i]) {
1220
+ if (overAt === -1 || toFilled[i] > overSq) {
1221
+ overAt = i;
1222
+ overSq = toFilled[i];
1223
+ }
1224
+ } else if (!artBits[i]) {
1225
+ holes++;
1226
+ if (holeAt === -1) holeAt = i;
1227
+ }
1228
+ }
1229
+ return { overAt, overSq, holes, holeAt };
1230
+ };
1231
+ return { covered, toCovered, coveredArt, anyCovered, undercut: { at: undercutAt, sq: undercutSq }, silhouette, islandsTouched: touched.size, secondIsland };
1232
+ }
1233
+
1234
+ /**
1235
+ * Rasters are read only for the art they were taken from: the same mask
1236
+ * array, mask size, threshold and frame. The first field that differs is
1237
+ * refused by name, with the value the rasters were taken at and the value the
1238
+ * input requires.
1239
+ */
1240
+ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
1241
+ const want = input.art;
1242
+ const got = rasters.art;
1243
+ if (got === want) return;
1244
+ const who = `attachment ${nameOf(input.attachment)}`;
1245
+ const differs = (field: string, found: string, required: string): never =>
1246
+ refuse('REDUCE_ART_RASTERS_MISMATCH', `${who}: the art rasters were taken at ${field} ${found}; required ${field} ${required}, the input's — rasters are read only for the art they were taken from`);
1247
+ const size = (m: AlphaMask | undefined): string => (m === undefined ? 'undefined' : `${m.width}x${m.height}`);
1248
+ if (size(got.mask) !== size(want.mask)) differs('art.mask size', size(got.mask), size(want.mask));
1249
+ if (got.threshold !== want.threshold) differs('art.threshold', JSON.stringify(got.threshold), JSON.stringify(want.threshold));
1250
+ for (const field of ['pageScale', 'width', 'height'] as const) {
1251
+ if (got.frame?.[field] !== want.frame[field]) differs(`art.frame.${field}`, JSON.stringify(got.frame?.[field]), JSON.stringify(want.frame[field]));
1252
+ }
1253
+ if (got.mask.alpha !== want.mask.alpha) differs('art.mask.alpha', 'another array', 'the input\'s own array (the same object)');
1254
+ }
1255
+
1256
+ /** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
1257
+ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
1258
+ rasters.tally.uses++;
1259
+ const { attachment, art, source, targets } = input;
1260
+ const { mask, threshold, frame } = art;
1261
+ const scale = frame.pageScale;
1262
+ const effective = effectiveOf(input);
1263
+ const report = (sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination | null): MeshQualityReport => ({
1264
+ spec: MESH_QUALITY_REPORT_SPEC,
1265
+ operation: 'measure',
1266
+ effective,
1267
+ poser: null,
1268
+ motionRequired: false,
1269
+ sourceCounts,
1270
+ reference: null,
1271
+ candidates: [candidate],
1272
+ termination,
1273
+ });
1274
+
1275
+ // The outline the triangles state — or the reason there is none.
1276
+ const n = source.points.length;
1277
+ let outline: MeshOutline;
1278
+ try {
1279
+ outline = traceOutline(n, source.triangles);
1280
+ if (outline.hull !== source.hull) {
1281
+ throw new MeshError(`source.hull says ${source.hull} and the triangles' outline has ${outline.hull} vertices`);
1282
+ }
1283
+ checkHullOrder(outline, n);
1284
+ } catch (err) {
1285
+ if (!(err instanceof MeshError)) throw err;
1286
+ const detail = `attachment ${nameOf(attachment)}: ${err.message}; required one closed outline listed first, in order (traceOutline, checkHullOrder)`;
1287
+ return report(null, { id: input.id, counts: null, geometry: null, motion: null, accepted: false }, { reason: 'unsupported-topology', code: 'REDUCE_SOURCE_NOT_ONE_LOOP', detail });
1288
+ }
1289
+
1290
+ const counts = countsOf(source, outline);
1291
+ const rows: Built[] = [];
1292
+ const spec = (code: string, unit: MeasureRow['unit'], region: string | null = null): RowSpec => ({ code, unit, region, attachment });
1293
+ const points: Pt[] = source.points;
1294
+ const hullPolygon: Pt[] = outline.walk.map((v) => points[v]);
1295
+
1296
+ // --- raster rows ---------------------------------------------------------
1297
+ const w = mask.width;
1298
+ const h = mask.height;
1299
+ const grid = { width: w, height: h, pageScale: scale };
1300
+ const artBits = rasters.artBits();
1301
+ const artCount = rasters.artCount();
1302
+ const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
1303
+ const fit = targets.artFit;
1304
+ const pxIncrement = 1 / scale;
1305
+ const rasterArt = (connectivity: 4 | 8 | null): NonNullable<MeasureRow['art']> => ({ threshold, connectivity, samples: artCount });
1306
+ const pixelOf = (i: number): [number, number] => [i % w, Math.floor(i / w)];
1307
+ if (artCount < input.minArtSamples) {
1308
+ const why = `attachment ${nameOf(attachment)} has ${artCount} art sample(s) at alpha >= ${threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`;
1309
+ rows.push(withRaster(unmeasuredRow(spec('MQ_COVERAGE', 'fraction'), 'not-measurable', why, fit !== null), rasterArt(null), grid, artCount === 0 ? 1 : 1 / artCount));
1310
+ rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null && fit.maxOvershoot !== null), rasterArt(8), grid, pxIncrement));
1311
+ rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null && fit.maxUndercut !== null), rasterArt(null), grid, pxIncrement));
1312
+ rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
1313
+ rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
1314
+ } else {
1315
+ // Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers. A reduction step
1316
+ // carries it from its last measurement (`StepRasters`), which reads the same values.
1317
+ const reading = steps === null ? coverageOf(onGrid, source.triangles, w, h, rasters) : steps.coverage(onGrid, source.triangles);
1318
+ const { coveredArt, anyCovered } = reading;
1319
+ // Undercut: the furthest uncovered art pixel from the covered set. Its pixel is coverage's worst too.
1320
+ const { at: undercutAt, sq: undercutSq } = reading.undercut;
1321
+ const missingWorst: WorstSample = undercutAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(undercutAt) } };
1322
+ rows.push(
1323
+ withRaster(
1324
+ measuredRow(spec('MQ_COVERAGE', 'fraction'), coveredArt / artCount, fit === null ? null : { op: '>=', value: fit.minCoverage }, missingWorst, true),
1325
+ rasterArt(null),
1326
+ grid,
1327
+ 1 / artCount,
1328
+ ),
1329
+ );
1330
+ const undercutSpec = spec('MQ_UNDERCUT', 'px');
1331
+ rows.push(
1332
+ withRaster(
1333
+ !anyCovered
1334
+ ? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null && fit.maxUndercut !== null)
1335
+ : measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null || fit.maxUndercut === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
1336
+ rasterArt(null),
1337
+ grid,
1338
+ pxIncrement,
1339
+ ),
1340
+ );
1341
+
1342
+ // Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
1343
+ // where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
1344
+ const fillsDiffer = rasters.fills().differ;
1345
+ const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
1346
+ const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
1347
+ const bound = gated && fit !== null && fit.maxOvershoot !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1348
+ rows.push(
1349
+ withRaster(
1350
+ measuredRow(spec('MQ_OVERSHOOT', 'px'), r6(Math.sqrt(overSq) / scale), bound, overAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(overAt) } }, gated),
1351
+ rasterArt(connectivity),
1352
+ grid,
1353
+ pxIncrement,
1354
+ ),
1355
+ );
1356
+ rows.push(withRaster(measuredRow(spec('MQ_HOLES', 'count'), holes, null, holeAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(holeAt) } }, false), rasterArt(connectivity), grid, 1));
1357
+ };
1358
+ silhouetteRows(8, true);
1359
+ if (fillsDiffer) silhouetteRows(4, false);
1360
+
1361
+ // Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
1362
+ const { label } = rasters.islands();
1363
+ const joinedAt = reading.islandsTouched >= 2 ? label.indexOf(reading.secondIsland) : -1;
1364
+ rows.push(
1365
+ withRaster(
1366
+ measuredRow(spec('MQ_ISLANDS', 'count'), reading.islandsTouched, null, joinedAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(joinedAt) } }, false),
1367
+ { threshold, connectivity: 4, samples: artCount },
1368
+ grid,
1369
+ 1,
1370
+ ),
1371
+ );
1372
+ }
1373
+
1374
+ // --- the outline against a reference, and against the traced art -------------------------
1375
+ const boundarySpec = spec('MQ_BOUNDARY_DEVIATION', 'px');
1376
+ const edgeOfHull = (i: number): [number, number] => [outline.walk[i], outline.walk[(i + 1) % outline.walk.length]];
1377
+ if (input.referenceHull === null) {
1378
+ rows.push(unmeasuredRow(boundarySpec, 'not-measurable', `attachment ${nameOf(attachment)}: no referenceHull was given, so there is no polygon to measure the hull's deviation from`, targets.maxBoundaryDeviation !== null));
1379
+ } else {
1380
+ const reference = input.referenceHull;
1381
+ const hd = steps === null ? hausdorff(hullPolygon, reference) : steps.outline('reference', hullPolygon, reference, (memo) => hausdorffCarried(memo, hullPolygon, reference, steps));
1382
+ const bound = targets.maxBoundaryDeviation === null ? null : { op: '<=' as const, value: targets.maxBoundaryDeviation };
1383
+ rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
1384
+ }
1385
+ const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
1386
+ const traced = rasters.traced();
1387
+ if ('outline' in traced) {
1388
+ const tracedOutline = traced.outline;
1389
+ const hd = steps === null ? hausdorff(hullPolygon, tracedOutline) : steps.outline('traced', hullPolygon, tracedOutline, (memo) => hausdorffCarried(memo, hullPolygon, tracedOutline, steps));
1390
+ rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
1391
+ } else {
1392
+ rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
1393
+ }
1394
+
1395
+ // --- triangles: sign, degeneracy, angle --------------------------------------------------
1396
+ const world: number[] = [];
1397
+ for (const [x, y] of points) world.push(x, cropToSpineY(y, frame.height));
1398
+ const areas = triangleAreas(world, source.triangles);
1399
+ const band = areaBand(areas, world);
1400
+ let flipped = 0;
1401
+ let flippedWorst = -1;
1402
+ let degenerate = 0;
1403
+ let degenerateWorst = -1;
1404
+ areas.forEach((a, t) => {
1405
+ if (Math.abs(a) <= band) {
1406
+ degenerate++;
1407
+ if (degenerateWorst === -1 || Math.abs(a) < Math.abs(areas[degenerateWorst])) degenerateWorst = t;
1408
+ } else if (a < 0) {
1409
+ flipped++;
1410
+ if (flippedWorst === -1 || a < areas[flippedWorst]) flippedWorst = t;
1411
+ }
1412
+ });
1413
+ rows.push(measuredRow(spec('MQ_ORIENTATION', 'count'), flipped, { op: '<=', value: 0 }, flippedWorst === -1 ? NOTHING_WORSE : { at: { triangle: flippedWorst } }, true));
1414
+ rows.push(measuredRow(spec('MQ_DEGENERATE', 'count'), degenerate, { op: '<=', value: 0 }, degenerateWorst === -1 ? NOTHING_WORSE : { at: { triangle: degenerateWorst } }, true));
1415
+ let smallest = Infinity;
1416
+ let smallestAt = 0;
1417
+ for (let t = 0; t * 3 + 2 < source.triangles.length; t++) {
1418
+ const corner = [points[source.triangles[t * 3]], points[source.triangles[t * 3 + 1]], points[source.triangles[t * 3 + 2]]];
1419
+ for (let k = 0; k < 3; k++) {
1420
+ const o = corner[k];
1421
+ const p = corner[(k + 1) % 3];
1422
+ const q = corner[(k + 2) % 3];
1423
+ const ux = p[0] - o[0];
1424
+ const uy = p[1] - o[1];
1425
+ const vx = q[0] - o[0];
1426
+ const vy = q[1] - o[1];
1427
+ const angle = (Math.atan2(Math.abs(ux * vy - uy * vx), ux * vx + uy * vy) * 180) / Math.PI;
1428
+ if (angle < smallest) {
1429
+ smallest = angle;
1430
+ smallestAt = t;
1431
+ }
1432
+ }
1433
+ }
1434
+ rows.push(
1435
+ measuredRow(spec('MQ_MIN_ANGLE', 'degrees'), r6(smallest), targets.minAngle === undefined ? null : { op: '>=', value: targets.minAngle }, { at: { triangle: smallestAt } }, true),
1436
+ );
1437
+
1438
+ // --- regions (§5) ------------------------------------------------------------------------
1439
+ rows.push(...regionRows(input, outline, hullPolygon, rasters, steps));
1440
+
1441
+ const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
1442
+ const geometry = sectionOf(ordered);
1443
+ const accepted = geometry.verdict === 'pass';
1444
+ return report(counts, { id: input.id, counts, geometry, motion: null, accepted }, null);
1445
+ }
1446
+
1447
+ /** What one source states about itself: hull and interior from the outline, bindings from the weights. */
1448
+ function countsOf(source: SourceMesh, outline: MeshOutline): MeshCounts {
1449
+ const bindings = source.weights === null ? 0 : source.weights.reduce((s, v) => s + v.length, 0);
1450
+ const maxInfluences = source.weights === null ? 0 : source.weights.reduce((m, v) => Math.max(m, v.length), 0);
1451
+ return {
1452
+ boundaryVertices: outline.hull,
1453
+ interiorVertices: source.points.length - outline.hull,
1454
+ triangles: source.triangles.length / 3,
1455
+ bindings,
1456
+ maxInfluences,
1457
+ };
1458
+ }
1459
+
1460
+ /**
1461
+ * The section's figures and verdict (§2, P6): `measured` is pass + fail; the
1462
+ * verdict is `fail` when a required row failed, `pass` only when every required
1463
+ * row passed and at least one row did, and `not-measured` otherwise — which is
1464
+ * what a required row that could not be measured, or was refused, leaves.
1465
+ */
1466
+ function sectionOf(built: readonly Built[]): EvidenceSection {
1467
+ const summary = { pass: 0, fail: 0, undeclared: 0, refused: 0, notMeasurable: 0, measured: 0 };
1468
+ for (const { row } of built) {
1469
+ if (row.state === 'pass') summary.pass++;
1470
+ else if (row.state === 'fail') summary.fail++;
1471
+ else if (row.state === 'undeclared') summary.undeclared++;
1472
+ else if (row.state === 'refused') summary.refused++;
1473
+ else summary.notMeasurable++;
1474
+ }
1475
+ summary.measured = summary.pass + summary.fail;
1476
+ const required = built.filter((b) => b.required);
1477
+ let verdict: EvidenceSection['verdict'];
1478
+ if (required.some((b) => b.row.state === 'fail')) verdict = 'fail';
1479
+ else if (required.every((b) => b.row.state === 'pass') && summary.pass > 0) verdict = 'pass';
1480
+ else verdict = 'not-measured';
1481
+ return { rows: built.map((b) => b.row), summary, verdict };
1482
+ }
1483
+
1484
+ // ---------------------------------------------------------------------------
1485
+ // §5 — regions
1486
+ // ---------------------------------------------------------------------------
1487
+
1488
+ /** A region's own refusal (§2's `refused`: this measurement's input is invalid), or null when it is measurable. */
1489
+ function regionRefusal(region: RefinementRegion, pageScale: number, who: string, steps: StepRasters | null): { code: string; message: string } | null {
1490
+ const numbers: Array<[string, unknown]> = [
1491
+ ['maxEdgeLength', region.maxEdgeLength],
1492
+ ['transition', region.transition],
1493
+ ['grade', region.grade],
1494
+ ...region.polygon.flatMap((p, i): Array<[string, unknown]> => (Array.isArray(p) ? [[`polygon[${i}][0]`, p[0]], [`polygon[${i}][1]`, p[1]]] : [[`polygon[${i}]`, p]])),
1495
+ ];
1496
+ for (const [field, value] of numbers) {
1497
+ if (!isFiniteNumber(value)) return { code: 'REGION_NOT_FINITE', message: `${who}, region "${region.name}": ${field} is ${String(value)}; required a finite number` };
1498
+ }
1499
+ if (region.transition < 0) return { code: 'REGION_TRANSITION_NEGATIVE', message: `${who}, region "${region.name}": transition is ${region.transition} px; required 0 or more (0 is a hard edge)` };
1500
+ if (region.grade < 0) return { code: 'REGION_GRADE_NEGATIVE', message: `${who}, region "${region.name}": grade is ${region.grade} px per px; required 0 or more` };
1501
+ if (region.maxEdgeLength < 1 / pageScale) {
1502
+ return {
1503
+ code: 'REGION_BOUND_BELOW_GRID',
1504
+ message: `${who}, region "${region.name}": maxEdgeLength is ${region.maxEdgeLength} px; required at least one texel, ${r6(1 / pageScale)} px at pageScale ${pageScale}`,
1505
+ };
1506
+ }
1507
+ // A function of the polygon alone: a reduction step has it handed back by its `StepRasters` (issue #1253).
1508
+ const crossing = steps === null ? findSelfIntersection(region.polygon) : steps.polygonCrossing(region.polygon, () => findSelfIntersection(region.polygon));
1509
+ if (crossing !== null) {
1510
+ return {
1511
+ code: 'REGION_SELF_INTERSECTS',
1512
+ message: `${who}, region "${region.name}": polygon edges ${crossing[0]} and ${crossing[1]} meet; required a strictly simple polygon (findSelfIntersection)`,
1513
+ };
1514
+ }
1515
+ return null;
1516
+ }
1517
+
1518
+ interface Edge {
1519
+ a: number;
1520
+ b: number;
1521
+ length: number;
1522
+ }
1523
+
1524
+ /**
1525
+ * The rows of every region: `MQ_MAX_EDGE` (§5's `L(R)`), `MQ_TRANSITION` (its
1526
+ * graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
1527
+ * sampled, informational).
1528
+ *
1529
+ * Which edges a region holds is `edgeIsHeldByRegion`'s answer and nothing
1530
+ * else's: an edge that only touches the band's outer boundary at one point,
1531
+ * from outside, is not held (spine-parts#126). One bound per edge, the smallest
1532
+ * applicable anywhere on it (P16): `L0` of every region whose closed polygon it
1533
+ * meets, and `L0 + grade·d` of every other region that holds it, `d` its
1534
+ * distance from that region. A row's value is the edge whose
1535
+ * length most exceeds its own bound — so the row fails exactly when some edge
1536
+ * it covers is over — and the row names that edge and that bound.
1537
+ *
1538
+ * `MQ_FILL_DISTANCE` reads the art pixels whose centre lies in the region's
1539
+ * closed polygon, a function of the art and the polygon alone: a reduction
1540
+ * step has it handed back by its `StepRasters` (`regionPixels`, issue #1253)
1541
+ * rather than testing every art pixel against the polygon again.
1542
+ */
1543
+ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters, steps: StepRasters | null): Built[] {
1544
+ const { attachment, source, art } = input;
1545
+ const who = `attachment ${nameOf(attachment)}`;
1546
+ const scale = art.frame.pageScale;
1547
+ const points: Pt[] = source.points;
1548
+ const out: Built[] = [];
1549
+ const spec = (code: string, unit: MeasureRow['unit'], region: string): RowSpec => ({ code, unit, region, attachment });
1550
+ const encoded = meshEdges(points.length, source.triangles, outline.hull);
1551
+ const edges: Edge[] = [];
1552
+ for (let i = 0; i < encoded.length; i += 2) {
1553
+ const a = encoded[i] / 2;
1554
+ const b = encoded[i + 1] / 2;
1555
+ edges.push({ a: Math.min(a, b), b: Math.max(a, b), length: Math.hypot(points[b][0] - points[a][0], points[b][1] - points[a][1]) });
1556
+ }
1557
+ edges.sort((p, q) => p.a - q.a || p.b - q.b);
1558
+
1559
+ // Each edge's answers for a region: computed here, or — on a reduction step — carried by its `StepRasters` from
1560
+ // the last reading of the region for an edge with the same two ends (`regionEdges`, issue #1253).
1561
+ const readings = new Map<RefinementRegion, RegionEdgeReading | null>();
1562
+ const readingOf = (region: RefinementRegion): RegionEdgeReading | null => {
1563
+ if (!readings.has(region)) readings.set(region, steps === null ? null : steps.regionEdges(region.polygon, region.transition));
1564
+ return readings.get(region)!;
1565
+ };
1566
+ const meetsOf = (region: RefinementRegion, e: Edge): boolean => {
1567
+ const record = readingOf(region)?.entry(points[e.a], points[e.b]);
1568
+ if (record === undefined) return segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
1569
+ if (record.meets === undefined) record.meets = segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
1570
+ return record.meets;
1571
+ };
1572
+ const heldOf = (region: RefinementRegion, e: Edge): number | null => {
1573
+ const held = (): number | null => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null);
1574
+ const record = readingOf(region)?.entry(points[e.a], points[e.b]);
1575
+ if (record === undefined) return held();
1576
+ if (record.held === undefined) record.held = held();
1577
+ return record.held;
1578
+ };
1579
+
1580
+ // Which regions are measurable, and the one refusal of each that is not.
1581
+ const live: RefinementRegion[] = [];
1582
+ const refusals = new Map<string, string>();
1583
+ for (const region of input.targets.regions) {
1584
+ let refusal = regionRefusal(region, scale, who, steps);
1585
+ if (refusal === null) {
1586
+ const poly: Pt[] = region.polygon;
1587
+ const meets = edges.some((e) => meetsOf(region, e)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
1588
+ if (!meets) {
1589
+ refusal = {
1590
+ code: 'REGION_OUTSIDE_ART',
1591
+ message: `${who}, region "${region.name}": the polygon does not meet the mesh's hull anywhere, so clipped to it the region is empty; required a region that meets the hull`,
1592
+ };
1593
+ }
1594
+ }
1595
+ if (refusal === null) live.push(region);
1596
+ else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
1597
+ }
1598
+
1599
+ /**
1600
+ * Per measurable region, per edge (in `edges` order): the edge's distance
1601
+ * from the closed polygon when the region holds it (`edgeIsHeldByRegion`,
1602
+ * the one definition the refinement reads too), null when it does not.
1603
+ */
1604
+ const holds = new Map<RefinementRegion, Array<number | null>>();
1605
+ for (const region of live) {
1606
+ holds.set(
1607
+ region,
1608
+ edges.map((e) => heldOf(region, e)),
1609
+ );
1610
+ }
1611
+ const edgeIndex = new Map(edges.map((e, i) => [e, i]));
1612
+
1613
+ /** Every bound that applies to an edge, from every measurable region that holds it, and the smallest. */
1614
+ const boundOf = (e: Edge): number | null => {
1615
+ let bound: number | null = null;
1616
+ const i = edgeIndex.get(e)!;
1617
+ for (const region of live) {
1618
+ const d = holds.get(region)![i];
1619
+ if (d === null) continue;
1620
+ const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
1621
+ if (bound === null || here < bound) bound = here;
1622
+ }
1623
+ return bound;
1624
+ };
1625
+
1626
+ /** The worst edge of a set: the largest length over its own bound, ties to the smaller (a, b). */
1627
+ const worstOf = (set: readonly Edge[]): { edge: Edge; bound: number } | null => {
1628
+ let best: { edge: Edge; bound: number; excess: number } | null = null;
1629
+ for (const e of set) {
1630
+ const bound = boundOf(e);
1631
+ if (bound === null) continue;
1632
+ const excess = r6(e.length) - bound;
1633
+ if (best === null || excess > best.excess) best = { edge: e, bound, excess };
1634
+ }
1635
+ return best === null ? null : { edge: best.edge, bound: best.bound };
1636
+ };
1637
+
1638
+ const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
1639
+ const { mask, threshold } = art;
1640
+ const artBits = rasters.artBits();
1641
+
1642
+ for (const region of input.targets.regions) {
1643
+ const refused = refusals.get(region.name);
1644
+ if (refused !== undefined) {
1645
+ out.push(unmeasuredRow(spec('MQ_MAX_EDGE', 'px', region.name), 'refused', refused, true));
1646
+ out.push(unmeasuredRow(spec('MQ_TRANSITION', 'px', region.name), 'refused', refused, true));
1647
+ out.push(unmeasuredRow(spec('MQ_FILL_DISTANCE', 'px', region.name), 'refused', refused, false));
1648
+ continue;
1649
+ }
1650
+ const poly: Pt[] = region.polygon;
1651
+ const held = holds.get(region)!;
1652
+ const inside = edges.filter((_, i) => held[i] === 0);
1653
+ const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
1654
+ const inWorst = worstOf(inside);
1655
+ if (inWorst === null) {
1656
+ out.push(unmeasuredRow(maxSpec, 'not-measurable', `${who}, region "${region.name}": no triangle edge meets the closed polygon — it lies inside one triangle — so L(R) is a maximum over no edge`, true));
1657
+ } else {
1658
+ out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
1659
+ }
1660
+ if (region.transition > 0) {
1661
+ const banded = edges.filter((_, i) => held[i] !== null && held[i]! > 0);
1662
+ const bandWorst = worstOf(banded);
1663
+ const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
1664
+ if (bandWorst === null) {
1665
+ out.push(unmeasuredRow(transitionSpec, 'not-measurable', `${who}, region "${region.name}": no triangle edge lies in the ${region.transition} px band outside the polygon`, true));
1666
+ } else {
1667
+ out.push(measuredRow(transitionSpec, r6(bandWorst.edge.length), { op: '<=', value: bandWorst.bound }, { at: { edge: [bandWorst.edge.a, bandWorst.edge.b] } }, true));
1668
+ }
1669
+ }
1670
+
1671
+ // h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex. The art
1672
+ // pixels inside the region come in ascending order, so the scan below visits what a scan of every art pixel
1673
+ // would keep, in the same order.
1674
+ const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
1675
+ const inRegion = (): Int32Array => {
1676
+ const kept: number[] = [];
1677
+ for (let i = 0; i < artBits.length; i++) if (artBits[i] && inClosedPolygon(centreOf(i), poly)) kept.push(i);
1678
+ return Int32Array.from(kept);
1679
+ };
1680
+ const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
1681
+ let count = 0;
1682
+ let worst = -1;
1683
+ let worstAt = -1;
1684
+ const visit = (i: number, nearest: number): void => {
1685
+ count++;
1686
+ if (nearest > worst) {
1687
+ worst = nearest;
1688
+ worstAt = i;
1689
+ }
1690
+ };
1691
+ const distance = (i: number, p: Pt): number => {
1692
+ const centre = centreOf(i);
1693
+ return Math.hypot(p[0] - centre[0], p[1] - centre[1]);
1694
+ };
1695
+ if (steps === null) {
1696
+ for (const i of regionPixels) {
1697
+ const centre = centreOf(i);
1698
+ if (!inClosedPolygon(centre, hullPolygon)) continue;
1699
+ let nearest = Infinity;
1700
+ for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
1701
+ visit(i, nearest);
1702
+ }
1703
+ } else {
1704
+ // A reduction step: the pixels in the hull and their nearest distances carried from the last reading (issue #1253).
1705
+ const carried = steps.regionFill(poly, { pixels: regionPixels, centre: centreOf, hull: hullPolygon, points, inHull: (i) => inClosedPolygon(centreOf(i), hullPolygon), distance });
1706
+ for (let j = 0; j < carried.inside.length; j++) visit(carried.inside[j], carried.nearest[j]);
1707
+ }
1708
+ const fillSpec = spec('MQ_FILL_DISTANCE', 'px', region.name);
1709
+ const floor = floorOf(region.name);
1710
+ const sampled = { threshold, connectivity: null, samples: count };
1711
+ const sampling = { domain: 'art pixel centres inside the region and the hull', count };
1712
+ let fill: Built;
1713
+ if (count < floor) {
1714
+ fill = unmeasuredRow(fillSpec, 'not-measurable', `${who}, region "${region.name}" holds ${count} art sample(s) inside the hull at alpha >= ${threshold}; required at least ${floor} (P9)`, false);
1715
+ } else {
1716
+ fill = measuredRow(fillSpec, r6(worst), null, { at: { pixel: [worstAt % mask.width, Math.floor(worstAt / mask.width)] } }, false);
1717
+ }
1718
+ fill.row.art = sampled;
1719
+ fill.row.sampling = sampling;
1720
+ out.push(fill);
1721
+ }
1722
+ return out;
1723
+ }
1724
+
1725
+ // ---------------------------------------------------------------------------
1726
+ // the echo, and the document's text
1727
+ // ---------------------------------------------------------------------------
1728
+
1729
+ function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
1730
+ const { art } = input;
1731
+ return {
1732
+ preset: input.preset,
1733
+ attachments: [
1734
+ {
1735
+ attachment: input.attachment,
1736
+ threshold: art.threshold,
1737
+ // A measurement states the threshold it was taken at and claims nothing at any other (Thresholds).
1738
+ finalThreshold: art.threshold,
1739
+ frame: art.frame,
1740
+ maskSize: [art.mask.width, art.mask.height],
1741
+ minArtSamples: input.minArtSamples,
1742
+ regions: input.targets.regions.map((r) => ({ name: r.name, minArtSamples: input.regionArtSamples.find((f) => f.region === r.name)!.minArtSamples })),
1743
+ },
1744
+ ],
1745
+ sourceBounds: null,
1746
+ referenceArtFit: null,
1747
+ candidateArtFit: null,
1748
+ targets: input.targets,
1749
+ referenceHull: input.referenceHull,
1750
+ motionBounds: null,
1751
+ protect: input.protect,
1752
+ influences: input.influences,
1753
+ boneOrder: input.boneOrder,
1754
+ schedule: null,
1755
+ budget: null,
1756
+ };
1757
+ }
1758
+
1759
+ // Every object of the document is rebuilt below in the order its type states
1760
+ // its keys, so the bytes depend on the values alone — never on the key order
1761
+ // an input object happened to be built in (A18's standard; `build-report/1`'s
1762
+ // precedent in `src/assertions/report.ts`). An optional key is written only
1763
+ // when it is present.
1764
+
1765
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
1766
+
1767
+ const pair = (p: readonly [number, number]): Json => [p[0], p[1]];
1768
+ const attachmentJson = (a: AttachmentRef): Json => ({ skin: a.skin, slot: a.slot, attachment: a.attachment });
1769
+ const fitJson = (f: ArtFitBounds | null): Json => (f === null ? null : { minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
1770
+ const frameJson = (f: SourceFrame): Json => ({ space: f.space, width: f.width, height: f.height, pageScale: f.pageScale, conversion: f.conversion });
1771
+
1772
+ function regionJson(r: RefinementRegion): Json {
1773
+ return {
1774
+ name: r.name,
1775
+ polygon: r.polygon.map(pair),
1776
+ maxEdgeLength: r.maxEdgeLength,
1777
+ transition: r.transition,
1778
+ grade: r.grade,
1779
+ approximation: r.approximation === null ? null : { from: r.approximation.from, policy: r.approximation.policy, maxError: r.approximation.maxError },
1780
+ };
1781
+ }
1782
+
1783
+ function targetsJson(t: ReductionTargets | MeasureTargets | null): Json {
1784
+ if (t === null) return null;
1785
+ const out: { [key: string]: Json } = { artFit: fitJson(t.artFit), maxBoundaryDeviation: t.maxBoundaryDeviation };
1786
+ if (t.minAngle !== undefined) out.minAngle = t.minAngle;
1787
+ out.regions = t.regions.map(regionJson);
1788
+ return out;
1789
+ }
1790
+
1791
+ function protectJson(p: ProtectedFeatures | null): Json {
1792
+ if (p === null) return null;
1793
+ return {
1794
+ hull: p.hull,
1795
+ vertices: [...p.vertices],
1796
+ edges: p.edges.map(pair),
1797
+ regionBoundaries: [...p.regionBoundaries],
1798
+ weightJump: p.weightJump,
1799
+ influences: [...p.influences],
1800
+ };
1801
+ }
1802
+
1803
+ function motionBoundsJson(b: MotionBounds | null): Json {
1804
+ if (b === null) return null;
1805
+ const out: { [key: string]: Json } = { maxLocalDeformation: b.maxLocalDeformation };
1806
+ if (b.maxStretch !== undefined) out.maxStretch = b.maxStretch;
1807
+ if (b.minStretch !== undefined) out.minStretch = b.minStretch;
1808
+ return out;
1809
+ }
1810
+
1811
+ function scheduleJson(s: MotionSchedule | null): Json {
1812
+ if (s === null) return null;
1813
+ return {
1814
+ frames: s.frames.map((f): Json => (f === 'setup' ? 'setup' : 'times' in f ? { animation: f.animation, times: [...f.times] } : { animation: f.animation, fps: f.fps })),
1815
+ phases: [...s.phases],
1816
+ physics: s.physics.mode === 'none' ? { mode: 'none' } : { mode: 'step', dt: s.physics.dt, warmupSteps: s.physics.warmupSteps },
1817
+ selection: [...s.selection],
1818
+ };
1819
+ }
1820
+
1821
+ function effectiveJson(e: EffectiveSettings): Json {
1822
+ return {
1823
+ preset: e.preset === null ? null : { name: e.preset.name, version: e.preset.version },
1824
+ attachments: e.attachments.map((a) => ({
1825
+ attachment: attachmentJson(a.attachment),
1826
+ threshold: a.threshold,
1827
+ finalThreshold: a.finalThreshold,
1828
+ frame: frameJson(a.frame),
1829
+ maskSize: pair(a.maskSize),
1830
+ minArtSamples: a.minArtSamples,
1831
+ regions: a.regions.map((r): Json => (r.polygon === undefined ? { name: r.name, minArtSamples: r.minArtSamples } : { name: r.name, minArtSamples: r.minArtSamples, polygon: r.polygon.map(pair) })),
1832
+ })),
1833
+ sourceBounds: fitJson(e.sourceBounds),
1834
+ referenceArtFit: fitJson(e.referenceArtFit),
1835
+ candidateArtFit: fitJson(e.candidateArtFit),
1836
+ targets: targetsJson(e.targets),
1837
+ referenceHull: e.referenceHull === null ? null : e.referenceHull.map(pair),
1838
+ motionBounds: motionBoundsJson(e.motionBounds),
1839
+ protect: protectJson(e.protect),
1840
+ influences: e.influences === null ? null : { maxInfluences: e.influences.maxInfluences, minWeight: e.influences.minWeight },
1841
+ boneOrder: e.boneOrder === null ? null : [...e.boneOrder],
1842
+ schedule: scheduleJson(e.schedule),
1843
+ budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
1844
+ };
1845
+ }
1846
+
1847
+ function frameRefJson(f: FrameRef): Json {
1848
+ return { id: f.id, animation: f.animation, phase: f.phase, time: f.time, index: f.index, role: f.role };
1849
+ }
1850
+
1851
+ function worstJson(w: WorstSample | null): Json {
1852
+ if (w === null) return null;
1853
+ const at: { [key: string]: Json } = {};
1854
+ if (w.at.pixel !== undefined) at.pixel = pair(w.at.pixel);
1855
+ if (w.at.triangle !== undefined) at.triangle = w.at.triangle;
1856
+ if (w.at.edge !== undefined) at.edge = pair(w.at.edge);
1857
+ if (w.at.vertex !== undefined) at.vertex = w.at.vertex;
1858
+ if (w.at.uv !== undefined) at.uv = pair(w.at.uv);
1859
+ const out: { [key: string]: Json } = { at };
1860
+ if (w.frame !== undefined) out.frame = frameRefJson(w.frame);
1861
+ return out;
1862
+ }
1863
+
1864
+ function rowJson(r: MeasureRow): Json {
1865
+ const out: { [key: string]: Json } = {
1866
+ code: r.code,
1867
+ object: { attachment: attachmentJson(r.object.attachment), region: r.object.region },
1868
+ state: r.state,
1869
+ value: r.value,
1870
+ bound: r.bound === null ? null : { op: r.bound.op, value: r.bound.value },
1871
+ unit: r.unit,
1872
+ worst: worstJson(r.worst),
1873
+ reason: r.reason,
1874
+ };
1875
+ if (r.art !== undefined) out.art = { threshold: r.art.threshold, connectivity: r.art.connectivity, samples: r.art.samples };
1876
+ if (r.raster !== undefined) {
1877
+ const g = r.raster.grid;
1878
+ out.raster = {
1879
+ grid: { width: g.width, height: g.height, pageScale: g.pageScale },
1880
+ spatialQuantum: r.raster.spatialQuantum,
1881
+ valueIncrement: r.raster.valueIncrement,
1882
+ nearBound: r.raster.nearBound,
1883
+ };
1884
+ }
1885
+ if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
1886
+ if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
1887
+ return out;
1888
+ }
1889
+
1890
+ function sectionJson(s: EvidenceSection): { [key: string]: Json } {
1891
+ const m = s.summary;
1892
+ return {
1893
+ rows: s.rows.map(rowJson),
1894
+ summary: { pass: m.pass, fail: m.fail, undeclared: m.undeclared, refused: m.refused, notMeasurable: m.notMeasurable, measured: m.measured },
1895
+ verdict: s.verdict,
1896
+ };
1897
+ }
1898
+
1899
+ const countsJson = (c: MeshCounts | null): Json =>
1900
+ c === null ? null : { boundaryVertices: c.boundaryVertices, interiorVertices: c.interiorVertices, triangles: c.triangles, bindings: c.bindings, maxInfluences: c.maxInfluences };
1901
+
1902
+ function candidateJson(c: CandidateReport): Json {
1903
+ const out: { [key: string]: Json } = {
1904
+ id: c.id,
1905
+ counts: countsJson(c.counts),
1906
+ geometry: c.geometry === null ? null : sectionJson(c.geometry),
1907
+ motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleUsedJson(c.motion.schedule) },
1908
+ accepted: c.accepted,
1909
+ };
1910
+ if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
1911
+ if (c.changes !== undefined) {
1912
+ const k = c.changes;
1913
+ out.changes = {
1914
+ removedVertices: k.removedVertices,
1915
+ insertedVertices: k.insertedVertices,
1916
+ sharesDroppedOnGrid: k.sharesDroppedOnGrid,
1917
+ sharesPruned: k.sharesPruned,
1918
+ deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
1919
+ deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
1920
+ linkedMeshes: k.linkedMeshes.map(attachmentJson),
1921
+ };
1922
+ }
1923
+ return out;
1924
+ }
1925
+
1926
+ function terminationJson(t: Termination | null): Json {
1927
+ if (t === null) return null;
1928
+ switch (t.reason) {
1929
+ case 'no-further-valid-reduction':
1930
+ return { reason: t.reason, candidatesTried: t.candidatesTried, blockingConstraint: t.blockingConstraint };
1931
+ case 'budget-exhausted':
1932
+ return { reason: t.reason, candidatesTried: t.candidatesTried, budget: t.budget, result: t.result };
1933
+ case 'invalid-input':
1934
+ case 'unsupported-topology':
1935
+ return { reason: t.reason, code: t.code, detail: t.detail };
1936
+ }
1937
+ }
1938
+
1939
+ /**
1940
+ * The document's text: two-space JSON and a final newline, every key in the
1941
+ * order its type states it, so two reports of one input are byte-identical —
1942
+ * nothing in it is a time, a path or a machine's.
1943
+ */
1944
+ export function writeMeshQualityReport(report: MeshQualityReport): string {
1945
+ const doc: Json = {
1946
+ spec: report.spec,
1947
+ operation: report.operation,
1948
+ effective: effectiveJson(report.effective),
1949
+ poser: report.poser === null ? null : { kind: report.poser.kind, rigcVersion: report.poser.rigcVersion },
1950
+ motionRequired: report.motionRequired,
1951
+ sourceCounts: countsJson(report.sourceCounts),
1952
+ reference: report.reference === null ? null : candidateJson(report.reference),
1953
+ candidates: report.candidates.map(candidateJson),
1954
+ termination: terminationJson(report.termination),
1955
+ };
1956
+ return `${JSON.stringify(doc, null, 2)}\n`;
1957
+ }
1958
+
1959
+ // ---------------------------------------------------------------------------
1960
+ // stage C — the motion section's own fields (issue #1230, `src/meshcompare.ts`)
1961
+ // ---------------------------------------------------------------------------
1962
+ //
1963
+ // Additive: nothing above reads these, a `measure` and a `reduce` never write
1964
+ // them, and every one is written only when present.
1965
+
1966
+ /** One group of frames a motion row was also taken over — a phase, or a role (P11). */
1967
+ export interface MotionReading {
1968
+ /** The group's worst value; null when no frame of the group drew the attachment. */
1969
+ value: number | null;
1970
+ /** The value against the row's bound, by the row's own rule; `undeclared` with no bound, `not-measurable` with no value. */
1971
+ state: MeasureState;
1972
+ /** The frame the group's worst value was taken at, by `FrameRef.id`. */
1973
+ frame: string | null;
1974
+ }
1975
+
1976
+ /**
1977
+ * How a motion row's value was taken over the schedule (stage C). The row's own
1978
+ * `value` is the worst over every frame measured; these keep apart what the
1979
+ * contract says must not be folded together.
1980
+ */
1981
+ export interface MotionRowDetail {
1982
+ /** Frames at which the attachment was drawn and measured, and the ids of those at which its slot drew something else or nothing. */
1983
+ frames: { measured: number; notDrawn: string[] };
1984
+ /**
1985
+ * §4 *Transition in time*: the row taken per sample phase — `grid` and `irr`
1986
+ * for the frames a rate generated, `null` for the setup frame and for frames
1987
+ * the caller gave as explicit times (no phase applies to those).
1988
+ */
1989
+ byPhase: Array<{ phase: 'grid' | 'irr' | null } & MotionReading>;
1990
+ /** When one phase passes and another fails: the worst frame of each, by id, and a sentence naming both. Null otherwise. */
1991
+ phasesDisagree: { pass: string; fail: string; sentence: string } | null;
1992
+ /** P11: the row per role. `heldOut` is null when no held-out frame was measured — no held-out claim is made. */
1993
+ byRole: { baseline: MotionReading | null; selection: MotionReading | null; heldOut: MotionReading | null };
1994
+ /**
1995
+ * Correction 4, world rows: the declared setup map of the attachment's slot
1996
+ * bone — its world 2×2 at the setup pose, `[a, b, c, d]` as the core poses it
1997
+ * from the bones' declared fields — and its two singular scales. Never a
1998
+ * single world-per-pixel ratio, and never fitted to output vertices.
1999
+ */
2000
+ setupMap?: { bone: string; linear: [number, number, number, number]; singularScales: [number, number] };
2001
+ /** §3: samples no non-degenerate UV triangle carries, listed by UV — in the reference's mesh and in this candidate's. */
2002
+ uncarried?: { reference: Array<[number, number]>; candidate: Array<[number, number]> };
2003
+ /** §3, `MQ_INVERSION` on a slot `invariants.deformMayFold` names: every reversed triangle with its frame — listed, never zeroed. */
2004
+ folds?: Array<{ triangle: number; frame: string }>;
2005
+ /** Triangles that have no area at the setup pose (the A39 band), so no sign and no stretch is read off them. */
2006
+ degenerateAtSetup?: number;
2007
+ }
2008
+
2009
+ /** What a motion section walked from the schedule it was given (`ScheduleUsed`). */
2010
+ export interface ScheduleWalked {
2011
+ /** Every frame the schedule names, in walk order, each with its role (P11). */
2012
+ walked: FrameRef[];
2013
+ /** The roles among those frames — what the section has evidence for. */
2014
+ roles: Array<FrameRef['role']>;
2015
+ /** P11: true only when at least one held-out frame, disjoint from `selection`, was walked. */
2016
+ heldOutClaim: boolean;
2017
+ /** P10: every walk resets physics at time 0 and steps from there, the same steps for the reference and every candidate. */
2018
+ reset: 'physics reset at time 0';
2019
+ /** Each walk: the animation, the phase (null for explicit times), and how many steps reached its last frame. */
2020
+ walks: Array<{ animation: string; phase: 'grid' | 'irr' | null; steps: number }>;
2021
+ }
2022
+
2023
+ function readingJson(r: MotionReading | null): Json {
2024
+ return r === null ? null : { value: r.value, state: r.state, frame: r.frame };
2025
+ }
2026
+
2027
+ function motionDetailJson(m: MotionRowDetail): Json {
2028
+ const out: { [key: string]: Json } = {
2029
+ frames: { measured: m.frames.measured, notDrawn: [...m.frames.notDrawn] },
2030
+ byPhase: m.byPhase.map((p) => ({ phase: p.phase, value: p.value, state: p.state, frame: p.frame })),
2031
+ phasesDisagree: m.phasesDisagree === null ? null : { pass: m.phasesDisagree.pass, fail: m.phasesDisagree.fail, sentence: m.phasesDisagree.sentence },
2032
+ byRole: { baseline: readingJson(m.byRole.baseline), selection: readingJson(m.byRole.selection), heldOut: readingJson(m.byRole.heldOut) },
2033
+ };
2034
+ if (m.setupMap !== undefined) out.setupMap = { bone: m.setupMap.bone, linear: [...m.setupMap.linear], singularScales: pair(m.setupMap.singularScales) };
2035
+ if (m.uncarried !== undefined) out.uncarried = { reference: m.uncarried.reference.map(pair), candidate: m.uncarried.candidate.map(pair) };
2036
+ if (m.folds !== undefined) out.folds = m.folds.map((f) => ({ triangle: f.triangle, frame: f.frame }));
2037
+ if (m.degenerateAtSetup !== undefined) out.degenerateAtSetup = m.degenerateAtSetup;
2038
+ return out;
2039
+ }
2040
+
2041
+ function scheduleUsedJson(s: ScheduleUsed): Json {
2042
+ const given = scheduleJson(s) as { [key: string]: Json };
2043
+ return {
2044
+ ...given,
2045
+ walked: s.walked.map(frameRefJson),
2046
+ roles: [...s.roles],
2047
+ heldOutClaim: s.heldOutClaim,
2048
+ reset: s.reset,
2049
+ walks: s.walks.map((w) => ({ animation: w.animation, phase: w.phase, steps: w.steps })),
2050
+ };
2051
+ }