spine-rigc 2.25.0 → 2.27.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,693 @@
1
+ /**
2
+ * Where a mesh's vertices sit, read as five `undeclared` rows of
3
+ * `mesh-quality-report/1` (issue #1280, Stage B of #1271): docs/MESH_REDUCTION.md
4
+ * §8, *Measured — spatial measurements of allocation*, gives each definition
5
+ * and the fixtures it separates; this module computes them for
6
+ * `measureMeshQuality` (`src/meshquality.ts`), which turns them into rows.
7
+ *
8
+ * - `MQ_GRADE` — the largest |h_u − h_v| / L over the mesh's edges, h a
9
+ * vertex's mean incident edge length: an uncontrolled transition, dense
10
+ * beside sparse, whether or not the density is justified.
11
+ * - `MQ_MIN_ANGLE_P10` — the tenth percentile of the triangles' smallest
12
+ * angles: the sliver fans a dense boundary leaves against an emptied interior.
13
+ * - `MQ_ALLOCATION_CONTRAST` — Δ, the share of the dense vertices removable
14
+ * alone by a half-edge collapse judged against declared needs only, minus
15
+ * that share among the rest; economy E, the share of all, beside it.
16
+ * - `MQ_DEFORM_LOAD` — the largest L · Δshare · θ / 4 over the edges: the chord
17
+ * error bound of the mesh's own weight field under the declared amplitude.
18
+ * A location reading; it is not predicted motion (§8's correction).
19
+ * - `MQ_BOUNDARY_NECESSARY` — B\*, the fewest vertices of the reference hull a
20
+ * closed outline can keep with every skipped run within the declared
21
+ * deviation of its chord both ways and no art pixel centre cut off.
22
+ *
23
+ * None of them takes a bound: there is no field to declare one in, so every
24
+ * row is `undeclared` when measured and `not-measurable`, with a reason
25
+ * naming what is missing, when not. None is required, so none can block a
26
+ * step, refuse a source or satisfy acceptance.
27
+ *
28
+ * Pure, as `src/meshquality.ts` is: no clock, no randomness, no runtime. Every
29
+ * loop runs in index order and every sort breaks its ties by index, so one
30
+ * input reads one answer (A18's standard).
31
+ *
32
+ * ⚠️ This module and `src/meshquality.ts` import each other. As with
33
+ * `src/mesh.ts`, it is safe only because neither reads the other's bindings at
34
+ * the top level: every use is inside a function.
35
+ */
36
+ import { distanceToSegment, r6, type AlphaMask } from './mesh.ts';
37
+ import { closedPolygonCentres, inClosedPolygon, regionEdgeBound, type MotionAmplitude, type RefinementRegion } from './meshquality.ts';
38
+
39
+ type Pt = readonly [number, number];
40
+ type Weights = ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>>;
41
+
42
+ /**
43
+ * A fault planted on purpose, for the `mesh-quality` suite's negative controls
44
+ * (MQ120 onward). `measureMeshQuality` plants none.
45
+ *
46
+ * - `omit-rows`: no allocation row at all — the report as it was before #1280,
47
+ * the baseline the opt-out identity is held to.
48
+ * - `grade-undivided`: |h_u − h_v| not divided by L.
49
+ * - `p10-is-min`: the minimum angle reported as the tenth percentile.
50
+ * - `contrast-without-amplitude`: Δ measured with no declared amplitude, the
51
+ * reading §8 rejected (the deformation need is then absent).
52
+ * - `load-without-theta`: the load reported as L · Δshare / 4, θ dropped.
53
+ * - `undeclared-pair-is-rigid`: a pair of bones no track declares read as θ 0.
54
+ * - `bstar-ignores-art`: B\* without the art test.
55
+ * - `rows-required`: the rows counted as required.
56
+ * - `mutates-input`: the measurement sorts the caller's triangle list in place.
57
+ */
58
+ export type AllocationPlant =
59
+ | 'omit-rows'
60
+ | 'grade-undivided'
61
+ | 'p10-is-min'
62
+ | 'contrast-without-amplitude'
63
+ | 'load-without-theta'
64
+ | 'undeclared-pair-is-rigid'
65
+ | 'bstar-ignores-art'
66
+ | 'rows-required'
67
+ | 'mutates-input';
68
+
69
+ /** The art as the allocation rows read it: art pixels on the mask's grid, and the scale that grid is at. */
70
+ export interface AllocationArt {
71
+ mask: AlphaMask;
72
+ /** 1 where the pixel is art (`alpha >= threshold`). */
73
+ bits: Uint8Array;
74
+ /** `pageScale`: texels = drawing px × scale. */
75
+ scale: number;
76
+ }
77
+
78
+ /** A measured reading, or the sentence saying why there is none. */
79
+ export type Reading<T> = { measured: true; value: T } | { measured: false; reason: string };
80
+
81
+ const unmeasured = (reason: string): { measured: false; reason: string } => ({ measured: false, reason });
82
+
83
+ /** The mesh's unique edges, `[low, high]`, sorted — each encoded as low × n + high and sorted as numbers. */
84
+ function uniqueEdges(triangles: readonly number[]): Array<[number, number]> {
85
+ let n = 0;
86
+ for (const v of triangles) n = Math.max(n, v + 1);
87
+ const seen = new Set<number>();
88
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
89
+ for (let k = 0; k < 3; k++) {
90
+ const a = triangles[t + k];
91
+ const b = triangles[t + ((k + 1) % 3)];
92
+ seen.add(Math.min(a, b) * n + Math.max(a, b));
93
+ }
94
+ }
95
+ const keys = Float64Array.from(seen).sort();
96
+ const out: Array<[number, number]> = [];
97
+ for (const key of keys) out.push([Math.floor(key / n), key % n]);
98
+ return out;
99
+ }
100
+
101
+ const lengthOf = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
102
+
103
+ /**
104
+ * Nearest rank at index round((n − 1) · p) of the values sorted ascending, ties by index — §8's percentile rule —
105
+ * as the index of the value at that rank: the values sorted as numbers, then, among the indices holding the value
106
+ * found, the one the rank falls on in ascending index order.
107
+ */
108
+ function rankOf(values: readonly number[], p: number): number {
109
+ const sorted = Float64Array.from(values).sort();
110
+ const rank = Math.round((sorted.length - 1) * p);
111
+ const value = sorted[rank];
112
+ let below = 0;
113
+ while (below < rank && sorted[below] < value) below++;
114
+ let seen = 0;
115
+ for (let i = 0; i < values.length; i++) {
116
+ if (values[i] !== value) continue;
117
+ if (seen === rank - below) return i;
118
+ seen++;
119
+ }
120
+ return values.indexOf(value);
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // MQ_GRADE
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** The largest |h_u − h_v| / L, and the edge it is taken on (the first in sorted order on a tie). */
128
+ export function gradeMax(points: readonly Pt[], triangles: readonly number[], plant: AllocationPlant | null): Reading<{ value: number; edge: [number, number] }> {
129
+ const edges = uniqueEdges(triangles);
130
+ const sum = new Array<number>(points.length).fill(0);
131
+ const count = new Array<number>(points.length).fill(0);
132
+ const lengths: number[] = [];
133
+ for (const [u, v] of edges) {
134
+ const L = lengthOf(points[u], points[v]);
135
+ if (!(L > 0)) return unmeasured(`edge [${u}, ${v}] has zero length, so |h_u − h_v| / L has no value on it`);
136
+ lengths.push(L);
137
+ sum[u] += L;
138
+ sum[v] += L;
139
+ count[u]++;
140
+ count[v]++;
141
+ }
142
+ if (edges.length === 0) return unmeasured('the mesh has no edge');
143
+ const h = sum.map((s, i) => (count[i] > 0 ? s / count[i] : 0));
144
+ let best = -1;
145
+ let at = edges[0];
146
+ edges.forEach(([u, v], i) => {
147
+ const g = plant === 'grade-undivided' ? Math.abs(h[u] - h[v]) : Math.abs(h[u] - h[v]) / lengths[i];
148
+ if (g > best) {
149
+ best = g;
150
+ at = [u, v];
151
+ }
152
+ });
153
+ return { measured: true, value: { value: r6(best), edge: at } };
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // MQ_MIN_ANGLE_P10
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /** Each triangle's smallest interior angle, degrees — `MQ_MIN_ANGLE`'s own formula. */
161
+ export function smallestAngles(points: readonly Pt[], triangles: readonly number[]): number[] {
162
+ const out: number[] = [];
163
+ for (let t = 0; t * 3 + 2 < triangles.length; t++) {
164
+ const corner = [points[triangles[t * 3]], points[triangles[t * 3 + 1]], points[triangles[t * 3 + 2]]];
165
+ let smallest = Infinity;
166
+ for (let k = 0; k < 3; k++) {
167
+ const o = corner[k];
168
+ const p = corner[(k + 1) % 3];
169
+ const q = corner[(k + 2) % 3];
170
+ const ux = p[0] - o[0];
171
+ const uy = p[1] - o[1];
172
+ const vx = q[0] - o[0];
173
+ const vy = q[1] - o[1];
174
+ smallest = Math.min(smallest, (Math.atan2(Math.abs(ux * vy - uy * vx), ux * vx + uy * vy) * 180) / Math.PI);
175
+ }
176
+ out.push(smallest);
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** The tenth percentile of the triangles' smallest angles, and the triangle it is read at. */
182
+ export function minAngleP10(points: readonly Pt[], triangles: readonly number[], plant: AllocationPlant | null): Reading<{ value: number; triangle: number }> {
183
+ const angles = smallestAngles(points, triangles);
184
+ if (angles.length === 0) return unmeasured('the mesh has no triangle');
185
+ const t = rankOf(angles, plant === 'p10-is-min' ? 0 : 0.1);
186
+ return { measured: true, value: { value: r6(angles[t]), triangle: t } };
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // the declared amplitude, per pair of bones
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** Half the L1 distance between two weight vectors: the share of weight that moves across an edge. */
194
+ export function shareJump(a: ReadonlyArray<{ bone: string; weight: number }>, b: ReadonlyArray<{ bone: string; weight: number }>): number {
195
+ const d = deltaOf(a, b);
196
+ let s = 0;
197
+ for (const bone of [...d.keys()].sort()) s += Math.abs(d.get(bone)!);
198
+ return s / 2;
199
+ }
200
+
201
+ function deltaOf(a: ReadonlyArray<{ bone: string; weight: number }>, b: ReadonlyArray<{ bone: string; weight: number }>): Map<string, number> {
202
+ const m = new Map<string, number>();
203
+ for (const x of a) m.set(x.bone, (m.get(x.bone) ?? 0) + x.weight);
204
+ for (const x of b) m.set(x.bone, (m.get(x.bone) ?? 0) - x.weight);
205
+ return m;
206
+ }
207
+
208
+ const pairKey = (p: string, q: string): string => (p < q ? `${p}\u0000${q}` : `${q}\u0000${p}`);
209
+
210
+ /**
211
+ * The declared amplitude read per pair of bones: the largest θ, and the
212
+ * largest θ / ε, any track declares for the pair. A share moving across an
213
+ * edge from bones that lose it to bones that gain it is a flow between such
214
+ * pairs, and |Σ Δw_b R_b| ≤ Σ flow · θ_pair ≤ Δshare · max θ_pair — so the
215
+ * largest over the pairs an edge moves a share between bounds it.
216
+ */
217
+ export class PairAmplitudes {
218
+ private readonly theta = new Map<string, number>();
219
+ private readonly ratio = new Map<string, number>();
220
+ /** The first pair a share moved between that no track declares, in the order the measurement met it. */
221
+ missing: { bones: [string, string]; edge: [number, number] } | null = null;
222
+
223
+ constructor(
224
+ declared: MotionAmplitude,
225
+ private readonly plant: AllocationPlant | null,
226
+ ) {
227
+ for (const track of declared.tracks) {
228
+ for (const pair of track.pairs) {
229
+ const key = pairKey(pair.bones[0], pair.bones[1]);
230
+ this.theta.set(key, Math.max(this.theta.get(key) ?? 0, pair.theta));
231
+ this.ratio.set(key, Math.max(this.ratio.get(key) ?? 0, pair.theta / track.epsilon));
232
+ }
233
+ }
234
+ }
235
+
236
+ /** The largest declared θ and θ / ε over the pairs a share moves between across `a`→`b`; null when a pair is undeclared. */
237
+ across(a: ReadonlyArray<{ bone: string; weight: number }>, b: ReadonlyArray<{ bone: string; weight: number }>, edge: [number, number]): { theta: number; ratio: number } | null {
238
+ const d = deltaOf(a, b);
239
+ const bones = [...d.keys()].sort();
240
+ const gain = bones.filter((x) => d.get(x)! > 0);
241
+ const loss = bones.filter((x) => d.get(x)! < 0);
242
+ let theta = 0;
243
+ let ratio = 0;
244
+ for (const g of gain) {
245
+ for (const l of loss) {
246
+ const key = pairKey(g, l);
247
+ const t = this.theta.get(key);
248
+ if (t === undefined) {
249
+ if (this.plant === 'undeclared-pair-is-rigid') continue;
250
+ if (this.missing === null) this.missing = { bones: g < l ? [g, l] : [l, g], edge };
251
+ return null;
252
+ }
253
+ theta = Math.max(theta, t);
254
+ ratio = Math.max(ratio, this.ratio.get(key)!);
255
+ }
256
+ }
257
+ return { theta, ratio };
258
+ }
259
+ }
260
+
261
+ function undeclaredPairReason(missing: NonNullable<PairAmplitudes['missing']>): string {
262
+ return (
263
+ `motionAmplitude declares no track moving bones "${missing.bones[0]}" and "${missing.bones[1]}", and a share moves between them across ` +
264
+ `[${missing.edge[0]}, ${missing.edge[1]}]; required a { bones, theta } entry for every pair of bones a share moves between — no amplitude is assumed`
265
+ );
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // MQ_DEFORM_LOAD
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** The largest L · Δshare · θ / 4 over the edges, px, and the edge; `{ edge: null }` when every edge carries none. */
273
+ export function deformLoad(points: readonly Pt[], triangles: readonly number[], weights: Weights, declared: MotionAmplitude, plant: AllocationPlant | null): Reading<{ value: number; edge: [number, number] | null }> {
274
+ const pairs = new PairAmplitudes(declared, plant);
275
+ let best = 0;
276
+ let at: [number, number] | null = null;
277
+ for (const [u, v] of uniqueEdges(triangles)) {
278
+ const amp = pairs.across(weights[u], weights[v], [u, v]);
279
+ if (amp === null) return unmeasured(undeclaredPairReason(pairs.missing!));
280
+ const theta = plant === 'load-without-theta' ? 1 : amp.theta;
281
+ const load = (lengthOf(points[u], points[v]) * shareJump(weights[u], weights[v]) * theta) / 4;
282
+ if (load > best) {
283
+ best = load;
284
+ at = [u, v];
285
+ }
286
+ }
287
+ return { measured: true, value: { value: r6(best), edge: at } };
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // MQ_BOUNDARY_NECESSARY — B*
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /** How close the search along a chord brings a piece before it stops, px: `HAUSDORFF_TOLERANCE`'s figure. */
295
+ const CHORD_TOLERANCE = 1e-9;
296
+ /** On the chord within this, px, a pixel centre is not cut off by it — `ON_BOUNDARY`'s figure. */
297
+ const ON_CHORD = 1e-9;
298
+
299
+ /**
300
+ * Is the furthest point of the chord `a`–`b` from the open polyline `run`
301
+ * more than `delta`? Exact up to `CHORD_TOLERANCE`, by the branch and bound
302
+ * `directedHausdorff` (`src/meshquality.ts`) uses: the distance to one segment
303
+ * is convex along the chord, so on a piece it is at most the larger of its end
304
+ * values, and the distance to the run is the minimum over its segments.
305
+ */
306
+ function chordLeavesRun(a: Pt, b: Pt, run: readonly Pt[], delta: number): boolean {
307
+ const m = run.length - 1;
308
+ const toEach = (p: Pt): Float64Array => {
309
+ const out = new Float64Array(m);
310
+ for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, run[j], run[j + 1]);
311
+ return out;
312
+ };
313
+ const minOf = (g: Float64Array): number => {
314
+ let v = Infinity;
315
+ for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
316
+ return v;
317
+ };
318
+ const at = (t: number): Pt => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
319
+ const g0 = toEach(a);
320
+ const g1 = toEach(b);
321
+ if (minOf(g0) > delta || minOf(g1) > delta) return true;
322
+ const stack: Array<{ t0: number; t1: number; g0: Float64Array; g1: Float64Array; depth: number }> = [{ t0: 0, t1: 1, g0, g1, depth: 0 }];
323
+ while (stack.length > 0) {
324
+ const piece = stack.pop()!;
325
+ let upper = Infinity;
326
+ for (let j = 0; j < m; j++) upper = Math.min(upper, Math.max(piece.g0[j], piece.g1[j]));
327
+ if (upper <= delta + CHORD_TOLERANCE || piece.depth >= 64) continue;
328
+ const tm = (piece.t0 + piece.t1) / 2;
329
+ const gm = toEach(at(tm));
330
+ if (minOf(gm) > delta) return true;
331
+ stack.push({ t0: piece.t0, t1: tm, g0: piece.g0, g1: gm, depth: piece.depth + 1 });
332
+ stack.push({ t0: tm, t1: piece.t1, g0: gm, g1: piece.g1, depth: piece.depth + 1 });
333
+ }
334
+ return false;
335
+ }
336
+
337
+ /**
338
+ * Whether the outline `hull` may skip forward from `hull[i]` to `hull[j]`:
339
+ * every skipped vertex within `delta` of the chord, the chord within `delta`
340
+ * of the skipped run (both exact), and — unless planted out — no art pixel
341
+ * centre inside the hull and strictly inside the sliver the chord cuts off
342
+ * (even-odd over the run closed by the chord; a centre on the chord is kept).
343
+ */
344
+ export function shortcutTest(hull: readonly Pt[], delta: number, art: AllocationArt, plant: AllocationPlant | null): { valid: (i: number, j: number) => boolean; tests: () => number } {
345
+ const n = hull.length;
346
+ const { mask, bits, scale } = art;
347
+ const w = mask.width;
348
+ const h = mask.height;
349
+ let inside: Uint8Array | null = null;
350
+ let tests = 0;
351
+ const valid = (i: number, j: number): boolean => {
352
+ const k = (j - i + n) % n;
353
+ if (k < 2) return true;
354
+ tests++;
355
+ const run: Pt[] = [];
356
+ for (let s = 0; s <= k; s++) run.push(hull[(i + s) % n]);
357
+ const a = hull[i];
358
+ const b = hull[j];
359
+ for (let s = 1; s < k; s++) if (distanceToSegment(run[s], a, b) > delta) return false;
360
+ if (chordLeavesRun(a, b, run, delta)) return false;
361
+ if (plant === 'bstar-ignores-art') return true;
362
+ if (inside === null) inside = closedPolygonCentres(hull, w, h, scale);
363
+ let x0 = Infinity;
364
+ let y0 = Infinity;
365
+ let x1 = -Infinity;
366
+ let y1 = -Infinity;
367
+ for (const p of run) {
368
+ x0 = Math.min(x0, p[0]);
369
+ y0 = Math.min(y0, p[1]);
370
+ x1 = Math.max(x1, p[0]);
371
+ y1 = Math.max(y1, p[1]);
372
+ }
373
+ const gy0 = Math.max(0, Math.floor(y0 * scale) - 1);
374
+ const gy1 = Math.min(h - 1, Math.ceil(y1 * scale));
375
+ const gx0 = Math.max(0, Math.floor(x0 * scale) - 1);
376
+ const gx1 = Math.min(w - 1, Math.ceil(x1 * scale));
377
+ for (let y = gy0; y <= gy1; y++) {
378
+ for (let x = gx0; x <= gx1; x++) {
379
+ const cell = y * w + x;
380
+ if (!inside[cell] || !bits[cell]) continue;
381
+ const c: Pt = [(x + 0.5) / scale, (y + 0.5) / scale];
382
+ if (distanceToSegment(c, a, b) <= ON_CHORD) continue;
383
+ let odd = false;
384
+ for (let p = 0, q = run.length - 1; p < run.length; q = p++) {
385
+ const [xp, yp] = run[p];
386
+ const [xq, yq] = run[q];
387
+ if (yp > c[1] !== yq > c[1] && c[0] < ((xq - xp) * (c[1] - yp)) / (yq - yp) + xp) odd = !odd;
388
+ }
389
+ if (odd) return false;
390
+ }
391
+ }
392
+ return true;
393
+ };
394
+ return { valid, tests: () => tests };
395
+ }
396
+
397
+ /** B\*, its outline (indices into the hull, in walk order) and the work the search took. */
398
+ export interface BoundaryNecessity {
399
+ count: number;
400
+ path: number[];
401
+ /** Shortcuts tested; at most n × (n − 2) for n hull vertices — every forward skip of 2 or more from every vertex, once. */
402
+ tests: number;
403
+ /** n × (n − 2): the bound `tests` is held to. */
404
+ bound: number;
405
+ }
406
+
407
+ /**
408
+ * B\*: the fewest vertices of `hull` a closed outline of at least three of
409
+ * them can keep, in order, when each skip is a valid shortcut. Every forward
410
+ * shortcut from every vertex is tested once (n × (n − 2) tests at most), then
411
+ * a breadth-first walk round the loop from every start, its hop count capped
412
+ * at 3 so a two-vertex "outline" is never the answer. Ties: the lowest start,
413
+ * then the first path found in ascending offsets.
414
+ */
415
+ export function boundaryNecessity(hull: readonly Pt[], delta: number, art: AllocationArt, plant: AllocationPlant | null): BoundaryNecessity {
416
+ const n = hull.length;
417
+ const shortcut = shortcutTest(hull, delta, art, plant);
418
+ const adj: number[][] = [];
419
+ for (let i = 0; i < n; i++) {
420
+ const out: number[] = [];
421
+ for (let k = 1; k < n; k++) if (shortcut.valid(i, (i + k) % n)) out.push(k);
422
+ adj.push(out);
423
+ }
424
+ let best = n;
425
+ let bestPath = hull.map((_, i) => i);
426
+ // States: (offset, hops capped at 3). dist[off * 4 + c].
427
+ for (let s = 0; s < n; s++) {
428
+ const dist = new Array<number>((n + 1) * 4).fill(Infinity);
429
+ const prev = new Array<number>((n + 1) * 4).fill(-1);
430
+ dist[0] = 0;
431
+ for (let off = 0; off < n; off++) {
432
+ for (let c = 0; c < 4; c++) {
433
+ const here = dist[off * 4 + c];
434
+ if (here === Infinity) continue;
435
+ for (const k of adj[(s + off) % n]) {
436
+ if (off + k > n) continue;
437
+ const nc = Math.min(c + 1, 3);
438
+ const to = (off + k) * 4 + nc;
439
+ if (here + 1 < dist[to]) {
440
+ dist[to] = here + 1;
441
+ prev[to] = off * 4 + c;
442
+ }
443
+ }
444
+ }
445
+ }
446
+ const end = n * 4 + 3;
447
+ if (dist[end] < best) {
448
+ best = dist[end];
449
+ const path: number[] = [];
450
+ for (let st = prev[end]; st > 0; st = prev[st]) path.push((s + Math.floor(st / 4)) % n);
451
+ path.push(s);
452
+ bestPath = path.reverse();
453
+ }
454
+ }
455
+ return { count: best, path: bestPath, tests: shortcut.tests(), bound: n * (n - 2) };
456
+ }
457
+
458
+ /**
459
+ * B\* for one hull, delta and art, once per art rasters object: `reduceMesh`
460
+ * measures every step against the same source hull, so its B\* is a function
461
+ * of the call and is computed once for it. Keyed by the hull's coordinates and
462
+ * the deviation; the plant is part of the key.
463
+ */
464
+ const necessityMemo = new WeakMap<object, Map<string, BoundaryNecessity>>();
465
+
466
+ export function boundaryNecessityOnce(owner: object, hull: readonly Pt[], delta: number, art: AllocationArt, plant: AllocationPlant | null): BoundaryNecessity {
467
+ let memo = necessityMemo.get(owner);
468
+ if (memo === undefined) {
469
+ memo = new Map();
470
+ necessityMemo.set(owner, memo);
471
+ }
472
+ const key = `${plant ?? ''}|${delta}|${hull.map((p) => `${p[0]},${p[1]}`).join(';')}`;
473
+ let found = memo.get(key);
474
+ if (found === undefined) {
475
+ found = boundaryNecessity(hull, delta, art, plant);
476
+ memo.set(key, found);
477
+ }
478
+ return found;
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // MQ_ALLOCATION_CONTRAST — Δ and E
483
+ // ---------------------------------------------------------------------------
484
+
485
+ export interface AllocationContrast {
486
+ /** Δ: removable-alone share among the dense vertices minus that share among the rest. */
487
+ contrast: number;
488
+ /** E: the removable-alone share over every vertex. */
489
+ economy: number;
490
+ dense: { vertices: number; removableAlone: number };
491
+ rest: { vertices: number; removableAlone: number };
492
+ /** The dense vertex with the lowest ν (ties: the lowest index). */
493
+ worstVertex: number;
494
+ }
495
+
496
+ /** h\*: the largest local size every primary need allows at a point, each relaxed at gradation G away from its source. */
497
+ interface Need {
498
+ at(p: Pt): number;
499
+ }
500
+
501
+ function needField(points: readonly Pt[], triangles: readonly number[], weights: Weights, silhouette: number[] | null, regions: readonly RefinementRegion[], pairs: PairAmplitudes, gradation: number): Need | null {
502
+ const sources: Array<{ a: Pt; b: Pt; h: number }> = [];
503
+ if (silhouette !== null && silhouette.length >= 3) {
504
+ for (let i = 0; i < silhouette.length; i++) {
505
+ const a = points[silhouette[i]];
506
+ const b = points[silhouette[(i + 1) % silhouette.length]];
507
+ sources.push({ a, b, h: lengthOf(a, b) });
508
+ }
509
+ }
510
+ const bones = [...new Set(weights.flatMap((v) => v.map((x) => x.bone)))].sort();
511
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
512
+ const [i, j, k] = [triangles[t], triangles[t + 1], triangles[t + 2]];
513
+ const [A, B, C] = [points[i], points[j], points[k]];
514
+ const det = (B[0] - A[0]) * (C[1] - A[1]) - (C[0] - A[0]) * (B[1] - A[1]);
515
+ if (Math.abs(det) < 1e-12) continue;
516
+ let tv = 0;
517
+ for (const bone of bones) {
518
+ const wt = (v: number): number => weights[v].find((x) => x.bone === bone)?.weight ?? 0;
519
+ const [wa, wb, wc] = [wt(i), wt(j), wt(k)];
520
+ const gx = ((wb - wa) * (C[1] - A[1]) - (wc - wa) * (B[1] - A[1])) / det;
521
+ const gy = ((wc - wa) * (B[0] - A[0]) - (wb - wa) * (C[0] - A[0])) / det;
522
+ tv += Math.hypot(gx, gy);
523
+ }
524
+ tv /= 2;
525
+ if (tv <= 0) continue;
526
+ let ratio = 0;
527
+ for (const [u, v] of [
528
+ [i, j],
529
+ [j, k],
530
+ [k, i],
531
+ ] as const) {
532
+ const amp = pairs.across(weights[u], weights[v], [Math.min(u, v), Math.max(u, v)]);
533
+ if (amp === null) return null;
534
+ ratio = Math.max(ratio, amp.ratio);
535
+ }
536
+ if (ratio <= 0) continue;
537
+ const c: Pt = [(A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3];
538
+ sources.push({ a: c, b: c, h: Math.sqrt(4 / (ratio * tv)) });
539
+ }
540
+ return {
541
+ at(p: Pt): number {
542
+ let need = Infinity;
543
+ for (const s of sources) need = Math.min(need, s.h + gradation * distanceToSegment(p, s.a, s.b));
544
+ for (const r of regions) {
545
+ const poly = r.polygon;
546
+ let d = 0;
547
+ if (!inClosedPolygon(p, poly)) {
548
+ d = Infinity;
549
+ for (let q = 0; q < poly.length; q++) d = Math.min(d, distanceToSegment(p, poly[q], poly[(q + 1) % poly.length]));
550
+ }
551
+ const here = d === 0 ? r.maxEdgeLength : r.transition > 0 && d <= r.transition ? r.maxEdgeLength + r.grade * d : Infinity;
552
+ need = Math.min(need, here);
553
+ }
554
+ return need;
555
+ },
556
+ };
557
+ }
558
+
559
+ const twiceSigned = (a: Pt, b: Pt, c: Pt): number => (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
560
+
561
+ /**
562
+ * Δ and E (§8; the stage-A record for #1271, M7). For each vertex the
563
+ * cheapest half-edge collapse into a neighbour — a hull vertex only along the
564
+ * hull — that turns no triangle over; ν is the largest, over what that
565
+ * collapse changes, of: the silhouette (the moved outline's chord distance
566
+ * over δ, and the skip must pass the art test), the deformation (each new
567
+ * edge's L · Δshare · θ / (4ε)), the region (each new edge over the bound its
568
+ * region holds it to, `regionEdgeBound`) and the need (each new edge over h\*
569
+ * at its midpoint). A ratio already over 1 on the edge it replaces counts only
570
+ * by how much it worsens it. ν < 1 is removable alone. Dense vertices are
571
+ * those whose mean neighbour distance (on the `r6` grid, so the class is not
572
+ * a summation order's) is below the area-weighted median of it.
573
+ */
574
+ export function allocationContrast(
575
+ points: readonly Pt[],
576
+ triangles: readonly number[],
577
+ hull: number,
578
+ weights: Weights,
579
+ delta: number,
580
+ art: AllocationArt,
581
+ regions: readonly RefinementRegion[],
582
+ declared: MotionAmplitude,
583
+ silhouette: number[],
584
+ plant: AllocationPlant | null,
585
+ ): Reading<AllocationContrast> {
586
+ const n = points.length;
587
+ const pairs = new PairAmplitudes(declared, plant);
588
+ const amplitudeRead = plant !== 'contrast-without-amplitude';
589
+ const need = needField(points, triangles, weights, silhouette, regions, amplitudeRead ? pairs : new PairAmplitudes({ tracks: [], gradation: declared.gradation }, 'undeclared-pair-is-rigid'), declared.gradation);
590
+ if (need === null) return unmeasured(undeclaredPairReason(pairs.missing!));
591
+ const nb: Array<Set<number>> = Array.from({ length: n }, () => new Set<number>());
592
+ const triOf: number[][] = Array.from({ length: n }, () => []);
593
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
594
+ for (let k = 0; k < 3; k++) {
595
+ const a = triangles[t + k];
596
+ nb[a].add(triangles[t + ((k + 1) % 3)]);
597
+ nb[a].add(triangles[t + ((k + 2) % 3)]);
598
+ triOf[a].push(t);
599
+ }
600
+ }
601
+ const neighbours = nb.map((s) => [...s].sort((p, q) => p - q));
602
+ const hullPoints = points.slice(0, hull);
603
+ const shortcut = shortcutTest(hullPoints, delta, art, null);
604
+ const adjacentOnHull = (v: number, w: number): boolean => w < hull && (Math.abs(w - v) === 1 || Math.abs(w - v) === hull - 1);
605
+ const term = (now: number, before: number): number => (now <= 1 ? now : now / Math.max(1, before));
606
+ const nu = new Array<number>(n).fill(Infinity);
607
+ for (let v = 0; v < n; v++) {
608
+ const isHull = v < hull;
609
+ for (const w of neighbours[v]) {
610
+ if (isHull && !adjacentOnHull(v, w)) continue;
611
+ let folds = false;
612
+ for (const t of triOf[v]) {
613
+ const tri = [triangles[t], triangles[t + 1], triangles[t + 2]];
614
+ if (tri.includes(w)) continue;
615
+ const before = twiceSigned(points[tri[0]], points[tri[1]], points[tri[2]]);
616
+ const moved = tri.map((i) => (i === v ? points[w] : points[i]));
617
+ const after = twiceSigned(moved[0], moved[1], moved[2]);
618
+ if (Math.sign(after) !== Math.sign(before) || Math.abs(after) < 1e-9) {
619
+ folds = true;
620
+ break;
621
+ }
622
+ }
623
+ if (folds) continue;
624
+ let worst = 0;
625
+ if (isHull) {
626
+ const other = v === (w + 1) % hull ? (v + 1) % hull : (v - 1 + hull) % hull;
627
+ worst = Math.max(worst, distanceToSegment(points[v], points[w], points[other]) / delta);
628
+ const [from, to] = other === (v + 1) % hull ? [w, other] : [other, w];
629
+ if (!shortcut.valid(from, to)) worst = Math.max(worst, 1);
630
+ }
631
+ for (const u of neighbours[v]) {
632
+ if (u === w) continue;
633
+ const a = points[w];
634
+ const b = points[u];
635
+ const L = lengthOf(a, b);
636
+ const L0 = lengthOf(points[v], b);
637
+ if (amplitudeRead) {
638
+ const now = pairs.across(weights[w], weights[u], [Math.min(w, u), Math.max(w, u)]);
639
+ const was = pairs.across(weights[v], weights[u], [Math.min(v, u), Math.max(v, u)]);
640
+ if (now === null || was === null) return unmeasured(undeclaredPairReason(pairs.missing!));
641
+ worst = Math.max(worst, term((L * shareJump(weights[w], weights[u]) * now.ratio) / 4, (L0 * shareJump(weights[v], weights[u]) * was.ratio) / 4));
642
+ }
643
+ const rb = regionEdgeBound(a, b, regions);
644
+ if (rb !== null) {
645
+ const rb0 = regionEdgeBound(points[v], b, regions);
646
+ worst = Math.max(worst, term(L / rb, rb0 === null ? 0 : L0 / rb0));
647
+ }
648
+ const q = need.at([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
649
+ if (Number.isFinite(q)) {
650
+ const q0 = need.at([(points[v][0] + b[0]) / 2, (points[v][1] + b[1]) / 2]);
651
+ worst = Math.max(worst, term(L / q, Number.isFinite(q0) ? L0 / q0 : 0));
652
+ }
653
+ }
654
+ nu[v] = Math.min(nu[v], worst);
655
+ }
656
+ }
657
+ const area = new Array<number>(n).fill(0);
658
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
659
+ const third = Math.abs(twiceSigned(points[triangles[t]], points[triangles[t + 1]], points[triangles[t + 2]])) / 6;
660
+ for (let k = 0; k < 3; k++) area[triangles[t + k]] += third;
661
+ }
662
+ const local = neighbours.map((s, x) => (s.length === 0 ? 0 : r6(s.reduce((sum, y) => sum + lengthOf(points[x], points[y]), 0) / s.length)));
663
+ const order = local.map((_, i) => i).sort((i, j) => local[i] - local[j] || i - j);
664
+ const total = area.reduce((s, x) => s + x, 0);
665
+ let median = local[order[order.length - 1]];
666
+ let acc = 0;
667
+ for (const i of order) {
668
+ acc += area[i];
669
+ if (acc >= 0.5 * total) {
670
+ median = local[i];
671
+ break;
672
+ }
673
+ }
674
+ const removable = (i: number): boolean => nu[i] < 1;
675
+ const dense = local.map((_, i) => i).filter((i) => local[i] < median);
676
+ const rest = local.map((_, i) => i).filter((i) => local[i] >= median);
677
+ if (dense.length === 0) return unmeasured(`no vertex's local size is below the area-weighted median ${r6(median)} px, so there is no dense part to contrast`);
678
+ if (rest.length === 0) return unmeasured('every vertex is below the area-weighted median local size, so there is no rest to contrast the dense part with');
679
+ const denseRemovable = dense.filter(removable).length;
680
+ const restRemovable = rest.filter(removable).length;
681
+ let worstVertex = dense[0];
682
+ for (const i of dense) if (nu[i] < nu[worstVertex]) worstVertex = i;
683
+ return {
684
+ measured: true,
685
+ value: {
686
+ contrast: r6(denseRemovable / dense.length - restRemovable / rest.length),
687
+ economy: r6(nu.filter((x) => x < 1).length / n),
688
+ dense: { vertices: dense.length, removableAlone: denseRemovable },
689
+ rest: { vertices: rest.length, removableAlone: restRemovable },
690
+ worstVertex,
691
+ },
692
+ };
693
+ }