spine-rigc 2.18.0 → 2.19.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.
@@ -0,0 +1,1294 @@
1
+ /**
2
+ * Mesh reduction and local refinement — stage B2 of the contract in
3
+ * docs/MESH_REDUCTION.md (issue #1221, stage B: issue #1224).
4
+ *
5
+ * One operation is exported, `reduceMesh`, and it is two operations composed:
6
+ *
7
+ * 1. **Refinement** (`refineRegions`) inserts vertices inside the union of the
8
+ * declared regions and their bands until §5's `L(R)` holds on every edge
9
+ * that meets each region — a coarse source is refined, never refused
10
+ * (correction 3).
11
+ * 2. **Reduction** (`removeVertices`) then removes source vertices one at a
12
+ * time, retriangulating the hole each leaves, and takes a step only when
13
+ * every bound the caller declared still holds after it — the region bounds
14
+ * included, so the reduction cannot undo the refinement.
15
+ *
16
+ * Each is its own function with its own entry and exit, so a control can run
17
+ * one with the other idle — regions empty, or every source vertex protected —
18
+ * which is the shape the consumer asked for (spine-parts#126, comment
19
+ * 6042150608: "separate composed operations … with independently passing
20
+ * controls for each").
21
+ *
22
+ * ## What every step is held to
23
+ *
24
+ * Each candidate step is measured by `measureMeshQuality` (`src/meshquality.ts`)
25
+ * — no row is re-implemented here — and taken only when every row the caller's
26
+ * contract requires is `pass`: the art fit at `targets.artFit`, the boundary
27
+ * deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
28
+ * angle when declared, orientation and degeneracy at 0, and every region's
29
+ * `MQ_MAX_EDGE` and `MQ_TRANSITION`. Before the measurement, the structural
30
+ * conditions a row cannot see: the hole's link polygon is strictly simple and
31
+ * ear-clips, the outline is still one loop, every protected source edge is
32
+ * still an edge, and no new edge joins two vertices whose weight vectors differ
33
+ * by more than `weightJump` (§6, conditions (a) and (b)).
34
+ *
35
+ * ## The order, which is the determinism (A18)
36
+ *
37
+ * The reduction sweeps the surviving SOURCE vertices in ascending source index,
38
+ * one removal attempted per vertex per pass; a step taken stays taken and the
39
+ * sweep continues with the next index. A pass in which no step is taken ends
40
+ * the reduction (`no-further-valid-reduction`, naming the constraint that
41
+ * blocked the last attempt). Inserted vertices are never candidates: they exist
42
+ * to meet `L(R)`, and removing one is undoing the refinement.
43
+ *
44
+ * The refinement measures, takes the first failing region row in report order
45
+ * (code, then region name) and splits that row's worst edge: at its midpoint
46
+ * when the midpoint lies in the region or its band, otherwise at the point of
47
+ * the edge inside them nearest the midpoint. A region no edge meets — one
48
+ * inside a single triangle — gets the first vertex of its polygon inserted into
49
+ * the triangle that holds it. Every inserted position is on the `r6` grid.
50
+ *
51
+ * ## What it never does
52
+ *
53
+ * - **Invent a value.** Every bound, floor, limit and the budget are inputs; a
54
+ * missing one is `REDUCE_INPUT_MISSING`, thrown.
55
+ * - **Resample a survivor.** A surviving vertex keeps its position, UV and
56
+ * bindings bit for bit (P19); only the order of its bindings is the canonical
57
+ * one (strongest first, `boneOrder` on a tie).
58
+ * - **Bind.** A `SourceMesh` names bones and carries no bind coordinates, and
59
+ * the input carries no bone transforms, so an inserted vertex's weights are
60
+ * by bone name exactly as a source vertex's are; the compiler binds them, as
61
+ * `bindWeightedVertices` binds every generated mesh.
62
+ * - **Claim a minimum.** A local stop is reported as one.
63
+ * - Pose, or link the runtime. Pure: no clock, no randomness, nothing from the
64
+ * compiler.
65
+ */
66
+ import {
67
+ distanceToSegment,
68
+ earClip,
69
+ findSelfIntersection,
70
+ labelIslands,
71
+ artOf,
72
+ MeshError,
73
+ MeshReductionError,
74
+ meshEdges,
75
+ r6,
76
+ rasteriseTriangles,
77
+ signedArea,
78
+ traceOutline,
79
+ checkHullOrder,
80
+ } from './mesh.ts';
81
+ import {
82
+ measureMeshQuality,
83
+ type AttachmentRef,
84
+ type ArtFitBounds,
85
+ type CandidateReport,
86
+ type DeformKeyInput,
87
+ type EffectiveSettings,
88
+ type MeasureRow,
89
+ type MeshCounts,
90
+ type MeshMeasureInput,
91
+ type MeshQualityReport,
92
+ type MeshReductionInput,
93
+ type RefinementRegion,
94
+ type ReductionChanges,
95
+ type SourceMesh,
96
+ type Termination,
97
+ } from './meshquality.ts';
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // the result
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** A `vertices` key after the remap: the run rewritten over the result's vertex order. */
104
+ export interface RemappedDeformKey {
105
+ animation: string;
106
+ attachment: AttachmentRef;
107
+ /** The key's position in its timeline's `keys`. */
108
+ key: number;
109
+ time: number;
110
+ /** Into the RESULT's deform array, as the compiler emits it. */
111
+ offset: number;
112
+ vertices: number[];
113
+ /** Source vertices whose offsets the run carried and whose vertex the reduction removed — dropped, never moved onto another vertex. */
114
+ droppedVertices: number[];
115
+ }
116
+
117
+ /** A `transform` key: re-evaluated over the new geometry at compile, so it has no run to rewrite. */
118
+ export interface DeformKeyRef {
119
+ animation: string;
120
+ attachment: AttachmentRef;
121
+ key: number;
122
+ time: number;
123
+ }
124
+
125
+ /**
126
+ * The reduced mesh: §1's `SourceMesh` in the canonical output order, plus what a
127
+ * consumer needs to carry anything indexed by the old vertices across.
128
+ */
129
+ export interface ReducedMesh extends SourceMesh {
130
+ /** `meshEdges`' list, in the export encoding (index × 2), outline loop first. */
131
+ edges: number[];
132
+ /** Source index → result index; null where the reduction removed the vertex. */
133
+ indexMap: Array<number | null>;
134
+ /** Result indices of the vertices the refinement inserted, ascending. */
135
+ inserted: number[];
136
+ /** Every linked mesh of the source, echoed: each inherits this topology. */
137
+ linkedMeshes: AttachmentRef[];
138
+ /** P18: what happened to every deform key the input listed. */
139
+ deform: { remapped: RemappedDeformKey[]; reevaluated: DeformKeyRef[] };
140
+ counts: MeshCounts;
141
+ }
142
+
143
+ export interface MeshReductionResult {
144
+ /** Null when no mesh is returned: a refusal, or a budget that ran out before any candidate met the targets. */
145
+ mesh: ReducedMesh | null;
146
+ report: MeshQualityReport;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // refusals and small helpers
151
+ // ---------------------------------------------------------------------------
152
+
153
+ type Pt = readonly [number, number];
154
+ type Binding = { bone: string; weight: number };
155
+
156
+ /** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
157
+ const ON_BOUNDARY = 1e-9;
158
+
159
+ /** How many halvings toward a point known to lie in a region's band the split search tries before it says none was found. */
160
+ const SPLIT_SEARCH_STEPS = 40;
161
+
162
+ function nameOf(ref: AttachmentRef): string {
163
+ return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
164
+ }
165
+
166
+ function sameRef(a: AttachmentRef, b: AttachmentRef): boolean {
167
+ return a.skin === b.skin && a.slot === b.slot && a.attachment === b.attachment;
168
+ }
169
+
170
+ function refuse(code: string, message: string): never {
171
+ throw new MeshReductionError(code, message);
172
+ }
173
+
174
+ function isObject(value: unknown): value is Record<string, unknown> {
175
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
176
+ }
177
+
178
+ function isFiniteNumber(value: unknown): value is number {
179
+ return typeof value === 'number' && Number.isFinite(value);
180
+ }
181
+
182
+ function isRef(value: unknown): value is AttachmentRef {
183
+ return isObject(value) && typeof value.slot === 'string' && value.slot !== '' && typeof value.attachment === 'string' && value.attachment !== '' && (value.skin === null || typeof value.skin === 'string');
184
+ }
185
+
186
+ /** A refusal the operation reports rather than throws: the input is well formed and the request cannot be met. */
187
+ class Stop extends Error {
188
+ constructor(
189
+ readonly reason: 'invalid-input' | 'unsupported-topology',
190
+ readonly code: string,
191
+ readonly detail: string,
192
+ ) {
193
+ super(`${code}: ${detail}`);
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // validation — what is refused by a throw, before anything is measured
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function checkFit(who: string, field: string, fit: unknown): void {
202
+ if (fit === undefined || fit === null || !isObject(fit)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is ${JSON.stringify(fit)}; required { minCoverage, maxOvershoot, maxUndercut } — no field has a default`);
203
+ if (!isFiniteNumber(fit.minCoverage) || fit.minCoverage < 0 || fit.minCoverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.minCoverage is ${JSON.stringify(fit.minCoverage)}; required a fraction in 0..1`);
204
+ for (const k of ['maxOvershoot', 'maxUndercut'] as const) {
205
+ if (!isFiniteNumber(fit[k]) || (fit[k] as number) < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(fit[k])}; required a finite number of px, 0 or more`);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The fields a reduction reads and a measurement does not, refused by name. The
211
+ * art, the mesh's arrays, the regions' shapes and the sample floors are refused
212
+ * by `measureMeshQuality`'s own validation when the source is first measured,
213
+ * with the same codes.
214
+ */
215
+ function validateReduction(input: MeshReductionInput): void {
216
+ if (!isObject(input)) refuse('REDUCE_INPUT_MISSING', `the input is ${JSON.stringify(input)}; required a MeshReductionInput object`);
217
+ if (!isRef(input.attachment)) refuse('REDUCE_INPUT_MISSING', `attachment is ${JSON.stringify(input.attachment)}; required { skin: string | null, slot: string, attachment: string }`);
218
+ const who = `attachment ${nameOf(input.attachment)}`;
219
+ checkFit(who, 'sourceBounds', input.sourceBounds);
220
+ const t = input.targets;
221
+ if (!isObject(t)) refuse('REDUCE_INPUT_MISSING', `${who}: targets is ${JSON.stringify(t)}; required { artFit, maxBoundaryDeviation, regions }`);
222
+ checkFit(who, 'targets.artFit', t.artFit);
223
+ if (!isFiniteNumber(t.maxBoundaryDeviation) || t.maxBoundaryDeviation < 0) {
224
+ refuse('REDUCE_INPUT_MISSING', `${who}: targets.maxBoundaryDeviation is ${JSON.stringify(t.maxBoundaryDeviation)}; required a finite number of px, 0 or more (P13: the required preservation bound)`);
225
+ }
226
+ if (!Array.isArray(t.regions)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions is ${JSON.stringify(t.regions)}; required a list (empty when none)`);
227
+ const b = input.budget;
228
+ if (!isObject(b) || !Number.isInteger(b.maxCandidates) || (b.maxCandidates as number) < 0) {
229
+ refuse('REDUCE_INPUT_MISSING', `${who}: budget is ${JSON.stringify(b)}; required { maxCandidates: a whole number, 0 or more } — the work bound has no default`);
230
+ }
231
+ const src = input.source;
232
+ if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
233
+ const n = src.points.length;
234
+
235
+ const p = input.protect;
236
+ if (p === undefined || p === null || !isObject(p)) refuse('REDUCE_INPUT_MISSING', `${who}: protect is ${JSON.stringify(p)}; required a ProtectedFeatures — a reduction has no default protection (P20)`);
237
+ if (typeof p.hull !== 'boolean') refuse('REDUCE_INPUT_MISSING', `${who}: protect.hull is ${JSON.stringify(p.hull)}; required true or false (P20: no default inside the operation)`);
238
+ if (!Array.isArray(p.vertices) || !Array.isArray(p.edges) || !Array.isArray(p.regionBoundaries) || !Array.isArray(p.influences) || !(p.weightJump === null || (isFiniteNumber(p.weightJump) && p.weightJump >= 0))) {
239
+ refuse('REDUCE_INPUT_MISSING', `${who}: protect is not { hull, vertices, edges, regionBoundaries, weightJump, influences } with lists and a weightJump that is null or 0 or more`);
240
+ }
241
+ p.vertices.forEach((v, i) => {
242
+ if (!Number.isInteger(v) || v < 0 || v >= n) refuse('REDUCE_INPUT_MISSING', `${who}: protect.vertices[${i}] is ${JSON.stringify(v)}; required a source vertex index in 0..${n - 1}`);
243
+ });
244
+ const sourceEdges = new Set<string>();
245
+ if (Array.isArray(src.triangles)) {
246
+ for (let i = 0; i + 2 < src.triangles.length; i += 3) {
247
+ const tri = [src.triangles[i], src.triangles[i + 1], src.triangles[i + 2]];
248
+ for (let k = 0; k < 3; k++) sourceEdges.add(edgeKey(tri[k], tri[(k + 1) % 3]));
249
+ }
250
+ }
251
+ p.edges.forEach((e, i) => {
252
+ if (!Array.isArray(e) || e.length !== 2 || !sourceEdges.has(edgeKey(e[0], e[1]))) {
253
+ refuse('REDUCE_INPUT_MISSING', `${who}: protect.edges[${i}] is ${JSON.stringify(e)}; required a pair of source indices that is an edge of a source triangle`);
254
+ }
255
+ });
256
+ const regionNames = new Set(t.regions.map((r) => (isObject(r) ? r.name : undefined)));
257
+ p.regionBoundaries.forEach((name, i) => {
258
+ if (!regionNames.has(name)) refuse('REDUCE_INPUT_MISSING', `${who}: protect.regionBoundaries[${i}] is ${JSON.stringify(name)}, which no region in targets.regions is; required a declared region's name`);
259
+ });
260
+
261
+ const weighted = src.weights !== null && src.weights !== undefined;
262
+ if (weighted) {
263
+ const lim = input.influences;
264
+ if (lim === undefined || lim === null || !isObject(lim)) refuse('REDUCE_INPUT_MISSING', `${who}: influences is ${JSON.stringify(lim)} on a weighted source; required { maxInfluences, minWeight } — stated on every weighted call, never inherited (P19)`);
265
+ if (!Number.isInteger(lim.maxInfluences) || lim.maxInfluences < 1) refuse('REDUCE_INPUT_MISSING', `${who}: influences.maxInfluences is ${JSON.stringify(lim.maxInfluences)}; required a whole number >= 1`);
266
+ if (!isFiniteNumber(lim.minWeight) || lim.minWeight < 0 || lim.minWeight >= 1) refuse('REDUCE_INPUT_MISSING', `${who}: influences.minWeight is ${JSON.stringify(lim.minWeight)}; required a share in 0..1 (0 drops only shares that are zero on the weight grid)`);
267
+ const order = input.boneOrder;
268
+ if (!Array.isArray(order) || order.some((x) => typeof x !== 'string')) refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder is ${JSON.stringify(order)} on a weighted source; required the skeleton's bone names in order (correction 1: the weight tie-break)`);
269
+ if (new Set(order).size !== order.length) refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder names a bone twice; required each bone once`);
270
+ const known = new Set(order);
271
+ if (Array.isArray(src.weights)) {
272
+ src.weights.forEach((vertex, i) => {
273
+ if (!Array.isArray(vertex)) return;
274
+ for (const bnd of vertex) {
275
+ if (isObject(bnd) && typeof bnd.bone === 'string' && !known.has(bnd.bone)) refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] binds "${bnd.bone}", which boneOrder does not name; required every bound bone in boneOrder`);
276
+ if (isObject(bnd) && isFiniteNumber(bnd.weight) && bnd.weight < 0) refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] gives "${String(bnd.bone)}" the weight ${bnd.weight}; required 0 or more`);
277
+ }
278
+ });
279
+ }
280
+ p.influences.forEach((bone, i) => {
281
+ if (!known.has(bone)) refuse('REDUCE_INPUT_MISSING', `${who}: protect.influences[${i}] is ${JSON.stringify(bone)}, which boneOrder does not name; required a bone of the skeleton`);
282
+ });
283
+ }
284
+
285
+ for (const field of ['minArtSamples', 'regionArtSamples', 'preset'] as const) {
286
+ if (input[field] === undefined) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is missing; required a value — no field has a default`);
287
+ }
288
+ if (!Array.isArray(input.linkedMeshes)) refuse('REDUCE_INPUT_MISSING', `${who}: linkedMeshes is ${JSON.stringify(input.linkedMeshes)}; required a list of the source's linked meshes (empty when none)`);
289
+ input.linkedMeshes.forEach((ref, i) => {
290
+ if (!isRef(ref)) refuse('REDUCE_INPUT_MISSING', `${who}: linkedMeshes[${i}] is ${JSON.stringify(ref)}; required an AttachmentRef`);
291
+ });
292
+ if (!Array.isArray(input.deform)) refuse('REDUCE_INPUT_MISSING', `${who}: deform is ${JSON.stringify(input.deform)}; required a list of the deform timelines keyed on the attachment and its linked meshes (empty when none)`);
293
+ input.deform.forEach((tl, i) => {
294
+ if (!isObject(tl) || typeof tl.animation !== 'string' || tl.animation === '' || !isRef(tl.attachment) || !Array.isArray(tl.keys)) {
295
+ refuse('REDUCE_INPUT_MISSING', `${who}: deform[${i}] is not { animation, attachment, keys }; required that`);
296
+ }
297
+ if (!sameRef(tl.attachment, input.attachment) && !input.linkedMeshes.some((l) => sameRef(l, tl.attachment))) {
298
+ refuse('REDUCE_INPUT_MISSING', `${who}: deform[${i}] (animation "${tl.animation}") is keyed on ${nameOf(tl.attachment)}, which is neither the attachment nor a listed linked mesh; required one of those`);
299
+ }
300
+ tl.keys.forEach((key, k) => {
301
+ const at = `${who}: deform[${i}] (animation "${tl.animation}") key ${k}`;
302
+ if (!isObject(key) || !isFiniteNumber(key.time)) refuse('REDUCE_INPUT_MISSING', `${at} has no finite time; required one`);
303
+ if (key.kind === 'vertices') {
304
+ if (!Number.isInteger(key.offset) || key.offset < 0) refuse('REDUCE_INPUT_MISSING', `${at}: offset is ${JSON.stringify(key.offset)}; required a whole index into the deform array, 0 or more`);
305
+ if (!Array.isArray(key.vertices) || key.vertices.some((v) => !isFiniteNumber(v))) refuse('REDUCE_INPUT_MISSING', `${at}: vertices is not a list of finite numbers; required the run as the compiler emits it`);
306
+ } else if (key.kind !== 'setup' && key.kind !== 'transform') {
307
+ refuse('REDUCE_INPUT_MISSING', `${at}: kind is ${JSON.stringify((key as { kind: unknown }).kind)}; required "setup", "vertices" or "transform"`);
308
+ }
309
+ });
310
+ });
311
+ }
312
+
313
+ function edgeKey(a: number, b: number): string {
314
+ return a < b ? `${a},${b}` : `${b},${a}`;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // the working mesh — vertices by stable id: source index, then insertion order
319
+ // ---------------------------------------------------------------------------
320
+
321
+ interface Work {
322
+ /** Number of source vertices: ids below it are source indices, ids from it are insertions in order. */
323
+ nSource: number;
324
+ pos: Pt[];
325
+ uv: Array<[number, number]>;
326
+ weights: Binding[][] | null;
327
+ alive: boolean[];
328
+ triangles: Array<[number, number, number]>;
329
+ /** For an inserted id, the source triangle its UV and weights were interpolated in. */
330
+ sourceTriangle: Map<number, number>;
331
+ }
332
+
333
+ interface Canonical {
334
+ mesh: SourceMesh;
335
+ /** Result index → working id. */
336
+ order: number[];
337
+ /** Working id → result index. */
338
+ indexOf: Map<number, number>;
339
+ }
340
+
341
+ /**
342
+ * §1's canonical order, read off a working mesh — or a `MeshError` naming why
343
+ * its triangles are not one closed outline.
344
+ *
345
+ * 1. hull vertices in walk order, from the surviving hull vertex with the
346
+ * smallest source index (an inserted one only when no source vertex is on
347
+ * the hull), turning the way the source's own hull listing turns;
348
+ * 2. surviving interior source vertices, ascending source index;
349
+ * 3. inserted interior vertices, ascending by (y, x) on the `r6` grid;
350
+ * 4. triangles rotated to start at their smallest index, winding kept, sorted;
351
+ * 5. bindings strongest first, ties by the bone's position in `boneOrder`.
352
+ */
353
+ function canonicalise(work: Work, sourceTurn: number, boneRank: Map<string, number>): Canonical {
354
+ const ids: number[] = [];
355
+ for (let id = 0; id < work.alive.length; id++) if (work.alive[id]) ids.push(id);
356
+ const compact = new Map<number, number>();
357
+ ids.forEach((id, i) => compact.set(id, i));
358
+ const flat: number[] = [];
359
+ for (const tri of work.triangles) for (const id of tri) flat.push(compact.get(id)!);
360
+ const outline = traceOutline(ids.length, flat);
361
+ let walk = outline.walk.map((i) => ids[i]);
362
+ const start = walk.indexOf(Math.min(...walk));
363
+ walk = [...walk.slice(start), ...walk.slice(0, start)];
364
+ const turn = Math.sign(signedArea(walk.map((id) => [work.pos[id][0], work.pos[id][1]])));
365
+ if (turn !== sourceTurn) walk = [walk[0], ...walk.slice(1).reverse()];
366
+ const onHull = new Set(walk);
367
+ const interiorSource = ids.filter((id) => !onHull.has(id) && id < work.nSource);
368
+ const interiorInserted = ids
369
+ .filter((id) => !onHull.has(id) && id >= work.nSource)
370
+ .sort((a, b) => work.pos[a][1] - work.pos[b][1] || work.pos[a][0] - work.pos[b][0] || a - b);
371
+ const order = [...walk, ...interiorSource, ...interiorInserted];
372
+ const indexOf = new Map<number, number>();
373
+ order.forEach((id, i) => indexOf.set(id, i));
374
+ const tris = work.triangles.map(([a, b, c]): [number, number, number] => {
375
+ const t: [number, number, number] = [indexOf.get(a)!, indexOf.get(b)!, indexOf.get(c)!];
376
+ const k = t.indexOf(Math.min(...t));
377
+ return [t[k], t[(k + 1) % 3], t[(k + 2) % 3]];
378
+ });
379
+ tris.sort((p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2]);
380
+ const mesh: SourceMesh = {
381
+ points: order.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]),
382
+ uvs: order.flatMap((id) => work.uv[id]),
383
+ triangles: tris.flat(),
384
+ hull: walk.length,
385
+ weights: work.weights === null ? null : order.map((id) => sortBindings(work.weights![id], boneRank)),
386
+ };
387
+ checkHullOrder({ hull: mesh.hull, walk: [...Array(mesh.hull).keys()] }, order.length);
388
+ return { mesh, order, indexOf };
389
+ }
390
+
391
+ /** Strongest first, ties by the bone's place in `boneOrder` — the values themselves untouched. */
392
+ function sortBindings(list: readonly Binding[], boneRank: Map<string, number>): Binding[] {
393
+ return list.map((b) => ({ bone: b.bone, weight: b.weight })).sort((p, q) => q.weight - p.weight || (boneRank.get(p.bone) ?? 0) - (boneRank.get(q.bone) ?? 0));
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // plane geometry the steps need (the rows' own geometry stays in meshquality.ts)
398
+ // ---------------------------------------------------------------------------
399
+
400
+ function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
401
+ const n = poly.length;
402
+ for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
403
+ let inside = false;
404
+ for (let i = 0, j = n - 1; i < n; j = i++) {
405
+ const [xi, yi] = poly[i];
406
+ const [xj, yj] = poly[j];
407
+ if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
408
+ }
409
+ return inside;
410
+ }
411
+
412
+ function distanceToBoundary(p: Pt, poly: readonly Pt[]): number {
413
+ let d = Infinity;
414
+ for (let i = 0; i < poly.length; i++) d = Math.min(d, distanceToSegment(p, poly[i], poly[(i + 1) % poly.length]));
415
+ return d;
416
+ }
417
+
418
+ /** Is `p` in the closed region or within its band — the set P16's checkable form confines an insertion to? */
419
+ function inRegionOrBand(p: Pt, region: RefinementRegion): boolean {
420
+ return inClosedPolygon(p, region.polygon) || (region.transition > 0 && distanceToBoundary(p, region.polygon) <= region.transition);
421
+ }
422
+
423
+ /** Barycentric coordinates of `p` in the triangle a, b, c, or null for a triangle with no area. */
424
+ function barycentric(p: Pt, a: Pt, b: Pt, c: Pt): [number, number, number] | null {
425
+ const det = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
426
+ if (Math.abs(det) < 1e-12) return null;
427
+ const l0 = ((b[1] - c[1]) * (p[0] - c[0]) + (c[0] - b[0]) * (p[1] - c[1])) / det;
428
+ const l1 = ((c[1] - a[1]) * (p[0] - c[0]) + (a[0] - c[0]) * (p[1] - c[1])) / det;
429
+ return [l0, l1, 1 - l0 - l1];
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // what an inserted vertex carries — §6: interpolate, then prune
434
+ // ---------------------------------------------------------------------------
435
+
436
+ interface Tally {
437
+ sharesDroppedOnGrid: number;
438
+ sharesPruned: number;
439
+ }
440
+
441
+ interface Interpolated {
442
+ uv: [number, number];
443
+ weights: Binding[] | null;
444
+ sourceTriangle: number;
445
+ }
446
+
447
+ /**
448
+ * UV and weights of a point by barycentric interpolation in the source triangle
449
+ * containing it — the first by triangle index whose smallest coordinate is the
450
+ * largest, so a point on a shared edge takes the lower-numbered triangle (the
451
+ * two agree there) and a point rounded a hair outside onto the grid still finds
452
+ * the triangle it belongs to.
453
+ */
454
+ function interpolate(p: Pt, input: MeshReductionInput, boneRank: Map<string, number>, tally: Tally, who: string): Interpolated {
455
+ const src = input.source;
456
+ let best = -1;
457
+ let bestMin = -Infinity;
458
+ let bestL: [number, number, number] = [0, 0, 0];
459
+ for (let t = 0; t * 3 + 2 < src.triangles.length; t++) {
460
+ const [a, b, c] = [src.triangles[t * 3], src.triangles[t * 3 + 1], src.triangles[t * 3 + 2]];
461
+ const l = barycentric(p, src.points[a], src.points[b], src.points[c]);
462
+ if (l === null) continue;
463
+ const m = Math.min(...l);
464
+ if (m > bestMin + 1e-12) {
465
+ best = t;
466
+ bestMin = m;
467
+ bestL = l;
468
+ }
469
+ }
470
+ const clamped = bestL.map((v) => Math.max(0, v));
471
+ const sum = clamped[0] + clamped[1] + clamped[2];
472
+ const lam = clamped.map((v) => v / sum);
473
+ const corners = [src.triangles[best * 3], src.triangles[best * 3 + 1], src.triangles[best * 3 + 2]];
474
+ const uv: [number, number] = [0, 1].map((k) => r6(corners.reduce((s, v, i) => s + lam[i] * src.uvs[v * 2 + k], 0))) as [number, number];
475
+ if (src.weights === null) return { uv, weights: null, sourceTriangle: best };
476
+ const shares = new Map<string, number>();
477
+ corners.forEach((v, i) => {
478
+ for (const bnd of src.weights![v]) shares.set(bnd.bone, (shares.get(bnd.bone) ?? 0) + lam[i] * bnd.weight);
479
+ });
480
+ const where = `${who}: the vertex inserted at (${p[0]}, ${p[1]}) in source triangle ${best} (vertices ${corners.join(', ')})`;
481
+ return { uv, weights: prune(shares, input, boneRank, tally, where), sourceTriangle: best };
482
+ }
483
+
484
+ /**
485
+ * `InfluenceLimits` applied to one interpolated weight vector (§6, P19): the
486
+ * protected influences kept first, then the strongest others up to
487
+ * `maxInfluences`; shares under `minWeight` dropped; a positive share that is 0
488
+ * on the 6-decimal weight grid dropped and counted rather than written as a 0
489
+ * binding (`minWeight: 0` drops only those); the rest closed at `1 − others`,
490
+ * the `segmentShares` rule. Every bone it returns is one the source triangle
491
+ * carried — interpolation cannot name another.
492
+ */
493
+ function prune(shares: Map<string, number>, input: MeshReductionInput, boneRank: Map<string, number>, tally: Tally, where: string): Binding[] {
494
+ const limits = input.influences!;
495
+ const guarded = new Set(input.protect.influences);
496
+ const rank = (bone: string): number => boneRank.get(bone) ?? 0;
497
+ let entries = [...shares.entries()].filter(([, s]) => s > 0).map(([bone, share]) => ({ bone, share }));
498
+ entries.sort((p, q) => q.share - p.share || rank(p.bone) - rank(q.bone));
499
+ const kept = entries.filter((e) => guarded.has(e.bone));
500
+ if (kept.length > limits.maxInfluences) {
501
+ throw new Stop(
502
+ 'invalid-input',
503
+ 'REDUCE_PROTECTED_INFLUENCES_OVER_CAP',
504
+ `${where} carries ${kept.length} protected influence(s) (${kept.map((e) => e.bone).join(', ')}); required at most influences.maxInfluences = ${limits.maxInfluences} — a protected influence is never pruned silently`,
505
+ );
506
+ }
507
+ for (const e of entries) {
508
+ if (guarded.has(e.bone)) continue;
509
+ if (kept.length < limits.maxInfluences) kept.push(e);
510
+ else tally.sharesPruned++;
511
+ }
512
+ entries = kept.sort((p, q) => q.share - p.share || rank(p.bone) - rank(q.bone));
513
+ const normalise = (): void => {
514
+ const total = entries.reduce((s, e) => s + e.share, 0);
515
+ for (const e of entries) e.share /= total;
516
+ };
517
+ normalise();
518
+ if (limits.minWeight > 0) {
519
+ const before = entries.length;
520
+ entries = entries.filter((e) => guarded.has(e.bone) || e.share >= limits.minWeight);
521
+ tally.sharesPruned += before - entries.length;
522
+ normalise();
523
+ }
524
+ for (;;) {
525
+ const zero = entries.find((e) => r6(e.share) === 0);
526
+ if (zero === undefined) break;
527
+ if (guarded.has(zero.bone)) {
528
+ throw new Stop(
529
+ 'invalid-input',
530
+ 'REDUCE_PROTECTED_INFLUENCE_BELOW_GRID',
531
+ `${where} gives the protected influence "${zero.bone}" a share of ${zero.share}, which is 0 on the 6-decimal weight grid; required a share that can be written as a positive binding — a protected influence is never dropped silently`,
532
+ );
533
+ }
534
+ entries = entries.filter((e) => e !== zero);
535
+ tally.sharesDroppedOnGrid++;
536
+ normalise();
537
+ }
538
+ for (;;) {
539
+ const out: Binding[] = [];
540
+ let others = 0;
541
+ entries.forEach((e, k) => {
542
+ const w = k === entries.length - 1 ? r6(1 - others) : r6(e.share);
543
+ others += w;
544
+ out.push({ bone: e.bone, weight: w });
545
+ });
546
+ const last = out[out.length - 1];
547
+ if (last.weight > 0) return out;
548
+ // The closing share fell to 0 or below on the grid: it is a share the grid cannot hold.
549
+ const lastEntry = entries[entries.length - 1];
550
+ if (guarded.has(lastEntry.bone)) {
551
+ throw new Stop(
552
+ 'invalid-input',
553
+ 'REDUCE_PROTECTED_INFLUENCE_BELOW_GRID',
554
+ `${where} closes the protected influence "${lastEntry.bone}" at ${last.weight} on the 6-decimal weight grid; required a positive binding`,
555
+ );
556
+ }
557
+ entries = entries.slice(0, -1);
558
+ tally.sharesDroppedOnGrid++;
559
+ normalise();
560
+ }
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // the deform keys (P18)
565
+ // ---------------------------------------------------------------------------
566
+
567
+ /** Deform-array start of each vertex and its width: two per vertex unweighted, two per influence weighted. */
568
+ function layoutOf(weights: readonly Binding[][] | null, count: number): { start: number[]; width: number[] } {
569
+ const start: number[] = [];
570
+ const width: number[] = [];
571
+ let at = 0;
572
+ for (let v = 0; v < count; v++) {
573
+ const w = weights === null ? 2 : 2 * weights[v].length;
574
+ start.push(at);
575
+ width.push(w);
576
+ at += w;
577
+ }
578
+ return { start, width };
579
+ }
580
+
581
+ /** The source vertices a `vertices` run writes into. */
582
+ function verticesUnder(key: Extract<DeformKeyInput, { kind: 'vertices' }>, layout: { start: number[]; width: number[] }): number[] {
583
+ const out: number[] = [];
584
+ const end = key.offset + key.vertices.length;
585
+ layout.start.forEach((s, v) => {
586
+ if (s < end && s + layout.width[v] > key.offset) out.push(v);
587
+ });
588
+ return out;
589
+ }
590
+
591
+ function deformName(animation: string, ref: AttachmentRef, key: number, time: number): string {
592
+ return `animation "${animation}", slot "${ref.slot}", attachment "${ref.attachment}"${ref.skin === null ? '' : ` (skin "${ref.skin}")`}, key ${key} (t ${time})`;
593
+ }
594
+
595
+ /** Refused before any work (P18): a deform timeline keyed on a linked mesh, whose own keys this remap does not carry; and a run past the source's array (A35). */
596
+ function checkDeformBeforeWork(input: MeshReductionInput): void {
597
+ const layout = layoutOf(input.source.weights, input.source.points.length);
598
+ const length = layout.start.length === 0 ? 0 : layout.start[layout.start.length - 1] + layout.width[layout.width.length - 1];
599
+ for (const tl of input.deform) {
600
+ const linked = !sameRef(tl.attachment, input.attachment);
601
+ tl.keys.forEach((key, k) => {
602
+ if (linked && key.kind !== 'setup') {
603
+ throw new Stop(
604
+ 'unsupported-topology',
605
+ 'REDUCE_DEFORM_INDEXED',
606
+ `attachment ${nameOf(input.attachment)}: ${deformName(tl.animation, tl.attachment, k, key.time)} is keyed on a linked mesh of ${nameOf(input.attachment)}; required keys on the source attachment only — a linked mesh's own vertex-indexed keys are not remapped by this operation, and a linked mesh that inherits plays the source's remapped keys`,
607
+ );
608
+ }
609
+ if (key.kind === 'vertices' && key.offset + key.vertices.length > length) {
610
+ refuse(
611
+ 'REDUCE_INPUT_MISSING',
612
+ `attachment ${nameOf(input.attachment)}: ${deformName(tl.animation, tl.attachment, k, key.time)} writes deform-array entries ${key.offset}..${key.offset + key.vertices.length - 1}; required within the source's ${length} (A35)`,
613
+ );
614
+ }
615
+ });
616
+ }
617
+ }
618
+
619
+ /** The vertices runs on the source that cover a source vertex — what an insertion beside it would sit under. */
620
+ function keyedSourceVertices(input: MeshReductionInput): Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }> {
621
+ const layout = layoutOf(input.source.weights, input.source.points.length);
622
+ const out = new Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }>();
623
+ for (const tl of input.deform) {
624
+ tl.keys.forEach((key, k) => {
625
+ if (key.kind !== 'vertices') return;
626
+ for (const v of verticesUnder(key, layout)) if (!out.has(v)) out.set(v, { animation: tl.animation, ref: tl.attachment, key: k, time: key.time });
627
+ });
628
+ }
629
+ return out;
630
+ }
631
+
632
+ /**
633
+ * Every deform key carried onto the result's vertex order. A `vertices` run is
634
+ * rewritten entry by entry onto the vertex and influence it addressed; entries
635
+ * of a removed vertex are dropped and reported; the gaps a reorder opens are
636
+ * zero, which is what the format reads outside a run anyway. Refused by name: a
637
+ * weighted keyed vertex whose binding list is not the same list in the same
638
+ * order in the result — its pairs would land on other influences.
639
+ */
640
+ function remapDeform(input: MeshReductionInput, result: SourceMesh, indexMap: Array<number | null>): { remapped: RemappedDeformKey[]; reevaluated: DeformKeyRef[] } {
641
+ const before = layoutOf(input.source.weights, input.source.points.length);
642
+ const after = layoutOf(result.weights, result.points.length);
643
+ const remapped: RemappedDeformKey[] = [];
644
+ const reevaluated: DeformKeyRef[] = [];
645
+ for (const tl of input.deform) {
646
+ tl.keys.forEach((key, k) => {
647
+ if (key.kind === 'transform') {
648
+ reevaluated.push({ animation: tl.animation, attachment: tl.attachment, key: k, time: key.time });
649
+ return;
650
+ }
651
+ if (key.kind !== 'vertices') return;
652
+ const placed = new Map<number, number>();
653
+ const dropped = new Set<number>();
654
+ key.vertices.forEach((value, j) => {
655
+ const at = key.offset + j;
656
+ let v = 0;
657
+ while (v + 1 < before.start.length && before.start[v + 1] <= at) v++;
658
+ const within = at - before.start[v];
659
+ const r = indexMap[v];
660
+ if (r === null) {
661
+ dropped.add(v);
662
+ return;
663
+ }
664
+ if (input.source.weights !== null) {
665
+ const was = input.source.weights[v].map((b) => b.bone).join(',');
666
+ const now = result.weights![r].map((b) => b.bone).join(',');
667
+ if (was !== now) {
668
+ throw new Stop(
669
+ 'unsupported-topology',
670
+ 'REDUCE_DEFORM_INDEXED',
671
+ `attachment ${nameOf(input.attachment)}: ${deformName(tl.animation, tl.attachment, k, key.time)} keys source vertex ${v}, weighted [${was}], which the result binds [${now}]; required the same influences in the same order, so each pair of the run still lands on its influence — a weighted keyed mesh whose influence layout changes is not remapped`,
672
+ );
673
+ }
674
+ }
675
+ placed.set(after.start[r] + within, value);
676
+ });
677
+ const positions = [...placed.keys()].sort((a, b) => a - b);
678
+ const offset = positions.length === 0 ? 0 : positions[0];
679
+ const vertices: number[] = [];
680
+ if (positions.length > 0) for (let at = offset; at <= positions[positions.length - 1]; at++) vertices.push(placed.get(at) ?? 0);
681
+ remapped.push({ animation: tl.animation, attachment: tl.attachment, key: k, time: key.time, offset, vertices, droppedVertices: [...dropped].sort((a, b) => a - b) });
682
+ });
683
+ }
684
+ return { remapped, reevaluated };
685
+ }
686
+
687
+ // ---------------------------------------------------------------------------
688
+ // the composed operation's shared state
689
+ // ---------------------------------------------------------------------------
690
+
691
+ interface Run {
692
+ input: MeshReductionInput;
693
+ who: string;
694
+ work: Work;
695
+ boneRank: Map<string, number>;
696
+ sourceTurn: number;
697
+ sourceHull: Array<[number, number]>;
698
+ /** Steps tried so far, refinement insertions and removal attempts alike. */
699
+ steps: number;
700
+ tally: Tally;
701
+ keyed: Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }>;
702
+ protectedVertices: Set<number>;
703
+ protectedEdges: Array<[number, number]>;
704
+ sourceEdges: Set<string>;
705
+ }
706
+
707
+ /** A measurement of a canonical mesh against the result's full contract. */
708
+ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQualityReport {
709
+ const { input } = run;
710
+ const targets = input.targets;
711
+ const measureInput: MeshMeasureInput = {
712
+ id,
713
+ attachment: input.attachment,
714
+ art: input.art,
715
+ source: mesh,
716
+ targets: { artFit: targets.artFit, maxBoundaryDeviation: targets.maxBoundaryDeviation, ...(targets.minAngle === undefined ? {} : { minAngle: targets.minAngle }), regions: targets.regions },
717
+ referenceHull: run.sourceHull,
718
+ minArtSamples: input.minArtSamples,
719
+ regionArtSamples: input.regionArtSamples,
720
+ protect: input.protect,
721
+ influences: input.influences,
722
+ boneOrder: input.boneOrder,
723
+ preset: input.preset,
724
+ };
725
+ return measureMeshQuality(measureInput);
726
+ }
727
+
728
+ /** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
729
+ const BLOCKING_ORDER = ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_BOUNDARY_DEVIATION', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT', 'MQ_MIN_ANGLE', 'MQ_MAX_EDGE', 'MQ_TRANSITION'];
730
+
731
+ function rowName(row: MeasureRow): string {
732
+ const region = row.object.region === null ? '' : `[${row.object.region}]`;
733
+ if (row.state === 'fail') return `${row.code}${region}: ${row.value} against ${row.bound!.op} ${row.bound!.value}`;
734
+ return `${row.code}${region}: ${row.state} — ${row.reason ?? ''}`;
735
+ }
736
+
737
+ /**
738
+ * The first required row that is not `pass`, in `BLOCKING_ORDER`, or null when
739
+ * every one passes. A row is required exactly when its bound is declared; the
740
+ * overshoot row gated is the 8-connected one (P12).
741
+ */
742
+ function firstBlockingRow(report: MeshQualityReport): MeasureRow | null {
743
+ const rows = report.candidates[0]?.geometry?.rows ?? [];
744
+ const required = (r: MeasureRow): boolean => {
745
+ if (r.code === 'MQ_OVERSHOOT') return r.art?.connectivity === 8;
746
+ if (r.code === 'MQ_MIN_ANGLE') return r.bound !== null;
747
+ if (r.code === 'MQ_HOLES' || r.code === 'MQ_ISLANDS' || r.code === 'MQ_TRACE_DEVIATION' || r.code === 'MQ_FILL_DISTANCE') return false;
748
+ return true;
749
+ };
750
+ for (const code of BLOCKING_ORDER) {
751
+ const hit = rows.find((r) => r.code === code && required(r) && r.state !== 'pass');
752
+ if (hit !== undefined) return hit;
753
+ }
754
+ return null;
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // refinement (§5)
759
+ // ---------------------------------------------------------------------------
760
+
761
+ /** Where to split edge a–b for `region`: its midpoint when that is in the region or its band, else the point inside them nearest the midpoint. */
762
+ function splitPoint(a: Pt, b: Pt, region: RefinementRegion): Pt | null {
763
+ const at = (t: number): Pt => [r6(a[0] + (b[0] - a[0]) * t), r6(a[1] + (b[1] - a[1]) * t)];
764
+ const usable = (q: Pt): boolean => inRegionOrBand(q, region) && !(q[0] === a[0] && q[1] === a[1]) && !(q[0] === b[0] && q[1] === b[1]);
765
+ if (usable(at(0.5))) return at(0.5);
766
+ // The parameters where the inside of the region or its band can begin or end: crossings of the
767
+ // polygon's edges and the feet of its vertices; the inside point nearest the midpoint is among them.
768
+ const dx = b[0] - a[0];
769
+ const dy = b[1] - a[1];
770
+ const len2 = dx * dx + dy * dy;
771
+ const ts = new Set<number>();
772
+ const poly = region.polygon;
773
+ for (let i = 0; i < poly.length; i++) {
774
+ const c = poly[i];
775
+ const d = poly[(i + 1) % poly.length];
776
+ ts.add(Math.max(0, Math.min(1, ((c[0] - a[0]) * dx + (c[1] - a[1]) * dy) / len2)));
777
+ const ex = d[0] - c[0];
778
+ const ey = d[1] - c[1];
779
+ const den = dx * ey - dy * ex;
780
+ if (Math.abs(den) > 1e-12) {
781
+ const t = ((c[0] - a[0]) * ey - (c[1] - a[1]) * ex) / den;
782
+ const u = ((c[0] - a[0]) * dy - (c[1] - a[1]) * dx) / den;
783
+ if (t >= 0 && t <= 1 && u >= -1e-9 && u <= 1 + 1e-9) ts.add(t);
784
+ }
785
+ }
786
+ const candidates = [...ts].filter((t) => t > 0 && t < 1 && usable(at(t))).sort((p, q) => Math.abs(p - 0.5) - Math.abs(q - 0.5) || p - q);
787
+ if (candidates.length > 0) return at(candidates[0]);
788
+ // A point of the edge in the band only at an endpoint: halve towards it from the midpoint.
789
+ for (const end of [0, 1]) {
790
+ if (!inRegionOrBand(end === 0 ? a : b, region)) continue;
791
+ let t = 0.5;
792
+ for (let i = 0; i < SPLIT_SEARCH_STEPS; i++) {
793
+ t = (t + end) / 2;
794
+ if (usable(at(t))) return at(t);
795
+ }
796
+ }
797
+ return null;
798
+ }
799
+
800
+ /** Add a vertex at `p` with what interpolation gives it, refusing it under a deform run (P18). */
801
+ function addVertex(run: Run, p: Pt): number {
802
+ const got = interpolate(p, run.input, run.boneRank, run.tally, run.who);
803
+ const src = run.input.source;
804
+ for (let k = 0; k < 3; k++) {
805
+ const corner = src.triangles[got.sourceTriangle * 3 + k];
806
+ const key = run.keyed.get(corner);
807
+ if (key !== undefined) {
808
+ throw new Stop(
809
+ 'unsupported-topology',
810
+ 'REDUCE_DEFORM_INDEXED',
811
+ `${run.who}: ${deformName(key.animation, key.ref, key.key, key.time)} keys source vertex ${corner}, and the refinement would insert a vertex at (${p[0]}, ${p[1]}) in source triangle ${got.sourceTriangle} beside it, under the run; required no insertion under a vertices run — the inserted vertex has no offset in any key`,
812
+ );
813
+ }
814
+ }
815
+ const id = run.work.pos.length;
816
+ run.work.pos.push(p);
817
+ run.work.uv.push(got.uv);
818
+ if (run.work.weights !== null) run.work.weights.push(got.weights!);
819
+ run.work.alive.push(true);
820
+ run.work.sourceTriangle.set(id, got.sourceTriangle);
821
+ return id;
822
+ }
823
+
824
+ /** Split the edge between working ids a and b at `p`: each triangle on it becomes two, winding kept. */
825
+ function splitEdge(run: Run, a: number, b: number, p: Pt): void {
826
+ const m = addVertex(run, p);
827
+ const next: Array<[number, number, number]> = [];
828
+ for (const tri of run.work.triangles) {
829
+ const i = tri.indexOf(a);
830
+ const j = tri.indexOf(b);
831
+ if (i === -1 || j === -1) {
832
+ next.push(tri);
833
+ continue;
834
+ }
835
+ // Rotate so the edge is the triangle's first two corners, in its own winding.
836
+ const r = (j - i + 3) % 3 === 1 ? i : j;
837
+ const x = tri[r];
838
+ const y = tri[(r + 1) % 3];
839
+ const c = tri[(r + 2) % 3];
840
+ next.push([x, m, c], [m, y, c]);
841
+ }
842
+ run.work.triangles = next;
843
+ }
844
+
845
+ /** Insert `p` into the working triangle that holds it: on an edge, that edge is split; inside, the triangle becomes three. */
846
+ function insertPoint(run: Run, p: Pt): boolean {
847
+ const { work } = run;
848
+ for (const tri of work.triangles) {
849
+ const l = barycentric(p, work.pos[tri[0]], work.pos[tri[1]], work.pos[tri[2]]);
850
+ if (l === null || Math.min(...l) < -1e-9) continue;
851
+ const onEdge = l.findIndex((v) => Math.abs(v) <= 1e-9);
852
+ if (onEdge !== -1) {
853
+ splitEdge(run, tri[(onEdge + 1) % 3], tri[(onEdge + 2) % 3], p);
854
+ return true;
855
+ }
856
+ const m = addVertex(run, p);
857
+ work.triangles = work.triangles.flatMap((t): Array<[number, number, number]> => (t === tri ? [[tri[0], tri[1], m], [tri[1], tri[2], m], [tri[2], tri[0], m]] : [t]));
858
+ return true;
859
+ }
860
+ return false;
861
+ }
862
+
863
+ type PhaseEnd =
864
+ | { kind: 'done' }
865
+ | { kind: 'budget' }
866
+ | { kind: 'stuck'; constraint: string };
867
+
868
+ /**
869
+ * Refine the working mesh until every region's `MQ_MAX_EDGE` and
870
+ * `MQ_TRANSITION` that the refinement can act on passes. One insertion per
871
+ * measurement: the first failing region row in report order has its worst edge
872
+ * split. A band no edge lies in leaves `MQ_TRANSITION` not-measurable (B1's
873
+ * definition) and is not something an insertion is aimed at.
874
+ */
875
+ function refineRegions(run: Run): PhaseEnd {
876
+ const regions = new Map(run.input.targets.regions.map((r) => [r.name, r]));
877
+ if (regions.size === 0) return { kind: 'done' };
878
+ for (;;) {
879
+ const canon = canonicalise(run.work, run.sourceTurn, run.boneRank);
880
+ const report = measureAgainstTargets(run, canon.mesh, 'refinement');
881
+ const rows = report.candidates[0]?.geometry?.rows ?? [];
882
+ const target = rows.find((r) => (r.code === 'MQ_MAX_EDGE' || r.code === 'MQ_TRANSITION') && r.state === 'fail') ?? rows.find((r) => r.code === 'MQ_MAX_EDGE' && r.state === 'not-measurable' && (r.reason ?? '').includes('no triangle edge meets'));
883
+ if (target === undefined) return { kind: 'done' };
884
+ if (run.steps >= run.input.budget.maxCandidates) return { kind: 'budget' };
885
+ run.steps++;
886
+ const region = regions.get(target.object.region!)!;
887
+ if (target.state === 'not-measurable') {
888
+ const p: Pt = [r6(region.polygon[0][0]), r6(region.polygon[0][1])];
889
+ if (!insertPoint(run, p)) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no triangle holding the region's first vertex)` };
890
+ continue;
891
+ }
892
+ const [ra, rb] = target.worst!.at.edge!;
893
+ const a = canon.order[ra];
894
+ const b = canon.order[rb];
895
+ // An end outside the region and its band further beyond the band than the edge's own bound: every
896
+ // split keeps an edge from a vertex inside them to that end, so no insertion P16 allows can meet it.
897
+ for (const [end, id] of [[ra, a], [rb, b]] as const) {
898
+ const at = run.work.pos[id];
899
+ if (inRegionOrBand(at, region)) continue;
900
+ const beyond = distanceToBoundary(at, region.polygon) - region.transition;
901
+ if (beyond > target.bound!.value) {
902
+ return {
903
+ kind: 'stuck',
904
+ constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: an edge from inside the region and its band to it stays over the bound, and the refinement inserts only inside them — P16)`,
905
+ };
906
+ }
907
+ }
908
+ const p = splitPoint(run.work.pos[a], run.work.pos[b], region);
909
+ if (p === null) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no point of edge ${ra}–${rb} strictly between its ends inside region "${region.name}" or its band)` };
910
+ splitEdge(run, a, b, p);
911
+ }
912
+ }
913
+
914
+ // ---------------------------------------------------------------------------
915
+ // reduction (§1, §6)
916
+ // ---------------------------------------------------------------------------
917
+
918
+ /** One removal tried: the working triangles after it and the edges it added, or the structural reason it cannot be made. */
919
+ function removalOf(work: Work, v: number): { triangles: Array<[number, number, number]>; added: Array<[number, number]> } | { blocked: string } {
920
+ const star = work.triangles.filter((t) => t.includes(v));
921
+ const next = new Map<number, number>();
922
+ const incoming = new Set<number>();
923
+ for (const t of star) {
924
+ const k = t.indexOf(v);
925
+ const a = t[(k + 1) % 3];
926
+ const b = t[(k + 2) % 3];
927
+ if (next.has(a) || incoming.has(b)) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
928
+ next.set(a, b);
929
+ incoming.add(b);
930
+ }
931
+ const heads = [...next.keys()].filter((a) => !incoming.has(a));
932
+ const boundary = heads.length === 1;
933
+ if (heads.length > 1) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
934
+ let at = boundary ? heads[0] : Math.min(...next.keys());
935
+ const ring = [at];
936
+ for (;;) {
937
+ const to = next.get(at);
938
+ if (to === undefined || to === ring[0]) break;
939
+ ring.push(to);
940
+ at = to;
941
+ if (ring.length > next.size + 1) return { blocked: `retriangulation: vertex ${v}'s link does not close` };
942
+ }
943
+ if (ring.length !== (boundary ? next.size + 1 : next.size)) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
944
+ const rest = work.triangles.filter((t) => !t.includes(v));
945
+ const fresh: Array<[number, number, number]> = [];
946
+ if (ring.length >= 3) {
947
+ const poly = ring.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]);
948
+ if (findSelfIntersection(poly) !== null) return { blocked: `retriangulation: the hole vertex ${v} leaves is not a strictly simple polygon` };
949
+ let tris: number[];
950
+ try {
951
+ tris = earClip(poly);
952
+ } catch (err) {
953
+ if (!(err instanceof MeshError)) throw err;
954
+ return { blocked: `retriangulation: the hole vertex ${v} leaves does not ear-clip (${err.message})` };
955
+ }
956
+ for (let i = 0; i < tris.length; i += 3) fresh.push([ring[tris[i]], ring[tris[i + 1]], ring[tris[i + 2]]]);
957
+ } else if (!boundary) {
958
+ return { blocked: `retriangulation: vertex ${v} has fewer than three neighbours` };
959
+ }
960
+ const old = new Set<string>();
961
+ for (const t of work.triangles) for (let k = 0; k < 3; k++) old.add(edgeKey(t[k], t[(k + 1) % 3]));
962
+ const added: Array<[number, number]> = [];
963
+ const seen = new Set<string>();
964
+ for (const t of fresh) {
965
+ for (let k = 0; k < 3; k++) {
966
+ const key = edgeKey(t[k], t[(k + 1) % 3]);
967
+ if (old.has(key) || seen.has(key)) continue;
968
+ seen.add(key);
969
+ added.push([t[k], t[(k + 1) % 3]]);
970
+ }
971
+ }
972
+ return { triangles: [...rest, ...fresh], added };
973
+ }
974
+
975
+ /** L1 difference of two weight vectors over the bones they name. */
976
+ function weightJump(work: Work, a: number, b: number): number {
977
+ if (work.weights === null) return 0;
978
+ const shares = new Map<string, number>();
979
+ for (const bnd of work.weights[a]) shares.set(bnd.bone, (shares.get(bnd.bone) ?? 0) + bnd.weight);
980
+ for (const bnd of work.weights[b]) shares.set(bnd.bone, (shares.get(bnd.bone) ?? 0) - bnd.weight);
981
+ let sum = 0;
982
+ for (const v of shares.values()) sum += Math.abs(v);
983
+ return sum;
984
+ }
985
+
986
+ /**
987
+ * Remove surviving source vertices in ascending source index, pass after pass,
988
+ * taking a step only when every structural condition and every required row
989
+ * holds after it. Ends when a pass takes no step, or when the budget is spent.
990
+ */
991
+ function removeVertices(run: Run): PhaseEnd {
992
+ const { work, input } = run;
993
+ let lastBlock = '';
994
+ for (;;) {
995
+ let taken = 0;
996
+ let tried = 0;
997
+ for (let v = 0; v < work.nSource; v++) {
998
+ if (!work.alive[v] || run.protectedVertices.has(v)) continue;
999
+ if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
1000
+ run.steps++;
1001
+ tried++;
1002
+ const block = tryRemoval(run, v);
1003
+ if (block === null) taken++;
1004
+ else lastBlock = `${block}, removing source vertex ${v}`;
1005
+ }
1006
+ if (taken === 0) {
1007
+ if (tried === 0) lastBlock = 'protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge)';
1008
+ return { kind: 'stuck', constraint: lastBlock };
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ /** One removal: null when it was taken, else the constraint that blocked it. */
1014
+ function tryRemoval(run: Run, v: number): string | null {
1015
+ const { work, input } = run;
1016
+ const step = removalOf(work, v);
1017
+ if ('blocked' in step) return step.blocked;
1018
+ const jump = input.protect.weightJump;
1019
+ if (jump !== null) {
1020
+ for (const [a, b] of step.added) {
1021
+ if (a < work.nSource && b < work.nSource && run.sourceEdges.has(edgeKey(a, b))) continue;
1022
+ const d = weightJump(work, a, b);
1023
+ if (d > jump) return `weightJump (b): the new edge ${a}–${b} joins weight vectors ${r6(d)} apart, over ${jump}`;
1024
+ }
1025
+ }
1026
+ const was = { triangles: work.triangles };
1027
+ work.triangles = step.triangles;
1028
+ work.alive[v] = false;
1029
+ const undo = (): void => {
1030
+ work.triangles = was.triangles;
1031
+ work.alive[v] = true;
1032
+ };
1033
+ const edges = new Set<string>();
1034
+ for (const t of work.triangles) for (let k = 0; k < 3; k++) edges.add(edgeKey(t[k], t[(k + 1) % 3]));
1035
+ for (const [a, b] of run.protectedEdges) {
1036
+ if (!edges.has(edgeKey(a, b))) {
1037
+ undo();
1038
+ return `protect (a): the protected source edge ${a}–${b} is no longer an edge`;
1039
+ }
1040
+ }
1041
+ let canon: Canonical;
1042
+ try {
1043
+ canon = canonicalise(work, run.sourceTurn, run.boneRank);
1044
+ } catch (err) {
1045
+ if (!(err instanceof MeshError)) throw err;
1046
+ undo();
1047
+ return `outline: ${err.message}`;
1048
+ }
1049
+ const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'));
1050
+ if (blocking !== null) {
1051
+ undo();
1052
+ return rowName(blocking);
1053
+ }
1054
+ return null;
1055
+ }
1056
+
1057
+ // ---------------------------------------------------------------------------
1058
+ // the operation
1059
+ // ---------------------------------------------------------------------------
1060
+
1061
+ /**
1062
+ * Reduce one mesh towards the caller's targets, refining it first where a
1063
+ * region asks for density — §1, §5 and §6 of docs/MESH_REDUCTION.md, geometry
1064
+ * only. Throws a `MeshReductionError` for a malformed input (a missing or
1065
+ * out-of-range field, a threshold, a mask or a UV out of range); reports
1066
+ * everything else, with exactly one `Termination`:
1067
+ *
1068
+ * - `invalid-input`: the source fails its own art bounds
1069
+ * (`REDUCE_SOURCE_FAILS_ITS_ART_BOUNDS`, `sourceBounds` only — correction 3),
1070
+ * a region is refused (`REGION_*`), full coverage is asked of a source that
1071
+ * leaves an art island untouched (`REDUCE_ISLAND_UNREACHED`), or an inserted
1072
+ * vertex's protected influences cannot be written
1073
+ * (`REDUCE_PROTECTED_INFLUENCES_OVER_CAP`, `REDUCE_PROTECTED_INFLUENCE_BELOW_GRID`);
1074
+ * - `unsupported-topology`: the source is not one loop
1075
+ * (`REDUCE_SOURCE_NOT_ONE_LOOP`) or a deform key cannot be carried
1076
+ * (`REDUCE_DEFORM_INDEXED`);
1077
+ * - `budget-exhausted`: `budget.maxCandidates` steps were tried — the result is
1078
+ * returned when it meets every required bound, and none is when it does not;
1079
+ * - `no-further-valid-reduction`: a pass took no step, naming what blocked the
1080
+ * last one. A local stop: nothing here says the result is the smallest.
1081
+ */
1082
+ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1083
+ validateReduction(input);
1084
+ const who = `attachment ${nameOf(input.attachment)}`;
1085
+ const src = input.source;
1086
+ const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
1087
+
1088
+ // The source, measured against its own admissibility bounds (correction 3) — this is also what refuses a
1089
+ // malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes.
1090
+ const admit = measureMeshQuality({
1091
+ id: 'source',
1092
+ attachment: input.attachment,
1093
+ art: input.art,
1094
+ source: src,
1095
+ targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
1096
+ referenceHull: null,
1097
+ minArtSamples: input.minArtSamples,
1098
+ regionArtSamples: input.regionArtSamples,
1099
+ protect: input.protect,
1100
+ influences: input.influences,
1101
+ boneOrder: input.boneOrder,
1102
+ preset: input.preset,
1103
+ });
1104
+ const effective = effectiveOf(input, admit.effective, sourceHull);
1105
+ const sourceCounts = admit.sourceCounts;
1106
+ const noMesh = (termination: Termination): MeshReductionResult => ({
1107
+ mesh: null,
1108
+ report: reportOf(effective, sourceCounts, { id: 'result', counts: null, geometry: null, motion: null, accepted: false }, termination),
1109
+ });
1110
+ if (admit.termination !== null) return noMesh(admit.termination);
1111
+
1112
+ try {
1113
+ const admitRows = admit.candidates[0]?.geometry?.rows ?? [];
1114
+ for (const row of admitRows) {
1115
+ if (row.state === 'refused') {
1116
+ const reason = row.reason ?? '';
1117
+ const code = reason.slice(0, reason.indexOf(':'));
1118
+ throw new Stop('invalid-input', code, reason.slice(code.length + 2));
1119
+ }
1120
+ }
1121
+ for (const code of ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT']) {
1122
+ const row = admitRows.find((r) => r.code === code && (code !== 'MQ_OVERSHOOT' || r.art?.connectivity === 8));
1123
+ if (row !== undefined && row.state !== 'pass') {
1124
+ const found = row.state === 'fail' ? `${row.value} against ${row.bound!.op} ${row.bound!.value}` : `${row.state} (${row.reason ?? ''})`;
1125
+ throw new Stop(
1126
+ 'invalid-input',
1127
+ 'REDUCE_SOURCE_FAILS_ITS_ART_BOUNDS',
1128
+ `${who}: the source's ${code} is ${found}; required the source to pass its own art bounds (sourceBounds) and its own winding — a source that does not is not a reference to reduce from (correction 3)`,
1129
+ );
1130
+ }
1131
+ }
1132
+ checkIslands(input, who);
1133
+ checkDeformBeforeWork(input);
1134
+
1135
+ const boneRank = new Map((input.boneOrder ?? []).map((b, i) => [b, i]));
1136
+ const work: Work = {
1137
+ nSource: src.points.length,
1138
+ pos: src.points.map((p): Pt => p),
1139
+ uv: src.points.map((_, i): [number, number] => [src.uvs[i * 2], src.uvs[i * 2 + 1]]),
1140
+ weights: src.weights === null ? null : src.weights.map((v) => v.map((b) => ({ bone: b.bone, weight: b.weight }))),
1141
+ alive: src.points.map(() => true),
1142
+ triangles: [],
1143
+ sourceTriangle: new Map(),
1144
+ };
1145
+ for (let i = 0; i + 2 < src.triangles.length; i += 3) work.triangles.push([src.triangles[i], src.triangles[i + 1], src.triangles[i + 2]]);
1146
+ const sourceEdges = new Set<string>();
1147
+ for (const t of work.triangles) for (let k = 0; k < 3; k++) sourceEdges.add(edgeKey(t[k], t[(k + 1) % 3]));
1148
+ const run: Run = {
1149
+ input,
1150
+ who,
1151
+ work,
1152
+ boneRank,
1153
+ sourceTurn: Math.sign(signedArea(sourceHull)),
1154
+ sourceHull,
1155
+ steps: 0,
1156
+ tally: { sharesDroppedOnGrid: 0, sharesPruned: 0 },
1157
+ keyed: keyedSourceVertices(input),
1158
+ ...protectionOf(input, work, sourceEdges),
1159
+ sourceEdges,
1160
+ };
1161
+
1162
+ const refined = refineRegions(run);
1163
+ if (refined.kind === 'budget') return noMesh({ reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'none-met-the-targets' });
1164
+ const startCanon = canonicalise(work, run.sourceTurn, boneRank);
1165
+ const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'));
1166
+ let termination: Termination;
1167
+ if (startBlock !== null) {
1168
+ const constraint = refined.kind === 'stuck' ? refined.constraint : rowName(startBlock);
1169
+ termination = { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: `${constraint} — before any removal: the refined source does not meet its targets, so no step from it can` };
1170
+ } else {
1171
+ const reduced = removeVertices(run);
1172
+ termination =
1173
+ reduced.kind === 'budget'
1174
+ ? { reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'best-meeting-every-bound' }
1175
+ : { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: reduced.kind === 'stuck' ? reduced.constraint : '' };
1176
+ }
1177
+ return finish(run, effective, sourceCounts, termination);
1178
+ } catch (err) {
1179
+ if (err instanceof Stop) return noMesh({ reason: err.reason, code: err.code, detail: err.detail });
1180
+ throw err;
1181
+ }
1182
+ }
1183
+
1184
+ /** §4: full coverage asked of a source that touches no pixel of some art island is refused, never met by deleting art. */
1185
+ function checkIslands(input: MeshReductionInput, who: string): void {
1186
+ if (input.targets.artFit.minCoverage !== 1) return;
1187
+ const { mask, threshold, frame } = input.art;
1188
+ const bits = artOf(mask, threshold);
1189
+ const { label, sizes } = labelIslands(bits, mask.width, mask.height);
1190
+ const onGrid = input.source.points.map(([x, y]): [number, number] => [x * frame.pageScale, y * frame.pageScale]);
1191
+ const covered = rasteriseTriangles(onGrid, input.source.triangles, mask.width, mask.height);
1192
+ const touched = new Set<number>();
1193
+ for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
1194
+ for (let island = 1; island <= sizes.length; island++) {
1195
+ if (touched.has(island) || sizes[island - 1] === 0) continue;
1196
+ const at = label.indexOf(island);
1197
+ throw new Stop(
1198
+ 'invalid-input',
1199
+ 'REDUCE_ISLAND_UNREACHED',
1200
+ `${who}: the art island of ${sizes[island - 1]} pixel(s) at (${at % mask.width}, ${Math.floor(at / mask.width)}) has no pixel the source covers, and targets.artFit.minCoverage is 1; required a source that reaches every island when full coverage is asked — art is never deleted to meet a bound (§4)`,
1201
+ );
1202
+ }
1203
+ }
1204
+
1205
+ /** The vertices no step may remove and the source edges every result must keep (§6, conditions (a) and (b)). */
1206
+ function protectionOf(input: MeshReductionInput, work: Work, sourceEdges: Set<string>): { protectedVertices: Set<number>; protectedEdges: Array<[number, number]> } {
1207
+ const p = input.protect;
1208
+ const vertices = new Set<number>(p.vertices);
1209
+ if (p.hull) for (let v = 0; v < input.source.hull; v++) vertices.add(v);
1210
+ const edges: Array<[number, number]> = p.edges.map(([a, b]): [number, number] => [a, b]);
1211
+ if (p.weightJump !== null) {
1212
+ for (const key of [...sourceEdges].sort()) {
1213
+ const [a, b] = key.split(',').map(Number);
1214
+ if (weightJump(work, a, b) > p.weightJump) edges.push([a, b]);
1215
+ }
1216
+ }
1217
+ for (const [a, b] of edges) {
1218
+ vertices.add(a);
1219
+ vertices.add(b);
1220
+ }
1221
+ for (const name of p.regionBoundaries) {
1222
+ const region = input.targets.regions.find((r) => r.name === name)!;
1223
+ input.source.points.forEach((pt, v) => {
1224
+ if (distanceToBoundary(pt, region.polygon) <= ON_BOUNDARY) vertices.add(v);
1225
+ });
1226
+ }
1227
+ return { protectedVertices: vertices, protectedEdges: edges };
1228
+ }
1229
+
1230
+ /** The result, canonical, with its deform keys carried and its own measurement as the report's one candidate. */
1231
+ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts | null, termination: Termination): MeshReductionResult {
1232
+ const { input, work } = run;
1233
+ const canon = canonicalise(work, run.sourceTurn, run.boneRank);
1234
+ const indexMap: Array<number | null> = [];
1235
+ for (let v = 0; v < work.nSource; v++) indexMap.push(work.alive[v] ? canon.indexOf.get(v)! : null);
1236
+ const inserted = [...canon.indexOf.entries()].filter(([id]) => id >= work.nSource).map(([, r]) => r).sort((a, b) => a - b);
1237
+ let deform: { remapped: RemappedDeformKey[]; reevaluated: DeformKeyRef[] };
1238
+ try {
1239
+ deform = remapDeform(input, canon.mesh, indexMap);
1240
+ } catch (err) {
1241
+ if (err instanceof Stop) {
1242
+ return { mesh: null, report: reportOf(effective, sourceCounts, { id: 'result', counts: null, geometry: null, motion: null, accepted: false }, { reason: err.reason, code: err.code, detail: err.detail }) };
1243
+ }
1244
+ throw err;
1245
+ }
1246
+ const measured = measureAgainstTargets(run, canon.mesh, 'result');
1247
+ const candidate = measured.candidates[0];
1248
+ const changes: ReductionChanges = {
1249
+ removedVertices: indexMap.filter((r) => r === null).length,
1250
+ insertedVertices: inserted.length,
1251
+ sharesDroppedOnGrid: run.tally.sharesDroppedOnGrid,
1252
+ sharesPruned: run.tally.sharesPruned,
1253
+ deformRemapped: deform.remapped.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key, droppedVertices: d.droppedVertices })),
1254
+ deformReevaluated: deform.reevaluated.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key })),
1255
+ linkedMeshes: input.linkedMeshes,
1256
+ };
1257
+ const counts = candidate.counts!;
1258
+ const mesh: ReducedMesh = {
1259
+ ...canon.mesh,
1260
+ edges: meshEdges(canon.mesh.points.length, canon.mesh.triangles, canon.mesh.hull),
1261
+ indexMap,
1262
+ inserted,
1263
+ linkedMeshes: input.linkedMeshes,
1264
+ deform,
1265
+ counts,
1266
+ };
1267
+ return { mesh, report: reportOf(effective, sourceCounts, { ...candidate, changes }, termination) };
1268
+ }
1269
+
1270
+ function reportOf(effective: EffectiveSettings, sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination): MeshQualityReport {
1271
+ return {
1272
+ spec: 'mesh-quality-report/1',
1273
+ operation: 'reduce',
1274
+ effective,
1275
+ poser: null,
1276
+ motionRequired: false,
1277
+ sourceCounts,
1278
+ reference: null,
1279
+ candidates: [candidate],
1280
+ termination,
1281
+ };
1282
+ }
1283
+
1284
+ /** Correction 1: the reduction's inputs echoed with their structure — the measurement's echo, with what a reduction adds. */
1285
+ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sourceHull: Array<[number, number]>): EffectiveSettings {
1286
+ const fit = (f: ArtFitBounds): ArtFitBounds => ({ minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
1287
+ return {
1288
+ ...measured,
1289
+ sourceBounds: fit(input.sourceBounds),
1290
+ targets: input.targets,
1291
+ referenceHull: sourceHull,
1292
+ budget: { maxCandidates: input.budget.maxCandidates },
1293
+ };
1294
+ }