spine-rigc 2.20.3 → 2.21.0

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.3",
3
+ "version": "2.21.0",
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": {
@@ -191,8 +191,11 @@ function missing(field: string, found: unknown, required: string): never {
191
191
  }
192
192
 
193
193
  function validateFit(field: string, f: unknown): void {
194
- if (!isObject(f) || !isFiniteNumber(f.minCoverage) || f.minCoverage < 0 || f.minCoverage > 1 || !isFiniteNumber(f.maxOvershoot) || f.maxOvershoot < 0 || !isFiniteNumber(f.maxUndercut) || f.maxUndercut < 0) {
195
- missing(field, f, '{ minCoverage in 0..1, maxOvershoot >= 0, maxUndercut >= 0 }');
194
+ // `null` on maxOvershoot or maxUndercut is a bound declared absent (issue #1254): measured, reported `undeclared`,
195
+ // never refusing a reference or failing a candidate. A field left out is still refused.
196
+ const distance = (v: unknown): boolean => v === null || (isFiniteNumber(v) && v >= 0);
197
+ if (!isObject(f) || !isFiniteNumber(f.minCoverage) || f.minCoverage < 0 || f.minCoverage > 1 || !distance(f.maxOvershoot) || !distance(f.maxUndercut)) {
198
+ missing(field, f, '{ minCoverage in 0..1, maxOvershoot >= 0 or null, maxUndercut >= 0 or null }');
196
199
  }
197
200
  }
198
201
 
@@ -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
  // ---------------------------------------------------------------------------
@@ -116,14 +116,23 @@ export interface SourceMesh {
116
116
  weights: Array<Array<{ bone: string; weight: number }>> | null;
117
117
  }
118
118
 
119
- /** All distances in the drawing's pixels; converted to texels by `pageScale`. */
119
+ /**
120
+ * All distances in the drawing's pixels; converted to texels by `pageScale`.
121
+ *
122
+ * `maxOvershoot` and `maxUndercut` may be `null` — a bound **declared absent**
123
+ * (issue #1254): the row is measured and reported `undeclared`, with its value
124
+ * and worst sample, and never passes, fails, blocks a step or refuses a source.
125
+ * A field left out (`undefined`) is still refused by name: an omission cannot be
126
+ * told from a caller that forgot, and only the explicit `null` says "measured,
127
+ * not bounded". `minCoverage` has no such form and is always required.
128
+ */
120
129
  export interface ArtFitBounds {
121
130
  /** Share of art pixel centres the triangles cover, 0..1. */
122
131
  minCoverage: number;
123
- /** Furthest a covered pixel may sit outside the filled silhouette, px. */
124
- maxOvershoot: number;
125
- /** Furthest an uncovered art pixel may sit from the covered set, px. */
126
- maxUndercut: number;
132
+ /** Furthest a covered pixel may sit outside the filled silhouette, px; null = declared absent. */
133
+ maxOvershoot: number | null;
134
+ /** Furthest an uncovered art pixel may sit from the covered set, px; null = declared absent. */
135
+ maxUndercut: number | null;
127
136
  }
128
137
 
129
138
  /** What a reduction's RESULT must satisfy (correction 3). Read by `reduceMesh`, stage B2. */
@@ -607,7 +616,7 @@ function validateInput(input: MeshMeasureInput): void {
607
616
  if (!isFiniteNumber(coverage) || coverage < 0 || coverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.minCoverage is ${JSON.stringify(coverage)}; required a fraction in 0..1`);
608
617
  for (const field of ['maxOvershoot', 'maxUndercut'] as const) {
609
618
  const v = fit[field];
610
- if (!isFiniteNumber(v) || v < 0) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more`);
619
+ if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: targets.artFit.${field} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
611
620
  }
612
621
  }
613
622
  present(who, 'targets.maxBoundaryDeviation', targets.maxBoundaryDeviation, 'a number of px or null');
@@ -1298,8 +1307,8 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1298
1307
  if (artCount < input.minArtSamples) {
1299
1308
  const why = `attachment ${nameOf(attachment)} has ${artCount} art sample(s) at alpha >= ${threshold}; required at least ${input.minArtSamples} (minArtSamples, P9) — a row over fewer is not a measurement`;
1300
1309
  rows.push(withRaster(unmeasuredRow(spec('MQ_COVERAGE', 'fraction'), 'not-measurable', why, fit !== null), rasterArt(null), grid, artCount === 0 ? 1 : 1 / artCount));
1301
- rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null), rasterArt(8), grid, pxIncrement));
1302
- rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null), rasterArt(null), grid, pxIncrement));
1310
+ rows.push(withRaster(unmeasuredRow(spec('MQ_OVERSHOOT', 'px'), 'not-measurable', why, fit !== null && fit.maxOvershoot !== null), rasterArt(8), grid, pxIncrement));
1311
+ rows.push(withRaster(unmeasuredRow(spec('MQ_UNDERCUT', 'px'), 'not-measurable', why, fit !== null && fit.maxUndercut !== null), rasterArt(null), grid, pxIncrement));
1303
1312
  rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
1304
1313
  rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
1305
1314
  } else {
@@ -1322,8 +1331,8 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1322
1331
  rows.push(
1323
1332
  withRaster(
1324
1333
  !anyCovered
1325
- ? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null)
1326
- : measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
1334
+ ? unmeasuredRow(undercutSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the triangles cover no pixel centre of the ${w}x${h} grid, so no art pixel has a distance to a covered one`, fit !== null && fit.maxUndercut !== null)
1335
+ : measuredRow(undercutSpec, r6(Math.sqrt(undercutSq) / scale), fit === null || fit.maxUndercut === null ? null : { op: '<=', value: fit.maxUndercut }, missingWorst, true),
1327
1336
  rasterArt(null),
1328
1337
  grid,
1329
1338
  pxIncrement,
@@ -1335,7 +1344,7 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1335
1344
  const fillsDiffer = rasters.fills().differ;
1336
1345
  const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
1337
1346
  const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
1338
- const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1347
+ const bound = gated && fit !== null && fit.maxOvershoot !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1339
1348
  rows.push(
1340
1349
  withRaster(
1341
1350
  measuredRow(spec('MQ_OVERSHOOT', 'px'), r6(Math.sqrt(overSq) / scale), bound, overAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(overAt) } }, gated),
@@ -1427,7 +1436,7 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: S
1427
1436
  );
1428
1437
 
1429
1438
  // --- regions (§5) ------------------------------------------------------------------------
1430
- rows.push(...regionRows(input, outline, hullPolygon, rasters));
1439
+ rows.push(...regionRows(input, outline, hullPolygon, rasters, steps));
1431
1440
 
1432
1441
  const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
1433
1442
  const geometry = sectionOf(ordered);
@@ -1477,7 +1486,7 @@ function sectionOf(built: readonly Built[]): EvidenceSection {
1477
1486
  // ---------------------------------------------------------------------------
1478
1487
 
1479
1488
  /** 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 {
1489
+ function regionRefusal(region: RefinementRegion, pageScale: number, who: string, steps: StepRasters | null): { code: string; message: string } | null {
1481
1490
  const numbers: Array<[string, unknown]> = [
1482
1491
  ['maxEdgeLength', region.maxEdgeLength],
1483
1492
  ['transition', region.transition],
@@ -1495,7 +1504,8 @@ function regionRefusal(region: RefinementRegion, pageScale: number, who: string)
1495
1504
  message: `${who}, region "${region.name}": maxEdgeLength is ${region.maxEdgeLength} px; required at least one texel, ${r6(1 / pageScale)} px at pageScale ${pageScale}`,
1496
1505
  };
1497
1506
  }
1498
- const crossing = findSelfIntersection(region.polygon);
1507
+ // A function of the polygon alone: a reduction step has it handed back by its `StepRasters` (issue #1253).
1508
+ const crossing = steps === null ? findSelfIntersection(region.polygon) : steps.polygonCrossing(region.polygon, () => findSelfIntersection(region.polygon));
1499
1509
  if (crossing !== null) {
1500
1510
  return {
1501
1511
  code: 'REGION_SELF_INTERSECTS',
@@ -1524,8 +1534,13 @@ interface Edge {
1524
1534
  * distance from that region. A row's value is the edge whose
1525
1535
  * length most exceeds its own bound — so the row fails exactly when some edge
1526
1536
  * it covers is over — and the row names that edge and that bound.
1537
+ *
1538
+ * `MQ_FILL_DISTANCE` reads the art pixels whose centre lies in the region's
1539
+ * closed polygon, a function of the art and the polygon alone: a reduction
1540
+ * step has it handed back by its `StepRasters` (`regionPixels`, issue #1253)
1541
+ * rather than testing every art pixel against the polygon again.
1527
1542
  */
1528
- function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
1543
+ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters, steps: StepRasters | null): Built[] {
1529
1544
  const { attachment, source, art } = input;
1530
1545
  const who = `attachment ${nameOf(attachment)}`;
1531
1546
  const scale = art.frame.pageScale;
@@ -1541,14 +1556,35 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1541
1556
  }
1542
1557
  edges.sort((p, q) => p.a - q.a || p.b - q.b);
1543
1558
 
1559
+ // Each edge's answers for a region: computed here, or — on a reduction step — carried by its `StepRasters` from
1560
+ // the last reading of the region for an edge with the same two ends (`regionEdges`, issue #1253).
1561
+ const readings = new Map<RefinementRegion, RegionEdgeReading | null>();
1562
+ const readingOf = (region: RefinementRegion): RegionEdgeReading | null => {
1563
+ if (!readings.has(region)) readings.set(region, steps === null ? null : steps.regionEdges(region.polygon, region.transition));
1564
+ return readings.get(region)!;
1565
+ };
1566
+ const meetsOf = (region: RefinementRegion, e: Edge): boolean => {
1567
+ const record = readingOf(region)?.entry(points[e.a], points[e.b]);
1568
+ if (record === undefined) return segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
1569
+ if (record.meets === undefined) record.meets = segmentMeetsPolygon(points[e.a], points[e.b], region.polygon);
1570
+ return record.meets;
1571
+ };
1572
+ const heldOf = (region: RefinementRegion, e: Edge): number | null => {
1573
+ const held = (): number | null => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null);
1574
+ const record = readingOf(region)?.entry(points[e.a], points[e.b]);
1575
+ if (record === undefined) return held();
1576
+ if (record.held === undefined) record.held = held();
1577
+ return record.held;
1578
+ };
1579
+
1544
1580
  // Which regions are measurable, and the one refusal of each that is not.
1545
1581
  const live: RefinementRegion[] = [];
1546
1582
  const refusals = new Map<string, string>();
1547
1583
  for (const region of input.targets.regions) {
1548
- let refusal = regionRefusal(region, scale, who);
1584
+ let refusal = regionRefusal(region, scale, who, steps);
1549
1585
  if (refusal === null) {
1550
1586
  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));
1587
+ const meets = edges.some((e) => meetsOf(region, e)) || poly.some((p) => inClosedPolygon(p, hullPolygon));
1552
1588
  if (!meets) {
1553
1589
  refusal = {
1554
1590
  code: 'REGION_OUTSIDE_ART',
@@ -1569,7 +1605,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1569
1605
  for (const region of live) {
1570
1606
  holds.set(
1571
1607
  region,
1572
- edges.map((e) => (edgeIsHeldByRegion(points[e.a], points[e.b], region) ? segmentToPolygon(points[e.a], points[e.b], region.polygon) : null)),
1608
+ edges.map((e) => heldOf(region, e)),
1573
1609
  );
1574
1610
  }
1575
1611
  const edgeIndex = new Map(edges.map((e, i) => [e, i]));
@@ -1632,23 +1668,42 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1632
1668
  }
1633
1669
  }
1634
1670
 
1635
- // h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex.
1671
+ // h(R): art pixel centres inside the region clipped to the hull, each to its nearest mesh vertex. The art
1672
+ // pixels inside the region come in ascending order, so the scan below visits what a scan of every art pixel
1673
+ // would keep, in the same order.
1674
+ const centreOf = (i: number): Pt => [((i % mask.width) + 0.5) / scale, (Math.floor(i / mask.width) + 0.5) / scale];
1675
+ const inRegion = (): Int32Array => {
1676
+ const kept: number[] = [];
1677
+ for (let i = 0; i < artBits.length; i++) if (artBits[i] && inClosedPolygon(centreOf(i), poly)) kept.push(i);
1678
+ return Int32Array.from(kept);
1679
+ };
1680
+ const regionPixels = steps === null ? inRegion() : steps.regionPixels(poly, inRegion);
1636
1681
  let count = 0;
1637
1682
  let worst = -1;
1638
1683
  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;
1684
+ const visit = (i: number, nearest: number): void => {
1645
1685
  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
1686
  if (nearest > worst) {
1649
1687
  worst = nearest;
1650
1688
  worstAt = i;
1651
1689
  }
1690
+ };
1691
+ const distance = (i: number, p: Pt): number => {
1692
+ const centre = centreOf(i);
1693
+ return Math.hypot(p[0] - centre[0], p[1] - centre[1]);
1694
+ };
1695
+ if (steps === null) {
1696
+ for (const i of regionPixels) {
1697
+ const centre = centreOf(i);
1698
+ if (!inClosedPolygon(centre, hullPolygon)) continue;
1699
+ let nearest = Infinity;
1700
+ for (const p of points) nearest = Math.min(nearest, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
1701
+ visit(i, nearest);
1702
+ }
1703
+ } else {
1704
+ // A reduction step: the pixels in the hull and their nearest distances carried from the last reading (issue #1253).
1705
+ const carried = steps.regionFill(poly, { pixels: regionPixels, centre: centreOf, hull: hullPolygon, points, inHull: (i) => inClosedPolygon(centreOf(i), hullPolygon), distance });
1706
+ for (let j = 0; j < carried.inside.length; j++) visit(carried.inside[j], carried.nearest[j]);
1652
1707
  }
1653
1708
  const fillSpec = spec('MQ_FILL_DISTANCE', 'px', region.name);
1654
1709
  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
@@ -29,7 +29,8 @@
29
29
  * #1240), and as `measureMeshQualityStep` carried from the last step's
30
30
  * measurement (`StepRasters`, issue #1246), each of which returns the same
31
31
  * report — and taken only when every row the caller's
32
- * contract requires is `pass`: the art fit at `targets.artFit`, the boundary
32
+ * contract requires is `pass`: the art fit at `targets.artFit` (an overshoot or
33
+ * undercut bound declared `null` is measured and never required), the boundary
33
34
  * deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
34
35
  * angle when declared, orientation and degeneracy at 0, and every region's
35
36
  * `MQ_MAX_EDGE` and `MQ_TRANSITION`. Before the measurement, the structural
@@ -216,11 +217,27 @@ class Stop extends Error {
216
217
  function checkFit(who: string, field: string, fit: unknown): void {
217
218
  if (fit === undefined || fit === null || !isObject(fit)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field} is ${JSON.stringify(fit)}; required { minCoverage, maxOvershoot, maxUndercut } — no field has a default`);
218
219
  if (!isFiniteNumber(fit.minCoverage) || fit.minCoverage < 0 || fit.minCoverage > 1) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.minCoverage is ${JSON.stringify(fit.minCoverage)}; required a fraction in 0..1`);
220
+ // `null` is a bound declared absent (issue #1254): measured, reported `undeclared`, never gating. A field left
221
+ // out is not the same thing and stays refused — `JSON.stringify` names it `undefined` in the message.
219
222
  for (const k of ['maxOvershoot', 'maxUndercut'] as const) {
220
- if (!isFiniteNumber(fit[k]) || (fit[k] as number) < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(fit[k])}; required a finite number of px, 0 or more`);
223
+ const v = fit[k];
224
+ if (v !== null && (!isFiniteNumber(v) || v < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: ${field}.${k} is ${JSON.stringify(v)}; required a finite number of px, 0 or more, or null (declared absent: measured, reported undeclared)`);
221
225
  }
222
226
  }
223
227
 
228
+ /**
229
+ * Whether `fit` declares the bound of an art row absent (issue #1254): the
230
+ * 8-connected `MQ_OVERSHOOT` under `maxOvershoot: null`, `MQ_UNDERCUT` under
231
+ * `maxUndercut: null`. Such a row is `undeclared` when measured and is never
232
+ * required — not for admission, not for a step, not when it could not be
233
+ * measured. Every other row answers false.
234
+ */
235
+ function artBoundAbsent(fit: ArtFitBounds, row: MeasureRow): boolean {
236
+ if (row.code === 'MQ_OVERSHOOT') return fit.maxOvershoot === null;
237
+ if (row.code === 'MQ_UNDERCUT') return fit.maxUndercut === null;
238
+ return false;
239
+ }
240
+
224
241
  /**
225
242
  * The fields a reduction reads and a measurement does not, refused by name. The
226
243
  * art, the mesh's arrays, the regions' shapes and the sample floors are refused
@@ -755,12 +772,14 @@ function rowName(row: MeasureRow): string {
755
772
 
756
773
  /**
757
774
  * The first required row that is not `pass`, in `BLOCKING_ORDER`, or null when
758
- * every one passes. A row is required exactly when its bound is declared; the
775
+ * every one passes. A row is required exactly when its bound is declared — an
776
+ * art bound declared `null` is not (`artBoundAbsent`, issue #1254); the
759
777
  * overshoot row gated is the 8-connected one (P12).
760
778
  */
761
- function firstBlockingRow(report: MeshQualityReport): MeasureRow | null {
779
+ function firstBlockingRow(report: MeshQualityReport, artFit: ArtFitBounds): MeasureRow | null {
762
780
  const rows = report.candidates[0]?.geometry?.rows ?? [];
763
781
  const required = (r: MeasureRow): boolean => {
782
+ if (artBoundAbsent(artFit, r)) return false;
764
783
  if (r.code === 'MQ_OVERSHOOT') return r.art?.connectivity === 8;
765
784
  if (r.code === 'MQ_MIN_ANGLE') return r.bound !== null;
766
785
  if (r.code === 'MQ_HOLES' || r.code === 'MQ_ISLANDS' || r.code === 'MQ_TRACE_DEVIATION' || r.code === 'MQ_FILL_DISTANCE') return false;
@@ -1142,7 +1161,7 @@ function tryRemoval(run: Run, v: number): string | null {
1142
1161
  undo();
1143
1162
  return `outline: ${err.message}`;
1144
1163
  }
1145
- const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'));
1164
+ const blocking = firstBlockingRow(measureAgainstTargets(run, canon.mesh, 'candidate'), run.input.targets.artFit);
1146
1165
  if (blocking !== null) {
1147
1166
  undo();
1148
1167
  return rowName(blocking);
@@ -1214,24 +1233,23 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1214
1233
  const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
1215
1234
 
1216
1235
  // 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
- );
1236
+ // malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes. Through the step
1237
+ // rasters when the call carries them, so what a region reads of the art alone is computed once (issue #1253).
1238
+ const admitInput: MeshMeasureInput = {
1239
+ id: 'source',
1240
+ attachment: input.attachment,
1241
+ art: input.art,
1242
+ source: src,
1243
+ targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
1244
+ referenceHull: null,
1245
+ minArtSamples: input.minArtSamples,
1246
+ regionArtSamples: input.regionArtSamples,
1247
+ protect: input.protect,
1248
+ influences: input.influences,
1249
+ boneOrder: input.boneOrder,
1250
+ preset: input.preset,
1251
+ };
1252
+ const admit = steps === null ? measureMeshQualityWith(admitInput, rasters) : measureMeshQualityStep(admitInput, steps);
1235
1253
  const effective = effectiveOf(input, admit.effective, sourceHull);
1236
1254
  const sourceCounts = admit.sourceCounts;
1237
1255
  const noMesh = (termination: Termination): MeshReductionResult => ({
@@ -1251,7 +1269,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1251
1269
  }
1252
1270
  for (const code of ['MQ_ORIENTATION', 'MQ_DEGENERATE', 'MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT']) {
1253
1271
  const row = admitRows.find((r) => r.code === code && (code !== 'MQ_OVERSHOOT' || r.art?.connectivity === 8));
1254
- if (row !== undefined && row.state !== 'pass') {
1272
+ if (row !== undefined && row.state !== 'pass' && !artBoundAbsent(input.sourceBounds, row)) {
1255
1273
  const found = row.state === 'fail' ? `${row.value} against ${row.bound!.op} ${row.bound!.value}` : `${row.state} (${row.reason ?? ''})`;
1256
1274
  throw new Stop(
1257
1275
  'invalid-input',
@@ -1295,7 +1313,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps:
1295
1313
  const refined = refineRegions(run);
1296
1314
  if (refined.kind === 'budget') return noMesh({ reason: 'budget-exhausted', candidatesTried: run.steps, budget: input.budget.maxCandidates, result: 'none-met-the-targets' });
1297
1315
  const startCanon = canonicalise(work, run.sourceTurn, boneRank);
1298
- const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'));
1316
+ const startBlock = firstBlockingRow(measureAgainstTargets(run, startCanon.mesh, 'start'), input.targets.artFit);
1299
1317
  let termination: Termination;
1300
1318
  if (startBlock !== null) {
1301
1319
  const constraint = refined.kind === 'stuck' ? refined.constraint : rowName(startBlock);