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.
- package/package.json +1 -1
- package/src/meshallocation.ts +693 -0
- package/src/meshquality.ts +322 -4
- package/src/meshreduce.ts +273 -4
|
@@ -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
|
+
}
|