@rig-c/parts 0.0.0-stage → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,585 @@
1
+ /**
2
+ * The automatic mesh mode (issue #126, item 2): geometry only. A mesh entry
3
+ * that says `auto` is built in three steps, and every number each step reads
4
+ * is one the author wrote in `meshes.<part>.auto` (`AutoSpec`, `src/config.ts`):
5
+ *
6
+ * 1. **The source** ({@link autoSource}) — P8's unreduced, independently gated
7
+ * reference: this package's own contour mesh (`src/contour.ts`) over the
8
+ * padded part image at **alpha 1 and above** (`contourMesh(…, { threshold:
9
+ * 0 })` reads `alpha > 0`, P4's final threshold), at the author's
10
+ * `source.tolerance`, `source.margin` and background `source.spacing`, with
11
+ * no region (the density a region asks for is rig-c's refinement, step
12
+ * 2). `contourMesh` gates it with this package's own checks before it is
13
+ * returned — `contourTopologyProblems` and `contourFit` at that threshold,
14
+ * its overshoot read with the background flooded 8-connected
15
+ * ({@link AUTO_SOURCE_FIT_CONNECTIVITY}), the fill rig-c's admission reads
16
+ * — and a refusal there is the part's refusal, in the contour mode's codes.
17
+ * Its weights are `localInfluences` (`src/localweights.ts`) under the
18
+ * author's `influences` — no 0.03 floor unless the author wrote it (P19) —
19
+ * unrounded, so a vertex that survives can be compared bit for bit.
20
+ * 2. **The call** ({@link autoReductionInput}) — rig-c's `reduceMesh`
21
+ * (`rig-c/mesh`, 2.28.x), refinement inside the declared regions then
22
+ * reduction, every input from the config or from the source, as the
23
+ * contract types it (docs/MESH_REDUCTION.md §1): `art` the padded image's
24
+ * alpha at threshold 1 in the part-local frame with `pageScale` 1 (the
25
+ * rig's images are the drawing) — under a declared `source.stray`, the
26
+ * mask the source was traced from (`ContourMesh.mask`, the islands it left
27
+ * out at alpha 0; issue #172), which the rig stage hands every reader; `protect.hull` false unless the author
28
+ * wrote true (P20); the region bones added to `protect.influences` (a
29
+ * density-only region names none and adds none, issue #155), so an
30
+ * inserted vertex never loses its region's share silently; `boneOrder` the
31
+ * rig's bone order; `preset` null (no preset exists in this package: a
32
+ * preset is a later, versioned thing, P5); `deform` and `linkedMeshes`
33
+ * empty — this package writes no deform key and no linked mesh.
34
+ * 3. **Acceptance** ({@link autoVerdict}) — the result is used only when the
35
+ * report's termination is `no-further-valid-reduction`, or
36
+ * `budget-exhausted` with `best-meeting-every-bound`, AND the candidate is
37
+ * `accepted` AND every row with a declared bound is `pass`. Anything else
38
+ * refuses the part by name with the report's own reason, code and detail.
39
+ * There is no fallback: a refused part is not quietly built as a lattice or
40
+ * as its contour source.
41
+ *
42
+ * ## What is measured, and what is not
43
+ *
44
+ * Geometry only: the report's rows are rig-c's `measureMeshQuality`
45
+ * rows (coverage, overshoot, undercut, boundary deviation from the source
46
+ * hull, orientation, degeneracy, the smallest angle, each region's maximum
47
+ * edge and transition). No pose is taken here, and `buildRig`'s row says so
48
+ * in those words ({@link DEFORMATION_UNMEASURED}); the rig stage then measures
49
+ * the result's motion against this source on the idle (issue #126 item 3,
50
+ * `src/automotion.ts`) and writes the part only when it passes, so a written
51
+ * row never says unmeasured.
52
+ *
53
+ * ## The winding the call is handed
54
+ *
55
+ * rig-c's `SourceMesh.triangles` are counter-clockwise in Spine world,
56
+ * which its `MQ_ORIENTATION` reads through `cropToSpineY`. The contour mesh
57
+ * writes its triangles that way (`src/contour.ts`, issue #126), so the source
58
+ * is handed over exactly as `contourMesh` returns it — no turn here, so none
59
+ * twice (before issue #126 the contour mesh was clockwise in Spine world and
60
+ * this module swapped each triple; the bytes handed to rig-c are the same
61
+ * either way). The result is written as rig-c returns it.
62
+ *
63
+ * ## A circle region (P17)
64
+ *
65
+ * rig-c holds density on a polygon. A circle region is handed over as
66
+ * the regular polygon {@link circlePolygon} names, whose rule and error are
67
+ * echoed in the region's `approximation`. The weights still read the circle.
68
+ *
69
+ * Pure: no clock, no randomness, nothing read or written.
70
+ */
71
+ import {
72
+ measureAuthoredMeshFit,
73
+ MeshReductionError,
74
+ reduceMesh,
75
+ type AlphaMask,
76
+ type MeasureRow,
77
+ type MeshCounts,
78
+ type MeshQualityReport,
79
+ type MeshReductionInput,
80
+ type ReducedMesh,
81
+ type RefinementRegion,
82
+ type SourceMesh,
83
+ type Termination,
84
+ writeMeshQualityReport,
85
+ } from 'rig-c/mesh';
86
+ import { type AutoRegionSpec, type AutoSpec, type AutoWeightRegionSpec, type Point, weightsABone } from './config.ts';
87
+ import { type ContourChainRibs, type ContourMesh, contourMesh, type ContourReport, GRID } from './contour.ts';
88
+ import { type HeatField, heatInfluences } from './heat.ts';
89
+ import type { Problem } from './errors.ts';
90
+ import { type LocalInfluence, localInfluences, type RegionOverlap } from './localweights.ts';
91
+ import { ART_ALPHA } from './mesh.ts';
92
+ import { pyRound } from './round.ts';
93
+ import { DISTANCE_EXPONENT, type Influence, type Segment } from './weights.ts';
94
+
95
+ /** The final threshold of the automatic mode: art is alpha 1 and above (P4). `contourMesh` reads "above", so it is handed one less. */
96
+ export const AUTO_THRESHOLD = 1;
97
+
98
+ /** What the report says about deformation, in so many words. */
99
+ export const DEFORMATION_UNMEASURED = 'unmeasured: geometry only — no pose was taken and no motion compared (issue #126 item 3, rigc#1221 stage C)';
100
+
101
+ /**
102
+ * How far inside the circle no vertex of its polygon may round: the polygon's
103
+ * inscribed radius is `r + CIRCLE_CLEARANCE` before each vertex is put on
104
+ * rig-c's 6-decimal grid, which moves a vertex by at most √2·5e-7 px, so
105
+ * every edge stays outside the circle. Derived from that grid, not chosen.
106
+ */
107
+ export const CIRCLE_CLEARANCE = 1e-6;
108
+
109
+ /** The largest distance √2·5e-7 px that the 6-decimal rounding can move a vertex, rounded up. */
110
+ const R6_MOVE = 7.1e-7;
111
+
112
+ /**
113
+ * A circle region as the polygon rig-c holds density on (P17). **Rule:**
114
+ * the regular polygon with `n` sides circumscribed about the circle of radius
115
+ * `r + CIRCLE_CLEARANCE`, its first vertex at angle 0 (+x) and the rest
116
+ * increasing (clockwise on screen), each on the 6-decimal grid, with `n` the
117
+ * smallest whole number ≥ 3 for which the polygon's boundary lies within one
118
+ * grid unit of the region's numbers (1/{@link GRID} px) of the circle:
119
+ * `(r + CIRCLE_CLEARANCE) / cos(π/n) − r + 7.1e-7 ≤ 1/256`. So the polygon
120
+ * holds the whole circle — every edge that meets the circle meets the polygon
121
+ * and is held to its bound — and asks for density at most 1/256 px further
122
+ * out than the circle does. `maxError` is that left-hand side. What is not
123
+ * exact: `Math.cos`/`Math.sin`, which an engine need not round correctly, can
124
+ * move a vertex by an ulp before the 6-decimal rounding.
125
+ */
126
+ export function circlePolygon(cx: number, cy: number, r: number): { polygon: Array<[number, number]>; sides: number; maxError: number } {
127
+ const unit = 1 / GRID;
128
+ let n = 3;
129
+ const errorOf = (k: number): number => (r + CIRCLE_CLEARANCE) / Math.cos(Math.PI / k) - r + R6_MOVE;
130
+ while (errorOf(n) > unit) n++;
131
+ const R = (r + CIRCLE_CLEARANCE) / Math.cos(Math.PI / n);
132
+ const polygon: Array<[number, number]> = [];
133
+ for (let k = 0; k < n; k++) {
134
+ const t = (2 * Math.PI * k) / n;
135
+ polygon.push([pyRound(cx + R * Math.cos(t), 6), pyRound(cy + R * Math.sin(t), 6)]);
136
+ }
137
+ return { polygon, sides: n, maxError: pyRound(errorOf(n), 9) };
138
+ }
139
+
140
+ /** A region as rig-c is handed it, in part-local px: a polygon, its density, and how a circle was approximated. */
141
+ export function refinementRegion(rg: AutoRegionSpec, ox: number, oy: number): RefinementRegion {
142
+ const density = { maxEdgeLength: rg.maxEdgeLength, transition: rg.transition, grade: rg.grade };
143
+ if (rg.shape === 'polygon') {
144
+ return { name: rg.name, polygon: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), ...density, approximation: null };
145
+ }
146
+ const c = circlePolygon(rg.cx - ox, rg.cy - oy, rg.r);
147
+ return {
148
+ name: rg.name,
149
+ polygon: c.polygon,
150
+ ...density,
151
+ approximation: {
152
+ from: `circle (${rg.cx - ox}, ${rg.cy - oy}) r ${rg.r}`,
153
+ policy: `regular ${c.sides}-gon circumscribed about radius r + ${CIRCLE_CLEARANCE}, first vertex at +x, vertices on the 6-decimal grid; sides the fewest >= 3 whose boundary lies within 1/${GRID} px of the circle`,
154
+ maxError: c.maxError,
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * How the source's own fit gate floods the background (rig-c 2.23.0's
161
+ * `measureAuthoredMeshFit` `connectivity`, rigc#1262): 8, the fill rig-c's
162
+ * `MQ_OVERSHOOT` reads and `reduceMesh` admits a source by
163
+ * (`REDUCE_SOURCE_FAILS_ITS_ART_BOUNDS`), so a source this package passes is
164
+ * read with the same number rig-c reads it with, and a source that overshoots
165
+ * through a pocket joined to the outside only at a corner is refused here, by
166
+ * this package's code, before the call. The contour mode keeps 4 (its
167
+ * report carries no `fitConnectivity`, its bytes are unchanged).
168
+ */
169
+ export const AUTO_SOURCE_FIT_CONNECTIVITY = 8;
170
+
171
+ /**
172
+ * The source of one automatic mesh: the contour mesh at alpha 1 and above, gated by `contourMesh` with its overshoot
173
+ * read at {@link AUTO_SOURCE_FIT_CONNECTIVITY}, or every problem that refuses it. `ribs` (issue #188) are placed on
174
+ * it as the contour mode places them, at `source.spacing`; none, and the call is the one it always was.
175
+ */
176
+ export function autoSource(part: string, mask: AlphaMask, spec: AutoSpec, ribs: readonly ContourChainRibs[] = []): ContourMesh | Problem[] {
177
+ return contourMesh(part, mask, {
178
+ threshold: AUTO_THRESHOLD - 1,
179
+ tolerance: spec.source.tolerance,
180
+ margin: spec.source.margin,
181
+ spacing: spec.source.spacing,
182
+ ...(spec.source.stray === undefined ? {} : { stray: spec.source.stray }),
183
+ regions: [],
184
+ fitConnectivity: AUTO_SOURCE_FIT_CONNECTIVITY,
185
+ ...(ribs.length === 0 ? {} : { ribs }),
186
+ });
187
+ }
188
+
189
+ /**
190
+ * The bound every rib's line is sent with (issue #188), drawing px: 0. A rib's vertices are all in `protect.vertices`,
191
+ * so none is removed and the bound is never what keeps one; the line is sent for what rig-c's `lines` add besides
192
+ * (rig-c 2.33.0, docs/MESH_REDUCTION.md §11): the triangulation post-pass flips no line edge, so the row stays a row of
193
+ * edges in the result, and the result's measurement carries one `MQ_LINE_DEVIATION` row per rib, by its name.
194
+ */
195
+ export const RIB_LINE_DEVIATION = 0;
196
+
197
+ /**
198
+ * The source's weights by bone name, unrounded, or the first vertex two regions both reach. Only the regions
199
+ * that weight a bone are read (issue #155): a density-only region gives no vertex a `g`, so a source vertex
200
+ * inside it is weighted as one no region reaches, it can never be one of an overlap's two, and the weights are
201
+ * the same numbers the part has with the region left out. Region indices in the result (`local[].region`, the
202
+ * overlap's `first` and `second`) are indices into `spec.regions`, every form counted. `exponent` is the mesh's
203
+ * distance-rule exponent (issue #161), which the rig stage always passes. With `heat` (issue #161,
204
+ * `rule: "heat"`), each vertex's segment shares are bone heat's at its pixel, under the author's influence limits.
205
+ */
206
+ export function sourceWeights(
207
+ vertices: ReadonlyArray<readonly [number, number]>,
208
+ ox: number,
209
+ oy: number,
210
+ segs: readonly Segment[],
211
+ r: number,
212
+ spec: AutoSpec,
213
+ exponent: number = DISTANCE_EXPONENT,
214
+ heat: HeatField | null = null,
215
+ ): { weights: Influence[][]; local: LocalInfluence[] } | { overlap: RegionOverlap; vertex: number; at: Point } {
216
+ const all = spec.regions ?? [];
217
+ const index: number[] = [];
218
+ const regions: AutoWeightRegionSpec[] = [];
219
+ all.forEach((rg, k) => {
220
+ if (!weightsABone(rg)) return;
221
+ index.push(k);
222
+ regions.push(rg);
223
+ });
224
+ const weights: Influence[][] = [];
225
+ const local: LocalInfluence[] = [];
226
+ for (let v = 0; v < vertices.length; v++) {
227
+ const at: Point = [vertices[v][0] + ox, vertices[v][1] + oy];
228
+ const li = localInfluences(at, segs, r, regions, spec.influences, exponent, heat === null ? undefined : heatInfluences(heat, [vertices[v][0], vertices[v][1]], spec.influences));
229
+ if ('first' in li) return { overlap: { ...li, first: index[li.first], second: index[li.second] }, vertex: v, at };
230
+ weights.push(li.influences.map((e) => ({ bone: e.bone, weight: e.weight })));
231
+ local.push(li.region < 0 ? li : { ...li, region: index[li.region] });
232
+ }
233
+ return { weights, local };
234
+ }
235
+
236
+ /** Everything `reduceMesh` is handed, each field from the config, the source or the rig — none from this package. */
237
+ export function autoReductionInput(args: {
238
+ part: string;
239
+ mask: AlphaMask;
240
+ ox: number;
241
+ oy: number;
242
+ spec: AutoSpec;
243
+ source: ContourMesh;
244
+ weights: Influence[][] | null;
245
+ boneOrder: string[];
246
+ }): MeshReductionInput {
247
+ const { part, mask, ox, oy, spec, source } = args;
248
+ const w = mask.width;
249
+ const h = mask.height;
250
+ const regions = spec.regions ?? [];
251
+ const protect = spec.protect ?? {};
252
+ // issue #188: every rib vertex is kept — the author's protected vertices first, as written, then each rib vertex not
253
+ // among them, ascending. No rib, and the list is the author's.
254
+ const ribs = source.ribs ?? [];
255
+ const kept: number[] = [...(protect.vertices ?? [])];
256
+ for (const v of [...new Set(ribs.flatMap((r) => r.vertices))].sort((a, b) => a - b)) if (!kept.includes(v)) kept.push(v);
257
+ const guarded: string[] = [...(protect.influences ?? [])];
258
+ // A density-only region names no bone and guards none (issue #155).
259
+ for (const rg of regions) if (weightsABone(rg) && !guarded.includes(rg.bone)) guarded.push(rg.bone);
260
+ const fit = (f: AutoSpec['sourceBounds']): MeshReductionInput['sourceBounds'] => ({ minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
261
+ const src: SourceMesh = {
262
+ points: source.vertices.map(([x, y]) => [x, y] as [number, number]),
263
+ uvs: source.vertices.flatMap(([x, y]) => [pyRound(x / w, 6), pyRound(y / h, 6)]),
264
+ triangles: [...source.triangles],
265
+ hull: source.hull,
266
+ weights: args.weights === null ? null : args.weights.map((list) => list.map((e) => ({ bone: e.bone, weight: e.weight }))),
267
+ };
268
+ return {
269
+ attachment: { skin: null, slot: part, attachment: part },
270
+ art: { mask, threshold: AUTO_THRESHOLD, frame: { space: 'part-local-drawing-px-y-down', width: w, height: h, pageScale: 1, conversion: 'texels = px * pageScale' } },
271
+ source: src,
272
+ sourceBounds: fit(spec.sourceBounds),
273
+ targets: {
274
+ artFit: fit(spec.targets.artFit),
275
+ maxBoundaryDeviation: spec.targets.maxBoundaryDeviation,
276
+ ...(spec.targets.minAngle === undefined ? {} : { minAngle: spec.targets.minAngle }),
277
+ regions: regions.map((rg) => refinementRegion(rg, ox, oy)),
278
+ },
279
+ protect: {
280
+ hull: protect.hull ?? false,
281
+ vertices: kept,
282
+ edges: (protect.edges ?? []).map(([a, b]) => [a, b] as [number, number]),
283
+ regionBoundaries: [...(protect.regionBoundaries ?? [])],
284
+ weightJump: protect.weightJump ?? null,
285
+ influences: guarded,
286
+ },
287
+ influences: { maxInfluences: spec.influences.maxInfluences, minWeight: spec.influences.minWeight },
288
+ boneOrder: [...args.boneOrder],
289
+ preset: null,
290
+ budget: { maxCandidates: spec.budget.maxCandidates },
291
+ minArtSamples: spec.minArtSamples,
292
+ regionArtSamples: regions.map((rg) => ({ region: rg.name, minArtSamples: rg.minArtSamples })),
293
+ deform: [],
294
+ linkedMeshes: [],
295
+ // Stage B (rigc#1271), each sent only when the author wrote it: absent, the input is the one it was before rig-c 2.25.0.
296
+ ...(spec.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: spec.boundaryRuns.maxVertices } }),
297
+ ...(spec.retriangulate === undefined ? {} : { retriangulate: spec.retriangulate }),
298
+ ...(spec.removalOrder === undefined ? {} : { removalOrder: spec.removalOrder }),
299
+ ...(ribs.length === 0 ? {} : { lines: ribs.map((r) => ({ name: r.name, vertices: [...r.vertices], closed: false, maxDeviation: RIB_LINE_DEVIATION })) }),
300
+ };
301
+ }
302
+
303
+ /** The Stage B opt-ins a part set (rigc#1271), as `AutoSpec` and the row's `settings` both carry them: each absent when not set. */
304
+ export interface StageBOptIns {
305
+ boundaryRuns?: { maxVertices: number };
306
+ retriangulate?: 'delaunay';
307
+ removalOrder?: 'deformation-load';
308
+ }
309
+
310
+ /**
311
+ * What the row's `result` carries about the Stage B opt-ins, each key only
312
+ * when its opt-in is set, so a part that sets none writes the `result` it
313
+ * always wrote: `boundary_runs` — the accepted operations of kind
314
+ * `boundary-run` and the vertices they removed, read off `acceptedAt` —
315
+ * and `retriangulation` — rig-c's `changes.retriangulation` without its fixed
316
+ * sentence (which the `quality_report` keeps): whether the pass was taken, its
317
+ * flips and sweeps, and the row that refused it; null when rig-c returned none.
318
+ */
319
+ export interface StageBResult {
320
+ boundary_runs?: { runs: number; vertices: number };
321
+ retriangulation?: { method: 'delaunay'; taken: boolean; flips: number; sweeps: number; refusedBy: string | null } | null;
322
+ }
323
+
324
+ export function stageBResult(report: MeshQualityReport, set: StageBOptIns): StageBResult {
325
+ const ch = report.candidates[0]?.changes;
326
+ const out: StageBResult = {};
327
+ if (set.boundaryRuns !== undefined) {
328
+ const runs = (ch?.acceptedAt ?? []).filter((o) => o.kind === 'boundary-run');
329
+ out.boundary_runs = { runs: runs.length, vertices: runs.reduce((n, o) => n + o.count, 0) };
330
+ }
331
+ if (set.retriangulate !== undefined) {
332
+ const rt = ch?.retriangulation;
333
+ out.retriangulation = rt === undefined ? null : { method: rt.method, taken: rt.taken, flips: rt.flips, sweeps: rt.sweeps, refusedBy: rt.refusedBy };
334
+ }
335
+ return out;
336
+ }
337
+
338
+ /**
339
+ * The build line's clause for the Stage B opt-ins (rigc#1271): empty when the
340
+ * part sets none, so a config that names none prints the line it always
341
+ * printed. Otherwise `; boundary runs <= k: r run(s), v vertex(es)`,
342
+ * `; retriangulate delaunay taken, n flip(s)` or `… refused by <row>`, and
343
+ * `; removal order deformation-load`.
344
+ */
345
+ export function stageBClause(set: StageBOptIns, result: StageBResult): string {
346
+ const out: string[] = [];
347
+ if (set.boundaryRuns !== undefined) {
348
+ const b = result.boundary_runs;
349
+ out.push(`boundary runs <= ${set.boundaryRuns.maxVertices}: ${b === undefined ? 'unread' : `${b.runs} run(s), ${b.vertices} vertex(es)`}`);
350
+ }
351
+ if (set.retriangulate !== undefined) {
352
+ const rt = result.retriangulation;
353
+ out.push(rt === undefined || rt === null ? `retriangulate ${set.retriangulate}: not reported` : rt.taken ? `retriangulate ${rt.method} taken, ${rt.flips} flip(s)` : `retriangulate ${rt.method} refused by ${rt.refusedBy ?? 'an unnamed row'}`);
354
+ }
355
+ if (set.removalOrder !== undefined) out.push(`removal order ${set.removalOrder}`);
356
+ return out.map((x) => `; ${x}`).join('');
357
+ }
358
+
359
+ /** The verdict on one call: the mesh to write, or the problem that refuses the part. */
360
+ export type AutoVerdict = { accepted: true; mesh: ReducedMesh } | { accepted: false; problem: Problem };
361
+
362
+ /** The rows with a declared bound that are not `pass` — none on an accepted result. */
363
+ export function gatedFailures(report: MeshQualityReport): MeasureRow[] {
364
+ return (report.candidates[0]?.geometry?.rows ?? []).filter((r) => r.bound !== null && r.state !== 'pass');
365
+ }
366
+
367
+ function rowText(r: MeasureRow): string {
368
+ const region = r.object.region === null ? '' : ` [region "${r.object.region}"]`;
369
+ return r.state === 'pass' || r.state === 'fail' ? `${r.code}${region} ${r.state} ${r.value} against ${r.bound?.op} ${r.bound?.value}` : `${r.code}${region} ${r.state}: ${r.reason ?? ''}`;
370
+ }
371
+
372
+ /** The termination in one line, as the report states it. */
373
+ export function terminationText(t: Termination | null): string {
374
+ if (t === null) return 'no termination';
375
+ if (t.reason === 'no-further-valid-reduction') return `no-further-valid-reduction after ${t.candidatesTried} candidate(s), blocked by ${t.blockingConstraint}`;
376
+ if (t.reason === 'budget-exhausted') return `budget-exhausted after ${t.candidatesTried} of ${t.budget} candidate(s), ${t.result}`;
377
+ if (t.reason === 'replayed-to-accepted-step') return `replayed-to-accepted-step ${t.acceptedSteps} after ${t.candidatesTried} candidate(s)`;
378
+ return `${t.reason} ${t.code}: ${t.detail}`;
379
+ }
380
+
381
+ /**
382
+ * Acceptance (module header, step 3). `object` names the config entry. A mesh
383
+ * returned with a gated row that does not pass — rig-c returns one, not
384
+ * accepted, when the refined source cannot meet its targets — is refused here
385
+ * as any other result that is not accepted.
386
+ */
387
+ export function autoVerdict(object: string, result: { mesh: ReducedMesh | null; report: MeshQualityReport }): AutoVerdict {
388
+ const { mesh, report } = result;
389
+ const t = report.termination;
390
+ const candidate = report.candidates[0];
391
+ if (t === null || t.reason === 'invalid-input' || t.reason === 'unsupported-topology') {
392
+ return { accepted: false, problem: { code: 'AUTO_MESH_TERMINATION', object, detail: `rig-c's reduceMesh ended ${terminationText(t)}; no mesh was returned, and nothing is built in its place` } };
393
+ }
394
+ if (t.reason === 'budget-exhausted' && t.result === 'none-met-the-targets') {
395
+ return {
396
+ accepted: false,
397
+ problem: { code: 'AUTO_MESH_TERMINATION', object, detail: `rig-c's reduceMesh spent its budget of ${t.budget} candidate(s) before any met every target (none-met-the-targets); no mesh was returned — raise budget.maxCandidates or relax a declared bound` },
398
+ };
399
+ }
400
+ const failing = gatedFailures(report);
401
+ if (mesh === null || candidate === undefined || !candidate.accepted || failing.length > 0) {
402
+ return {
403
+ accepted: false,
404
+ problem: {
405
+ code: 'AUTO_MESH_ACCEPTED',
406
+ object,
407
+ detail: `rig-c's reduceMesh ended ${terminationText(t)} and its result is not accepted (${failing.length > 0 ? failing.map(rowText).join('; ') : 'a required measurement is unavailable'}); every declared bound passing is required, and nothing is built in its place`,
408
+ },
409
+ };
410
+ }
411
+ return { accepted: true, mesh };
412
+ }
413
+
414
+ /** What one reduction returns: rig-c's mesh and report, or its refusal of the input as the part's problem. */
415
+ export type ReductionResult = { mesh: ReducedMesh | null; report: MeshQualityReport } | Problem;
416
+
417
+ /**
418
+ * How `buildRig` runs a part's reduction: {@link runReduction} unless a caller
419
+ * hands it another (issue #135). The only other one this package makes is
420
+ * {@link reuseReductions}, which hands back a reduction already run on the
421
+ * very same input — `reduceMesh` is deterministic, so the same input is the
422
+ * same result — and runs the call itself on any other input.
423
+ */
424
+ export type Reducer = (object: string, input: MeshReductionInput) => ReductionResult;
425
+
426
+ /** One call: `reduceMesh`, with a thrown refusal read as the part's problem. */
427
+ export function runReduction(object: string, input: MeshReductionInput): ReductionResult {
428
+ try {
429
+ return reduceMesh(input);
430
+ } catch (err) {
431
+ if (!(err instanceof MeshReductionError)) throw err;
432
+ return { code: 'AUTO_MESH_INPUT', object, detail: `rig-c's reduceMesh refused its input: ${err.message}` };
433
+ }
434
+ }
435
+
436
+ /**
437
+ * A reduction input as one string: every field in the order it was built, a
438
+ * typed array (the art mask's alpha) as its plain list of numbers. Two inputs
439
+ * are the same input exactly when their keys are equal; a key order that
440
+ * differs reads as a different input, which costs a call and never a wrong
441
+ * result.
442
+ */
443
+ export function reductionKey(input: MeshReductionInput): string {
444
+ return JSON.stringify(input, (_k, v: unknown) => (v instanceof Uint8Array || v instanceof Uint8ClampedArray || v instanceof Float32Array || v instanceof Float64Array ? Array.from(v) : v));
445
+ }
446
+
447
+ /** A {@link Reducer} that reuses reductions already run, and counts what it reused and what it ran. */
448
+ export interface ReusingReducer {
449
+ reduce: Reducer;
450
+ /** Calls answered from a reduction already run on the same input. */
451
+ reused: () => number;
452
+ /** Calls whose input matched none, run through `run`. */
453
+ ran: () => number;
454
+ }
455
+
456
+ /**
457
+ * Hand back a reduction already run when the input is the same input — `done`
458
+ * holds each result under its input's {@link reductionKey} — and run `run` (by
459
+ * default {@link runReduction}) on any other. A reused refusal is
460
+ * re-addressed to the asking call's object, so a problem always names the
461
+ * config field that asked. Nothing is guessed: a miss is a call, never a near
462
+ * match.
463
+ */
464
+ export function reuseReductions(done: ReadonlyMap<string, ReductionResult>, run: Reducer = runReduction): ReusingReducer {
465
+ const byKey = done;
466
+ let reused = 0;
467
+ let ran = 0;
468
+ return {
469
+ reduce: (object, input) => {
470
+ const hit = byKey.get(reductionKey(input));
471
+ if (hit === undefined) {
472
+ ran++;
473
+ return run(object, input);
474
+ }
475
+ reused++;
476
+ return 'code' in hit ? { ...hit, object } : hit;
477
+ },
478
+ reused: () => reused,
479
+ ran: () => ran,
480
+ };
481
+ }
482
+
483
+ /** The report's whole document, as rig-c writes it (its key order), parsed so it can sit inside `mesh_report.json`. */
484
+ export function qualityDocument(report: MeshQualityReport): unknown {
485
+ return JSON.parse(writeMeshQualityReport(report));
486
+ }
487
+
488
+ /** One residual as `mesh_report.json` carries it: every row, its state, value and bound. */
489
+ export interface Residual {
490
+ code: string;
491
+ region: string | null;
492
+ state: MeasureRow['state'];
493
+ value: number | null;
494
+ bound: { op: '<=' | '>='; value: number } | null;
495
+ unit: MeasureRow['unit'];
496
+ }
497
+
498
+ export function residuals(report: MeshQualityReport): Residual[] {
499
+ return (report.candidates[0]?.geometry?.rows ?? []).map((r) => ({ code: r.code, region: r.object.region, state: r.state, value: r.value, bound: r.bound, unit: r.unit }));
500
+ }
501
+
502
+ /**
503
+ * The worst residual: of the rows with a declared bound, the one nearest its
504
+ * bound, as the share of the bound used — `value / bound` for a `<=` row,
505
+ * `bound / value` for a `>=` row (coverage, the smallest angle); a bound of 0
506
+ * met exactly is 0, a value of 0 under a `>=` bound above 0 is Infinity. The
507
+ * first in report order wins a tie. Null when no row has a bound.
508
+ */
509
+ export function worstResidual(rows: readonly Residual[]): (Residual & { used: number }) | null {
510
+ let best: (Residual & { used: number }) | null = null;
511
+ for (const r of rows) {
512
+ if (r.bound === null || r.value === null) continue;
513
+ const used = r.bound.op === '<=' ? (r.bound.value === 0 ? (r.value === 0 ? 0 : Infinity) : r.value / r.bound.value) : r.value === 0 ? (r.bound.value === 0 ? 0 : Infinity) : r.bound.value / r.value;
514
+ if (best === null || used > best.used) best = { ...r, used };
515
+ }
516
+ return best;
517
+ }
518
+
519
+ /**
520
+ * The art-fit rows whose bound the author declared absent: `MQ_OVERSHOOT`
521
+ * under `targets.artFit.maxOvershoot: null`, `MQ_UNDERCUT` under
522
+ * `maxUndercut: null` — the part-wide row of each (region null), and of the
523
+ * overshoot rows the first, the 8-connected one rig-c gates (a second,
524
+ * 4-connected reading is reported beside it only where a diagonal pinch makes
525
+ * the fills differ, and is never bounded). rig-c measures such a row and
526
+ * reports it `undeclared`, with its value and no bound; it gates nothing, so
527
+ * {@link worstResidual} never picks it. A bound written as a number selects
528
+ * nothing here.
529
+ */
530
+ export function unboundedArtRows(rows: readonly Residual[], artFit: { maxOvershoot: number | null; maxUndercut: number | null }): Array<{ label: 'overshoot' | 'undercut'; row: Residual }> {
531
+ const out: Array<{ label: 'overshoot' | 'undercut'; row: Residual }> = [];
532
+ const pick = (code: string): Residual | undefined => rows.find((r) => r.code === code && r.region === null);
533
+ if (artFit.maxOvershoot === null) {
534
+ const r = pick('MQ_OVERSHOOT');
535
+ if (r !== undefined) out.push({ label: 'overshoot', row: r });
536
+ }
537
+ if (artFit.maxUndercut === null) {
538
+ const r = pick('MQ_UNDERCUT');
539
+ if (r !== undefined) out.push({ label: 'undercut', row: r });
540
+ }
541
+ return out;
542
+ }
543
+
544
+ /**
545
+ * The build line's clause for {@link unboundedArtRows}: `; undercut 3.2 (not
546
+ * bounded)` per row, the value as rig-c reported it (or the state, when it
547
+ * could not be measured); empty when every art-fit bound is a number, so a
548
+ * config that declares both bounds prints the line it always printed.
549
+ */
550
+ export function unboundedClause(rows: readonly Residual[], artFit: { maxOvershoot: number | null; maxUndercut: number | null }): string {
551
+ return unboundedArtRows(rows, artFit)
552
+ .map(({ label, row }) => `; ${label} ${row.value === null ? row.state : row.value} (not bounded)`)
553
+ .join('');
554
+ }
555
+
556
+ /**
557
+ * The build line's weight-aware allocation readings (rig-c 2.29.0, rigc#1291), each printed only when rig-c measured it:
558
+ * `MQ_DEFORM_LOAD` (D · θ / 4, px — a location reading, not predicted motion) and `MQ_ALLOCATION_CONTRAST` (Δ, only
559
+ * with the author's gradation). Both are `undeclared`: no bound, never required. Nothing is printed for a row that is
560
+ * `not-measurable`, so a part with no amplitude prints the line it printed before.
561
+ */
562
+ export function allocationClause(rows: readonly Residual[]): string {
563
+ const at = (code: string): Residual | undefined => rows.find((r) => r.code === code && r.region === null && r.value !== null);
564
+ const load = at('MQ_DEFORM_LOAD');
565
+ const contrast = at('MQ_ALLOCATION_CONTRAST');
566
+ return `${load === undefined ? '' : `; deform load ${load.value} px (undeclared)`}${contrast === undefined ? '' : `; allocation contrast ${contrast.value} (undeclared)`}`;
567
+ }
568
+
569
+ /** The region whose density rows are nearest their bounds (the worst residual among `MQ_MAX_EDGE`/`MQ_TRANSITION` rows), or null without a region. */
570
+ export function worstRegion(rows: readonly Residual[]): string | null {
571
+ return worstResidual(rows.filter((r) => r.region !== null))?.region ?? null;
572
+ }
573
+
574
+ /** The share of art (alpha above `ART_ALPHA`, the other modes' reading) a mesh covers — `art_coverage`, comparable across modes. */
575
+ export function legacyArtCoverage(mask: AlphaMask, points: Array<[number, number]>, triangles: number[]): number {
576
+ const fit = measureAuthoredMeshFit(mask, ART_ALPHA + 1, points, triangles);
577
+ return fit.artPixels === 0 ? 0 : fit.coveredArt / fit.artPixels;
578
+ }
579
+
580
+ /** The counts the mesh line and the row carry: boundary / interior vertices, triangles, bindings. */
581
+ export function countsText(c: MeshCounts | null): string {
582
+ return c === null ? 'unread' : `${c.boundaryVertices}+${c.interiorVertices} v, ${c.triangles} t, ${c.bindings} b`;
583
+ }
584
+
585
+ export type { ContourReport };