spine-rigc 2.20.2 → 2.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.20.2",
3
+ "version": "2.20.4",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,7 +96,7 @@
96
96
  "fetch-examples": "bash scripts/fetch-examples.sh",
97
97
  "bench:usage": "bun bench/count_features.ts",
98
98
  "viewer": "vite --config viewer/vite.config.ts",
99
- "prepublishOnly": "bun run typecheck && bun run lint && bun run selftest"
99
+ "prepublishOnly": "bun scripts/prepublish_gate.ts"
100
100
  },
101
101
  "devDependencies": {
102
102
  "@esotericsoftware/spine-core": "4.3.13",
@@ -69,7 +69,7 @@ import {
69
69
  type MeshOutline,
70
70
  } from './mesh.ts';
71
71
  import { areaBand, triangleAreas } from './areaband.ts';
72
- import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
72
+ import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
73
73
  import { cropToSpineY } from './transform.ts';
74
74
 
75
75
  // ---------------------------------------------------------------------------
@@ -1427,7 +1427,7 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1427
1427
  );
1428
1428
 
1429
1429
  // --- regions (§5) ------------------------------------------------------------------------
1430
- rows.push(...regionRows(input, outline, hullPolygon, rasters));
1430
+ rows.push(...regionRows(input, outline, hullPolygon, rasters, steps));
1431
1431
 
1432
1432
  const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
1433
1433
  const geometry = sectionOf(ordered);
@@ -1477,7 +1477,7 @@ function sectionOf(built: readonly Built[]): EvidenceSection {
1477
1477
  // ---------------------------------------------------------------------------
1478
1478
 
1479
1479
  /** A region's own refusal (§2's `refused`: this measurement's input is invalid), or null when it is measurable. */
1480
- function regionRefusal(region: RefinementRegion, pageScale: number, who: string): { code: string; message: string } | null {
1480
+ function regionRefusal(region: RefinementRegion, pageScale: number, who: string, steps: StepRasters | null): { code: string; message: string } | null {
1481
1481
  const numbers: Array<[string, unknown]> = [
1482
1482
  ['maxEdgeLength', region.maxEdgeLength],
1483
1483
  ['transition', region.transition],
@@ -1495,7 +1495,8 @@ function regionRefusal(region: RefinementRegion, pageScale: number, who: string)
1495
1495
  message: `${who}, region "${region.name}": maxEdgeLength is ${region.maxEdgeLength} px; required at least one texel, ${r6(1 / pageScale)} px at pageScale ${pageScale}`,
1496
1496
  };
1497
1497
  }
1498
- const crossing = findSelfIntersection(region.polygon);
1498
+ // A function of the polygon alone: a reduction step has it handed back by its `StepRasters` (issue #1253).
1499
+ const crossing = steps === null ? findSelfIntersection(region.polygon) : steps.polygonCrossing(region.polygon, () => findSelfIntersection(region.polygon));
1499
1500
  if (crossing !== null) {
1500
1501
  return {
1501
1502
  code: 'REGION_SELF_INTERSECTS',
@@ -1524,8 +1525,13 @@ interface Edge {
1524
1525
  * distance from that region. A row's value is the edge whose
1525
1526
  * length most exceeds its own bound — so the row fails exactly when some edge
1526
1527
  * it covers is over — and the row names that edge and that bound.
1528
+ *
1529
+ * `MQ_FILL_DISTANCE` reads the art pixels whose centre lies in the region's
1530
+ * closed polygon, a function of the art and the polygon alone: a reduction
1531
+ * step has it handed back by its `StepRasters` (`regionPixels`, issue #1253)
1532
+ * rather than testing every art pixel against the polygon again.
1527
1533
  */
1528
- function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
1534
+ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters, steps: StepRasters | null): Built[] {
1529
1535
  const { attachment, source, art } = input;
1530
1536
  const who = `attachment ${nameOf(attachment)}`;
1531
1537
  const scale = art.frame.pageScale;
@@ -1541,14 +1547,35 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1541
1547
  }
1542
1548
  edges.sort((p, q) => p.a - q.a || p.b - q.b);
1543
1549
 
1550
+ // Each edge's answers for a region: computed here, or — on a reduction step — carried by its `StepRasters` from
1551
+ // the last reading of the region for an edge with the same two ends (`regionEdges`, issue #1253).
1552
+ const readings = new Map<RefinementRegion, RegionEdgeReading | null>();
1553
+ const readingOf = (region: RefinementRegion): RegionEdgeReading | null => {
1554
+ if (!readings.has(region)) readings.set(region, steps === null ? null : steps.regionEdges(region.polygon, region.transition));
1555
+ return readings.get(region)!;
1556
+ };
1557
+ const meetsOf = (region: RefinementRegion, e: Edge): boolean => {
1558
+ const record = readingOf(region)?.entry(points[e.a], points[e.b]);
1559
+ if (record === undefined) return segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
1560
+ if (record.meets === undefined) record.meets = segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
1561
+ return record.meets;
1562
+ };
1563
+ const heldOf = (region: RefinementRegion, e: Edge): number | null => {
1564
+ const held = (): number | null => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null);
1565
+ const record = readingOf(region)?.entry(points[e.a], points[e.b]);
1566
+ if (record === undefined) return held();
1567
+ if (record.held === undefined) record.held = held();
1568
+ return record.held;
1569
+ };
1570
+
1544
1571
  // Which regions are measurable, and the one refusal of each that is not.
1545
1572
  const live: RefinementRegion[] = [];
1546
1573
  const refusals = new Map<string, string>();
1547
1574
  for (const region of input.targets.regions) {
1548
- let refusal = regionRefusal(region, scale, who);
1575
+ let refusal = regionRefusal(region, scale, who, steps);
1549
1576
  if (refusal === null) {
1550
1577
  const poly: Pt[] = region.polygon;
1551
- const meets = edges.some((e) => segmentMeetsPolygon(points[e.a], points[e.b], poly)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
1578
+ const meets = edges.some((e) => meetsOf(region, e)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
1552
1579
  if (!meets) {
1553
1580
  refusal = {
1554
1581
  code: 'REGION_OUTSIDE_ART',
@@ -1569,7 +1596,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1569
1596
  for (const region of live) {
1570
1597
  holds.set(
1571
1598
  region,
1572
- edges.map((e) => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null)),
1599
+ edges.map((e) => heldOf(region, e)),
1573
1600
  );
1574
1601
  }
1575
1602
  const edgeIndex = new Map(edges.map((e, i) => [e, i]));
@@ -1632,23 +1659,42 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1632
1659
  }
1633
1660
  }
1634
1661
 
1635
- // h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex.
1662
+ // h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex. The art
1663
+ // pixels inside the region come in ascending order, so the scan below visits what a scan of every art pixel
1664
+ // would keep, in the same order.
1665
+ const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
1666
+ const inRegion = (): Int32Array => {
1667
+ const kept: number[] = [];
1668
+ for (let i = 0; i < artBits.length; i++) if (artBits[i] && inClosedPolygon(centreOf(i), poly)) kept.push(i);
1669
+ return Int32Array.from(kept);
1670
+ };
1671
+ const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
1636
1672
  let count = 0;
1637
1673
  let worst = -1;
1638
1674
  let worstAt = -1;
1639
- for (let i = 0; i < artBits.length; i++) {
1640
- if (!artBits[i]) continue;
1641
- const x = i % mask.width;
1642
- const y = Math.floor(i / mask.width);
1643
- const centre: Pt = [(x + 0.5) / scale, (y + 0.5) / scale];
1644
- if (!inClosedPolygon(centre, poly) || !inClosedPolygon(centre, hullPolygon)) continue;
1675
+ const visit = (i: number, nearest: number): void => {
1645
1676
  count++;
1646
- let nearest = Infinity;
1647
- for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
1648
1677
  if (nearest > worst) {
1649
1678
  worst = nearest;
1650
1679
  worstAt = i;
1651
1680
  }
1681
+ };
1682
+ const distance = (i: number, p: Pt): number => {
1683
+ const centre = centreOf(i);
1684
+ return Math.hypot(p[0] - centre[0], p[1] - centre[1]);
1685
+ };
1686
+ if (steps === null) {
1687
+ for (const i of regionPixels) {
1688
+ const centre = centreOf(i);
1689
+ if (!inClosedPolygon(centre, hullPolygon)) continue;
1690
+ let nearest = Infinity;
1691
+ for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
1692
+ visit(i, nearest);
1693
+ }
1694
+ } else {
1695
+ // A reduction step: the pixels in the hull and their nearest distances carried from the last reading (issue #1253).
1696
+ const carried = steps.regionFill(poly, { pixels: regionPixels, centre: centreOf, hull: hullPolygon, points, inHull: (i) => inClosedPolygon(centreOf(i), hullPolygon), distance });
1697
+ for (let j = 0; j < carried.inside.length; j++) visit(carried.inside[j], carried.nearest[j]);
1652
1698
  }
1653
1699
  const fillSpec = spec('MQ_FILL_DISTANCE', 'px', region.name);
1654
1700
  const floor = floorOf(region.name);
@@ -215,15 +215,38 @@ export interface StepRasterTally {
215
215
  /** Edges a carried list took from the last list of its point, and edges it computed because the edge is new. */
216
216
  edgesCarried: number;
217
217
  edgesComputed: number;
218
+ /** Region pixel sets (`regionPixels`) computed, and handed back because the polygon equals one already read. */
219
+ regionSetsComputed: number;
220
+ regionSetsReused: number;
221
+ /** Region polygons checked for a self-intersection (`polygonCrossing`), and checks handed back for an equal polygon. */
222
+ polygonChecksComputed: number;
223
+ polygonChecksReused: number;
224
+ /** Region edge readings (`regionEdges`): an edge's answers carried from the last reading of its region, or computed. */
225
+ regionEdgesCarried: number;
226
+ regionEdgesComputed: number;
227
+ /** Fill readings (`regionFill`): the region pixels in the hull handed back for an equal hull, carried by the hull edges that changed, or measured from nothing; and pixels measured again inside a changed hull's box. */
228
+ fillHullsReused: number;
229
+ fillHullsCarried: number;
230
+ fillHullsMeasured: number;
231
+ fillHullPixelsMeasured: number;
232
+ /** Fill readings whose nearest-vertex distances were built from nothing, and pixels carried or measured again in the others. */
233
+ fillNearestRebuilt: number;
234
+ fillNearestCarried: number;
235
+ fillNearestMeasured: number;
218
236
  }
219
237
 
220
238
  /**
221
239
  * A fault planted on purpose in the carried path, for the mesh-quality
222
240
  * 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.
241
+ * column or row of the distance transform not redone, an outline reading
242
+ * reused when only the vertex counts match, or a region's art pixels handed
243
+ * back, or a region polygon's self-intersection check handed back, for a
244
+ * polygon that only has as many vertices as one read before, or a region
245
+ * edge's answers carried from an edge that shares only its first end,
246
+ * the region pixels near a changed hull edge not measured again, or a pixel's
247
+ * nearest vertex kept after that vertex was removed. `reduceMesh` plants none.
225
248
  */
226
- export type StepRasterPlant = 'skip-a-triangle' | 'skip-a-column' | 'skip-a-row' | 'stale-outline' | 'skip-an-edge';
249
+ export type StepRasterPlant = 'skip-a-triangle' | 'skip-a-column' | 'skip-a-row' | 'stale-outline' | 'skip-an-edge' | 'stale-region' | 'stale-polygon-check' | 'stale-region-edge' | 'stale-fill-hull' | 'stale-fill-nearest';
227
250
 
228
251
  /**
229
252
  * One outline slot's carried distances (`src/meshquality.ts`, `hausdorff`): a
@@ -283,6 +306,38 @@ export interface OutlineMemo {
283
306
  * with a zero's sign, to the last ones of that slot — the same pure function
284
307
  * of the same input.
285
308
  *
309
+ * - **A region's art pixels** (`regionPixels`, issue #1253) — the art pixels
310
+ * whose centre lies in a region's closed polygon, which `MQ_FILL_DISTANCE`
311
+ * reads — are a function of the art and the polygon alone, so a set is
312
+ * handed back whenever the polygon equals, coordinate by coordinate with a
313
+ * zero's sign, one already read through this object. A region polygon's
314
+ * self-intersection check (`polygonCrossing`), a function of the polygon
315
+ * alone, is handed back the same way.
316
+ * - **A region's edge answers** (`regionEdges`, issue #1253) — whether an
317
+ * edge meets the closed polygon, and whether the region holds it and how far
318
+ * it lies — are functions of the edge's two ends and the region's polygon and
319
+ * band alone, so each is carried from the last reading of that region for an
320
+ * edge with both ends equal, coordinate by coordinate, in the same order.
321
+ * - **A region's fill distances** (`regionFill`, issue #1253) — which of its
322
+ * art pixels lie in the hull, and each one's distance to the nearest vertex.
323
+ * Whether a pixel lies in the hull is measured again only inside the box of
324
+ * the hull edges that changed (directed, both ends exact), widened by
325
+ * `HULL_CARRY_MARGIN`: outside it the edges the two hulls share answer the
326
+ * same, a changed edge is further than the boundary tolerance, and the
327
+ * changed edges cross a horizontal ray from the pixel an even number of
328
+ * times between them — none when the pixel is right of, above or below the
329
+ * box, and when it is left of it, every changed edge that spans its row,
330
+ * whose count is even because the removed and added edges meet each vertex
331
+ * an even number of times — so the crossing parity is the last one's.
332
+ * A pixel's nearest distance is carried by the vertices that differ
333
+ * from the last reading's, compared as a multiset of exact coordinates: when
334
+ * the vertex that attained it is still there, the minimum over the vertices
335
+ * kept is the last minimum, and the new one is the smaller of it and the
336
+ * distances to the vertices added; when that vertex is gone, the pixel is
337
+ * measured again over every vertex. A minimum is exact, so either way it is
338
+ * the same number. A reading whose vertices differ from the last in more than
339
+ * they share is built from nothing.
340
+ *
286
341
  * A reading whose triangles differ from the last in more triangles than they
287
342
  * share is built from nothing, as the first one is; which way a reading is
288
343
  * built changes its cost and never its values.
@@ -300,6 +355,64 @@ export interface StepRasters {
300
355
  * distances, which `measure` reads and keeps current.
301
356
  */
302
357
  outline<T>(slot: string, outline: ReadonlyArray<readonly [number, number]>, against: ReadonlyArray<readonly [number, number]>, measure: (memo: OutlineMemo) => T): T;
358
+ /**
359
+ * `compute()`'s value — the art pixels, ascending, whose centre lies in the
360
+ * closed `polygon` — or the set already computed for a polygon equal to it
361
+ * coordinate by coordinate. The art is this object's, so the polygon is the
362
+ * whole of the key.
363
+ */
364
+ regionPixels(polygon: ReadonlyArray<readonly [number, number]>, compute: () => Int32Array): Int32Array;
365
+ /** `compute()`'s value — `findSelfIntersection` of `polygon` — or the one already computed for a polygon equal to it coordinate by coordinate. */
366
+ polygonCrossing(polygon: ReadonlyArray<readonly [number, number]>, compute: () => [number, number] | null): [number, number] | null;
367
+ /**
368
+ * One reading's answers per edge for the region with this polygon and band:
369
+ * `entry(a, b)` is the edge's record, carried from the last reading of the
370
+ * region when an edge with the same two ends (exact coordinates, this order)
371
+ * was read there, else empty for the caller to fill. Records not read again
372
+ * are dropped at the next reading.
373
+ */
374
+ regionEdges(polygon: ReadonlyArray<readonly [number, number]>, transition: number): RegionEdgeReading;
375
+ /** The region's fill reading, carried from the last one of the region with this polygon; valid until the next. */
376
+ regionFill(polygon: ReadonlyArray<readonly [number, number]>, question: RegionFillQuestion): RegionFillReading;
377
+ }
378
+
379
+ /** A region edge's answers, as `src/meshquality.ts`'s `regionRows` reads them; a field absent is not yet computed. */
380
+ export interface RegionEdgeRecord {
381
+ /** `segmentMeetsPolygon` of the edge against the closed polygon. */
382
+ meets?: boolean;
383
+ /** The edge's distance from the closed polygon when the region holds it (`edgeIsHeldByRegion`), else null. */
384
+ held?: number | null;
385
+ }
386
+
387
+ export interface RegionEdgeReading {
388
+ entry(a: readonly [number, number], b: readonly [number, number]): RegionEdgeRecord;
389
+ }
390
+
391
+ /**
392
+ * How far beyond the box of a hull's changed edges a pixel centre must lie for
393
+ * its answer to be carried, in the hull's units. Far larger than any rounding
394
+ * of a crossing's abscissa and than the boundary tolerance the test reads.
395
+ */
396
+ const HULL_CARRY_MARGIN = 1;
397
+
398
+ /** What `regionFill` is handed: the definitions it reads, which stay `src/meshquality.ts`'s. */
399
+ export interface RegionFillQuestion {
400
+ /** The region's art pixels, ascending (`regionPixels`). */
401
+ pixels: Int32Array;
402
+ /** The centre of art pixel `i`, in the hull's units. */
403
+ centre(i: number): readonly [number, number];
404
+ hull: ReadonlyArray<readonly [number, number]>;
405
+ points: ReadonlyArray<readonly [number, number]>;
406
+ /** Does the centre of art pixel `i` lie in the closed hull? */
407
+ inHull(i: number): boolean;
408
+ /** The distance from the centre of art pixel `i` to `p`. */
409
+ distance(i: number, p: readonly [number, number]): number;
410
+ }
411
+
412
+ /** The region pixels in the hull, ascending, and each one's distance to its nearest vertex. */
413
+ export interface RegionFillReading {
414
+ inside: Int32Array;
415
+ nearest: Float64Array;
303
416
  }
304
417
 
305
418
  /** A coordinate as a key: its shortest round-trip decimal, with a negative zero told from a positive one. */
@@ -325,6 +438,19 @@ function flat(p: ReadonlyArray<readonly [number, number]>): Float64Array {
325
438
 
326
439
  type Corners = [readonly [number, number], readonly [number, number], readonly [number, number]];
327
440
 
441
+ /** One region's carried fill state: the pixels it was read for, the hull and the positions inside it, and per pixel the nearest vertex. */
442
+ interface FillSlot {
443
+ polygon: Float64Array;
444
+ pixels: Int32Array;
445
+ /** The last hull's directed edges by both ends' exact coordinates, with their counts, and per pixel whether it lay in that hull. */
446
+ hullEdges: Map<string, { a: readonly [number, number]; b: readonly [number, number]; n: number }> | null;
447
+ inHull: Uint8Array;
448
+ points: Map<string, { p: readonly [number, number]; n: number }> | null;
449
+ nearest: Float64Array;
450
+ nearX: Float64Array;
451
+ nearY: Float64Array;
452
+ }
453
+
328
454
  interface HeldTriangles {
329
455
  corners: Corners;
330
456
  n: number;
@@ -367,11 +493,28 @@ export function stepRastersOf(rasters: ArtRasters, plant: StepRasterPlant | null
367
493
  distanceListsCarried: 0,
368
494
  edgesCarried: 0,
369
495
  edgesComputed: 0,
496
+ regionSetsComputed: 0,
497
+ regionSetsReused: 0,
498
+ polygonChecksComputed: 0,
499
+ polygonChecksReused: 0,
500
+ regionEdgesCarried: 0,
501
+ regionEdgesComputed: 0,
502
+ fillHullsReused: 0,
503
+ fillHullsCarried: 0,
504
+ fillHullsMeasured: 0,
505
+ fillHullPixelsMeasured: 0,
506
+ fillNearestRebuilt: 0,
507
+ fillNearestCarried: 0,
508
+ fillNearestMeasured: 0,
370
509
  };
371
510
  const { width: w, height: h } = rasters.art.mask;
372
511
  let state: CoverageState | null = null;
373
512
  const outlines = new Map<string, { outline: Float64Array; against: Float64Array; value: unknown }>();
374
513
  const memos = new Map<string, OutlineMemo>();
514
+ const regionSets: Array<{ polygon: Float64Array; pixels: Int32Array }> = [];
515
+ const polygonChecks: Array<{ polygon: Float64Array; crossing: [number, number] | null }> = [];
516
+ const regionEdgeSlots: Array<{ polygon: Float64Array; transition: number; last: Map<string, RegionEdgeRecord> }> = [];
517
+ const fillSlots: FillSlot[] = [];
375
518
 
376
519
  /** Add `sign` to the count of every centre the triangle covers, pushing each such centre to `touched`. */
377
520
  const draw = (count: Int32Array, corners: Corners, sign: number, touched: number[] | null): void => {
@@ -617,5 +760,185 @@ export function stepRastersOf(rasters: ArtRasters, plant: StepRasterPlant | null
617
760
  outlines.set(slot, { outline: flat(outline), against: flat(against), value });
618
761
  return value;
619
762
  },
763
+ regionPixels: (polygon, compute) => {
764
+ const kept = regionSets.find((r) => (plant === 'stale-region' ? r.polygon.length === polygon.length * 2 : samePolygon(polygon, r.polygon)));
765
+ if (kept !== undefined) {
766
+ tally.regionSetsReused++;
767
+ return kept.pixels;
768
+ }
769
+ tally.regionSetsComputed++;
770
+ const pixels = compute();
771
+ regionSets.push({ polygon: flat(polygon), pixels });
772
+ return pixels;
773
+ },
774
+ polygonCrossing: (polygon, compute) => {
775
+ const kept = polygonChecks.find((r) => (plant === 'stale-polygon-check' ? r.polygon.length === polygon.length * 2 : samePolygon(polygon, r.polygon)));
776
+ if (kept !== undefined) {
777
+ tally.polygonChecksReused++;
778
+ return kept.crossing;
779
+ }
780
+ tally.polygonChecksComputed++;
781
+ const crossing = compute();
782
+ polygonChecks.push({ polygon: flat(polygon), crossing });
783
+ return crossing;
784
+ },
785
+ regionFill: (polygon, q) => {
786
+ let slot = fillSlots.find((r) => samePolygon(polygon, r.polygon));
787
+ if (slot === undefined) {
788
+ slot = { polygon: flat(polygon), pixels: q.pixels, hullEdges: null, inHull: new Uint8Array(0), points: null, nearest: new Float64Array(0), nearX: new Float64Array(0), nearY: new Float64Array(0) };
789
+ fillSlots.push(slot);
790
+ }
791
+ const n = q.pixels.length;
792
+ // The vertices, as a multiset of exact coordinates, against the last reading's.
793
+ const keyOf = (p: readonly [number, number]): string => `${coordKey(p[0])},${coordKey(p[1])}`;
794
+ const points = new Map<string, { p: readonly [number, number]; n: number }>();
795
+ for (const p of q.points) {
796
+ const k = keyOf(p);
797
+ const e = points.get(k);
798
+ if (e === undefined) points.set(k, { p, n: 1 });
799
+ else e.n++;
800
+ }
801
+ const measure = (k: number): void => {
802
+ let best = Infinity;
803
+ let bx = NaN;
804
+ let by = NaN;
805
+ for (const p of q.points) {
806
+ const d = q.distance(q.pixels[k], p);
807
+ if (d < best) {
808
+ best = d;
809
+ bx = p[0];
810
+ by = p[1];
811
+ }
812
+ }
813
+ slot!.nearest[k] = best;
814
+ slot!.nearX[k] = bx;
815
+ slot!.nearY[k] = by;
816
+ };
817
+ // A vertex whose coordinates are gone, and one whose coordinates are new; a count that only moved changes no minimum.
818
+ let gone: Set<string> | null = null;
819
+ const added: Array<readonly [number, number]> = [];
820
+ const samePixels = slot.points !== null && slot.pixels === q.pixels;
821
+ if (samePixels) {
822
+ gone = new Set();
823
+ let kept = 0;
824
+ let differ = 0;
825
+ for (const [k, { p, n: now }] of points) {
826
+ const was = slot.points!.get(k)?.n ?? 0;
827
+ kept += Math.min(was, now);
828
+ differ += Math.max(0, now - was);
829
+ if (was === 0) added.push(p);
830
+ }
831
+ for (const [k, { n: was }] of slot.points!) {
832
+ const now = points.get(k)?.n ?? 0;
833
+ differ += Math.max(0, was - now);
834
+ if (now === 0) gone.add(k);
835
+ }
836
+ if (differ > kept) gone = null;
837
+ }
838
+ if (gone === null) {
839
+ tally.fillNearestRebuilt++;
840
+ slot.pixels = q.pixels;
841
+ slot.nearest = new Float64Array(n);
842
+ slot.nearX = new Float64Array(n);
843
+ slot.nearY = new Float64Array(n);
844
+ for (let k = 0; k < n; k++) measure(k);
845
+ } else {
846
+ for (let k = 0; k < n; k++) {
847
+ if (plant !== 'stale-fill-nearest' && gone.has(`${coordKey(slot.nearX[k])},${coordKey(slot.nearY[k])}`)) {
848
+ tally.fillNearestMeasured++;
849
+ measure(k);
850
+ continue;
851
+ }
852
+ tally.fillNearestCarried++;
853
+ for (const p of added) {
854
+ const d = q.distance(q.pixels[k], p);
855
+ if (d < slot.nearest[k]) {
856
+ slot.nearest[k] = d;
857
+ slot.nearX[k] = p[0];
858
+ slot.nearY[k] = p[1];
859
+ }
860
+ }
861
+ }
862
+ }
863
+ slot.points = points;
864
+ // The pixels in the hull: carried outside the box of the hull edges that changed, measured again inside it.
865
+ const hullEdges = new Map<string, { a: readonly [number, number]; b: readonly [number, number]; n: number }>();
866
+ for (let k = 0; k < q.hull.length; k++) {
867
+ const a = q.hull[k];
868
+ const b = q.hull[(k + 1) % q.hull.length];
869
+ const key = `${keyOf(a)}>${keyOf(b)}`;
870
+ const e = hullEdges.get(key);
871
+ if (e === undefined) hullEdges.set(key, { a, b, n: 1 });
872
+ else e.n++;
873
+ }
874
+ if (!samePixels || slot.hullEdges === null) {
875
+ tally.fillHullsMeasured++;
876
+ slot.inHull = new Uint8Array(n);
877
+ for (let k = 0; k < n; k++) slot.inHull[k] = q.inHull(q.pixels[k]) ? 1 : 0;
878
+ } else {
879
+ let minX = Infinity;
880
+ let minY = Infinity;
881
+ let maxX = -Infinity;
882
+ let maxY = -Infinity;
883
+ const widen = (p: readonly [number, number]): void => {
884
+ minX = Math.min(minX, p[0]);
885
+ maxX = Math.max(maxX, p[0]);
886
+ minY = Math.min(minY, p[1]);
887
+ maxY = Math.max(maxY, p[1]);
888
+ };
889
+ for (const [key, e] of hullEdges) if ((slot.hullEdges.get(key)?.n ?? 0) !== e.n) (widen(e.a), widen(e.b));
890
+ for (const [key, e] of slot.hullEdges) if ((hullEdges.get(key)?.n ?? 0) !== e.n) (widen(e.a), widen(e.b));
891
+ if (minX === Infinity) tally.fillHullsReused++;
892
+ else {
893
+ tally.fillHullsCarried++;
894
+ minX -= HULL_CARRY_MARGIN;
895
+ minY -= HULL_CARRY_MARGIN;
896
+ maxX += HULL_CARRY_MARGIN;
897
+ maxY += HULL_CARRY_MARGIN;
898
+ for (let k = 0; k < n; k++) {
899
+ const c = q.centre(q.pixels[k]);
900
+ if (c[0] < minX || c[0] > maxX || c[1] < minY || c[1] > maxY) continue;
901
+ if (plant === 'stale-fill-hull') continue;
902
+ tally.fillHullPixelsMeasured++;
903
+ slot.inHull[k] = q.inHull(q.pixels[k]) ? 1 : 0;
904
+ }
905
+ }
906
+ }
907
+ slot.hullEdges = hullEdges;
908
+ const kept: number[] = [];
909
+ for (let k = 0; k < n; k++) if (slot.inHull[k]) kept.push(k);
910
+ const inside = new Int32Array(kept.length);
911
+ const nearest = new Float64Array(kept.length);
912
+ kept.forEach((k, j) => {
913
+ inside[j] = q.pixels[k];
914
+ nearest[j] = slot!.nearest[k];
915
+ });
916
+ return { inside, nearest };
917
+ },
918
+ regionEdges: (polygon, transition) => {
919
+ let slot = regionEdgeSlots.find((r) => Object.is(r.transition, transition) && samePolygon(polygon, r.polygon));
920
+ if (slot === undefined) {
921
+ slot = { polygon: flat(polygon), transition, last: new Map() };
922
+ regionEdgeSlots.push(slot);
923
+ }
924
+ const last = slot.last;
925
+ const now = new Map<string, RegionEdgeRecord>();
926
+ slot.last = now;
927
+ const keyOf = (p: readonly [number, number]): string => `${coordKey(p[0])},${coordKey(p[1])}`;
928
+ return {
929
+ entry: (a, b) => {
930
+ const key = plant === 'stale-region-edge' ? keyOf(a) : `${keyOf(a)};${keyOf(b)}`;
931
+ let record = now.get(key);
932
+ if (record !== undefined) return record;
933
+ record = last.get(key);
934
+ if (record === undefined) {
935
+ tally.regionEdgesComputed++;
936
+ record = {};
937
+ } else tally.regionEdgesCarried++;
938
+ now.set(key, record);
939
+ return record;
940
+ },
941
+ };
942
+ },
620
943
  };
621
944
  }
package/src/meshreduce.ts CHANGED
@@ -1214,24 +1214,23 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1214
1214
  const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
1215
1215
 
1216
1216
  // The source, measured against its own admissibility bounds (correction 3) — this is also what refuses a
1217
- // malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes.
1218
- const admit = measureMeshQualityWith(
1219
- {
1220
- id: 'source',
1221
- attachment: input.attachment,
1222
- art: input.art,
1223
- source: src,
1224
- targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
1225
- referenceHull: null,
1226
- minArtSamples: input.minArtSamples,
1227
- regionArtSamples: input.regionArtSamples,
1228
- protect: input.protect,
1229
- influences: input.influences,
1230
- boneOrder: input.boneOrder,
1231
- preset: input.preset,
1232
- },
1233
- rasters,
1234
- );
1217
+ // malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes. Through the step
1218
+ // rasters when the call carries them, so what a region reads of the art alone is computed once (issue #1253).
1219
+ const admitInput: MeshMeasureInput = {
1220
+ id: 'source',
1221
+ attachment: input.attachment,
1222
+ art: input.art,
1223
+ source: src,
1224
+ targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
1225
+ referenceHull: null,
1226
+ minArtSamples: input.minArtSamples,
1227
+ regionArtSamples: input.regionArtSamples,
1228
+ protect: input.protect,
1229
+ influences: input.influences,
1230
+ boneOrder: input.boneOrder,
1231
+ preset: input.preset,
1232
+ };
1233
+ const admit = steps === null ? measureMeshQualityWith(admitInput, rasters) : measureMeshQualityStep(admitInput, steps);
1235
1234
  const effective = effectiveOf(input, admit.effective, sourceHull);
1236
1235
  const sourceCounts = admit.sourceCounts;
1237
1236
  const noMesh = (termination: Termination): MeshReductionResult => ({