spine-rigc 2.17.0 → 2.18.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/README.md +7 -1
- package/package.json +1 -1
- package/src/areaband.ts +96 -0
- package/src/deformsurvey.ts +5 -80
- package/src/mesh.ts +36 -8
- package/src/meshquality.ts +1507 -0
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh quality, measured without posing anything — the geometry half of the
|
|
3
|
+
* contract in docs/MESH_REDUCTION.md (issue #1221, stage B1: issue #1224).
|
|
4
|
+
*
|
|
5
|
+
* One operation lives here today, `measureMeshQuality`: a mesh, the art one
|
|
6
|
+
* attachment draws, and the bounds the caller declared, measured into one
|
|
7
|
+
* `mesh-quality-report/1` document (`writeMeshQualityReport` writes its text).
|
|
8
|
+
* Every type the contract declares is declared here too, including the ones
|
|
9
|
+
* only the reduction (`reduceMesh`, stage B2) and the motion comparison
|
|
10
|
+
* (stage C) will fill, so that those stages build on one set of types rather
|
|
11
|
+
* than restating them.
|
|
12
|
+
*
|
|
13
|
+
* ## What it is for
|
|
14
|
+
*
|
|
15
|
+
* An agent that cannot see a mesh cannot tell a mesh that drops art from one
|
|
16
|
+
* that does not, a spanned hole from overshoot, or a sliver from a fold. Each of
|
|
17
|
+
* those is a row here with its value, where the worst value was taken, the bound
|
|
18
|
+
* the caller declared, and a state that keeps "measured and failed" apart from
|
|
19
|
+
* "nothing was declared" and from "could not be measured". The five states are
|
|
20
|
+
* §2's; nothing folds one into another, and an informational row never counts
|
|
21
|
+
* towards a pass.
|
|
22
|
+
*
|
|
23
|
+
* ## What it never does
|
|
24
|
+
*
|
|
25
|
+
* - **Invent a bound.** A row whose bound the caller did not declare is
|
|
26
|
+
* `undeclared`: measured, reported, and out of the pass count. The two rows
|
|
27
|
+
* with a bound of their own — `MQ_ORIENTATION` and `MQ_DEGENERATE`, both 0 —
|
|
28
|
+
* take it from the input's own definition (`SourceMesh.triangles` is
|
|
29
|
+
* counter-clockwise in Spine world, and a triangle whose sign the A39 band
|
|
30
|
+
* cannot read cannot be shown to be), not from a guess.
|
|
31
|
+
* - **Exchange thresholds.** Every raster row records the threshold it was
|
|
32
|
+
* taken at (`MeasureRow.art.threshold`); a measurement claims nothing at any
|
|
33
|
+
* other.
|
|
34
|
+
* - **Change the legacy fit.** `measureAuthoredMeshFit` (`src/mesh.ts`) keeps
|
|
35
|
+
* its 4-connected fill for every existing caller. The rows here fill with the
|
|
36
|
+
* tracer's 8-connected background flood over all art (P12), and where the two
|
|
37
|
+
* fills differ — a diagonal pinch — both labelled results are rows.
|
|
38
|
+
* - **Pose, or link the runtime.** Pure: no clock, no randomness, no network,
|
|
39
|
+
* no spine-core, nothing from the compiler.
|
|
40
|
+
*
|
|
41
|
+
* ## Coordinates
|
|
42
|
+
*
|
|
43
|
+
* Every caller distance is in the drawing's part-local pixels, y down (P3). The
|
|
44
|
+
* mask is read on its own grid — the plate's pixels, or a `scale:` page's
|
|
45
|
+
* texels — so a point is carried to the grid as `px × pageScale`, and a raster
|
|
46
|
+
* distance comes back as `texels / pageScale`. A pixel named in a `worst` is a
|
|
47
|
+
* cell of the mask's grid, `[x, y]`, y down. Orientation is read in Spine world,
|
|
48
|
+
* through `cropToSpineY` (`src/transform.ts`), the one place that conversion
|
|
49
|
+
* lives.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ `src/mesh.ts` re-exports this module and this module imports helpers from
|
|
52
|
+
* it, which is an import cycle. It is safe only because nothing at the top
|
|
53
|
+
* level of either reads the other's bindings: every use is inside a function.
|
|
54
|
+
* `MeshReductionError` is defined in `src/mesh.ts` for exactly that reason.
|
|
55
|
+
*/
|
|
56
|
+
import {
|
|
57
|
+
artOf,
|
|
58
|
+
checkHullOrder,
|
|
59
|
+
distanceToSegment,
|
|
60
|
+
fillEnclosed,
|
|
61
|
+
findSelfIntersection,
|
|
62
|
+
labelIslands,
|
|
63
|
+
MeshError,
|
|
64
|
+
MeshReductionError,
|
|
65
|
+
meshEdges,
|
|
66
|
+
prunePolygon,
|
|
67
|
+
r6,
|
|
68
|
+
rasteriseTriangles,
|
|
69
|
+
segmentsMeet,
|
|
70
|
+
squaredDistanceToSet,
|
|
71
|
+
traceAlphaOutline,
|
|
72
|
+
traceOutline,
|
|
73
|
+
type AlphaMask,
|
|
74
|
+
type MeshOutline,
|
|
75
|
+
} from './mesh.ts';
|
|
76
|
+
import { areaBand, triangleAreas } from './areaband.ts';
|
|
77
|
+
import { cropToSpineY } from './transform.ts';
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// §1 — inputs
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** The art one attachment is measured against. Correction 1: one per attachment, never shared. */
|
|
84
|
+
export interface ArtInput {
|
|
85
|
+
/** The art, on the grid it is read off: the plate's pixels, or a `scale:` page's texels. */
|
|
86
|
+
mask: AlphaMask;
|
|
87
|
+
/** Art is `alpha >= threshold`. Required, a whole number in 1..255. Never exchanged. */
|
|
88
|
+
threshold: number;
|
|
89
|
+
/** The frame the caller's distances are in — P3. Echoed in the report, never inferred. */
|
|
90
|
+
frame: SourceFrame;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** P3: the caller authors in drawing pixels, part-local, y down. */
|
|
94
|
+
export interface SourceFrame {
|
|
95
|
+
space: 'part-local-drawing-px-y-down';
|
|
96
|
+
/** The part window in drawing pixels. */
|
|
97
|
+
width: number;
|
|
98
|
+
height: number;
|
|
99
|
+
/** The `scale:` the page states (1 when none) — the meaning of `ContourSpecInput.pageScale`. */
|
|
100
|
+
pageScale: number;
|
|
101
|
+
/** The conversion applied, stated rather than implied: texels = drawing px × pageScale. */
|
|
102
|
+
conversion: 'texels = px * pageScale';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AttachmentRef {
|
|
106
|
+
skin: string | null;
|
|
107
|
+
slot: string;
|
|
108
|
+
attachment: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface SourceMesh {
|
|
112
|
+
/** Part-local pixels of the drawing, y down. */
|
|
113
|
+
points: Array<[number, number]>;
|
|
114
|
+
/** Region UVs, 0..1, `v` from the top — one pair per point. */
|
|
115
|
+
uvs: number[];
|
|
116
|
+
/** Counter-clockwise in Spine world; hull first, as `checkHullOrder` requires. */
|
|
117
|
+
triangles: number[];
|
|
118
|
+
hull: number;
|
|
119
|
+
/** Per vertex, by bone NAME (`ModelBinding` without the bind coordinates), or null for an unweighted mesh. */
|
|
120
|
+
weights: Array<Array<{ bone: string; weight: number }>> | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** All distances in the drawing's pixels; converted to texels by `pageScale`. */
|
|
124
|
+
export interface ArtFitBounds {
|
|
125
|
+
/** Share of art pixel centres the triangles cover, 0..1. */
|
|
126
|
+
minCoverage: number;
|
|
127
|
+
/** Furthest a covered pixel may sit outside the filled silhouette, px. */
|
|
128
|
+
maxOvershoot: number;
|
|
129
|
+
/** Furthest an uncovered art pixel may sit from the covered set, px. */
|
|
130
|
+
maxUndercut: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What a reduction's RESULT must satisfy (correction 3). Read by `reduceMesh`, stage B2. */
|
|
134
|
+
export interface ReductionTargets {
|
|
135
|
+
artFit: ArtFitBounds;
|
|
136
|
+
/** P13: largest Hausdorff distance between the reduced and the source hull polygons, px. */
|
|
137
|
+
maxBoundaryDeviation: number;
|
|
138
|
+
/** Optional: smallest interior angle any triangle may have, degrees. Undeclared = reported, not gated. */
|
|
139
|
+
minAngle?: number;
|
|
140
|
+
/** Local density requirements — §5. */
|
|
141
|
+
regions: RefinementRegion[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** P16/P17: a local density requirement over a closed polygon. */
|
|
145
|
+
export interface RefinementRegion {
|
|
146
|
+
/** Caller's name, echoed in the report; rigc reads nothing into it. */
|
|
147
|
+
name: string;
|
|
148
|
+
/** Closed polygon, part-local drawing pixels, y down — resolved by the caller (P17). */
|
|
149
|
+
polygon: Array<[number, number]>;
|
|
150
|
+
/** L0: every edge that intersects the closed polygon is at most this long, px. */
|
|
151
|
+
maxEdgeLength: number;
|
|
152
|
+
/** Width of the band outside the polygon over which the bound relaxes, px. Finite, >= 0; 0 is a hard edge. */
|
|
153
|
+
transition: number;
|
|
154
|
+
/** In the band, L(d) = L0 + grade × d, d the distance from the polygon, px per px. Finite, >= 0. */
|
|
155
|
+
grade: number;
|
|
156
|
+
/** P17: when the caller approximated another shape by this polygon, how — echoed, never read. */
|
|
157
|
+
approximation: { from: string; policy: string; maxError: number } | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** §6, with P19/P20 folded in. Echoed by `measureMeshQuality`; read by `reduceMesh`, stage B2. */
|
|
161
|
+
export interface ProtectedFeatures {
|
|
162
|
+
/** P20: keep every source hull vertex. Required — no default inside the operation. */
|
|
163
|
+
hull: boolean;
|
|
164
|
+
/** Source vertex indices that must survive. */
|
|
165
|
+
vertices: number[];
|
|
166
|
+
/** P20: source edges (pairs of source indices) that must survive as edges. */
|
|
167
|
+
edges: Array<[number, number]>;
|
|
168
|
+
/** Region polygons whose boundary vertices must survive. */
|
|
169
|
+
regionBoundaries: string[];
|
|
170
|
+
/** L1 weight-vector difference above which an edge is protected (correction 3). Null = no such protection, reported. */
|
|
171
|
+
weightJump: number | null;
|
|
172
|
+
/** Bones whose binding may not be pruned from any vertex that carries it (P19). */
|
|
173
|
+
influences: string[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** P19: stated on every weighted call, never inherited. */
|
|
177
|
+
export interface InfluenceLimits {
|
|
178
|
+
/** The cap on bindings per vertex. */
|
|
179
|
+
maxInfluences: number;
|
|
180
|
+
/** Shares under this are dropped; 0 drops only shares that are zero on the weight grid. */
|
|
181
|
+
minWeight: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Everything a reduction reads (stage B2). No field has a default inside the operation. */
|
|
185
|
+
export interface MeshReductionInput {
|
|
186
|
+
attachment: AttachmentRef;
|
|
187
|
+
art: ArtInput;
|
|
188
|
+
/** P8: the unreduced, independently gated source. */
|
|
189
|
+
source: SourceMesh;
|
|
190
|
+
/** Correction 3: what the SOURCE must already satisfy to be admissible. */
|
|
191
|
+
sourceBounds: ArtFitBounds;
|
|
192
|
+
/** Correction 3: what the RESULT must satisfy. */
|
|
193
|
+
targets: ReductionTargets;
|
|
194
|
+
protect: ProtectedFeatures;
|
|
195
|
+
/** Required when `source.weights` is non-null — §6, P19. */
|
|
196
|
+
influences: InfluenceLimits | null;
|
|
197
|
+
/** Correction 1: the skeleton's bone order, used for every weight tie-break. Required when weighted. */
|
|
198
|
+
boneOrder: string[] | null;
|
|
199
|
+
/** P5: the preset the caller expanded, if any, echoed and never read. */
|
|
200
|
+
preset: { name: string; version: string } | null;
|
|
201
|
+
/** Work bound — *Termination reasons*. */
|
|
202
|
+
budget: { maxCandidates: number };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What a measurement is held to. `ReductionTargets` with every bound allowed to
|
|
207
|
+
* be undeclared: a measurement is also asked of meshes nobody has set a target
|
|
208
|
+
* for, and a null bound is a row reported `undeclared`, never one given a value.
|
|
209
|
+
*/
|
|
210
|
+
export interface MeasureTargets {
|
|
211
|
+
artFit: ArtFitBounds | null;
|
|
212
|
+
maxBoundaryDeviation: number | null;
|
|
213
|
+
minAngle?: number;
|
|
214
|
+
regions: RefinementRegion[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What `measureMeshQuality` reads: the part of `MeshReductionInput` a
|
|
219
|
+
* measurement needs, plus the three things only a measurement is handed — the
|
|
220
|
+
* caller's id for the mesh, the hull polygon `MQ_BOUNDARY_DEVIATION` is taken
|
|
221
|
+
* against, and the art sample floors (P9). Every field is required; a field the
|
|
222
|
+
* caller has nothing for is `null` (or an empty list), never left out.
|
|
223
|
+
*/
|
|
224
|
+
export interface MeshMeasureInput {
|
|
225
|
+
/** The caller's id for the mesh measured, echoed as the candidate's id. */
|
|
226
|
+
id: string;
|
|
227
|
+
attachment: AttachmentRef;
|
|
228
|
+
art: ArtInput;
|
|
229
|
+
/** The mesh measured. */
|
|
230
|
+
source: SourceMesh;
|
|
231
|
+
targets: MeasureTargets;
|
|
232
|
+
/** The hull polygon to measure boundary deviation against, drawing px; null makes that row `not-measurable`. */
|
|
233
|
+
referenceHull: Array<[number, number]> | null;
|
|
234
|
+
/** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
|
|
235
|
+
minArtSamples: number;
|
|
236
|
+
/** P9: one floor per region, by region name — every region named once. */
|
|
237
|
+
regionArtSamples: Array<{ region: string; minArtSamples: number }>;
|
|
238
|
+
protect: ProtectedFeatures | null;
|
|
239
|
+
influences: InfluenceLimits | null;
|
|
240
|
+
boneOrder: string[] | null;
|
|
241
|
+
preset: { name: string; version: string } | null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// §2 — the report
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
export const MESH_QUALITY_REPORT_SPEC = 'mesh-quality-report/1';
|
|
249
|
+
|
|
250
|
+
export type MeasureState = 'pass' | 'fail' | 'undeclared' | 'refused' | 'not-measurable';
|
|
251
|
+
|
|
252
|
+
export interface MeasureRow {
|
|
253
|
+
/** Stable code, e.g. `MQ_COVERAGE`. */
|
|
254
|
+
code: string;
|
|
255
|
+
/** The object measured: attachment always; region when the row is a region's. */
|
|
256
|
+
object: { attachment: AttachmentRef; region: string | null };
|
|
257
|
+
state: MeasureState;
|
|
258
|
+
/** The measured value: present on pass, fail and undeclared; null otherwise. */
|
|
259
|
+
value: number | null;
|
|
260
|
+
/** The declared bound, inclusive: present on pass and fail; null on undeclared, refused, not-measurable. */
|
|
261
|
+
bound: { op: '<=' | '>='; value: number } | null;
|
|
262
|
+
unit: 'px' | 'world' | 'fraction' | 'degrees' | 'ratio' | 'count';
|
|
263
|
+
/** Where the worst value was taken: present whenever `value` is. `{ at: {} }` when nothing is worse than ideal. */
|
|
264
|
+
worst: WorstSample | null;
|
|
265
|
+
/** Required on refused and not-measurable: a sentence naming what was missing. */
|
|
266
|
+
reason: string | null;
|
|
267
|
+
/** Raster and sampled rows: the art the row was taken against — never read across thresholds. */
|
|
268
|
+
art?: { threshold: number; connectivity: 4 | 8 | null; samples: number };
|
|
269
|
+
/** Raster rows only — correction 2. */
|
|
270
|
+
raster?: RasterSensitivity;
|
|
271
|
+
/** Sampled rows — correction 2. */
|
|
272
|
+
sampling?: { domain: string; count: number };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Correction 2: the spatial grid and the value's own granularity are two fields. */
|
|
276
|
+
export interface RasterSensitivity {
|
|
277
|
+
grid: { width: number; height: number; pageScale: number };
|
|
278
|
+
/** One texel, in the drawing's pixels: 1 / pageScale. */
|
|
279
|
+
spatialQuantum: number;
|
|
280
|
+
/** The smallest step the VALUE can take, in the row's own unit. */
|
|
281
|
+
valueIncrement: number;
|
|
282
|
+
/** A diagnostic: it changes no verdict and bounds no error. */
|
|
283
|
+
nearBound: 'at-bound' | 'within-increment' | 'clear';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface WorstSample {
|
|
287
|
+
at: { pixel?: [number, number]; triangle?: number; edge?: [number, number]; vertex?: number; uv?: [number, number] };
|
|
288
|
+
/** Motion rows only — P7, P11. */
|
|
289
|
+
frame?: FrameRef;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** P7/P11: a frame named by a stable id carrying animation, phase and time. Stage C. */
|
|
293
|
+
export interface FrameRef {
|
|
294
|
+
id: string;
|
|
295
|
+
animation: string | null;
|
|
296
|
+
phase: 'grid' | 'irr' | null;
|
|
297
|
+
time: number | null;
|
|
298
|
+
index: number | null;
|
|
299
|
+
role: 'selection' | 'held-out' | 'baseline';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface EvidenceSection {
|
|
303
|
+
rows: MeasureRow[];
|
|
304
|
+
/** Counts per state. `measured` = pass + fail. Never summed across sections. */
|
|
305
|
+
summary: { pass: number; fail: number; undeclared: number; refused: number; notMeasurable: number; measured: number };
|
|
306
|
+
/** pass only when every REQUIRED row is pass AND at least one row is pass. */
|
|
307
|
+
verdict: 'pass' | 'fail' | 'not-measured';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** §3's motion bounds. Stage C. */
|
|
311
|
+
export interface MotionBounds {
|
|
312
|
+
maxLocalDeformation: number;
|
|
313
|
+
maxStretch?: number;
|
|
314
|
+
minStretch?: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** §3's schedule. Stage C. */
|
|
318
|
+
export interface MotionSchedule {
|
|
319
|
+
frames: Array<'setup' | { animation: string; times: number[] } | { animation: string; fps: number }>;
|
|
320
|
+
phases: Array<'grid' | 'irr'>;
|
|
321
|
+
physics: { mode: 'none' } | { mode: 'step'; dt: number; warmupSteps: 0 };
|
|
322
|
+
selection: string[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The schedule a motion section was measured under. The contract names this
|
|
327
|
+
* type without defining it; until stage C does, it is the schedule as given.
|
|
328
|
+
*/
|
|
329
|
+
export type ScheduleUsed = MotionSchedule;
|
|
330
|
+
|
|
331
|
+
/** One candidate's evidence — correction 1. `reduce` and `measure` carry exactly one. */
|
|
332
|
+
export interface CandidateReport {
|
|
333
|
+
/** The caller's id for the candidate, or `result` for a `reduce`. */
|
|
334
|
+
id: string;
|
|
335
|
+
counts: MeshCounts | null;
|
|
336
|
+
/** Null when no mesh exists to measure (correction 3). */
|
|
337
|
+
geometry: EvidenceSection | null;
|
|
338
|
+
/** Null when no motion was asked for — never an empty PASS (P6). */
|
|
339
|
+
motion: (EvidenceSection & { schedule: ScheduleUsed }) | null;
|
|
340
|
+
/** P6 — declared-contract acceptance. */
|
|
341
|
+
accepted: boolean;
|
|
342
|
+
/** Opt-in (P7): every row's value at every frame. Absent unless asked for. */
|
|
343
|
+
perFrame?: Array<{ code: string; frame: string; value: number | null }>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface MeshQualityReport {
|
|
347
|
+
spec: typeof MESH_QUALITY_REPORT_SPEC;
|
|
348
|
+
operation: 'reduce' | 'measure' | 'compare';
|
|
349
|
+
/** Correction 1: the inputs echoed with their structure. */
|
|
350
|
+
effective: EffectiveSettings;
|
|
351
|
+
/** P2: which poser ran and its version. Null on geometry-only operations. */
|
|
352
|
+
poser: { kind: 'core'; rigcVersion: string } | null;
|
|
353
|
+
/** Whether the caller required motion evidence (P6). */
|
|
354
|
+
motionRequired: boolean;
|
|
355
|
+
/** Null when the source could not be read as a mesh (correction 3: counts are never invented). */
|
|
356
|
+
sourceCounts: MeshCounts | null;
|
|
357
|
+
reference: CandidateReport | null;
|
|
358
|
+
candidates: CandidateReport[];
|
|
359
|
+
termination: Termination | null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Correction 1: every input the text requires, echoed field for field. */
|
|
363
|
+
export interface EffectiveSettings {
|
|
364
|
+
preset: { name: string; version: string } | null;
|
|
365
|
+
attachments: Array<{
|
|
366
|
+
attachment: AttachmentRef;
|
|
367
|
+
threshold: number;
|
|
368
|
+
finalThreshold: number;
|
|
369
|
+
frame: SourceFrame;
|
|
370
|
+
maskSize: [number, number];
|
|
371
|
+
minArtSamples: number;
|
|
372
|
+
/** P9: each region's own floor, in the order the regions were given. */
|
|
373
|
+
regions: Array<{ name: string; minArtSamples: number }>;
|
|
374
|
+
}>;
|
|
375
|
+
sourceBounds: ArtFitBounds | null;
|
|
376
|
+
referenceArtFit: ArtFitBounds | null;
|
|
377
|
+
candidateArtFit: ArtFitBounds | null;
|
|
378
|
+
targets: ReductionTargets | MeasureTargets | null;
|
|
379
|
+
/** The hull polygon `MQ_BOUNDARY_DEVIATION` was taken against on a `measure`; null when none was given. */
|
|
380
|
+
referenceHull: Array<[number, number]> | null;
|
|
381
|
+
motionBounds: MotionBounds | null;
|
|
382
|
+
protect: ProtectedFeatures | null;
|
|
383
|
+
influences: InfluenceLimits | null;
|
|
384
|
+
boneOrder: string[] | null;
|
|
385
|
+
schedule: MotionSchedule | null;
|
|
386
|
+
budget: { maxCandidates: number } | null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface MeshCounts {
|
|
390
|
+
boundaryVertices: number;
|
|
391
|
+
interiorVertices: number;
|
|
392
|
+
triangles: number;
|
|
393
|
+
/** Total `{ bone, weight }` entries over all vertices; 0 on an unweighted mesh. */
|
|
394
|
+
bindings: number;
|
|
395
|
+
/** The most entries any one vertex carries; 0 on an unweighted mesh. */
|
|
396
|
+
maxInfluences: number;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** *Termination reasons*. Every `reduce` report carries exactly one; a `measure` carries one only when it could not read the mesh. */
|
|
400
|
+
export type Termination =
|
|
401
|
+
| { reason: 'no-further-valid-reduction'; candidatesTried: number; blockingConstraint: string }
|
|
402
|
+
| { reason: 'budget-exhausted'; candidatesTried: number; budget: number; result: 'best-meeting-every-bound' | 'none-met-the-targets' }
|
|
403
|
+
| { reason: 'invalid-input'; code: string; detail: string }
|
|
404
|
+
| { reason: 'unsupported-topology'; code: string; detail: string };
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// fixed tolerances
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/** A22's slack on a region UV (`src/assertions/bodies/a22.ts`): outside 0..1 by more than this is refused. */
|
|
411
|
+
const UV_RANGE_SLACK = 1e-6;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* How close the two-sided Hausdorff search brings its lower and upper bounds
|
|
415
|
+
* before it stops, in drawing px. The value reported is then put on the `r6`
|
|
416
|
+
* grid, six decimals, so the search is exact to three orders below what is
|
|
417
|
+
* printed.
|
|
418
|
+
*/
|
|
419
|
+
const HAUSDORFF_TOLERANCE = 1e-9;
|
|
420
|
+
|
|
421
|
+
/** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
|
|
422
|
+
const ON_BOUNDARY = 1e-9;
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// refusals
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** `skin/slot/attachment`, the way every message names the attachment. */
|
|
429
|
+
function nameOf(ref: AttachmentRef): string {
|
|
430
|
+
return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function refuse(code: string, message: string): never {
|
|
434
|
+
throw new MeshReductionError(code, message);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
438
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
442
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isPoint(value: unknown): value is [number, number] {
|
|
446
|
+
return Array.isArray(value) && value.length === 2 && isFiniteNumber(value[0]) && isFiniteNumber(value[1]);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** A field that has to be present: `undefined` is a refusal naming it. */
|
|
450
|
+
function present(owner: string, field: string, value: unknown, required: string): void {
|
|
451
|
+
if (value === undefined) refuse('REDUCE_INPUT_MISSING', `${owner}: ${field} is missing; required ${required} — no field has a default`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Everything §1 refuses before a pixel is read, by code. The order is the order
|
|
456
|
+
* a fix has to happen in: the attachment's identity, then the art, then the
|
|
457
|
+
* mesh, then the bounds.
|
|
458
|
+
*/
|
|
459
|
+
function validateInput(input: MeshMeasureInput): void {
|
|
460
|
+
if (!isObject(input)) refuse('REDUCE_INPUT_MISSING', `the input is ${JSON.stringify(input)}; required a MeshMeasureInput object`);
|
|
461
|
+
const ref = input.attachment;
|
|
462
|
+
if (!isObject(ref) || typeof ref.slot !== 'string' || ref.slot === '' || typeof ref.attachment !== 'string' || ref.attachment === '' || !(ref.skin === null || typeof ref.skin === 'string')) {
|
|
463
|
+
refuse('REDUCE_INPUT_MISSING', `attachment is ${JSON.stringify(ref)}; required { skin: string | null, slot: string, attachment: string } with a slot and an attachment name`);
|
|
464
|
+
}
|
|
465
|
+
const who = `attachment ${nameOf(ref)}`;
|
|
466
|
+
if (typeof input.id !== 'string' || input.id === '') refuse('REDUCE_INPUT_MISSING', `${who}: id is ${JSON.stringify(input.id)}; required a non-empty string naming the mesh measured`);
|
|
467
|
+
|
|
468
|
+
// The art.
|
|
469
|
+
const art = input.art;
|
|
470
|
+
present(who, 'art', art, '{ mask, threshold, frame }');
|
|
471
|
+
if (!isObject(art)) refuse('REDUCE_INPUT_MISSING', `${who}: art is ${JSON.stringify(art)}; required { mask, threshold, frame }`);
|
|
472
|
+
present(who, 'art.threshold', art.threshold, 'a whole number in 1..255');
|
|
473
|
+
if (!Number.isInteger(art.threshold) || art.threshold < 1 || art.threshold > 255) {
|
|
474
|
+
refuse('REDUCE_THRESHOLD_RANGE', `${who}: art.threshold is ${JSON.stringify(art.threshold)}; required a whole number in 1..255 (art is alpha >= threshold)`);
|
|
475
|
+
}
|
|
476
|
+
const frame = art.frame;
|
|
477
|
+
present(who, 'art.frame', frame, '{ space, width, height, pageScale, conversion }');
|
|
478
|
+
if (!isObject(frame)) refuse('REDUCE_INPUT_MISSING', `${who}: art.frame is ${JSON.stringify(frame)}; required { space, width, height, pageScale, conversion }`);
|
|
479
|
+
if (frame.space !== 'part-local-drawing-px-y-down') refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.space is ${JSON.stringify(frame.space)}; required "part-local-drawing-px-y-down"`);
|
|
480
|
+
if (frame.conversion !== 'texels = px * pageScale') refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.conversion is ${JSON.stringify(frame.conversion)}; required "texels = px * pageScale"`);
|
|
481
|
+
for (const field of ['width', 'height', 'pageScale'] as const) {
|
|
482
|
+
const v = frame[field];
|
|
483
|
+
if (!isFiniteNumber(v) || v <= 0) refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.${field} is ${JSON.stringify(v)}; required a finite number above 0`);
|
|
484
|
+
}
|
|
485
|
+
const mask = art.mask;
|
|
486
|
+
present(who, 'art.mask', mask, '{ width, height, alpha }');
|
|
487
|
+
if (!isObject(mask) || !(mask.alpha instanceof Uint8Array)) refuse('REDUCE_INPUT_MISSING', `${who}: art.mask is not { width, height, alpha: Uint8Array }; required an AlphaMask`);
|
|
488
|
+
if (!Number.isInteger(mask.width) || !Number.isInteger(mask.height) || mask.width < 1 || mask.height < 1) {
|
|
489
|
+
refuse('REDUCE_MASK_SIZE', `${who}: the mask is ${mask.width}x${mask.height}; required whole dimensions of at least 1x1`);
|
|
490
|
+
}
|
|
491
|
+
if (mask.alpha.length !== mask.width * mask.height) {
|
|
492
|
+
refuse('REDUCE_MASK_SIZE', `${who}: the mask holds ${mask.alpha.length} bytes for ${mask.width}x${mask.height}; required width × height = ${mask.width * mask.height}`);
|
|
493
|
+
}
|
|
494
|
+
// Correction 1: the mask has to be THIS attachment's — its grid is the frame's, by the stated scale.
|
|
495
|
+
const wantW = r6(frame.width * frame.pageScale);
|
|
496
|
+
const wantH = r6(frame.height * frame.pageScale);
|
|
497
|
+
if (mask.width !== wantW || mask.height !== wantH) {
|
|
498
|
+
refuse(
|
|
499
|
+
'REDUCE_MASK_SIZE',
|
|
500
|
+
`${who}: the mask is ${mask.width}x${mask.height} and the frame is ${frame.width}x${frame.height} px at pageScale ${frame.pageScale}; ` +
|
|
501
|
+
`required a ${wantW}x${wantH} mask (frame × pageScale) — a mask is one attachment's and is never shared with another of a different size`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// The mesh.
|
|
506
|
+
const src = input.source;
|
|
507
|
+
present(who, 'source', src, '{ points, uvs, triangles, hull, weights }');
|
|
508
|
+
if (!isObject(src) || !Array.isArray(src.points) || !Array.isArray(src.uvs) || !Array.isArray(src.triangles)) {
|
|
509
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights } with three arrays`);
|
|
510
|
+
}
|
|
511
|
+
src.points.forEach((p, i) => {
|
|
512
|
+
if (!isPoint(p)) refuse('REDUCE_INPUT_MISSING', `${who}: source.points[${i}] is ${JSON.stringify(p)}; required a pair of finite numbers`);
|
|
513
|
+
});
|
|
514
|
+
if (src.uvs.length !== 2 * src.points.length) {
|
|
515
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: source.uvs holds ${src.uvs.length} numbers for ${src.points.length} points; required one u, v pair per point (${2 * src.points.length})`);
|
|
516
|
+
}
|
|
517
|
+
src.uvs.forEach((u, i) => {
|
|
518
|
+
if (!isFiniteNumber(u) || u < -UV_RANGE_SLACK || u > 1 + UV_RANGE_SLACK) {
|
|
519
|
+
refuse('REDUCE_UV_RANGE', `${who}: source.uvs[${i}] (vertex ${Math.floor(i / 2)}, ${i % 2 === 0 ? 'u' : 'v'}) is ${u}; required 0..1 within ${UV_RANGE_SLACK} (A22)`);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
if (!Number.isInteger(src.hull)) refuse('REDUCE_INPUT_MISSING', `${who}: source.hull is ${JSON.stringify(src.hull)}; required the whole number of hull vertices`);
|
|
523
|
+
if (src.weights !== null) {
|
|
524
|
+
if (!Array.isArray(src.weights) || src.weights.length !== src.points.length) {
|
|
525
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: source.weights is not one binding list per point (${src.points.length}); required that, or null for an unweighted mesh`);
|
|
526
|
+
}
|
|
527
|
+
src.weights.forEach((vertex, i) => {
|
|
528
|
+
if (!Array.isArray(vertex) || vertex.some((b) => !isObject(b) || typeof b.bone !== 'string' || !isFiniteNumber(b.weight))) {
|
|
529
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] is not a list of { bone, weight }; required that`);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The bounds.
|
|
535
|
+
const targets = input.targets;
|
|
536
|
+
present(who, 'targets', targets, '{ artFit, maxBoundaryDeviation, regions }');
|
|
537
|
+
if (!isObject(targets)) refuse('REDUCE_INPUT_MISSING', `${who}: targets is ${JSON.stringify(targets)}; required { artFit, maxBoundaryDeviation, regions }`);
|
|
538
|
+
present(who, 'targets.artFit', targets.artFit, 'an ArtFitBounds or null');
|
|
539
|
+
if (targets.artFit !== null) {
|
|
540
|
+
const fit = targets.artFit;
|
|
541
|
+
const coverage = fit.minCoverage;
|
|
542
|
+
if (!isFiniteNumber(coverage) || coverage < 0 || coverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.minCoverage is ${JSON.stringify(coverage)}; required a fraction in 0..1`);
|
|
543
|
+
for (const field of ['maxOvershoot', 'maxUndercut'] as const) {
|
|
544
|
+
const v = fit[field];
|
|
545
|
+
if (!isFiniteNumber(v) || v < 0) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
present(who, 'targets.maxBoundaryDeviation', targets.maxBoundaryDeviation, 'a number of px or null');
|
|
549
|
+
if (targets.maxBoundaryDeviation !== null && (!isFiniteNumber(targets.maxBoundaryDeviation) || targets.maxBoundaryDeviation < 0)) {
|
|
550
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: targets.maxBoundaryDeviation is ${JSON.stringify(targets.maxBoundaryDeviation)}; required a finite number of px, 0 or more, or null`);
|
|
551
|
+
}
|
|
552
|
+
if (targets.minAngle !== undefined && (!isFiniteNumber(targets.minAngle) || targets.minAngle < 0)) {
|
|
553
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: targets.minAngle is ${JSON.stringify(targets.minAngle)}; required a finite number of degrees, 0 or more, or the field left out`);
|
|
554
|
+
}
|
|
555
|
+
present(who, 'targets.regions', targets.regions, 'a list of regions (empty when none)');
|
|
556
|
+
if (!Array.isArray(targets.regions)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions is ${JSON.stringify(targets.regions)}; required a list (empty when none)`);
|
|
557
|
+
present(who, 'referenceHull', input.referenceHull, 'a polygon or null');
|
|
558
|
+
if (input.referenceHull !== null) {
|
|
559
|
+
if (!Array.isArray(input.referenceHull) || input.referenceHull.length < 3 || !input.referenceHull.every(isPoint)) {
|
|
560
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: referenceHull is not a polygon of at least 3 finite points; required that, or null`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// P9: the sample floors.
|
|
565
|
+
present(who, 'minArtSamples', input.minArtSamples, 'a whole number >= 1');
|
|
566
|
+
if (!Number.isInteger(input.minArtSamples) || input.minArtSamples < 1) {
|
|
567
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: minArtSamples is ${JSON.stringify(input.minArtSamples)}; required a whole number >= 1 (P9, no hidden constant)`);
|
|
568
|
+
}
|
|
569
|
+
present(who, 'regionArtSamples', input.regionArtSamples, 'one { region, minArtSamples } per region');
|
|
570
|
+
if (!Array.isArray(input.regionArtSamples)) refuse('REDUCE_INPUT_MISSING', `${who}: regionArtSamples is not a list; required one { region, minArtSamples } per region`);
|
|
571
|
+
const names = new Set<string>();
|
|
572
|
+
targets.regions.forEach((region, i) => {
|
|
573
|
+
if (!isObject(region) || typeof region.name !== 'string' || region.name === '') {
|
|
574
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions[${i}] has no name; required a non-empty name, which every row of the region is keyed by`);
|
|
575
|
+
}
|
|
576
|
+
if (names.has(region.name)) refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" is named twice; required one name per region, which its rows and its sample floor are keyed by`);
|
|
577
|
+
names.add(region.name);
|
|
578
|
+
if (!Array.isArray(region.polygon) || region.polygon.length < 3) {
|
|
579
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" has a polygon of ${Array.isArray(region.polygon) ? region.polygon.length : 'no'} vertices; required at least 3`);
|
|
580
|
+
}
|
|
581
|
+
for (const field of ['maxEdgeLength', 'transition', 'grade'] as const) present(`${who}, region "${region.name}"`, field, region[field], 'a finite number');
|
|
582
|
+
present(`${who}, region "${region.name}"`, 'approximation', region.approximation, 'an approximation or null');
|
|
583
|
+
const floors = input.regionArtSamples.filter((f) => isObject(f) && f.region === region.name);
|
|
584
|
+
if (floors.length !== 1 || !Number.isInteger(floors[0].minArtSamples) || floors[0].minArtSamples < 1) {
|
|
585
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" has ${floors.length} sample floor(s) in regionArtSamples; required exactly one, a whole number >= 1 (P9)`);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
input.regionArtSamples.forEach((f, i) => {
|
|
589
|
+
if (!isObject(f) || !names.has(f.region)) refuse('REDUCE_INPUT_MISSING', `${who}: regionArtSamples[${i}] names ${JSON.stringify(isObject(f) ? f.region : f)}, which no region is; required a floor for a declared region`);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Echoed, never read — but present, since nothing has a default.
|
|
593
|
+
for (const field of ['protect', 'influences', 'boneOrder', 'preset'] as const) present(who, field, input[field], 'a value or null');
|
|
594
|
+
if (input.protect !== null) {
|
|
595
|
+
const p = input.protect;
|
|
596
|
+
const ok =
|
|
597
|
+
isObject(p) &&
|
|
598
|
+
typeof p.hull === 'boolean' &&
|
|
599
|
+
Array.isArray(p.vertices) &&
|
|
600
|
+
Array.isArray(p.edges) &&
|
|
601
|
+
Array.isArray(p.regionBoundaries) &&
|
|
602
|
+
(p.weightJump === null || isFiniteNumber(p.weightJump)) &&
|
|
603
|
+
Array.isArray(p.influences);
|
|
604
|
+
if (!ok) refuse('REDUCE_INPUT_MISSING', `${who}: protect is not a ProtectedFeatures with every field; required { hull, vertices, edges, regionBoundaries, weightJump, influences }, or null`);
|
|
605
|
+
}
|
|
606
|
+
if (input.influences !== null && (!isObject(input.influences) || !isFiniteNumber(input.influences.maxInfluences) || !isFiniteNumber(input.influences.minWeight))) {
|
|
607
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: influences is not { maxInfluences, minWeight }; required that, or null`);
|
|
608
|
+
}
|
|
609
|
+
if (input.boneOrder !== null && (!Array.isArray(input.boneOrder) || input.boneOrder.some((b) => typeof b !== 'string'))) {
|
|
610
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder is not a list of bone names; required that, or null`);
|
|
611
|
+
}
|
|
612
|
+
if (input.preset !== null && (!isObject(input.preset) || typeof input.preset.name !== 'string' || typeof input.preset.version !== 'string')) {
|
|
613
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// plane geometry
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
type Pt = readonly [number, number];
|
|
622
|
+
|
|
623
|
+
/** Is `p` inside or on the closed polygon? On the boundary within `ON_BOUNDARY` counts as inside. */
|
|
624
|
+
function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
625
|
+
const n = poly.length;
|
|
626
|
+
for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
|
|
627
|
+
let inside = false;
|
|
628
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
629
|
+
const [xi, yi] = poly[i];
|
|
630
|
+
const [xj, yj] = poly[j];
|
|
631
|
+
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
|
632
|
+
}
|
|
633
|
+
return inside;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Does the segment `a`–`b` meet the closed polygon — an endpoint inside or on it, or any crossing or touch? */
|
|
637
|
+
function segmentMeetsPolygon(a: Pt, b: Pt, poly: readonly Pt[]): boolean {
|
|
638
|
+
if (inClosedPolygon(a, poly) || inClosedPolygon(b, poly)) return true;
|
|
639
|
+
const n = poly.length;
|
|
640
|
+
for (let i = 0; i < n; i++) if (segmentsMeet(a, b, poly[i], poly[(i + 1) % n])) return true;
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Distance between two segments that do not meet: the nearest of the four endpoint-to-segment distances. */
|
|
645
|
+
function segmentDistance(a: Pt, b: Pt, c: Pt, d: Pt): number {
|
|
646
|
+
if (segmentsMeet(a, b, c, d)) return 0;
|
|
647
|
+
return Math.min(distanceToSegment(a, c, d), distanceToSegment(b, c, d), distanceToSegment(c, a, b), distanceToSegment(d, a, b));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Distance from a segment to a closed polygon's region: 0 when they meet, else to its nearest edge. */
|
|
651
|
+
function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
|
|
652
|
+
if (segmentMeetsPolygon(a, b, poly)) return 0;
|
|
653
|
+
let best = Infinity;
|
|
654
|
+
const n = poly.length;
|
|
655
|
+
for (let i = 0; i < n; i++) best = Math.min(best, segmentDistance(a, b, poly[i], poly[(i + 1) % n]));
|
|
656
|
+
return best;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
|
|
660
|
+
function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
|
|
661
|
+
let d = Infinity;
|
|
662
|
+
let edge = 0;
|
|
663
|
+
const n = poly.length;
|
|
664
|
+
for (let i = 0; i < n; i++) {
|
|
665
|
+
const v = distanceToSegment(p, poly[i], poly[(i + 1) % n]);
|
|
666
|
+
if (v < d) {
|
|
667
|
+
d = v;
|
|
668
|
+
edge = i;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return { d, edge };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The directed Hausdorff distance from the boundary of `a` to the boundary of
|
|
676
|
+
* `b` — the furthest any point of `a`'s outline sits from `b`'s — and where.
|
|
677
|
+
*
|
|
678
|
+
* Exact up to `HAUSDORFF_TOLERANCE`, by branch and bound along each edge of
|
|
679
|
+
* `a`. The distance to one edge of `b` is convex along a segment, so on any
|
|
680
|
+
* piece of an edge of `a` it is at most the larger of its two end values; the
|
|
681
|
+
* distance to the whole of `b` is the minimum over `b`'s edges, so the smallest
|
|
682
|
+
* of those maxima bounds it from above. A piece whose bound cannot beat the best
|
|
683
|
+
* value already found is dropped; the rest are halved. No sampling density is
|
|
684
|
+
* chosen, so nothing here can miss a maximum between two samples.
|
|
685
|
+
*/
|
|
686
|
+
function directedHausdorff(a: readonly Pt[], b: readonly Pt[]): { d: number; edge: number; at: Pt } {
|
|
687
|
+
const m = b.length;
|
|
688
|
+
const toEach = (p: Pt): Float64Array => {
|
|
689
|
+
const out = new Float64Array(m);
|
|
690
|
+
for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
|
|
691
|
+
return out;
|
|
692
|
+
};
|
|
693
|
+
const minOf = (g: Float64Array): number => {
|
|
694
|
+
let v = Infinity;
|
|
695
|
+
for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
|
|
696
|
+
return v;
|
|
697
|
+
};
|
|
698
|
+
let best = -1;
|
|
699
|
+
let bestEdge = 0;
|
|
700
|
+
let bestAt: Pt = a[0];
|
|
701
|
+
const n = a.length;
|
|
702
|
+
for (let i = 0; i < n; i++) {
|
|
703
|
+
const p0 = a[i];
|
|
704
|
+
const p1 = a[(i + 1) % n];
|
|
705
|
+
const at = (t: number): Pt => [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t];
|
|
706
|
+
const consider = (f: number, t: number): void => {
|
|
707
|
+
if (f > best) {
|
|
708
|
+
best = f;
|
|
709
|
+
bestEdge = i;
|
|
710
|
+
bestAt = at(t);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
const g0 = toEach(p0);
|
|
714
|
+
const g1 = toEach(p1);
|
|
715
|
+
consider(minOf(g0), 0);
|
|
716
|
+
consider(minOf(g1), 1);
|
|
717
|
+
const stack: Array<{ t0: number; t1: number; g0: Float64Array; g1: Float64Array; depth: number }> = [{ t0: 0, t1: 1, g0, g1, depth: 0 }];
|
|
718
|
+
while (stack.length > 0) {
|
|
719
|
+
const piece = stack.pop()!;
|
|
720
|
+
let upper = Infinity;
|
|
721
|
+
for (let j = 0; j < m; j++) upper = Math.min(upper, Math.max(piece.g0[j], piece.g1[j]));
|
|
722
|
+
if (upper <= best + HAUSDORFF_TOLERANCE || piece.depth >= 64) continue;
|
|
723
|
+
const tm = (piece.t0 + piece.t1) / 2;
|
|
724
|
+
const gm = toEach(at(tm));
|
|
725
|
+
consider(minOf(gm), tm);
|
|
726
|
+
stack.push({ t0: piece.t0, t1: tm, g0: piece.g0, g1: gm, depth: piece.depth + 1 });
|
|
727
|
+
stack.push({ t0: tm, t1: piece.t1, g0: gm, g1: piece.g1, depth: piece.depth + 1 });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return { d: Math.max(best, 0), edge: bestEdge, at: bestAt };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The symmetric Hausdorff distance between two closed polygons' boundaries,
|
|
735
|
+
* with the candidate hull edge the worst value belongs to: the edge it was
|
|
736
|
+
* taken on when the worst point is the candidate's, else the candidate edge
|
|
737
|
+
* nearest the reference's worst point.
|
|
738
|
+
*/
|
|
739
|
+
function hausdorff(candidate: readonly Pt[], reference: readonly Pt[]): { d: number; candidateEdge: number } {
|
|
740
|
+
const forward = directedHausdorff(candidate, reference);
|
|
741
|
+
const backward = directedHausdorff(reference, candidate);
|
|
742
|
+
if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
|
|
743
|
+
return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
// rows
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
/** A row as it is being built: the report's row, and whether the caller's declared contract requires it (P6). */
|
|
751
|
+
interface Built {
|
|
752
|
+
row: MeasureRow;
|
|
753
|
+
required: boolean;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function nearBoundOf(value: number | null, bound: MeasureRow['bound'], increment: number): RasterSensitivity['nearBound'] {
|
|
757
|
+
if (value === null || bound === null) return 'clear';
|
|
758
|
+
const gap = Math.abs(value - bound.value);
|
|
759
|
+
if (gap === 0) return 'at-bound';
|
|
760
|
+
return gap <= increment ? 'within-increment' : 'clear';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Pass or fail against an inclusive bound — equality passes. */
|
|
764
|
+
function judged(value: number, bound: { op: '<=' | '>='; value: number }): 'pass' | 'fail' {
|
|
765
|
+
return (bound.op === '<=' ? value <= bound.value : value >= bound.value) ? 'pass' : 'fail';
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const NOTHING_WORSE: WorstSample = { at: {} };
|
|
769
|
+
|
|
770
|
+
interface RowSpec {
|
|
771
|
+
code: string;
|
|
772
|
+
region: string | null;
|
|
773
|
+
unit: MeasureRow['unit'];
|
|
774
|
+
attachment: AttachmentRef;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function measuredRow(spec: RowSpec, value: number, bound: MeasureRow['bound'], worst: WorstSample, required: boolean): Built {
|
|
778
|
+
return {
|
|
779
|
+
row: {
|
|
780
|
+
code: spec.code,
|
|
781
|
+
object: { attachment: spec.attachment, region: spec.region },
|
|
782
|
+
state: bound === null ? 'undeclared' : judged(value, bound),
|
|
783
|
+
value,
|
|
784
|
+
bound,
|
|
785
|
+
unit: spec.unit,
|
|
786
|
+
worst,
|
|
787
|
+
reason: null,
|
|
788
|
+
},
|
|
789
|
+
required: required && bound !== null,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function unmeasuredRow(spec: RowSpec, state: 'refused' | 'not-measurable', reason: string, required: boolean): Built {
|
|
794
|
+
return {
|
|
795
|
+
row: { code: spec.code, object: { attachment: spec.attachment, region: spec.region }, state, value: null, bound: null, unit: spec.unit, worst: null, reason },
|
|
796
|
+
required,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The raster fields of a row: its art, and the grid and increment its value is quantised on (correction 2). */
|
|
801
|
+
function withRaster(built: Built, art: NonNullable<MeasureRow['art']>, grid: RasterSensitivity['grid'], increment: number): Built {
|
|
802
|
+
const row = built.row;
|
|
803
|
+
row.art = art;
|
|
804
|
+
row.raster = {
|
|
805
|
+
grid,
|
|
806
|
+
spatialQuantum: 1 / grid.pageScale,
|
|
807
|
+
valueIncrement: increment,
|
|
808
|
+
nearBound: nearBoundOf(row.value, row.bound, increment),
|
|
809
|
+
};
|
|
810
|
+
return built;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Row order (§2): code, then region (the attachment's own row first), then the gated fill before the labelled legacy one. */
|
|
814
|
+
function compareRows(a: MeasureRow, b: MeasureRow): number {
|
|
815
|
+
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
|
|
816
|
+
const ra = a.object.region;
|
|
817
|
+
const rb = b.object.region;
|
|
818
|
+
if (ra !== rb) {
|
|
819
|
+
if (ra === null) return -1;
|
|
820
|
+
if (rb === null) return 1;
|
|
821
|
+
return ra < rb ? -1 : 1;
|
|
822
|
+
}
|
|
823
|
+
const ca = a.art?.connectivity ?? 0;
|
|
824
|
+
const cb = b.art?.connectivity ?? 0;
|
|
825
|
+
return cb - ca;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// the operation
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Measure one mesh against the art its attachment draws and the bounds the
|
|
834
|
+
* caller declared — §4's geometry rows, no motion. Throws a
|
|
835
|
+
* `MeshReductionError` for an input §1 refuses; reports a source whose
|
|
836
|
+
* triangles are not one closed loop rather than throwing, with `sourceCounts`
|
|
837
|
+
* null and an `unsupported-topology` termination saying why (correction 3: a
|
|
838
|
+
* count is never invented).
|
|
839
|
+
*/
|
|
840
|
+
export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
841
|
+
validateInput(input);
|
|
842
|
+
const { attachment, art, source, targets } = input;
|
|
843
|
+
const { mask, threshold, frame } = art;
|
|
844
|
+
const scale = frame.pageScale;
|
|
845
|
+
const effective = effectiveOf(input);
|
|
846
|
+
const report = (sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination | null): MeshQualityReport => ({
|
|
847
|
+
spec: MESH_QUALITY_REPORT_SPEC,
|
|
848
|
+
operation: 'measure',
|
|
849
|
+
effective,
|
|
850
|
+
poser: null,
|
|
851
|
+
motionRequired: false,
|
|
852
|
+
sourceCounts,
|
|
853
|
+
reference: null,
|
|
854
|
+
candidates: [candidate],
|
|
855
|
+
termination,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// The outline the triangles state — or the reason there is none.
|
|
859
|
+
const n = source.points.length;
|
|
860
|
+
let outline: MeshOutline;
|
|
861
|
+
try {
|
|
862
|
+
outline = traceOutline(n, source.triangles);
|
|
863
|
+
if (outline.hull !== source.hull) {
|
|
864
|
+
throw new MeshError(`source.hull says ${source.hull} and the triangles' outline has ${outline.hull} vertices`);
|
|
865
|
+
}
|
|
866
|
+
checkHullOrder(outline, n);
|
|
867
|
+
} catch (err) {
|
|
868
|
+
if (!(err instanceof MeshError)) throw err;
|
|
869
|
+
const detail = `attachment ${nameOf(attachment)}: ${err.message}; required one closed outline listed first, in order (traceOutline, checkHullOrder)`;
|
|
870
|
+
return report(null, { id: input.id, counts: null, geometry: null, motion: null, accepted: false }, { reason: 'unsupported-topology', code: 'REDUCE_SOURCE_NOT_ONE_LOOP', detail });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const counts = countsOf(source, outline);
|
|
874
|
+
const rows: Built[] = [];
|
|
875
|
+
const spec = (code: string, unit: MeasureRow['unit'], region: string | null = null): RowSpec => ({ code, unit, region, attachment });
|
|
876
|
+
const points: Pt[] = source.points;
|
|
877
|
+
const hullPolygon: Pt[] = outline.walk.map((v) => points[v]);
|
|
878
|
+
|
|
879
|
+
// --- raster rows ---------------------------------------------------------
|
|
880
|
+
const w = mask.width;
|
|
881
|
+
const h = mask.height;
|
|
882
|
+
const grid = { width: w, height: h, pageScale: scale };
|
|
883
|
+
const artBits = artOf(mask, threshold);
|
|
884
|
+
let artCount = 0;
|
|
885
|
+
for (const bit of artBits) artCount += bit;
|
|
886
|
+
const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
|
|
887
|
+
const covered = rasteriseTriangles(onGrid, source.triangles, w, h);
|
|
888
|
+
const fit = targets.artFit;
|
|
889
|
+
const pxIncrement = 1 / scale;
|
|
890
|
+
const rasterArt = (connectivity: 4 | 8 | null): NonNullable<MeasureRow['art']> => ({ threshold, connectivity, samples: artCount });
|
|
891
|
+
const pixelOf = (i: number): [number, number] => [i % w, Math.floor(i / w)];
|
|
892
|
+
if (artCount < input.minArtSamples) {
|
|
893
|
+
const why = `attachment ${nameOf(attachment)} has ${artCount} art sample(s) at alpha >= ${threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`;
|
|
894
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_COVERAGE', 'fraction'), 'not-measurable', why, fit !== null), rasterArt(null), grid, artCount === 0 ? 1 : 1 / artCount));
|
|
895
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null), rasterArt(8), grid, pxIncrement));
|
|
896
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null), rasterArt(null), grid, pxIncrement));
|
|
897
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
|
|
898
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
|
|
899
|
+
} else {
|
|
900
|
+
// Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers.
|
|
901
|
+
let coveredArt = 0;
|
|
902
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
|
|
903
|
+
const toCovered = squaredDistanceToSet(covered, w, h);
|
|
904
|
+
const anyCovered = covered.some((c) => c === 1);
|
|
905
|
+
// Undercut: the furthest uncovered art pixel from the covered set. Its pixel is coverage's worst too.
|
|
906
|
+
let undercutAt = -1;
|
|
907
|
+
let undercutSq = 0;
|
|
908
|
+
for (let i = 0; i < artBits.length; i++) {
|
|
909
|
+
if (!artBits[i] || covered[i]) continue;
|
|
910
|
+
if (undercutAt === -1 || toCovered[i] > undercutSq) {
|
|
911
|
+
undercutAt = i;
|
|
912
|
+
undercutSq = toCovered[i];
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const missingWorst: WorstSample = undercutAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(undercutAt) } };
|
|
916
|
+
rows.push(
|
|
917
|
+
withRaster(
|
|
918
|
+
measuredRow(spec('MQ_COVERAGE', 'fraction'), coveredArt / artCount, fit === null ? null : { op: '>=', value: fit.minCoverage }, missingWorst, true),
|
|
919
|
+
rasterArt(null),
|
|
920
|
+
grid,
|
|
921
|
+
1 / artCount,
|
|
922
|
+
),
|
|
923
|
+
);
|
|
924
|
+
const undercutSpec = spec('MQ_UNDERCUT', 'px');
|
|
925
|
+
rows.push(
|
|
926
|
+
withRaster(
|
|
927
|
+
!anyCovered
|
|
928
|
+
? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null)
|
|
929
|
+
: measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
|
|
930
|
+
rasterArt(null),
|
|
931
|
+
grid,
|
|
932
|
+
pxIncrement,
|
|
933
|
+
),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
// Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
|
|
937
|
+
// where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
|
|
938
|
+
const fill8 = fillEnclosed(artBits, w, h, 8).filled;
|
|
939
|
+
const fill4 = fillEnclosed(artBits, w, h, 4).filled;
|
|
940
|
+
let fillsDiffer = false;
|
|
941
|
+
for (let i = 0; i < fill8.length; i++) {
|
|
942
|
+
if (fill8[i] !== fill4[i]) {
|
|
943
|
+
fillsDiffer = true;
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
const silhouetteRows = (filled: Uint8Array, connectivity: 4 | 8, gated: boolean): void => {
|
|
948
|
+
const toFilled = squaredDistanceToSet(filled, w, h);
|
|
949
|
+
let overAt = -1;
|
|
950
|
+
let overSq = 0;
|
|
951
|
+
let holes = 0;
|
|
952
|
+
let holeAt = -1;
|
|
953
|
+
for (let i = 0; i < covered.length; i++) {
|
|
954
|
+
if (!covered[i]) continue;
|
|
955
|
+
if (!filled[i]) {
|
|
956
|
+
if (overAt === -1 || toFilled[i] > overSq) {
|
|
957
|
+
overAt = i;
|
|
958
|
+
overSq = toFilled[i];
|
|
959
|
+
}
|
|
960
|
+
} else if (!artBits[i]) {
|
|
961
|
+
holes++;
|
|
962
|
+
if (holeAt === -1) holeAt = i;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
|
|
966
|
+
rows.push(
|
|
967
|
+
withRaster(
|
|
968
|
+
measuredRow(spec('MQ_OVERSHOOT', 'px'), r6(Math.sqrt(overSq) / scale), bound, overAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(overAt) } }, gated),
|
|
969
|
+
rasterArt(connectivity),
|
|
970
|
+
grid,
|
|
971
|
+
pxIncrement,
|
|
972
|
+
),
|
|
973
|
+
);
|
|
974
|
+
rows.push(withRaster(measuredRow(spec('MQ_HOLES', 'count'), holes, null, holeAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(holeAt) } }, false), rasterArt(connectivity), grid, 1));
|
|
975
|
+
};
|
|
976
|
+
silhouetteRows(fill8, 8, true);
|
|
977
|
+
if (fillsDiffer) silhouetteRows(fill4, 4, false);
|
|
978
|
+
|
|
979
|
+
// Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
|
|
980
|
+
const { label } = labelIslands(artBits, w, h);
|
|
981
|
+
const touched = new Set<number>();
|
|
982
|
+
for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
|
|
983
|
+
let joinedAt = -1;
|
|
984
|
+
if (touched.size >= 2) {
|
|
985
|
+
const second = [...touched].sort((p, q) => p - q)[1];
|
|
986
|
+
joinedAt = label.indexOf(second);
|
|
987
|
+
}
|
|
988
|
+
rows.push(
|
|
989
|
+
withRaster(
|
|
990
|
+
measuredRow(spec('MQ_ISLANDS', 'count'), touched.size, null, joinedAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(joinedAt) } }, false),
|
|
991
|
+
{ threshold, connectivity: 4, samples: artCount },
|
|
992
|
+
grid,
|
|
993
|
+
1,
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// --- the outline against a reference, and against the traced art -------------------------
|
|
999
|
+
const boundarySpec = spec('MQ_BOUNDARY_DEVIATION', 'px');
|
|
1000
|
+
const edgeOfHull = (i: number): [number, number] => [outline.walk[i], outline.walk[(i + 1) % outline.walk.length]];
|
|
1001
|
+
if (input.referenceHull === null) {
|
|
1002
|
+
rows.push(unmeasuredRow(boundarySpec, 'not-measurable', `attachment ${nameOf(attachment)}: no referenceHull was given, so there is no polygon to measure the hull's deviation from`, targets.maxBoundaryDeviation !== null));
|
|
1003
|
+
} else {
|
|
1004
|
+
const hd = hausdorff(hullPolygon, input.referenceHull);
|
|
1005
|
+
const bound = targets.maxBoundaryDeviation === null ? null : { op: '<=' as const, value: targets.maxBoundaryDeviation };
|
|
1006
|
+
rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
|
|
1007
|
+
}
|
|
1008
|
+
const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
|
|
1009
|
+
try {
|
|
1010
|
+
const traced = traceAlphaOutline(mask, threshold);
|
|
1011
|
+
const outlinePx = prunePolygon(traced.outline.map(([x, y]): [number, number] => [x / scale, y / scale]));
|
|
1012
|
+
const hd = hausdorff(hullPolygon, outlinePx);
|
|
1013
|
+
rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
if (!(err instanceof MeshError)) throw err;
|
|
1016
|
+
rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${err.message}`, false));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// --- triangles: sign, degeneracy, angle --------------------------------------------------
|
|
1020
|
+
const world: number[] = [];
|
|
1021
|
+
for (const [x, y] of points) world.push(x, cropToSpineY(y, frame.height));
|
|
1022
|
+
const areas = triangleAreas(world, source.triangles);
|
|
1023
|
+
const band = areaBand(areas, world);
|
|
1024
|
+
let flipped = 0;
|
|
1025
|
+
let flippedWorst = -1;
|
|
1026
|
+
let degenerate = 0;
|
|
1027
|
+
let degenerateWorst = -1;
|
|
1028
|
+
areas.forEach((a, t) => {
|
|
1029
|
+
if (Math.abs(a) <= band) {
|
|
1030
|
+
degenerate++;
|
|
1031
|
+
if (degenerateWorst === -1 || Math.abs(a) < Math.abs(areas[degenerateWorst])) degenerateWorst = t;
|
|
1032
|
+
} else if (a < 0) {
|
|
1033
|
+
flipped++;
|
|
1034
|
+
if (flippedWorst === -1 || a < areas[flippedWorst]) flippedWorst = t;
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
rows.push(measuredRow(spec('MQ_ORIENTATION', 'count'), flipped, { op: '<=', value: 0 }, flippedWorst === -1 ? NOTHING_WORSE : { at: { triangle: flippedWorst } }, true));
|
|
1038
|
+
rows.push(measuredRow(spec('MQ_DEGENERATE', 'count'), degenerate, { op: '<=', value: 0 }, degenerateWorst === -1 ? NOTHING_WORSE : { at: { triangle: degenerateWorst } }, true));
|
|
1039
|
+
let smallest = Infinity;
|
|
1040
|
+
let smallestAt = 0;
|
|
1041
|
+
for (let t = 0; t * 3 + 2 < source.triangles.length; t++) {
|
|
1042
|
+
const corner = [points[source.triangles[t * 3]], points[source.triangles[t * 3 + 1]], points[source.triangles[t * 3 + 2]]];
|
|
1043
|
+
for (let k = 0; k < 3; k++) {
|
|
1044
|
+
const o = corner[k];
|
|
1045
|
+
const p = corner[(k + 1) % 3];
|
|
1046
|
+
const q = corner[(k + 2) % 3];
|
|
1047
|
+
const ux = p[0] - o[0];
|
|
1048
|
+
const uy = p[1] - o[1];
|
|
1049
|
+
const vx = q[0] - o[0];
|
|
1050
|
+
const vy = q[1] - o[1];
|
|
1051
|
+
const angle = (Math.atan2(Math.abs(ux * vy - uy * vx), ux * vx + uy * vy) * 180) / Math.PI;
|
|
1052
|
+
if (angle < smallest) {
|
|
1053
|
+
smallest = angle;
|
|
1054
|
+
smallestAt = t;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
rows.push(
|
|
1059
|
+
measuredRow(spec('MQ_MIN_ANGLE', 'degrees'), r6(smallest), targets.minAngle === undefined ? null : { op: '>=', value: targets.minAngle }, { at: { triangle: smallestAt } }, true),
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
// --- regions (§5) ------------------------------------------------------------------------
|
|
1063
|
+
rows.push(...regionRows(input, outline, hullPolygon));
|
|
1064
|
+
|
|
1065
|
+
const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
|
|
1066
|
+
const geometry = sectionOf(ordered);
|
|
1067
|
+
const accepted = geometry.verdict === 'pass';
|
|
1068
|
+
return report(counts, { id: input.id, counts, geometry, motion: null, accepted }, null);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** What one source states about itself: hull and interior from the outline, bindings from the weights. */
|
|
1072
|
+
function countsOf(source: SourceMesh, outline: MeshOutline): MeshCounts {
|
|
1073
|
+
const bindings = source.weights === null ? 0 : source.weights.reduce((s, v) => s + v.length, 0);
|
|
1074
|
+
const maxInfluences = source.weights === null ? 0 : source.weights.reduce((m, v) => Math.max(m, v.length), 0);
|
|
1075
|
+
return {
|
|
1076
|
+
boundaryVertices: outline.hull,
|
|
1077
|
+
interiorVertices: source.points.length - outline.hull,
|
|
1078
|
+
triangles: source.triangles.length / 3,
|
|
1079
|
+
bindings,
|
|
1080
|
+
maxInfluences,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* The section's figures and verdict (§2, P6): `measured` is pass + fail; the
|
|
1086
|
+
* verdict is `fail` when a required row failed, `pass` only when every required
|
|
1087
|
+
* row passed and at least one row did, and `not-measured` otherwise — which is
|
|
1088
|
+
* what a required row that could not be measured, or was refused, leaves.
|
|
1089
|
+
*/
|
|
1090
|
+
function sectionOf(built: readonly Built[]): EvidenceSection {
|
|
1091
|
+
const summary = { pass: 0, fail: 0, undeclared: 0, refused: 0, notMeasurable: 0, measured: 0 };
|
|
1092
|
+
for (const { row } of built) {
|
|
1093
|
+
if (row.state === 'pass') summary.pass++;
|
|
1094
|
+
else if (row.state === 'fail') summary.fail++;
|
|
1095
|
+
else if (row.state === 'undeclared') summary.undeclared++;
|
|
1096
|
+
else if (row.state === 'refused') summary.refused++;
|
|
1097
|
+
else summary.notMeasurable++;
|
|
1098
|
+
}
|
|
1099
|
+
summary.measured = summary.pass + summary.fail;
|
|
1100
|
+
const required = built.filter((b) => b.required);
|
|
1101
|
+
let verdict: EvidenceSection['verdict'];
|
|
1102
|
+
if (required.some((b) => b.row.state === 'fail')) verdict = 'fail';
|
|
1103
|
+
else if (required.every((b) => b.row.state === 'pass') && summary.pass > 0) verdict = 'pass';
|
|
1104
|
+
else verdict = 'not-measured';
|
|
1105
|
+
return { rows: built.map((b) => b.row), summary, verdict };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
// §5 — regions
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
|
|
1112
|
+
/** A region's own refusal (§2's `refused`: this measurement's input is invalid), or null when it is measurable. */
|
|
1113
|
+
function regionRefusal(region: RefinementRegion, pageScale: number, who: string): { code: string; message: string } | null {
|
|
1114
|
+
const numbers: Array<[string, unknown]> = [
|
|
1115
|
+
['maxEdgeLength', region.maxEdgeLength],
|
|
1116
|
+
['transition', region.transition],
|
|
1117
|
+
['grade', region.grade],
|
|
1118
|
+
...region.polygon.flatMap((p, i): Array<[string, unknown]> => (Array.isArray(p) ? [[`polygon[${i}][0]`, p[0]], [`polygon[${i}][1]`, p[1]]] : [[`polygon[${i}]`, p]])),
|
|
1119
|
+
];
|
|
1120
|
+
for (const [field, value] of numbers) {
|
|
1121
|
+
if (!isFiniteNumber(value)) return { code: 'REGION_NOT_FINITE', message: `${who}, region "${region.name}": ${field} is ${String(value)}; required a finite number` };
|
|
1122
|
+
}
|
|
1123
|
+
if (region.transition < 0) return { code: 'REGION_TRANSITION_NEGATIVE', message: `${who}, region "${region.name}": transition is ${region.transition} px; required 0 or more (0 is a hard edge)` };
|
|
1124
|
+
if (region.grade < 0) return { code: 'REGION_GRADE_NEGATIVE', message: `${who}, region "${region.name}": grade is ${region.grade} px per px; required 0 or more` };
|
|
1125
|
+
if (region.maxEdgeLength < 1 / pageScale) {
|
|
1126
|
+
return {
|
|
1127
|
+
code: 'REGION_BOUND_BELOW_GRID',
|
|
1128
|
+
message: `${who}, region "${region.name}": maxEdgeLength is ${region.maxEdgeLength} px; required at least one texel, ${r6(1 / pageScale)} px at pageScale ${pageScale}`,
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
const crossing = findSelfIntersection(region.polygon);
|
|
1132
|
+
if (crossing !== null) {
|
|
1133
|
+
return {
|
|
1134
|
+
code: 'REGION_SELF_INTERSECTS',
|
|
1135
|
+
message: `${who}, region "${region.name}": polygon edges ${crossing[0]} and ${crossing[1]} meet; required a strictly simple polygon (findSelfIntersection)`,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
interface Edge {
|
|
1142
|
+
a: number;
|
|
1143
|
+
b: number;
|
|
1144
|
+
length: number;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* The rows of every region: `MQ_MAX_EDGE` (§5's `L(R)`), `MQ_TRANSITION` (its
|
|
1149
|
+
* graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
|
|
1150
|
+
* sampled, informational).
|
|
1151
|
+
*
|
|
1152
|
+
* One bound per edge, the smallest applicable anywhere on it (P16): `L0` of
|
|
1153
|
+
* every region whose closed polygon it meets, and `L0 + grade·d` of every band
|
|
1154
|
+
* it lies in, `d` its distance from that region. A row's value is the edge whose
|
|
1155
|
+
* length most exceeds its own bound — so the row fails exactly when some edge
|
|
1156
|
+
* it covers is over — and the row names that edge and that bound.
|
|
1157
|
+
*/
|
|
1158
|
+
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[]): Built[] {
|
|
1159
|
+
const { attachment, source, art } = input;
|
|
1160
|
+
const who = `attachment ${nameOf(attachment)}`;
|
|
1161
|
+
const scale = art.frame.pageScale;
|
|
1162
|
+
const points: Pt[] = source.points;
|
|
1163
|
+
const out: Built[] = [];
|
|
1164
|
+
const spec = (code: string, unit: MeasureRow['unit'], region: string): RowSpec => ({ code, unit, region, attachment });
|
|
1165
|
+
const encoded = meshEdges(points.length, source.triangles, outline.hull);
|
|
1166
|
+
const edges: Edge[] = [];
|
|
1167
|
+
for (let i = 0; i < encoded.length; i += 2) {
|
|
1168
|
+
const a = encoded[i] / 2;
|
|
1169
|
+
const b = encoded[i + 1] / 2;
|
|
1170
|
+
edges.push({ a: Math.min(a, b), b: Math.max(a, b), length: Math.hypot(points[b][0] - points[a][0], points[b][1] - points[a][1]) });
|
|
1171
|
+
}
|
|
1172
|
+
edges.sort((p, q) => p.a - q.a || p.b - q.b);
|
|
1173
|
+
|
|
1174
|
+
// Which regions are measurable, and the one refusal of each that is not.
|
|
1175
|
+
const live: RefinementRegion[] = [];
|
|
1176
|
+
const refusals = new Map<string, string>();
|
|
1177
|
+
for (const region of input.targets.regions) {
|
|
1178
|
+
let refusal = regionRefusal(region, scale, who);
|
|
1179
|
+
if (refusal === null) {
|
|
1180
|
+
const poly: Pt[] = region.polygon;
|
|
1181
|
+
const meets = edges.some((e) => segmentMeetsPolygon(points[e.a], points[e.b], poly)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
|
|
1182
|
+
if (!meets) {
|
|
1183
|
+
refusal = {
|
|
1184
|
+
code: 'REGION_OUTSIDE_ART',
|
|
1185
|
+
message: `${who}, region "${region.name}": the polygon does not meet the mesh's hull anywhere, so clipped to it the region is empty; required a region that meets the hull`,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (refusal === null) live.push(region);
|
|
1190
|
+
else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Every bound that applies to an edge, from every measurable region, and the smallest. */
|
|
1194
|
+
const boundOf = (e: Edge): number | null => {
|
|
1195
|
+
let bound: number | null = null;
|
|
1196
|
+
for (const region of live) {
|
|
1197
|
+
const d = segmentToPolygon(points[e.a], points[e.b], region.polygon);
|
|
1198
|
+
let here: number | null = null;
|
|
1199
|
+
if (d === 0) here = region.maxEdgeLength;
|
|
1200
|
+
else if (region.transition > 0 && d <= region.transition) here = r6(region.maxEdgeLength + region.grade * d);
|
|
1201
|
+
if (here !== null && (bound === null || here < bound)) bound = here;
|
|
1202
|
+
}
|
|
1203
|
+
return bound;
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
/** The worst edge of a set: the largest length over its own bound, ties to the smaller (a, b). */
|
|
1207
|
+
const worstOf = (set: readonly Edge[]): { edge: Edge; bound: number } | null => {
|
|
1208
|
+
let best: { edge: Edge; bound: number; excess: number } | null = null;
|
|
1209
|
+
for (const e of set) {
|
|
1210
|
+
const bound = boundOf(e);
|
|
1211
|
+
if (bound === null) continue;
|
|
1212
|
+
const excess = r6(e.length) - bound;
|
|
1213
|
+
if (best === null || excess > best.excess) best = { edge: e, bound, excess };
|
|
1214
|
+
}
|
|
1215
|
+
return best === null ? null : { edge: best.edge, bound: best.bound };
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
|
|
1219
|
+
const { mask, threshold } = art;
|
|
1220
|
+
const artBits = artOf(mask, threshold);
|
|
1221
|
+
|
|
1222
|
+
for (const region of input.targets.regions) {
|
|
1223
|
+
const refused = refusals.get(region.name);
|
|
1224
|
+
if (refused !== undefined) {
|
|
1225
|
+
out.push(unmeasuredRow(spec('MQ_MAX_EDGE', 'px', region.name), 'refused', refused, true));
|
|
1226
|
+
out.push(unmeasuredRow(spec('MQ_TRANSITION', 'px', region.name), 'refused', refused, true));
|
|
1227
|
+
out.push(unmeasuredRow(spec('MQ_FILL_DISTANCE', 'px', region.name), 'refused', refused, false));
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
const poly: Pt[] = region.polygon;
|
|
1231
|
+
const inside = edges.filter((e) => segmentMeetsPolygon(points[e.a], points[e.b], poly));
|
|
1232
|
+
const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
|
|
1233
|
+
const inWorst = worstOf(inside);
|
|
1234
|
+
if (inWorst === null) {
|
|
1235
|
+
out.push(unmeasuredRow(maxSpec, 'not-measurable', `${who}, region "${region.name}": no triangle edge meets the closed polygon — it lies inside one triangle — so L(R) is a maximum over no edge`, true));
|
|
1236
|
+
} else {
|
|
1237
|
+
out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
|
|
1238
|
+
}
|
|
1239
|
+
if (region.transition > 0) {
|
|
1240
|
+
const banded = edges.filter((e) => {
|
|
1241
|
+
const d = segmentToPolygon(points[e.a], points[e.b], poly);
|
|
1242
|
+
return d > 0 && d <= region.transition;
|
|
1243
|
+
});
|
|
1244
|
+
const bandWorst = worstOf(banded);
|
|
1245
|
+
const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
|
|
1246
|
+
if (bandWorst === null) {
|
|
1247
|
+
out.push(unmeasuredRow(transitionSpec, 'not-measurable', `${who}, region "${region.name}": no triangle edge lies in the ${region.transition} px band outside the polygon`, true));
|
|
1248
|
+
} else {
|
|
1249
|
+
out.push(measuredRow(transitionSpec, r6(bandWorst.edge.length), { op: '<=', value: bandWorst.bound }, { at: { edge: [bandWorst.edge.a, bandWorst.edge.b] } }, true));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex.
|
|
1254
|
+
let count = 0;
|
|
1255
|
+
let worst = -1;
|
|
1256
|
+
let worstAt = -1;
|
|
1257
|
+
for (let i = 0; i < artBits.length; i++) {
|
|
1258
|
+
if (!artBits[i]) continue;
|
|
1259
|
+
const x = i % mask.width;
|
|
1260
|
+
const y = Math.floor(i / mask.width);
|
|
1261
|
+
const centre: Pt = [(x + 0.5) / scale, (y + 0.5) / scale];
|
|
1262
|
+
if (!inClosedPolygon(centre, poly) || !inClosedPolygon(centre, hullPolygon)) continue;
|
|
1263
|
+
count++;
|
|
1264
|
+
let nearest = Infinity;
|
|
1265
|
+
for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
|
|
1266
|
+
if (nearest > worst) {
|
|
1267
|
+
worst = nearest;
|
|
1268
|
+
worstAt = i;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const fillSpec = spec('MQ_FILL_DISTANCE', 'px', region.name);
|
|
1272
|
+
const floor = floorOf(region.name);
|
|
1273
|
+
const sampled = { threshold, connectivity: null, samples: count };
|
|
1274
|
+
const sampling = { domain: 'art pixel centres inside the region and the hull', count };
|
|
1275
|
+
let fill: Built;
|
|
1276
|
+
if (count < floor) {
|
|
1277
|
+
fill = unmeasuredRow(fillSpec, 'not-measurable', `${who}, region "${region.name}" holds ${count} art sample(s) inside the hull at alpha >= ${threshold}; required at least ${floor} (P9)`, false);
|
|
1278
|
+
} else {
|
|
1279
|
+
fill = measuredRow(fillSpec, r6(worst), null, { at: { pixel: [worstAt % mask.width, Math.floor(worstAt / mask.width)] } }, false);
|
|
1280
|
+
}
|
|
1281
|
+
fill.row.art = sampled;
|
|
1282
|
+
fill.row.sampling = sampling;
|
|
1283
|
+
out.push(fill);
|
|
1284
|
+
}
|
|
1285
|
+
return out;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// ---------------------------------------------------------------------------
|
|
1289
|
+
// the echo, and the document's text
|
|
1290
|
+
// ---------------------------------------------------------------------------
|
|
1291
|
+
|
|
1292
|
+
function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
|
|
1293
|
+
const { art } = input;
|
|
1294
|
+
return {
|
|
1295
|
+
preset: input.preset,
|
|
1296
|
+
attachments: [
|
|
1297
|
+
{
|
|
1298
|
+
attachment: input.attachment,
|
|
1299
|
+
threshold: art.threshold,
|
|
1300
|
+
// A measurement states the threshold it was taken at and claims nothing at any other (Thresholds).
|
|
1301
|
+
finalThreshold: art.threshold,
|
|
1302
|
+
frame: art.frame,
|
|
1303
|
+
maskSize: [art.mask.width, art.mask.height],
|
|
1304
|
+
minArtSamples: input.minArtSamples,
|
|
1305
|
+
regions: input.targets.regions.map((r) => ({ name: r.name, minArtSamples: input.regionArtSamples.find((f) => f.region === r.name)!.minArtSamples })),
|
|
1306
|
+
},
|
|
1307
|
+
],
|
|
1308
|
+
sourceBounds: null,
|
|
1309
|
+
referenceArtFit: null,
|
|
1310
|
+
candidateArtFit: null,
|
|
1311
|
+
targets: input.targets,
|
|
1312
|
+
referenceHull: input.referenceHull,
|
|
1313
|
+
motionBounds: null,
|
|
1314
|
+
protect: input.protect,
|
|
1315
|
+
influences: input.influences,
|
|
1316
|
+
boneOrder: input.boneOrder,
|
|
1317
|
+
schedule: null,
|
|
1318
|
+
budget: null,
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Every object of the document is rebuilt below in the order its type states
|
|
1323
|
+
// its keys, so the bytes depend on the values alone — never on the key order
|
|
1324
|
+
// an input object happened to be built in (A18's standard; `build-report/1`'s
|
|
1325
|
+
// precedent in `src/assertions/report.ts`). An optional key is written only
|
|
1326
|
+
// when it is present.
|
|
1327
|
+
|
|
1328
|
+
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
1329
|
+
|
|
1330
|
+
const pair = (p: readonly [number, number]): Json => [p[0], p[1]];
|
|
1331
|
+
const attachmentJson = (a: AttachmentRef): Json => ({ skin: a.skin, slot: a.slot, attachment: a.attachment });
|
|
1332
|
+
const fitJson = (f: ArtFitBounds | null): Json => (f === null ? null : { minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
|
|
1333
|
+
const frameJson = (f: SourceFrame): Json => ({ space: f.space, width: f.width, height: f.height, pageScale: f.pageScale, conversion: f.conversion });
|
|
1334
|
+
|
|
1335
|
+
function regionJson(r: RefinementRegion): Json {
|
|
1336
|
+
return {
|
|
1337
|
+
name: r.name,
|
|
1338
|
+
polygon: r.polygon.map(pair),
|
|
1339
|
+
maxEdgeLength: r.maxEdgeLength,
|
|
1340
|
+
transition: r.transition,
|
|
1341
|
+
grade: r.grade,
|
|
1342
|
+
approximation: r.approximation === null ? null : { from: r.approximation.from, policy: r.approximation.policy, maxError: r.approximation.maxError },
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function targetsJson(t: ReductionTargets | MeasureTargets | null): Json {
|
|
1347
|
+
if (t === null) return null;
|
|
1348
|
+
const out: { [key: string]: Json } = { artFit: fitJson(t.artFit), maxBoundaryDeviation: t.maxBoundaryDeviation };
|
|
1349
|
+
if (t.minAngle !== undefined) out.minAngle = t.minAngle;
|
|
1350
|
+
out.regions = t.regions.map(regionJson);
|
|
1351
|
+
return out;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function protectJson(p: ProtectedFeatures | null): Json {
|
|
1355
|
+
if (p === null) return null;
|
|
1356
|
+
return {
|
|
1357
|
+
hull: p.hull,
|
|
1358
|
+
vertices: [...p.vertices],
|
|
1359
|
+
edges: p.edges.map(pair),
|
|
1360
|
+
regionBoundaries: [...p.regionBoundaries],
|
|
1361
|
+
weightJump: p.weightJump,
|
|
1362
|
+
influences: [...p.influences],
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function motionBoundsJson(b: MotionBounds | null): Json {
|
|
1367
|
+
if (b === null) return null;
|
|
1368
|
+
const out: { [key: string]: Json } = { maxLocalDeformation: b.maxLocalDeformation };
|
|
1369
|
+
if (b.maxStretch !== undefined) out.maxStretch = b.maxStretch;
|
|
1370
|
+
if (b.minStretch !== undefined) out.minStretch = b.minStretch;
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function scheduleJson(s: MotionSchedule | null): Json {
|
|
1375
|
+
if (s === null) return null;
|
|
1376
|
+
return {
|
|
1377
|
+
frames: s.frames.map((f): Json => (f === 'setup' ? 'setup' : 'times' in f ? { animation: f.animation, times: [...f.times] } : { animation: f.animation, fps: f.fps })),
|
|
1378
|
+
phases: [...s.phases],
|
|
1379
|
+
physics: s.physics.mode === 'none' ? { mode: 'none' } : { mode: 'step', dt: s.physics.dt, warmupSteps: s.physics.warmupSteps },
|
|
1380
|
+
selection: [...s.selection],
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function effectiveJson(e: EffectiveSettings): Json {
|
|
1385
|
+
return {
|
|
1386
|
+
preset: e.preset === null ? null : { name: e.preset.name, version: e.preset.version },
|
|
1387
|
+
attachments: e.attachments.map((a) => ({
|
|
1388
|
+
attachment: attachmentJson(a.attachment),
|
|
1389
|
+
threshold: a.threshold,
|
|
1390
|
+
finalThreshold: a.finalThreshold,
|
|
1391
|
+
frame: frameJson(a.frame),
|
|
1392
|
+
maskSize: pair(a.maskSize),
|
|
1393
|
+
minArtSamples: a.minArtSamples,
|
|
1394
|
+
regions: a.regions.map((r) => ({ name: r.name, minArtSamples: r.minArtSamples })),
|
|
1395
|
+
})),
|
|
1396
|
+
sourceBounds: fitJson(e.sourceBounds),
|
|
1397
|
+
referenceArtFit: fitJson(e.referenceArtFit),
|
|
1398
|
+
candidateArtFit: fitJson(e.candidateArtFit),
|
|
1399
|
+
targets: targetsJson(e.targets),
|
|
1400
|
+
referenceHull: e.referenceHull === null ? null : e.referenceHull.map(pair),
|
|
1401
|
+
motionBounds: motionBoundsJson(e.motionBounds),
|
|
1402
|
+
protect: protectJson(e.protect),
|
|
1403
|
+
influences: e.influences === null ? null : { maxInfluences: e.influences.maxInfluences, minWeight: e.influences.minWeight },
|
|
1404
|
+
boneOrder: e.boneOrder === null ? null : [...e.boneOrder],
|
|
1405
|
+
schedule: scheduleJson(e.schedule),
|
|
1406
|
+
budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function frameRefJson(f: FrameRef): Json {
|
|
1411
|
+
return { id: f.id, animation: f.animation, phase: f.phase, time: f.time, index: f.index, role: f.role };
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function worstJson(w: WorstSample | null): Json {
|
|
1415
|
+
if (w === null) return null;
|
|
1416
|
+
const at: { [key: string]: Json } = {};
|
|
1417
|
+
if (w.at.pixel !== undefined) at.pixel = pair(w.at.pixel);
|
|
1418
|
+
if (w.at.triangle !== undefined) at.triangle = w.at.triangle;
|
|
1419
|
+
if (w.at.edge !== undefined) at.edge = pair(w.at.edge);
|
|
1420
|
+
if (w.at.vertex !== undefined) at.vertex = w.at.vertex;
|
|
1421
|
+
if (w.at.uv !== undefined) at.uv = pair(w.at.uv);
|
|
1422
|
+
const out: { [key: string]: Json } = { at };
|
|
1423
|
+
if (w.frame !== undefined) out.frame = frameRefJson(w.frame);
|
|
1424
|
+
return out;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function rowJson(r: MeasureRow): Json {
|
|
1428
|
+
const out: { [key: string]: Json } = {
|
|
1429
|
+
code: r.code,
|
|
1430
|
+
object: { attachment: attachmentJson(r.object.attachment), region: r.object.region },
|
|
1431
|
+
state: r.state,
|
|
1432
|
+
value: r.value,
|
|
1433
|
+
bound: r.bound === null ? null : { op: r.bound.op, value: r.bound.value },
|
|
1434
|
+
unit: r.unit,
|
|
1435
|
+
worst: worstJson(r.worst),
|
|
1436
|
+
reason: r.reason,
|
|
1437
|
+
};
|
|
1438
|
+
if (r.art !== undefined) out.art = { threshold: r.art.threshold, connectivity: r.art.connectivity, samples: r.art.samples };
|
|
1439
|
+
if (r.raster !== undefined) {
|
|
1440
|
+
const g = r.raster.grid;
|
|
1441
|
+
out.raster = {
|
|
1442
|
+
grid: { width: g.width, height: g.height, pageScale: g.pageScale },
|
|
1443
|
+
spatialQuantum: r.raster.spatialQuantum,
|
|
1444
|
+
valueIncrement: r.raster.valueIncrement,
|
|
1445
|
+
nearBound: r.raster.nearBound,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
|
|
1449
|
+
return out;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function sectionJson(s: EvidenceSection): { [key: string]: Json } {
|
|
1453
|
+
const m = s.summary;
|
|
1454
|
+
return {
|
|
1455
|
+
rows: s.rows.map(rowJson),
|
|
1456
|
+
summary: { pass: m.pass, fail: m.fail, undeclared: m.undeclared, refused: m.refused, notMeasurable: m.notMeasurable, measured: m.measured },
|
|
1457
|
+
verdict: s.verdict,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const countsJson = (c: MeshCounts | null): Json =>
|
|
1462
|
+
c === null ? null : { boundaryVertices: c.boundaryVertices, interiorVertices: c.interiorVertices, triangles: c.triangles, bindings: c.bindings, maxInfluences: c.maxInfluences };
|
|
1463
|
+
|
|
1464
|
+
function candidateJson(c: CandidateReport): Json {
|
|
1465
|
+
const out: { [key: string]: Json } = {
|
|
1466
|
+
id: c.id,
|
|
1467
|
+
counts: countsJson(c.counts),
|
|
1468
|
+
geometry: c.geometry === null ? null : sectionJson(c.geometry),
|
|
1469
|
+
motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleJson(c.motion.schedule) },
|
|
1470
|
+
accepted: c.accepted,
|
|
1471
|
+
};
|
|
1472
|
+
if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
|
|
1473
|
+
return out;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function terminationJson(t: Termination | null): Json {
|
|
1477
|
+
if (t === null) return null;
|
|
1478
|
+
switch (t.reason) {
|
|
1479
|
+
case 'no-further-valid-reduction':
|
|
1480
|
+
return { reason: t.reason, candidatesTried: t.candidatesTried, blockingConstraint: t.blockingConstraint };
|
|
1481
|
+
case 'budget-exhausted':
|
|
1482
|
+
return { reason: t.reason, candidatesTried: t.candidatesTried, budget: t.budget, result: t.result };
|
|
1483
|
+
case 'invalid-input':
|
|
1484
|
+
case 'unsupported-topology':
|
|
1485
|
+
return { reason: t.reason, code: t.code, detail: t.detail };
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* The document's text: two-space JSON and a final newline, every key in the
|
|
1491
|
+
* order its type states it, so two reports of one input are byte-identical —
|
|
1492
|
+
* nothing in it is a time, a path or a machine's.
|
|
1493
|
+
*/
|
|
1494
|
+
export function writeMeshQualityReport(report: MeshQualityReport): string {
|
|
1495
|
+
const doc: Json = {
|
|
1496
|
+
spec: report.spec,
|
|
1497
|
+
operation: report.operation,
|
|
1498
|
+
effective: effectiveJson(report.effective),
|
|
1499
|
+
poser: report.poser === null ? null : { kind: report.poser.kind, rigcVersion: report.poser.rigcVersion },
|
|
1500
|
+
motionRequired: report.motionRequired,
|
|
1501
|
+
sourceCounts: countsJson(report.sourceCounts),
|
|
1502
|
+
reference: report.reference === null ? null : candidateJson(report.reference),
|
|
1503
|
+
candidates: report.candidates.map(candidateJson),
|
|
1504
|
+
termination: terminationJson(report.termination),
|
|
1505
|
+
};
|
|
1506
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
1507
|
+
}
|