rig-c 0.0.0-stage → 2.20.4
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 +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -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,1425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh reduction and local refinement — stage B2 of the contract in
|
|
3
|
+
* docs/MESH_REDUCTION.md (issue #1221, stage B: issue #1224).
|
|
4
|
+
*
|
|
5
|
+
* One operation is exported, `reduceMesh` — `reduceMeshWith` is the same
|
|
6
|
+
* operation over art rasters the caller made — and it is two operations
|
|
7
|
+
* composed:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Refinement** (`refineRegions`) inserts vertices inside the union of the
|
|
10
|
+
* declared regions and their bands until §5's `L(R)` holds on every edge
|
|
11
|
+
* that meets each region — a coarse source is refined, never refused
|
|
12
|
+
* (correction 3).
|
|
13
|
+
* 2. **Reduction** (`removeVertices`) then removes source vertices one at a
|
|
14
|
+
* time, retriangulating the hole each leaves, and takes a step only when
|
|
15
|
+
* every bound the caller declared still holds after it — the region bounds
|
|
16
|
+
* included, so the reduction cannot undo the refinement.
|
|
17
|
+
*
|
|
18
|
+
* Each is its own function with its own entry and exit, so a control can run
|
|
19
|
+
* one with the other idle — regions empty, or every source vertex protected —
|
|
20
|
+
* which is the shape the consumer asked for (spine-parts#126, comment
|
|
21
|
+
* 6042150608: "separate composed operations … with independently passing
|
|
22
|
+
* controls for each").
|
|
23
|
+
*
|
|
24
|
+
* ## What every step is held to
|
|
25
|
+
*
|
|
26
|
+
* Each candidate step is measured by `measureMeshQuality` (`src/meshquality.ts`)
|
|
27
|
+
* — no row is re-implemented here; the call reads it as `measureMeshQualityWith`
|
|
28
|
+
* over the art's rasters taken once at admission (`src/meshrasters.ts`, issue
|
|
29
|
+
* #1240), and as `measureMeshQualityStep` carried from the last step's
|
|
30
|
+
* measurement (`StepRasters`, issue #1246), each of which returns the same
|
|
31
|
+
* report — and taken only when every row the caller's
|
|
32
|
+
* contract requires is `pass`: the art fit at `targets.artFit`, the boundary
|
|
33
|
+
* deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
|
|
34
|
+
* angle when declared, orientation and degeneracy at 0, and every region's
|
|
35
|
+
* `MQ_MAX_EDGE` and `MQ_TRANSITION`. Before the measurement, the structural
|
|
36
|
+
* conditions a row cannot see: the hole's link polygon is strictly simple and
|
|
37
|
+
* ear-clips, the outline is still one loop, every protected source edge is
|
|
38
|
+
* still an edge, and no new edge joins two vertices whose weight vectors differ
|
|
39
|
+
* by more than `weightJump` (§6, conditions (a) and (b)).
|
|
40
|
+
*
|
|
41
|
+
* ## The order, which is the determinism (A18)
|
|
42
|
+
*
|
|
43
|
+
* The reduction sweeps the surviving SOURCE vertices in ascending source index,
|
|
44
|
+
* one removal attempted per vertex per pass; a step taken stays taken and the
|
|
45
|
+
* sweep continues with the next index. A pass in which no step is taken ends
|
|
46
|
+
* the reduction (`no-further-valid-reduction`, naming the constraint that
|
|
47
|
+
* blocked the last attempt). Inserted vertices are never candidates: they exist
|
|
48
|
+
* to meet `L(R)`, and removing one is undoing the refinement.
|
|
49
|
+
*
|
|
50
|
+
* The refinement measures, takes the first failing region row in report order
|
|
51
|
+
* (code, then region name) and splits that row's worst edge. When an end of
|
|
52
|
+
* the edge lies outside the region and its band, the split is where the edge
|
|
53
|
+
* leaves the band, so the piece to that end only touches the band's outer
|
|
54
|
+
* boundary and `edgeIsHeldByRegion` — the one definition the rows read —
|
|
55
|
+
* exempts it (spine-parts#126). Otherwise the split is at the midpoint when
|
|
56
|
+
* the midpoint lies in the region or its band, else at the point of the edge
|
|
57
|
+
* inside them nearest the midpoint. A region no edge meets — one
|
|
58
|
+
* inside a single triangle — gets the first vertex of its polygon inserted into
|
|
59
|
+
* the triangle that holds it. Every inserted position is on the `r6` grid.
|
|
60
|
+
*
|
|
61
|
+
* ## What it never does
|
|
62
|
+
*
|
|
63
|
+
* - **Invent a value.** Every bound, floor, limit and the budget are inputs; a
|
|
64
|
+
* missing one is `REDUCE_INPUT_MISSING`, thrown.
|
|
65
|
+
* - **Resample a survivor.** A surviving vertex keeps its position, UV and
|
|
66
|
+
* bindings bit for bit (P19); only the order of its bindings is the canonical
|
|
67
|
+
* one (strongest first, `boneOrder` on a tie).
|
|
68
|
+
* - **Bind.** A `SourceMesh` names bones and carries no bind coordinates, and
|
|
69
|
+
* the input carries no bone transforms, so an inserted vertex's weights are
|
|
70
|
+
* by bone name exactly as a source vertex's are; the compiler binds them, as
|
|
71
|
+
* `bindWeightedVertices` binds every generated mesh.
|
|
72
|
+
* - **Claim a minimum.** A local stop is reported as one.
|
|
73
|
+
* - Pose, or link the runtime. Pure: no clock, no randomness, nothing from the
|
|
74
|
+
* compiler.
|
|
75
|
+
*/
|
|
76
|
+
import {
|
|
77
|
+
distanceToSegment,
|
|
78
|
+
earClip,
|
|
79
|
+
findSelfIntersection,
|
|
80
|
+
MeshError,
|
|
81
|
+
MeshReductionError,
|
|
82
|
+
meshEdges,
|
|
83
|
+
r6,
|
|
84
|
+
rasteriseTriangles,
|
|
85
|
+
signedArea,
|
|
86
|
+
traceOutline,
|
|
87
|
+
checkHullOrder,
|
|
88
|
+
} from './mesh.ts';
|
|
89
|
+
import {
|
|
90
|
+
BAND_CONTACT_TOLERANCE,
|
|
91
|
+
edgeIsHeldByRegion,
|
|
92
|
+
measureMeshQualityStep,
|
|
93
|
+
measureMeshQualityWith,
|
|
94
|
+
type AttachmentRef,
|
|
95
|
+
type ArtFitBounds,
|
|
96
|
+
type CandidateReport,
|
|
97
|
+
type DeformKeyInput,
|
|
98
|
+
type EffectiveSettings,
|
|
99
|
+
type MeasureRow,
|
|
100
|
+
type MeshCounts,
|
|
101
|
+
type MeshMeasureInput,
|
|
102
|
+
type MeshQualityReport,
|
|
103
|
+
type MeshReductionInput,
|
|
104
|
+
type RefinementRegion,
|
|
105
|
+
type ReductionChanges,
|
|
106
|
+
type SourceMesh,
|
|
107
|
+
type Termination,
|
|
108
|
+
} from './meshquality.ts';
|
|
109
|
+
import { artRastersOf, stepRastersOf, type ArtRasters, type StepRasters } from './meshrasters.ts';
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// the result
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** A `vertices` key after the remap: the run rewritten over the result's vertex order. */
|
|
116
|
+
export interface RemappedDeformKey {
|
|
117
|
+
animation: string;
|
|
118
|
+
attachment: AttachmentRef;
|
|
119
|
+
/** The key's position in its timeline's `keys`. */
|
|
120
|
+
key: number;
|
|
121
|
+
time: number;
|
|
122
|
+
/** Into the RESULT's deform array, as the compiler emits it. */
|
|
123
|
+
offset: number;
|
|
124
|
+
vertices: number[];
|
|
125
|
+
/** Source vertices whose offsets the run carried and whose vertex the reduction removed — dropped, never moved onto another vertex. */
|
|
126
|
+
droppedVertices: number[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A `transform` key: re-evaluated over the new geometry at compile, so it has no run to rewrite. */
|
|
130
|
+
export interface DeformKeyRef {
|
|
131
|
+
animation: string;
|
|
132
|
+
attachment: AttachmentRef;
|
|
133
|
+
key: number;
|
|
134
|
+
time: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The reduced mesh: §1's `SourceMesh` in the canonical output order, plus what a
|
|
139
|
+
* consumer needs to carry anything indexed by the old vertices across.
|
|
140
|
+
*/
|
|
141
|
+
export interface ReducedMesh extends SourceMesh {
|
|
142
|
+
/** `meshEdges`' list, in the export encoding (index × 2), outline loop first. */
|
|
143
|
+
edges: number[];
|
|
144
|
+
/** Source index → result index; null where the reduction removed the vertex. */
|
|
145
|
+
indexMap: Array<number | null>;
|
|
146
|
+
/** Result indices of the vertices the refinement inserted, ascending. */
|
|
147
|
+
inserted: number[];
|
|
148
|
+
/** Every linked mesh of the source, echoed: each inherits this topology. */
|
|
149
|
+
linkedMeshes: AttachmentRef[];
|
|
150
|
+
/** P18: what happened to every deform key the input listed. */
|
|
151
|
+
deform: { remapped: RemappedDeformKey[]; reevaluated: DeformKeyRef[] };
|
|
152
|
+
counts: MeshCounts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface MeshReductionResult {
|
|
156
|
+
/** Null when no mesh is returned: a refusal, or a budget that ran out before any candidate met the targets. */
|
|
157
|
+
mesh: ReducedMesh | null;
|
|
158
|
+
report: MeshQualityReport;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// refusals and small helpers
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
type Pt = readonly [number, number];
|
|
166
|
+
type Binding = { bone: string; weight: number };
|
|
167
|
+
|
|
168
|
+
/** The predicate epsilon `segmentsMeet` and `prunePolygon` use (`src/mesh.ts`), for "on the boundary". */
|
|
169
|
+
const ON_BOUNDARY = 1e-9;
|
|
170
|
+
|
|
171
|
+
/** How many halvings toward a point known to lie in a region's band the split search tries before it says none was found. */
|
|
172
|
+
const SPLIT_SEARCH_STEPS = 40;
|
|
173
|
+
|
|
174
|
+
/** Iterations of the convex searches `offsetExit` runs along an edge: 2^-60 of an edge is below a double's resolution of it. */
|
|
175
|
+
const EXIT_SEARCH_STEPS = 60;
|
|
176
|
+
|
|
177
|
+
function nameOf(ref: AttachmentRef): string {
|
|
178
|
+
return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sameRef(a: AttachmentRef, b: AttachmentRef): boolean {
|
|
182
|
+
return a.skin === b.skin && a.slot === b.slot && a.attachment === b.attachment;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function refuse(code: string, message: string): never {
|
|
186
|
+
throw new MeshReductionError(code, message);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
190
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
194
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isRef(value: unknown): value is AttachmentRef {
|
|
198
|
+
return isObject(value) && typeof value.slot === 'string' && value.slot !== '' && typeof value.attachment === 'string' && value.attachment !== '' && (value.skin === null || typeof value.skin === 'string');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A refusal the operation reports rather than throws: the input is well formed and the request cannot be met. */
|
|
202
|
+
class Stop extends Error {
|
|
203
|
+
constructor(
|
|
204
|
+
readonly reason: 'invalid-input' | 'unsupported-topology',
|
|
205
|
+
readonly code: string,
|
|
206
|
+
readonly detail: string,
|
|
207
|
+
) {
|
|
208
|
+
super(`${code}: ${detail}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// validation — what is refused by a throw, before anything is measured
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function checkFit(who: string, field: string, fit: unknown): void {
|
|
217
|
+
if (fit === undefined || fit === null || !isObject(fit)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is ${JSON.stringify(fit)}; required { minCoverage, maxOvershoot, maxUndercut } — no field has a default`);
|
|
218
|
+
if (!isFiniteNumber(fit.minCoverage) || fit.minCoverage < 0 || fit.minCoverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.minCoverage is ${JSON.stringify(fit.minCoverage)}; required a fraction in 0..1`);
|
|
219
|
+
for (const k of ['maxOvershoot', 'maxUndercut'] as const) {
|
|
220
|
+
if (!isFiniteNumber(fit[k]) || (fit[k] as number) < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(fit[k])}; required a finite number of px, 0 or more`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The fields a reduction reads and a measurement does not, refused by name. The
|
|
226
|
+
* art, the mesh's arrays, the regions' shapes and the sample floors are refused
|
|
227
|
+
* by `measureMeshQuality`'s own validation when the source is first measured,
|
|
228
|
+
* with the same codes.
|
|
229
|
+
*/
|
|
230
|
+
function validateReduction(input: MeshReductionInput): void {
|
|
231
|
+
if (!isObject(input)) refuse('REDUCE_INPUT_MISSING', `the input is ${JSON.stringify(input)}; required a MeshReductionInput object`);
|
|
232
|
+
if (!isRef(input.attachment)) refuse('REDUCE_INPUT_MISSING', `attachment is ${JSON.stringify(input.attachment)}; required { skin: string | null, slot: string, attachment: string }`);
|
|
233
|
+
const who = `attachment ${nameOf(input.attachment)}`;
|
|
234
|
+
checkFit(who, 'sourceBounds', input.sourceBounds);
|
|
235
|
+
const t = input.targets;
|
|
236
|
+
if (!isObject(t)) refuse('REDUCE_INPUT_MISSING', `${who}: targets is ${JSON.stringify(t)}; required { artFit, maxBoundaryDeviation, regions }`);
|
|
237
|
+
checkFit(who, 'targets.artFit', t.artFit);
|
|
238
|
+
if (!isFiniteNumber(t.maxBoundaryDeviation) || t.maxBoundaryDeviation < 0) {
|
|
239
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: targets.maxBoundaryDeviation is ${JSON.stringify(t.maxBoundaryDeviation)}; required a finite number of px, 0 or more (P13: the required preservation bound)`);
|
|
240
|
+
}
|
|
241
|
+
if (!Array.isArray(t.regions)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.regions is ${JSON.stringify(t.regions)}; required a list (empty when none)`);
|
|
242
|
+
const b = input.budget;
|
|
243
|
+
if (!isObject(b) || !Number.isInteger(b.maxCandidates) || (b.maxCandidates as number) < 0) {
|
|
244
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: budget is ${JSON.stringify(b)}; required { maxCandidates: a whole number, 0 or more } — the work bound has no default`);
|
|
245
|
+
}
|
|
246
|
+
const src = input.source;
|
|
247
|
+
if (!isObject(src) || !Array.isArray(src.points)) refuse('REDUCE_INPUT_MISSING', `${who}: source is not { points, uvs, triangles, hull, weights }`);
|
|
248
|
+
const n = src.points.length;
|
|
249
|
+
|
|
250
|
+
const p = input.protect;
|
|
251
|
+
if (p === undefined || p === null || !isObject(p)) refuse('REDUCE_INPUT_MISSING', `${who}: protect is ${JSON.stringify(p)}; required a ProtectedFeatures — a reduction has no default protection (P20)`);
|
|
252
|
+
if (typeof p.hull !== 'boolean') refuse('REDUCE_INPUT_MISSING', `${who}: protect.hull is ${JSON.stringify(p.hull)}; required true or false (P20: no default inside the operation)`);
|
|
253
|
+
if (!Array.isArray(p.vertices) || !Array.isArray(p.edges) || !Array.isArray(p.regionBoundaries) || !Array.isArray(p.influences) || !(p.weightJump === null || (isFiniteNumber(p.weightJump) && p.weightJump >= 0))) {
|
|
254
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: protect is not { hull, vertices, edges, regionBoundaries, weightJump, influences } with lists and a weightJump that is null or 0 or more`);
|
|
255
|
+
}
|
|
256
|
+
p.vertices.forEach((v, i) => {
|
|
257
|
+
if (!Number.isInteger(v) || v < 0 || v >= n) refuse('REDUCE_INPUT_MISSING', `${who}: protect.vertices[${i}] is ${JSON.stringify(v)}; required a source vertex index in 0..${n - 1}`);
|
|
258
|
+
});
|
|
259
|
+
const sourceEdges = new Set<string>();
|
|
260
|
+
if (Array.isArray(src.triangles)) {
|
|
261
|
+
for (let i = 0; i + 2 < src.triangles.length; i += 3) {
|
|
262
|
+
const tri = [src.triangles[i], src.triangles[i + 1], src.triangles[i + 2]];
|
|
263
|
+
for (let k = 0; k < 3; k++) sourceEdges.add(edgeKey(tri[k], tri[(k + 1) % 3]));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
p.edges.forEach((e, i) => {
|
|
267
|
+
if (!Array.isArray(e) || e.length !== 2 || !sourceEdges.has(edgeKey(e[0], e[1]))) {
|
|
268
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: protect.edges[${i}] is ${JSON.stringify(e)}; required a pair of source indices that is an edge of a source triangle`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const regionNames = new Set(t.regions.map((r) => (isObject(r) ? r.name : undefined)));
|
|
272
|
+
p.regionBoundaries.forEach((name, i) => {
|
|
273
|
+
if (!regionNames.has(name)) refuse('REDUCE_INPUT_MISSING', `${who}: protect.regionBoundaries[${i}] is ${JSON.stringify(name)}, which no region in targets.regions is; required a declared region's name`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const weighted = src.weights !== null && src.weights !== undefined;
|
|
277
|
+
if (weighted) {
|
|
278
|
+
const lim = input.influences;
|
|
279
|
+
if (lim === undefined || lim === null || !isObject(lim)) refuse('REDUCE_INPUT_MISSING', `${who}: influences is ${JSON.stringify(lim)} on a weighted source; required { maxInfluences, minWeight } — stated on every weighted call, never inherited (P19)`);
|
|
280
|
+
if (!Number.isInteger(lim.maxInfluences) || lim.maxInfluences < 1) refuse('REDUCE_INPUT_MISSING', `${who}: influences.maxInfluences is ${JSON.stringify(lim.maxInfluences)}; required a whole number >= 1`);
|
|
281
|
+
if (!isFiniteNumber(lim.minWeight) || lim.minWeight < 0 || lim.minWeight >= 1) refuse('REDUCE_INPUT_MISSING', `${who}: influences.minWeight is ${JSON.stringify(lim.minWeight)}; required a share in 0..1 (0 drops only shares that are zero on the weight grid)`);
|
|
282
|
+
const order = input.boneOrder;
|
|
283
|
+
if (!Array.isArray(order) || order.some((x) => typeof x !== 'string')) refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder is ${JSON.stringify(order)} on a weighted source; required the skeleton's bone names in order (correction 1: the weight tie-break)`);
|
|
284
|
+
if (new Set(order).size !== order.length) refuse('REDUCE_INPUT_MISSING', `${who}: boneOrder names a bone twice; required each bone once`);
|
|
285
|
+
const known = new Set(order);
|
|
286
|
+
if (Array.isArray(src.weights)) {
|
|
287
|
+
src.weights.forEach((vertex, i) => {
|
|
288
|
+
if (!Array.isArray(vertex)) return;
|
|
289
|
+
for (const bnd of vertex) {
|
|
290
|
+
if (isObject(bnd) && typeof bnd.bone === 'string' && !known.has(bnd.bone)) refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] binds "${bnd.bone}", which boneOrder does not name; required every bound bone in boneOrder`);
|
|
291
|
+
if (isObject(bnd) && isFiniteNumber(bnd.weight) && bnd.weight < 0) refuse('REDUCE_INPUT_MISSING', `${who}: source.weights[${i}] gives "${String(bnd.bone)}" the weight ${bnd.weight}; required 0 or more`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
p.influences.forEach((bone, i) => {
|
|
296
|
+
if (!known.has(bone)) refuse('REDUCE_INPUT_MISSING', `${who}: protect.influences[${i}] is ${JSON.stringify(bone)}, which boneOrder does not name; required a bone of the skeleton`);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const field of ['minArtSamples', 'regionArtSamples', 'preset'] as const) {
|
|
301
|
+
if (input[field] === undefined) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is missing; required a value — no field has a default`);
|
|
302
|
+
}
|
|
303
|
+
if (!Array.isArray(input.linkedMeshes)) refuse('REDUCE_INPUT_MISSING', `${who}: linkedMeshes is ${JSON.stringify(input.linkedMeshes)}; required a list of the source's linked meshes (empty when none)`);
|
|
304
|
+
input.linkedMeshes.forEach((ref, i) => {
|
|
305
|
+
if (!isRef(ref)) refuse('REDUCE_INPUT_MISSING', `${who}: linkedMeshes[${i}] is ${JSON.stringify(ref)}; required an AttachmentRef`);
|
|
306
|
+
});
|
|
307
|
+
if (!Array.isArray(input.deform)) refuse('REDUCE_INPUT_MISSING', `${who}: deform is ${JSON.stringify(input.deform)}; required a list of the deform timelines keyed on the attachment and its linked meshes (empty when none)`);
|
|
308
|
+
input.deform.forEach((tl, i) => {
|
|
309
|
+
if (!isObject(tl) || typeof tl.animation !== 'string' || tl.animation === '' || !isRef(tl.attachment) || !Array.isArray(tl.keys)) {
|
|
310
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: deform[${i}] is not { animation, attachment, keys }; required that`);
|
|
311
|
+
}
|
|
312
|
+
if (!sameRef(tl.attachment, input.attachment) && !input.linkedMeshes.some((l) => sameRef(l, tl.attachment))) {
|
|
313
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: deform[${i}] (animation "${tl.animation}") is keyed on ${nameOf(tl.attachment)}, which is neither the attachment nor a listed linked mesh; required one of those`);
|
|
314
|
+
}
|
|
315
|
+
tl.keys.forEach((key, k) => {
|
|
316
|
+
const at = `${who}: deform[${i}] (animation "${tl.animation}") key ${k}`;
|
|
317
|
+
if (!isObject(key) || !isFiniteNumber(key.time)) refuse('REDUCE_INPUT_MISSING', `${at} has no finite time; required one`);
|
|
318
|
+
if (key.kind === 'vertices') {
|
|
319
|
+
if (!Number.isInteger(key.offset) || key.offset < 0) refuse('REDUCE_INPUT_MISSING', `${at}: offset is ${JSON.stringify(key.offset)}; required a whole index into the deform array, 0 or more`);
|
|
320
|
+
if (!Array.isArray(key.vertices) || key.vertices.some((v) => !isFiniteNumber(v))) refuse('REDUCE_INPUT_MISSING', `${at}: vertices is not a list of finite numbers; required the run as the compiler emits it`);
|
|
321
|
+
} else if (key.kind !== 'setup' && key.kind !== 'transform') {
|
|
322
|
+
refuse('REDUCE_INPUT_MISSING', `${at}: kind is ${JSON.stringify((key as { kind: unknown }).kind)}; required "setup", "vertices" or "transform"`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function edgeKey(a: number, b: number): string {
|
|
329
|
+
return a < b ? `${a},${b}` : `${b},${a}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// the working mesh — vertices by stable id: source index, then insertion order
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
interface Work {
|
|
337
|
+
/** Number of source vertices: ids below it are source indices, ids from it are insertions in order. */
|
|
338
|
+
nSource: number;
|
|
339
|
+
pos: Pt[];
|
|
340
|
+
uv: Array<[number, number]>;
|
|
341
|
+
weights: Binding[][] | null;
|
|
342
|
+
alive: boolean[];
|
|
343
|
+
triangles: Array<[number, number, number]>;
|
|
344
|
+
/** For an inserted id, the source triangle its UV and weights were interpolated in. */
|
|
345
|
+
sourceTriangle: Map<number, number>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface Canonical {
|
|
349
|
+
mesh: SourceMesh;
|
|
350
|
+
/** Result index → working id. */
|
|
351
|
+
order: number[];
|
|
352
|
+
/** Working id → result index. */
|
|
353
|
+
indexOf: Map<number, number>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* §1's canonical order, read off a working mesh — or a `MeshError` naming why
|
|
358
|
+
* its triangles are not one closed outline.
|
|
359
|
+
*
|
|
360
|
+
* 1. hull vertices in walk order, from the surviving hull vertex with the
|
|
361
|
+
* smallest source index (an inserted one only when no source vertex is on
|
|
362
|
+
* the hull), turning the way the source's own hull listing turns;
|
|
363
|
+
* 2. surviving interior source vertices, ascending source index;
|
|
364
|
+
* 3. inserted interior vertices, ascending by (y, x) on the `r6` grid;
|
|
365
|
+
* 4. triangles rotated to start at their smallest index, winding kept, sorted;
|
|
366
|
+
* 5. bindings strongest first, ties by the bone's position in `boneOrder`.
|
|
367
|
+
*/
|
|
368
|
+
function canonicalise(work: Work, sourceTurn: number, boneRank: Map<string, number>): Canonical {
|
|
369
|
+
const ids: number[] = [];
|
|
370
|
+
for (let id = 0; id < work.alive.length; id++) if (work.alive[id]) ids.push(id);
|
|
371
|
+
const compact = new Map<number, number>();
|
|
372
|
+
ids.forEach((id, i) => compact.set(id, i));
|
|
373
|
+
const flat: number[] = [];
|
|
374
|
+
for (const tri of work.triangles) for (const id of tri) flat.push(compact.get(id)!);
|
|
375
|
+
const outline = traceOutline(ids.length, flat);
|
|
376
|
+
let walk = outline.walk.map((i) => ids[i]);
|
|
377
|
+
const start = walk.indexOf(Math.min(...walk));
|
|
378
|
+
walk = [...walk.slice(start), ...walk.slice(0, start)];
|
|
379
|
+
const turn = Math.sign(signedArea(walk.map((id) => [work.pos[id][0], work.pos[id][1]])));
|
|
380
|
+
if (turn !== sourceTurn) walk = [walk[0], ...walk.slice(1).reverse()];
|
|
381
|
+
const onHull = new Set(walk);
|
|
382
|
+
const interiorSource = ids.filter((id) => !onHull.has(id) && id < work.nSource);
|
|
383
|
+
const interiorInserted = ids
|
|
384
|
+
.filter((id) => !onHull.has(id) && id >= work.nSource)
|
|
385
|
+
.sort((a, b) => work.pos[a][1] - work.pos[b][1] || work.pos[a][0] - work.pos[b][0] || a - b);
|
|
386
|
+
const order = [...walk, ...interiorSource, ...interiorInserted];
|
|
387
|
+
const indexOf = new Map<number, number>();
|
|
388
|
+
order.forEach((id, i) => indexOf.set(id, i));
|
|
389
|
+
const tris = work.triangles.map(([a, b, c]): [number, number, number] => {
|
|
390
|
+
const t: [number, number, number] = [indexOf.get(a)!, indexOf.get(b)!, indexOf.get(c)!];
|
|
391
|
+
const k = t.indexOf(Math.min(...t));
|
|
392
|
+
return [t[k], t[(k + 1) % 3], t[(k + 2) % 3]];
|
|
393
|
+
});
|
|
394
|
+
tris.sort((p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2]);
|
|
395
|
+
const mesh: SourceMesh = {
|
|
396
|
+
points: order.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]),
|
|
397
|
+
uvs: order.flatMap((id) => work.uv[id]),
|
|
398
|
+
triangles: tris.flat(),
|
|
399
|
+
hull: walk.length,
|
|
400
|
+
weights: work.weights === null ? null : order.map((id) => sortBindings(work.weights![id], boneRank)),
|
|
401
|
+
};
|
|
402
|
+
checkHullOrder({ hull: mesh.hull, walk: [...Array(mesh.hull).keys()] }, order.length);
|
|
403
|
+
return { mesh, order, indexOf };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Strongest first, ties by the bone's place in `boneOrder` — the values themselves untouched. */
|
|
407
|
+
function sortBindings(list: readonly Binding[], boneRank: Map<string, number>): Binding[] {
|
|
408
|
+
return list.map((b) => ({ bone: b.bone, weight: b.weight })).sort((p, q) => q.weight - p.weight || (boneRank.get(p.bone) ?? 0) - (boneRank.get(q.bone) ?? 0));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// plane geometry the steps need (the rows' own geometry stays in meshquality.ts)
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
function inClosedPolygon(p: Pt, poly: readonly Pt[]): boolean {
|
|
416
|
+
const n = poly.length;
|
|
417
|
+
for (let i = 0; i < n; i++) if (distanceToSegment(p, poly[i], poly[(i + 1) % n]) <= ON_BOUNDARY) return true;
|
|
418
|
+
let inside = false;
|
|
419
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
420
|
+
const [xi, yi] = poly[i];
|
|
421
|
+
const [xj, yj] = poly[j];
|
|
422
|
+
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
|
423
|
+
}
|
|
424
|
+
return inside;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function distanceToBoundary(p: Pt, poly: readonly Pt[]): number {
|
|
428
|
+
let d = Infinity;
|
|
429
|
+
for (let i = 0; i < poly.length; i++) d = Math.min(d, distanceToSegment(p, poly[i], poly[(i + 1) % poly.length]));
|
|
430
|
+
return d;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Is `p` in the closed region or within its band — the set P16's checkable form confines an insertion to? */
|
|
434
|
+
function inRegionOrBand(p: Pt, region: RefinementRegion): boolean {
|
|
435
|
+
return inClosedPolygon(p, region.polygon) || (region.transition > 0 && distanceToBoundary(p, region.polygon) <= region.transition);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Barycentric coordinates of `p` in the triangle a, b, c, or null for a triangle with no area. */
|
|
439
|
+
function barycentric(p: Pt, a: Pt, b: Pt, c: Pt): [number, number, number] | null {
|
|
440
|
+
const det = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
|
|
441
|
+
if (Math.abs(det) < 1e-12) return null;
|
|
442
|
+
const l0 = ((b[1] - c[1]) * (p[0] - c[0]) + (c[0] - b[0]) * (p[1] - c[1])) / det;
|
|
443
|
+
const l1 = ((c[1] - a[1]) * (p[0] - c[0]) + (a[0] - c[0]) * (p[1] - c[1])) / det;
|
|
444
|
+
return [l0, l1, 1 - l0 - l1];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// what an inserted vertex carries — §6: interpolate, then prune
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
interface Tally {
|
|
452
|
+
sharesDroppedOnGrid: number;
|
|
453
|
+
sharesPruned: number;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface Interpolated {
|
|
457
|
+
uv: [number, number];
|
|
458
|
+
weights: Binding[] | null;
|
|
459
|
+
sourceTriangle: number;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* UV and weights of a point by barycentric interpolation in the source triangle
|
|
464
|
+
* containing it — the first by triangle index whose smallest coordinate is the
|
|
465
|
+
* largest, so a point on a shared edge takes the lower-numbered triangle (the
|
|
466
|
+
* two agree there) and a point rounded a hair outside onto the grid still finds
|
|
467
|
+
* the triangle it belongs to.
|
|
468
|
+
*/
|
|
469
|
+
function interpolate(p: Pt, input: MeshReductionInput, boneRank: Map<string, number>, tally: Tally, who: string): Interpolated {
|
|
470
|
+
const src = input.source;
|
|
471
|
+
let best = -1;
|
|
472
|
+
let bestMin = -Infinity;
|
|
473
|
+
let bestL: [number, number, number] = [0, 0, 0];
|
|
474
|
+
for (let t = 0; t * 3 + 2 < src.triangles.length; t++) {
|
|
475
|
+
const [a, b, c] = [src.triangles[t * 3], src.triangles[t * 3 + 1], src.triangles[t * 3 + 2]];
|
|
476
|
+
const l = barycentric(p, src.points[a], src.points[b], src.points[c]);
|
|
477
|
+
if (l === null) continue;
|
|
478
|
+
const m = Math.min(...l);
|
|
479
|
+
if (m > bestMin + 1e-12) {
|
|
480
|
+
best = t;
|
|
481
|
+
bestMin = m;
|
|
482
|
+
bestL = l;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const clamped = bestL.map((v) => Math.max(0, v));
|
|
486
|
+
const sum = clamped[0] + clamped[1] + clamped[2];
|
|
487
|
+
const lam = clamped.map((v) => v / sum);
|
|
488
|
+
const corners = [src.triangles[best * 3], src.triangles[best * 3 + 1], src.triangles[best * 3 + 2]];
|
|
489
|
+
const uv: [number, number] = [0, 1].map((k) => r6(corners.reduce((s, v, i) => s + lam[i] * src.uvs[v * 2 + k], 0))) as [number, number];
|
|
490
|
+
if (src.weights === null) return { uv, weights: null, sourceTriangle: best };
|
|
491
|
+
const shares = new Map<string, number>();
|
|
492
|
+
corners.forEach((v, i) => {
|
|
493
|
+
for (const bnd of src.weights![v]) shares.set(bnd.bone, (shares.get(bnd.bone) ?? 0) + lam[i] * bnd.weight);
|
|
494
|
+
});
|
|
495
|
+
const where = `${who}: the vertex inserted at (${p[0]}, ${p[1]}) in source triangle ${best} (vertices ${corners.join(', ')})`;
|
|
496
|
+
return { uv, weights: prune(shares, input, boneRank, tally, where), sourceTriangle: best };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* `InfluenceLimits` applied to one interpolated weight vector (§6, P19): the
|
|
501
|
+
* protected influences kept first, then the strongest others up to
|
|
502
|
+
* `maxInfluences`; shares under `minWeight` dropped; a positive share that is 0
|
|
503
|
+
* on the 6-decimal weight grid dropped and counted rather than written as a 0
|
|
504
|
+
* binding (`minWeight: 0` drops only those); the rest closed at `1 − others`,
|
|
505
|
+
* the `segmentShares` rule. Every bone it returns is one the source triangle
|
|
506
|
+
* carried — interpolation cannot name another.
|
|
507
|
+
*/
|
|
508
|
+
function prune(shares: Map<string, number>, input: MeshReductionInput, boneRank: Map<string, number>, tally: Tally, where: string): Binding[] {
|
|
509
|
+
const limits = input.influences!;
|
|
510
|
+
const guarded = new Set(input.protect.influences);
|
|
511
|
+
const rank = (bone: string): number => boneRank.get(bone) ?? 0;
|
|
512
|
+
let entries = [...shares.entries()].filter(([, s]) => s > 0).map(([bone, share]) => ({ bone, share }));
|
|
513
|
+
entries.sort((p, q) => q.share - p.share || rank(p.bone) - rank(q.bone));
|
|
514
|
+
const kept = entries.filter((e) => guarded.has(e.bone));
|
|
515
|
+
if (kept.length > limits.maxInfluences) {
|
|
516
|
+
throw new Stop(
|
|
517
|
+
'invalid-input',
|
|
518
|
+
'REDUCE_PROTECTED_INFLUENCES_OVER_CAP',
|
|
519
|
+
`${where} carries ${kept.length} protected influence(s) (${kept.map((e) => e.bone).join(', ')}); required at most influences.maxInfluences = ${limits.maxInfluences} — a protected influence is never pruned silently`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
for (const e of entries) {
|
|
523
|
+
if (guarded.has(e.bone)) continue;
|
|
524
|
+
if (kept.length < limits.maxInfluences) kept.push(e);
|
|
525
|
+
else tally.sharesPruned++;
|
|
526
|
+
}
|
|
527
|
+
entries = kept.sort((p, q) => q.share - p.share || rank(p.bone) - rank(q.bone));
|
|
528
|
+
const normalise = (): void => {
|
|
529
|
+
const total = entries.reduce((s, e) => s + e.share, 0);
|
|
530
|
+
for (const e of entries) e.share /= total;
|
|
531
|
+
};
|
|
532
|
+
normalise();
|
|
533
|
+
if (limits.minWeight > 0) {
|
|
534
|
+
const before = entries.length;
|
|
535
|
+
entries = entries.filter((e) => guarded.has(e.bone) || e.share >= limits.minWeight);
|
|
536
|
+
tally.sharesPruned += before - entries.length;
|
|
537
|
+
normalise();
|
|
538
|
+
}
|
|
539
|
+
for (;;) {
|
|
540
|
+
const zero = entries.find((e) => r6(e.share) === 0);
|
|
541
|
+
if (zero === undefined) break;
|
|
542
|
+
if (guarded.has(zero.bone)) {
|
|
543
|
+
throw new Stop(
|
|
544
|
+
'invalid-input',
|
|
545
|
+
'REDUCE_PROTECTED_INFLUENCE_BELOW_GRID',
|
|
546
|
+
`${where} gives the protected influence "${zero.bone}" a share of ${zero.share}, which is 0 on the 6-decimal weight grid; required a share that can be written as a positive binding — a protected influence is never dropped silently`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
entries = entries.filter((e) => e !== zero);
|
|
550
|
+
tally.sharesDroppedOnGrid++;
|
|
551
|
+
normalise();
|
|
552
|
+
}
|
|
553
|
+
for (;;) {
|
|
554
|
+
const out: Binding[] = [];
|
|
555
|
+
let others = 0;
|
|
556
|
+
entries.forEach((e, k) => {
|
|
557
|
+
const w = k === entries.length - 1 ? r6(1 - others) : r6(e.share);
|
|
558
|
+
others += w;
|
|
559
|
+
out.push({ bone: e.bone, weight: w });
|
|
560
|
+
});
|
|
561
|
+
const last = out[out.length - 1];
|
|
562
|
+
if (last.weight > 0) return out;
|
|
563
|
+
// The closing share fell to 0 or below on the grid: it is a share the grid cannot hold.
|
|
564
|
+
const lastEntry = entries[entries.length - 1];
|
|
565
|
+
if (guarded.has(lastEntry.bone)) {
|
|
566
|
+
throw new Stop(
|
|
567
|
+
'invalid-input',
|
|
568
|
+
'REDUCE_PROTECTED_INFLUENCE_BELOW_GRID',
|
|
569
|
+
`${where} closes the protected influence "${lastEntry.bone}" at ${last.weight} on the 6-decimal weight grid; required a positive binding`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
entries = entries.slice(0, -1);
|
|
573
|
+
tally.sharesDroppedOnGrid++;
|
|
574
|
+
normalise();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// the deform keys (P18)
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/** Deform-array start of each vertex and its width: two per vertex unweighted, two per influence weighted. */
|
|
583
|
+
function layoutOf(weights: readonly Binding[][] | null, count: number): { start: number[]; width: number[] } {
|
|
584
|
+
const start: number[] = [];
|
|
585
|
+
const width: number[] = [];
|
|
586
|
+
let at = 0;
|
|
587
|
+
for (let v = 0; v < count; v++) {
|
|
588
|
+
const w = weights === null ? 2 : 2 * weights[v].length;
|
|
589
|
+
start.push(at);
|
|
590
|
+
width.push(w);
|
|
591
|
+
at += w;
|
|
592
|
+
}
|
|
593
|
+
return { start, width };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The source vertices a `vertices` run writes into. */
|
|
597
|
+
function verticesUnder(key: Extract<DeformKeyInput, { kind: 'vertices' }>, layout: { start: number[]; width: number[] }): number[] {
|
|
598
|
+
const out: number[] = [];
|
|
599
|
+
const end = key.offset + key.vertices.length;
|
|
600
|
+
layout.start.forEach((s, v) => {
|
|
601
|
+
if (s < end && s + layout.width[v] > key.offset) out.push(v);
|
|
602
|
+
});
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function deformName(animation: string, ref: AttachmentRef, key: number, time: number): string {
|
|
607
|
+
return `animation "${animation}", slot "${ref.slot}", attachment "${ref.attachment}"${ref.skin === null ? '' : ` (skin "${ref.skin}")`}, key ${key} (t ${time})`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Refused before any work (P18): a deform timeline keyed on a linked mesh, whose own keys this remap does not carry; and a run past the source's array (A35). */
|
|
611
|
+
function checkDeformBeforeWork(input: MeshReductionInput): void {
|
|
612
|
+
const layout = layoutOf(input.source.weights, input.source.points.length);
|
|
613
|
+
const length = layout.start.length === 0 ? 0 : layout.start[layout.start.length - 1] + layout.width[layout.width.length - 1];
|
|
614
|
+
for (const tl of input.deform) {
|
|
615
|
+
const linked = !sameRef(tl.attachment, input.attachment);
|
|
616
|
+
tl.keys.forEach((key, k) => {
|
|
617
|
+
if (linked && key.kind !== 'setup') {
|
|
618
|
+
throw new Stop(
|
|
619
|
+
'unsupported-topology',
|
|
620
|
+
'REDUCE_DEFORM_INDEXED',
|
|
621
|
+
`attachment ${nameOf(input.attachment)}: ${deformName(tl.animation, tl.attachment, k, key.time)} is keyed on a linked mesh of ${nameOf(input.attachment)}; required keys on the source attachment only — a linked mesh's own vertex-indexed keys are not remapped by this operation, and a linked mesh that inherits plays the source's remapped keys`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (key.kind === 'vertices' && key.offset + key.vertices.length > length) {
|
|
625
|
+
refuse(
|
|
626
|
+
'REDUCE_INPUT_MISSING',
|
|
627
|
+
`attachment ${nameOf(input.attachment)}: ${deformName(tl.animation, tl.attachment, k, key.time)} writes deform-array entries ${key.offset}..${key.offset + key.vertices.length - 1}; required within the source's ${length} (A35)`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The vertices runs on the source that cover a source vertex — what an insertion beside it would sit under. */
|
|
635
|
+
function keyedSourceVertices(input: MeshReductionInput): Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }> {
|
|
636
|
+
const layout = layoutOf(input.source.weights, input.source.points.length);
|
|
637
|
+
const out = new Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }>();
|
|
638
|
+
for (const tl of input.deform) {
|
|
639
|
+
tl.keys.forEach((key, k) => {
|
|
640
|
+
if (key.kind !== 'vertices') return;
|
|
641
|
+
for (const v of verticesUnder(key, layout)) if (!out.has(v)) out.set(v, { animation: tl.animation, ref: tl.attachment, key: k, time: key.time });
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Every deform key carried onto the result's vertex order. A `vertices` run is
|
|
649
|
+
* rewritten entry by entry onto the vertex and influence it addressed; entries
|
|
650
|
+
* of a removed vertex are dropped and reported; the gaps a reorder opens are
|
|
651
|
+
* zero, which is what the format reads outside a run anyway. Refused by name: a
|
|
652
|
+
* weighted keyed vertex whose binding list is not the same list in the same
|
|
653
|
+
* order in the result — its pairs would land on other influences.
|
|
654
|
+
*/
|
|
655
|
+
function remapDeform(input: MeshReductionInput, result: SourceMesh, indexMap: Array<number | null>): { remapped: RemappedDeformKey[]; reevaluated: DeformKeyRef[] } {
|
|
656
|
+
const before = layoutOf(input.source.weights, input.source.points.length);
|
|
657
|
+
const after = layoutOf(result.weights, result.points.length);
|
|
658
|
+
const remapped: RemappedDeformKey[] = [];
|
|
659
|
+
const reevaluated: DeformKeyRef[] = [];
|
|
660
|
+
for (const tl of input.deform) {
|
|
661
|
+
tl.keys.forEach((key, k) => {
|
|
662
|
+
if (key.kind === 'transform') {
|
|
663
|
+
reevaluated.push({ animation: tl.animation, attachment: tl.attachment, key: k, time: key.time });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (key.kind !== 'vertices') return;
|
|
667
|
+
const placed = new Map<number, number>();
|
|
668
|
+
const dropped = new Set<number>();
|
|
669
|
+
key.vertices.forEach((value, j) => {
|
|
670
|
+
const at = key.offset + j;
|
|
671
|
+
let v = 0;
|
|
672
|
+
while (v + 1 < before.start.length && before.start[v + 1] <= at) v++;
|
|
673
|
+
const within = at - before.start[v];
|
|
674
|
+
const r = indexMap[v];
|
|
675
|
+
if (r === null) {
|
|
676
|
+
dropped.add(v);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (input.source.weights !== null) {
|
|
680
|
+
const was = input.source.weights[v].map((b) => b.bone).join(',');
|
|
681
|
+
const now = result.weights![r].map((b) => b.bone).join(',');
|
|
682
|
+
if (was !== now) {
|
|
683
|
+
throw new Stop(
|
|
684
|
+
'unsupported-topology',
|
|
685
|
+
'REDUCE_DEFORM_INDEXED',
|
|
686
|
+
`attachment ${nameOf(input.attachment)}: ${deformName(tl.animation, tl.attachment, k, key.time)} keys source vertex ${v}, weighted [${was}], which the result binds [${now}]; required the same influences in the same order, so each pair of the run still lands on its influence — a weighted keyed mesh whose influence layout changes is not remapped`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
placed.set(after.start[r] + within, value);
|
|
691
|
+
});
|
|
692
|
+
const positions = [...placed.keys()].sort((a, b) => a - b);
|
|
693
|
+
const offset = positions.length === 0 ? 0 : positions[0];
|
|
694
|
+
const vertices: number[] = [];
|
|
695
|
+
if (positions.length > 0) for (let at = offset; at <= positions[positions.length - 1]; at++) vertices.push(placed.get(at) ?? 0);
|
|
696
|
+
remapped.push({ animation: tl.animation, attachment: tl.attachment, key: k, time: key.time, offset, vertices, droppedVertices: [...dropped].sort((a, b) => a - b) });
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return { remapped, reevaluated };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// the composed operation's shared state
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
interface Run {
|
|
707
|
+
input: MeshReductionInput;
|
|
708
|
+
who: string;
|
|
709
|
+
work: Work;
|
|
710
|
+
boneRank: Map<string, number>;
|
|
711
|
+
sourceTurn: number;
|
|
712
|
+
sourceHull: Array<[number, number]>;
|
|
713
|
+
/** Steps tried so far, refinement insertions and removal attempts alike. */
|
|
714
|
+
steps: number;
|
|
715
|
+
tally: Tally;
|
|
716
|
+
keyed: Map<number, { animation: string; ref: AttachmentRef; key: number; time: number }>;
|
|
717
|
+
protectedVertices: Set<number>;
|
|
718
|
+
protectedEdges: Array<[number, number]>;
|
|
719
|
+
sourceEdges: Set<string>;
|
|
720
|
+
/** The art's rasters, taken once for the call and read by every measurement in it (issue #1240). */
|
|
721
|
+
rasters: ArtRasters;
|
|
722
|
+
/** The step state each removal and insertion is measured through, carried from the last measurement (issue #1246); null measures every step in full. */
|
|
723
|
+
stepRasters: StepRasters | null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** A measurement of a canonical mesh against the result's full contract. */
|
|
727
|
+
function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQualityReport {
|
|
728
|
+
const { input } = run;
|
|
729
|
+
const targets = input.targets;
|
|
730
|
+
const measureInput: MeshMeasureInput = {
|
|
731
|
+
id,
|
|
732
|
+
attachment: input.attachment,
|
|
733
|
+
art: input.art,
|
|
734
|
+
source: mesh,
|
|
735
|
+
targets: { artFit: targets.artFit, maxBoundaryDeviation: targets.maxBoundaryDeviation, ...(targets.minAngle === undefined ? {} : { minAngle: targets.minAngle }), regions: targets.regions },
|
|
736
|
+
referenceHull: run.sourceHull,
|
|
737
|
+
minArtSamples: input.minArtSamples,
|
|
738
|
+
regionArtSamples: input.regionArtSamples,
|
|
739
|
+
protect: input.protect,
|
|
740
|
+
influences: input.influences,
|
|
741
|
+
boneOrder: input.boneOrder,
|
|
742
|
+
preset: input.preset,
|
|
743
|
+
};
|
|
744
|
+
return run.stepRasters === null ? measureMeshQualityWith(measureInput, run.rasters) : measureMeshQualityStep(measureInput, run.stepRasters);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
|
|
748
|
+
const BLOCKING_ORDER = ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_BOUNDARY_DEVIATION', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT', 'MQ_MIN_ANGLE', 'MQ_MAX_EDGE', 'MQ_TRANSITION'];
|
|
749
|
+
|
|
750
|
+
function rowName(row: MeasureRow): string {
|
|
751
|
+
const region = row.object.region === null ? '' : `[${row.object.region}]`;
|
|
752
|
+
if (row.state === 'fail') return `${row.code}${region}: ${row.value} against ${row.bound!.op} ${row.bound!.value}`;
|
|
753
|
+
return `${row.code}${region}: ${row.state} — ${row.reason ?? ''}`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The first required row that is not `pass`, in `BLOCKING_ORDER`, or null when
|
|
758
|
+
* every one passes. A row is required exactly when its bound is declared; the
|
|
759
|
+
* overshoot row gated is the 8-connected one (P12).
|
|
760
|
+
*/
|
|
761
|
+
function firstBlockingRow(report: MeshQualityReport): MeasureRow | null {
|
|
762
|
+
const rows = report.candidates[0]?.geometry?.rows ?? [];
|
|
763
|
+
const required = (r: MeasureRow): boolean => {
|
|
764
|
+
if (r.code === 'MQ_OVERSHOOT') return r.art?.connectivity === 8;
|
|
765
|
+
if (r.code === 'MQ_MIN_ANGLE') return r.bound !== null;
|
|
766
|
+
if (r.code === 'MQ_HOLES' || r.code === 'MQ_ISLANDS' || r.code === 'MQ_TRACE_DEVIATION' || r.code === 'MQ_FILL_DISTANCE') return false;
|
|
767
|
+
return true;
|
|
768
|
+
};
|
|
769
|
+
for (const code of BLOCKING_ORDER) {
|
|
770
|
+
const hit = rows.find((r) => r.code === code && required(r) && r.state !== 'pass');
|
|
771
|
+
if (hit !== undefined) return hit;
|
|
772
|
+
}
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// refinement (§5)
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
/** Where to split edge a–b for `region`: its midpoint when that is in the region or its band, else the point inside them nearest the midpoint. */
|
|
781
|
+
function splitPoint(a: Pt, b: Pt, region: RefinementRegion): Pt | null {
|
|
782
|
+
const at = (t: number): Pt => [r6(a[0] + (b[0] - a[0]) * t), r6(a[1] + (b[1] - a[1]) * t)];
|
|
783
|
+
const usable = (q: Pt): boolean => inRegionOrBand(q, region) && !(q[0] === a[0] && q[1] === a[1]) && !(q[0] === b[0] && q[1] === b[1]);
|
|
784
|
+
if (usable(at(0.5))) return at(0.5);
|
|
785
|
+
// The parameters where the inside of the region or its band can begin or end: crossings of the
|
|
786
|
+
// polygon's edges and the feet of its vertices; the inside point nearest the midpoint is among them.
|
|
787
|
+
const dx = b[0] - a[0];
|
|
788
|
+
const dy = b[1] - a[1];
|
|
789
|
+
const len2 = dx * dx + dy * dy;
|
|
790
|
+
const ts = new Set<number>();
|
|
791
|
+
const poly = region.polygon;
|
|
792
|
+
for (let i = 0; i < poly.length; i++) {
|
|
793
|
+
const c = poly[i];
|
|
794
|
+
const d = poly[(i + 1) % poly.length];
|
|
795
|
+
ts.add(Math.max(0, Math.min(1, ((c[0] - a[0]) * dx + (c[1] - a[1]) * dy) / len2)));
|
|
796
|
+
const ex = d[0] - c[0];
|
|
797
|
+
const ey = d[1] - c[1];
|
|
798
|
+
const den = dx * ey - dy * ex;
|
|
799
|
+
if (Math.abs(den) > 1e-12) {
|
|
800
|
+
const t = ((c[0] - a[0]) * ey - (c[1] - a[1]) * ex) / den;
|
|
801
|
+
const u = ((c[0] - a[0]) * dy - (c[1] - a[1]) * dx) / den;
|
|
802
|
+
if (t >= 0 && t <= 1 && u >= -1e-9 && u <= 1 + 1e-9) ts.add(t);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const candidates = [...ts].filter((t) => t > 0 && t < 1 && usable(at(t))).sort((p, q) => Math.abs(p - 0.5) - Math.abs(q - 0.5) || p - q);
|
|
806
|
+
if (candidates.length > 0) return at(candidates[0]);
|
|
807
|
+
// A point of the edge in the band only at an endpoint: halve towards it from the midpoint.
|
|
808
|
+
for (const end of [0, 1]) {
|
|
809
|
+
if (!inRegionOrBand(end === 0 ? a : b, region)) continue;
|
|
810
|
+
let t = 0.5;
|
|
811
|
+
for (let i = 0; i < SPLIT_SEARCH_STEPS; i++) {
|
|
812
|
+
t = (t + end) / 2;
|
|
813
|
+
if (usable(at(t))) return at(t);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The parameter, from `inner` (0) to `outer` (1), of the point of the edge
|
|
821
|
+
* nearest `outer` that lies within `level` of `region`'s polygon — where the
|
|
822
|
+
* edge last leaves that offset of the polygon on its way to `outer` — or null
|
|
823
|
+
* when no point of it comes that near. `outer` lies further than `level`, so
|
|
824
|
+
* walking back from it the offset is first met where some polygon side is
|
|
825
|
+
* exactly `level` away: the largest such parameter over the sides. Each side's
|
|
826
|
+
* distance is convex along the edge, so its minimum is found by golden section
|
|
827
|
+
* and the last parameter within `level` by bisection from there towards `outer`.
|
|
828
|
+
*/
|
|
829
|
+
function offsetExit(inner: Pt, outer: Pt, poly: readonly Pt[], level: number): number | null {
|
|
830
|
+
const at = (t: number): Pt => [inner[0] + (outer[0] - inner[0]) * t, inner[1] + (outer[1] - inner[1]) * t];
|
|
831
|
+
const g = (Math.sqrt(5) - 1) / 2;
|
|
832
|
+
let best: number | null = null;
|
|
833
|
+
for (let i = 0; i < poly.length; i++) {
|
|
834
|
+
const c = poly[i];
|
|
835
|
+
const e = poly[(i + 1) % poly.length];
|
|
836
|
+
const f = (t: number): number => distanceToSegment(at(t), c, e);
|
|
837
|
+
let lo = 0;
|
|
838
|
+
let hi = 1;
|
|
839
|
+
for (let k = 0; k < EXIT_SEARCH_STEPS; k++) {
|
|
840
|
+
const p = hi - g * (hi - lo);
|
|
841
|
+
const q = lo + g * (hi - lo);
|
|
842
|
+
if (f(p) <= f(q)) hi = q;
|
|
843
|
+
else lo = p;
|
|
844
|
+
}
|
|
845
|
+
let inside = (lo + hi) / 2;
|
|
846
|
+
if (f(inside) > level) continue;
|
|
847
|
+
let outside = 1;
|
|
848
|
+
for (let k = 0; k < EXIT_SEARCH_STEPS; k++) {
|
|
849
|
+
const mid = (inside + outside) / 2;
|
|
850
|
+
if (f(mid) <= level) inside = mid;
|
|
851
|
+
else outside = mid;
|
|
852
|
+
}
|
|
853
|
+
if (best === null || inside > best) best = inside;
|
|
854
|
+
}
|
|
855
|
+
return best;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* [agreed, spine-parts#126] A split of `inner`–`outer` where it leaves
|
|
860
|
+
* `region`'s band, so that the piece to `outer` only touches the band's outer
|
|
861
|
+
* boundary and is exempt by `edgeIsHeldByRegion` — the one definition the
|
|
862
|
+
* measurement reads. The split is put half of `BAND_CONTACT_TOLERANCE` inside
|
|
863
|
+
* the outer boundary, then on the `r6` grid, so it lies inside the band (P16's
|
|
864
|
+
* checkable form) and within the tolerance of its edge. Null when there is no
|
|
865
|
+
* band, no point of the edge comes that near the polygon, or the point found
|
|
866
|
+
* is an end or fails either condition — each re-checked rather than assumed.
|
|
867
|
+
*/
|
|
868
|
+
function outerSplit(inner: Pt, outer: Pt, region: RefinementRegion): Pt | null {
|
|
869
|
+
if (!(region.transition > 0)) return null;
|
|
870
|
+
const t = offsetExit(inner, outer, region.polygon, region.transition - BAND_CONTACT_TOLERANCE / 2);
|
|
871
|
+
if (t === null) return null;
|
|
872
|
+
const q: Pt = [r6(inner[0] + (outer[0] - inner[0]) * t), r6(inner[1] + (outer[1] - inner[1]) * t)];
|
|
873
|
+
if ((q[0] === inner[0] && q[1] === inner[1]) || (q[0] === outer[0] && q[1] === outer[1])) return null;
|
|
874
|
+
return inRegionOrBand(q, region) && !edgeIsHeldByRegion(q, outer, region) ? q : null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Add a vertex at `p` with what interpolation gives it, refusing it under a deform run (P18). */
|
|
878
|
+
function addVertex(run: Run, p: Pt): number {
|
|
879
|
+
const got = interpolate(p, run.input, run.boneRank, run.tally, run.who);
|
|
880
|
+
const src = run.input.source;
|
|
881
|
+
for (let k = 0; k < 3; k++) {
|
|
882
|
+
const corner = src.triangles[got.sourceTriangle * 3 + k];
|
|
883
|
+
const key = run.keyed.get(corner);
|
|
884
|
+
if (key !== undefined) {
|
|
885
|
+
throw new Stop(
|
|
886
|
+
'unsupported-topology',
|
|
887
|
+
'REDUCE_DEFORM_INDEXED',
|
|
888
|
+
`${run.who}: ${deformName(key.animation, key.ref, key.key, key.time)} keys source vertex ${corner}, and the refinement would insert a vertex at (${p[0]}, ${p[1]}) in source triangle ${got.sourceTriangle} beside it, under the run; required no insertion under a vertices run — the inserted vertex has no offset in any key`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const id = run.work.pos.length;
|
|
893
|
+
run.work.pos.push(p);
|
|
894
|
+
run.work.uv.push(got.uv);
|
|
895
|
+
if (run.work.weights !== null) run.work.weights.push(got.weights!);
|
|
896
|
+
run.work.alive.push(true);
|
|
897
|
+
run.work.sourceTriangle.set(id, got.sourceTriangle);
|
|
898
|
+
return id;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Split the edge between working ids a and b at `p`: each triangle on it becomes two, winding kept. */
|
|
902
|
+
function splitEdge(run: Run, a: number, b: number, p: Pt): void {
|
|
903
|
+
const m = addVertex(run, p);
|
|
904
|
+
const next: Array<[number, number, number]> = [];
|
|
905
|
+
for (const tri of run.work.triangles) {
|
|
906
|
+
const i = tri.indexOf(a);
|
|
907
|
+
const j = tri.indexOf(b);
|
|
908
|
+
if (i === -1 || j === -1) {
|
|
909
|
+
next.push(tri);
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
// Rotate so the edge is the triangle's first two corners, in its own winding.
|
|
913
|
+
const r = (j - i + 3) % 3 === 1 ? i : j;
|
|
914
|
+
const x = tri[r];
|
|
915
|
+
const y = tri[(r + 1) % 3];
|
|
916
|
+
const c = tri[(r + 2) % 3];
|
|
917
|
+
next.push([x, m, c], [m, y, c]);
|
|
918
|
+
}
|
|
919
|
+
run.work.triangles = next;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Insert `p` into the working triangle that holds it: on an edge, that edge is split; inside, the triangle becomes three. */
|
|
923
|
+
function insertPoint(run: Run, p: Pt): boolean {
|
|
924
|
+
const { work } = run;
|
|
925
|
+
for (const tri of work.triangles) {
|
|
926
|
+
const l = barycentric(p, work.pos[tri[0]], work.pos[tri[1]], work.pos[tri[2]]);
|
|
927
|
+
if (l === null || Math.min(...l) < -1e-9) continue;
|
|
928
|
+
const onEdge = l.findIndex((v) => Math.abs(v) <= 1e-9);
|
|
929
|
+
if (onEdge !== -1) {
|
|
930
|
+
splitEdge(run, tri[(onEdge + 1) % 3], tri[(onEdge + 2) % 3], p);
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
const m = addVertex(run, p);
|
|
934
|
+
work.triangles = work.triangles.flatMap((t): Array<[number, number, number]> => (t === tri ? [[tri[0], tri[1], m], [tri[1], tri[2], m], [tri[2], tri[0], m]] : [t]));
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
type PhaseEnd =
|
|
941
|
+
| { kind: 'done' }
|
|
942
|
+
| { kind: 'budget' }
|
|
943
|
+
| { kind: 'stuck'; constraint: string };
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Refine the working mesh until every region's `MQ_MAX_EDGE` and
|
|
947
|
+
* `MQ_TRANSITION` that the refinement can act on passes. One insertion per
|
|
948
|
+
* measurement: the first failing region row in report order has its worst edge
|
|
949
|
+
* split. A band no edge lies in leaves `MQ_TRANSITION` not-measurable (B1's
|
|
950
|
+
* definition) and is not something an insertion is aimed at.
|
|
951
|
+
*/
|
|
952
|
+
function refineRegions(run: Run): PhaseEnd {
|
|
953
|
+
const regions = new Map(run.input.targets.regions.map((r) => [r.name, r]));
|
|
954
|
+
if (regions.size === 0) return { kind: 'done' };
|
|
955
|
+
for (;;) {
|
|
956
|
+
const canon = canonicalise(run.work, run.sourceTurn, run.boneRank);
|
|
957
|
+
const report = measureAgainstTargets(run, canon.mesh, 'refinement');
|
|
958
|
+
const rows = report.candidates[0]?.geometry?.rows ?? [];
|
|
959
|
+
const target = rows.find((r) => (r.code === 'MQ_MAX_EDGE' || r.code === 'MQ_TRANSITION') && r.state === 'fail') ?? rows.find((r) => r.code === 'MQ_MAX_EDGE' && r.state === 'not-measurable' && (r.reason ?? '').includes('no triangle edge meets'));
|
|
960
|
+
if (target === undefined) return { kind: 'done' };
|
|
961
|
+
if (run.steps >= run.input.budget.maxCandidates) return { kind: 'budget' };
|
|
962
|
+
run.steps++;
|
|
963
|
+
const region = regions.get(target.object.region!)!;
|
|
964
|
+
if (target.state === 'not-measurable') {
|
|
965
|
+
const p: Pt = [r6(region.polygon[0][0]), r6(region.polygon[0][1])];
|
|
966
|
+
if (!insertPoint(run, p)) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no triangle holding the region's first vertex)` };
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const [ra, rb] = target.worst!.at.edge!;
|
|
970
|
+
const a = canon.order[ra];
|
|
971
|
+
const b = canon.order[rb];
|
|
972
|
+
// An end outside the region and its band: split where the edge leaves the band, so the piece to that
|
|
973
|
+
// end only touches the band's outer boundary and is exempt (spine-parts#126) — the same predicate the
|
|
974
|
+
// measurement reads decides it, so the next measurement agrees.
|
|
975
|
+
let exited = false;
|
|
976
|
+
for (const [inner, outer] of [[b, a], [a, b]] as const) {
|
|
977
|
+
if (inRegionOrBand(run.work.pos[outer], region)) continue;
|
|
978
|
+
const q = outerSplit(run.work.pos[inner], run.work.pos[outer], region);
|
|
979
|
+
if (q === null) continue;
|
|
980
|
+
splitEdge(run, a, b, q);
|
|
981
|
+
exited = true;
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
if (exited) continue;
|
|
985
|
+
// An end outside the region and its band further beyond it than the edge's own bound, with no split on
|
|
986
|
+
// the band's outer boundary that frees the piece to it — always so with transition 0, where a contact
|
|
987
|
+
// with the authored boundary stays held: every split keeps a held edge from a vertex inside the region
|
|
988
|
+
// and its band to that end, so no insertion P16 allows can meet it.
|
|
989
|
+
for (const [end, id] of [[ra, a], [rb, b]] as const) {
|
|
990
|
+
const at = run.work.pos[id];
|
|
991
|
+
if (inRegionOrBand(at, region)) continue;
|
|
992
|
+
const beyond = distanceToBoundary(at, region.polygon) - region.transition;
|
|
993
|
+
if (beyond > target.bound!.value) {
|
|
994
|
+
const why =
|
|
995
|
+
region.transition > 0
|
|
996
|
+
? 'no point of the edge on the band\'s outer boundary leaves the piece to it exempt, so an edge from inside the region and its band to it stays over the bound'
|
|
997
|
+
: 'with no band, an edge from the region to it touches the authored boundary and stays held (spine-parts#126), so it stays over the bound';
|
|
998
|
+
return {
|
|
999
|
+
kind: 'stuck',
|
|
1000
|
+
constraint: `${rowName(target)} (vertex ${end} of edge ${ra}–${rb} lies ${r6(beyond)} px beyond region "${region.name}"'s ${region.transition} px band, further than the edge's bound ${target.bound!.value}: ${why}, and the refinement inserts only inside them — P16)`,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const p = splitPoint(run.work.pos[a], run.work.pos[b], region);
|
|
1005
|
+
if (p === null) return { kind: 'stuck', constraint: `${rowName(target)} (the refinement found no point of edge ${ra}–${rb} strictly between its ends inside region "${region.name}" or its band)` };
|
|
1006
|
+
splitEdge(run, a, b, p);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ---------------------------------------------------------------------------
|
|
1011
|
+
// reduction (§1, §6)
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
/** One removal tried: the working triangles after it and the edges it added, or the structural reason it cannot be made. */
|
|
1015
|
+
function removalOf(work: Work, v: number): { triangles: Array<[number, number, number]>; added: Array<[number, number]> } | { blocked: string } {
|
|
1016
|
+
const star = work.triangles.filter((t) => t.includes(v));
|
|
1017
|
+
const next = new Map<number, number>();
|
|
1018
|
+
const incoming = new Set<number>();
|
|
1019
|
+
for (const t of star) {
|
|
1020
|
+
const k = t.indexOf(v);
|
|
1021
|
+
const a = t[(k + 1) % 3];
|
|
1022
|
+
const b = t[(k + 2) % 3];
|
|
1023
|
+
if (next.has(a) || incoming.has(b)) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
|
|
1024
|
+
next.set(a, b);
|
|
1025
|
+
incoming.add(b);
|
|
1026
|
+
}
|
|
1027
|
+
const heads = [...next.keys()].filter((a) => !incoming.has(a));
|
|
1028
|
+
const boundary = heads.length === 1;
|
|
1029
|
+
if (heads.length > 1) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
|
|
1030
|
+
let at = boundary ? heads[0] : Math.min(...next.keys());
|
|
1031
|
+
const ring = [at];
|
|
1032
|
+
for (;;) {
|
|
1033
|
+
const to = next.get(at);
|
|
1034
|
+
if (to === undefined || to === ring[0]) break;
|
|
1035
|
+
ring.push(to);
|
|
1036
|
+
at = to;
|
|
1037
|
+
if (ring.length > next.size + 1) return { blocked: `retriangulation: vertex ${v}'s link does not close` };
|
|
1038
|
+
}
|
|
1039
|
+
if (ring.length !== (boundary ? next.size + 1 : next.size)) return { blocked: `retriangulation: vertex ${v}'s triangles do not form one fan` };
|
|
1040
|
+
const rest = work.triangles.filter((t) => !t.includes(v));
|
|
1041
|
+
const fresh: Array<[number, number, number]> = [];
|
|
1042
|
+
if (ring.length >= 3) {
|
|
1043
|
+
const poly = ring.map((id): [number, number] => [work.pos[id][0], work.pos[id][1]]);
|
|
1044
|
+
if (findSelfIntersection(poly) !== null) return { blocked: `retriangulation: the hole vertex ${v} leaves is not a strictly simple polygon` };
|
|
1045
|
+
let tris: number[];
|
|
1046
|
+
try {
|
|
1047
|
+
tris = earClip(poly);
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
if (!(err instanceof MeshError)) throw err;
|
|
1050
|
+
return { blocked: `retriangulation: the hole vertex ${v} leaves does not ear-clip (${err.message})` };
|
|
1051
|
+
}
|
|
1052
|
+
for (let i = 0; i < tris.length; i += 3) fresh.push([ring[tris[i]], ring[tris[i + 1]], ring[tris[i + 2]]]);
|
|
1053
|
+
} else if (!boundary) {
|
|
1054
|
+
return { blocked: `retriangulation: vertex ${v} has fewer than three neighbours` };
|
|
1055
|
+
}
|
|
1056
|
+
const old = new Set<string>();
|
|
1057
|
+
for (const t of work.triangles) for (let k = 0; k < 3; k++) old.add(edgeKey(t[k], t[(k + 1) % 3]));
|
|
1058
|
+
const added: Array<[number, number]> = [];
|
|
1059
|
+
const seen = new Set<string>();
|
|
1060
|
+
for (const t of fresh) {
|
|
1061
|
+
for (let k = 0; k < 3; k++) {
|
|
1062
|
+
const key = edgeKey(t[k], t[(k + 1) % 3]);
|
|
1063
|
+
if (old.has(key) || seen.has(key)) continue;
|
|
1064
|
+
seen.add(key);
|
|
1065
|
+
added.push([t[k], t[(k + 1) % 3]]);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return { triangles: [...rest, ...fresh], added };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** L1 difference of two weight vectors over the bones they name. */
|
|
1072
|
+
function weightJump(work: Work, a: number, b: number): number {
|
|
1073
|
+
if (work.weights === null) return 0;
|
|
1074
|
+
const shares = new Map<string, number>();
|
|
1075
|
+
for (const bnd of work.weights[a]) shares.set(bnd.bone, (shares.get(bnd.bone) ?? 0) + bnd.weight);
|
|
1076
|
+
for (const bnd of work.weights[b]) shares.set(bnd.bone, (shares.get(bnd.bone) ?? 0) - bnd.weight);
|
|
1077
|
+
let sum = 0;
|
|
1078
|
+
for (const v of shares.values()) sum += Math.abs(v);
|
|
1079
|
+
return sum;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Remove surviving source vertices in ascending source index, pass after pass,
|
|
1084
|
+
* taking a step only when every structural condition and every required row
|
|
1085
|
+
* holds after it. Ends when a pass takes no step, or when the budget is spent.
|
|
1086
|
+
*/
|
|
1087
|
+
function removeVertices(run: Run): PhaseEnd {
|
|
1088
|
+
const { work, input } = run;
|
|
1089
|
+
let lastBlock = '';
|
|
1090
|
+
for (;;) {
|
|
1091
|
+
let taken = 0;
|
|
1092
|
+
let tried = 0;
|
|
1093
|
+
for (let v = 0; v < work.nSource; v++) {
|
|
1094
|
+
if (!work.alive[v] || run.protectedVertices.has(v)) continue;
|
|
1095
|
+
if (run.steps >= input.budget.maxCandidates) return { kind: 'budget' };
|
|
1096
|
+
run.steps++;
|
|
1097
|
+
tried++;
|
|
1098
|
+
const block = tryRemoval(run, v);
|
|
1099
|
+
if (block === null) taken++;
|
|
1100
|
+
else lastBlock = `${block}, removing source vertex ${v}`;
|
|
1101
|
+
}
|
|
1102
|
+
if (taken === 0) {
|
|
1103
|
+
if (tried === 0) lastBlock = 'protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge)';
|
|
1104
|
+
return { kind: 'stuck', constraint: lastBlock };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** One removal: null when it was taken, else the constraint that blocked it. */
|
|
1110
|
+
function tryRemoval(run: Run, v: number): string | null {
|
|
1111
|
+
const { work, input } = run;
|
|
1112
|
+
const step = removalOf(work, v);
|
|
1113
|
+
if ('blocked' in step) return step.blocked;
|
|
1114
|
+
const jump = input.protect.weightJump;
|
|
1115
|
+
if (jump !== null) {
|
|
1116
|
+
for (const [a, b] of step.added) {
|
|
1117
|
+
if (a < work.nSource && b < work.nSource && run.sourceEdges.has(edgeKey(a, b))) continue;
|
|
1118
|
+
const d = weightJump(work, a, b);
|
|
1119
|
+
if (d > jump) return `weightJump (b): the new edge ${a}–${b} joins weight vectors ${r6(d)} apart, over ${jump}`;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const was = { triangles: work.triangles };
|
|
1123
|
+
work.triangles = step.triangles;
|
|
1124
|
+
work.alive[v] = false;
|
|
1125
|
+
const undo = (): void => {
|
|
1126
|
+
work.triangles = was.triangles;
|
|
1127
|
+
work.alive[v] = true;
|
|
1128
|
+
};
|
|
1129
|
+
const edges = new Set<string>();
|
|
1130
|
+
for (const t of work.triangles) for (let k = 0; k < 3; k++) edges.add(edgeKey(t[k], t[(k + 1) % 3]));
|
|
1131
|
+
for (const [a, b] of run.protectedEdges) {
|
|
1132
|
+
if (!edges.has(edgeKey(a, b))) {
|
|
1133
|
+
undo();
|
|
1134
|
+
return `protect (a): the protected source edge ${a}–${b} is no longer an edge`;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
let canon: Canonical;
|
|
1138
|
+
try {
|
|
1139
|
+
canon = canonicalise(work, run.sourceTurn, run.boneRank);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
if (!(err instanceof MeshError)) throw err;
|
|
1142
|
+
undo();
|
|
1143
|
+
return `outline: ${err.message}`;
|
|
1144
|
+
}
|
|
1145
|
+
const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'));
|
|
1146
|
+
if (blocking !== null) {
|
|
1147
|
+
undo();
|
|
1148
|
+
return rowName(blocking);
|
|
1149
|
+
}
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ---------------------------------------------------------------------------
|
|
1154
|
+
// the operation
|
|
1155
|
+
// ---------------------------------------------------------------------------
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Reduce one mesh towards the caller's targets, refining it first where a
|
|
1159
|
+
* region asks for density — §1, §5 and §6 of docs/MESH_REDUCTION.md, geometry
|
|
1160
|
+
* only. Throws a `MeshReductionError` for a malformed input (a missing or
|
|
1161
|
+
* out-of-range field, a threshold, a mask or a UV out of range); reports
|
|
1162
|
+
* everything else, with exactly one `Termination`:
|
|
1163
|
+
*
|
|
1164
|
+
* - `invalid-input`: the source fails its own art bounds
|
|
1165
|
+
* (`REDUCE_SOURCE_FAILS_ITS_ART_BOUNDS`, `sourceBounds` only — correction 3),
|
|
1166
|
+
* a region is refused (`REGION_*`), full coverage is asked of a source that
|
|
1167
|
+
* leaves an art island untouched (`REDUCE_ISLAND_UNREACHED`), or an inserted
|
|
1168
|
+
* vertex's protected influences cannot be written
|
|
1169
|
+
* (`REDUCE_PROTECTED_INFLUENCES_OVER_CAP`, `REDUCE_PROTECTED_INFLUENCE_BELOW_GRID`);
|
|
1170
|
+
* - `unsupported-topology`: the source is not one loop
|
|
1171
|
+
* (`REDUCE_SOURCE_NOT_ONE_LOOP`) or a deform key cannot be carried
|
|
1172
|
+
* (`REDUCE_DEFORM_INDEXED`);
|
|
1173
|
+
* - `budget-exhausted`: `budget.maxCandidates` steps were tried — the result is
|
|
1174
|
+
* returned when it meets every required bound, and none is when it does not;
|
|
1175
|
+
* - `no-further-valid-reduction`: a pass took no step, naming what blocked the
|
|
1176
|
+
* last one. A local stop: nothing here says the result is the smallest.
|
|
1177
|
+
*/
|
|
1178
|
+
export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
|
|
1179
|
+
validateReduction(input);
|
|
1180
|
+
const rasters = artRastersOf(input.art);
|
|
1181
|
+
return reduceValidated(input, rasters, stepRastersOf(rasters));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* `reduceMesh` over art rasters the caller made (`artRastersOf`,
|
|
1186
|
+
* `src/meshrasters.ts`) — the result is `reduceMesh`'s for the same input, byte
|
|
1187
|
+
* for byte, and every measurement of the call reads the one object, so its
|
|
1188
|
+
* `tally` is the count of what the call computed. Rasters taken from another
|
|
1189
|
+
* art are refused at admission (`REDUCE_ART_RASTERS_MISMATCH`). Each step is
|
|
1190
|
+
* measured through `steps` (`stepRastersOf(rasters)` unless given), carried
|
|
1191
|
+
* from the last measurement (issue #1246); `null` measures every step in full,
|
|
1192
|
+
* which is the path the carried one is held equal to. Step rasters made over
|
|
1193
|
+
* another rasters object are refused by the same code.
|
|
1194
|
+
*
|
|
1195
|
+
* Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
|
|
1196
|
+
* module with `export *`, and a symbol that is merely exported is not promised
|
|
1197
|
+
* (RELEASING.md, *The import surface*).
|
|
1198
|
+
*/
|
|
1199
|
+
export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null = stepRastersOf(rasters)): MeshReductionResult {
|
|
1200
|
+
validateReduction(input);
|
|
1201
|
+
if (steps !== null && steps.rasters !== rasters) {
|
|
1202
|
+
refuse('REDUCE_ART_RASTERS_MISMATCH', `attachment ${nameOf(input.attachment)}: the step rasters were made over another rasters object; required step rasters made over the rasters passed beside them (stepRastersOf(rasters))`);
|
|
1203
|
+
}
|
|
1204
|
+
return reduceValidated(input, rasters, steps);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The operation, over an input `validateReduction` accepted; the art's rasters are computed at most once, in
|
|
1209
|
+
* `rasters`, and every refinement and removal step is measured through `steps` when it is given (issue #1246).
|
|
1210
|
+
*/
|
|
1211
|
+
function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null): MeshReductionResult {
|
|
1212
|
+
const who = `attachment ${nameOf(input.attachment)}`;
|
|
1213
|
+
const src = input.source;
|
|
1214
|
+
const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
|
|
1215
|
+
|
|
1216
|
+
// The source, measured against its own admissibility bounds (correction 3) — this is also what refuses a
|
|
1217
|
+
// malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes. Through the step
|
|
1218
|
+
// rasters when the call carries them, so what a region reads of the art alone is computed once (issue #1253).
|
|
1219
|
+
const admitInput: MeshMeasureInput = {
|
|
1220
|
+
id: 'source',
|
|
1221
|
+
attachment: input.attachment,
|
|
1222
|
+
art: input.art,
|
|
1223
|
+
source: src,
|
|
1224
|
+
targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
|
|
1225
|
+
referenceHull: null,
|
|
1226
|
+
minArtSamples: input.minArtSamples,
|
|
1227
|
+
regionArtSamples: input.regionArtSamples,
|
|
1228
|
+
protect: input.protect,
|
|
1229
|
+
influences: input.influences,
|
|
1230
|
+
boneOrder: input.boneOrder,
|
|
1231
|
+
preset: input.preset,
|
|
1232
|
+
};
|
|
1233
|
+
const admit = steps === null ? measureMeshQualityWith(admitInput, rasters) : measureMeshQualityStep(admitInput, steps);
|
|
1234
|
+
const effective = effectiveOf(input, admit.effective, sourceHull);
|
|
1235
|
+
const sourceCounts = admit.sourceCounts;
|
|
1236
|
+
const noMesh = (termination: Termination): MeshReductionResult => ({
|
|
1237
|
+
mesh: null,
|
|
1238
|
+
report: reportOf(effective, sourceCounts, { id: 'result', counts: null, geometry: null, motion: null, accepted: false }, termination),
|
|
1239
|
+
});
|
|
1240
|
+
if (admit.termination !== null) return noMesh(admit.termination);
|
|
1241
|
+
|
|
1242
|
+
try {
|
|
1243
|
+
const admitRows = admit.candidates[0]?.geometry?.rows ?? [];
|
|
1244
|
+
for (const row of admitRows) {
|
|
1245
|
+
if (row.state === 'refused') {
|
|
1246
|
+
const reason = row.reason ?? '';
|
|
1247
|
+
const code = reason.slice(0, reason.indexOf(':'));
|
|
1248
|
+
throw new Stop('invalid-input', code, reason.slice(code.length + 2));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
for (const code of ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT']) {
|
|
1252
|
+
const row = admitRows.find((r) => r.code === code && (code !== 'MQ_OVERSHOOT' || r.art?.connectivity === 8));
|
|
1253
|
+
if (row !== undefined && row.state !== 'pass') {
|
|
1254
|
+
const found = row.state === 'fail' ? `${row.value} against ${row.bound!.op} ${row.bound!.value}` : `${row.state} (${row.reason ?? ''})`;
|
|
1255
|
+
throw new Stop(
|
|
1256
|
+
'invalid-input',
|
|
1257
|
+
'REDUCE_SOURCE_FAILS_ITS_ART_BOUNDS',
|
|
1258
|
+
`${who}: the source's ${code} is ${found}; required the source to pass its own art bounds (sourceBounds) and its own winding — a source that does not is not a reference to reduce from (correction 3)`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
checkIslands(input, who, rasters);
|
|
1263
|
+
checkDeformBeforeWork(input);
|
|
1264
|
+
|
|
1265
|
+
const boneRank = new Map((input.boneOrder ?? []).map((b, i) => [b, i]));
|
|
1266
|
+
const work: Work = {
|
|
1267
|
+
nSource: src.points.length,
|
|
1268
|
+
pos: src.points.map((p): Pt => p),
|
|
1269
|
+
uv: src.points.map((_, i): [number, number] => [src.uvs[i * 2], src.uvs[i * 2 + 1]]),
|
|
1270
|
+
weights: src.weights === null ? null : src.weights.map((v) => v.map((b) => ({ bone: b.bone, weight: b.weight }))),
|
|
1271
|
+
alive: src.points.map(() => true),
|
|
1272
|
+
triangles: [],
|
|
1273
|
+
sourceTriangle: new Map(),
|
|
1274
|
+
};
|
|
1275
|
+
for (let i = 0; i + 2 < src.triangles.length; i += 3) work.triangles.push([src.triangles[i], src.triangles[i + 1], src.triangles[i + 2]]);
|
|
1276
|
+
const sourceEdges = new Set<string>();
|
|
1277
|
+
for (const t of work.triangles) for (let k = 0; k < 3; k++) sourceEdges.add(edgeKey(t[k], t[(k + 1) % 3]));
|
|
1278
|
+
const run: Run = {
|
|
1279
|
+
input,
|
|
1280
|
+
who,
|
|
1281
|
+
work,
|
|
1282
|
+
boneRank,
|
|
1283
|
+
sourceTurn: Math.sign(signedArea(sourceHull)),
|
|
1284
|
+
sourceHull,
|
|
1285
|
+
steps: 0,
|
|
1286
|
+
tally: { sharesDroppedOnGrid: 0, sharesPruned: 0 },
|
|
1287
|
+
keyed: keyedSourceVertices(input),
|
|
1288
|
+
...protectionOf(input, work, sourceEdges),
|
|
1289
|
+
sourceEdges,
|
|
1290
|
+
rasters,
|
|
1291
|
+
stepRasters: steps,
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
const refined = refineRegions(run);
|
|
1295
|
+
if (refined.kind === 'budget') return noMesh({ reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'none-met-the-targets' });
|
|
1296
|
+
const startCanon = canonicalise(work, run.sourceTurn, boneRank);
|
|
1297
|
+
const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'));
|
|
1298
|
+
let termination: Termination;
|
|
1299
|
+
if (startBlock !== null) {
|
|
1300
|
+
const constraint = refined.kind === 'stuck' ? refined.constraint : rowName(startBlock);
|
|
1301
|
+
termination = { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: `${constraint} — before any removal: the refined source does not meet its targets, so no step from it can` };
|
|
1302
|
+
} else {
|
|
1303
|
+
const reduced = removeVertices(run);
|
|
1304
|
+
termination =
|
|
1305
|
+
reduced.kind === 'budget'
|
|
1306
|
+
? { reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'best-meeting-every-bound' }
|
|
1307
|
+
: { reason: 'no-further-valid-reduction', candidatesTried: run.steps, blockingConstraint: reduced.kind === 'stuck' ? reduced.constraint : '' };
|
|
1308
|
+
}
|
|
1309
|
+
return finish(run, effective, sourceCounts, termination);
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
if (err instanceof Stop) return noMesh({ reason: err.reason, code: err.code, detail: err.detail });
|
|
1312
|
+
throw err;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** §4: full coverage asked of a source that touches no pixel of some art island is refused, never met by deleting art. */
|
|
1317
|
+
function checkIslands(input: MeshReductionInput, who: string, rasters: ArtRasters): void {
|
|
1318
|
+
if (input.targets.artFit.minCoverage !== 1) return;
|
|
1319
|
+
const { mask, frame } = input.art;
|
|
1320
|
+
const { label, sizes } = rasters.islands();
|
|
1321
|
+
const onGrid = input.source.points.map(([x, y]): [number, number] => [x * frame.pageScale, y * frame.pageScale]);
|
|
1322
|
+
const covered = rasteriseTriangles(onGrid, input.source.triangles, mask.width, mask.height);
|
|
1323
|
+
const touched = new Set<number>();
|
|
1324
|
+
for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
|
|
1325
|
+
for (let island = 1; island <= sizes.length; island++) {
|
|
1326
|
+
if (touched.has(island) || sizes[island - 1] === 0) continue;
|
|
1327
|
+
const at = label.indexOf(island);
|
|
1328
|
+
throw new Stop(
|
|
1329
|
+
'invalid-input',
|
|
1330
|
+
'REDUCE_ISLAND_UNREACHED',
|
|
1331
|
+
`${who}: the art island of ${sizes[island - 1]} pixel(s) at (${at % mask.width}, ${Math.floor(at / mask.width)}) has no pixel the source covers, and targets.artFit.minCoverage is 1; required a source that reaches every island when full coverage is asked — art is never deleted to meet a bound (§4)`,
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The vertices no step may remove and the source edges every result must keep (§6, conditions (a) and (b)). */
|
|
1337
|
+
function protectionOf(input: MeshReductionInput, work: Work, sourceEdges: Set<string>): { protectedVertices: Set<number>; protectedEdges: Array<[number, number]> } {
|
|
1338
|
+
const p = input.protect;
|
|
1339
|
+
const vertices = new Set<number>(p.vertices);
|
|
1340
|
+
if (p.hull) for (let v = 0; v < input.source.hull; v++) vertices.add(v);
|
|
1341
|
+
const edges: Array<[number, number]> = p.edges.map(([a, b]): [number, number] => [a, b]);
|
|
1342
|
+
if (p.weightJump !== null) {
|
|
1343
|
+
for (const key of [...sourceEdges].sort()) {
|
|
1344
|
+
const [a, b] = key.split(',').map(Number);
|
|
1345
|
+
if (weightJump(work, a, b) > p.weightJump) edges.push([a, b]);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
for (const [a, b] of edges) {
|
|
1349
|
+
vertices.add(a);
|
|
1350
|
+
vertices.add(b);
|
|
1351
|
+
}
|
|
1352
|
+
for (const name of p.regionBoundaries) {
|
|
1353
|
+
const region = input.targets.regions.find((r) => r.name === name)!;
|
|
1354
|
+
input.source.points.forEach((pt, v) => {
|
|
1355
|
+
if (distanceToBoundary(pt, region.polygon) <= ON_BOUNDARY) vertices.add(v);
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
return { protectedVertices: vertices, protectedEdges: edges };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** The result, canonical, with its deform keys carried and its own measurement as the report's one candidate. */
|
|
1362
|
+
function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts | null, termination: Termination): MeshReductionResult {
|
|
1363
|
+
const { input, work } = run;
|
|
1364
|
+
const canon = canonicalise(work, run.sourceTurn, run.boneRank);
|
|
1365
|
+
const indexMap: Array<number | null> = [];
|
|
1366
|
+
for (let v = 0; v < work.nSource; v++) indexMap.push(work.alive[v] ? canon.indexOf.get(v)! : null);
|
|
1367
|
+
const inserted = [...canon.indexOf.entries()].filter(([id]) => id >= work.nSource).map(([, r]) => r).sort((a, b) => a - b);
|
|
1368
|
+
let deform: { remapped: RemappedDeformKey[]; reevaluated: DeformKeyRef[] };
|
|
1369
|
+
try {
|
|
1370
|
+
deform = remapDeform(input, canon.mesh, indexMap);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
if (err instanceof Stop) {
|
|
1373
|
+
return { mesh: null, report: reportOf(effective, sourceCounts, { id: 'result', counts: null, geometry: null, motion: null, accepted: false }, { reason: err.reason, code: err.code, detail: err.detail }) };
|
|
1374
|
+
}
|
|
1375
|
+
throw err;
|
|
1376
|
+
}
|
|
1377
|
+
const measured = measureAgainstTargets(run, canon.mesh, 'result');
|
|
1378
|
+
const candidate = measured.candidates[0];
|
|
1379
|
+
const changes: ReductionChanges = {
|
|
1380
|
+
removedVertices: indexMap.filter((r) => r === null).length,
|
|
1381
|
+
insertedVertices: inserted.length,
|
|
1382
|
+
sharesDroppedOnGrid: run.tally.sharesDroppedOnGrid,
|
|
1383
|
+
sharesPruned: run.tally.sharesPruned,
|
|
1384
|
+
deformRemapped: deform.remapped.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key, droppedVertices: d.droppedVertices })),
|
|
1385
|
+
deformReevaluated: deform.reevaluated.map((d) => ({ animation: d.animation, attachment: d.attachment, key: d.key })),
|
|
1386
|
+
linkedMeshes: input.linkedMeshes,
|
|
1387
|
+
};
|
|
1388
|
+
const counts = candidate.counts!;
|
|
1389
|
+
const mesh: ReducedMesh = {
|
|
1390
|
+
...canon.mesh,
|
|
1391
|
+
edges: meshEdges(canon.mesh.points.length, canon.mesh.triangles, canon.mesh.hull),
|
|
1392
|
+
indexMap,
|
|
1393
|
+
inserted,
|
|
1394
|
+
linkedMeshes: input.linkedMeshes,
|
|
1395
|
+
deform,
|
|
1396
|
+
counts,
|
|
1397
|
+
};
|
|
1398
|
+
return { mesh, report: reportOf(effective, sourceCounts, { ...candidate, changes }, termination) };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function reportOf(effective: EffectiveSettings, sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination): MeshQualityReport {
|
|
1402
|
+
return {
|
|
1403
|
+
spec: 'mesh-quality-report/1',
|
|
1404
|
+
operation: 'reduce',
|
|
1405
|
+
effective,
|
|
1406
|
+
poser: null,
|
|
1407
|
+
motionRequired: false,
|
|
1408
|
+
sourceCounts,
|
|
1409
|
+
reference: null,
|
|
1410
|
+
candidates: [candidate],
|
|
1411
|
+
termination,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Correction 1: the reduction's inputs echoed with their structure — the measurement's echo, with what a reduction adds. */
|
|
1416
|
+
function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sourceHull: Array<[number, number]>): EffectiveSettings {
|
|
1417
|
+
const fit = (f: ArtFitBounds): ArtFitBounds => ({ minCoverage: f.minCoverage, maxOvershoot: f.maxOvershoot, maxUndercut: f.maxUndercut });
|
|
1418
|
+
return {
|
|
1419
|
+
...measured,
|
|
1420
|
+
sourceBounds: fit(input.sourceBounds),
|
|
1421
|
+
targets: input.targets,
|
|
1422
|
+
referenceHull: sourceHull,
|
|
1423
|
+
budget: { maxCandidates: input.budget.maxCandidates },
|
|
1424
|
+
};
|
|
1425
|
+
}
|