@rig-c/parts 0.0.0-stage → 1.5.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
package/src/contour.ts ADDED
@@ -0,0 +1,1901 @@
1
+ /**
2
+ * The contour mesh: a part's alpha outline, traced and simplified by
3
+ * rig-c's own functions, with interior vertices placed where they are
4
+ * declared and a triangulation that never crosses the outline (issue #84). The
5
+ * rig stage builds it for a mesh whose config entry says `contour` (step 2,
6
+ * `src/rig.ts`); the lattice (`src/mesh.ts`) is untouched and stays the mode
7
+ * of every entry that says `grid`.
8
+ *
9
+ * ## What it does, in order
10
+ *
11
+ * 1. **Art.** A pixel is art when its alpha is ABOVE `threshold` — the
12
+ * lattice's reading (`ART_ALPHA` in `src/mesh.ts`, the reference's
13
+ * `alpha > 8`). rig-c's tracer and fit counter read "at or above", so
14
+ * they are handed `threshold + 1`, which is the same set of pixels.
15
+ * 2. **Islands and holes.** The art's 4-connected islands are counted
16
+ * (`connectedComponents`, 4-connectivity, rigc's own island rule); a second
17
+ * island holding any art pixel is refused with every island's pixel count
18
+ * (`CONTOUR_ONE_ISLAND`). Nothing is discarded and nothing falls back to the
19
+ * lattice — unless the caller declares `stray`, the largest island that may
20
+ * be left out ({@link strayIslands}): then islands other than the largest
21
+ * at or under that figure are taken out of the art before the trace, and
22
+ * the report says how many and how many pixels (`strayIslands`,
23
+ * `strayPixels`). A hole is filled, as rigc's trace and the lattice both
24
+ * fill it, and its area is reported (`filledHolePixels`).
25
+ * 3. **The outline** ({@link contourOutline}; issue #106 replaced rigc's
26
+ * `offsetPolygon`, which folded the outline over any notch narrower than
27
+ * twice the margin): the silhouette F — the kept island with its holes
28
+ * filled — is read by rigc's `traceAlphaOutline` first (a diagonal pinch in
29
+ * F itself stays rigc's refusal), then grown by the margin, centre to
30
+ * centre, and the diagonal pinches the growth made are filled
31
+ * ({@link growSilhouette}); that silhouette is traced by
32
+ * `traceAlphaOutline` → `simplifyClosedPolygon(tolerance)` → clamped to the
33
+ * part window → snapped to the {@link GRID} → `prunePolygon`, rigc's
34
+ * functions imported and never copied. A grown silhouette is a set of
35
+ * pixels, so its trace cannot cross itself; a notch narrower than about
36
+ * twice the margin grows shut. A margin above 0 and below 1 px adds no
37
+ * pixel and is refused by name. This module does not raise a margin by
38
+ * itself: a margin that clips art is refused (`CONTOUR_COVERAGE`).
39
+ * 4. **Interior vertices are declared**, never inferred: see
40
+ * {@link interiorCandidates} for the candidates and {@link KEEP_FRACTION}
41
+ * for the one rule that keeps or drops each. Where the outline passes
42
+ * through a region's support, points are inserted on the outline at the
43
+ * region's spacing ({@link outlineInRegions}, issue #110): exactly on its
44
+ * edges, so the outline encloses the same set and only gains vertices.
45
+ * 5. **Triangulation**: rigc's `earClip` of the outline (clockwise on screen,
46
+ * as the outline is), made constrained
47
+ * Delaunay by flipping; then each kept point inserted in order — the
48
+ * triangle that holds it is split in three, or the two that share the edge
49
+ * it lies on in four — and the Delaunay condition restored by flipping
50
+ * interior edges only. An outline edge belongs to one triangle and is never
51
+ * flipped, so no triangle crosses the outline. A last pass over every edge
52
+ * flips whatever is still not locally Delaunay, and the report counts what
53
+ * is left (`nonDelaunayEdges`, 0 on every mesh this module returns).
54
+ * 6. **Refusals**, each naming the part, the rule, the value found and the
55
+ * value required (the settled comment on issue #84, §1): coverage — any art
56
+ * pixel outside the mesh; overshoot — past `margin + tolerance + 1` px;
57
+ * budget; topology ({@link contourTopologyProblems}); islands; and the
58
+ * parameters themselves. Every problem found is returned at once.
59
+ * 7. **The report** ({@link ContourReport}): what the settled comment's §2
60
+ * lists, measured on the mesh that would be written.
61
+ *
62
+ * ## What margin a tolerance needs — measured, not raised
63
+ *
64
+ * `bun tools/contour_survey.ts` sweeps tolerance x margin (0, 1, 1.5, 2, 2.5,
65
+ * 3) over five generated shapes (a disc, a ~70° triangle, a two-horned
66
+ * crescent, a 2 px spike, a diagonal bar). The smallest margin that covers
67
+ * every art pixel, over the five: 0 at tolerance 0.5; 1 at tolerance 1 and
68
+ * 1.5; 1.5 at 2; 3 at 3 (the crescent; the others 1.5). No cell refuses
69
+ * overshoot, and along each row, once a margin passes every larger one in the
70
+ * sweep passes: before simplification a grown pixel lies within the margin of
71
+ * art by definition (selftest `CE09`), where rigc's offset moved an acute
72
+ * corner up to 4 x margin. That is the sweep's shapes; on a real part a
73
+ * larger margin can close a gap whose enclosure lies further from the art than
74
+ * the bound, which the overshoot check refuses (the issue #106 survey: one
75
+ * public part passes at margin 1 and refuses at 1.5). Hence the module never
76
+ * adjusts a margin; it refuses, and the author's figures are the ones it ran
77
+ * at.
78
+ *
79
+ * ## Exactness, and what happens at every tie
80
+ *
81
+ * Every coordinate this module produces is snapped to {@link GRID} — integer
82
+ * multiples of 1/256 px — and every predicate that decides topology runs on
83
+ * those integers: orientation in doubles, exact because a part is at most
84
+ * {@link MAX_SIDE} px a side, so a coordinate difference is under 2^24 units,
85
+ * a product under 2^48 and a difference of two products under 2^49 < 2^53;
86
+ * in-circle and point-to-segment distance in `BigInt`, which is exact at any
87
+ * size. On those coordinates rig-c's own float predicates (`earClip`,
88
+ * `prunePolygon`, `findSelfIntersection`, with their 1e-9 and 1e-12 slacks)
89
+ * are exact too: a non-zero cross product of two grid differences is at least
90
+ * 2^-16 px², far above either slack. So no topological decision here is made
91
+ * by rounding noise. The growth is exact too: a squared pixel distance, a whole
92
+ * number, against the margin's exact binary value ({@link withinMarginSquared}),
93
+ * and the pinch fill is a set operation on pixels. The ties, each decided by a
94
+ * stated rule:
95
+ *
96
+ * - **in-circle = 0** (four cocircular points — every square of a grid): the
97
+ * edge is NOT flipped, so a cocircular quad keeps the diagonal it had, which
98
+ * the fixed insertion order decides.
99
+ * - **a point exactly on an interior edge**: the two triangles sharing it are
100
+ * split into four. A point on an outline edge cannot occur: every kept point
101
+ * is at least its keep radius (≥ 1 grid unit) from the outline.
102
+ * - **two candidates at once**: none — candidates are tried one at a time in a
103
+ * fixed order, and the first triangle in index order that holds a point is
104
+ * the one split.
105
+ * - **keep radius exactly met**: kept (the rule is `≥`).
106
+ * - **smallest angle / largest edge ratio**: the first triangle in output
107
+ * order with the extreme value.
108
+ *
109
+ * What is NOT exact, said where it is: the circle region's boundary samples use
110
+ * `Math.cos` / `Math.sin`, which an engine need not round correctly, so a
111
+ * different engine could move a sampled point by one grid unit where the
112
+ * value lies within an ulp of a rounding boundary; rigc's
113
+ * `simplifyClosedPolygon` (and `offsetPolygon`, for a polygon region's band)
114
+ * use `Math.hypot`, the same kind of function; and the
115
+ * report's angles use `Math.acos` (reported to 6 decimals, decide nothing). All
116
+ * other arithmetic is IEEE `+ − × ÷ √`, which every conforming engine rounds the
117
+ * same way. Two runs on one engine are byte-identical (selftest `CT20`).
118
+ *
119
+ * ## The output's order
120
+ *
121
+ * Vertices: the outline first, in rigc's walk order (the hull Spine needs:
122
+ * `hull` is a count, and the first `hull` vertices are the outline, in order),
123
+ * then the kept interior points in the order they were kept. The outline's
124
+ * shape does not depend on the regions; its vertices do only where a region's
125
+ * support reaches it, which inserts points on the edges there
126
+ * ({@link outlineInRegions}). So adding a region whose support reaches no
127
+ * outline edge renumbers no hull vertex (`CT21`, `CE14`); one whose support
128
+ * does keeps every hull vertex, in the same cyclic order and position from
129
+ * index 0, and moves a vertex's index only by the points inserted before it in
130
+ * walk order (`CE13`). Triangles: built wound as the outline is (clockwise on
131
+ * screen) — the triangulation's predicates are written for that winding —,
132
+ * rotated to start at its smallest index, and sorted, so the list does not
133
+ * depend on the order the flips happened in; then written with each triple's
134
+ * last two corners swapped (`counterClockwiseInSpineWorld`, `src/mesh.ts`), so
135
+ * every triangle is counter-clockwise in Spine world, the winding rig-c's
136
+ * `MQ_ORIENTATION` and its `SourceMesh` read (issue #126). The y flip does not
137
+ * turn a loop over: before the swap, rig-c 2.20.3 read every triangle of
138
+ * every contour mesh on the three public examples as clockwise in Spine world
139
+ * (2,553 of 2,553 on demo). The swap keeps the order of the list and the first
140
+ * index of every triple, so it is exactly `[a, b, c]` → `[a, c, b]`. The
141
+ * automatic mode hands these triangles to rig-c as they are.
142
+ */
143
+ import { type Problem } from './errors.ts';
144
+ import { counterClockwiseInSpineWorld } from './mesh.ts';
145
+ import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
146
+ import {
147
+ type AlphaMask,
148
+ checkHullOrder,
149
+ earClip,
150
+ findSelfIntersection,
151
+ measureAuthoredMeshFit,
152
+ MeshError,
153
+ offsetPolygon,
154
+ prunePolygon,
155
+ signedArea,
156
+ simplifyClosedPolygon,
157
+ traceAlphaOutline,
158
+ traceOutline,
159
+ } from 'rig-c/mesh';
160
+
161
+ /** Grid units per pixel: every coordinate this module writes is an integer multiple of 1/GRID px. */
162
+ export const GRID = 256;
163
+
164
+ /**
165
+ * The largest part side, in px, the exactness argument above holds for: a
166
+ * coordinate is at most `MAX_SIDE * GRID` = 2^23 units and the outline may sit
167
+ * up to a margin outside the window before the clamp, so every difference
168
+ * stays under 2^24. A larger part is refused by name, not computed inexactly.
169
+ */
170
+ export const MAX_SIDE = 32768;
171
+
172
+ /**
173
+ * The keep rule's one constant — a de-duplication radius, not a quality
174
+ * threshold. **Definition:** a candidate interior point with spacing `s` is
175
+ * kept when it lies strictly inside the outline AND its distance to every
176
+ * outline edge AND to every point already kept is at least
177
+ * `KEEP_FRACTION × s`, that radius snapped to the {@link GRID}
178
+ * (`round(KEEP_FRACTION × s × GRID)` units, which must be at least 1). Each
179
+ * candidate is held to ITS OWN spacing's radius, so a background point keeps a
180
+ * background distance from a region's fine points, and a region's points keep
181
+ * a fine distance from each other.
182
+ */
183
+ export const KEEP_FRACTION = 0.5;
184
+
185
+ /**
186
+ * The largest keep radius, in grid units, whose square is exact in a double
187
+ * (2^26, so the square is at most 2^52): a spacing of 2^19 = 524288 px.
188
+ * Derived, not chosen — a larger spacing is refused, not computed inexactly.
189
+ */
190
+ export const MAX_RADIUS = 2 ** 26;
191
+
192
+ /** A refinement region: a circle in part-image px. */
193
+ export interface ContourCircleRegion {
194
+ name: string;
195
+ shape: 'circle';
196
+ /** Centre, part-image px, y down. */
197
+ cx: number;
198
+ cy: number;
199
+ /** Radius, px. */
200
+ r: number;
201
+ /** Interior spacing inside the region and its band, px. */
202
+ spacing: number;
203
+ /** Width of the transition band around the region that gets the region's spacing too, px; 0 is no band. */
204
+ band: number;
205
+ }
206
+
207
+ /** A refinement region: a simple polygon in part-image px, either winding. */
208
+ export interface ContourPolygonRegion {
209
+ name: string;
210
+ shape: 'polygon';
211
+ points: Array<[number, number]>;
212
+ spacing: number;
213
+ band: number;
214
+ }
215
+
216
+ export type ContourRegion = ContourCircleRegion | ContourPolygonRegion;
217
+
218
+ export interface ContourParams {
219
+ /** A pixel is art when its alpha is above this; a whole number in 0..254. */
220
+ threshold: number;
221
+ /** rigc's Douglas–Peucker tolerance, px, 0 or more (0 keeps every traced corner). */
222
+ tolerance: number;
223
+ /** rigc's outward offset, px, 0 or more. */
224
+ margin: number;
225
+ /** The background interior spacing, px, above 0. */
226
+ spacing: number;
227
+ /** Refuse a mesh with more vertices than this; absent is no budget. */
228
+ budget?: number;
229
+ /**
230
+ * The largest island, in art pixels, that may be left out of the mesh (the
231
+ * stray-island amendment on issue #84). Absent, a second island refuses as
232
+ * the settled rule says; there is no default. See {@link strayIslands}.
233
+ */
234
+ stray?: number;
235
+ /** Refinement regions, in the order their points are placed. */
236
+ regions: readonly ContourRegion[];
237
+ /**
238
+ * How {@link contourFit} floods the background to find the filled
239
+ * silhouette overshoot is measured from — rig-c's `measureAuthoredMeshFit`
240
+ * `connectivity` (2.23.0, rigc#1262). Absent is 4, the fill the contour
241
+ * mode has always read, and the report then carries no
242
+ * `fitConnectivity`; `8` is the fill rig-c's `MQ_OVERSHOOT` row reads (P12),
243
+ * so a background pocket joined to the outside only at a corner counts as
244
+ * outside. The automatic mode's source declares 8 ({@link
245
+ * AUTO_SOURCE_FIT_CONNECTIVITY} in `src/automesh.ts`).
246
+ */
247
+ fitConnectivity?: 4 | 8;
248
+ /**
249
+ * The chains a row of vertices is placed across, at each link joint and at
250
+ * `stations` points between (issue #188; {@link placeRibs}). Absent or empty
251
+ * places none, and the mesh is byte for byte the one it was before the field
252
+ * existed.
253
+ */
254
+ ribs?: readonly ContourChainRibs[];
255
+ }
256
+
257
+ /**
258
+ * One chain the ribs are placed along (issue #188): its link origins then its
259
+ * tip, in part-image px, as the config declares them less the part's offset —
260
+ * link k runs from `points[k]` to `points[k + 1]` — and the stations per link.
261
+ */
262
+ export interface ContourChainRibs {
263
+ chain: string;
264
+ points: ReadonlyArray<readonly [number, number]>;
265
+ /** Ribs between a link's origin and its end, evenly spaced; a whole number, 0 or more. */
266
+ stations: number;
267
+ }
268
+
269
+ /** One rib as placed (issue #188): its name and its vertices, in mesh vertex indices, from one end across to the other. */
270
+ export interface ContourRib {
271
+ name: string;
272
+ vertices: number[];
273
+ }
274
+
275
+ /** A triangle and a figure measured on it, by its index in `triangles`. */
276
+ export interface TriangleFigure {
277
+ triangle: number;
278
+ value: number;
279
+ }
280
+
281
+ export interface ContourReport {
282
+ /** Vertices on the outline — Spine's `hull`. */
283
+ boundaryVertices: number;
284
+ /** Kept interior points. */
285
+ interiorVertices: number;
286
+ /** Kept interior points by where they were declared: each region in order, then the background. */
287
+ interiorBySource: Array<{ source: string; vertices: number }>;
288
+ triangles: number;
289
+ /** Corner-lattice vertices rigc's trace produced, before simplification. */
290
+ tracedVertices: number;
291
+ /** Pixels with alpha above the threshold. */
292
+ artPixels: number;
293
+ /** How many of them a triangle covers — rigc's `measureAuthoredMeshFit`: a pixel is covered when its centre is in or on a triangle. */
294
+ coveredArtPixels: number;
295
+ /** `coveredArtPixels / artPixels`, unrounded. */
296
+ coverage: number;
297
+ /** rigc's `measureAuthoredMeshFit` overshoot: the furthest a covered pixel's centre sits from the nearest pixel of the filled silhouette, px. */
298
+ overshoot: number;
299
+ /** `margin + tolerance + 1`, px — the settled bound overshoot is refused past. */
300
+ overshootBound: number;
301
+ /** The fill overshoot was read against, as declared in {@link ContourParams.fitConnectivity}; absent when none was declared (4, the contour mode's). */
302
+ fitConnectivity?: 4 | 8;
303
+ /** The area the outline encloses, px² (exact: the grid's shoelace sum). */
304
+ meshArea: number;
305
+ /** `meshArea − coveredArtPixels`, px²: the transparent area the mesh draws over. */
306
+ enclosedTransparentArea: number;
307
+ /** Transparent pixels enclosed by the island and filled into the outline (rigc's `holePixels`). */
308
+ filledHolePixels: number;
309
+ /** The smallest interior angle of any triangle, degrees (6 decimals), and that triangle. */
310
+ smallestAngle: TriangleFigure;
311
+ /** The largest longest-edge / shortest-edge ratio of any triangle (6 decimals), and that triangle. */
312
+ largestEdgeRatio: TriangleFigure;
313
+ /** Interior edges whose opposite vertex lies strictly inside the neighbouring circumcircle after the last pass — 0 when the triangulation is constrained Delaunay. */
314
+ nonDelaunayEdges: number;
315
+ /** Islands left out of the mesh under a declared `stray` figure — 0 when none is declared or none was left out. */
316
+ strayIslands: number;
317
+ /** The art pixels those islands hold; they are not meshed, so not drawn, and `artPixels` does not count them. */
318
+ strayPixels: number;
319
+ /**
320
+ * Transparent pixels the margin's growth added round the filled silhouette
321
+ * ({@link growSilhouette}): centre within `margin` of a pixel of it. The
322
+ * silhouette the outline is traced from holds, each pixel counted once:
323
+ * `artPixels` + `filledHolePixels` + `grownPixels` + `pinchFilledPixels` +
324
+ * `grownHolePixels`.
325
+ */
326
+ grownPixels: number;
327
+ /** Transparent pixels the pinch fill added after the growth — both clear pixels of every pinch the growth made. */
328
+ pinchFilledPixels: number;
329
+ /** Transparent pixels the grown silhouette encloses without holding them (a notch the growth closed), which the trace fills — rigc's `holePixels` of that trace. Not the art's own holes, which are `filledHolePixels`. */
330
+ grownHolePixels: number;
331
+ /** Only when ribs were declared (issue #188): every rib, in placement order, with its vertices. Absent otherwise, so a mesh without ribs reports what it always reported. */
332
+ ribs?: ContourRib[];
333
+ }
334
+
335
+ export interface ContourMesh {
336
+ /** Part-image px, y down, every value a multiple of 1/{@link GRID}; the first `hull` are the outline, in walk order. */
337
+ vertices: Array<[number, number]>;
338
+ /** Three vertex indices per triangle. */
339
+ triangles: number[];
340
+ hull: number;
341
+ report: ContourReport;
342
+ /**
343
+ * The mask the mesh was traced and measured on: the input mask with every island left out under a declared
344
+ * `stray` set to alpha 0 (issue #172), so a reader of the mesh's art reads the art the trace read. When nothing
345
+ * was left out it is the input mask itself, the same object, so nothing downstream of a part without stray moves.
346
+ */
347
+ mask: AlphaMask;
348
+ /** Only when ribs were declared (issue #188): the report's list, the same objects. */
349
+ ribs?: ContourRib[];
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // the parameters
354
+ // ---------------------------------------------------------------------------
355
+
356
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
357
+
358
+ /** The keep radius of a spacing, in grid units. */
359
+ export function keepRadius(spacing: number): number {
360
+ return Math.round(KEEP_FRACTION * spacing * GRID);
361
+ }
362
+
363
+ /** Every problem with the mask and the parameters, before anything is traced. */
364
+ function parameterProblems(part: string, mask: AlphaMask, p: ContourParams): Problem[] {
365
+ const out: Problem[] = [];
366
+ const bad = (field: string, detail: string): void => {
367
+ out.push({ code: 'CONTOUR_PARAMETER', object: `contour mesh "${part}", ${field}`, detail });
368
+ };
369
+ const { width: w, height: h } = mask;
370
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1 || w > MAX_SIDE || h > MAX_SIDE) {
371
+ bad('mask', `is ${w}x${h}; whole numbers from 1 to ${MAX_SIDE} px a side are required (the exact predicates are proved up to that size)`);
372
+ } else if (mask.alpha.length !== w * h) {
373
+ bad('mask', `holds ${mask.alpha.length} alpha bytes for a ${w}x${h} part; ${w * h} are required`);
374
+ }
375
+ if (!Number.isInteger(p.threshold) || p.threshold < 0 || p.threshold > 254) bad('threshold', `is ${p.threshold}; a whole number in 0..254 is required (art is alpha above it)`);
376
+ if (!finite(p.tolerance) || p.tolerance < 0) bad('tolerance', `is ${p.tolerance}; a number of px, 0 or more, is required`);
377
+ if (!finite(p.margin) || p.margin < 0) bad('margin', `is ${p.margin}; a number of px, 0 or more, is required`);
378
+ else if (p.margin > 0 && p.margin < 1) bad('margin', `is ${p.margin}; 0, or 1 px or more, is required — the silhouette grows by the pixels whose centre lies within the margin of an art pixel's centre, and no centre lies closer than 1 px, so a margin under 1 px adds nothing`);
379
+ const spacingOk = (field: string, s: number): void => {
380
+ if (!finite(s) || s <= 0) bad(field, `is ${s}; a number of px above 0 is required`);
381
+ else if (keepRadius(s) < 1) bad(field, `is ${s}; its keep radius ${KEEP_FRACTION} x ${s} px snaps to 0 grid units of 1/${GRID} px, and at least 1 is required`);
382
+ else if (keepRadius(s) > MAX_RADIUS) bad(field, `is ${s}; its keep radius is ${keepRadius(s)} grid units, past the ${MAX_RADIUS} (${MAX_RADIUS / GRID} px) whose square is exact in a double`);
383
+ };
384
+ spacingOk('spacing', p.spacing);
385
+ if (p.budget !== undefined && (!Number.isInteger(p.budget) || p.budget < 3)) bad('budget', `is ${p.budget}; a whole number of vertices, 3 or more, is required`);
386
+ if (p.fitConnectivity !== undefined && p.fitConnectivity !== 4 && p.fitConnectivity !== 8) bad('fitConnectivity', `is ${JSON.stringify(p.fitConnectivity)}; 4 or 8 is required (how the background is flooded when overshoot is measured)`);
387
+ if (p.stray !== undefined && (!Number.isInteger(p.stray) || p.stray < 0)) bad('stray', `is ${p.stray}; a whole number of art pixels, 0 or more, is required (the largest island that may be left out)`);
388
+ ribProblems(part, p, out);
389
+ const names = new Set<string>();
390
+ p.regions.forEach((r, i) => {
391
+ const at = `regions[${i}]`;
392
+ if (typeof r.name !== 'string' || r.name === '') bad(`${at}.name`, `is ${JSON.stringify(r.name)}; a non-empty name is required`);
393
+ else if (names.has(r.name)) bad(`${at}.name`, `"${r.name}" is declared twice; each region needs its own name`);
394
+ else names.add(r.name);
395
+ spacingOk(`${at}.spacing`, r.spacing);
396
+ if (!finite(r.band) || r.band < 0) bad(`${at}.band`, `is ${r.band}; a number of px, 0 or more, is required`);
397
+ if (r.shape === 'circle') {
398
+ if (!finite(r.cx) || !finite(r.cy)) bad(`${at}`, `centre (${r.cx}, ${r.cy}); finite px are required`);
399
+ if (!finite(r.r) || r.r <= 0) bad(`${at}.r`, `is ${r.r}; a radius of px above 0 is required`);
400
+ } else if (r.shape === 'polygon') {
401
+ if (!Array.isArray(r.points) || r.points.length < 3 || !r.points.every((q) => Array.isArray(q) && q.length === 2 && finite(q[0]) && finite(q[1]))) {
402
+ bad(`${at}.points`, `has ${Array.isArray(r.points) ? r.points.length : 'no'} point(s); 3 or more [x, y] pairs of finite px are required`);
403
+ } else {
404
+ const ring = r.points.map(([x, y]) => [x, y] as [number, number]);
405
+ const crossing = findSelfIntersection(ring);
406
+ if (Math.abs(signedArea(ring)) === 0) bad(`${at}.points`, 'enclose no area; a simple polygon is required');
407
+ else if (crossing !== null) bad(`${at}.points`, `edge ${crossing[0]} meets edge ${crossing[1]}; a simple polygon (no edge touching another) is required`);
408
+ else if (finite(r.band) && r.band > 0 && findSelfIntersection(bandRing(ring, r.band)) !== null) {
409
+ const c = findSelfIntersection(bandRing(ring, r.band)) as [number, number];
410
+ bad(`${at}.band`, `${r.band} px pushed out of the polygon by rigc's offsetPolygon crosses itself (edge ${c[0]} meets edge ${c[1]}); a band the polygon can be offset by without crossing is required`);
411
+ }
412
+ }
413
+ } else {
414
+ bad(`${at}.shape`, `is ${JSON.stringify((r as { shape: unknown }).shape)}; "circle" or "polygon" is required`);
415
+ }
416
+ });
417
+ return out;
418
+ }
419
+
420
+ /** A polygon wound clockwise on screen (positive `signedArea`), rigc's `offsetPolygon`'s requirement. */
421
+ function clockwise(ring: Array<[number, number]>): Array<[number, number]> {
422
+ return signedArea(ring) > 0 ? ring : [...ring].reverse();
423
+ }
424
+
425
+ /** The outer edge of a polygon region's band: rigc's `offsetPolygon` by `band`, on the clockwise ring. */
426
+ function bandRing(ring: Array<[number, number]>, band: number): Array<[number, number]> {
427
+ return offsetPolygon(clockwise(ring), band);
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // exact predicates on grid units
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /** Twice the signed area of a, b, c in grid units: exact in doubles below 2^24 units a difference. */
435
+ function orient(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): number {
436
+ return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
437
+ }
438
+
439
+ /**
440
+ * The in-circle determinant of d against a, b, c, in `BigInt`. Positive when d
441
+ * is strictly inside the circle through a, b, c and `orient(a, b, c) > 0`;
442
+ * zero when the four are cocircular.
443
+ */
444
+ export function inCircle(ax: number, ay: number, bx: number, by: number, cx: number, cy: number, dx: number, dy: number): bigint {
445
+ const adx = BigInt(ax - dx);
446
+ const ady = BigInt(ay - dy);
447
+ const bdx = BigInt(bx - dx);
448
+ const bdy = BigInt(by - dy);
449
+ const cdx = BigInt(cx - dx);
450
+ const cdy = BigInt(cy - dy);
451
+ return (
452
+ (adx * adx + ady * ady) * (bdx * cdy - cdx * bdy) +
453
+ (bdx * bdx + bdy * bdy) * (cdx * ady - adx * cdy) +
454
+ (cdx * cdx + cdy * cdy) * (adx * bdy - bdx * ady)
455
+ );
456
+ }
457
+
458
+ /** Is the squared distance from p to the segment a–b at least `r2`? Exact, in grid units. */
459
+ function farFromSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number, r2: number): boolean {
460
+ const dx = bx - ax;
461
+ const dy = by - ay;
462
+ const vx = px - ax;
463
+ const vy = py - ay;
464
+ const along = vx * dx + vy * dy;
465
+ const len2 = dx * dx + dy * dy;
466
+ if (along <= 0) return vx * vx + vy * vy >= r2;
467
+ if (along >= len2) return (px - bx) * (px - bx) + (py - by) * (py - by) >= r2;
468
+ // Inside the span: distance² = cross² / len2, compared without dividing.
469
+ const cross = BigInt(dx * vy - dy * vx);
470
+ return cross * cross >= BigInt(r2) * BigInt(len2);
471
+ }
472
+
473
+ /** Is p strictly inside the closed ring (even–odd crossing rule, exact on grid units)? A point on an edge is not asked. */
474
+ function insideRing(px: number, py: number, xs: readonly number[], ys: readonly number[]): boolean {
475
+ let inside = false;
476
+ const n = xs.length;
477
+ for (let i = 0, j = n - 1; i < n; j = i++) {
478
+ const ay = ys[j];
479
+ const by = ys[i];
480
+ if (ay > py === by > py) continue;
481
+ const o = orient(xs[j], ay, xs[i], by, px, py);
482
+ if (by > ay ? o > 0 : o < 0) inside = !inside;
483
+ }
484
+ return inside;
485
+ }
486
+
487
+ /** The same rule in plain doubles, for a region's own shape (a membership test, not topology). */
488
+ function insideFloatRing(px: number, py: number, ring: ReadonlyArray<readonly [number, number]>): boolean {
489
+ let inside = false;
490
+ const n = ring.length;
491
+ for (let i = 0, j = n - 1; i < n; j = i++) {
492
+ const [ax, ay] = ring[j];
493
+ const [bx, by] = ring[i];
494
+ if (ay > py === by > py) continue;
495
+ const o = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
496
+ if (by > ay ? o > 0 : o < 0) inside = !inside;
497
+ }
498
+ return inside;
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // interior candidates
503
+ // ---------------------------------------------------------------------------
504
+
505
+ /** One candidate interior point: grid units, the keep radius it is held to, and where it was declared. */
506
+ export interface Candidate {
507
+ x: number;
508
+ y: number;
509
+ radius: number;
510
+ source: string;
511
+ }
512
+
513
+ const snap = (v: number): number => Math.round(v * GRID);
514
+
515
+ /** Points along a closed ring, `ceil(edge length / spacing)` per edge (at least 1), starting at each edge's first vertex. */
516
+ function alongRing(ring: ReadonlyArray<readonly [number, number]>, spacing: number): Array<[number, number]> {
517
+ const out: Array<[number, number]> = [];
518
+ for (let k = 0; k < ring.length; k++) {
519
+ const [ax, ay] = ring[k];
520
+ const [bx, by] = ring[(k + 1) % ring.length];
521
+ const n = Math.max(1, Math.ceil(Math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay)) / spacing));
522
+ for (let m = 0; m < n; m++) out.push([ax + ((bx - ax) * m) / n, ay + ((by - ay) * m) / n]);
523
+ }
524
+ return out;
525
+ }
526
+
527
+ /** `max(3, ceil(2πr / spacing))` points round a circle, from angle 0 (+x), increasing (clockwise on screen). */
528
+ function alongCircle(cx: number, cy: number, r: number, spacing: number): Array<[number, number]> {
529
+ const n = Math.max(3, Math.ceil((2 * Math.PI * r) / spacing));
530
+ const out: Array<[number, number]> = [];
531
+ for (let k = 0; k < n; k++) {
532
+ const t = (2 * Math.PI * k) / n;
533
+ out.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]);
534
+ }
535
+ return out;
536
+ }
537
+
538
+ /** The grid points `(i·s, j·s)` inside a box, row-major. */
539
+ function gridIn(x0: number, y0: number, x1: number, y1: number, s: number): Array<[number, number]> {
540
+ const out: Array<[number, number]> = [];
541
+ for (let j = Math.ceil(y0 / s); j * s <= y1; j++) for (let i = Math.ceil(x0 / s); i * s <= x1; i++) out.push([i * s, j * s]);
542
+ return out;
543
+ }
544
+
545
+ /**
546
+ * Every candidate interior point, in the order the keep rule tries them:
547
+ *
548
+ * 1. each region, in the order declared: points along its boundary at its
549
+ * spacing; then points along the outer edge of its band at its spacing
550
+ * (when the band is above 0); then the points `(i·s, j·s)` of its own
551
+ * spacing's grid, anchored at the part image's origin, that lie in the
552
+ * region or its band, row-major. A circle's band edge is the circle of
553
+ * radius `r + band`; a polygon's is rigc's `offsetPolygon` of it by `band`
554
+ * (mitred, clamped), and "in the band" means inside that ring;
555
+ * 2. the background: `(i·s, j·s)` for the background spacing over the whole
556
+ * part image, row-major.
557
+ *
558
+ * Each is snapped to the {@link GRID}; nothing here looks at the art.
559
+ */
560
+ export function interiorCandidates(width: number, height: number, params: ContourParams): Candidate[] {
561
+ const out: Candidate[] = [];
562
+ const add = (pts: Array<[number, number]>, spacing: number, source: string): void => {
563
+ const radius = keepRadius(spacing);
564
+ for (const [x, y] of pts) out.push({ x: snap(x), y: snap(y), radius, source });
565
+ };
566
+ for (const r of params.regions) {
567
+ const source = `region "${r.name}"`;
568
+ if (r.shape === 'circle') {
569
+ const outer = r.r + r.band;
570
+ add(alongCircle(r.cx, r.cy, r.r, r.spacing), r.spacing, source);
571
+ if (r.band > 0) add(alongCircle(r.cx, r.cy, outer, r.spacing), r.spacing, source);
572
+ add(
573
+ gridIn(r.cx - outer, r.cy - outer, r.cx + outer, r.cy + outer, r.spacing).filter(([x, y]) => (x - r.cx) * (x - r.cx) + (y - r.cy) * (y - r.cy) <= outer * outer),
574
+ r.spacing,
575
+ source,
576
+ );
577
+ } else {
578
+ const ring = r.points.map(([x, y]) => [x, y] as [number, number]);
579
+ const edge = r.band > 0 ? bandRing(ring, r.band) : ring;
580
+ add(alongRing(ring, r.spacing), r.spacing, source);
581
+ if (r.band > 0) add(alongRing(edge, r.spacing), r.spacing, source);
582
+ const xs = edge.map((q) => q[0]);
583
+ const ys = edge.map((q) => q[1]);
584
+ add(gridIn(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys), r.spacing).filter(([x, y]) => insideFloatRing(x, y, edge)), r.spacing, source);
585
+ }
586
+ }
587
+ add(gridIn(0, 0, width, height, params.spacing), params.spacing, 'background');
588
+ return out;
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // the outline inside a region's support
593
+ // ---------------------------------------------------------------------------
594
+
595
+ /** The greatest common divisor of two whole numbers, by Euclid. */
596
+ function gcd(a: number, b: number): number {
597
+ let x = Math.abs(a);
598
+ let y = Math.abs(b);
599
+ while (y !== 0) [x, y] = [y, x % y];
600
+ return x;
601
+ }
602
+
603
+ /**
604
+ * The parameter intervals `[t0, t1]` (0 ≤ t0 < t1 ≤ 1) over which the segment
605
+ * a → b (px) lies in a region's support — the region and its band, the set
606
+ * {@link interiorCandidates} fills: for a circle the disc of radius `r + band`;
607
+ * for a polygon the inside of its band ring (rigc's `offsetPolygon` by `band`),
608
+ * or of the polygon itself when the band is 0.
609
+ */
610
+ function supportIntervals(ax: number, ay: number, bx: number, by: number, r: ContourRegion): Array<[number, number]> {
611
+ const dx = bx - ax;
612
+ const dy = by - ay;
613
+ if (r.shape === 'circle') {
614
+ const R = r.r + r.band;
615
+ const fx = ax - r.cx;
616
+ const fy = ay - r.cy;
617
+ const A = dx * dx + dy * dy;
618
+ const B = 2 * (fx * dx + fy * dy);
619
+ const disc = B * B - 4 * A * (fx * fx + fy * fy - R * R);
620
+ if (disc <= 0) return [];
621
+ const sq = Math.sqrt(disc);
622
+ const t0 = Math.max(0, (-B - sq) / (2 * A));
623
+ const t1 = Math.min(1, (-B + sq) / (2 * A));
624
+ return t0 < t1 ? [[t0, t1]] : [];
625
+ }
626
+ const poly = r.points.map(([x, y]) => [x, y] as [number, number]);
627
+ const ring = r.band > 0 ? bandRing(poly, r.band) : poly;
628
+ const ts = [0, 1];
629
+ for (let k = 0; k < ring.length; k++) {
630
+ const [cx, cy] = ring[k];
631
+ const [ex, ey] = ring[(k + 1) % ring.length];
632
+ const den = dx * (ey - cy) - dy * (ex - cx);
633
+ if (den === 0) continue;
634
+ const t = ((cx - ax) * (ey - cy) - (cy - ay) * (ex - cx)) / den;
635
+ const u = ((cx - ax) * dy - (cy - ay) * dx) / den;
636
+ if (t > 0 && t < 1 && u >= 0 && u <= 1) ts.push(t);
637
+ }
638
+ ts.sort((p, q) => p - q);
639
+ const out: Array<[number, number]> = [];
640
+ for (let k = 0; k + 1 < ts.length; k++) {
641
+ const [u, v] = [ts[k], ts[k + 1]];
642
+ if (!(u < v) || !insideFloatRing(ax + ((u + v) / 2) * dx, ay + ((u + v) / 2) * dy, ring)) continue;
643
+ const last = out[out.length - 1];
644
+ if (last !== undefined && last[1] === u) last[1] = v;
645
+ else out.push([u, v]);
646
+ }
647
+ return out;
648
+ }
649
+
650
+ /**
651
+ * The outline with points inserted where it passes through a region's support
652
+ * (issue #110). A region's weight ramps from 1 to 0 across its band; where an
653
+ * outline edge crosses that ramp, the art beside the edge is held by the
654
+ * edge's two ends alone (no interior point may sit within its keep radius of
655
+ * the outline), so one long edge with one end at g = 1 and the other at g = 0
656
+ * spreads the region's motion over its whole length. Measured on a public
657
+ * part (demo/hair_front, issue #110): a 35 px edge through a radius-11 region
658
+ * held the worst local pixel in every pose. The outline is therefore sampled
659
+ * there the way the region's own boundary is:
660
+ *
661
+ * - **On the edge, exactly.** The outline's vertices are on the {@link GRID};
662
+ * the grid points ON an edge a → b are `a + j (b − a) / G`, j = 0..G, with
663
+ * G the greatest common divisor of the edge's two grid-unit differences.
664
+ * Every point inserted is one of them, so it is collinear with its edge
665
+ * exactly and the outline encloses the same set as before: coverage,
666
+ * overshoot, area and every interior point's keep test are unchanged by it.
667
+ * - **Rule.** For each outline edge, in walk order, and each region, in the
668
+ * order declared: the intervals `[t0, t1]` of the edge inside the region's
669
+ * support ({@link supportIntervals}), their ends taken to the nearest grid
670
+ * points on the edge, `j0 = round(t0 G)` and `j1 = round(t1 G)`; that span
671
+ * is cut into `k = max(1, ceil(length × (j1 − j0) / G / spacing))` equal
672
+ * parts at the region's spacing (as {@link interiorCandidates} samples a
673
+ * region's boundary, `ceil(length / spacing)` per edge), and every cut
674
+ * `j0 + round(m (j1 − j0) / k)`, m = 0..k, strictly between the edge's
675
+ * ends (0 < j < G) is a candidate. Reading the span on the grid first makes
676
+ * the count exact where the span is: on an axis-aligned edge a 16 px span at
677
+ * spacing 4 is 4 parts, not 5 because a square root rounded up (selftest
678
+ * `CE10`; the mutant that counts the float span is caught there).
679
+ * - **Kept** by the keep rule's own radius ({@link KEEP_FRACTION} × the
680
+ * region's spacing): a candidate at least that far from the edge's two ends
681
+ * and from every point already inserted on the outline, in walk order and,
682
+ * on one edge, in order of `j` (regions in declared order at a tie).
683
+ *
684
+ * What is not exact: the intervals use `Math.sqrt` (a circle) or rigc's
685
+ * `offsetPolygon` (a polygon's band), so an engine that rounds a square root
686
+ * differently could move a span's end by one grid step, and so a cut, or the
687
+ * count `k` where `length × span / spacing` lies within an ulp of a whole
688
+ * number on a slanted edge (`length` is a square root there); every position written is
689
+ * an exact grid point on the edge whatever `j` is. **The hull's order:** every
690
+ * vertex of the outline without regions is in the outline with them, in the
691
+ * same cyclic order at the same position, and the first one (index 0) is
692
+ * first; an inserted point sits between the two ends of its edge. A hull
693
+ * vertex is renumbered only by the inserted points before it in walk order —
694
+ * none when no region's support reaches the outline, which then is the same
695
+ * list (selftest `CE14`).
696
+ */
697
+ export function outlineInRegions(outline: ReadonlyArray<readonly [number, number]>, regions: readonly ContourRegion[]): Array<[number, number]> {
698
+ const out: Array<[number, number]> = [];
699
+ const placed: Array<{ x: number; y: number }> = [];
700
+ const n = outline.length;
701
+ for (let i = 0; i < n; i++) {
702
+ const [ax, ay] = outline[i];
703
+ const [bx, by] = outline[(i + 1) % n];
704
+ out.push([ax, ay]);
705
+ if (regions.length === 0) continue;
706
+ const A = { x: snap(ax), y: snap(ay) };
707
+ const B = { x: snap(bx), y: snap(by) };
708
+ const G = gcd(B.x - A.x, B.y - A.y);
709
+ if (G === 0) continue;
710
+ const length = Math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay));
711
+ const cands: Array<{ j: number; radius: number }> = [];
712
+ for (const r of regions) {
713
+ const radius = keepRadius(r.spacing);
714
+ for (const [t0, t1] of supportIntervals(ax, ay, bx, by, r)) {
715
+ const j0 = Math.round(t0 * G);
716
+ const j1 = Math.round(t1 * G);
717
+ if (j1 <= j0) continue;
718
+ const k = Math.max(1, Math.ceil(((j1 - j0) * length) / G / r.spacing));
719
+ for (let m = 0; m <= k; m++) {
720
+ const j = j0 + Math.round((m * (j1 - j0)) / k);
721
+ if (j > 0 && j < G) cands.push({ j, radius });
722
+ }
723
+ }
724
+ }
725
+ cands.sort((p, q) => p.j - q.j);
726
+ for (const c of cands) {
727
+ const x = A.x + (c.j * (B.x - A.x)) / G;
728
+ const y = A.y + (c.j * (B.y - A.y)) / G;
729
+ const r2 = c.radius * c.radius;
730
+ const far = (q: { x: number; y: number }): boolean => (q.x - x) * (q.x - x) + (q.y - y) * (q.y - y) >= r2;
731
+ if (!far(A) || !far(B) || !placed.every(far)) continue;
732
+ placed.push({ x, y });
733
+ out.push([x / GRID, y / GRID]);
734
+ }
735
+ }
736
+ return out;
737
+ }
738
+
739
+ /**
740
+ * The keep rule ({@link KEEP_FRACTION}) over the candidates in order, against
741
+ * an outline in grid units. Exact: inside by the crossing rule on integers,
742
+ * distances compared squared, in `BigInt` where a product could pass 2^53.
743
+ */
744
+ export function keepPoints(hx: readonly number[], hy: readonly number[], candidates: readonly Candidate[], ribs: ReadonlyArray<readonly [number, number, number, number]> = []): Candidate[] {
745
+ const kept: Candidate[] = [];
746
+ const n = hx.length;
747
+ for (const c of candidates) {
748
+ const r2 = c.radius * c.radius;
749
+ if (!insideRing(c.x, c.y, hx, hy)) continue;
750
+ let ok = true;
751
+ for (let i = 0; i < n && ok; i++) ok = farFromSegment(c.x, c.y, hx[i], hy[i], hx[(i + 1) % n], hy[(i + 1) % n], r2);
752
+ // issue #188: a rib is held like the outline — no candidate within its keep radius of one, so nothing sits on a rib's row.
753
+ for (let i = 0; i < ribs.length && ok; i++) ok = farFromSegment(c.x, c.y, ribs[i][0], ribs[i][1], ribs[i][2], ribs[i][3], r2);
754
+ for (let k = 0; k < kept.length && ok; k++) {
755
+ const dx = c.x - kept[k].x;
756
+ const dy = c.y - kept[k].y;
757
+ ok = dx * dx + dy * dy >= r2;
758
+ }
759
+ if (ok) kept.push(c);
760
+ }
761
+ return kept;
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // ribs along a chain (issue #188)
766
+ // ---------------------------------------------------------------------------
767
+
768
+ /**
769
+ * The declared ribs' own problems, before anything is traced: the shape of
770
+ * each entry (`CONTOUR_PARAMETER`), and each link that cannot carry a rib
771
+ * (`CONTOUR_RIB_LINK`) — one shorter than the outline's `tolerance` (or of no
772
+ * length), where a row would sit within the outline's own simplification error
773
+ * of the next, and a joint where the chain turns straight back on itself,
774
+ * which has no direction across it.
775
+ */
776
+ function ribProblems(part: string, p: ContourParams, out: Problem[]): void {
777
+ const object = `contour mesh "${part}"`;
778
+ const names = new Set<string>();
779
+ (p.ribs ?? []).forEach((rb, i) => {
780
+ const at = `${object}, ribs[${i}]`;
781
+ if (typeof rb.chain !== 'string' || rb.chain === '' || names.has(rb.chain)) {
782
+ out.push({ code: 'CONTOUR_PARAMETER', object: `${at}.chain`, detail: `is ${JSON.stringify(rb.chain)}; a chain name, non-empty and given once, is required` });
783
+ } else names.add(rb.chain);
784
+ if (!Number.isInteger(rb.stations) || rb.stations < 0) out.push({ code: 'CONTOUR_PARAMETER', object: `${at}.stations`, detail: `is ${rb.stations}; a whole number, 0 or more, is required (the ribs between a link's origin and its end)` });
785
+ const pts = rb.points;
786
+ if (!Array.isArray(pts) || pts.length < 2 || !pts.every((q) => Array.isArray(q) && q.length === 2 && finite(q[0]) && finite(q[1]))) {
787
+ out.push({ code: 'CONTOUR_PARAMETER', object: `${at}.points`, detail: `has ${Array.isArray(pts) ? pts.length : 'no'} point(s); the chain's points and its tip, 2 or more finite [x, y] pairs, are required` });
788
+ return;
789
+ }
790
+ const unit: Array<[number, number]> = [];
791
+ for (let k = 0; k + 1 < pts.length; k++) {
792
+ const dx = pts[k + 1][0] - pts[k][0];
793
+ const dy = pts[k + 1][1] - pts[k][1];
794
+ const len = Math.sqrt(dx * dx + dy * dy);
795
+ unit.push(len > 0 ? [dx / len, dy / len] : [0, 0]);
796
+ if (!(len > 0) || len < p.tolerance) {
797
+ out.push({
798
+ code: 'CONTOUR_RIB_LINK',
799
+ object: `${object}, chain "${rb.chain}" link ${k}`,
800
+ detail: `runs ${len} px from (${pts[k][0]}, ${pts[k][1]}) to ${k + 2 < pts.length ? `points[${k + 1}]` : 'the tip'} (${pts[k + 1][0]}, ${pts[k + 1][1]}); a link longer than 0 and at least the outline tolerance ${p.tolerance} px (contour.tolerance, or auto.source.tolerance) is required — a row of vertices closer than that to the next sits inside the outline's own simplification error`,
801
+ });
802
+ }
803
+ }
804
+ for (let k = 1; k < unit.length; k++) {
805
+ if (unit[k - 1][0] + unit[k][0] === 0 && unit[k - 1][1] + unit[k][1] === 0 && (unit[k][0] !== 0 || unit[k][1] !== 0)) {
806
+ out.push({ code: 'CONTOUR_RIB_LINK', object: `${object}, chain "${rb.chain}" link ${k}`, detail: `turns straight back along link ${k - 1} at (${pts[k][0]}, ${pts[k][1]}), so the joint has no direction across it; a chain that does not fold back on itself is required` });
807
+ }
808
+ }
809
+ });
810
+ }
811
+
812
+ /** Do the closed segments a–b and c–d (grid units) meet — cross or touch? Exact: orientations of grid integers. */
813
+ function segmentsMeet(ax: number, ay: number, bx: number, by: number, cx: number, cy: number, dx: number, dy: number): boolean {
814
+ const o1 = Math.sign(orient(ax, ay, bx, by, cx, cy));
815
+ const o2 = Math.sign(orient(ax, ay, bx, by, dx, dy));
816
+ const o3 = Math.sign(orient(cx, cy, dx, dy, ax, ay));
817
+ const o4 = Math.sign(orient(cx, cy, dx, dy, bx, by));
818
+ if (o1 * o2 < 0 && o3 * o4 < 0) return true;
819
+ const on = (px: number, py: number, qx: number, qy: number, rx: number, ry: number): boolean => Math.min(px, qx) <= rx && rx <= Math.max(px, qx) && Math.min(py, qy) <= ry && ry <= Math.max(py, qy);
820
+ return (o1 === 0 && on(ax, ay, bx, by, cx, cy)) || (o2 === 0 && on(ax, ay, bx, by, dx, dy)) || (o3 === 0 && on(cx, cy, dx, dy, ax, ay)) || (o4 === 0 && on(cx, cy, dx, dy, bx, by));
821
+ }
822
+
823
+ /** Where the ribs went (issue #188): the outline with their ends on it, their interior points, and their rows. */
824
+ export interface RibPlacement {
825
+ /** The outline (px) with every rib's two ends inserted on the edge each lands on. */
826
+ outline: Array<[number, number]>;
827
+ /** The ribs' interior points, grid units, in rib order: they are the first interior vertices, from index `outline.length`. */
828
+ points: Candidate[];
829
+ /** Each rib's segment from end to end, grid units: no other interior point is kept within its keep radius of one. */
830
+ segments: Array<[number, number, number, number]>;
831
+ /** Each rib's vertices, end to end, in the mesh's vertex indices. */
832
+ ribs: ContourRib[];
833
+ }
834
+
835
+ /**
836
+ * The ribs of every declared chain (issue #188): a row of vertices across the
837
+ * part at each link joint and at `stations` points between, placed on the
838
+ * outline the mode traced. **The rule**, in part-image px (y down):
839
+ *
840
+ * 1. **Where.** Link k runs from `points[k]` to `points[k + 1]` (the last to
841
+ * the tip). Each link carries a rib at its origin (station 0) and at
842
+ * `points[k] + m/(stations + 1) · (points[k + 1] − points[k])`, m =
843
+ * 1..stations — so a chain of L links carries L × (stations + 1) ribs, in
844
+ * chain order, then link, then station. The tip carries none: it is the
845
+ * strand's end, which the outline already holds.
846
+ * 2. **Across.** The rib's direction d is the link's at a station and at the
847
+ * chain's root; at a joint between two links it is the sum of the two
848
+ * links' unit directions, the bisector of the bend. The rib is the line
849
+ * through the point along n = (−d_y, d_x) / |d|.
850
+ * 3. **Clipped to the art.** The point must be on the part's art — its pixel
851
+ * (the floor of each coordinate) has alpha above the threshold — and
852
+ * strictly inside the outline (`CONTOUR_RIB_ART` otherwise: a rib that
853
+ * leaves the art entirely). From it the line runs both ways to the first
854
+ * point where it meets the outline (an edge or a vertex); those two points,
855
+ * snapped to the {@link GRID}, are the rib's ends. The outline is the art
856
+ * at the mode's threshold, holes filled, grown by the margin and simplified
857
+ * at the tolerance; a rib's ends are on it because a vertex off it would be
858
+ * no part of the mesh's boundary.
859
+ * 4. **The ends join the outline.** Each end is inserted between the two ends
860
+ * of the outline edge it lands on (in order along the edge), or is that
861
+ * edge's vertex when it snaps onto one. Snapping moves an end off its edge
862
+ * by at most half a grid unit, 1/512 px; the outline is then held to every
863
+ * check it always was (self-intersection here, coverage and overshoot on the
864
+ * mesh), so a move that mattered is refused, not hidden.
865
+ * 5. **Between the ends**, at the mode's background spacing s: the rib's own
866
+ * point (the joint or the station, snapped to the grid) is an interior
867
+ * vertex, and each half — from an end to the point — of length ℓ is cut
868
+ * into k = max(1, ⌈ℓ / s⌉) equal parts, whose k − 1 cuts, each snapped,
869
+ * are interior vertices too. So the joint itself is on its row, where the
870
+ * two links' weights meet. Each must lie inside the outline at least one
871
+ * grid unit from every outline edge (`CONTOUR_RIB_ART` otherwise).
872
+ * 6. **No two ribs meet** — cross, touch or share an end, ends as snapped
873
+ * (`CONTOUR_RIB_CROSSING`, naming both): two rows through one point are no
874
+ * longer two cross-sections.
875
+ *
876
+ * The triangulation then makes every consecutive pair of a rib an edge and
877
+ * never flips it ({@link Triangulation.constrain}), and keeps every other
878
+ * interior point at least its keep radius from every rib ({@link keepPoints}),
879
+ * so each rib is a row of edges across the strand. Nothing here reads a weight.
880
+ */
881
+ export function placeRibs(part: string, outline: ReadonlyArray<readonly [number, number]>, mask: AlphaMask, art: Mask, params: ContourParams): RibPlacement | Problem[] {
882
+ const object = `contour mesh "${part}"`;
883
+ const out: Problem[] = [];
884
+ const n = outline.length;
885
+ const hx = outline.map((q) => snap(q[0]));
886
+ const hy = outline.map((q) => snap(q[1]));
887
+ interface Hit { edge: number; u: number; x: number; y: number }
888
+ interface Planned { name: string; qx: number; qy: number; lo: Hit; hi: Hit }
889
+ const planned: Planned[] = [];
890
+ for (const rb of params.ribs ?? []) {
891
+ const P = rb.points;
892
+ const unit = (k: number): [number, number] => {
893
+ const dx = P[k + 1][0] - P[k][0];
894
+ const dy = P[k + 1][1] - P[k][1];
895
+ const len = Math.sqrt(dx * dx + dy * dy);
896
+ return [dx / len, dy / len];
897
+ };
898
+ for (let k = 0; k + 1 < P.length; k++) {
899
+ for (let m = 0; m <= rb.stations; m++) {
900
+ const name = `rib ${rb.chain} link ${k} station ${m}`;
901
+ let qx: number;
902
+ let qy: number;
903
+ let dx: number;
904
+ let dy: number;
905
+ if (m === 0) {
906
+ [qx, qy] = [P[k][0], P[k][1]];
907
+ if (k === 0) [dx, dy] = unit(0);
908
+ else {
909
+ const a = unit(k - 1);
910
+ const b = unit(k);
911
+ [dx, dy] = [a[0] + b[0], a[1] + b[1]];
912
+ }
913
+ } else {
914
+ const t = m / (rb.stations + 1);
915
+ [qx, qy] = [P[k][0] + (P[k + 1][0] - P[k][0]) * t, P[k][1] + (P[k + 1][1] - P[k][1]) * t];
916
+ [dx, dy] = unit(k);
917
+ }
918
+ const dl = Math.sqrt(dx * dx + dy * dy);
919
+ const nx = -dy / dl;
920
+ const ny = dx / dl;
921
+ const px = Math.floor(qx);
922
+ const py = Math.floor(qy);
923
+ const inWindow = px >= 0 && py >= 0 && px < art.width && py < art.height;
924
+ const onArt = inWindow && art.data[py * art.width + px] !== 0;
925
+ if (!onArt || !insideRing(snap(qx), snap(qy), hx, hy)) {
926
+ out.push({
927
+ code: 'CONTOUR_RIB_ART',
928
+ object: `${object}, ${name}`,
929
+ detail: `${m === 0 ? (k === 0 ? "the chain's root" : 'the joint') : `station ${m} of the link`} is at (${qx}, ${qy}) in the part image (${inWindow ? `pixel (${px}, ${py}), alpha ${mask.alpha[py * art.width + px]}` : `outside its ${art.width}x${art.height} px`})${onArt ? ', on the art but not inside the outline' : `, not art (alpha above ${params.threshold})`}; a rib is the part's cross-section through the chain, so its point must lie on the part's art, inside the outline — move the chain onto the part, or leave the chain "${rb.chain}" out of ribs`,
930
+ });
931
+ continue;
932
+ }
933
+ let lo: Hit | null = null;
934
+ let hi: Hit | null = null;
935
+ let loS = -Infinity;
936
+ let hiS = Infinity;
937
+ for (let i = 0; i < n; i++) {
938
+ const [ax, ay] = outline[i];
939
+ const [bx, by] = outline[(i + 1) % n];
940
+ const ex = bx - ax;
941
+ const ey = by - ay;
942
+ const den = nx * ey - ny * ex;
943
+ if (den === 0) continue;
944
+ const wx = ax - qx;
945
+ const wy = ay - qy;
946
+ const s = (wx * ey - wy * ex) / den;
947
+ const u = (wx * ny - wy * nx) / den;
948
+ if (u < 0 || u > 1 || s === 0) continue;
949
+ if (s > 0 && s < hiS) [hiS, hi] = [s, { edge: i, u, x: snap(qx + s * nx), y: snap(qy + s * ny) }];
950
+ if (s < 0 && s > loS) [loS, lo] = [s, { edge: i, u, x: snap(qx + s * nx), y: snap(qy + s * ny) }];
951
+ }
952
+ if (lo === null || hi === null) {
953
+ out.push({ code: 'CONTOUR_RIB_ART', object: `${object}, ${name}`, detail: `the line across the chain at (${qx}, ${qy}) meets the outline on ${lo === null && hi === null ? 'neither side' : 'one side only'}; a point inside the outline is required` });
954
+ continue;
955
+ }
956
+ planned.push({ name, qx: snap(qx), qy: snap(qy), lo, hi });
957
+ }
958
+ }
959
+ }
960
+ if (out.length > 0) return out;
961
+ for (let i = 0; i < planned.length; i++) {
962
+ for (let j = i + 1; j < planned.length; j++) {
963
+ const a = planned[i];
964
+ const b = planned[j];
965
+ if (segmentsMeet(a.lo.x, a.lo.y, a.hi.x, a.hi.y, b.lo.x, b.lo.y, b.hi.x, b.hi.y)) {
966
+ out.push({
967
+ code: 'CONTOUR_RIB_CROSSING',
968
+ object: `${object}, ${a.name}`,
969
+ detail: `from (${a.lo.x / GRID}, ${a.lo.y / GRID}) to (${a.hi.x / GRID}, ${a.hi.y / GRID}) meets ${b.name}, from (${b.lo.x / GRID}, ${b.lo.y / GRID}) to (${b.hi.x / GRID}, ${b.hi.y / GRID}); ribs that cross, touch or share an end are required to be apart — two rows through one point are not two cross-sections (fewer stations, or a chain that bends less sharply inside a wide part)`,
970
+ });
971
+ }
972
+ }
973
+ }
974
+ if (out.length > 0) return out;
975
+
976
+ // The ends onto the outline, edge by edge, in order along each edge (rib, then the lo end first, at a tie).
977
+ const onEdge: Array<Array<{ u: number; x: number; y: number; rib: number; side: 0 | 1 }>> = Array.from({ length: n }, () => []);
978
+ planned.forEach((r, k) => {
979
+ onEdge[r.lo.edge].push({ u: r.lo.u, x: r.lo.x, y: r.lo.y, rib: k, side: 0 });
980
+ onEdge[r.hi.edge].push({ u: r.hi.u, x: r.hi.x, y: r.hi.y, rib: k, side: 1 });
981
+ });
982
+ const next: Array<[number, number]> = [];
983
+ const start: number[] = [];
984
+ const endAt = planned.map(() => [-1, -1]);
985
+ const toNext: Array<{ rib: number; side: 0 | 1; edge: number }> = [];
986
+ for (let i = 0; i < n; i++) {
987
+ start.push(next.length);
988
+ next.push([outline[i][0], outline[i][1]]);
989
+ const list = onEdge[i].sort((p, q) => p.u - q.u || p.rib - q.rib || p.side - q.side);
990
+ for (const e of list) {
991
+ const j = (i + 1) % n;
992
+ if (e.x === hx[i] && e.y === hy[i]) endAt[e.rib][e.side] = start[i];
993
+ else if (e.x === hx[j] && e.y === hy[j]) toNext.push({ rib: e.rib, side: e.side, edge: j });
994
+ else {
995
+ endAt[e.rib][e.side] = next.length;
996
+ next.push([e.x / GRID, e.y / GRID]);
997
+ }
998
+ }
999
+ }
1000
+ for (const t of toNext) endAt[t.rib][t.side] = start[t.edge];
1001
+ if (signedArea(next) <= 0 || findSelfIntersection(next) !== null) {
1002
+ const c = findSelfIntersection(next);
1003
+ return [{ code: 'CONTOUR_SELF_INTERSECTION', object, detail: `with the ribs' ends put on it, its outline ${c === null ? 'encloses no positive area' : `edge ${c[0]} meets edge ${c[1]}`}; an outline that touches itself nowhere is required — each end moves at most 1/512 px off its edge, so fewer stations or a lower tolerance` }];
1004
+ }
1005
+ const H = next.length;
1006
+ const nx = next.map((q) => snap(q[0]));
1007
+ const ny = next.map((q) => snap(q[1]));
1008
+ const points: Candidate[] = [];
1009
+ const segments: Array<[number, number, number, number]> = [];
1010
+ const ribs: ContourRib[] = [];
1011
+ const radius = keepRadius(params.spacing);
1012
+ planned.forEach((r, k) => {
1013
+ const vertices = [endAt[k][0]];
1014
+ // The rib's own point, then each half cut at the spacing: lo end -> point -> hi end, every cut snapped.
1015
+ const along: Array<[number, number]> = [];
1016
+ const half = (ax: number, ay: number, bx: number, by: number, last: boolean): void => {
1017
+ const parts = Math.max(1, Math.ceil(Math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay)) / GRID / params.spacing));
1018
+ for (let j = 1; j < parts; j++) along.push([Math.round(ax + ((bx - ax) * j) / parts), Math.round(ay + ((by - ay) * j) / parts)]);
1019
+ if (!last) along.push([bx, by]);
1020
+ };
1021
+ half(r.lo.x, r.lo.y, r.qx, r.qy, false);
1022
+ half(r.qx, r.qy, r.hi.x, r.hi.y, true);
1023
+ along.forEach(([x, y], j) => {
1024
+ let ok = insideRing(x, y, nx, ny);
1025
+ for (let i = 0; i < H && ok; i++) ok = farFromSegment(x, y, nx[i], ny[i], nx[(i + 1) % H], ny[(i + 1) % H], 1);
1026
+ if (!ok) {
1027
+ out.push({ code: 'CONTOUR_RIB_ART', object: `${object}, ${r.name}`, detail: `its point ${j + 1} of ${along.length} between the ends, (${x / GRID}, ${y / GRID}), is outside the outline or within 1/${GRID} px of it; every point between a rib's ends inside the outline is required` });
1028
+ return;
1029
+ }
1030
+ vertices.push(H + points.length);
1031
+ points.push({ x, y, radius, source: 'ribs' });
1032
+ });
1033
+ vertices.push(endAt[k][1]);
1034
+ segments.push([r.lo.x, r.lo.y, r.hi.x, r.hi.y]);
1035
+ ribs.push({ name: r.name, vertices });
1036
+ });
1037
+ if (out.length > 0) return out;
1038
+ return { outline: next, points, segments, ribs };
1039
+ }
1040
+
1041
+ // ---------------------------------------------------------------------------
1042
+ // the triangulation
1043
+ // ---------------------------------------------------------------------------
1044
+
1045
+ /**
1046
+ * A triangulation under construction: triangles as vertex triples wound with
1047
+ * `orient > 0`, and every directed edge a→b mapped to the triangle that holds
1048
+ * it. The neighbour across a→b is the owner of b→a; an outline edge has none,
1049
+ * which is what makes it unflippable.
1050
+ */
1051
+ class Triangulation {
1052
+ readonly tri: number[] = [];
1053
+ private readonly owner = new Map<number, number>();
1054
+ /** Edges never flipped, beside the outline's (issue #188: the ribs' rows), by their smaller-index-first key. */
1055
+ private readonly locked = new Set<number>();
1056
+
1057
+ constructor(
1058
+ readonly X: readonly number[],
1059
+ readonly Y: readonly number[],
1060
+ ) {}
1061
+
1062
+ private key(a: number, b: number): number {
1063
+ return a * this.X.length + b;
1064
+ }
1065
+
1066
+ get count(): number {
1067
+ return this.tri.length / 3;
1068
+ }
1069
+
1070
+ /** Is the undirected edge a–b one {@link constrain} locked? */
1071
+ isLocked(a: number, b: number): boolean {
1072
+ return this.locked.size > 0 && this.locked.has(this.key(Math.min(a, b), Math.max(a, b)));
1073
+ }
1074
+
1075
+ /** Lock the undirected edge a–b, so no flip ever removes it. */
1076
+ lock(a: number, b: number): void {
1077
+ this.locked.add(this.key(Math.min(a, b), Math.max(a, b)));
1078
+ }
1079
+
1080
+ /**
1081
+ * Make a–b an edge of the triangulation and lock it (issue #188). While it
1082
+ * is not one, the first interior edge in triangle order that crosses the
1083
+ * open segment a–b strictly (exact orientations) and whose two triangles form
1084
+ * a strictly convex quad is flipped (Sloan's edge recovery). Requires that
1085
+ * no vertex lies on the open segment, which the rib rule ensures: every
1086
+ * other interior point is kept its keep radius off a rib, and a rib's ends
1087
+ * are the outline's first contact on each side. Returns false, without
1088
+ * locking, when no crossing edge can be flipped or `limit` flips pass first.
1089
+ */
1090
+ constrain(a: number, b: number, limit: number): boolean {
1091
+ for (let step = 0; step <= limit; step++) {
1092
+ if (this.ownerOf(a, b) !== undefined || this.ownerOf(b, a) !== undefined) {
1093
+ this.lock(a, b);
1094
+ return true;
1095
+ }
1096
+ let flipped = false;
1097
+ for (let t = 0; t < this.count && !flipped; t++) {
1098
+ for (let k = 0; k < 3; k++) {
1099
+ const u = this.tri[3 * t + k];
1100
+ const v = this.tri[3 * t + ((k + 1) % 3)];
1101
+ if (u === a || u === b || v === a || v === b) continue;
1102
+ const o = this.ownerOf(v, u);
1103
+ if (o === undefined || o < t || this.isLocked(u, v)) continue;
1104
+ const s1 = Math.sign(this.orient(a, b, u)) * Math.sign(this.orient(a, b, v));
1105
+ const s2 = Math.sign(this.orient(u, v, a)) * Math.sign(this.orient(u, v, b));
1106
+ if (s1 >= 0 || s2 >= 0) continue;
1107
+ const c = this.third(t, u);
1108
+ const d = this.third(o, v);
1109
+ if (!(this.orient(u, d, c) > 0 && this.orient(v, c, d) > 0)) continue;
1110
+ this.flip(t, o, u, v, c, d);
1111
+ flipped = true;
1112
+ break;
1113
+ }
1114
+ }
1115
+ if (!flipped) return false;
1116
+ }
1117
+ return false;
1118
+ }
1119
+
1120
+ ownerOf(a: number, b: number): number | undefined {
1121
+ return this.owner.get(this.key(a, b));
1122
+ }
1123
+
1124
+ private link(t: number): void {
1125
+ const [a, b, c] = [this.tri[3 * t], this.tri[3 * t + 1], this.tri[3 * t + 2]];
1126
+ this.owner.set(this.key(a, b), t);
1127
+ this.owner.set(this.key(b, c), t);
1128
+ this.owner.set(this.key(c, a), t);
1129
+ }
1130
+
1131
+ private unlink(t: number): void {
1132
+ const [a, b, c] = [this.tri[3 * t], this.tri[3 * t + 1], this.tri[3 * t + 2]];
1133
+ this.owner.delete(this.key(a, b));
1134
+ this.owner.delete(this.key(b, c));
1135
+ this.owner.delete(this.key(c, a));
1136
+ }
1137
+
1138
+ /** Overwrite triangle `t` (or append when `t` is the count). */
1139
+ set(t: number, a: number, b: number, c: number): void {
1140
+ if (t < this.count) this.unlink(t);
1141
+ this.tri[3 * t] = a;
1142
+ this.tri[3 * t + 1] = b;
1143
+ this.tri[3 * t + 2] = c;
1144
+ this.link(t);
1145
+ }
1146
+
1147
+ orient(a: number, b: number, c: number): number {
1148
+ return orient(this.X[a], this.Y[a], this.X[b], this.Y[b], this.X[c], this.Y[c]);
1149
+ }
1150
+
1151
+ /** The vertex of triangle `t` that is not on its directed edge a→b. */
1152
+ third(t: number, a: number): number {
1153
+ const v = [this.tri[3 * t], this.tri[3 * t + 1], this.tri[3 * t + 2]];
1154
+ const k = v.indexOf(a);
1155
+ return v[(k + 2) % 3];
1156
+ }
1157
+
1158
+ /**
1159
+ * Is the edge a→b (of triangle `t`, opposite vertex c) not locally Delaunay —
1160
+ * the vertex d across it strictly inside the circumcircle of a, b, c — and
1161
+ * flippable (a, d, c and b, c, d both strictly wound)? Exact; a tie (d on the
1162
+ * circle) is not a violation.
1163
+ */
1164
+ illegal(a: number, b: number, c: number, d: number): boolean {
1165
+ const { X, Y } = this;
1166
+ if (inCircle(X[a], Y[a], X[b], Y[b], X[c], Y[c], X[d], Y[d]) <= 0n) return false;
1167
+ return this.orient(a, d, c) > 0 && this.orient(b, c, d) > 0;
1168
+ }
1169
+
1170
+ /** Flip the edge a→b shared by t = (a, b, c) and u = (b, a, d) into c–d: t becomes (a, d, c), u becomes (b, c, d). */
1171
+ flip(t: number, u: number, a: number, b: number, c: number, d: number): void {
1172
+ this.unlink(t);
1173
+ this.unlink(u);
1174
+ this.tri[3 * t] = a;
1175
+ this.tri[3 * t + 1] = d;
1176
+ this.tri[3 * t + 2] = c;
1177
+ this.tri[3 * u] = b;
1178
+ this.tri[3 * u + 1] = c;
1179
+ this.tri[3 * u + 2] = d;
1180
+ this.link(t);
1181
+ this.link(u);
1182
+ }
1183
+
1184
+ /** Restore the Delaunay condition around a new point p, from the edges opposite it, last pushed first. */
1185
+ legalize(p: number, edges: Array<[number, number]>): void {
1186
+ while (edges.length > 0) {
1187
+ const [x, y] = edges.pop() as [number, number];
1188
+ const t = this.ownerOf(x, y);
1189
+ const u = this.ownerOf(y, x);
1190
+ if (t === undefined || u === undefined) continue; // an outline edge: never flipped
1191
+ if (this.isLocked(x, y)) continue; // a rib's edge (issue #188): never flipped
1192
+ if (this.third(t, x) !== p) continue; // the edge was flipped away since it was pushed
1193
+ const d = this.third(u, y);
1194
+ if (!this.illegal(x, y, p, d)) continue;
1195
+ this.flip(t, u, x, y, p, d);
1196
+ edges.push([x, d], [d, y]);
1197
+ }
1198
+ }
1199
+
1200
+ /** Flip every interior edge that is not locally Delaunay, scanning in triangle order, until a pass flips none. Returns the flips. */
1201
+ legalizeAll(): number {
1202
+ let flips = 0;
1203
+ for (let changed = true; changed; ) {
1204
+ changed = false;
1205
+ for (let t = 0; t < this.count; t++) {
1206
+ for (let k = 0; k < 3; k++) {
1207
+ const a = this.tri[3 * t + k];
1208
+ const b = this.tri[3 * t + ((k + 1) % 3)];
1209
+ const u = this.ownerOf(b, a);
1210
+ if (u === undefined || u < t || this.isLocked(a, b)) continue;
1211
+ const c = this.third(t, a);
1212
+ const d = this.third(u, b);
1213
+ if (!this.illegal(a, b, c, d)) continue;
1214
+ this.flip(t, u, a, b, c, d);
1215
+ flips++;
1216
+ changed = true;
1217
+ break;
1218
+ }
1219
+ }
1220
+ }
1221
+ return flips;
1222
+ }
1223
+
1224
+ /** Interior edges that are not locally Delaunay — the count the report carries. */
1225
+ violations(): number {
1226
+ let n = 0;
1227
+ for (let t = 0; t < this.count; t++) {
1228
+ for (let k = 0; k < 3; k++) {
1229
+ const a = this.tri[3 * t + k];
1230
+ const b = this.tri[3 * t + ((k + 1) % 3)];
1231
+ const u = this.ownerOf(b, a);
1232
+ if (u === undefined || u < t || this.isLocked(a, b)) continue;
1233
+ const { X, Y } = this;
1234
+ const c = this.third(t, a);
1235
+ const d = this.third(u, b);
1236
+ if (inCircle(X[a], Y[a], X[b], Y[b], X[c], Y[c], X[d], Y[d]) > 0n) n++;
1237
+ }
1238
+ }
1239
+ return n;
1240
+ }
1241
+
1242
+ /**
1243
+ * Insert vertex p: split the first triangle (in index order) that holds it,
1244
+ * in three, or — when it lies on an interior edge — that triangle and its
1245
+ * neighbour across the edge, in four; then legalize.
1246
+ */
1247
+ insert(p: number): void {
1248
+ for (let t = 0; t < this.count; t++) {
1249
+ const a = this.tri[3 * t];
1250
+ const b = this.tri[3 * t + 1];
1251
+ const c = this.tri[3 * t + 2];
1252
+ const o1 = this.orient(a, b, p);
1253
+ const o2 = this.orient(b, c, p);
1254
+ const o3 = this.orient(c, a, p);
1255
+ if (o1 < 0 || o2 < 0 || o3 < 0) continue;
1256
+ const zero = o1 === 0 ? [a, b, c] : o2 === 0 ? [b, c, a] : o3 === 0 ? [c, a, b] : null;
1257
+ if (zero === null) {
1258
+ this.set(t, a, b, p);
1259
+ const t2 = this.count;
1260
+ this.set(t2, b, c, p);
1261
+ this.set(t2 + 1, c, a, p);
1262
+ this.legalize(p, [[a, b], [b, c], [c, a]]);
1263
+ return;
1264
+ }
1265
+ const [e0, e1, e2] = zero; // p on e0→e1, e2 opposite
1266
+ const u = this.ownerOf(e1, e0);
1267
+ if (u === undefined) throw new Error(`contour: vertex ${p} lies on outline edge ${e0}-${e1}, which the keep radius rules out`);
1268
+ const d = this.third(u, e1);
1269
+ this.set(t, e2, e0, p);
1270
+ this.set(u, e1, e2, p);
1271
+ const t3 = this.count;
1272
+ this.set(t3, d, e1, p);
1273
+ this.set(t3 + 1, e0, d, p);
1274
+ this.legalize(p, [[e2, e0], [e1, e2], [d, e1], [e0, d]]);
1275
+ return;
1276
+ }
1277
+ throw new Error(`contour: no triangle holds vertex ${p}, which the keep rule placed strictly inside the outline`);
1278
+ }
1279
+ }
1280
+
1281
+ /**
1282
+ * Interior edges of a mesh in this module's form that are not locally
1283
+ * Delaunay: the vertex across the edge strictly inside the circumcircle of the
1284
+ * triangle on this side. Exact on {@link GRID} coordinates; 0 for a
1285
+ * constrained Delaunay triangulation (the outline's edges, which have no
1286
+ * neighbour, are not asked). Being Delaunay does not depend on winding, so
1287
+ * each triangle is read in the order that is clockwise on screen — the order
1288
+ * the triangulation's predicates are written for — whichever way it is listed:
1289
+ * the module's output (counter-clockwise in Spine world) and a list wound as
1290
+ * the outline read the same. A `constrained` edge (issue #188: a rib's) is
1291
+ * not asked, as an outline edge is not: it is kept whatever its circle holds.
1292
+ */
1293
+ export function delaunayViolations(vertices: ReadonlyArray<readonly [number, number]>, triangles: readonly number[], constrained: ReadonlyArray<readonly [number, number]> = []): number {
1294
+ const X = vertices.map((v) => snap(v[0]));
1295
+ const Y = vertices.map((v) => snap(v[1]));
1296
+ const mesh = new Triangulation(X, Y);
1297
+ for (const [a, b] of constrained) mesh.lock(a, b);
1298
+ for (let t = 0; t < triangles.length; t += 3) {
1299
+ const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
1300
+ if (orient(X[a], Y[a], X[b], Y[b], X[c], Y[c]) < 0) mesh.set(t / 3, a, c, b);
1301
+ else mesh.set(t / 3, a, b, c);
1302
+ }
1303
+ return mesh.violations();
1304
+ }
1305
+
1306
+ /** Each triangle rotated to start at its smallest index (winding kept), and the list sorted. */
1307
+ function canonicalTriangles(tri: readonly number[]): number[] {
1308
+ const rows: Array<[number, number, number]> = [];
1309
+ for (let t = 0; t < tri.length; t += 3) {
1310
+ const v = [tri[t], tri[t + 1], tri[t + 2]];
1311
+ const k = v.indexOf(Math.min(...v));
1312
+ rows.push([v[k], v[(k + 1) % 3], v[(k + 2) % 3]]);
1313
+ }
1314
+ rows.sort((p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2]);
1315
+ return rows.flat();
1316
+ }
1317
+
1318
+ // ---------------------------------------------------------------------------
1319
+ // the checks a returned mesh is held to
1320
+ // ---------------------------------------------------------------------------
1321
+
1322
+ /** Twice the area a closed ring of grid units encloses, exactly: the shoelace sum in `BigInt`. */
1323
+ function twiceAreaUnits(xs: readonly number[], ys: readonly number[]): bigint {
1324
+ let sum = 0n;
1325
+ for (let i = 0; i < xs.length; i++) {
1326
+ const j = (i + 1) % xs.length;
1327
+ sum += BigInt(xs[i]) * BigInt(ys[j]) - BigInt(xs[j]) * BigInt(ys[i]);
1328
+ }
1329
+ return sum;
1330
+ }
1331
+
1332
+ /** A twice-area in grid units², as px² for a message or the report (rounded once, at the end). */
1333
+ const unitsToPx2 = (twice: bigint): number => Number(twice) / 2 / (GRID * GRID);
1334
+
1335
+ /**
1336
+ * The topology refusals of the settled comment, on any mesh in this module's
1337
+ * form (part-image px, y down, the outline first), each naming the part:
1338
+ *
1339
+ * - `CONTOUR_INDEX` — a triangle index that is not a whole number in range;
1340
+ * - `CONTOUR_GRID` — a vertex that is not on the {@link GRID} inside
1341
+ * ±{@link MAX_SIDE} px: every exact predicate below is exact only there;
1342
+ * - `CONTOUR_COINCIDENT_VERTICES` — two vertices at the same point;
1343
+ * - `CONTOUR_ZERO_AREA_TRIANGLE` — a triangle whose corners are collinear;
1344
+ * - `CONTOUR_SELF_INTERSECTION` — the first `hull` vertices, as a ring, meet
1345
+ * themselves (rigc's `findSelfIntersection`, touching included);
1346
+ * - `CONTOUR_ONE_LOOP` — rigc's own `traceOutline` and `checkHullOrder`, the
1347
+ * functions its gate runs on an authored mesh: one closed loop, `2V − hull −
1348
+ * 2` triangles, the outline first and in order, and a `hull` that agrees;
1349
+ * - `CONTOUR_TILING` — a triangle that is not counter-clockwise in Spine world
1350
+ * (y up: a negative area in this module's y-down units), or triangle areas
1351
+ * that do not sum to the area the outline encloses: with every triangle
1352
+ * wound one way and the boundary the outline, the sum is what rules out an
1353
+ * overlap. rigc's
1354
+ * `traceOutline` counts edge USES, so a triangle listed four times over (its
1355
+ * edges then used four times, "interior") passes it; the sum does not
1356
+ * (selftest `CT18`).
1357
+ *
1358
+ * Orientation is computed on grid integers (exact, see the module header);
1359
+ * the two area sums in `BigInt`.
1360
+ */
1361
+ export function contourTopologyProblems(part: string, vertices: ReadonlyArray<readonly [number, number]>, triangles: readonly number[], hull: number): Problem[] {
1362
+ const out: Problem[] = [];
1363
+ const object = `contour mesh "${part}"`;
1364
+ const V = vertices.length;
1365
+ const badIndex = triangles.findIndex((i) => !Number.isInteger(i) || i < 0 || i >= V);
1366
+ if (triangles.length % 3 !== 0 || badIndex >= 0) {
1367
+ out.push({
1368
+ code: 'CONTOUR_INDEX',
1369
+ object,
1370
+ detail:
1371
+ triangles.length % 3 !== 0
1372
+ ? `has ${triangles.length} triangle indices; a multiple of 3 is required`
1373
+ : `triangle ${Math.floor(badIndex / 3)} names vertex ${triangles[badIndex]}; whole numbers in 0..${V - 1} are required`,
1374
+ });
1375
+ return out;
1376
+ }
1377
+ const X = vertices.map((v) => v[0] * GRID);
1378
+ const Y = vertices.map((v) => v[1] * GRID);
1379
+ const off = X.findIndex((x, i) => !Number.isInteger(x) || !Number.isInteger(Y[i]) || Math.abs(x) > MAX_SIDE * GRID || Math.abs(Y[i]) > MAX_SIDE * GRID);
1380
+ if (off >= 0) {
1381
+ out.push({ code: 'CONTOUR_GRID', object, detail: `vertex ${off} is at (${vertices[off][0]}, ${vertices[off][1]}); every coordinate must be a multiple of 1/${GRID} px within ±${MAX_SIDE} px, where the predicates are exact` });
1382
+ return out;
1383
+ }
1384
+ const seen = new Map<string, number>();
1385
+ for (let i = 0; i < V; i++) {
1386
+ const k = `${X[i]},${Y[i]}`;
1387
+ const j = seen.get(k);
1388
+ if (j !== undefined) {
1389
+ out.push({ code: 'CONTOUR_COINCIDENT_VERTICES', object, detail: `vertices ${j} and ${i} are both at (${vertices[i][0]}, ${vertices[i][1]}); distinct positions are required` });
1390
+ break;
1391
+ }
1392
+ seen.set(k, i);
1393
+ }
1394
+ let against = -1;
1395
+ let sum = 0n;
1396
+ for (let t = 0; t < triangles.length; t += 3) {
1397
+ const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
1398
+ const s = orient(X[a], Y[a], X[b], Y[b], X[c], Y[c]);
1399
+ if (s === 0) {
1400
+ out.push({ code: 'CONTOUR_ZERO_AREA_TRIANGLE', object, detail: `triangle ${t / 3} (${a}, ${b}, ${c}) has zero area; every triangle needs a non-zero area` });
1401
+ break;
1402
+ }
1403
+ // Counter-clockwise in Spine world is a NEGATIVE orientation in y-down units (the y flip changes the sign).
1404
+ if (s > 0 && against < 0) against = t / 3;
1405
+ sum -= BigInt(s);
1406
+ }
1407
+ const ring = vertices.slice(0, hull).map(([x, y]) => [x, y] as [number, number]);
1408
+ const crossing = hull >= 3 ? findSelfIntersection(ring) : null;
1409
+ if (crossing !== null) {
1410
+ out.push({ code: 'CONTOUR_SELF_INTERSECTION', object, detail: `outline edge ${crossing[0]} meets outline edge ${crossing[1]}; an outline that touches itself nowhere is required` });
1411
+ }
1412
+ try {
1413
+ const outline = traceOutline(V, triangles);
1414
+ if (outline.hull !== hull) throw new MeshError(`it declares hull ${hull} and its triangles outline ${outline.hull} vertices`);
1415
+ checkHullOrder(outline, V);
1416
+ } catch (err) {
1417
+ if (!(err instanceof MeshError)) throw err;
1418
+ out.push({ code: 'CONTOUR_ONE_LOOP', object, detail: `rig-c's traceOutline/checkHullOrder: ${err.message}; one closed loop of the first ${hull} vertices in order, with 2 x ${V} - ${hull} - 2 = ${2 * V - hull - 2} triangles, is required` });
1419
+ }
1420
+ const listed = twiceAreaUnits(X.slice(0, hull), Y.slice(0, hull));
1421
+ const outlineTwice = listed < 0n ? -listed : listed;
1422
+ if (against >= 0 || (out.length === 0 && sum !== outlineTwice)) {
1423
+ out.push({
1424
+ code: 'CONTOUR_TILING',
1425
+ object,
1426
+ detail:
1427
+ against >= 0
1428
+ ? `triangle ${against} (${triangles[3 * against]}, ${triangles[3 * against + 1]}, ${triangles[3 * against + 2]}) is clockwise in Spine world; every triangle must be counter-clockwise in Spine world (y up), the winding rig-c reads a mesh in`
1429
+ : `its triangles' areas sum to ${unitsToPx2(sum)} px² and its outline encloses ${unitsToPx2(outlineTwice)} px²; equal is required, or triangles overlap`,
1430
+ });
1431
+ }
1432
+ return out;
1433
+ }
1434
+
1435
+ /**
1436
+ * Is the whole number `k` (0 ≤ k < 2^53) at most `margin²`, exactly? `margin`
1437
+ * is a finite double ≥ 0, which is exactly `n / 2^s` for whole numbers n and
1438
+ * s (doubling a double is exact, and a finite one becomes whole within 1074
1439
+ * doublings); the comparison is `k · 4^s ≤ n²` in `BigInt`. No square is
1440
+ * rounded, so a margin of exactly √2's double, or 1.5, decides the pixel at
1441
+ * squared distance 2 by its exact value, not by `margin * margin`'s rounding.
1442
+ */
1443
+ export function withinMarginSquared(k: number, margin: number): boolean {
1444
+ let n = margin;
1445
+ let s = 0n;
1446
+ while (!Number.isInteger(n)) {
1447
+ n *= 2;
1448
+ s++;
1449
+ }
1450
+ const whole = BigInt(n);
1451
+ return BigInt(k) * 4n ** s <= whole * whole;
1452
+ }
1453
+
1454
+ /**
1455
+ * The offsets `(dx, dy)` of the margin's disc: every pair of whole numbers
1456
+ * with `dx² + dy² ≤ margin²` ({@link withinMarginSquared}), row-major from
1457
+ * (−r, −r), r = ⌊margin⌋. Margin 1: the four neighbours and the centre;
1458
+ * √2 ≤ margin < 2: the 3x3 block; margin 2: the 3x3 block and (±2, 0), (0, ±2).
1459
+ */
1460
+ export function marginDisc(margin: number): Array<[number, number]> {
1461
+ const r = Math.floor(margin);
1462
+ const out: Array<[number, number]> = [];
1463
+ for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) if (withinMarginSquared(dx * dx + dy * dy, margin)) out.push([dx, dy]);
1464
+ return out;
1465
+ }
1466
+
1467
+ /** The silhouette grown by the margin, and what each step added (see {@link growSilhouette}). */
1468
+ export interface GrownSilhouette {
1469
+ mask: Mask;
1470
+ /** Pixels the growth added: centre within the margin of a pixel of the filled silhouette, not in it. */
1471
+ grownPixels: number;
1472
+ /** Pixels the pinch fill added, after the growth. */
1473
+ pinchFilledPixels: number;
1474
+ /** Passes of the pinch fill that added a pixel (0 when the growth made no pinch). */
1475
+ pinchFillPasses: number;
1476
+ }
1477
+
1478
+ /** Every pixel corner where two set pixels meet diagonally with both others clear — rig-c's diagonal pinch — as the clear pixels' indices. */
1479
+ function pinchPixels(data: Uint8Array, w: number, h: number): number[] {
1480
+ const out: number[] = [];
1481
+ for (let y = 0; y + 1 < h; y++) {
1482
+ for (let x = 0; x + 1 < w; x++) {
1483
+ const tl = y * w + x;
1484
+ const tr = tl + 1;
1485
+ const bl = tl + w;
1486
+ const br = bl + 1;
1487
+ if (data[tl] && data[br] && !data[tr] && !data[bl]) out.push(tr, bl);
1488
+ else if (data[tr] && data[bl] && !data[tl] && !data[br]) out.push(tl, br);
1489
+ }
1490
+ }
1491
+ return out;
1492
+ }
1493
+
1494
+ /**
1495
+ * The silhouette the outline is traced from (the ruling on issue #106). `filled`
1496
+ * is the kept island with its holes filled, F.
1497
+ *
1498
+ * 1. **Growth.** A pixel joins when its centre lies within `margin` of the
1499
+ * centre of a pixel of F — Euclidean, centre to centre, decided exactly
1500
+ * ({@link withinMarginSquared}). That is the distance `measureAuthoredMeshFit`
1501
+ * measures overshoot in, so every grown pixel has an overshoot of at most
1502
+ * the margin. The pixel of F nearest to a pixel outside it is on F's edge
1503
+ * (a pixel of F whose four neighbours are all in F has one strictly nearer
1504
+ * to the outside pixel), so stamping the {@link marginDisc} at the edge
1505
+ * pixels of F is exact. Margin 0 grows nothing.
1506
+ * 2. **Pinch fill.** Growth can leave two grown pixels meeting only at a
1507
+ * corner (a 1 px crack running diagonally between two strands), which
1508
+ * rig-c's tracer refuses. Every such pinch is the growth's: F has none
1509
+ * (a 4-connected island touching itself at a corner encloses one of the two
1510
+ * clear pixels, which is then a hole and filled; selftest `CE08` checks it
1511
+ * over every 4x4 mask), and at margin ≥ 1 a pixel of F would put its four
1512
+ * neighbours in G. **Rule:** one pass finds every pinch of the current set
1513
+ * and adds BOTH clear pixels of each — both, because choosing one of two
1514
+ * symmetric pixels is a choice nothing decides; all of a pass at once, so
1515
+ * no scan order decides anything — and passes repeat until one finds no
1516
+ * pinch. **Termination:** a pass that finds a pinch adds at least two
1517
+ * pixels not yet set, so there are at most `w·h / 2` passes. A filled pixel
1518
+ * is a 4-neighbour of a grown one; how far it lies from the art is what the
1519
+ * overshoot check measures, not assumed.
1520
+ */
1521
+ export function growSilhouette(filled: Mask, margin: number): GrownSilhouette {
1522
+ const { width: w, height: h, data } = filled;
1523
+ const out = new Uint8Array(data);
1524
+ const disc = marginDisc(margin);
1525
+ if (disc.length > 1) {
1526
+ for (let y = 0; y < h; y++) {
1527
+ for (let x = 0; x < w; x++) {
1528
+ const i = y * w + x;
1529
+ if (!data[i]) continue;
1530
+ if (x > 0 && data[i - 1] && x < w - 1 && data[i + 1] && y > 0 && data[i - w] && y < h - 1 && data[i + w]) continue;
1531
+ for (const [dx, dy] of disc) {
1532
+ const nx = x + dx;
1533
+ const ny = y + dy;
1534
+ if (nx >= 0 && ny >= 0 && nx < w && ny < h) out[ny * w + nx] = 1;
1535
+ }
1536
+ }
1537
+ }
1538
+ }
1539
+ let grownPixels = 0;
1540
+ for (let i = 0; i < out.length; i++) if (out[i] && !data[i]) grownPixels++;
1541
+ let pinchFilledPixels = 0;
1542
+ let pinchFillPasses = 0;
1543
+ for (let found = pinchPixels(out, w, h); found.length > 0; found = pinchPixels(out, w, h)) {
1544
+ for (const i of found) {
1545
+ if (out[i]) continue;
1546
+ out[i] = 1;
1547
+ pinchFilledPixels++;
1548
+ }
1549
+ pinchFillPasses++;
1550
+ }
1551
+ return { mask: { width: w, height: h, data: out }, grownPixels, pinchFilledPixels, pinchFillPasses };
1552
+ }
1553
+
1554
+ /** The art a threshold makes: alpha above it, as a 0/1 mask. */
1555
+ export function artMask(mask: AlphaMask, threshold: number): Mask {
1556
+ const data = new Uint8Array(mask.width * mask.height);
1557
+ for (let i = 0; i < data.length; i++) data[i] = mask.alpha[i] > threshold ? 1 : 0;
1558
+ return { width: mask.width, height: mask.height, data };
1559
+ }
1560
+
1561
+ /**
1562
+ * Coverage and overshoot, the two fit refusals, measured by rig-c's
1563
+ * `measureAuthoredMeshFit` (the settled comment names it): a pixel is covered
1564
+ * when its centre is in or on a triangle, and overshoot is the furthest a
1565
+ * covered pixel's centre sits from the nearest pixel of the filled silhouette
1566
+ * (all art plus what it encloses), centre to centre. That is the convention
1567
+ * the bound's `+ 1` is for — a pixel whose centre lands inside can sit up to a
1568
+ * pixel from the true edge (rigc's `contourOvershootBound`, whose `margin`
1569
+ * term is `margin × 4`, its offset's miter clamp; the settled bound here is
1570
+ * `margin`, and this module's grown silhouette keeps it before simplification).
1571
+ * The refusal on coverage is any art pixel uncovered, not rigc's 99.5 %.
1572
+ *
1573
+ * `connectivity` is how the background is flooded to find that silhouette
1574
+ * (rig-c 2.23.0's argument): 4 — the default, every reading this function
1575
+ * made before — or 8, the fill rig-c's `MQ_OVERSHOOT` reads, where a pocket
1576
+ * of background joined to the outside only at a corner is outside, so a mesh
1577
+ * spanning it overshoots. The two agree on every mask without such a pocket.
1578
+ * Only the overshoot depends on it; coverage counts art pixels, which no fill
1579
+ * moves.
1580
+ */
1581
+ export function contourFit(
1582
+ part: string,
1583
+ mask: AlphaMask,
1584
+ threshold: number,
1585
+ bound: { margin: number; tolerance: number },
1586
+ vertices: ReadonlyArray<readonly [number, number]>,
1587
+ triangles: readonly number[],
1588
+ connectivity: 4 | 8 = 4,
1589
+ ): { problems: Problem[]; artPixels: number; coveredArt: number; coverage: number; overshoot: number; overshootBound: number } {
1590
+ const fit = measureAuthoredMeshFit(
1591
+ mask,
1592
+ threshold + 1,
1593
+ vertices.map(([x, y]) => [x, y] as [number, number]),
1594
+ [...triangles],
1595
+ connectivity,
1596
+ );
1597
+ const overshootBound = bound.margin + bound.tolerance + 1;
1598
+ const problems: Problem[] = [];
1599
+ const object = `contour mesh "${part}"`;
1600
+ if (fit.coveredArt < fit.artPixels) {
1601
+ problems.push({
1602
+ code: 'CONTOUR_COVERAGE',
1603
+ object,
1604
+ detail: `${fit.artPixels - fit.coveredArt} of ${fit.artPixels} art pixel(s) (alpha above ${threshold}) lie outside the mesh at tolerance ${bound.tolerance} px and margin ${bound.margin} px; every art pixel inside is required — art is never clipped, so raise the margin or lower the tolerance`,
1605
+ });
1606
+ }
1607
+ if (fit.overshoot > overshootBound) {
1608
+ problems.push({
1609
+ code: 'CONTOUR_OVERSHOOT',
1610
+ object,
1611
+ detail: `the mesh reaches ${fit.overshoot} px past the art${connectivity === 8 ? " (the background flooded 8-connected, as rig-c's MQ_OVERSHOOT reads it)" : ''}; at most margin ${bound.margin} + tolerance ${bound.tolerance} + 1 = ${overshootBound} px is required`,
1612
+ });
1613
+ }
1614
+ return { problems, artPixels: fit.artPixels, coveredArt: fit.coveredArt, coverage: fit.artPixels === 0 ? 0 : fit.coveredArt / fit.artPixels, overshoot: fit.overshoot, overshootBound };
1615
+ }
1616
+
1617
+ const r6 = (v: number): number => Math.round(v * 1e6) / 1e6;
1618
+
1619
+ /** The smallest angle and the largest edge ratio over the triangles, each with the first triangle that has it. */
1620
+ export function triangleQuality(vertices: ReadonlyArray<readonly [number, number]>, triangles: readonly number[]): { smallestAngle: TriangleFigure; largestEdgeRatio: TriangleFigure } {
1621
+ let minAngle = Infinity;
1622
+ let minAt = -1;
1623
+ let maxRatio = -Infinity;
1624
+ let maxAt = -1;
1625
+ for (let t = 0; t < triangles.length; t += 3) {
1626
+ const p = [vertices[triangles[t]], vertices[triangles[t + 1]], vertices[triangles[t + 2]]];
1627
+ const len2 = [0, 1, 2].map((k) => {
1628
+ const a = p[(k + 1) % 3];
1629
+ const b = p[(k + 2) % 3];
1630
+ return (a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]);
1631
+ });
1632
+ const len = len2.map(Math.sqrt);
1633
+ for (let k = 0; k < 3; k++) {
1634
+ // The angle at corner k, opposite side k, by the law of cosines.
1635
+ const cos = (len2[(k + 1) % 3] + len2[(k + 2) % 3] - len2[k]) / (2 * len[(k + 1) % 3] * len[(k + 2) % 3]);
1636
+ const deg = (Math.acos(Math.max(-1, Math.min(1, cos))) * 180) / Math.PI;
1637
+ if (deg < minAngle) {
1638
+ minAngle = deg;
1639
+ minAt = t / 3;
1640
+ }
1641
+ }
1642
+ const ratio = Math.max(...len) / Math.min(...len);
1643
+ if (ratio > maxRatio) {
1644
+ maxRatio = ratio;
1645
+ maxAt = t / 3;
1646
+ }
1647
+ }
1648
+ return { smallestAngle: { triangle: minAt, value: r6(minAngle) }, largestEdgeRatio: { triangle: maxAt, value: r6(maxRatio) } };
1649
+ }
1650
+
1651
+ // ---------------------------------------------------------------------------
1652
+ // the mesh
1653
+ // ---------------------------------------------------------------------------
1654
+
1655
+ /** The outline stage's result: the outline in walk order (px, on the {@link GRID}) and what the silhouette was. */
1656
+ export interface ContourOutline {
1657
+ outline: Array<[number, number]>;
1658
+ /** Corner-lattice points of the traced silhouette, before simplification. */
1659
+ tracedVertices: number;
1660
+ grown: GrownSilhouette;
1661
+ /** rigc's `holePixels` of the grown silhouette's trace (0 at margin 0, where the trace is F's and F has no hole). */
1662
+ grownHolePixels: number;
1663
+ }
1664
+
1665
+ /**
1666
+ * The outline of a filled silhouette F (a 0/1 mask of the part's size: the
1667
+ * kept island with its holes filled), or every problem that refuses it:
1668
+ *
1669
+ * 1. rig-c's `traceAlphaOutline` reads F itself first, so a diagonal
1670
+ * pinch in F is refused in rigc's words (`CONTOUR_TRACE`) at every margin,
1671
+ * before the growth could hide it — the pinch fill below resolves only the
1672
+ * pinches the growth made. (F built from one 4-connected island by
1673
+ * `fillHoles` has none, `CE08`; this is the guard, and `CE07` plants one.)
1674
+ * 2. F grows by `margin` and the growth's pinches are filled
1675
+ * ({@link growSilhouette}); at margin 0 nothing grows and F's own trace is
1676
+ * the one used.
1677
+ * 3. That silhouette is traced (`traceAlphaOutline`), simplified at
1678
+ * `tolerance` (`simplifyClosedPolygon`), clamped to the window, snapped to
1679
+ * the {@link GRID} and pruned (`prunePolygon`). There is no offset: the
1680
+ * trace runs on whole pixel corners, so clamp and snap move nothing.
1681
+ * 4. Douglas–Peucker can still cross an outline over itself, and an outline
1682
+ * under three vertices or enclosing no area is no outline: both
1683
+ * `CONTOUR_SELF_INTERSECTION`, before any triangle is made.
1684
+ */
1685
+ export function contourOutline(part: string, filled: Mask, threshold: number, tolerance: number, margin: number): ContourOutline | Problem[] {
1686
+ const object = `contour mesh "${part}"`;
1687
+ const { width: w, height: h } = filled;
1688
+ const trace = (m: Mask): ReturnType<typeof traceAlphaOutline> | Problem[] => {
1689
+ try {
1690
+ return traceAlphaOutline({ width: w, height: h, alpha: m.data }, 1);
1691
+ } catch (err) {
1692
+ if (!(err instanceof MeshError)) throw err;
1693
+ return [{ code: 'CONTOUR_TRACE', object, detail: `rig-c's traceAlphaOutline refused it at alpha above ${threshold}: ${err.message}` }];
1694
+ }
1695
+ };
1696
+ const silhouette = trace(filled);
1697
+ if (Array.isArray(silhouette)) return silhouette;
1698
+ const grown = growSilhouette(filled, margin);
1699
+ const traced = margin === 0 ? silhouette : trace(grown.mask);
1700
+ if (Array.isArray(traced)) return traced;
1701
+ const simplified = simplifyClosedPolygon(traced.outline, tolerance);
1702
+ const snapped = simplified.map(([x, y]) => [snap(Math.min(w, Math.max(0, x))) / GRID, snap(Math.min(h, Math.max(0, y))) / GRID] as [number, number]);
1703
+ const outline = prunePolygon(snapped);
1704
+ if (outline.length < 3 || signedArea(outline) <= 0 || findSelfIntersection(outline) !== null) {
1705
+ const crossing = outline.length < 3 ? null : findSelfIntersection(outline);
1706
+ return [
1707
+ {
1708
+ code: 'CONTOUR_SELF_INTERSECTION',
1709
+ object,
1710
+ detail:
1711
+ crossing !== null
1712
+ ? `after margin ${margin} px and tolerance ${tolerance} px its simplified outline's edge ${crossing[0]} meets edge ${crossing[1]}; an outline that touches itself nowhere is required — lower the tolerance`
1713
+ : `after margin ${margin} px and tolerance ${tolerance} px its outline has ${outline.length} vertices enclosing ${outline.length < 3 ? 0 : signedArea(outline)} px² (clockwise on screen is positive); 3 or more enclosing a positive area are required — lower the tolerance`,
1714
+ },
1715
+ ];
1716
+ }
1717
+ return { outline, tracedVertices: traced.outline.length, grown, grownHolePixels: margin === 0 ? 0 : traced.holePixels };
1718
+ }
1719
+
1720
+ /**
1721
+ * The contour mesh of one part, or every problem that refuses it. `part` is
1722
+ * the name each refusal carries. Pure: same mask and parameters, same bytes.
1723
+ */
1724
+ export function contourMesh(part: string, mask: AlphaMask, params: ContourParams): ContourMesh | Problem[] {
1725
+ const object = `contour mesh "${part}"`;
1726
+ const early = parameterProblems(part, mask, params);
1727
+ if (early.length > 0) return early;
1728
+ const { width: w, height: h } = mask;
1729
+ const { threshold, tolerance, margin } = params;
1730
+
1731
+ const art = artMask(mask, threshold);
1732
+ const comp = connectedComponents(art, 4);
1733
+ const islands = comp.count - 1;
1734
+ if (islands === 0) {
1735
+ return [{ code: 'CONTOUR_PART_HAS_ART', object, detail: `its ${w}x${h} image has no pixel with alpha above ${threshold}; a mesh needs at least one art pixel` }];
1736
+ }
1737
+ const split = strayIslands(comp.stats.map((s) => s.area), params.stray);
1738
+ if (split.refused !== null) {
1739
+ const sizes = comp.stats.slice(1).map((s) => `${s.area} px at (${s.left}, ${s.top})`);
1740
+ const declared = params.stray === undefined ? '' : ` With stray ${params.stray} px declared, ${split.refused}.`;
1741
+ return [
1742
+ {
1743
+ code: 'CONTOUR_ONE_ISLAND',
1744
+ object,
1745
+ detail: `its art (alpha above ${threshold}) is ${islands} separate 4-connected islands — ${sizes.join(', ')}; one island is required, because rig-c takes one closed outline per mesh.${declared} Nothing was discarded; the lattice mode stays available for this part`,
1746
+ },
1747
+ ];
1748
+ }
1749
+ // The islands left out are taken out of the art before anything is traced or measured: alpha 0 there, so the
1750
+ // trace, the coverage and the overshoot all read the one island that remains. Nothing else of the mask moves.
1751
+ let meshed = mask;
1752
+ let strayPixels = 0;
1753
+ if (split.leave.length > 0) {
1754
+ const out = new Set(split.leave);
1755
+ const alpha = new Uint8Array(mask.alpha);
1756
+ for (let i = 0; i < alpha.length; i++) if (out.has(comp.labels[i])) alpha[i] = 0;
1757
+ meshed = { width: w, height: h, alpha };
1758
+ for (const l of split.leave) strayPixels += comp.stats[l].area;
1759
+ }
1760
+
1761
+ // The silhouette F: the kept island with its holes filled; contourOutline grows it, fills the growth's own
1762
+ // pinches, traces and simplifies it.
1763
+ const island = artMask(meshed, threshold);
1764
+ const filled = fillHoles(island);
1765
+ let filledHolePixels = 0;
1766
+ for (let i = 0; i < filled.data.length; i++) if (filled.data[i] && !island.data[i]) filledHolePixels++;
1767
+ const stage = contourOutline(part, filled, threshold, tolerance, margin);
1768
+ if (Array.isArray(stage)) return stage;
1769
+ const { grown } = stage;
1770
+ let outline = outlineInRegions(stage.outline, params.regions);
1771
+ // issue #188: the ribs' ends join the outline, and their interior points come first among the interior vertices.
1772
+ // None declared, nothing here runs and every list below is the one it always was.
1773
+ let ribs: RibPlacement | null = null;
1774
+ if ((params.ribs ?? []).length > 0) {
1775
+ const placed = placeRibs(part, outline, meshed, island, params);
1776
+ if (Array.isArray(placed)) return placed;
1777
+ ribs = placed;
1778
+ outline = placed.outline;
1779
+ }
1780
+ const held = ribs?.points ?? [];
1781
+
1782
+ const hx = outline.map((q) => snap(q[0]));
1783
+ const hy = outline.map((q) => snap(q[1]));
1784
+ // A candidate outside the part window cannot be inside the outline (clamped to it), and dropping it first keeps
1785
+ // every coordinate the predicates see within the window — the exactness bound.
1786
+ const kept = keepPoints(hx, hy, interiorCandidates(w, h, params).filter((c) => c.x >= 0 && c.y >= 0 && c.x <= w * GRID && c.y <= h * GRID), ribs?.segments ?? []);
1787
+ const X = [...hx, ...held.map((c) => c.x), ...kept.map((c) => c.x)];
1788
+ const Y = [...hy, ...held.map((c) => c.y), ...kept.map((c) => c.y)];
1789
+ const H = outline.length;
1790
+
1791
+ const mesh = new Triangulation(X, Y);
1792
+ const ears = earClip(outline);
1793
+ for (let t = 0; t < ears.length; t += 3) mesh.set(t / 3, ears[t], ears[t + 1], ears[t + 2]);
1794
+ mesh.legalizeAll();
1795
+ for (let k = 0; k < held.length + kept.length; k++) mesh.insert(H + k);
1796
+ const ribEdges: Array<[number, number]> = [];
1797
+ for (const rib of ribs?.ribs ?? []) {
1798
+ for (let k = 0; k + 1 < rib.vertices.length; k++) {
1799
+ const [a, b] = [rib.vertices[k], rib.vertices[k + 1]];
1800
+ ribEdges.push([a, b]);
1801
+ if (!mesh.constrain(a, b, mesh.count * mesh.count)) {
1802
+ return [{ code: 'CONTOUR_RIB_EDGE', object: `${object}, ${rib.name}`, detail: `its vertices ${a} and ${b} could not be joined by an edge — no edge crossing the segment between them could be flipped; a rib whose consecutive vertices the triangulation can join is required` }];
1803
+ }
1804
+ }
1805
+ }
1806
+ mesh.legalizeAll();
1807
+
1808
+ const vertices = X.map((x, i) => [x / GRID, Y[i] / GRID] as [number, number]);
1809
+ const triangles = counterClockwiseInSpineWorld(canonicalTriangles(mesh.tri));
1810
+ const problems = contourTopologyProblems(part, vertices, triangles, H);
1811
+ const fit = contourFit(part, meshed, threshold, { margin, tolerance }, vertices, triangles, params.fitConnectivity ?? 4);
1812
+ problems.push(...fit.problems);
1813
+ if (params.budget !== undefined && vertices.length > params.budget) {
1814
+ problems.push({
1815
+ code: 'CONTOUR_BUDGET',
1816
+ object,
1817
+ detail: `has ${vertices.length} vertices (${H} on the outline, ${held.length + kept.length} inside); the declared budget is ${params.budget} — nothing is thinned to fit, so raise the spacing or the tolerance, or the budget`,
1818
+ });
1819
+ }
1820
+ if (problems.length > 0) return problems;
1821
+
1822
+ const bySource = new Map<string, number>();
1823
+ for (const r of params.regions) bySource.set(`region "${r.name}"`, 0);
1824
+ bySource.set('background', 0);
1825
+ for (const c of kept) bySource.set(c.source, (bySource.get(c.source) as number) + 1);
1826
+ if (ribs !== null) bySource.set('ribs', held.length);
1827
+ const meshArea = unitsToPx2(twiceAreaUnits(hx, hy));
1828
+ return {
1829
+ vertices,
1830
+ triangles,
1831
+ hull: H,
1832
+ mask: meshed,
1833
+ ...(ribs === null ? {} : { ribs: ribs.ribs }),
1834
+ report: {
1835
+ boundaryVertices: H,
1836
+ interiorVertices: held.length + kept.length,
1837
+ interiorBySource: [...bySource].map(([source, n]) => ({ source, vertices: n })),
1838
+ triangles: triangles.length / 3,
1839
+ tracedVertices: stage.tracedVertices,
1840
+ artPixels: fit.artPixels,
1841
+ coveredArtPixels: fit.coveredArt,
1842
+ coverage: fit.coverage,
1843
+ overshoot: fit.overshoot,
1844
+ overshootBound: fit.overshootBound,
1845
+ ...(params.fitConnectivity === undefined ? {} : { fitConnectivity: params.fitConnectivity }),
1846
+ meshArea,
1847
+ enclosedTransparentArea: meshArea - fit.coveredArt,
1848
+ filledHolePixels,
1849
+ ...triangleQuality(vertices, triangles),
1850
+ nonDelaunayEdges: delaunayViolations(vertices, triangles, ribEdges),
1851
+ strayIslands: split.leave.length,
1852
+ strayPixels,
1853
+ grownPixels: grown.grownPixels,
1854
+ pinchFilledPixels: grown.pinchFilledPixels,
1855
+ grownHolePixels: stage.grownHolePixels,
1856
+ ...(ribs === null ? {} : { ribs: ribs.ribs }),
1857
+ },
1858
+ };
1859
+ }
1860
+
1861
+ /**
1862
+ * The stray-island rule (issue #84, the amendment to the settled comment), on
1863
+ * the pixel counts of a part's 4-connected art islands — `areas[0]` is the
1864
+ * background and is not read, as `connectedComponents` numbers them.
1865
+ *
1866
+ * - One island: kept, nothing left out, whatever is declared.
1867
+ * - `stray` absent: a second island refuses, as the settled rule says.
1868
+ * - `stray` declared: the largest island is kept; every OTHER island holding
1869
+ * `stray` art pixels or fewer is left out; any other island above the figure
1870
+ * refuses. **Ties:** when two or more islands share the largest count, none
1871
+ * of them is "the largest" — keeping one of two equal pieces would be a
1872
+ * choice the declaration does not make — so the part refuses, naming the
1873
+ * tie. A stray island that sits inside a hole of the kept island is left out
1874
+ * of the art like any other; the hole it sits in is then filled as every
1875
+ * hole is, so the mesh still spans it (its pixels count as filled hole).
1876
+ *
1877
+ * `leave` lists the labels left out, in label (raster-scan) order; `refused`
1878
+ * is null or the reason, in words a refusal can carry.
1879
+ */
1880
+ export function strayIslands(areas: readonly number[], stray: number | undefined): { keep: number; leave: number[]; refused: string | null } {
1881
+ const n = areas.length - 1;
1882
+ if (n <= 1) return { keep: 1, leave: [], refused: null };
1883
+ if (stray === undefined) return { keep: -1, leave: [], refused: 'no stray figure is declared' };
1884
+ let largest = 0;
1885
+ for (let l = 1; l <= n; l++) largest = Math.max(largest, areas[l]);
1886
+ const tied: number[] = [];
1887
+ for (let l = 1; l <= n; l++) if (areas[l] === largest) tied.push(l);
1888
+ if (tied.length > 1) return { keep: -1, leave: [], refused: `${tied.length} islands share the largest count, ${largest} px, so no one island is the largest and none of them is left out` };
1889
+ const keep = tied[0];
1890
+ const leave: number[] = [];
1891
+ const above: number[] = [];
1892
+ for (let l = 1; l <= n; l++) {
1893
+ if (l === keep) continue;
1894
+ if (areas[l] <= stray) leave.push(l);
1895
+ else above.push(l);
1896
+ }
1897
+ if (above.length > 0) {
1898
+ return { keep: -1, leave: [], refused: `besides the largest (${largest} px) ${above.length} island(s) hold more than ${stray} px — ${above.map((l) => `${areas[l]} px`).join(', ')} — and only an island at or under the figure may be left out` };
1899
+ }
1900
+ return { keep, leave, refused: null };
1901
+ }