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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. 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
+ }