rig-c 0.0.0-stage → 2.21.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
|
@@ -0,0 +1,2051 @@
|
|
|
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, in `src/meshreduce.ts`) and the
|
|
10
|
+
* motion comparison (stage C) fill, so that those stages build on one set of
|
|
11
|
+
* types rather 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
|
+
checkHullOrder,
|
|
58
|
+
distanceToSegment,
|
|
59
|
+
findSelfIntersection,
|
|
60
|
+
MeshError,
|
|
61
|
+
MeshReductionError,
|
|
62
|
+
meshEdges,
|
|
63
|
+
r6,
|
|
64
|
+
rasteriseTriangles,
|
|
65
|
+
segmentsMeet,
|
|
66
|
+
squaredDistanceToSet,
|
|
67
|
+
traceOutline,
|
|
68
|
+
type AlphaMask,
|
|
69
|
+
type MeshOutline,
|
|
70
|
+
} from './mesh.ts';
|
|
71
|
+
import { areaBand, triangleAreas } from './areaband.ts';
|
|
72
|
+
import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
|
|
73
|
+
import { cropToSpineY } from './transform.ts';
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// §1 — inputs
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** The art one attachment is measured against. Correction 1: one per attachment, never shared. */
|
|
80
|
+
export interface ArtInput {
|
|
81
|
+
/** The art, on the grid it is read off: the plate's pixels, or a `scale:` page's texels. */
|
|
82
|
+
mask: AlphaMask;
|
|
83
|
+
/** Art is `alpha >= threshold`. Required, a whole number in 1..255. Never exchanged. */
|
|
84
|
+
threshold: number;
|
|
85
|
+
/** The frame the caller's distances are in — P3. Echoed in the report, never inferred. */
|
|
86
|
+
frame: SourceFrame;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** P3: the caller authors in drawing pixels, part-local, y down. */
|
|
90
|
+
export interface SourceFrame {
|
|
91
|
+
space: 'part-local-drawing-px-y-down';
|
|
92
|
+
/** The part window in drawing pixels. */
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
/** The `scale:` the page states (1 when none) — the meaning of `ContourSpecInput.pageScale`. */
|
|
96
|
+
pageScale: number;
|
|
97
|
+
/** The conversion applied, stated rather than implied: texels = drawing px × pageScale. */
|
|
98
|
+
conversion: 'texels = px * pageScale';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AttachmentRef {
|
|
102
|
+
skin: string | null;
|
|
103
|
+
slot: string;
|
|
104
|
+
attachment: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SourceMesh {
|
|
108
|
+
/** Part-local pixels of the drawing, y down. */
|
|
109
|
+
points: Array<[number, number]>;
|
|
110
|
+
/** Region UVs, 0..1, `v` from the top — one pair per point. */
|
|
111
|
+
uvs: number[];
|
|
112
|
+
/** Counter-clockwise in Spine world; hull first, as `checkHullOrder` requires. */
|
|
113
|
+
triangles: number[];
|
|
114
|
+
hull: number;
|
|
115
|
+
/** Per vertex, by bone NAME (`ModelBinding` without the bind coordinates), or null for an unweighted mesh. */
|
|
116
|
+
weights: Array<Array<{ bone: string; weight: number }>> | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* All distances in the drawing's pixels; converted to texels by `pageScale`.
|
|
121
|
+
*
|
|
122
|
+
* `maxOvershoot` and `maxUndercut` may be `null` — a bound **declared absent**
|
|
123
|
+
* (issue #1254): the row is measured and reported `undeclared`, with its value
|
|
124
|
+
* and worst sample, and never passes, fails, blocks a step or refuses a source.
|
|
125
|
+
* A field left out (`undefined`) is still refused by name: an omission cannot be
|
|
126
|
+
* told from a caller that forgot, and only the explicit `null` says "measured,
|
|
127
|
+
* not bounded". `minCoverage` has no such form and is always required.
|
|
128
|
+
*/
|
|
129
|
+
export interface ArtFitBounds {
|
|
130
|
+
/** Share of art pixel centres the triangles cover, 0..1. */
|
|
131
|
+
minCoverage: number;
|
|
132
|
+
/** Furthest a covered pixel may sit outside the filled silhouette, px; null = declared absent. */
|
|
133
|
+
maxOvershoot: number | null;
|
|
134
|
+
/** Furthest an uncovered art pixel may sit from the covered set, px; null = declared absent. */
|
|
135
|
+
maxUndercut: number | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What a reduction's RESULT must satisfy (correction 3). Read by `reduceMesh`, stage B2. */
|
|
139
|
+
export interface ReductionTargets {
|
|
140
|
+
artFit: ArtFitBounds;
|
|
141
|
+
/** P13: largest Hausdorff distance between the reduced and the source hull polygons, px. */
|
|
142
|
+
maxBoundaryDeviation: number;
|
|
143
|
+
/** Optional: smallest interior angle any triangle may have, degrees. Undeclared = reported, not gated. */
|
|
144
|
+
minAngle?: number;
|
|
145
|
+
/** Local density requirements — §5. */
|
|
146
|
+
regions: RefinementRegion[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** P16/P17: a local density requirement over a closed polygon. */
|
|
150
|
+
export interface RefinementRegion {
|
|
151
|
+
/** Caller's name, echoed in the report; rigc reads nothing into it. */
|
|
152
|
+
name: string;
|
|
153
|
+
/** Closed polygon, part-local drawing pixels, y down — resolved by the caller (P17). */
|
|
154
|
+
polygon: Array<[number, number]>;
|
|
155
|
+
/** L0: every edge that intersects the closed polygon is at most this long, px. */
|
|
156
|
+
maxEdgeLength: number;
|
|
157
|
+
/** Width of the band outside the polygon over which the bound relaxes, px. Finite, >= 0; 0 is a hard edge. */
|
|
158
|
+
transition: number;
|
|
159
|
+
/** In the band, L(d) = L0 + grade × d, d the distance from the polygon, px per px. Finite, >= 0. */
|
|
160
|
+
grade: number;
|
|
161
|
+
/** P17: when the caller approximated another shape by this polygon, how — echoed, never read. */
|
|
162
|
+
approximation: { from: string; policy: string; maxError: number } | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** §6, with P19/P20 folded in. Echoed by `measureMeshQuality`; read by `reduceMesh`, stage B2. */
|
|
166
|
+
export interface ProtectedFeatures {
|
|
167
|
+
/** P20: keep every source hull vertex. Required — no default inside the operation. */
|
|
168
|
+
hull: boolean;
|
|
169
|
+
/** Source vertex indices that must survive. */
|
|
170
|
+
vertices: number[];
|
|
171
|
+
/** P20: source edges (pairs of source indices) that must survive as edges. */
|
|
172
|
+
edges: Array<[number, number]>;
|
|
173
|
+
/** Region polygons whose boundary vertices must survive. */
|
|
174
|
+
regionBoundaries: string[];
|
|
175
|
+
/** L1 weight-vector difference above which an edge is protected (correction 3). Null = no such protection, reported. */
|
|
176
|
+
weightJump: number | null;
|
|
177
|
+
/** Bones whose binding may not be pruned from any vertex that carries it (P19). */
|
|
178
|
+
influences: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** P19: stated on every weighted call, never inherited. */
|
|
182
|
+
export interface InfluenceLimits {
|
|
183
|
+
/** The cap on bindings per vertex. */
|
|
184
|
+
maxInfluences: number;
|
|
185
|
+
/** Shares under this are dropped; 0 drops only shares that are zero on the weight grid. */
|
|
186
|
+
minWeight: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One deform key of a timeline the reduced attachment is keyed under (§6, P18),
|
|
191
|
+
* in the form the compiler emits it: a `vertices` run is the emitted `offset`
|
|
192
|
+
* (an index into the deform array — two numbers per vertex unweighted, two per
|
|
193
|
+
* influence weighted, AUTHORING §4.11) and the numbers copied in from there; a
|
|
194
|
+
* `transform` key is a model the compile evaluates over the attachment's own
|
|
195
|
+
* geometry (§4.11.1), so it has no run to remap; a `setup` key carries no run.
|
|
196
|
+
*/
|
|
197
|
+
export type DeformKeyInput =
|
|
198
|
+
| { time: number; kind: 'setup' }
|
|
199
|
+
| { time: number; kind: 'vertices'; offset: number; vertices: number[] }
|
|
200
|
+
| { time: number; kind: 'transform' };
|
|
201
|
+
|
|
202
|
+
/** One deform timeline: the animation, the attachment it is keyed on, and its keys in order. */
|
|
203
|
+
export interface DeformTimelineInput {
|
|
204
|
+
animation: string;
|
|
205
|
+
/** The reduced attachment itself, or one of `MeshReductionInput.linkedMeshes`. */
|
|
206
|
+
attachment: AttachmentRef;
|
|
207
|
+
keys: DeformKeyInput[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Everything a reduction reads (stage B2, `reduceMesh` in `src/meshreduce.ts`). No field has a default inside the operation. */
|
|
211
|
+
export interface MeshReductionInput {
|
|
212
|
+
attachment: AttachmentRef;
|
|
213
|
+
art: ArtInput;
|
|
214
|
+
/** P8: the unreduced, independently gated source. */
|
|
215
|
+
source: SourceMesh;
|
|
216
|
+
/** Correction 3: what the SOURCE must already satisfy to be admissible. */
|
|
217
|
+
sourceBounds: ArtFitBounds;
|
|
218
|
+
/** Correction 3: what the RESULT must satisfy. */
|
|
219
|
+
targets: ReductionTargets;
|
|
220
|
+
protect: ProtectedFeatures;
|
|
221
|
+
/** Required when `source.weights` is non-null — §6, P19. */
|
|
222
|
+
influences: InfluenceLimits | null;
|
|
223
|
+
/** Correction 1: the skeleton's bone order, used for every weight tie-break. Required when weighted. */
|
|
224
|
+
boneOrder: string[] | null;
|
|
225
|
+
/** P5: the preset the caller expanded, if any, echoed and never read. */
|
|
226
|
+
preset: { name: string; version: string } | null;
|
|
227
|
+
/** Work bound — *Termination reasons*. Every refinement insertion and every removal attempted counts one. */
|
|
228
|
+
budget: { maxCandidates: number };
|
|
229
|
+
/** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
|
|
230
|
+
minArtSamples: number;
|
|
231
|
+
/** P9: one floor per region, by region name — every region named once. */
|
|
232
|
+
regionArtSamples: Array<{ region: string; minArtSamples: number }>;
|
|
233
|
+
/** P18: every deform timeline keyed on the attachment or on one of its linked meshes; empty when none. */
|
|
234
|
+
deform: DeformTimelineInput[];
|
|
235
|
+
/** Every linked mesh of the source (they inherit the new topology); empty when none. */
|
|
236
|
+
linkedMeshes: AttachmentRef[];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What a measurement is held to. `ReductionTargets` with every bound allowed to
|
|
241
|
+
* be undeclared: a measurement is also asked of meshes nobody has set a target
|
|
242
|
+
* for, and a null bound is a row reported `undeclared`, never one given a value.
|
|
243
|
+
*/
|
|
244
|
+
export interface MeasureTargets {
|
|
245
|
+
artFit: ArtFitBounds | null;
|
|
246
|
+
maxBoundaryDeviation: number | null;
|
|
247
|
+
minAngle?: number;
|
|
248
|
+
regions: RefinementRegion[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* What `measureMeshQuality` reads: the part of `MeshReductionInput` a
|
|
253
|
+
* measurement needs, plus the three things only a measurement is handed — the
|
|
254
|
+
* caller's id for the mesh, the hull polygon `MQ_BOUNDARY_DEVIATION` is taken
|
|
255
|
+
* against, and the art sample floors (P9). Every field is required; a field the
|
|
256
|
+
* caller has nothing for is `null` (or an empty list), never left out.
|
|
257
|
+
*/
|
|
258
|
+
export interface MeshMeasureInput {
|
|
259
|
+
/** The caller's id for the mesh measured, echoed as the candidate's id. */
|
|
260
|
+
id: string;
|
|
261
|
+
attachment: AttachmentRef;
|
|
262
|
+
art: ArtInput;
|
|
263
|
+
/** The mesh measured. */
|
|
264
|
+
source: SourceMesh;
|
|
265
|
+
targets: MeasureTargets;
|
|
266
|
+
/** The hull polygon to measure boundary deviation against, drawing px; null makes that row `not-measurable`. */
|
|
267
|
+
referenceHull: Array<[number, number]> | null;
|
|
268
|
+
/** P9: the fewest art samples the attachment's raster rows are taken over. A whole number >= 1. */
|
|
269
|
+
minArtSamples: number;
|
|
270
|
+
/** P9: one floor per region, by region name — every region named once. */
|
|
271
|
+
regionArtSamples: Array<{ region: string; minArtSamples: number }>;
|
|
272
|
+
protect: ProtectedFeatures | null;
|
|
273
|
+
influences: InfluenceLimits | null;
|
|
274
|
+
boneOrder: string[] | null;
|
|
275
|
+
preset: { name: string; version: string } | null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// §2 — the report
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
export const MESH_QUALITY_REPORT_SPEC = 'mesh-quality-report/1';
|
|
283
|
+
|
|
284
|
+
export type MeasureState = 'pass' | 'fail' | 'undeclared' | 'refused' | 'not-measurable';
|
|
285
|
+
|
|
286
|
+
export interface MeasureRow {
|
|
287
|
+
/** Stable code, e.g. `MQ_COVERAGE`. */
|
|
288
|
+
code: string;
|
|
289
|
+
/** The object measured: attachment always; region when the row is a region's. */
|
|
290
|
+
object: { attachment: AttachmentRef; region: string | null };
|
|
291
|
+
state: MeasureState;
|
|
292
|
+
/** The measured value: present on pass, fail and undeclared; null otherwise. */
|
|
293
|
+
value: number | null;
|
|
294
|
+
/** The declared bound, inclusive: present on pass and fail; null on undeclared, refused, not-measurable. */
|
|
295
|
+
bound: { op: '<=' | '>='; value: number } | null;
|
|
296
|
+
unit: 'px' | 'world' | 'fraction' | 'degrees' | 'ratio' | 'count';
|
|
297
|
+
/** Where the worst value was taken: present whenever `value` is. `{ at: {} }` when nothing is worse than ideal. */
|
|
298
|
+
worst: WorstSample | null;
|
|
299
|
+
/** Required on refused and not-measurable: a sentence naming what was missing. */
|
|
300
|
+
reason: string | null;
|
|
301
|
+
/** Raster and sampled rows: the art the row was taken against — never read across thresholds. */
|
|
302
|
+
art?: { threshold: number; connectivity: 4 | 8 | null; samples: number };
|
|
303
|
+
/** Raster rows only — correction 2. */
|
|
304
|
+
raster?: RasterSensitivity;
|
|
305
|
+
/** Sampled rows — correction 2. */
|
|
306
|
+
sampling?: { domain: string; count: number };
|
|
307
|
+
/** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
|
|
308
|
+
motion?: MotionRowDetail;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Correction 2: the spatial grid and the value's own granularity are two fields. */
|
|
312
|
+
export interface RasterSensitivity {
|
|
313
|
+
grid: { width: number; height: number; pageScale: number };
|
|
314
|
+
/** One texel, in the drawing's pixels: 1 / pageScale. */
|
|
315
|
+
spatialQuantum: number;
|
|
316
|
+
/** The smallest step the VALUE can take, in the row's own unit. */
|
|
317
|
+
valueIncrement: number;
|
|
318
|
+
/** A diagnostic: it changes no verdict and bounds no error. */
|
|
319
|
+
nearBound: 'at-bound' | 'within-increment' | 'clear';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface WorstSample {
|
|
323
|
+
at: { pixel?: [number, number]; triangle?: number; edge?: [number, number]; vertex?: number; uv?: [number, number] };
|
|
324
|
+
/** Motion rows only — P7, P11. */
|
|
325
|
+
frame?: FrameRef;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** P7/P11: a frame named by a stable id carrying animation, phase and time. Stage C. */
|
|
329
|
+
export interface FrameRef {
|
|
330
|
+
id: string;
|
|
331
|
+
animation: string | null;
|
|
332
|
+
phase: 'grid' | 'irr' | null;
|
|
333
|
+
time: number | null;
|
|
334
|
+
index: number | null;
|
|
335
|
+
role: 'selection' | 'held-out' | 'baseline';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface EvidenceSection {
|
|
339
|
+
rows: MeasureRow[];
|
|
340
|
+
/** Counts per state. `measured` = pass + fail. Never summed across sections. */
|
|
341
|
+
summary: { pass: number; fail: number; undeclared: number; refused: number; notMeasurable: number; measured: number };
|
|
342
|
+
/** pass only when every REQUIRED row is pass AND at least one row is pass. */
|
|
343
|
+
verdict: 'pass' | 'fail' | 'not-measured';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** §3's motion bounds. Stage C. */
|
|
347
|
+
export interface MotionBounds {
|
|
348
|
+
maxLocalDeformation: number;
|
|
349
|
+
maxStretch?: number;
|
|
350
|
+
minStretch?: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** §3's schedule. Stage C. */
|
|
354
|
+
export interface MotionSchedule {
|
|
355
|
+
frames: Array<'setup' | { animation: string; times: number[] } | { animation: string; fps: number }>;
|
|
356
|
+
phases: Array<'grid' | 'irr'>;
|
|
357
|
+
physics: { mode: 'none' } | { mode: 'step'; dt: number; warmupSteps: 0 };
|
|
358
|
+
selection: string[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The schedule a motion section was measured under. The contract names this
|
|
363
|
+
* type without defining it; stage C (issue #1230) defines it as the schedule as
|
|
364
|
+
* given plus what was walked from it (`ScheduleWalked`, at the end of this file).
|
|
365
|
+
*/
|
|
366
|
+
export type ScheduleUsed = MotionSchedule & ScheduleWalked;
|
|
367
|
+
|
|
368
|
+
/** One candidate's evidence — correction 1. `reduce` and `measure` carry exactly one. */
|
|
369
|
+
export interface CandidateReport {
|
|
370
|
+
/** The caller's id for the candidate, or `result` for a `reduce`. */
|
|
371
|
+
id: string;
|
|
372
|
+
counts: MeshCounts | null;
|
|
373
|
+
/** Null when no mesh exists to measure (correction 3). */
|
|
374
|
+
geometry: EvidenceSection | null;
|
|
375
|
+
/** Null when no motion was asked for — never an empty PASS (P6). */
|
|
376
|
+
motion: (EvidenceSection & { schedule: ScheduleUsed }) | null;
|
|
377
|
+
/** P6 — declared-contract acceptance. */
|
|
378
|
+
accepted: boolean;
|
|
379
|
+
/** Opt-in (P7): every row's value at every frame. Absent unless asked for. */
|
|
380
|
+
perFrame?: Array<{ code: string; frame: string; value: number | null }>;
|
|
381
|
+
/** A `reduce` whose result exists: what the operation changed. Absent on a `measure` and when no mesh is returned. */
|
|
382
|
+
changes?: ReductionChanges;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* What a reduction changed, counted rather than described — the figures the
|
|
387
|
+
* contract says are reported (§6, P18 and P19) and that no geometry row carries.
|
|
388
|
+
*/
|
|
389
|
+
export interface ReductionChanges {
|
|
390
|
+
/** Source vertices the reduction removed. */
|
|
391
|
+
removedVertices: number;
|
|
392
|
+
/** Vertices the refinement inserted (§5). */
|
|
393
|
+
insertedVertices: number;
|
|
394
|
+
/** P19: positive interpolated shares that are 0 on the 6-decimal weight grid, dropped rather than written as 0. */
|
|
395
|
+
sharesDroppedOnGrid: number;
|
|
396
|
+
/** Interpolated shares pruned by `InfluenceLimits` — over `maxInfluences`, or under a nonzero `minWeight`. */
|
|
397
|
+
sharesPruned: number;
|
|
398
|
+
/** P18: `vertices` keys remapped, each with the source vertices whose offsets were dropped. */
|
|
399
|
+
deformRemapped: Array<{ animation: string; attachment: AttachmentRef; key: number; droppedVertices: number[] }>;
|
|
400
|
+
/** P18: `transform` keys, re-evaluated over the new geometry at compile rather than remapped. */
|
|
401
|
+
deformReevaluated: Array<{ animation: string; attachment: AttachmentRef; key: number }>;
|
|
402
|
+
/** Every linked mesh of the source: each inherits the new topology. */
|
|
403
|
+
linkedMeshes: AttachmentRef[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface MeshQualityReport {
|
|
407
|
+
spec: typeof MESH_QUALITY_REPORT_SPEC;
|
|
408
|
+
operation: 'reduce' | 'measure' | 'compare';
|
|
409
|
+
/** Correction 1: the inputs echoed with their structure. */
|
|
410
|
+
effective: EffectiveSettings;
|
|
411
|
+
/** P2: which poser ran and its version. Null on geometry-only operations. */
|
|
412
|
+
poser: { kind: 'core'; rigcVersion: string } | null;
|
|
413
|
+
/** Whether the caller required motion evidence (P6). */
|
|
414
|
+
motionRequired: boolean;
|
|
415
|
+
/** Null when the source could not be read as a mesh (correction 3: counts are never invented). */
|
|
416
|
+
sourceCounts: MeshCounts | null;
|
|
417
|
+
reference: CandidateReport | null;
|
|
418
|
+
candidates: CandidateReport[];
|
|
419
|
+
termination: Termination | null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Correction 1: every input the text requires, echoed field for field. */
|
|
423
|
+
export interface EffectiveSettings {
|
|
424
|
+
preset: { name: string; version: string } | null;
|
|
425
|
+
attachments: Array<{
|
|
426
|
+
attachment: AttachmentRef;
|
|
427
|
+
threshold: number;
|
|
428
|
+
finalThreshold: number;
|
|
429
|
+
frame: SourceFrame;
|
|
430
|
+
maskSize: [number, number];
|
|
431
|
+
minArtSamples: number;
|
|
432
|
+
/** P9: each region's own floor, in the order the regions were given — and, on a `compare`, its polygon. */
|
|
433
|
+
regions: Array<{ name: string; minArtSamples: number; polygon?: Array<[number, number]> }>;
|
|
434
|
+
}>;
|
|
435
|
+
sourceBounds: ArtFitBounds | null;
|
|
436
|
+
referenceArtFit: ArtFitBounds | null;
|
|
437
|
+
candidateArtFit: ArtFitBounds | null;
|
|
438
|
+
targets: ReductionTargets | MeasureTargets | null;
|
|
439
|
+
/** The hull polygon `MQ_BOUNDARY_DEVIATION` was taken against on a `measure`; null when none was given. */
|
|
440
|
+
referenceHull: Array<[number, number]> | null;
|
|
441
|
+
motionBounds: MotionBounds | null;
|
|
442
|
+
protect: ProtectedFeatures | null;
|
|
443
|
+
influences: InfluenceLimits | null;
|
|
444
|
+
boneOrder: string[] | null;
|
|
445
|
+
schedule: MotionSchedule | null;
|
|
446
|
+
budget: { maxCandidates: number } | null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface MeshCounts {
|
|
450
|
+
boundaryVertices: number;
|
|
451
|
+
interiorVertices: number;
|
|
452
|
+
triangles: number;
|
|
453
|
+
/** Total `{ bone, weight }` entries over all vertices; 0 on an unweighted mesh. */
|
|
454
|
+
bindings: number;
|
|
455
|
+
/** The most entries any one vertex carries; 0 on an unweighted mesh. */
|
|
456
|
+
maxInfluences: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** *Termination reasons*. Every `reduce` report carries exactly one; a `measure` carries one only when it could not read the mesh. */
|
|
460
|
+
export type Termination =
|
|
461
|
+
| { reason: 'no-further-valid-reduction'; candidatesTried: number; blockingConstraint: string }
|
|
462
|
+
| { reason: 'budget-exhausted'; candidatesTried: number; budget: number; result: 'best-meeting-every-bound' | 'none-met-the-targets' }
|
|
463
|
+
| { reason: 'invalid-input'; code: string; detail: string }
|
|
464
|
+
| { reason: 'unsupported-topology'; code: string; detail: string };
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// fixed tolerances
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
/** A22's slack on a region UV (`src/assertions/bodies/a22.ts`): outside 0..1 by more than this is refused. */
|
|
471
|
+
const UV_RANGE_SLACK = 1e-6;
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* How close the two-sided Hausdorff search brings its lower and upper bounds
|
|
475
|
+
* before it stops, in drawing px. The value reported is then put on the `r6`
|
|
476
|
+
* grid, six decimals, so the search is exact to three orders below what is
|
|
477
|
+
* printed.
|
|
478
|
+
*/
|
|
479
|
+
const HAUSDORFF_TOLERANCE = 1e-9;
|
|
480
|
+
|
|
481
|
+
/** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
|
|
482
|
+
const ON_BOUNDARY = 1e-9;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* How near a region's band's outer boundary, in drawing px, an edge's nearest
|
|
486
|
+
* point has to come to count as touching it rather than entering the band —
|
|
487
|
+
* the precision of "a single point on the outer boundary" in
|
|
488
|
+
* `edgeIsHeldByRegion`, ten units of the `r6` grid. The refinement puts the
|
|
489
|
+
* vertex it splits an edge at half of this inside the outer boundary and then
|
|
490
|
+
* on the grid, which moves it by at most √2 × 5e-7 px, so the piece beyond it
|
|
491
|
+
* lands inside this tolerance from either side.
|
|
492
|
+
*/
|
|
493
|
+
export const BAND_CONTACT_TOLERANCE = 1e-5;
|
|
494
|
+
|
|
495
|
+
/** Halvings of the parameter interval in the convex searches along an edge (`edgeIsHeldByRegion`): 2^-60 of an edge is below a double's resolution of it. */
|
|
496
|
+
const EDGE_SEARCH_STEPS = 60;
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// refusals
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
/** `skin/slot/attachment`, the way every message names the attachment. */
|
|
503
|
+
function nameOf(ref: AttachmentRef): string {
|
|
504
|
+
return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function refuse(code: string, message: string): never {
|
|
508
|
+
throw new MeshReductionError(code, message);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
512
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
516
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isPoint(value: unknown): value is [number, number] {
|
|
520
|
+
return Array.isArray(value) && value.length === 2 && isFiniteNumber(value[0]) && isFiniteNumber(value[1]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** A field that has to be present: `undefined` is a refusal naming it. */
|
|
524
|
+
function present(owner: string, field: string, value: unknown, required: string): void {
|
|
525
|
+
if (value === undefined) refuse('REDUCE_INPUT_MISSING', `${owner}: ${field} is missing; required ${required} — no field has a default`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Everything §1 refuses before a pixel is read, by code. The order is the order
|
|
530
|
+
* a fix has to happen in: the attachment's identity, then the art, then the
|
|
531
|
+
* mesh, then the bounds.
|
|
532
|
+
*/
|
|
533
|
+
function validateInput(input: MeshMeasureInput): void {
|
|
534
|
+
if (!isObject(input)) refuse('REDUCE_INPUT_MISSING', `the input is ${JSON.stringify(input)}; required a MeshMeasureInput object`);
|
|
535
|
+
const ref = input.attachment;
|
|
536
|
+
if (!isObject(ref) || typeof ref.slot !== 'string' || ref.slot === '' || typeof ref.attachment !== 'string' || ref.attachment === '' || !(ref.skin === null || typeof ref.skin === 'string')) {
|
|
537
|
+
refuse('REDUCE_INPUT_MISSING', `attachment is ${JSON.stringify(ref)}; required { skin: string | null, slot: string, attachment: string } with a slot and an attachment name`);
|
|
538
|
+
}
|
|
539
|
+
const who = `attachment ${nameOf(ref)}`;
|
|
540
|
+
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`);
|
|
541
|
+
|
|
542
|
+
// The art.
|
|
543
|
+
const art = input.art;
|
|
544
|
+
present(who, 'art', art, '{ mask, threshold, frame }');
|
|
545
|
+
if (!isObject(art)) refuse('REDUCE_INPUT_MISSING', `${who}: art is ${JSON.stringify(art)}; required { mask, threshold, frame }`);
|
|
546
|
+
present(who, 'art.threshold', art.threshold, 'a whole number in 1..255');
|
|
547
|
+
if (!Number.isInteger(art.threshold) || art.threshold < 1 || art.threshold > 255) {
|
|
548
|
+
refuse('REDUCE_THRESHOLD_RANGE', `${who}: art.threshold is ${JSON.stringify(art.threshold)}; required a whole number in 1..255 (art is alpha >= threshold)`);
|
|
549
|
+
}
|
|
550
|
+
const frame = art.frame;
|
|
551
|
+
present(who, 'art.frame', frame, '{ space, width, height, pageScale, conversion }');
|
|
552
|
+
if (!isObject(frame)) refuse('REDUCE_INPUT_MISSING', `${who}: art.frame is ${JSON.stringify(frame)}; required { space, width, height, pageScale, conversion }`);
|
|
553
|
+
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"`);
|
|
554
|
+
if (frame.conversion !== 'texels = px * pageScale') refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.conversion is ${JSON.stringify(frame.conversion)}; required "texels = px * pageScale"`);
|
|
555
|
+
for (const field of ['width', 'height', 'pageScale'] as const) {
|
|
556
|
+
const v = frame[field];
|
|
557
|
+
if (!isFiniteNumber(v) || v <= 0) refuse('REDUCE_INPUT_MISSING', `${who}: art.frame.${field} is ${JSON.stringify(v)}; required a finite number above 0`);
|
|
558
|
+
}
|
|
559
|
+
const mask = art.mask;
|
|
560
|
+
present(who, 'art.mask', mask, '{ width, height, alpha }');
|
|
561
|
+
if (!isObject(mask) || !(mask.alpha instanceof Uint8Array)) refuse('REDUCE_INPUT_MISSING', `${who}: art.mask is not { width, height, alpha: Uint8Array }; required an AlphaMask`);
|
|
562
|
+
if (!Number.isInteger(mask.width) || !Number.isInteger(mask.height) || mask.width < 1 || mask.height < 1) {
|
|
563
|
+
refuse('REDUCE_MASK_SIZE', `${who}: the mask is ${mask.width}x${mask.height}; required whole dimensions of at least 1x1`);
|
|
564
|
+
}
|
|
565
|
+
if (mask.alpha.length !== mask.width * mask.height) {
|
|
566
|
+
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}`);
|
|
567
|
+
}
|
|
568
|
+
// Correction 1: the mask has to be THIS attachment's — its grid is the frame's, by the stated scale.
|
|
569
|
+
const wantW = r6(frame.width * frame.pageScale);
|
|
570
|
+
const wantH = r6(frame.height * frame.pageScale);
|
|
571
|
+
if (mask.width !== wantW || mask.height !== wantH) {
|
|
572
|
+
refuse(
|
|
573
|
+
'REDUCE_MASK_SIZE',
|
|
574
|
+
`${who}: the mask is ${mask.width}x${mask.height} and the frame is ${frame.width}x${frame.height} px at pageScale ${frame.pageScale}; ` +
|
|
575
|
+
`required a ${wantW}x${wantH} mask (frame × pageScale) — a mask is one attachment's and is never shared with another of a different size`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// The mesh.
|
|
580
|
+
const src = input.source;
|
|
581
|
+
present(who, 'source', src, '{ points, uvs, triangles, hull, weights }');
|
|
582
|
+
if (!isObject(src) || !Array.isArray(src.points) || !Array.isArray(src.uvs) || !Array.isArray(src.triangles)) {
|
|
583
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights } with three arrays`);
|
|
584
|
+
}
|
|
585
|
+
src.points.forEach((p, i) => {
|
|
586
|
+
if (!isPoint(p)) refuse('REDUCE_INPUT_MISSING', `${who}: source.points[${i}] is ${JSON.stringify(p)}; required a pair of finite numbers`);
|
|
587
|
+
});
|
|
588
|
+
if (src.uvs.length !== 2 * src.points.length) {
|
|
589
|
+
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})`);
|
|
590
|
+
}
|
|
591
|
+
src.uvs.forEach((u, i) => {
|
|
592
|
+
if (!isFiniteNumber(u) || u < -UV_RANGE_SLACK || u > 1 + UV_RANGE_SLACK) {
|
|
593
|
+
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)`);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
if (!Number.isInteger(src.hull)) refuse('REDUCE_INPUT_MISSING', `${who}: source.hull is ${JSON.stringify(src.hull)}; required the whole number of hull vertices`);
|
|
597
|
+
if (src.weights !== null) {
|
|
598
|
+
if (!Array.isArray(src.weights) || src.weights.length !== src.points.length) {
|
|
599
|
+
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`);
|
|
600
|
+
}
|
|
601
|
+
src.weights.forEach((vertex, i) => {
|
|
602
|
+
if (!Array.isArray(vertex) || vertex.some((b) => !isObject(b) || typeof b.bone !== 'string' || !isFiniteNumber(b.weight))) {
|
|
603
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] is not a list of { bone, weight }; required that`);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The bounds.
|
|
609
|
+
const targets = input.targets;
|
|
610
|
+
present(who, 'targets', targets, '{ artFit, maxBoundaryDeviation, regions }');
|
|
611
|
+
if (!isObject(targets)) refuse('REDUCE_INPUT_MISSING', `${who}: targets is ${JSON.stringify(targets)}; required { artFit, maxBoundaryDeviation, regions }`);
|
|
612
|
+
present(who, 'targets.artFit', targets.artFit, 'an ArtFitBounds or null');
|
|
613
|
+
if (targets.artFit !== null) {
|
|
614
|
+
const fit = targets.artFit;
|
|
615
|
+
const coverage = fit.minCoverage;
|
|
616
|
+
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`);
|
|
617
|
+
for (const field of ['maxOvershoot', 'maxUndercut'] as const) {
|
|
618
|
+
const v = fit[field];
|
|
619
|
+
if (v !== null && (!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, or null (declared absent: measured, reported undeclared)`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
present(who, 'targets.maxBoundaryDeviation', targets.maxBoundaryDeviation, 'a number of px or null');
|
|
623
|
+
if (targets.maxBoundaryDeviation !== null && (!isFiniteNumber(targets.maxBoundaryDeviation) || targets.maxBoundaryDeviation < 0)) {
|
|
624
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: targets.maxBoundaryDeviation is ${JSON.stringify(targets.maxBoundaryDeviation)}; required a finite number of px, 0 or more, or null`);
|
|
625
|
+
}
|
|
626
|
+
if (targets.minAngle !== undefined && (!isFiniteNumber(targets.minAngle) || targets.minAngle < 0)) {
|
|
627
|
+
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`);
|
|
628
|
+
}
|
|
629
|
+
present(who, 'targets.regions', targets.regions, 'a list of regions (empty when none)');
|
|
630
|
+
if (!Array.isArray(targets.regions)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions is ${JSON.stringify(targets.regions)}; required a list (empty when none)`);
|
|
631
|
+
present(who, 'referenceHull', input.referenceHull, 'a polygon or null');
|
|
632
|
+
if (input.referenceHull !== null) {
|
|
633
|
+
if (!Array.isArray(input.referenceHull) || input.referenceHull.length < 3 || !input.referenceHull.every(isPoint)) {
|
|
634
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: referenceHull is not a polygon of at least 3 finite points; required that, or null`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// P9: the sample floors.
|
|
639
|
+
present(who, 'minArtSamples', input.minArtSamples, 'a whole number >= 1');
|
|
640
|
+
if (!Number.isInteger(input.minArtSamples) || input.minArtSamples < 1) {
|
|
641
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: minArtSamples is ${JSON.stringify(input.minArtSamples)}; required a whole number >= 1 (P9, no hidden constant)`);
|
|
642
|
+
}
|
|
643
|
+
present(who, 'regionArtSamples', input.regionArtSamples, 'one { region, minArtSamples } per region');
|
|
644
|
+
if (!Array.isArray(input.regionArtSamples)) refuse('REDUCE_INPUT_MISSING', `${who}: regionArtSamples is not a list; required one { region, minArtSamples } per region`);
|
|
645
|
+
const names = new Set<string>();
|
|
646
|
+
targets.regions.forEach((region, i) => {
|
|
647
|
+
if (!isObject(region) || typeof region.name !== 'string' || region.name === '') {
|
|
648
|
+
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`);
|
|
649
|
+
}
|
|
650
|
+
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`);
|
|
651
|
+
names.add(region.name);
|
|
652
|
+
if (!Array.isArray(region.polygon) || region.polygon.length < 3) {
|
|
653
|
+
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`);
|
|
654
|
+
}
|
|
655
|
+
for (const field of ['maxEdgeLength', 'transition', 'grade'] as const) present(`${who}, region "${region.name}"`, field, region[field], 'a finite number');
|
|
656
|
+
present(`${who}, region "${region.name}"`, 'approximation', region.approximation, 'an approximation or null');
|
|
657
|
+
const floors = input.regionArtSamples.filter((f) => isObject(f) && f.region === region.name);
|
|
658
|
+
if (floors.length !== 1 || !Number.isInteger(floors[0].minArtSamples) || floors[0].minArtSamples < 1) {
|
|
659
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: region "${region.name}" has ${floors.length} sample floor(s) in regionArtSamples; required exactly one, a whole number >= 1 (P9)`);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
input.regionArtSamples.forEach((f, i) => {
|
|
663
|
+
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`);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Echoed, never read — but present, since nothing has a default.
|
|
667
|
+
for (const field of ['protect', 'influences', 'boneOrder', 'preset'] as const) present(who, field, input[field], 'a value or null');
|
|
668
|
+
if (input.protect !== null) {
|
|
669
|
+
const p = input.protect;
|
|
670
|
+
const ok =
|
|
671
|
+
isObject(p) &&
|
|
672
|
+
typeof p.hull === 'boolean' &&
|
|
673
|
+
Array.isArray(p.vertices) &&
|
|
674
|
+
Array.isArray(p.edges) &&
|
|
675
|
+
Array.isArray(p.regionBoundaries) &&
|
|
676
|
+
(p.weightJump === null || isFiniteNumber(p.weightJump)) &&
|
|
677
|
+
Array.isArray(p.influences);
|
|
678
|
+
if (!ok) refuse('REDUCE_INPUT_MISSING', `${who}: protect is not a ProtectedFeatures with every field; required { hull, vertices, edges, regionBoundaries, weightJump, influences }, or null`);
|
|
679
|
+
}
|
|
680
|
+
if (input.influences !== null && (!isObject(input.influences) || !isFiniteNumber(input.influences.maxInfluences) || !isFiniteNumber(input.influences.minWeight))) {
|
|
681
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: influences is not { maxInfluences, minWeight }; required that, or null`);
|
|
682
|
+
}
|
|
683
|
+
if (input.boneOrder !== null && (!Array.isArray(input.boneOrder) || input.boneOrder.some((b) => typeof b !== 'string'))) {
|
|
684
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder is not a list of bone names; required that, or null`);
|
|
685
|
+
}
|
|
686
|
+
if (input.preset !== null && (!isObject(input.preset) || typeof input.preset.name !== 'string' || typeof input.preset.version !== 'string')) {
|
|
687
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: preset is not { name, version }; required that, or null`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// plane geometry
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
type Pt = readonly [number, number];
|
|
696
|
+
|
|
697
|
+
/** Is `p` inside or on the closed polygon? On the boundary within `ON_BOUNDARY` counts as inside. */
|
|
698
|
+
function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
699
|
+
const n = poly.length;
|
|
700
|
+
for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
|
|
701
|
+
let inside = false;
|
|
702
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
703
|
+
const [xi, yi] = poly[i];
|
|
704
|
+
const [xj, yj] = poly[j];
|
|
705
|
+
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
|
706
|
+
}
|
|
707
|
+
return inside;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Does the segment `a`–`b` meet the closed polygon — an endpoint inside or on it, or any crossing or touch? */
|
|
711
|
+
function segmentMeetsPolygon(a: Pt, b: Pt, poly: readonly Pt[]): boolean {
|
|
712
|
+
if (inClosedPolygon(a, poly) || inClosedPolygon(b, poly)) return true;
|
|
713
|
+
const n = poly.length;
|
|
714
|
+
for (let i = 0; i < n; i++) if (segmentsMeet(a, b, poly[i], poly[(i + 1) % n])) return true;
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Distance between two segments that do not meet: the nearest of the four endpoint-to-segment distances. */
|
|
719
|
+
function segmentDistance(a: Pt, b: Pt, c: Pt, d: Pt): number {
|
|
720
|
+
if (segmentsMeet(a, b, c, d)) return 0;
|
|
721
|
+
return Math.min(distanceToSegment(a, c, d), distanceToSegment(b, c, d), distanceToSegment(c, a, b), distanceToSegment(d, a, b));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Distance from a segment to a closed polygon's region: 0 when they meet, else to its nearest edge. */
|
|
725
|
+
function segmentToPolygon(a: Pt, b: Pt, poly: readonly Pt[]): number {
|
|
726
|
+
if (segmentMeetsPolygon(a, b, poly)) return 0;
|
|
727
|
+
let best = Infinity;
|
|
728
|
+
const n = poly.length;
|
|
729
|
+
for (let i = 0; i < n; i++) best = Math.min(best, segmentDistance(a, b, poly[i], poly[(i + 1) % n]));
|
|
730
|
+
return best;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The parameter in `[0, 1]` along `a`–`b` nearest which `c`–`d` lies — the
|
|
735
|
+
* minimum of the distance from `a + t(b − a)` to the segment `c`–`d`, which is
|
|
736
|
+
* convex in `t`, found by golden-section search. A flat minimum (parallel
|
|
737
|
+
* segments) returns some point of it; the callers never read one there.
|
|
738
|
+
*/
|
|
739
|
+
function nearestParameter(a: Pt, b: Pt, c: Pt, d: Pt): number {
|
|
740
|
+
const at = (t: number): number => distanceToSegment([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], c, d);
|
|
741
|
+
const g = (Math.sqrt(5) - 1) / 2;
|
|
742
|
+
let lo = 0;
|
|
743
|
+
let hi = 1;
|
|
744
|
+
for (let i = 0; i < EDGE_SEARCH_STEPS; i++) {
|
|
745
|
+
const p = hi - g * (hi - lo);
|
|
746
|
+
const q = lo + g * (hi - lo);
|
|
747
|
+
if (at(p) <= at(q)) hi = q;
|
|
748
|
+
else lo = p;
|
|
749
|
+
}
|
|
750
|
+
return (lo + hi) / 2;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Does `a`–`b` run along the outer boundary of a band of width `transition`
|
|
755
|
+
* for a positive length — some polygon edge it overlaps in projection by more
|
|
756
|
+
* than `BAND_CONTACT_TOLERANCE`, at that edge's perpendicular distance
|
|
757
|
+
* `transition` (within the tolerance) at both ends of the overlap, so at every
|
|
758
|
+
* point between them? The caller has already found the edge's nearest approach
|
|
759
|
+
* to the polygon within the tolerance of `transition`.
|
|
760
|
+
*/
|
|
761
|
+
function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: number): boolean {
|
|
762
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
763
|
+
const n = poly.length;
|
|
764
|
+
for (let i = 0; i < n; i++) {
|
|
765
|
+
const c = poly[i];
|
|
766
|
+
const e = poly[(i + 1) % n];
|
|
767
|
+
const side = Math.hypot(e[0] - c[0], e[1] - c[1]);
|
|
768
|
+
if (side === 0) continue;
|
|
769
|
+
const ux = (e[0] - c[0]) / side;
|
|
770
|
+
const uy = (e[1] - c[1]) / side;
|
|
771
|
+
const sa = (a[0] - c[0]) * ux + (a[1] - c[1]) * uy;
|
|
772
|
+
const sb = (b[0] - c[0]) * ux + (b[1] - c[1]) * uy;
|
|
773
|
+
let t0 = 0;
|
|
774
|
+
let t1 = 1;
|
|
775
|
+
if (sa === sb) {
|
|
776
|
+
if (sa < 0 || sa > side) continue;
|
|
777
|
+
} else {
|
|
778
|
+
const p = (0 - sa) / (sb - sa);
|
|
779
|
+
const q = (side - sa) / (sb - sa);
|
|
780
|
+
t0 = Math.max(0, Math.min(p, q));
|
|
781
|
+
t1 = Math.min(1, Math.max(p, q));
|
|
782
|
+
}
|
|
783
|
+
if ((t1 - t0) * length <= BAND_CONTACT_TOLERANCE) continue;
|
|
784
|
+
const off = (t: number): number => Math.abs(ux * (a[1] + (b[1] - a[1]) * t - c[1]) - uy * (a[0] + (b[0] - a[0]) * t - c[0]));
|
|
785
|
+
if (Math.abs(off(t0) - transition) <= BAND_CONTACT_TOLERANCE && Math.abs(off(t1) - transition) <= BAND_CONTACT_TOLERANCE) return true;
|
|
786
|
+
}
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* [agreed, spine-parts#126] Is the edge `a`–`b` held by `region` — does the
|
|
792
|
+
* region's density bound (`MQ_MAX_EDGE` or `MQ_TRANSITION`) apply to it? The
|
|
793
|
+
* one definition both `measureMeshQuality` and `reduceMesh`'s refinement read.
|
|
794
|
+
*
|
|
795
|
+
* The region's active domain is its closed polygon and, when `transition > 0`,
|
|
796
|
+
* the band of that width outside it. An edge is held when it meets that domain,
|
|
797
|
+
* except for one case: its intersection with the domain is a **single point on
|
|
798
|
+
* the band's outer boundary** and the rest of the edge lies outside — an edge
|
|
799
|
+
* that only touches the band from outside is exempt.
|
|
800
|
+
*
|
|
801
|
+
* - An edge that meets the closed polygon is held, whatever the band.
|
|
802
|
+
* - With `transition: 0` there is no outer band, and a contact with the
|
|
803
|
+
* authored boundary is held under the closed-region rule — never exempt.
|
|
804
|
+
* - A positive-length intersection is held, including a segment lying along
|
|
805
|
+
* the band's outer boundary.
|
|
806
|
+
* - Two separate touches of the outer boundary are two points, not one, and
|
|
807
|
+
* the edge is held.
|
|
808
|
+
*
|
|
809
|
+
* "On the outer boundary" is within `BAND_CONTACT_TOLERANCE`. Each region is
|
|
810
|
+
* read on its own: an exemption from one never removes another's bound.
|
|
811
|
+
*/
|
|
812
|
+
export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [number, number], region: RefinementRegion): boolean {
|
|
813
|
+
const poly: readonly Pt[] = region.polygon;
|
|
814
|
+
if (segmentMeetsPolygon(a, b, poly)) return true;
|
|
815
|
+
const transition = region.transition;
|
|
816
|
+
if (!(transition > 0)) return false;
|
|
817
|
+
const d = segmentToPolygon(a, b, poly);
|
|
818
|
+
if (d > transition) return false;
|
|
819
|
+
if (d < transition - BAND_CONTACT_TOLERANCE) return true;
|
|
820
|
+
if (runsAlongOuterBoundary(a, b, poly, transition)) return true;
|
|
821
|
+
// Where on the edge each polygon side is touched: one point, or several apart.
|
|
822
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
823
|
+
let first = Infinity;
|
|
824
|
+
let last = -Infinity;
|
|
825
|
+
const n = poly.length;
|
|
826
|
+
for (let i = 0; i < n; i++) {
|
|
827
|
+
const c = poly[i];
|
|
828
|
+
const e = poly[(i + 1) % n];
|
|
829
|
+
if (segmentDistance(a, b, c, e) > transition) continue;
|
|
830
|
+
const t = nearestParameter(a, b, c, e);
|
|
831
|
+
first = Math.min(first, t);
|
|
832
|
+
last = Math.max(last, t);
|
|
833
|
+
}
|
|
834
|
+
return (last - first) * length > BAND_CONTACT_TOLERANCE;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Distance from a point to a closed polyline (the polygon's boundary), and the edge that realises it. */
|
|
838
|
+
function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number } {
|
|
839
|
+
let d = Infinity;
|
|
840
|
+
let edge = 0;
|
|
841
|
+
const n = poly.length;
|
|
842
|
+
for (let i = 0; i < n; i++) {
|
|
843
|
+
const v = distanceToSegment(p, poly[i], poly[(i + 1) % n]);
|
|
844
|
+
if (v < d) {
|
|
845
|
+
d = v;
|
|
846
|
+
edge = i;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return { d, edge };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* The directed Hausdorff distance from the boundary of `a` to the boundary of
|
|
854
|
+
* `b` — the furthest any point of `a`'s outline sits from `b`'s — and where.
|
|
855
|
+
*
|
|
856
|
+
* Exact up to `HAUSDORFF_TOLERANCE`, by branch and bound along each edge of
|
|
857
|
+
* `a`. The distance to one edge of `b` is convex along a segment, so on any
|
|
858
|
+
* piece of an edge of `a` it is at most the larger of its two end values; the
|
|
859
|
+
* distance to the whole of `b` is the minimum over `b`'s edges, so the smallest
|
|
860
|
+
* of those maxima bounds it from above. A piece whose bound cannot beat the best
|
|
861
|
+
* value already found is dropped; the rest are halved. No sampling density is
|
|
862
|
+
* chosen, so nothing here can miss a maximum between two samples.
|
|
863
|
+
*/
|
|
864
|
+
function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt) => Float64Array) | null = null): { d: number; edge: number; at: Pt } {
|
|
865
|
+
const m = b.length;
|
|
866
|
+
const toEach =
|
|
867
|
+
carried ??
|
|
868
|
+
((p: Pt): Float64Array => {
|
|
869
|
+
const out = new Float64Array(m);
|
|
870
|
+
for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
|
|
871
|
+
return out;
|
|
872
|
+
});
|
|
873
|
+
const minOf = (g: Float64Array): number => {
|
|
874
|
+
let v = Infinity;
|
|
875
|
+
for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
|
|
876
|
+
return v;
|
|
877
|
+
};
|
|
878
|
+
let best = -1;
|
|
879
|
+
let bestEdge = 0;
|
|
880
|
+
let bestAt: Pt = a[0];
|
|
881
|
+
const n = a.length;
|
|
882
|
+
for (let i = 0; i < n; i++) {
|
|
883
|
+
const p0 = a[i];
|
|
884
|
+
const p1 = a[(i + 1) % n];
|
|
885
|
+
const at = (t: number): Pt => [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t];
|
|
886
|
+
const consider = (f: number, t: number): void => {
|
|
887
|
+
if (f > best) {
|
|
888
|
+
best = f;
|
|
889
|
+
bestEdge = i;
|
|
890
|
+
bestAt = at(t);
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
const g0 = toEach(p0);
|
|
894
|
+
const g1 = toEach(p1);
|
|
895
|
+
consider(minOf(g0), 0);
|
|
896
|
+
consider(minOf(g1), 1);
|
|
897
|
+
const stack: Array<{ t0: number; t1: number; g0: Float64Array; g1: Float64Array; depth: number }> = [{ t0: 0, t1: 1, g0, g1, depth: 0 }];
|
|
898
|
+
while (stack.length > 0) {
|
|
899
|
+
const piece = stack.pop()!;
|
|
900
|
+
let upper = Infinity;
|
|
901
|
+
for (let j = 0; j < m; j++) upper = Math.min(upper, Math.max(piece.g0[j], piece.g1[j]));
|
|
902
|
+
if (upper <= best + HAUSDORFF_TOLERANCE || piece.depth >= 64) continue;
|
|
903
|
+
const tm = (piece.t0 + piece.t1) / 2;
|
|
904
|
+
const gm = toEach(at(tm));
|
|
905
|
+
consider(minOf(gm), tm);
|
|
906
|
+
stack.push({ t0: piece.t0, t1: tm, g0: piece.g0, g1: gm, depth: piece.depth + 1 });
|
|
907
|
+
stack.push({ t0: tm, t1: piece.t1, g0: gm, g1: piece.g1, depth: piece.depth + 1 });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return { d: Math.max(best, 0), edge: bestEdge, at: bestAt };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The symmetric Hausdorff distance between two closed polygons' boundaries,
|
|
915
|
+
* with the candidate hull edge the worst value belongs to: the edge it was
|
|
916
|
+
* taken on when the worst point is the candidate's, else the candidate edge
|
|
917
|
+
* nearest the reference's worst point.
|
|
918
|
+
*/
|
|
919
|
+
function hausdorff(candidate: readonly Pt[], reference: readonly Pt[], carried: { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array } | null = null): { d: number; candidateEdge: number } {
|
|
920
|
+
const forward = directedHausdorff(candidate, reference, carried?.forward ?? null);
|
|
921
|
+
const backward = directedHausdorff(reference, candidate, carried?.backward ?? null);
|
|
922
|
+
if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
|
|
923
|
+
return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** A point as a key: both coordinates' shortest round-trip decimals, a negative zero told from a positive one. */
|
|
927
|
+
function pointKey(p: Pt): string {
|
|
928
|
+
const k = (v: number): string => (v === 0 && 1 / v < 0 ? '-0' : String(v));
|
|
929
|
+
return `${k(p[0])},${k(p[1])}`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* `hausdorff`'s point-to-edge distance lists for one outline slot of a
|
|
934
|
+
* reduction, carried from the slot's last reading (`OutlineMemo`, issue #1246).
|
|
935
|
+
* Every list is the one `directedHausdorff` computes — `distanceToSegment(p,
|
|
936
|
+
* b[j], b[(j + 1) % m])` for every edge `j` — had in one of three ways, each
|
|
937
|
+
* the same function of the same input:
|
|
938
|
+
*
|
|
939
|
+
* - **toward the polygon read against** (fixed in a reduction): the list
|
|
940
|
+
* kept for the same point, handed back whole while that polygon is equal;
|
|
941
|
+
* - **toward the outline**, which a step changes: the last reading's list for
|
|
942
|
+
* the same point, each distance copied when its edge — both ends, exactly —
|
|
943
|
+
* is an edge of the last outline, and computed when it is not;
|
|
944
|
+
* - anything else: computed.
|
|
945
|
+
*/
|
|
946
|
+
function carriedDistances(
|
|
947
|
+
memo: OutlineMemo,
|
|
948
|
+
outline: readonly Pt[],
|
|
949
|
+
against: readonly Pt[],
|
|
950
|
+
steps: StepRasters,
|
|
951
|
+
): { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array; commit: () => void } {
|
|
952
|
+
const { tally, plant } = steps;
|
|
953
|
+
memo.readings++;
|
|
954
|
+
const reading = memo.readings;
|
|
955
|
+
// The polygon read against: keep the lists while it is equal, coordinate by coordinate.
|
|
956
|
+
const againstFlat = new Float64Array(against.length * 2);
|
|
957
|
+
against.forEach((p, i) => {
|
|
958
|
+
againstFlat[i * 2] = p[0];
|
|
959
|
+
againstFlat[i * 2 + 1] = p[1];
|
|
960
|
+
});
|
|
961
|
+
const sameAgainst = memo.against !== null && memo.against.length === againstFlat.length && memo.against.every((v, i) => Object.is(v, againstFlat[i]));
|
|
962
|
+
if (!sameAgainst) {
|
|
963
|
+
memo.toAgainst.clear();
|
|
964
|
+
memo.against = againstFlat;
|
|
965
|
+
}
|
|
966
|
+
const ma = against.length;
|
|
967
|
+
const forward = (p: Pt): Float64Array => {
|
|
968
|
+
tally.distanceLists++;
|
|
969
|
+
const key = pointKey(p);
|
|
970
|
+
const kept = memo.toAgainst.get(key);
|
|
971
|
+
if (kept !== undefined) {
|
|
972
|
+
kept.used = reading;
|
|
973
|
+
tally.distanceListsReused++;
|
|
974
|
+
return kept.list;
|
|
975
|
+
}
|
|
976
|
+
const list = new Float64Array(ma);
|
|
977
|
+
for (let j = 0; j < ma; j++) list[j] = distanceToSegment(p, against[j], against[(j + 1) % ma]);
|
|
978
|
+
tally.edgesComputed += ma;
|
|
979
|
+
memo.toAgainst.set(key, { list, used: reading });
|
|
980
|
+
return list;
|
|
981
|
+
};
|
|
982
|
+
// The outline's edges, and where each sat in the last outline.
|
|
983
|
+
const mo = outline.length;
|
|
984
|
+
const edges = new Map<string, number>();
|
|
985
|
+
const from = new Int32Array(mo);
|
|
986
|
+
const lastEdges = memo.outlineEdges;
|
|
987
|
+
let plantPending = plant === 'skip-an-edge';
|
|
988
|
+
for (let j = 0; j < mo; j++) {
|
|
989
|
+
const key = `${pointKey(outline[j])};${pointKey(outline[(j + 1) % mo])}`;
|
|
990
|
+
edges.set(key, j);
|
|
991
|
+
const was = lastEdges?.get(key);
|
|
992
|
+
from[j] = was ?? -1;
|
|
993
|
+
if (was === undefined && plantPending && lastEdges !== null && j < lastEdges.size) {
|
|
994
|
+
// The plant: one new edge read as if it were the last outline's edge at the same index.
|
|
995
|
+
plantPending = false;
|
|
996
|
+
from[j] = j;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const last = memo.toOutline;
|
|
1000
|
+
const next = new Map<string, Float64Array>();
|
|
1001
|
+
const backward = (p: Pt): Float64Array => {
|
|
1002
|
+
tally.distanceLists++;
|
|
1003
|
+
const key = pointKey(p);
|
|
1004
|
+
const done = next.get(key);
|
|
1005
|
+
if (done !== undefined) {
|
|
1006
|
+
tally.distanceListsReused++;
|
|
1007
|
+
return done;
|
|
1008
|
+
}
|
|
1009
|
+
const before = last.get(key);
|
|
1010
|
+
const list = new Float64Array(mo);
|
|
1011
|
+
if (before === undefined) {
|
|
1012
|
+
for (let j = 0; j < mo; j++) list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
|
|
1013
|
+
tally.edgesComputed += mo;
|
|
1014
|
+
} else {
|
|
1015
|
+
tally.distanceListsCarried++;
|
|
1016
|
+
for (let j = 0; j < mo; j++) {
|
|
1017
|
+
if (from[j] >= 0) {
|
|
1018
|
+
list[j] = before[from[j]];
|
|
1019
|
+
tally.edgesCarried++;
|
|
1020
|
+
} else {
|
|
1021
|
+
list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
|
|
1022
|
+
tally.edgesComputed++;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
next.set(key, list);
|
|
1027
|
+
return list;
|
|
1028
|
+
};
|
|
1029
|
+
return {
|
|
1030
|
+
forward,
|
|
1031
|
+
backward,
|
|
1032
|
+
commit: () => {
|
|
1033
|
+
// Keep this reading's outline lists for the next one; drop the lists toward the fixed polygon the last two readings did not ask for.
|
|
1034
|
+
memo.toOutline = next;
|
|
1035
|
+
memo.outlineEdges = edges;
|
|
1036
|
+
for (const [key, entry] of memo.toAgainst) if (entry.used < reading - 1) memo.toAgainst.delete(key);
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** `hausdorff` through a reduction's carried distances (`carriedDistances`), dropping what the last two readings of the slot did not ask for. */
|
|
1042
|
+
function hausdorffCarried(memo: OutlineMemo, candidate: readonly Pt[], reference: readonly Pt[], steps: StepRasters): { d: number; candidateEdge: number } {
|
|
1043
|
+
const carried = carriedDistances(memo, candidate, reference, steps);
|
|
1044
|
+
const value = hausdorff(candidate, reference, carried);
|
|
1045
|
+
carried.commit();
|
|
1046
|
+
return value;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ---------------------------------------------------------------------------
|
|
1050
|
+
// rows
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
/** A row as it is being built: the report's row, and whether the caller's declared contract requires it (P6). */
|
|
1054
|
+
interface Built {
|
|
1055
|
+
row: MeasureRow;
|
|
1056
|
+
required: boolean;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function nearBoundOf(value: number | null, bound: MeasureRow['bound'], increment: number): RasterSensitivity['nearBound'] {
|
|
1060
|
+
if (value === null || bound === null) return 'clear';
|
|
1061
|
+
const gap = Math.abs(value - bound.value);
|
|
1062
|
+
if (gap === 0) return 'at-bound';
|
|
1063
|
+
return gap <= increment ? 'within-increment' : 'clear';
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Pass or fail against an inclusive bound — equality passes. */
|
|
1067
|
+
function judged(value: number, bound: { op: '<=' | '>='; value: number }): 'pass' | 'fail' {
|
|
1068
|
+
return (bound.op === '<=' ? value <= bound.value : value >= bound.value) ? 'pass' : 'fail';
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const NOTHING_WORSE: WorstSample = { at: {} };
|
|
1072
|
+
|
|
1073
|
+
interface RowSpec {
|
|
1074
|
+
code: string;
|
|
1075
|
+
region: string | null;
|
|
1076
|
+
unit: MeasureRow['unit'];
|
|
1077
|
+
attachment: AttachmentRef;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function measuredRow(spec: RowSpec, value: number, bound: MeasureRow['bound'], worst: WorstSample, required: boolean): Built {
|
|
1081
|
+
return {
|
|
1082
|
+
row: {
|
|
1083
|
+
code: spec.code,
|
|
1084
|
+
object: { attachment: spec.attachment, region: spec.region },
|
|
1085
|
+
state: bound === null ? 'undeclared' : judged(value, bound),
|
|
1086
|
+
value,
|
|
1087
|
+
bound,
|
|
1088
|
+
unit: spec.unit,
|
|
1089
|
+
worst,
|
|
1090
|
+
reason: null,
|
|
1091
|
+
},
|
|
1092
|
+
required: required && bound !== null,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function unmeasuredRow(spec: RowSpec, state: 'refused' | 'not-measurable', reason: string, required: boolean): Built {
|
|
1097
|
+
return {
|
|
1098
|
+
row: { code: spec.code, object: { attachment: spec.attachment, region: spec.region }, state, value: null, bound: null, unit: spec.unit, worst: null, reason },
|
|
1099
|
+
required,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** The raster fields of a row: its art, and the grid and increment its value is quantised on (correction 2). */
|
|
1104
|
+
function withRaster(built: Built, art: NonNullable<MeasureRow['art']>, grid: RasterSensitivity['grid'], increment: number): Built {
|
|
1105
|
+
const row = built.row;
|
|
1106
|
+
row.art = art;
|
|
1107
|
+
row.raster = {
|
|
1108
|
+
grid,
|
|
1109
|
+
spatialQuantum: 1 / grid.pageScale,
|
|
1110
|
+
valueIncrement: increment,
|
|
1111
|
+
nearBound: nearBoundOf(row.value, row.bound, increment),
|
|
1112
|
+
};
|
|
1113
|
+
return built;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Row order (§2): code, then region (the attachment's own row first), then the gated fill before the labelled legacy one. */
|
|
1117
|
+
function compareRows(a: MeasureRow, b: MeasureRow): number {
|
|
1118
|
+
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
|
|
1119
|
+
const ra = a.object.region;
|
|
1120
|
+
const rb = b.object.region;
|
|
1121
|
+
if (ra !== rb) {
|
|
1122
|
+
if (ra === null) return -1;
|
|
1123
|
+
if (rb === null) return 1;
|
|
1124
|
+
return ra < rb ? -1 : 1;
|
|
1125
|
+
}
|
|
1126
|
+
const ca = a.art?.connectivity ?? 0;
|
|
1127
|
+
const cb = b.art?.connectivity ?? 0;
|
|
1128
|
+
return cb - ca;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
// the operation
|
|
1133
|
+
// ---------------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Measure one mesh against the art its attachment draws and the bounds the
|
|
1137
|
+
* caller declared — §4's geometry rows, no motion. Throws a
|
|
1138
|
+
* `MeshReductionError` for an input §1 refuses; reports a source whose
|
|
1139
|
+
* triangles are not one closed loop rather than throwing, with `sourceCounts`
|
|
1140
|
+
* null and an `unsupported-topology` termination saying why (correction 3: a
|
|
1141
|
+
* count is never invented).
|
|
1142
|
+
*/
|
|
1143
|
+
export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
|
|
1144
|
+
validateInput(input);
|
|
1145
|
+
return measureValidated(input, artRastersOf(input.art), null);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* `measureMeshQuality` over art rasters the caller already holds
|
|
1150
|
+
* (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
|
|
1151
|
+
* one call, so the quantities of the art alone are computed once per call
|
|
1152
|
+
* rather than once per step (issue #1240). The report is the one
|
|
1153
|
+
* `measureMeshQuality` returns for the same input, byte for byte; rasters
|
|
1154
|
+
* taken from another art are refused (`REDUCE_ART_RASTERS_MISMATCH`), never
|
|
1155
|
+
* read.
|
|
1156
|
+
*
|
|
1157
|
+
* Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
|
|
1158
|
+
* module with `export *`, and a symbol that is merely exported is not promised
|
|
1159
|
+
* (RELEASING.md, *The import surface*).
|
|
1160
|
+
*/
|
|
1161
|
+
export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
|
|
1162
|
+
validateInput(input);
|
|
1163
|
+
checkArtRasters(input, rasters);
|
|
1164
|
+
return measureValidated(input, rasters, null);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* `measureMeshQualityWith` carried from the last measurement the same
|
|
1169
|
+
* `StepRasters` made (`src/meshrasters.ts`, issue #1246) — what `reduceMesh`
|
|
1170
|
+
* calls for each step, so a step redraws only the triangles its removal
|
|
1171
|
+
* changed, redoes the distance transform only where the coverage flipped, and
|
|
1172
|
+
* reads the outline rows again only when the outline moved. The report is the
|
|
1173
|
+
* one `measureMeshQuality` returns for the same input, byte for byte; rasters
|
|
1174
|
+
* taken from another art are refused as `measureMeshQualityWith` refuses them.
|
|
1175
|
+
*
|
|
1176
|
+
* Internal, as `measureMeshQualityWith` is.
|
|
1177
|
+
*/
|
|
1178
|
+
export function measureMeshQualityStep(input: MeshMeasureInput, steps: StepRasters): MeshQualityReport {
|
|
1179
|
+
validateInput(input);
|
|
1180
|
+
checkArtRasters(input, steps.rasters);
|
|
1181
|
+
return measureValidated(input, steps.rasters, steps);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* The coverage reading of a triangle set measured on its own — the full path:
|
|
1186
|
+
* every triangle drawn (`rasteriseTriangles`), the whole distance transform,
|
|
1187
|
+
* and the covered art and islands counted over every pixel.
|
|
1188
|
+
*/
|
|
1189
|
+
function coverageOf(onGrid: Array<[number, number]>, triangles: number[], w: number, h: number, rasters: ArtRasters): CoverageReading {
|
|
1190
|
+
const artBits = rasters.artBits();
|
|
1191
|
+
const covered = rasteriseTriangles(onGrid, triangles, w, h);
|
|
1192
|
+
let coveredArt = 0;
|
|
1193
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
|
|
1194
|
+
const toCovered = squaredDistanceToSet(covered, w, h);
|
|
1195
|
+
const anyCovered = covered.some((c) => c === 1);
|
|
1196
|
+
let undercutAt = -1;
|
|
1197
|
+
let undercutSq = 0;
|
|
1198
|
+
for (let i = 0; i < artBits.length; i++) {
|
|
1199
|
+
if (!artBits[i] || covered[i]) continue;
|
|
1200
|
+
if (undercutAt === -1 || toCovered[i] > undercutSq) {
|
|
1201
|
+
undercutAt = i;
|
|
1202
|
+
undercutSq = toCovered[i];
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const { label } = rasters.islands();
|
|
1206
|
+
const touched = new Set<number>();
|
|
1207
|
+
for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
|
|
1208
|
+
const secondIsland = touched.size >= 2 ? [...touched].sort((p, q) => p - q)[1] : 0;
|
|
1209
|
+
const silhouette = (connectivity: 4 | 8): SilhouetteReading => {
|
|
1210
|
+
const fills = rasters.fills();
|
|
1211
|
+
const filled = connectivity === 8 ? fills.fill8 : fills.fill4;
|
|
1212
|
+
const toFilled = rasters.toFilled(connectivity);
|
|
1213
|
+
let overAt = -1;
|
|
1214
|
+
let overSq = 0;
|
|
1215
|
+
let holes = 0;
|
|
1216
|
+
let holeAt = -1;
|
|
1217
|
+
for (let i = 0; i < covered.length; i++) {
|
|
1218
|
+
if (!covered[i]) continue;
|
|
1219
|
+
if (!filled[i]) {
|
|
1220
|
+
if (overAt === -1 || toFilled[i] > overSq) {
|
|
1221
|
+
overAt = i;
|
|
1222
|
+
overSq = toFilled[i];
|
|
1223
|
+
}
|
|
1224
|
+
} else if (!artBits[i]) {
|
|
1225
|
+
holes++;
|
|
1226
|
+
if (holeAt === -1) holeAt = i;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return { overAt, overSq, holes, holeAt };
|
|
1230
|
+
};
|
|
1231
|
+
return { covered, toCovered, coveredArt, anyCovered, undercut: { at: undercutAt, sq: undercutSq }, silhouette, islandsTouched: touched.size, secondIsland };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Rasters are read only for the art they were taken from: the same mask
|
|
1236
|
+
* array, mask size, threshold and frame. The first field that differs is
|
|
1237
|
+
* refused by name, with the value the rasters were taken at and the value the
|
|
1238
|
+
* input requires.
|
|
1239
|
+
*/
|
|
1240
|
+
function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
|
|
1241
|
+
const want = input.art;
|
|
1242
|
+
const got = rasters.art;
|
|
1243
|
+
if (got === want) return;
|
|
1244
|
+
const who = `attachment ${nameOf(input.attachment)}`;
|
|
1245
|
+
const differs = (field: string, found: string, required: string): never =>
|
|
1246
|
+
refuse('REDUCE_ART_RASTERS_MISMATCH', `${who}: the art rasters were taken at ${field} ${found}; required ${field} ${required}, the input's — rasters are read only for the art they were taken from`);
|
|
1247
|
+
const size = (m: AlphaMask | undefined): string => (m === undefined ? 'undefined' : `${m.width}x${m.height}`);
|
|
1248
|
+
if (size(got.mask) !== size(want.mask)) differs('art.mask size', size(got.mask), size(want.mask));
|
|
1249
|
+
if (got.threshold !== want.threshold) differs('art.threshold', JSON.stringify(got.threshold), JSON.stringify(want.threshold));
|
|
1250
|
+
for (const field of ['pageScale', 'width', 'height'] as const) {
|
|
1251
|
+
if (got.frame?.[field] !== want.frame[field]) differs(`art.frame.${field}`, JSON.stringify(got.frame?.[field]), JSON.stringify(want.frame[field]));
|
|
1252
|
+
}
|
|
1253
|
+
if (got.mask.alpha !== want.mask.alpha) differs('art.mask.alpha', 'another array', 'the input\'s own array (the same object)');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
|
|
1257
|
+
function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
|
|
1258
|
+
rasters.tally.uses++;
|
|
1259
|
+
const { attachment, art, source, targets } = input;
|
|
1260
|
+
const { mask, threshold, frame } = art;
|
|
1261
|
+
const scale = frame.pageScale;
|
|
1262
|
+
const effective = effectiveOf(input);
|
|
1263
|
+
const report = (sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination | null): MeshQualityReport => ({
|
|
1264
|
+
spec: MESH_QUALITY_REPORT_SPEC,
|
|
1265
|
+
operation: 'measure',
|
|
1266
|
+
effective,
|
|
1267
|
+
poser: null,
|
|
1268
|
+
motionRequired: false,
|
|
1269
|
+
sourceCounts,
|
|
1270
|
+
reference: null,
|
|
1271
|
+
candidates: [candidate],
|
|
1272
|
+
termination,
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// The outline the triangles state — or the reason there is none.
|
|
1276
|
+
const n = source.points.length;
|
|
1277
|
+
let outline: MeshOutline;
|
|
1278
|
+
try {
|
|
1279
|
+
outline = traceOutline(n, source.triangles);
|
|
1280
|
+
if (outline.hull !== source.hull) {
|
|
1281
|
+
throw new MeshError(`source.hull says ${source.hull} and the triangles' outline has ${outline.hull} vertices`);
|
|
1282
|
+
}
|
|
1283
|
+
checkHullOrder(outline, n);
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
if (!(err instanceof MeshError)) throw err;
|
|
1286
|
+
const detail = `attachment ${nameOf(attachment)}: ${err.message}; required one closed outline listed first, in order (traceOutline, checkHullOrder)`;
|
|
1287
|
+
return report(null, { id: input.id, counts: null, geometry: null, motion: null, accepted: false }, { reason: 'unsupported-topology', code: 'REDUCE_SOURCE_NOT_ONE_LOOP', detail });
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const counts = countsOf(source, outline);
|
|
1291
|
+
const rows: Built[] = [];
|
|
1292
|
+
const spec = (code: string, unit: MeasureRow['unit'], region: string | null = null): RowSpec => ({ code, unit, region, attachment });
|
|
1293
|
+
const points: Pt[] = source.points;
|
|
1294
|
+
const hullPolygon: Pt[] = outline.walk.map((v) => points[v]);
|
|
1295
|
+
|
|
1296
|
+
// --- raster rows ---------------------------------------------------------
|
|
1297
|
+
const w = mask.width;
|
|
1298
|
+
const h = mask.height;
|
|
1299
|
+
const grid = { width: w, height: h, pageScale: scale };
|
|
1300
|
+
const artBits = rasters.artBits();
|
|
1301
|
+
const artCount = rasters.artCount();
|
|
1302
|
+
const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
|
|
1303
|
+
const fit = targets.artFit;
|
|
1304
|
+
const pxIncrement = 1 / scale;
|
|
1305
|
+
const rasterArt = (connectivity: 4 | 8 | null): NonNullable<MeasureRow['art']> => ({ threshold, connectivity, samples: artCount });
|
|
1306
|
+
const pixelOf = (i: number): [number, number] => [i % w, Math.floor(i / w)];
|
|
1307
|
+
if (artCount < input.minArtSamples) {
|
|
1308
|
+
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`;
|
|
1309
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_COVERAGE', 'fraction'), 'not-measurable', why, fit !== null), rasterArt(null), grid, artCount === 0 ? 1 : 1 / artCount));
|
|
1310
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null && fit.maxOvershoot !== null), rasterArt(8), grid, pxIncrement));
|
|
1311
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null && fit.maxUndercut !== null), rasterArt(null), grid, pxIncrement));
|
|
1312
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
|
|
1313
|
+
rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
|
|
1314
|
+
} else {
|
|
1315
|
+
// Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers. A reduction step
|
|
1316
|
+
// carries it from its last measurement (`StepRasters`), which reads the same values.
|
|
1317
|
+
const reading = steps === null ? coverageOf(onGrid, source.triangles, w, h, rasters) : steps.coverage(onGrid, source.triangles);
|
|
1318
|
+
const { coveredArt, anyCovered } = reading;
|
|
1319
|
+
// Undercut: the furthest uncovered art pixel from the covered set. Its pixel is coverage's worst too.
|
|
1320
|
+
const { at: undercutAt, sq: undercutSq } = reading.undercut;
|
|
1321
|
+
const missingWorst: WorstSample = undercutAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(undercutAt) } };
|
|
1322
|
+
rows.push(
|
|
1323
|
+
withRaster(
|
|
1324
|
+
measuredRow(spec('MQ_COVERAGE', 'fraction'), coveredArt / artCount, fit === null ? null : { op: '>=', value: fit.minCoverage }, missingWorst, true),
|
|
1325
|
+
rasterArt(null),
|
|
1326
|
+
grid,
|
|
1327
|
+
1 / artCount,
|
|
1328
|
+
),
|
|
1329
|
+
);
|
|
1330
|
+
const undercutSpec = spec('MQ_UNDERCUT', 'px');
|
|
1331
|
+
rows.push(
|
|
1332
|
+
withRaster(
|
|
1333
|
+
!anyCovered
|
|
1334
|
+
? 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 && fit.maxUndercut !== null)
|
|
1335
|
+
: measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null || fit.maxUndercut === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
|
|
1336
|
+
rasterArt(null),
|
|
1337
|
+
grid,
|
|
1338
|
+
pxIncrement,
|
|
1339
|
+
),
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1342
|
+
// Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
|
|
1343
|
+
// where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
|
|
1344
|
+
const fillsDiffer = rasters.fills().differ;
|
|
1345
|
+
const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
|
|
1346
|
+
const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
|
|
1347
|
+
const bound = gated && fit !== null && fit.maxOvershoot !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
|
|
1348
|
+
rows.push(
|
|
1349
|
+
withRaster(
|
|
1350
|
+
measuredRow(spec('MQ_OVERSHOOT', 'px'), r6(Math.sqrt(overSq) / scale), bound, overAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(overAt) } }, gated),
|
|
1351
|
+
rasterArt(connectivity),
|
|
1352
|
+
grid,
|
|
1353
|
+
pxIncrement,
|
|
1354
|
+
),
|
|
1355
|
+
);
|
|
1356
|
+
rows.push(withRaster(measuredRow(spec('MQ_HOLES', 'count'), holes, null, holeAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(holeAt) } }, false), rasterArt(connectivity), grid, 1));
|
|
1357
|
+
};
|
|
1358
|
+
silhouetteRows(8, true);
|
|
1359
|
+
if (fillsDiffer) silhouetteRows(4, false);
|
|
1360
|
+
|
|
1361
|
+
// Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
|
|
1362
|
+
const { label } = rasters.islands();
|
|
1363
|
+
const joinedAt = reading.islandsTouched >= 2 ? label.indexOf(reading.secondIsland) : -1;
|
|
1364
|
+
rows.push(
|
|
1365
|
+
withRaster(
|
|
1366
|
+
measuredRow(spec('MQ_ISLANDS', 'count'), reading.islandsTouched, null, joinedAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(joinedAt) } }, false),
|
|
1367
|
+
{ threshold, connectivity: 4, samples: artCount },
|
|
1368
|
+
grid,
|
|
1369
|
+
1,
|
|
1370
|
+
),
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// --- the outline against a reference, and against the traced art -------------------------
|
|
1375
|
+
const boundarySpec = spec('MQ_BOUNDARY_DEVIATION', 'px');
|
|
1376
|
+
const edgeOfHull = (i: number): [number, number] => [outline.walk[i], outline.walk[(i + 1) % outline.walk.length]];
|
|
1377
|
+
if (input.referenceHull === null) {
|
|
1378
|
+
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));
|
|
1379
|
+
} else {
|
|
1380
|
+
const reference = input.referenceHull;
|
|
1381
|
+
const hd = steps === null ? hausdorff(hullPolygon, reference) : steps.outline('reference', hullPolygon, reference, (memo) => hausdorffCarried(memo, hullPolygon, reference, steps));
|
|
1382
|
+
const bound = targets.maxBoundaryDeviation === null ? null : { op: '<=' as const, value: targets.maxBoundaryDeviation };
|
|
1383
|
+
rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
|
|
1384
|
+
}
|
|
1385
|
+
const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
|
|
1386
|
+
const traced = rasters.traced();
|
|
1387
|
+
if ('outline' in traced) {
|
|
1388
|
+
const tracedOutline = traced.outline;
|
|
1389
|
+
const hd = steps === null ? hausdorff(hullPolygon, tracedOutline) : steps.outline('traced', hullPolygon, tracedOutline, (memo) => hausdorffCarried(memo, hullPolygon, tracedOutline, steps));
|
|
1390
|
+
rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
|
|
1391
|
+
} else {
|
|
1392
|
+
rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// --- triangles: sign, degeneracy, angle --------------------------------------------------
|
|
1396
|
+
const world: number[] = [];
|
|
1397
|
+
for (const [x, y] of points) world.push(x, cropToSpineY(y, frame.height));
|
|
1398
|
+
const areas = triangleAreas(world, source.triangles);
|
|
1399
|
+
const band = areaBand(areas, world);
|
|
1400
|
+
let flipped = 0;
|
|
1401
|
+
let flippedWorst = -1;
|
|
1402
|
+
let degenerate = 0;
|
|
1403
|
+
let degenerateWorst = -1;
|
|
1404
|
+
areas.forEach((a, t) => {
|
|
1405
|
+
if (Math.abs(a) <= band) {
|
|
1406
|
+
degenerate++;
|
|
1407
|
+
if (degenerateWorst === -1 || Math.abs(a) < Math.abs(areas[degenerateWorst])) degenerateWorst = t;
|
|
1408
|
+
} else if (a < 0) {
|
|
1409
|
+
flipped++;
|
|
1410
|
+
if (flippedWorst === -1 || a < areas[flippedWorst]) flippedWorst = t;
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
rows.push(measuredRow(spec('MQ_ORIENTATION', 'count'), flipped, { op: '<=', value: 0 }, flippedWorst === -1 ? NOTHING_WORSE : { at: { triangle: flippedWorst } }, true));
|
|
1414
|
+
rows.push(measuredRow(spec('MQ_DEGENERATE', 'count'), degenerate, { op: '<=', value: 0 }, degenerateWorst === -1 ? NOTHING_WORSE : { at: { triangle: degenerateWorst } }, true));
|
|
1415
|
+
let smallest = Infinity;
|
|
1416
|
+
let smallestAt = 0;
|
|
1417
|
+
for (let t = 0; t * 3 + 2 < source.triangles.length; t++) {
|
|
1418
|
+
const corner = [points[source.triangles[t * 3]], points[source.triangles[t * 3 + 1]], points[source.triangles[t * 3 + 2]]];
|
|
1419
|
+
for (let k = 0; k < 3; k++) {
|
|
1420
|
+
const o = corner[k];
|
|
1421
|
+
const p = corner[(k + 1) % 3];
|
|
1422
|
+
const q = corner[(k + 2) % 3];
|
|
1423
|
+
const ux = p[0] - o[0];
|
|
1424
|
+
const uy = p[1] - o[1];
|
|
1425
|
+
const vx = q[0] - o[0];
|
|
1426
|
+
const vy = q[1] - o[1];
|
|
1427
|
+
const angle = (Math.atan2(Math.abs(ux * vy - uy * vx), ux * vx + uy * vy) * 180) / Math.PI;
|
|
1428
|
+
if (angle < smallest) {
|
|
1429
|
+
smallest = angle;
|
|
1430
|
+
smallestAt = t;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
rows.push(
|
|
1435
|
+
measuredRow(spec('MQ_MIN_ANGLE', 'degrees'), r6(smallest), targets.minAngle === undefined ? null : { op: '>=', value: targets.minAngle }, { at: { triangle: smallestAt } }, true),
|
|
1436
|
+
);
|
|
1437
|
+
|
|
1438
|
+
// --- regions (§5) ------------------------------------------------------------------------
|
|
1439
|
+
rows.push(...regionRows(input, outline, hullPolygon, rasters, steps));
|
|
1440
|
+
|
|
1441
|
+
const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
|
|
1442
|
+
const geometry = sectionOf(ordered);
|
|
1443
|
+
const accepted = geometry.verdict === 'pass';
|
|
1444
|
+
return report(counts, { id: input.id, counts, geometry, motion: null, accepted }, null);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** What one source states about itself: hull and interior from the outline, bindings from the weights. */
|
|
1448
|
+
function countsOf(source: SourceMesh, outline: MeshOutline): MeshCounts {
|
|
1449
|
+
const bindings = source.weights === null ? 0 : source.weights.reduce((s, v) => s + v.length, 0);
|
|
1450
|
+
const maxInfluences = source.weights === null ? 0 : source.weights.reduce((m, v) => Math.max(m, v.length), 0);
|
|
1451
|
+
return {
|
|
1452
|
+
boundaryVertices: outline.hull,
|
|
1453
|
+
interiorVertices: source.points.length - outline.hull,
|
|
1454
|
+
triangles: source.triangles.length / 3,
|
|
1455
|
+
bindings,
|
|
1456
|
+
maxInfluences,
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* The section's figures and verdict (§2, P6): `measured` is pass + fail; the
|
|
1462
|
+
* verdict is `fail` when a required row failed, `pass` only when every required
|
|
1463
|
+
* row passed and at least one row did, and `not-measured` otherwise — which is
|
|
1464
|
+
* what a required row that could not be measured, or was refused, leaves.
|
|
1465
|
+
*/
|
|
1466
|
+
function sectionOf(built: readonly Built[]): EvidenceSection {
|
|
1467
|
+
const summary = { pass: 0, fail: 0, undeclared: 0, refused: 0, notMeasurable: 0, measured: 0 };
|
|
1468
|
+
for (const { row } of built) {
|
|
1469
|
+
if (row.state === 'pass') summary.pass++;
|
|
1470
|
+
else if (row.state === 'fail') summary.fail++;
|
|
1471
|
+
else if (row.state === 'undeclared') summary.undeclared++;
|
|
1472
|
+
else if (row.state === 'refused') summary.refused++;
|
|
1473
|
+
else summary.notMeasurable++;
|
|
1474
|
+
}
|
|
1475
|
+
summary.measured = summary.pass + summary.fail;
|
|
1476
|
+
const required = built.filter((b) => b.required);
|
|
1477
|
+
let verdict: EvidenceSection['verdict'];
|
|
1478
|
+
if (required.some((b) => b.row.state === 'fail')) verdict = 'fail';
|
|
1479
|
+
else if (required.every((b) => b.row.state === 'pass') && summary.pass > 0) verdict = 'pass';
|
|
1480
|
+
else verdict = 'not-measured';
|
|
1481
|
+
return { rows: built.map((b) => b.row), summary, verdict };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ---------------------------------------------------------------------------
|
|
1485
|
+
// §5 — regions
|
|
1486
|
+
// ---------------------------------------------------------------------------
|
|
1487
|
+
|
|
1488
|
+
/** A region's own refusal (§2's `refused`: this measurement's input is invalid), or null when it is measurable. */
|
|
1489
|
+
function regionRefusal(region: RefinementRegion, pageScale: number, who: string, steps: StepRasters | null): { code: string; message: string } | null {
|
|
1490
|
+
const numbers: Array<[string, unknown]> = [
|
|
1491
|
+
['maxEdgeLength', region.maxEdgeLength],
|
|
1492
|
+
['transition', region.transition],
|
|
1493
|
+
['grade', region.grade],
|
|
1494
|
+
...region.polygon.flatMap((p, i): Array<[string, unknown]> => (Array.isArray(p) ? [[`polygon[${i}][0]`, p[0]], [`polygon[${i}][1]`, p[1]]] : [[`polygon[${i}]`, p]])),
|
|
1495
|
+
];
|
|
1496
|
+
for (const [field, value] of numbers) {
|
|
1497
|
+
if (!isFiniteNumber(value)) return { code: 'REGION_NOT_FINITE', message: `${who}, region "${region.name}": ${field} is ${String(value)}; required a finite number` };
|
|
1498
|
+
}
|
|
1499
|
+
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)` };
|
|
1500
|
+
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` };
|
|
1501
|
+
if (region.maxEdgeLength < 1 / pageScale) {
|
|
1502
|
+
return {
|
|
1503
|
+
code: 'REGION_BOUND_BELOW_GRID',
|
|
1504
|
+
message: `${who}, region "${region.name}": maxEdgeLength is ${region.maxEdgeLength} px; required at least one texel, ${r6(1 / pageScale)} px at pageScale ${pageScale}`,
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
// A function of the polygon alone: a reduction step has it handed back by its `StepRasters` (issue #1253).
|
|
1508
|
+
const crossing = steps === null ? findSelfIntersection(region.polygon) : steps.polygonCrossing(region.polygon, () => findSelfIntersection(region.polygon));
|
|
1509
|
+
if (crossing !== null) {
|
|
1510
|
+
return {
|
|
1511
|
+
code: 'REGION_SELF_INTERSECTS',
|
|
1512
|
+
message: `${who}, region "${region.name}": polygon edges ${crossing[0]} and ${crossing[1]} meet; required a strictly simple polygon (findSelfIntersection)`,
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
interface Edge {
|
|
1519
|
+
a: number;
|
|
1520
|
+
b: number;
|
|
1521
|
+
length: number;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* The rows of every region: `MQ_MAX_EDGE` (§5's `L(R)`), `MQ_TRANSITION` (its
|
|
1526
|
+
* graded band, only where the band has width) and `MQ_FILL_DISTANCE` (`h(R)`,
|
|
1527
|
+
* sampled, informational).
|
|
1528
|
+
*
|
|
1529
|
+
* Which edges a region holds is `edgeIsHeldByRegion`'s answer and nothing
|
|
1530
|
+
* else's: an edge that only touches the band's outer boundary at one point,
|
|
1531
|
+
* from outside, is not held (spine-parts#126). One bound per edge, the smallest
|
|
1532
|
+
* applicable anywhere on it (P16): `L0` of every region whose closed polygon it
|
|
1533
|
+
* meets, and `L0 + grade·d` of every other region that holds it, `d` its
|
|
1534
|
+
* distance from that region. A row's value is the edge whose
|
|
1535
|
+
* length most exceeds its own bound — so the row fails exactly when some edge
|
|
1536
|
+
* it covers is over — and the row names that edge and that bound.
|
|
1537
|
+
*
|
|
1538
|
+
* `MQ_FILL_DISTANCE` reads the art pixels whose centre lies in the region's
|
|
1539
|
+
* closed polygon, a function of the art and the polygon alone: a reduction
|
|
1540
|
+
* step has it handed back by its `StepRasters` (`regionPixels`, issue #1253)
|
|
1541
|
+
* rather than testing every art pixel against the polygon again.
|
|
1542
|
+
*/
|
|
1543
|
+
function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters, steps: StepRasters | null): Built[] {
|
|
1544
|
+
const { attachment, source, art } = input;
|
|
1545
|
+
const who = `attachment ${nameOf(attachment)}`;
|
|
1546
|
+
const scale = art.frame.pageScale;
|
|
1547
|
+
const points: Pt[] = source.points;
|
|
1548
|
+
const out: Built[] = [];
|
|
1549
|
+
const spec = (code: string, unit: MeasureRow['unit'], region: string): RowSpec => ({ code, unit, region, attachment });
|
|
1550
|
+
const encoded = meshEdges(points.length, source.triangles, outline.hull);
|
|
1551
|
+
const edges: Edge[] = [];
|
|
1552
|
+
for (let i = 0; i < encoded.length; i += 2) {
|
|
1553
|
+
const a = encoded[i] / 2;
|
|
1554
|
+
const b = encoded[i + 1] / 2;
|
|
1555
|
+
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]) });
|
|
1556
|
+
}
|
|
1557
|
+
edges.sort((p, q) => p.a - q.a || p.b - q.b);
|
|
1558
|
+
|
|
1559
|
+
// Each edge's answers for a region: computed here, or — on a reduction step — carried by its `StepRasters` from
|
|
1560
|
+
// the last reading of the region for an edge with the same two ends (`regionEdges`, issue #1253).
|
|
1561
|
+
const readings = new Map<RefinementRegion, RegionEdgeReading | null>();
|
|
1562
|
+
const readingOf = (region: RefinementRegion): RegionEdgeReading | null => {
|
|
1563
|
+
if (!readings.has(region)) readings.set(region, steps === null ? null : steps.regionEdges(region.polygon, region.transition));
|
|
1564
|
+
return readings.get(region)!;
|
|
1565
|
+
};
|
|
1566
|
+
const meetsOf = (region: RefinementRegion, e: Edge): boolean => {
|
|
1567
|
+
const record = readingOf(region)?.entry(points[e.a], points[e.b]);
|
|
1568
|
+
if (record === undefined) return segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
|
|
1569
|
+
if (record.meets === undefined) record.meets = segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
|
|
1570
|
+
return record.meets;
|
|
1571
|
+
};
|
|
1572
|
+
const heldOf = (region: RefinementRegion, e: Edge): number | null => {
|
|
1573
|
+
const held = (): number | null => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null);
|
|
1574
|
+
const record = readingOf(region)?.entry(points[e.a], points[e.b]);
|
|
1575
|
+
if (record === undefined) return held();
|
|
1576
|
+
if (record.held === undefined) record.held = held();
|
|
1577
|
+
return record.held;
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
// Which regions are measurable, and the one refusal of each that is not.
|
|
1581
|
+
const live: RefinementRegion[] = [];
|
|
1582
|
+
const refusals = new Map<string, string>();
|
|
1583
|
+
for (const region of input.targets.regions) {
|
|
1584
|
+
let refusal = regionRefusal(region, scale, who, steps);
|
|
1585
|
+
if (refusal === null) {
|
|
1586
|
+
const poly: Pt[] = region.polygon;
|
|
1587
|
+
const meets = edges.some((e) => meetsOf(region, e)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
|
|
1588
|
+
if (!meets) {
|
|
1589
|
+
refusal = {
|
|
1590
|
+
code: 'REGION_OUTSIDE_ART',
|
|
1591
|
+
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`,
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (refusal === null) live.push(region);
|
|
1596
|
+
else refusals.set(region.name, `${refusal.code}: ${refusal.message}`);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Per measurable region, per edge (in `edges` order): the edge's distance
|
|
1601
|
+
* from the closed polygon when the region holds it (`edgeIsHeldByRegion`,
|
|
1602
|
+
* the one definition the refinement reads too), null when it does not.
|
|
1603
|
+
*/
|
|
1604
|
+
const holds = new Map<RefinementRegion, Array<number | null>>();
|
|
1605
|
+
for (const region of live) {
|
|
1606
|
+
holds.set(
|
|
1607
|
+
region,
|
|
1608
|
+
edges.map((e) => heldOf(region, e)),
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
const edgeIndex = new Map(edges.map((e, i) => [e, i]));
|
|
1612
|
+
|
|
1613
|
+
/** Every bound that applies to an edge, from every measurable region that holds it, and the smallest. */
|
|
1614
|
+
const boundOf = (e: Edge): number | null => {
|
|
1615
|
+
let bound: number | null = null;
|
|
1616
|
+
const i = edgeIndex.get(e)!;
|
|
1617
|
+
for (const region of live) {
|
|
1618
|
+
const d = holds.get(region)![i];
|
|
1619
|
+
if (d === null) continue;
|
|
1620
|
+
const here = d === 0 ? region.maxEdgeLength : r6(region.maxEdgeLength + region.grade * d);
|
|
1621
|
+
if (bound === null || here < bound) bound = here;
|
|
1622
|
+
}
|
|
1623
|
+
return bound;
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
/** The worst edge of a set: the largest length over its own bound, ties to the smaller (a, b). */
|
|
1627
|
+
const worstOf = (set: readonly Edge[]): { edge: Edge; bound: number } | null => {
|
|
1628
|
+
let best: { edge: Edge; bound: number; excess: number } | null = null;
|
|
1629
|
+
for (const e of set) {
|
|
1630
|
+
const bound = boundOf(e);
|
|
1631
|
+
if (bound === null) continue;
|
|
1632
|
+
const excess = r6(e.length) - bound;
|
|
1633
|
+
if (best === null || excess > best.excess) best = { edge: e, bound, excess };
|
|
1634
|
+
}
|
|
1635
|
+
return best === null ? null : { edge: best.edge, bound: best.bound };
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
|
|
1639
|
+
const { mask, threshold } = art;
|
|
1640
|
+
const artBits = rasters.artBits();
|
|
1641
|
+
|
|
1642
|
+
for (const region of input.targets.regions) {
|
|
1643
|
+
const refused = refusals.get(region.name);
|
|
1644
|
+
if (refused !== undefined) {
|
|
1645
|
+
out.push(unmeasuredRow(spec('MQ_MAX_EDGE', 'px', region.name), 'refused', refused, true));
|
|
1646
|
+
out.push(unmeasuredRow(spec('MQ_TRANSITION', 'px', region.name), 'refused', refused, true));
|
|
1647
|
+
out.push(unmeasuredRow(spec('MQ_FILL_DISTANCE', 'px', region.name), 'refused', refused, false));
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
const poly: Pt[] = region.polygon;
|
|
1651
|
+
const held = holds.get(region)!;
|
|
1652
|
+
const inside = edges.filter((_, i) => held[i] === 0);
|
|
1653
|
+
const maxSpec = spec('MQ_MAX_EDGE', 'px', region.name);
|
|
1654
|
+
const inWorst = worstOf(inside);
|
|
1655
|
+
if (inWorst === null) {
|
|
1656
|
+
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));
|
|
1657
|
+
} else {
|
|
1658
|
+
out.push(measuredRow(maxSpec, r6(inWorst.edge.length), { op: '<=', value: inWorst.bound }, { at: { edge: [inWorst.edge.a, inWorst.edge.b] } }, true));
|
|
1659
|
+
}
|
|
1660
|
+
if (region.transition > 0) {
|
|
1661
|
+
const banded = edges.filter((_, i) => held[i] !== null && held[i]! > 0);
|
|
1662
|
+
const bandWorst = worstOf(banded);
|
|
1663
|
+
const transitionSpec = spec('MQ_TRANSITION', 'px', region.name);
|
|
1664
|
+
if (bandWorst === null) {
|
|
1665
|
+
out.push(unmeasuredRow(transitionSpec, 'not-measurable', `${who}, region "${region.name}": no triangle edge lies in the ${region.transition} px band outside the polygon`, true));
|
|
1666
|
+
} else {
|
|
1667
|
+
out.push(measuredRow(transitionSpec, r6(bandWorst.edge.length), { op: '<=', value: bandWorst.bound }, { at: { edge: [bandWorst.edge.a, bandWorst.edge.b] } }, true));
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex. The art
|
|
1672
|
+
// pixels inside the region come in ascending order, so the scan below visits what a scan of every art pixel
|
|
1673
|
+
// would keep, in the same order.
|
|
1674
|
+
const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
|
|
1675
|
+
const inRegion = (): Int32Array => {
|
|
1676
|
+
const kept: number[] = [];
|
|
1677
|
+
for (let i = 0; i < artBits.length; i++) if (artBits[i] && inClosedPolygon(centreOf(i), poly)) kept.push(i);
|
|
1678
|
+
return Int32Array.from(kept);
|
|
1679
|
+
};
|
|
1680
|
+
const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
|
|
1681
|
+
let count = 0;
|
|
1682
|
+
let worst = -1;
|
|
1683
|
+
let worstAt = -1;
|
|
1684
|
+
const visit = (i: number, nearest: number): void => {
|
|
1685
|
+
count++;
|
|
1686
|
+
if (nearest > worst) {
|
|
1687
|
+
worst = nearest;
|
|
1688
|
+
worstAt = i;
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
const distance = (i: number, p: Pt): number => {
|
|
1692
|
+
const centre = centreOf(i);
|
|
1693
|
+
return Math.hypot(p[0] - centre[0], p[1] - centre[1]);
|
|
1694
|
+
};
|
|
1695
|
+
if (steps === null) {
|
|
1696
|
+
for (const i of regionPixels) {
|
|
1697
|
+
const centre = centreOf(i);
|
|
1698
|
+
if (!inClosedPolygon(centre, hullPolygon)) continue;
|
|
1699
|
+
let nearest = Infinity;
|
|
1700
|
+
for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
|
|
1701
|
+
visit(i, nearest);
|
|
1702
|
+
}
|
|
1703
|
+
} else {
|
|
1704
|
+
// A reduction step: the pixels in the hull and their nearest distances carried from the last reading (issue #1253).
|
|
1705
|
+
const carried = steps.regionFill(poly, { pixels: regionPixels, centre: centreOf, hull: hullPolygon, points, inHull: (i) => inClosedPolygon(centreOf(i), hullPolygon), distance });
|
|
1706
|
+
for (let j = 0; j < carried.inside.length; j++) visit(carried.inside[j], carried.nearest[j]);
|
|
1707
|
+
}
|
|
1708
|
+
const fillSpec = spec('MQ_FILL_DISTANCE', 'px', region.name);
|
|
1709
|
+
const floor = floorOf(region.name);
|
|
1710
|
+
const sampled = { threshold, connectivity: null, samples: count };
|
|
1711
|
+
const sampling = { domain: 'art pixel centres inside the region and the hull', count };
|
|
1712
|
+
let fill: Built;
|
|
1713
|
+
if (count < floor) {
|
|
1714
|
+
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);
|
|
1715
|
+
} else {
|
|
1716
|
+
fill = measuredRow(fillSpec, r6(worst), null, { at: { pixel: [worstAt % mask.width, Math.floor(worstAt / mask.width)] } }, false);
|
|
1717
|
+
}
|
|
1718
|
+
fill.row.art = sampled;
|
|
1719
|
+
fill.row.sampling = sampling;
|
|
1720
|
+
out.push(fill);
|
|
1721
|
+
}
|
|
1722
|
+
return out;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ---------------------------------------------------------------------------
|
|
1726
|
+
// the echo, and the document's text
|
|
1727
|
+
// ---------------------------------------------------------------------------
|
|
1728
|
+
|
|
1729
|
+
function effectiveOf(input: MeshMeasureInput): EffectiveSettings {
|
|
1730
|
+
const { art } = input;
|
|
1731
|
+
return {
|
|
1732
|
+
preset: input.preset,
|
|
1733
|
+
attachments: [
|
|
1734
|
+
{
|
|
1735
|
+
attachment: input.attachment,
|
|
1736
|
+
threshold: art.threshold,
|
|
1737
|
+
// A measurement states the threshold it was taken at and claims nothing at any other (Thresholds).
|
|
1738
|
+
finalThreshold: art.threshold,
|
|
1739
|
+
frame: art.frame,
|
|
1740
|
+
maskSize: [art.mask.width, art.mask.height],
|
|
1741
|
+
minArtSamples: input.minArtSamples,
|
|
1742
|
+
regions: input.targets.regions.map((r) => ({ name: r.name, minArtSamples: input.regionArtSamples.find((f) => f.region === r.name)!.minArtSamples })),
|
|
1743
|
+
},
|
|
1744
|
+
],
|
|
1745
|
+
sourceBounds: null,
|
|
1746
|
+
referenceArtFit: null,
|
|
1747
|
+
candidateArtFit: null,
|
|
1748
|
+
targets: input.targets,
|
|
1749
|
+
referenceHull: input.referenceHull,
|
|
1750
|
+
motionBounds: null,
|
|
1751
|
+
protect: input.protect,
|
|
1752
|
+
influences: input.influences,
|
|
1753
|
+
boneOrder: input.boneOrder,
|
|
1754
|
+
schedule: null,
|
|
1755
|
+
budget: null,
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Every object of the document is rebuilt below in the order its type states
|
|
1760
|
+
// its keys, so the bytes depend on the values alone — never on the key order
|
|
1761
|
+
// an input object happened to be built in (A18's standard; `build-report/1`'s
|
|
1762
|
+
// precedent in `src/assertions/report.ts`). An optional key is written only
|
|
1763
|
+
// when it is present.
|
|
1764
|
+
|
|
1765
|
+
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
1766
|
+
|
|
1767
|
+
const pair = (p: readonly [number, number]): Json => [p[0], p[1]];
|
|
1768
|
+
const attachmentJson = (a: AttachmentRef): Json => ({ skin: a.skin, slot: a.slot, attachment: a.attachment });
|
|
1769
|
+
const fitJson = (f: ArtFitBounds | null): Json => (f === null ? null : { minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
|
|
1770
|
+
const frameJson = (f: SourceFrame): Json => ({ space: f.space, width: f.width, height: f.height, pageScale: f.pageScale, conversion: f.conversion });
|
|
1771
|
+
|
|
1772
|
+
function regionJson(r: RefinementRegion): Json {
|
|
1773
|
+
return {
|
|
1774
|
+
name: r.name,
|
|
1775
|
+
polygon: r.polygon.map(pair),
|
|
1776
|
+
maxEdgeLength: r.maxEdgeLength,
|
|
1777
|
+
transition: r.transition,
|
|
1778
|
+
grade: r.grade,
|
|
1779
|
+
approximation: r.approximation === null ? null : { from: r.approximation.from, policy: r.approximation.policy, maxError: r.approximation.maxError },
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function targetsJson(t: ReductionTargets | MeasureTargets | null): Json {
|
|
1784
|
+
if (t === null) return null;
|
|
1785
|
+
const out: { [key: string]: Json } = { artFit: fitJson(t.artFit), maxBoundaryDeviation: t.maxBoundaryDeviation };
|
|
1786
|
+
if (t.minAngle !== undefined) out.minAngle = t.minAngle;
|
|
1787
|
+
out.regions = t.regions.map(regionJson);
|
|
1788
|
+
return out;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function protectJson(p: ProtectedFeatures | null): Json {
|
|
1792
|
+
if (p === null) return null;
|
|
1793
|
+
return {
|
|
1794
|
+
hull: p.hull,
|
|
1795
|
+
vertices: [...p.vertices],
|
|
1796
|
+
edges: p.edges.map(pair),
|
|
1797
|
+
regionBoundaries: [...p.regionBoundaries],
|
|
1798
|
+
weightJump: p.weightJump,
|
|
1799
|
+
influences: [...p.influences],
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function motionBoundsJson(b: MotionBounds | null): Json {
|
|
1804
|
+
if (b === null) return null;
|
|
1805
|
+
const out: { [key: string]: Json } = { maxLocalDeformation: b.maxLocalDeformation };
|
|
1806
|
+
if (b.maxStretch !== undefined) out.maxStretch = b.maxStretch;
|
|
1807
|
+
if (b.minStretch !== undefined) out.minStretch = b.minStretch;
|
|
1808
|
+
return out;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function scheduleJson(s: MotionSchedule | null): Json {
|
|
1812
|
+
if (s === null) return null;
|
|
1813
|
+
return {
|
|
1814
|
+
frames: s.frames.map((f): Json => (f === 'setup' ? 'setup' : 'times' in f ? { animation: f.animation, times: [...f.times] } : { animation: f.animation, fps: f.fps })),
|
|
1815
|
+
phases: [...s.phases],
|
|
1816
|
+
physics: s.physics.mode === 'none' ? { mode: 'none' } : { mode: 'step', dt: s.physics.dt, warmupSteps: s.physics.warmupSteps },
|
|
1817
|
+
selection: [...s.selection],
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function effectiveJson(e: EffectiveSettings): Json {
|
|
1822
|
+
return {
|
|
1823
|
+
preset: e.preset === null ? null : { name: e.preset.name, version: e.preset.version },
|
|
1824
|
+
attachments: e.attachments.map((a) => ({
|
|
1825
|
+
attachment: attachmentJson(a.attachment),
|
|
1826
|
+
threshold: a.threshold,
|
|
1827
|
+
finalThreshold: a.finalThreshold,
|
|
1828
|
+
frame: frameJson(a.frame),
|
|
1829
|
+
maskSize: pair(a.maskSize),
|
|
1830
|
+
minArtSamples: a.minArtSamples,
|
|
1831
|
+
regions: a.regions.map((r): Json => (r.polygon === undefined ? { name: r.name, minArtSamples: r.minArtSamples } : { name: r.name, minArtSamples: r.minArtSamples, polygon: r.polygon.map(pair) })),
|
|
1832
|
+
})),
|
|
1833
|
+
sourceBounds: fitJson(e.sourceBounds),
|
|
1834
|
+
referenceArtFit: fitJson(e.referenceArtFit),
|
|
1835
|
+
candidateArtFit: fitJson(e.candidateArtFit),
|
|
1836
|
+
targets: targetsJson(e.targets),
|
|
1837
|
+
referenceHull: e.referenceHull === null ? null : e.referenceHull.map(pair),
|
|
1838
|
+
motionBounds: motionBoundsJson(e.motionBounds),
|
|
1839
|
+
protect: protectJson(e.protect),
|
|
1840
|
+
influences: e.influences === null ? null : { maxInfluences: e.influences.maxInfluences, minWeight: e.influences.minWeight },
|
|
1841
|
+
boneOrder: e.boneOrder === null ? null : [...e.boneOrder],
|
|
1842
|
+
schedule: scheduleJson(e.schedule),
|
|
1843
|
+
budget: e.budget === null ? null : { maxCandidates: e.budget.maxCandidates },
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function frameRefJson(f: FrameRef): Json {
|
|
1848
|
+
return { id: f.id, animation: f.animation, phase: f.phase, time: f.time, index: f.index, role: f.role };
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function worstJson(w: WorstSample | null): Json {
|
|
1852
|
+
if (w === null) return null;
|
|
1853
|
+
const at: { [key: string]: Json } = {};
|
|
1854
|
+
if (w.at.pixel !== undefined) at.pixel = pair(w.at.pixel);
|
|
1855
|
+
if (w.at.triangle !== undefined) at.triangle = w.at.triangle;
|
|
1856
|
+
if (w.at.edge !== undefined) at.edge = pair(w.at.edge);
|
|
1857
|
+
if (w.at.vertex !== undefined) at.vertex = w.at.vertex;
|
|
1858
|
+
if (w.at.uv !== undefined) at.uv = pair(w.at.uv);
|
|
1859
|
+
const out: { [key: string]: Json } = { at };
|
|
1860
|
+
if (w.frame !== undefined) out.frame = frameRefJson(w.frame);
|
|
1861
|
+
return out;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function rowJson(r: MeasureRow): Json {
|
|
1865
|
+
const out: { [key: string]: Json } = {
|
|
1866
|
+
code: r.code,
|
|
1867
|
+
object: { attachment: attachmentJson(r.object.attachment), region: r.object.region },
|
|
1868
|
+
state: r.state,
|
|
1869
|
+
value: r.value,
|
|
1870
|
+
bound: r.bound === null ? null : { op: r.bound.op, value: r.bound.value },
|
|
1871
|
+
unit: r.unit,
|
|
1872
|
+
worst: worstJson(r.worst),
|
|
1873
|
+
reason: r.reason,
|
|
1874
|
+
};
|
|
1875
|
+
if (r.art !== undefined) out.art = { threshold: r.art.threshold, connectivity: r.art.connectivity, samples: r.art.samples };
|
|
1876
|
+
if (r.raster !== undefined) {
|
|
1877
|
+
const g = r.raster.grid;
|
|
1878
|
+
out.raster = {
|
|
1879
|
+
grid: { width: g.width, height: g.height, pageScale: g.pageScale },
|
|
1880
|
+
spatialQuantum: r.raster.spatialQuantum,
|
|
1881
|
+
valueIncrement: r.raster.valueIncrement,
|
|
1882
|
+
nearBound: r.raster.nearBound,
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
|
|
1886
|
+
if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
|
|
1887
|
+
return out;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function sectionJson(s: EvidenceSection): { [key: string]: Json } {
|
|
1891
|
+
const m = s.summary;
|
|
1892
|
+
return {
|
|
1893
|
+
rows: s.rows.map(rowJson),
|
|
1894
|
+
summary: { pass: m.pass, fail: m.fail, undeclared: m.undeclared, refused: m.refused, notMeasurable: m.notMeasurable, measured: m.measured },
|
|
1895
|
+
verdict: s.verdict,
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const countsJson = (c: MeshCounts | null): Json =>
|
|
1900
|
+
c === null ? null : { boundaryVertices: c.boundaryVertices, interiorVertices: c.interiorVertices, triangles: c.triangles, bindings: c.bindings, maxInfluences: c.maxInfluences };
|
|
1901
|
+
|
|
1902
|
+
function candidateJson(c: CandidateReport): Json {
|
|
1903
|
+
const out: { [key: string]: Json } = {
|
|
1904
|
+
id: c.id,
|
|
1905
|
+
counts: countsJson(c.counts),
|
|
1906
|
+
geometry: c.geometry === null ? null : sectionJson(c.geometry),
|
|
1907
|
+
motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleUsedJson(c.motion.schedule) },
|
|
1908
|
+
accepted: c.accepted,
|
|
1909
|
+
};
|
|
1910
|
+
if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
|
|
1911
|
+
if (c.changes !== undefined) {
|
|
1912
|
+
const k = c.changes;
|
|
1913
|
+
out.changes = {
|
|
1914
|
+
removedVertices: k.removedVertices,
|
|
1915
|
+
insertedVertices: k.insertedVertices,
|
|
1916
|
+
sharesDroppedOnGrid: k.sharesDroppedOnGrid,
|
|
1917
|
+
sharesPruned: k.sharesPruned,
|
|
1918
|
+
deformRemapped: k.deformRemapped.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key, droppedVertices: [...d.droppedVertices] })),
|
|
1919
|
+
deformReevaluated: k.deformReevaluated.map((d) => ({ animation: d.animation, attachment: attachmentJson(d.attachment), key: d.key })),
|
|
1920
|
+
linkedMeshes: k.linkedMeshes.map(attachmentJson),
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
return out;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function terminationJson(t: Termination | null): Json {
|
|
1927
|
+
if (t === null) return null;
|
|
1928
|
+
switch (t.reason) {
|
|
1929
|
+
case 'no-further-valid-reduction':
|
|
1930
|
+
return { reason: t.reason, candidatesTried: t.candidatesTried, blockingConstraint: t.blockingConstraint };
|
|
1931
|
+
case 'budget-exhausted':
|
|
1932
|
+
return { reason: t.reason, candidatesTried: t.candidatesTried, budget: t.budget, result: t.result };
|
|
1933
|
+
case 'invalid-input':
|
|
1934
|
+
case 'unsupported-topology':
|
|
1935
|
+
return { reason: t.reason, code: t.code, detail: t.detail };
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* The document's text: two-space JSON and a final newline, every key in the
|
|
1941
|
+
* order its type states it, so two reports of one input are byte-identical —
|
|
1942
|
+
* nothing in it is a time, a path or a machine's.
|
|
1943
|
+
*/
|
|
1944
|
+
export function writeMeshQualityReport(report: MeshQualityReport): string {
|
|
1945
|
+
const doc: Json = {
|
|
1946
|
+
spec: report.spec,
|
|
1947
|
+
operation: report.operation,
|
|
1948
|
+
effective: effectiveJson(report.effective),
|
|
1949
|
+
poser: report.poser === null ? null : { kind: report.poser.kind, rigcVersion: report.poser.rigcVersion },
|
|
1950
|
+
motionRequired: report.motionRequired,
|
|
1951
|
+
sourceCounts: countsJson(report.sourceCounts),
|
|
1952
|
+
reference: report.reference === null ? null : candidateJson(report.reference),
|
|
1953
|
+
candidates: report.candidates.map(candidateJson),
|
|
1954
|
+
termination: terminationJson(report.termination),
|
|
1955
|
+
};
|
|
1956
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// ---------------------------------------------------------------------------
|
|
1960
|
+
// stage C — the motion section's own fields (issue #1230, `src/meshcompare.ts`)
|
|
1961
|
+
// ---------------------------------------------------------------------------
|
|
1962
|
+
//
|
|
1963
|
+
// Additive: nothing above reads these, a `measure` and a `reduce` never write
|
|
1964
|
+
// them, and every one is written only when present.
|
|
1965
|
+
|
|
1966
|
+
/** One group of frames a motion row was also taken over — a phase, or a role (P11). */
|
|
1967
|
+
export interface MotionReading {
|
|
1968
|
+
/** The group's worst value; null when no frame of the group drew the attachment. */
|
|
1969
|
+
value: number | null;
|
|
1970
|
+
/** The value against the row's bound, by the row's own rule; `undeclared` with no bound, `not-measurable` with no value. */
|
|
1971
|
+
state: MeasureState;
|
|
1972
|
+
/** The frame the group's worst value was taken at, by `FrameRef.id`. */
|
|
1973
|
+
frame: string | null;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* How a motion row's value was taken over the schedule (stage C). The row's own
|
|
1978
|
+
* `value` is the worst over every frame measured; these keep apart what the
|
|
1979
|
+
* contract says must not be folded together.
|
|
1980
|
+
*/
|
|
1981
|
+
export interface MotionRowDetail {
|
|
1982
|
+
/** Frames at which the attachment was drawn and measured, and the ids of those at which its slot drew something else or nothing. */
|
|
1983
|
+
frames: { measured: number; notDrawn: string[] };
|
|
1984
|
+
/**
|
|
1985
|
+
* §4 *Transition in time*: the row taken per sample phase — `grid` and `irr`
|
|
1986
|
+
* for the frames a rate generated, `null` for the setup frame and for frames
|
|
1987
|
+
* the caller gave as explicit times (no phase applies to those).
|
|
1988
|
+
*/
|
|
1989
|
+
byPhase: Array<{ phase: 'grid' | 'irr' | null } & MotionReading>;
|
|
1990
|
+
/** When one phase passes and another fails: the worst frame of each, by id, and a sentence naming both. Null otherwise. */
|
|
1991
|
+
phasesDisagree: { pass: string; fail: string; sentence: string } | null;
|
|
1992
|
+
/** P11: the row per role. `heldOut` is null when no held-out frame was measured — no held-out claim is made. */
|
|
1993
|
+
byRole: { baseline: MotionReading | null; selection: MotionReading | null; heldOut: MotionReading | null };
|
|
1994
|
+
/**
|
|
1995
|
+
* Correction 4, world rows: the declared setup map of the attachment's slot
|
|
1996
|
+
* bone — its world 2×2 at the setup pose, `[a, b, c, d]` as the core poses it
|
|
1997
|
+
* from the bones' declared fields — and its two singular scales. Never a
|
|
1998
|
+
* single world-per-pixel ratio, and never fitted to output vertices.
|
|
1999
|
+
*/
|
|
2000
|
+
setupMap?: { bone: string; linear: [number, number, number, number]; singularScales: [number, number] };
|
|
2001
|
+
/** §3: samples no non-degenerate UV triangle carries, listed by UV — in the reference's mesh and in this candidate's. */
|
|
2002
|
+
uncarried?: { reference: Array<[number, number]>; candidate: Array<[number, number]> };
|
|
2003
|
+
/** §3, `MQ_INVERSION` on a slot `invariants.deformMayFold` names: every reversed triangle with its frame — listed, never zeroed. */
|
|
2004
|
+
folds?: Array<{ triangle: number; frame: string }>;
|
|
2005
|
+
/** Triangles that have no area at the setup pose (the A39 band), so no sign and no stretch is read off them. */
|
|
2006
|
+
degenerateAtSetup?: number;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** What a motion section walked from the schedule it was given (`ScheduleUsed`). */
|
|
2010
|
+
export interface ScheduleWalked {
|
|
2011
|
+
/** Every frame the schedule names, in walk order, each with its role (P11). */
|
|
2012
|
+
walked: FrameRef[];
|
|
2013
|
+
/** The roles among those frames — what the section has evidence for. */
|
|
2014
|
+
roles: Array<FrameRef['role']>;
|
|
2015
|
+
/** P11: true only when at least one held-out frame, disjoint from `selection`, was walked. */
|
|
2016
|
+
heldOutClaim: boolean;
|
|
2017
|
+
/** P10: every walk resets physics at time 0 and steps from there, the same steps for the reference and every candidate. */
|
|
2018
|
+
reset: 'physics reset at time 0';
|
|
2019
|
+
/** Each walk: the animation, the phase (null for explicit times), and how many steps reached its last frame. */
|
|
2020
|
+
walks: Array<{ animation: string; phase: 'grid' | 'irr' | null; steps: number }>;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function readingJson(r: MotionReading | null): Json {
|
|
2024
|
+
return r === null ? null : { value: r.value, state: r.state, frame: r.frame };
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function motionDetailJson(m: MotionRowDetail): Json {
|
|
2028
|
+
const out: { [key: string]: Json } = {
|
|
2029
|
+
frames: { measured: m.frames.measured, notDrawn: [...m.frames.notDrawn] },
|
|
2030
|
+
byPhase: m.byPhase.map((p) => ({ phase: p.phase, value: p.value, state: p.state, frame: p.frame })),
|
|
2031
|
+
phasesDisagree: m.phasesDisagree === null ? null : { pass: m.phasesDisagree.pass, fail: m.phasesDisagree.fail, sentence: m.phasesDisagree.sentence },
|
|
2032
|
+
byRole: { baseline: readingJson(m.byRole.baseline), selection: readingJson(m.byRole.selection), heldOut: readingJson(m.byRole.heldOut) },
|
|
2033
|
+
};
|
|
2034
|
+
if (m.setupMap !== undefined) out.setupMap = { bone: m.setupMap.bone, linear: [...m.setupMap.linear], singularScales: pair(m.setupMap.singularScales) };
|
|
2035
|
+
if (m.uncarried !== undefined) out.uncarried = { reference: m.uncarried.reference.map(pair), candidate: m.uncarried.candidate.map(pair) };
|
|
2036
|
+
if (m.folds !== undefined) out.folds = m.folds.map((f) => ({ triangle: f.triangle, frame: f.frame }));
|
|
2037
|
+
if (m.degenerateAtSetup !== undefined) out.degenerateAtSetup = m.degenerateAtSetup;
|
|
2038
|
+
return out;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function scheduleUsedJson(s: ScheduleUsed): Json {
|
|
2042
|
+
const given = scheduleJson(s) as { [key: string]: Json };
|
|
2043
|
+
return {
|
|
2044
|
+
...given,
|
|
2045
|
+
walked: s.walked.map(frameRefJson),
|
|
2046
|
+
roles: [...s.roles],
|
|
2047
|
+
heldOutClaim: s.heldOutClaim,
|
|
2048
|
+
reset: s.reset,
|
|
2049
|
+
walks: s.walks.map((w) => ({ animation: w.animation, phase: w.phase, steps: w.steps })),
|
|
2050
|
+
};
|
|
2051
|
+
}
|