rig-c 2.25.0 → 2.26.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 +262 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-c",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.26.0",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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
|
+
}
|
package/src/meshquality.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
type MeshOutline,
|
|
71
71
|
} from './mesh.ts';
|
|
72
72
|
import { areaBand, triangleAreas } from './areaband.ts';
|
|
73
|
+
import { allocationContrast, boundaryNecessityOnce, deformLoad, gradeMax, minAngleP10, type AllocationArt, type AllocationPlant } from './meshallocation.ts';
|
|
73
74
|
import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
|
|
74
75
|
import { cropToSpineY } from './transform.ts';
|
|
75
76
|
|
|
@@ -311,6 +312,41 @@ export interface MeshMeasureInput {
|
|
|
311
312
|
influences: InfluenceLimits | null;
|
|
312
313
|
boneOrder: string[] | null;
|
|
313
314
|
preset: { name: string; version: string } | null;
|
|
315
|
+
/**
|
|
316
|
+
* Issue #1280 (§8, [agreed, rig-parts#126] Q2): the motion amplitude the two weight-aware allocation rows read —
|
|
317
|
+
* `MQ_ALLOCATION_CONTRAST` and `MQ_DEFORM_LOAD`. Left out, it is not declared and those two rows are
|
|
318
|
+
* `not-measurable`, naming this field; `null` declares it absent, with the same result and a reason saying so (as a
|
|
319
|
+
* `null` art bound is declared absent, #1257). It declares no bound: every allocation row stays `undeclared`.
|
|
320
|
+
*/
|
|
321
|
+
motionAmplitude?: MotionAmplitude | null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Issue #1280: the amplitude a caller's motion moves a mesh's weight field by, per track, as the allocation rows read
|
|
326
|
+
* it. Nothing in it has a default; nothing in it gates anything.
|
|
327
|
+
*/
|
|
328
|
+
export interface MotionAmplitude {
|
|
329
|
+
/** One entry per track the caller declares (an animation, a parameter axis — its name is echoed, never read). */
|
|
330
|
+
tracks: TrackAmplitude[];
|
|
331
|
+
/**
|
|
332
|
+
* G, px per px: how fast a declared need relaxes away from its source — the size a need allows at distance d is its
|
|
333
|
+
* own plus G × d (the form of a region's `grade`). Read by `MQ_ALLOCATION_CONTRAST` only. Finite, 0 or more.
|
|
334
|
+
*/
|
|
335
|
+
gradation: number;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One track's amplitude: the pairs of bones a share moves between in it, each with θ, and the track's ε. */
|
|
339
|
+
export interface TrackAmplitude {
|
|
340
|
+
/** The caller's name for the track; echoed, never read. */
|
|
341
|
+
track: string;
|
|
342
|
+
/**
|
|
343
|
+
* Each pair of bones whose relative transform the track moves, and θ: the largest ‖M − I‖ of the relative linear
|
|
344
|
+
* part M over the track — 2 sin(α / 2) for a rotation by α (≈ α in radians for a small one), |s − 1| for a uniform
|
|
345
|
+
* scale by s. Dimensionless, finite, 0 or more. A pair is unordered; declared twice, the larger θ is read.
|
|
346
|
+
*/
|
|
347
|
+
pairs: Array<{ bones: [string, string]; theta: number }>;
|
|
348
|
+
/** ε, drawing px: the chord error the track tolerates — what a deformation need is sized against. Finite, above 0. */
|
|
349
|
+
epsilon: number;
|
|
314
350
|
}
|
|
315
351
|
|
|
316
352
|
// ---------------------------------------------------------------------------
|
|
@@ -344,6 +380,18 @@ export interface MeasureRow {
|
|
|
344
380
|
sampling?: { domain: string; count: number };
|
|
345
381
|
/** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
|
|
346
382
|
motion?: MotionRowDetail;
|
|
383
|
+
/** The five allocation rows only (issue #1280): what the value is a reading of, and what it was read from. */
|
|
384
|
+
allocation?: AllocationDetail;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Issue #1280: an allocation row's own fields. Written on those five rows, measured or not, and on no other. */
|
|
388
|
+
export interface AllocationDetail {
|
|
389
|
+
/** One sentence: what the value is a reading of — and, for `MQ_DEFORM_LOAD`, that it is not predicted motion. */
|
|
390
|
+
reading: string;
|
|
391
|
+
/** `MQ_ALLOCATION_CONTRAST` when measured: economy E, and the counts Δ and E are taken from. */
|
|
392
|
+
contrast?: { economy: number; dense: { vertices: number; removableAlone: number }; rest: { vertices: number; removableAlone: number } };
|
|
393
|
+
/** `MQ_BOUNDARY_NECESSARY` when measured: the shortcuts the search tested, and the bound n × (n − 2) it is held to. */
|
|
394
|
+
search?: { shortcutsTested: number; bound: number };
|
|
347
395
|
}
|
|
348
396
|
|
|
349
397
|
/** Correction 2: the spatial grid and the value's own granularity are two fields. */
|
|
@@ -494,6 +542,8 @@ export interface EffectiveSettings {
|
|
|
494
542
|
stopAfterAccepted?: number;
|
|
495
543
|
/** A reduction's `boundaryRuns`, echoed when the input set it (issue #1279); absent otherwise. */
|
|
496
544
|
boundaryRuns?: BoundaryRuns;
|
|
545
|
+
/** A measurement's `motionAmplitude`, echoed when the input set it — `null` included (issue #1280); absent otherwise. */
|
|
546
|
+
motionAmplitude?: MotionAmplitude | null;
|
|
497
547
|
}
|
|
498
548
|
|
|
499
549
|
export interface MeshCounts {
|
|
@@ -746,6 +796,33 @@ function validateInput(input: MeshMeasureInput): void {
|
|
|
746
796
|
if (input.preset !== null && (!isObject(input.preset) || typeof input.preset.name !== 'string' || typeof input.preset.version !== 'string')) {
|
|
747
797
|
refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
|
|
748
798
|
}
|
|
799
|
+
validateAmplitude(who, input.motionAmplitude);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Issue #1280: `motionAmplitude` left out or `null` is accepted as it stands; anything else is a `MotionAmplitude` in full. */
|
|
803
|
+
function validateAmplitude(who: string, amplitude: unknown): void {
|
|
804
|
+
if (amplitude === undefined || amplitude === null) return;
|
|
805
|
+
const shape = '{ tracks: [{ track, pairs: [{ bones: [a, b], theta }], epsilon }], gradation }';
|
|
806
|
+
if (!isObject(amplitude) || !Array.isArray(amplitude.tracks)) refuse('REDUCE_INPUT_MISSING', `${who}: motionAmplitude is ${JSON.stringify(amplitude)}; required ${shape}, null, or the field left out`);
|
|
807
|
+
if (!isFiniteNumber(amplitude.gradation) || amplitude.gradation < 0) {
|
|
808
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: motionAmplitude.gradation is ${JSON.stringify(amplitude.gradation)}; required a finite number of px per px, 0 or more — no gradation is assumed`);
|
|
809
|
+
}
|
|
810
|
+
amplitude.tracks.forEach((track: unknown, i: number) => {
|
|
811
|
+
const at = `motionAmplitude.tracks[${i}]`;
|
|
812
|
+
if (!isObject(track) || typeof track.track !== 'string' || track.track === '' || !Array.isArray(track.pairs)) {
|
|
813
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${JSON.stringify(track)}; required { track: a non-empty name, pairs: a list, epsilon }`);
|
|
814
|
+
}
|
|
815
|
+
if (!isFiniteNumber(track.epsilon) || track.epsilon <= 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.epsilon is ${JSON.stringify(track.epsilon)}; required a finite number of px above 0`);
|
|
816
|
+
track.pairs.forEach((pair: unknown, j: number) => {
|
|
817
|
+
const here = `${at}.pairs[${j}]`;
|
|
818
|
+
const bones = isObject(pair) ? pair.bones : undefined;
|
|
819
|
+
if (!Array.isArray(bones) || bones.length !== 2 || bones.some((b) => typeof b !== 'string' || b === '') || bones[0] === bones[1]) {
|
|
820
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: ${here}.bones is ${JSON.stringify(bones)}; required two different bone names`);
|
|
821
|
+
}
|
|
822
|
+
const theta = (pair as Record<string, unknown>).theta;
|
|
823
|
+
if (!isFiniteNumber(theta) || theta < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${here}.theta is ${JSON.stringify(theta)}; required a finite number, 0 or more (‖M − I‖ of the pair's relative linear part)`);
|
|
824
|
+
});
|
|
825
|
+
});
|
|
749
826
|
}
|
|
750
827
|
|
|
751
828
|
// ---------------------------------------------------------------------------
|
|
@@ -1013,6 +1090,24 @@ export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [nu
|
|
|
1013
1090
|
return (last - first) * length > BAND_CONTACT_TOLERANCE;
|
|
1014
1091
|
}
|
|
1015
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* The density bound the regions hold the segment `a`–`b` to, as `regionRows` reads it for a mesh edge — the
|
|
1095
|
+
* smallest over every region that holds it (`edgeIsHeldByRegion`): `L0` where it meets the closed polygon, else
|
|
1096
|
+
* `L0 + grade·d` on the `r6` grid — or null when no region holds it. Read by `MQ_ALLOCATION_CONTRAST` (issue #1280)
|
|
1097
|
+
* for the edges a collapse would create. The regions are taken as given: the caller has refused any that
|
|
1098
|
+
* `regionRefusal` refuses. Internal, as `measureMeshQualityWith` is: on `rig-c/mesh` only through `export *`.
|
|
1099
|
+
*/
|
|
1100
|
+
export function regionEdgeBound(a: readonly [number, number], b: readonly [number, number], regions: readonly RefinementRegion[]): number | null {
|
|
1101
|
+
let bound: number | null = null;
|
|
1102
|
+
for (const region of regions) {
|
|
1103
|
+
if (!edgeIsHeldByRegion(a, b, region)) continue;
|
|
1104
|
+
const d = segmentToPolygon(a, b, region.polygon);
|
|
1105
|
+
const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
|
|
1106
|
+
if (bound === null || here < bound) bound = here;
|
|
1107
|
+
}
|
|
1108
|
+
return bound;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1016
1111
|
/** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
|
|
1017
1112
|
function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
|
|
1018
1113
|
let d = Infinity;
|
|
@@ -1321,7 +1416,17 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
|
|
|
1321
1416
|
*/
|
|
1322
1417
|
export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
1323
1418
|
validateInput(input);
|
|
1324
|
-
return measureValidated(input, artRastersOf(input.art), null);
|
|
1419
|
+
return measureValidated(input, artRastersOf(input.art), null, null);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* `measureMeshQuality` with a fault planted in its allocation rows (issue #1280, `AllocationPlant` in
|
|
1424
|
+
* `src/meshallocation.ts`) — the `mesh-quality` suite's negative controls, and with `omit-rows` the report without
|
|
1425
|
+
* those rows that the opt-out identity is held to. Internal, as `measureMeshQualityWith` is.
|
|
1426
|
+
*/
|
|
1427
|
+
export function measureMeshQualityPlanted(input: MeshMeasureInput, plant: AllocationPlant): MeshQualityReport {
|
|
1428
|
+
validateInput(input);
|
|
1429
|
+
return measureValidated(input, artRastersOf(input.art), null, plant);
|
|
1325
1430
|
}
|
|
1326
1431
|
|
|
1327
1432
|
/**
|
|
@@ -1340,7 +1445,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
|
1340
1445
|
export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
|
|
1341
1446
|
validateInput(input);
|
|
1342
1447
|
checkArtRasters(input, rasters);
|
|
1343
|
-
return measureValidated(input, rasters, null);
|
|
1448
|
+
return measureValidated(input, rasters, null, null);
|
|
1344
1449
|
}
|
|
1345
1450
|
|
|
1346
1451
|
/**
|
|
@@ -1357,7 +1462,7 @@ export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRast
|
|
|
1357
1462
|
export function measureMeshQualityStep(input: MeshMeasureInput, steps: StepRasters): MeshQualityReport {
|
|
1358
1463
|
validateInput(input);
|
|
1359
1464
|
checkArtRasters(input, steps.rasters);
|
|
1360
|
-
return measureValidated(input, steps.rasters, steps);
|
|
1465
|
+
return measureValidated(input, steps.rasters, steps, null);
|
|
1361
1466
|
}
|
|
1362
1467
|
|
|
1363
1468
|
/**
|
|
@@ -1433,7 +1538,7 @@ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
|
|
|
1433
1538
|
}
|
|
1434
1539
|
|
|
1435
1540
|
/** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
|
|
1436
|
-
function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
|
|
1541
|
+
function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null, plant: AllocationPlant | null): MeshQualityReport {
|
|
1437
1542
|
rasters.tally.uses++;
|
|
1438
1543
|
const { attachment, art, source, targets } = input;
|
|
1439
1544
|
const { mask, threshold, frame } = art;
|
|
@@ -1472,6 +1577,9 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
|
|
|
1472
1577
|
const points: Pt[] = source.points;
|
|
1473
1578
|
const hullPolygon: Pt[] = outline.walk.map((v) => points[v]);
|
|
1474
1579
|
|
|
1580
|
+
// --- allocation rows (issue #1280): undeclared or not-measurable, never required ------------
|
|
1581
|
+
if (plant !== 'omit-rows') rows.push(...allocationRows(input, outline, rasters, steps, plant));
|
|
1582
|
+
|
|
1475
1583
|
// --- raster rows ---------------------------------------------------------
|
|
1476
1584
|
const w = mask.width;
|
|
1477
1585
|
const h = mask.height;
|
|
@@ -1623,6 +1731,131 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
|
|
|
1623
1731
|
return report(counts, { id: input.id, counts, geometry, motion: null, accepted }, null);
|
|
1624
1732
|
}
|
|
1625
1733
|
|
|
1734
|
+
/** What each allocation row is a reading of — `AllocationDetail.reading`, one fixed sentence per code. */
|
|
1735
|
+
const ALLOCATION_READINGS: Record<string, string> = {
|
|
1736
|
+
MQ_GRADE:
|
|
1737
|
+
'the largest |h_u − h_v| / L over the edges, h a vertex\'s mean incident edge length: how abruptly the local size changes, whether or not the density is justified',
|
|
1738
|
+
MQ_MIN_ANGLE_P10: 'the tenth percentile, nearest rank, of the triangles\' smallest angles: sliver fans, where MQ_MIN_ANGLE reads the single worst triangle',
|
|
1739
|
+
MQ_ALLOCATION_CONTRAST:
|
|
1740
|
+
'Δ: the share of the dense vertices removable alone against the declared needs (silhouette, deformation under motionAmplitude, regions) minus that share among the rest; economy E is the share of all',
|
|
1741
|
+
MQ_DEFORM_LOAD:
|
|
1742
|
+
'the largest L · Δshare · θ / 4 over the edges, Δshare the share of weight that moves across the edge: a bound on the chord error of the mesh\'s own weight field under motionAmplitude, read to locate the edge; it is not predicted motion and no difference to any reference (docs/MESH_REDUCTION.md §8)',
|
|
1743
|
+
MQ_BOUNDARY_NECESSARY:
|
|
1744
|
+
'B*: the fewest vertices of the reference hull a closed outline can keep with every skipped run within maxBoundaryDeviation of its chord both ways and no art pixel centre cut off (overshoot is not tested); worst is the first hull vertex of this mesh not on that outline',
|
|
1745
|
+
};
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* The five allocation rows (issue #1280; docs/MESH_REDUCTION.md §8): each `undeclared` with its value and worst
|
|
1749
|
+
* sample, or `not-measurable` with the sentence naming what is missing — never required, so none can block a step,
|
|
1750
|
+
* refuse a source, or count towards acceptance. `MQ_BOUNDARY_NECESSARY` is read against the reference hull once per
|
|
1751
|
+
* art rasters object (`boundaryNecessityOnce`), so a reduction computes it once per call.
|
|
1752
|
+
*/
|
|
1753
|
+
function allocationRows(input: MeshMeasureInput, outline: MeshOutline, rasters: ArtRasters, steps: StepRasters | null, plant: AllocationPlant | null): Built[] {
|
|
1754
|
+
const { attachment, source, targets, art } = input;
|
|
1755
|
+
const who = `attachment ${nameOf(attachment)}`;
|
|
1756
|
+
if (plant === 'mutates-input') {
|
|
1757
|
+
const tris = source.triangles;
|
|
1758
|
+
for (let t = 0; t + 2 < tris.length; t += 3) [tris[t + 1], tris[t + 2]] = [tris[t + 2], tris[t + 1]];
|
|
1759
|
+
}
|
|
1760
|
+
const points: Pt[] = source.points;
|
|
1761
|
+
const out: Built[] = [];
|
|
1762
|
+
const row = (code: string, unit: MeasureRow['unit'], built: Built, detail: Omit<AllocationDetail, 'reading'> = {}): void => {
|
|
1763
|
+
built.row.allocation = { reading: ALLOCATION_READINGS[code], ...detail };
|
|
1764
|
+
if (plant === 'rows-required') built.required = true;
|
|
1765
|
+
out.push(built);
|
|
1766
|
+
};
|
|
1767
|
+
const spec = (code: string, unit: MeasureRow['unit']): RowSpec => ({ code, unit, region: null, attachment });
|
|
1768
|
+
const measured = (code: string, unit: MeasureRow['unit'], value: number, worst: WorstSample): Built => measuredRow(spec(code, unit), value, null, worst, false);
|
|
1769
|
+
const missing = (code: string, unit: MeasureRow['unit'], reason: string): Built => unmeasuredRow(spec(code, unit), 'not-measurable', reason, false);
|
|
1770
|
+
|
|
1771
|
+
// Geometry alone.
|
|
1772
|
+
const grade = gradeMax(points, source.triangles, plant);
|
|
1773
|
+
row('MQ_GRADE', 'ratio', grade.measured ? measured('MQ_GRADE', 'ratio', grade.value.value, { at: { edge: grade.value.edge } }) : missing('MQ_GRADE', 'ratio', `${who}: ${grade.reason}`));
|
|
1774
|
+
const p10 = minAngleP10(points, source.triangles, plant);
|
|
1775
|
+
row(
|
|
1776
|
+
'MQ_MIN_ANGLE_P10',
|
|
1777
|
+
'degrees',
|
|
1778
|
+
p10.measured ? measured('MQ_MIN_ANGLE_P10', 'degrees', p10.value.value, { at: { triangle: p10.value.triangle } }) : missing('MQ_MIN_ANGLE_P10', 'degrees', `${who}: ${p10.reason}`),
|
|
1779
|
+
);
|
|
1780
|
+
|
|
1781
|
+
// What the weight-aware and art-reading rows need.
|
|
1782
|
+
const amplitude = input.motionAmplitude;
|
|
1783
|
+
const amplitudeMissing =
|
|
1784
|
+
amplitude === undefined
|
|
1785
|
+
? `${who}: motionAmplitude is not declared (the field is left out), so no motion amplitude sizes a deformation need; required { tracks, gradation } — no amplitude is assumed, and without one no reading of justification separates anything (§8)`
|
|
1786
|
+
: amplitude === null
|
|
1787
|
+
? `${who}: motionAmplitude is declared absent (null), so no motion amplitude sizes a deformation need; required { tracks, gradation } for this row — no amplitude is assumed`
|
|
1788
|
+
: null;
|
|
1789
|
+
const unweighted = source.weights === null ? `${who}: source.weights is null — an unweighted mesh has no weight field for a motion to move` : null;
|
|
1790
|
+
const delta = targets.maxBoundaryDeviation;
|
|
1791
|
+
const noDelta = delta === null ? `${who}: targets.maxBoundaryDeviation is null, so no skip of the outline has a deviation to be held to` : null;
|
|
1792
|
+
const artCount = rasters.artCount();
|
|
1793
|
+
const fewArt =
|
|
1794
|
+
artCount < input.minArtSamples
|
|
1795
|
+
? `${who} has ${artCount} art sample(s) at alpha >= ${art.threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`
|
|
1796
|
+
: null;
|
|
1797
|
+
const allocationArt: AllocationArt = { mask: art.mask, bits: rasters.artBits(), scale: art.frame.pageScale };
|
|
1798
|
+
|
|
1799
|
+
// MQ_DEFORM_LOAD.
|
|
1800
|
+
const loadWhy = amplitudeMissing ?? unweighted;
|
|
1801
|
+
if (loadWhy !== null || amplitude === undefined || amplitude === null || source.weights === null) {
|
|
1802
|
+
row('MQ_DEFORM_LOAD', 'px', missing('MQ_DEFORM_LOAD', 'px', loadWhy ?? ''));
|
|
1803
|
+
} else {
|
|
1804
|
+
const load = deformLoad(points, source.triangles, source.weights, amplitude, plant);
|
|
1805
|
+
row(
|
|
1806
|
+
'MQ_DEFORM_LOAD',
|
|
1807
|
+
'px',
|
|
1808
|
+
load.measured
|
|
1809
|
+
? measured('MQ_DEFORM_LOAD', 'px', load.value.value, load.value.edge === null ? NOTHING_WORSE : { at: { edge: load.value.edge } })
|
|
1810
|
+
: missing('MQ_DEFORM_LOAD', 'px', `${who}: ${load.reason}`),
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// MQ_ALLOCATION_CONTRAST.
|
|
1815
|
+
const planted = plant === 'contrast-without-amplitude' && (amplitude === undefined || amplitude === null) ? { tracks: [], gradation: 0 } : null;
|
|
1816
|
+
const declared = planted ?? amplitude;
|
|
1817
|
+
let regionWhy: string | null = null;
|
|
1818
|
+
for (const region of targets.regions) {
|
|
1819
|
+
const refusal = regionRefusal(region, art.frame.pageScale, who, steps);
|
|
1820
|
+
if (refusal !== null) {
|
|
1821
|
+
regionWhy = `${who}: region "${region.name}" is refused (${refusal.code}), so its density cannot be read as a declared need`;
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
const contrastWhy = (planted === null ? amplitudeMissing : null) ?? unweighted ?? noDelta ?? fewArt ?? regionWhy;
|
|
1826
|
+
if (contrastWhy !== null || declared === undefined || declared === null || source.weights === null || delta === null) {
|
|
1827
|
+
row('MQ_ALLOCATION_CONTRAST', 'fraction', missing('MQ_ALLOCATION_CONTRAST', 'fraction', contrastWhy ?? ''));
|
|
1828
|
+
} else {
|
|
1829
|
+
const ownHull = points.slice(0, outline.hull);
|
|
1830
|
+
const silhouette = boundaryNecessityOnce(rasters, ownHull, delta, allocationArt, null).path;
|
|
1831
|
+
const contrast = allocationContrast(points, source.triangles, outline.hull, source.weights, delta, allocationArt, targets.regions, declared, silhouette, plant);
|
|
1832
|
+
if (contrast.measured) {
|
|
1833
|
+
const c = contrast.value;
|
|
1834
|
+
row('MQ_ALLOCATION_CONTRAST', 'fraction', measured('MQ_ALLOCATION_CONTRAST', 'fraction', c.contrast, { at: { vertex: c.worstVertex } }), {
|
|
1835
|
+
contrast: { economy: c.economy, dense: c.dense, rest: c.rest },
|
|
1836
|
+
});
|
|
1837
|
+
} else {
|
|
1838
|
+
row('MQ_ALLOCATION_CONTRAST', 'fraction', missing('MQ_ALLOCATION_CONTRAST', 'fraction', `${who}: ${contrast.reason}`));
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// MQ_BOUNDARY_NECESSARY.
|
|
1843
|
+
const reference = input.referenceHull;
|
|
1844
|
+
const noReference = reference === null ? `${who}: no referenceHull was given, so there is no source hull to find the necessary outline of` : null;
|
|
1845
|
+
const necessaryWhy = noReference ?? noDelta ?? fewArt;
|
|
1846
|
+
if (necessaryWhy !== null || reference === null || delta === null) {
|
|
1847
|
+
row('MQ_BOUNDARY_NECESSARY', 'count', missing('MQ_BOUNDARY_NECESSARY', 'count', necessaryWhy ?? ''));
|
|
1848
|
+
} else {
|
|
1849
|
+
const found = boundaryNecessityOnce(rasters, reference, delta, allocationArt, plant);
|
|
1850
|
+
const kept = new Set(found.path.map((i) => pointKey(reference[i])));
|
|
1851
|
+
const beyond = outline.walk.find((v) => !kept.has(pointKey(points[v])));
|
|
1852
|
+
row('MQ_BOUNDARY_NECESSARY', 'count', measured('MQ_BOUNDARY_NECESSARY', 'count', found.count, beyond === undefined ? NOTHING_WORSE : { at: { vertex: beyond } }), {
|
|
1853
|
+
search: { shortcutsTested: found.tests, bound: found.bound },
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
return out;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1626
1859
|
/** What one source states about itself: hull and interior from the outline, bindings from the weights. */
|
|
1627
1860
|
function countsOf(source: SourceMesh, outline: MeshOutline): MeshCounts {
|
|
1628
1861
|
const bindings = source.weights === null ? 0 : source.weights.reduce((s, v) => s + v.length, 0);
|
|
@@ -1934,6 +2167,7 @@ function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
|
|
|
1934
2167
|
boneOrder: input.boneOrder,
|
|
1935
2168
|
schedule: null,
|
|
1936
2169
|
budget: null,
|
|
2170
|
+
...(input.motionAmplitude === undefined ? {} : { motionAmplitude: input.motionAmplitude }),
|
|
1937
2171
|
};
|
|
1938
2172
|
}
|
|
1939
2173
|
|
|
@@ -2024,6 +2258,7 @@ function effectiveJson(e: EffectiveSettings): Json {
|
|
|
2024
2258
|
budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
|
|
2025
2259
|
...(e.stopAfterAccepted === undefined ? {} : { stopAfterAccepted: e.stopAfterAccepted }),
|
|
2026
2260
|
...(e.boundaryRuns === undefined ? {} : { boundaryRuns: { maxVertices: e.boundaryRuns.maxVertices } }),
|
|
2261
|
+
...(e.motionAmplitude === undefined ? {} : { motionAmplitude: amplitudeJson(e.motionAmplitude) }),
|
|
2027
2262
|
};
|
|
2028
2263
|
}
|
|
2029
2264
|
|
|
@@ -2067,9 +2302,32 @@ function rowJson(r: MeasureRow): Json {
|
|
|
2067
2302
|
}
|
|
2068
2303
|
if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
|
|
2069
2304
|
if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
|
|
2305
|
+
if (r.allocation !== undefined) out.allocation = allocationDetailJson(r.allocation);
|
|
2070
2306
|
return out;
|
|
2071
2307
|
}
|
|
2072
2308
|
|
|
2309
|
+
function allocationDetailJson(a: AllocationDetail): Json {
|
|
2310
|
+
const out: { [key: string]: Json } = { reading: a.reading };
|
|
2311
|
+
if (a.contrast !== undefined) {
|
|
2312
|
+
const c = a.contrast;
|
|
2313
|
+
out.contrast = {
|
|
2314
|
+
economy: c.economy,
|
|
2315
|
+
dense: { vertices: c.dense.vertices, removableAlone: c.dense.removableAlone },
|
|
2316
|
+
rest: { vertices: c.rest.vertices, removableAlone: c.rest.removableAlone },
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
if (a.search !== undefined) out.search = { shortcutsTested: a.search.shortcutsTested, bound: a.search.bound };
|
|
2320
|
+
return out;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function amplitudeJson(a: MotionAmplitude | null): Json {
|
|
2324
|
+
if (a === null) return null;
|
|
2325
|
+
return {
|
|
2326
|
+
tracks: a.tracks.map((t) => ({ track: t.track, pairs: t.pairs.map((p) => ({ bones: [p.bones[0], p.bones[1]], theta: p.theta })), epsilon: t.epsilon })),
|
|
2327
|
+
gradation: a.gradation,
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2073
2331
|
function sectionJson(s: EvidenceSection): { [key: string]: Json } {
|
|
2074
2332
|
const m = s.summary;
|
|
2075
2333
|
return {
|