spine-rigc 2.20.0 → 2.20.2

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.
@@ -0,0 +1,621 @@
1
+ /**
2
+ * The art-derived rasters one mesh measurement reads — every quantity of
3
+ * `measureMeshQuality`'s raster rows (`src/meshquality.ts`) whose only input is
4
+ * the art: the art bits at the threshold, the filled silhouette at both
5
+ * background connectivities and each one's distance transform, the
6
+ * 4-connected island labels, and the traced outline in drawing pixels.
7
+ *
8
+ * ## Why it is a value and not a cache
9
+ *
10
+ * `reduceMesh` measures one candidate per step against art that never changes
11
+ * within the call, and recomputing these per step was nearly all of its cost
12
+ * (issue #1240: 98.98 % of a 1101-step call's samples in the measurement, at
13
+ * least 40 % in functions of the art alone). So the call takes them once, at
14
+ * admission, and hands this object to every measurement it makes. There is no
15
+ * module-level cache and no hidden state: the object is created by the caller,
16
+ * passed explicitly, and dropped with the call. `measureMeshQuality` itself
17
+ * makes a fresh one per call, so its behaviour is the uncached one.
18
+ *
19
+ * Each quantity is computed on first read and kept, so a measurement that
20
+ * never reaches a row (a source that is not one loop, art under its sample
21
+ * floor) computes no more than it did before. `tally` counts every
22
+ * computation and every measurement handed the object — the count a control
23
+ * reads to see that a reduction computed each quantity once.
24
+ *
25
+ * ## What it is taken at, and the refusal
26
+ *
27
+ * The object holds the `ArtInput` it was made from. A measurement handed it
28
+ * for a different art is refused, not served stale numbers: the mask array,
29
+ * the mask's size, the threshold and the frame's scale and size are each
30
+ * compared, and a difference is `REDUCE_ART_RASTERS_MISMATCH` naming the field,
31
+ * the value the rasters were taken at and the value the input requires
32
+ * (`checkArtRasters`, `src/meshquality.ts`).
33
+ *
34
+ * Not re-exported by `src/mesh.ts`: nothing here is on `spine-rigc/mesh`.
35
+ */
36
+ import { artOf, distancePassesOf, fillEnclosed, labelIslands, MeshError, pixelCentreInTriangle, prunePolygon, squaredDistanceToSet, traceAlphaOutline, triangleRasterBox } from './mesh.ts';
37
+ import type { ArtInput } from './meshquality.ts';
38
+
39
+ /** How many times each quantity was computed, and how many measurements were handed the object. */
40
+ export interface ArtRasterTally {
41
+ /** Measurements that read the object (each after its own check). */
42
+ uses: number;
43
+ artBits: number;
44
+ fills: number;
45
+ toFilled8: number;
46
+ toFilled4: number;
47
+ islands: number;
48
+ traced: number;
49
+ }
50
+
51
+ /** The traced art outline in drawing pixels, pruned — or the tracer's refusal, as its message. */
52
+ export type TracedArt = { outline: Array<[number, number]> } | { refused: string };
53
+
54
+ export interface ArtRasters {
55
+ /** The art these were taken from: its mask array, size, threshold and frame. */
56
+ readonly art: ArtInput;
57
+ readonly tally: ArtRasterTally;
58
+ /** `artOf(mask, threshold)`: 1 where alpha >= threshold. */
59
+ artBits(): Uint8Array;
60
+ /** The number of art pixels in `artBits()`. */
61
+ artCount(): number;
62
+ /** `fillEnclosed` of the art at both background connectivities, and whether they differ anywhere. */
63
+ fills(): { fill8: Uint8Array; fill4: Uint8Array; differ: boolean };
64
+ /** `squaredDistanceToSet` of the filled silhouette at one connectivity. */
65
+ toFilled(connectivity: 4 | 8): Float64Array;
66
+ /** `labelIslands` of the art: 4-connected labels, 1-based, and each island's size. */
67
+ islands(): { label: Int32Array; sizes: number[] };
68
+ /** `traceAlphaOutline` at the threshold, carried to drawing pixels by `pageScale` and pruned. */
69
+ traced(): TracedArt;
70
+ }
71
+
72
+ /**
73
+ * The rasters of one art, each computed on first read. Reads nothing at
74
+ * construction, so it may be made before the art is validated; every reader
75
+ * checks it against a validated input first.
76
+ */
77
+ export function artRastersOf(art: ArtInput): ArtRasters {
78
+ const tally: ArtRasterTally = { uses: 0, artBits: 0, fills: 0, toFilled8: 0, toFilled4: 0, islands: 0, traced: 0 };
79
+ let bits: { bits: Uint8Array; count: number } | null = null;
80
+ let fills: { fill8: Uint8Array; fill4: Uint8Array; differ: boolean } | null = null;
81
+ let toFilled8: Float64Array | null = null;
82
+ let toFilled4: Float64Array | null = null;
83
+ let islands: { label: Int32Array; sizes: number[] } | null = null;
84
+ let traced: TracedArt | null = null;
85
+ const artBitsOf = (): { bits: Uint8Array; count: number } => {
86
+ if (bits === null) {
87
+ tally.artBits++;
88
+ const b = artOf(art.mask, art.threshold);
89
+ let count = 0;
90
+ for (const bit of b) count += bit;
91
+ bits = { bits: b, count };
92
+ }
93
+ return bits;
94
+ };
95
+ const fillsOf = (): { fill8: Uint8Array; fill4: Uint8Array; differ: boolean } => {
96
+ if (fills === null) {
97
+ tally.fills++;
98
+ const { width: w, height: h } = art.mask;
99
+ const b = artBitsOf().bits;
100
+ const fill8 = fillEnclosed(b, w, h, 8).filled;
101
+ const fill4 = fillEnclosed(b, w, h, 4).filled;
102
+ let differ = false;
103
+ for (let i = 0; i < fill8.length; i++) {
104
+ if (fill8[i] !== fill4[i]) {
105
+ differ = true;
106
+ break;
107
+ }
108
+ }
109
+ fills = { fill8, fill4, differ };
110
+ }
111
+ return fills;
112
+ };
113
+ return {
114
+ art,
115
+ tally,
116
+ artBits: () => artBitsOf().bits,
117
+ artCount: () => artBitsOf().count,
118
+ fills: fillsOf,
119
+ toFilled: (connectivity) => {
120
+ const { width: w, height: h } = art.mask;
121
+ if (connectivity === 8) {
122
+ if (toFilled8 === null) {
123
+ tally.toFilled8++;
124
+ toFilled8 = squaredDistanceToSet(fillsOf().fill8, w, h);
125
+ }
126
+ return toFilled8;
127
+ }
128
+ if (toFilled4 === null) {
129
+ tally.toFilled4++;
130
+ toFilled4 = squaredDistanceToSet(fillsOf().fill4, w, h);
131
+ }
132
+ return toFilled4;
133
+ },
134
+ islands: () => {
135
+ if (islands === null) {
136
+ tally.islands++;
137
+ islands = labelIslands(artBitsOf().bits, art.mask.width, art.mask.height);
138
+ }
139
+ return islands;
140
+ },
141
+ traced: () => {
142
+ if (traced === null) {
143
+ tally.traced++;
144
+ const scale = art.frame.pageScale;
145
+ try {
146
+ const t = traceAlphaOutline(art.mask, art.threshold);
147
+ traced = { outline: prunePolygon(t.outline.map(([x, y]): [number, number] => [x / scale, y / scale])) };
148
+ } catch (err) {
149
+ if (!(err instanceof MeshError)) throw err;
150
+ traced = { refused: err.message };
151
+ }
152
+ }
153
+ return traced;
154
+ },
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Overshoot and holes against one filled silhouette: the covered centre
160
+ * outside it furthest from it (the first in pixel order among equals; −1 when
161
+ * none) with its squared distance, and the covered centres inside it that are
162
+ * not art — how many, and the first in pixel order (−1 when none).
163
+ */
164
+ export interface SilhouetteReading {
165
+ overAt: number;
166
+ overSq: number;
167
+ holes: number;
168
+ holeAt: number;
169
+ }
170
+
171
+ /**
172
+ * What one step's coverage reads, from either path: the covered pixel centres,
173
+ * their distance transform, the covered art pixels, whether any centre is
174
+ * covered, the uncovered art pixel furthest from the covered set (the first in
175
+ * pixel order among equals; −1 when none) with its squared distance, the
176
+ * silhouette reading at either connectivity, and the 4-connected art islands
177
+ * the coverage touches — how many, and the second-smallest label among them
178
+ * (0 when fewer than two).
179
+ */
180
+ export interface CoverageReading {
181
+ covered: Uint8Array;
182
+ toCovered: Float64Array;
183
+ coveredArt: number;
184
+ anyCovered: boolean;
185
+ undercut: { at: number; sq: number };
186
+ silhouette(connectivity: 4 | 8): SilhouetteReading;
187
+ islandsTouched: number;
188
+ secondIsland: number;
189
+ }
190
+
191
+ /** How a step-rasters object came by each reading, and what it redid. */
192
+ export interface StepRasterTally {
193
+ /** Coverage readings handed out. */
194
+ readings: number;
195
+ /** Readings built from nothing: the first, and any whose triangles differ from the last in more than they share. */
196
+ rebuilt: number;
197
+ /** Readings carried from the last one by the triangles that differ. */
198
+ carried: number;
199
+ /** Triangles drawn (taken away or added) by carried readings. */
200
+ trianglesDrawn: number;
201
+ /** Triangles a carried reading did not draw because the last reading held them already. */
202
+ trianglesKept: number;
203
+ /** Pixel centres whose coverage changed between readings. */
204
+ pixelsFlipped: number;
205
+ /** Columns and rows of the distance transform carried readings redid. */
206
+ columnsRedone: number;
207
+ rowsRedone: number;
208
+ /** Outline deviations measured, and handed back because the outline and the polygon it is read against equal the last ones. */
209
+ outlinesMeasured: number;
210
+ outlinesReused: number;
211
+ /** Point-to-edge distance lists an outline measurement asked for, and how each was had: handed back whole, carried edge by edge from the last list of the same point, or computed. */
212
+ distanceLists: number;
213
+ distanceListsReused: number;
214
+ distanceListsCarried: number;
215
+ /** Edges a carried list took from the last list of its point, and edges it computed because the edge is new. */
216
+ edgesCarried: number;
217
+ edgesComputed: number;
218
+ }
219
+
220
+ /**
221
+ * A fault planted on purpose in the carried path, for the mesh-quality
222
+ * controls to show they catch it: one added triangle not drawn, one changed
223
+ * column or row of the distance transform not redone, or an outline reading
224
+ * reused when only the vertex counts match. `reduceMesh` plants none.
225
+ */
226
+ export type StepRasterPlant = 'skip-a-triangle' | 'skip-a-column' | 'skip-a-row' | 'stale-outline' | 'skip-an-edge';
227
+
228
+ /**
229
+ * One outline slot's carried distances (`src/meshquality.ts`, `hausdorff`): a
230
+ * directed Hausdorff reading asks, for points along one polygon, the distance
231
+ * to every edge of the other, and those lists are kept from the last reading
232
+ * of the slot by the point they were asked for, keyed by its exact
233
+ * coordinates.
234
+ *
235
+ * - `toAgainst` — lists of distances to the edges of the polygon the outline
236
+ * is read against (fixed for a reduction). Kept while that polygon is equal;
237
+ * an entry not asked for by the last two readings is dropped.
238
+ * - `toOutline` — lists of distances to the outline's own edges, which a step
239
+ * changes. Kept for the last reading's outline only, with its edges
240
+ * (`outlineEdges`, keyed by both ends), so the next reading copies each
241
+ * distance whose edge it still has and computes the rest.
242
+ */
243
+ export interface OutlineMemo {
244
+ against: Float64Array | null;
245
+ toAgainst: Map<string, { list: Float64Array; used: number }>;
246
+ outlineEdges: Map<string, number> | null;
247
+ toOutline: Map<string, Float64Array>;
248
+ readings: number;
249
+ }
250
+
251
+ /**
252
+ * The per-step state of one reduction (issue #1246): the last coverage
253
+ * reading and the last outline deviations, so the next measurement redoes only
254
+ * what its mesh changed. Like `ArtRasters` it is a value made by the caller,
255
+ * passed explicitly and dropped with the call; nothing is kept at module level.
256
+ *
257
+ * ## Why every reading equals the full one
258
+ *
259
+ * A reduction step changes the triangles of one vertex's link and nothing
260
+ * else, yet the full measurement redraws every triangle and redoes the whole
261
+ * distance transform. Here each quantity is carried from the last reading by a
262
+ * rule under which it is the same function of the same input:
263
+ *
264
+ * - **Coverage** is held as a count per pixel centre of the triangles whose
265
+ * centre test (`triangleRasterBox`, `pixelCentreInTriangle` — the rule
266
+ * `rasteriseTriangles` reads) holds there. The triangles are compared with
267
+ * the last reading's as a multiset keyed by their three corners' exact
268
+ * coordinates in the order given (a zero's sign included); a triangle taken
269
+ * away is drawn with −1 and one added with +1, so every count is the exact
270
+ * number of triangles covering that centre, and a centre is covered exactly
271
+ * when its count is positive — `rasteriseTriangles`'s union.
272
+ * - **The distance transform** is `squaredDistanceToSet`'s two passes
273
+ * (`distancePassesOf`). A column's pass reads that column of the coverage
274
+ * alone, so only columns holding a flipped centre are redone; a row's pass
275
+ * reads that row of the column results alone, so only rows where a redone
276
+ * column's value changed are redone. Every other value is the output of the
277
+ * same pass over the same input.
278
+ * - **Covered art and islands** are integer counts updated at each flipped
279
+ * centre: art pixels covered, centres covered, and covered pixels per island
280
+ * label; the islands touched are the labels whose count is positive.
281
+ * - **Outline deviations** (`outline`) are handed back only when the outline
282
+ * and the polygon it is read against are equal, coordinate by coordinate
283
+ * with a zero's sign, to the last ones of that slot — the same pure function
284
+ * of the same input.
285
+ *
286
+ * A reading whose triangles differ from the last in more triangles than they
287
+ * share is built from nothing, as the first one is; which way a reading is
288
+ * built changes its cost and never its values.
289
+ */
290
+ export interface StepRasters {
291
+ readonly rasters: ArtRasters;
292
+ readonly tally: StepRasterTally;
293
+ /** The coverage reading of these triangles over the drawing-pixel `points`; its arrays are valid until the next reading. */
294
+ coverage(points: Array<[number, number]>, triangles: number[]): CoverageReading;
295
+ /** The fault planted for a control, or null. */
296
+ readonly plant: StepRasterPlant | null;
297
+ /**
298
+ * `measure(memo)`'s value, or the last one of `slot` when `outline` and
299
+ * `against` equal the last ones exactly; `memo` is the slot's carried
300
+ * distances, which `measure` reads and keeps current.
301
+ */
302
+ outline<T>(slot: string, outline: ReadonlyArray<readonly [number, number]>, against: ReadonlyArray<readonly [number, number]>, measure: (memo: OutlineMemo) => T): T;
303
+ }
304
+
305
+ /** A coordinate as a key: its shortest round-trip decimal, with a negative zero told from a positive one. */
306
+ function coordKey(v: number): string {
307
+ return v === 0 && 1 / v < 0 ? '-0' : String(v);
308
+ }
309
+
310
+ /** Equal coordinate by coordinate, a zero's sign included. */
311
+ function samePolygon(a: ReadonlyArray<readonly [number, number]>, b: Float64Array): boolean {
312
+ if (a.length * 2 !== b.length) return false;
313
+ for (let i = 0; i < a.length; i++) if (!Object.is(a[i][0], b[i * 2]) || !Object.is(a[i][1], b[i * 2 + 1])) return false;
314
+ return true;
315
+ }
316
+
317
+ function flat(p: ReadonlyArray<readonly [number, number]>): Float64Array {
318
+ const out = new Float64Array(p.length * 2);
319
+ for (let i = 0; i < p.length; i++) {
320
+ out[i * 2] = p[i][0];
321
+ out[i * 2 + 1] = p[i][1];
322
+ }
323
+ return out;
324
+ }
325
+
326
+ type Corners = [readonly [number, number], readonly [number, number], readonly [number, number]];
327
+
328
+ interface HeldTriangles {
329
+ corners: Corners;
330
+ n: number;
331
+ }
332
+
333
+ interface CoverageState {
334
+ held: Map<string, HeldTriangles>;
335
+ /** Triangles covering each pixel centre. */
336
+ count: Int32Array;
337
+ covered: Uint8Array;
338
+ /** The column pass of the distance transform, and the transform. */
339
+ columns: Float64Array;
340
+ dist: Float64Array;
341
+ coveredArt: number;
342
+ coveredTotal: number;
343
+ /** Covered pixels per island label (index 0 unused). */
344
+ islandHits: Int32Array;
345
+ islandsTouched: number;
346
+ /** Art pixels not covered. */
347
+ uncoveredArt: Set<number>;
348
+ /** Per connectivity, once read: covered centres outside the filled silhouette, and covered centres inside it that are not art. */
349
+ silhouettes: Map<4 | 8, { outside: Set<number>; holes: Set<number> }>;
350
+ }
351
+
352
+ /** Step rasters over `rasters`' art. Reads nothing until the first reading. */
353
+ export function stepRastersOf(rasters: ArtRasters, plant: StepRasterPlant | null = null): StepRasters {
354
+ const tally: StepRasterTally = {
355
+ readings: 0,
356
+ rebuilt: 0,
357
+ carried: 0,
358
+ trianglesDrawn: 0,
359
+ trianglesKept: 0,
360
+ pixelsFlipped: 0,
361
+ columnsRedone: 0,
362
+ rowsRedone: 0,
363
+ outlinesMeasured: 0,
364
+ outlinesReused: 0,
365
+ distanceLists: 0,
366
+ distanceListsReused: 0,
367
+ distanceListsCarried: 0,
368
+ edgesCarried: 0,
369
+ edgesComputed: 0,
370
+ };
371
+ const { width: w, height: h } = rasters.art.mask;
372
+ let state: CoverageState | null = null;
373
+ const outlines = new Map<string, { outline: Float64Array; against: Float64Array; value: unknown }>();
374
+ const memos = new Map<string, OutlineMemo>();
375
+
376
+ /** Add `sign` to the count of every centre the triangle covers, pushing each such centre to `touched`. */
377
+ const draw = (count: Int32Array, corners: Corners, sign: number, touched: number[] | null): void => {
378
+ const [a, b, c] = corners;
379
+ const box = triangleRasterBox(a, b, c, w, h);
380
+ if (box === null) return;
381
+ for (let y = box.minY; y <= box.maxY; y++) {
382
+ for (let x = box.minX; x <= box.maxX; x++) {
383
+ if (!pixelCentreInTriangle(x, y, a, b, c, box.orient)) continue;
384
+ count[y * w + x] += sign;
385
+ if (touched !== null) touched.push(y * w + x);
386
+ }
387
+ }
388
+ };
389
+
390
+ const readingOf = (s: CoverageState): CoverageReading => {
391
+ let second = 0;
392
+ if (s.islandsTouched >= 2) {
393
+ let seen = 0;
394
+ for (let l = 1; l < s.islandHits.length; l++) {
395
+ if (s.islandHits[l] > 0 && ++seen === 2) {
396
+ second = l;
397
+ break;
398
+ }
399
+ }
400
+ }
401
+ // The worst of a set by the full scan's rule: the largest value, the smallest index among equals.
402
+ const worstOf = (set: Set<number>, value: Float64Array): { at: number; sq: number } => {
403
+ let at = -1;
404
+ let sq = 0;
405
+ for (const i of set) {
406
+ const v = value[i];
407
+ if (at === -1 || v > sq || (v === sq && i < at)) {
408
+ at = i;
409
+ sq = v;
410
+ }
411
+ }
412
+ return { at, sq };
413
+ };
414
+ const silhouette = (connectivity: 4 | 8): SilhouetteReading => {
415
+ let sets = s.silhouettes.get(connectivity);
416
+ if (sets === undefined) {
417
+ const fills = rasters.fills();
418
+ const filled = connectivity === 8 ? fills.fill8 : fills.fill4;
419
+ const artBits = rasters.artBits();
420
+ sets = { outside: new Set(), holes: new Set() };
421
+ for (let i = 0; i < s.covered.length; i++) {
422
+ if (!s.covered[i]) continue;
423
+ if (!filled[i]) sets.outside.add(i);
424
+ else if (!artBits[i]) sets.holes.add(i);
425
+ }
426
+ s.silhouettes.set(connectivity, sets);
427
+ }
428
+ const over = worstOf(sets.outside, rasters.toFilled(connectivity));
429
+ let holeAt = -1;
430
+ for (const i of sets.holes) if (holeAt === -1 || i < holeAt) holeAt = i;
431
+ return { overAt: over.at, overSq: over.sq, holes: sets.holes.size, holeAt };
432
+ };
433
+ return {
434
+ covered: s.covered,
435
+ toCovered: s.dist,
436
+ coveredArt: s.coveredArt,
437
+ anyCovered: s.coveredTotal > 0,
438
+ undercut: worstOf(s.uncoveredArt, s.dist),
439
+ silhouette,
440
+ islandsTouched: s.islandsTouched,
441
+ secondIsland: second,
442
+ };
443
+ };
444
+
445
+ const rebuild = (held: Map<string, HeldTriangles>): CoverageState => {
446
+ tally.rebuilt++;
447
+ const artBits = rasters.artBits();
448
+ const { label, sizes } = rasters.islands();
449
+ const count = new Int32Array(w * h);
450
+ for (const { corners, n } of held.values()) for (let k = 0; k < n; k++) draw(count, corners, 1, null);
451
+ const covered = new Uint8Array(w * h);
452
+ const islandHits = new Int32Array(sizes.length + 1);
453
+ let coveredArt = 0;
454
+ let coveredTotal = 0;
455
+ let islandsTouched = 0;
456
+ const uncoveredArt = new Set<number>();
457
+ for (let i = 0; i < covered.length; i++) {
458
+ if (count[i] <= 0) {
459
+ if (artBits[i]) uncoveredArt.add(i);
460
+ continue;
461
+ }
462
+ covered[i] = 1;
463
+ coveredTotal++;
464
+ if (artBits[i]) coveredArt++;
465
+ if (label[i] && islandHits[label[i]]++ === 0) islandsTouched++;
466
+ }
467
+ const passes = distancePassesOf(w, h);
468
+ const columns = new Float64Array(w * h);
469
+ for (let x = 0; x < w; x++) {
470
+ const out = passes.column(covered, x);
471
+ for (let y = 0; y < h; y++) columns[y * w + x] = out[y];
472
+ }
473
+ const dist = new Float64Array(w * h);
474
+ for (let y = 0; y < h; y++) {
475
+ const out = passes.row(columns, y);
476
+ for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
477
+ }
478
+ return { held, count, covered, columns, dist, coveredArt, coveredTotal, islandHits, islandsTouched, uncoveredArt, silhouettes: new Map() };
479
+ };
480
+
481
+ /** Carry `s` to the triangles `held` by drawing the ones that differ, then redo the distance transform where it changed. */
482
+ const carry = (s: CoverageState, held: Map<string, HeldTriangles>, removed: Corners[], added: Corners[]): void => {
483
+ tally.carried++;
484
+ tally.trianglesDrawn += removed.length + added.length;
485
+ const touched: number[] = [];
486
+ for (const corners of removed) draw(s.count, corners, -1, touched);
487
+ added.forEach((corners, k) => {
488
+ if (plant === 'skip-a-triangle' && k === 0) return;
489
+ draw(s.count, corners, 1, touched);
490
+ });
491
+ s.held = held;
492
+ // Flip the centres whose count crossed zero, keeping the integer counts with them.
493
+ const artBits = rasters.artBits();
494
+ const { label } = rasters.islands();
495
+ const fills = s.silhouettes.size > 0 ? rasters.fills() : null;
496
+ const dirtyColumns = new Uint8Array(w);
497
+ for (const i of touched) {
498
+ const now = s.count[i] > 0 ? 1 : 0;
499
+ if (now === s.covered[i]) continue;
500
+ s.covered[i] = now;
501
+ tally.pixelsFlipped++;
502
+ dirtyColumns[i % w] = 1;
503
+ const d = now === 1 ? 1 : -1;
504
+ s.coveredTotal += d;
505
+ if (artBits[i]) {
506
+ s.coveredArt += d;
507
+ if (now === 1) s.uncoveredArt.delete(i);
508
+ else s.uncoveredArt.add(i);
509
+ }
510
+ for (const [connectivity, sets] of s.silhouettes) {
511
+ if (now === 0) {
512
+ sets.outside.delete(i);
513
+ sets.holes.delete(i);
514
+ continue;
515
+ }
516
+ const filled = connectivity === 8 ? fills!.fill8 : fills!.fill4;
517
+ if (!filled[i]) sets.outside.add(i);
518
+ else if (!artBits[i]) sets.holes.add(i);
519
+ }
520
+ const l = label[i];
521
+ if (l) {
522
+ const before = s.islandHits[l];
523
+ s.islandHits[l] += d;
524
+ if (before === 0) s.islandsTouched++;
525
+ else if (s.islandHits[l] === 0) s.islandsTouched--;
526
+ }
527
+ }
528
+ // Redo the columns holding a flipped centre, then the rows where a column value changed.
529
+ const passes = distancePassesOf(w, h);
530
+ const dirtyRows = new Uint8Array(h);
531
+ let skipColumn = plant === 'skip-a-column';
532
+ for (let x = 0; x < w; x++) {
533
+ if (!dirtyColumns[x]) continue;
534
+ if (skipColumn) {
535
+ skipColumn = false;
536
+ continue;
537
+ }
538
+ tally.columnsRedone++;
539
+ const out = passes.column(s.covered, x);
540
+ for (let y = 0; y < h; y++) {
541
+ if (Object.is(s.columns[y * w + x], out[y])) continue;
542
+ s.columns[y * w + x] = out[y];
543
+ dirtyRows[y] = 1;
544
+ }
545
+ }
546
+ let skipRow = plant === 'skip-a-row';
547
+ for (let y = 0; y < h; y++) {
548
+ if (!dirtyRows[y]) continue;
549
+ if (skipRow) {
550
+ skipRow = false;
551
+ continue;
552
+ }
553
+ tally.rowsRedone++;
554
+ const out = passes.row(s.columns, y);
555
+ for (let x = 0; x < w; x++) s.dist[y * w + x] = out[x];
556
+ }
557
+ };
558
+
559
+ return {
560
+ rasters,
561
+ tally,
562
+ coverage: (points, triangles) => {
563
+ tally.readings++;
564
+ const held = new Map<string, HeldTriangles>();
565
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
566
+ const corners: Corners = [points[triangles[t]], points[triangles[t + 1]], points[triangles[t + 2]]];
567
+ const key = `${coordKey(corners[0][0])},${coordKey(corners[0][1])},${coordKey(corners[1][0])},${coordKey(corners[1][1])},${coordKey(corners[2][0])},${coordKey(corners[2][1])}`;
568
+ const entry = held.get(key);
569
+ if (entry === undefined) held.set(key, { corners, n: 1 });
570
+ else entry.n++;
571
+ }
572
+ if (state === null) {
573
+ state = rebuild(held);
574
+ return readingOf(state);
575
+ }
576
+ // The multiset difference against the last reading's triangles.
577
+ const removed: Corners[] = [];
578
+ const added: Corners[] = [];
579
+ let kept = 0;
580
+ for (const [key, { corners, n }] of held) {
581
+ const was = state.held.get(key)?.n ?? 0;
582
+ kept += Math.min(was, n);
583
+ for (let k = was; k < n; k++) added.push(corners);
584
+ }
585
+ for (const [key, { corners, n }] of state.held) {
586
+ const now = held.get(key)?.n ?? 0;
587
+ for (let k = now; k < n; k++) removed.push(corners);
588
+ }
589
+ if (removed.length + added.length > kept) {
590
+ state = rebuild(held);
591
+ return readingOf(state);
592
+ }
593
+ tally.trianglesKept += kept;
594
+ carry(state, held, removed, added);
595
+ return readingOf(state);
596
+ },
597
+ plant,
598
+ outline: <T>(slot: string, outline: ReadonlyArray<readonly [number, number]>, against: ReadonlyArray<readonly [number, number]>, measure: (memo: OutlineMemo) => T): T => {
599
+ const last = outlines.get(slot);
600
+ if (last !== undefined) {
601
+ const same =
602
+ plant === 'stale-outline'
603
+ ? last.outline.length === outline.length * 2 && last.against.length === against.length * 2
604
+ : samePolygon(outline, last.outline) && samePolygon(against, last.against);
605
+ if (same) {
606
+ tally.outlinesReused++;
607
+ return last.value as T;
608
+ }
609
+ }
610
+ tally.outlinesMeasured++;
611
+ let memo = memos.get(slot);
612
+ if (memo === undefined) {
613
+ memo = { against: null, toAgainst: new Map(), outlineEdges: null, toOutline: new Map(), readings: 0 };
614
+ memos.set(slot, memo);
615
+ }
616
+ const value = measure(memo);
617
+ outlines.set(slot, { outline: flat(outline), against: flat(against), value });
618
+ return value;
619
+ },
620
+ };
621
+ }