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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.20.0",
3
+ "version": "2.20.2",
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": {
package/src/mesh.ts CHANGED
@@ -1319,23 +1319,60 @@ export function rasteriseTriangles(points: Array<[number, number]>, triangles: n
1319
1319
  const a = points[triangles[t]];
1320
1320
  const b = points[triangles[t + 1]];
1321
1321
  const c = points[triangles[t + 2]];
1322
- const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1323
- if (Math.abs(twice) < 1e-12) continue;
1324
- const orient = twice > 0 ? 1 : -1;
1325
- const minX = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1));
1326
- const maxX = Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1));
1327
- const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1));
1328
- const maxY = Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1));
1329
- for (let y = minY; y <= maxY; y++) {
1330
- for (let x = minX; x <= maxX; x++) {
1322
+ const box = triangleRasterBox(a, b, c, w, h);
1323
+ if (box === null) continue;
1324
+ for (let y = box.minY; y <= box.maxY; y++) {
1325
+ for (let x = box.minX; x <= box.maxX; x++) {
1331
1326
  if (covered[y * w + x]) continue;
1332
- if (pointInTriangle([x + 0.5, y + 0.5], a, b, c, orient)) covered[y * w + x] = 1;
1327
+ if (pixelCentreInTriangle(x, y, a, b, c, box.orient)) covered[y * w + x] = 1;
1333
1328
  }
1334
1329
  }
1335
1330
  }
1336
1331
  return covered;
1337
1332
  }
1338
1333
 
1334
+ /**
1335
+ * The pixels of a `w`x`h` grid one triangle can cover under `rasteriseTriangles`'s
1336
+ * rule — the box of its corners widened by a pixel and clamped to the grid —
1337
+ * and the orientation its centre test reads; null for a degenerate triangle
1338
+ * (zero doubled area), which covers nothing. `rasteriseTriangles` and the
1339
+ * per-step coverage of `src/meshrasters.ts` both read the rule from here and
1340
+ * from `pixelCentreInTriangle`, so the two cannot drift apart.
1341
+ */
1342
+ export function triangleRasterBox(
1343
+ a: readonly [number, number],
1344
+ b: readonly [number, number],
1345
+ c: readonly [number, number],
1346
+ w: number,
1347
+ h: number,
1348
+ ): { orient: number; minX: number; maxX: number; minY: number; maxY: number } | null {
1349
+ const twice = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1350
+ if (Math.abs(twice) < 1e-12) return null;
1351
+ return {
1352
+ orient: twice > 0 ? 1 : -1,
1353
+ minX: Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]) - 1)),
1354
+ maxX: Math.min(w - 1, Math.ceil(Math.max(a[0], b[0], c[0]) + 1)),
1355
+ minY: Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]) - 1)),
1356
+ maxY: Math.min(h - 1, Math.ceil(Math.max(a[1], b[1], c[1]) + 1)),
1357
+ };
1358
+ }
1359
+
1360
+ /**
1361
+ * Is the centre of pixel (`x`, `y`) in or on the triangle — `rasteriseTriangles`'s
1362
+ * test, with the orientation `triangleRasterBox` gave. `pointInTriangle` at
1363
+ * `[x + 0.5, y + 0.5]`, written out without the point array: the same three
1364
+ * products, in the same order, against the same tolerance.
1365
+ */
1366
+ export function pixelCentreInTriangle(x: number, y: number, a: readonly [number, number], b: readonly [number, number], c: readonly [number, number], orient: number): boolean {
1367
+ const px = x + 0.5;
1368
+ const py = y + 0.5;
1369
+ return (
1370
+ ((b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0])) * orient >= -1e-9 &&
1371
+ ((c[0] - b[0]) * (py - b[1]) - (c[1] - b[1]) * (px - b[0])) * orient >= -1e-9 &&
1372
+ ((a[0] - c[0]) * (py - c[1]) - (a[1] - c[1]) * (px - c[0])) * orient >= -1e-9
1373
+ );
1374
+ }
1375
+
1339
1376
  /**
1340
1377
  * Rasterise a triangle set over the part's pixel grid and measure two things the
1341
1378
  * skeleton file cannot say: how much of the art the mesh covers, and how far past
@@ -1492,10 +1529,33 @@ export function measureAuthoredMeshFit(
1492
1529
  * never asks (a mask with no art has no covered-outside pixel to ask about).
1493
1530
  */
1494
1531
  export function squaredDistanceToSet(inside: Uint8Array, w: number, h: number): Float64Array {
1495
- const INF = w * w + h * h + 1;
1532
+ const passes = distancePassesOf(w, h);
1496
1533
  const dist = new Float64Array(w * h);
1497
- for (let i = 0; i < dist.length; i++) dist[i] = inside[i] ? 0 : INF;
1534
+ for (let x = 0; x < w; x++) {
1535
+ const out = passes.column(inside, x);
1536
+ for (let y = 0; y < h; y++) dist[y * w + x] = out[y];
1537
+ }
1538
+ for (let y = 0; y < h; y++) {
1539
+ const out = passes.row(dist, y);
1540
+ for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
1541
+ }
1542
+ return dist;
1543
+ }
1498
1544
 
1545
+ /**
1546
+ * `squaredDistanceToSet`'s two passes over one `w`x`h` grid, one column or one
1547
+ * row per call, so a caller holding the previous grid's passes can redo only
1548
+ * the columns whose set changed and the rows whose column values changed.
1549
+ * `column(inside, x)` is column `x`'s pass over the set (`INF` where the
1550
+ * column has no set pixel); `row(columns, y)` is row `y`'s pass over the
1551
+ * column results. Each returns a scratch array whose first `h` (column) or
1552
+ * `w` (row) entries are the result, valid until the next call. A column's
1553
+ * result depends on that column of `inside` alone and a row's on that row of
1554
+ * `columns` alone, which is what makes redoing a subset exact: every output
1555
+ * is this one function of the same input.
1556
+ */
1557
+ export function distancePassesOf(w: number, h: number): { column(inside: Uint8Array, x: number): Float64Array; row(columns: Float64Array, y: number): Float64Array } {
1558
+ const INF = w * w + h * h + 1;
1499
1559
  const span = Math.max(w, h);
1500
1560
  const f = new Float64Array(span);
1501
1561
  const out = new Float64Array(span);
@@ -1526,17 +1586,18 @@ export function squaredDistanceToSet(inside: Uint8Array, w: number, h: number):
1526
1586
  }
1527
1587
  };
1528
1588
 
1529
- for (let x = 0; x < w; x++) {
1530
- for (let y = 0; y < h; y++) f[y] = dist[y * w + x];
1531
- envelope(h);
1532
- for (let y = 0; y < h; y++) dist[y * w + x] = out[y];
1533
- }
1534
- for (let y = 0; y < h; y++) {
1535
- for (let x = 0; x < w; x++) f[x] = dist[y * w + x];
1536
- envelope(w);
1537
- for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
1538
- }
1539
- return dist;
1589
+ return {
1590
+ column: (inside, x) => {
1591
+ for (let y = 0; y < h; y++) f[y] = inside[y * w + x] ? 0 : INF;
1592
+ envelope(h);
1593
+ return out;
1594
+ },
1595
+ row: (columns, y) => {
1596
+ for (let x = 0; x < w; x++) f[x] = columns[y * w + x];
1597
+ envelope(w);
1598
+ return out;
1599
+ },
1600
+ };
1540
1601
  }
1541
1602
 
1542
1603
  /**
@@ -54,26 +54,22 @@
54
54
  * `MeshReductionError` is defined in `src/mesh.ts` for exactly that reason.
55
55
  */
56
56
  import {
57
- artOf,
58
57
  checkHullOrder,
59
58
  distanceToSegment,
60
- fillEnclosed,
61
59
  findSelfIntersection,
62
- labelIslands,
63
60
  MeshError,
64
61
  MeshReductionError,
65
62
  meshEdges,
66
- prunePolygon,
67
63
  r6,
68
64
  rasteriseTriangles,
69
65
  segmentsMeet,
70
66
  squaredDistanceToSet,
71
- traceAlphaOutline,
72
67
  traceOutline,
73
68
  type AlphaMask,
74
69
  type MeshOutline,
75
70
  } from './mesh.ts';
76
71
  import { areaBand, triangleAreas } from './areaband.ts';
72
+ import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
77
73
  import { cropToSpineY } from './transform.ts';
78
74
 
79
75
  // ---------------------------------------------------------------------------
@@ -856,13 +852,15 @@ function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number
856
852
  * value already found is dropped; the rest are halved. No sampling density is
857
853
  * chosen, so nothing here can miss a maximum between two samples.
858
854
  */
859
- function directedHausdorff(a: readonly Pt[], b: readonly Pt[]): { d: number; edge: number; at: Pt } {
855
+ function directedHausdorff(a: readonly Pt[], b: readonly Pt[], carried: ((p: Pt) => Float64Array) | null = null): { d: number; edge: number; at: Pt } {
860
856
  const m = b.length;
861
- const toEach = (p: Pt): Float64Array => {
862
- const out = new Float64Array(m);
863
- for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
864
- return out;
865
- };
857
+ const toEach =
858
+ carried ??
859
+ ((p: Pt): Float64Array => {
860
+ const out = new Float64Array(m);
861
+ for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
862
+ return out;
863
+ });
866
864
  const minOf = (g: Float64Array): number => {
867
865
  let v = Infinity;
868
866
  for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
@@ -909,13 +907,136 @@ function directedHausdorff(a: readonly Pt[], b: readonly Pt[]): { d: number; edg
909
907
  * taken on when the worst point is the candidate's, else the candidate edge
910
908
  * nearest the reference's worst point.
911
909
  */
912
- function hausdorff(candidate: readonly Pt[], reference: readonly Pt[]): { d: number; candidateEdge: number } {
913
- const forward = directedHausdorff(candidate, reference);
914
- const backward = directedHausdorff(reference, candidate);
910
+ function hausdorff(candidate: readonly Pt[], reference: readonly Pt[], carried: { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array } | null = null): { d: number; candidateEdge: number } {
911
+ const forward = directedHausdorff(candidate, reference, carried?.forward ?? null);
912
+ const backward = directedHausdorff(reference, candidate, carried?.backward ?? null);
915
913
  if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
916
914
  return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
917
915
  }
918
916
 
917
+ /** A point as a key: both coordinates' shortest round-trip decimals, a negative zero told from a positive one. */
918
+ function pointKey(p: Pt): string {
919
+ const k = (v: number): string => (v === 0 && 1 / v < 0 ? '-0' : String(v));
920
+ return `${k(p[0])},${k(p[1])}`;
921
+ }
922
+
923
+ /**
924
+ * `hausdorff`'s point-to-edge distance lists for one outline slot of a
925
+ * reduction, carried from the slot's last reading (`OutlineMemo`, issue #1246).
926
+ * Every list is the one `directedHausdorff` computes — `distanceToSegment(p,
927
+ * b[j], b[(j + 1) % m])` for every edge `j` — had in one of three ways, each
928
+ * the same function of the same input:
929
+ *
930
+ * - **toward the polygon read against** (fixed in a reduction): the list
931
+ * kept for the same point, handed back whole while that polygon is equal;
932
+ * - **toward the outline**, which a step changes: the last reading's list for
933
+ * the same point, each distance copied when its edge — both ends, exactly —
934
+ * is an edge of the last outline, and computed when it is not;
935
+ * - anything else: computed.
936
+ */
937
+ function carriedDistances(
938
+ memo: OutlineMemo,
939
+ outline: readonly Pt[],
940
+ against: readonly Pt[],
941
+ steps: StepRasters,
942
+ ): { forward: (p: Pt) => Float64Array; backward: (p: Pt) => Float64Array; commit: () => void } {
943
+ const { tally, plant } = steps;
944
+ memo.readings++;
945
+ const reading = memo.readings;
946
+ // The polygon read against: keep the lists while it is equal, coordinate by coordinate.
947
+ const againstFlat = new Float64Array(against.length * 2);
948
+ against.forEach((p, i) => {
949
+ againstFlat[i * 2] = p[0];
950
+ againstFlat[i * 2 + 1] = p[1];
951
+ });
952
+ const sameAgainst = memo.against !== null && memo.against.length === againstFlat.length && memo.against.every((v, i) => Object.is(v, againstFlat[i]));
953
+ if (!sameAgainst) {
954
+ memo.toAgainst.clear();
955
+ memo.against = againstFlat;
956
+ }
957
+ const ma = against.length;
958
+ const forward = (p: Pt): Float64Array => {
959
+ tally.distanceLists++;
960
+ const key = pointKey(p);
961
+ const kept = memo.toAgainst.get(key);
962
+ if (kept !== undefined) {
963
+ kept.used = reading;
964
+ tally.distanceListsReused++;
965
+ return kept.list;
966
+ }
967
+ const list = new Float64Array(ma);
968
+ for (let j = 0; j < ma; j++) list[j] = distanceToSegment(p, against[j], against[(j + 1) % ma]);
969
+ tally.edgesComputed += ma;
970
+ memo.toAgainst.set(key, { list, used: reading });
971
+ return list;
972
+ };
973
+ // The outline's edges, and where each sat in the last outline.
974
+ const mo = outline.length;
975
+ const edges = new Map<string, number>();
976
+ const from = new Int32Array(mo);
977
+ const lastEdges = memo.outlineEdges;
978
+ let plantPending = plant === 'skip-an-edge';
979
+ for (let j = 0; j < mo; j++) {
980
+ const key = `${pointKey(outline[j])};${pointKey(outline[(j + 1) % mo])}`;
981
+ edges.set(key, j);
982
+ const was = lastEdges?.get(key);
983
+ from[j] = was ?? -1;
984
+ if (was === undefined && plantPending && lastEdges !== null && j < lastEdges.size) {
985
+ // The plant: one new edge read as if it were the last outline's edge at the same index.
986
+ plantPending = false;
987
+ from[j] = j;
988
+ }
989
+ }
990
+ const last = memo.toOutline;
991
+ const next = new Map<string, Float64Array>();
992
+ const backward = (p: Pt): Float64Array => {
993
+ tally.distanceLists++;
994
+ const key = pointKey(p);
995
+ const done = next.get(key);
996
+ if (done !== undefined) {
997
+ tally.distanceListsReused++;
998
+ return done;
999
+ }
1000
+ const before = last.get(key);
1001
+ const list = new Float64Array(mo);
1002
+ if (before === undefined) {
1003
+ for (let j = 0; j < mo; j++) list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
1004
+ tally.edgesComputed += mo;
1005
+ } else {
1006
+ tally.distanceListsCarried++;
1007
+ for (let j = 0; j < mo; j++) {
1008
+ if (from[j] >= 0) {
1009
+ list[j] = before[from[j]];
1010
+ tally.edgesCarried++;
1011
+ } else {
1012
+ list[j] = distanceToSegment(p, outline[j], outline[(j + 1) % mo]);
1013
+ tally.edgesComputed++;
1014
+ }
1015
+ }
1016
+ }
1017
+ next.set(key, list);
1018
+ return list;
1019
+ };
1020
+ return {
1021
+ forward,
1022
+ backward,
1023
+ commit: () => {
1024
+ // Keep this reading's outline lists for the next one; drop the lists toward the fixed polygon the last two readings did not ask for.
1025
+ memo.toOutline = next;
1026
+ memo.outlineEdges = edges;
1027
+ for (const [key, entry] of memo.toAgainst) if (entry.used < reading - 1) memo.toAgainst.delete(key);
1028
+ },
1029
+ };
1030
+ }
1031
+
1032
+ /** `hausdorff` through a reduction's carried distances (`carriedDistances`), dropping what the last two readings of the slot did not ask for. */
1033
+ function hausdorffCarried(memo: OutlineMemo, candidate: readonly Pt[], reference: readonly Pt[], steps: StepRasters): { d: number; candidateEdge: number } {
1034
+ const carried = carriedDistances(memo, candidate, reference, steps);
1035
+ const value = hausdorff(candidate, reference, carried);
1036
+ carried.commit();
1037
+ return value;
1038
+ }
1039
+
919
1040
  // ---------------------------------------------------------------------------
920
1041
  // rows
921
1042
  // ---------------------------------------------------------------------------
@@ -1012,6 +1133,120 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
1012
1133
  */
1013
1134
  export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1014
1135
  validateInput(input);
1136
+ return measureValidated(input, artRastersOf(input.art), null);
1137
+ }
1138
+
1139
+ /**
1140
+ * `measureMeshQuality` over art rasters the caller already holds
1141
+ * (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
1142
+ * one call, so the quantities of the art alone are computed once per call
1143
+ * rather than once per step (issue #1240). The report is the one
1144
+ * `measureMeshQuality` returns for the same input, byte for byte; rasters
1145
+ * taken from another art are refused (`REDUCE_ART_RASTERS_MISMATCH`), never
1146
+ * read.
1147
+ *
1148
+ * Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
1149
+ * module with `export *`, and a symbol that is merely exported is not promised
1150
+ * (RELEASING.md, *The import surface*).
1151
+ */
1152
+ export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1153
+ validateInput(input);
1154
+ checkArtRasters(input, rasters);
1155
+ return measureValidated(input, rasters, null);
1156
+ }
1157
+
1158
+ /**
1159
+ * `measureMeshQualityWith` carried from the last measurement the same
1160
+ * `StepRasters` made (`src/meshrasters.ts`, issue #1246) — what `reduceMesh`
1161
+ * calls for each step, so a step redraws only the triangles its removal
1162
+ * changed, redoes the distance transform only where the coverage flipped, and
1163
+ * reads the outline rows again only when the outline moved. The report is the
1164
+ * one `measureMeshQuality` returns for the same input, byte for byte; rasters
1165
+ * taken from another art are refused as `measureMeshQualityWith` refuses them.
1166
+ *
1167
+ * Internal, as `measureMeshQualityWith` is.
1168
+ */
1169
+ export function measureMeshQualityStep(input: MeshMeasureInput, steps: StepRasters): MeshQualityReport {
1170
+ validateInput(input);
1171
+ checkArtRasters(input, steps.rasters);
1172
+ return measureValidated(input, steps.rasters, steps);
1173
+ }
1174
+
1175
+ /**
1176
+ * The coverage reading of a triangle set measured on its own — the full path:
1177
+ * every triangle drawn (`rasteriseTriangles`), the whole distance transform,
1178
+ * and the covered art and islands counted over every pixel.
1179
+ */
1180
+ function coverageOf(onGrid: Array<[number, number]>, triangles: number[], w: number, h: number, rasters: ArtRasters): CoverageReading {
1181
+ const artBits = rasters.artBits();
1182
+ const covered = rasteriseTriangles(onGrid, triangles, w, h);
1183
+ let coveredArt = 0;
1184
+ for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
1185
+ const toCovered = squaredDistanceToSet(covered, w, h);
1186
+ const anyCovered = covered.some((c) => c === 1);
1187
+ let undercutAt = -1;
1188
+ let undercutSq = 0;
1189
+ for (let i = 0; i < artBits.length; i++) {
1190
+ if (!artBits[i] || covered[i]) continue;
1191
+ if (undercutAt === -1 || toCovered[i] > undercutSq) {
1192
+ undercutAt = i;
1193
+ undercutSq = toCovered[i];
1194
+ }
1195
+ }
1196
+ const { label } = rasters.islands();
1197
+ const touched = new Set<number>();
1198
+ for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
1199
+ const secondIsland = touched.size >= 2 ? [...touched].sort((p, q) => p - q)[1] : 0;
1200
+ const silhouette = (connectivity: 4 | 8): SilhouetteReading => {
1201
+ const fills = rasters.fills();
1202
+ const filled = connectivity === 8 ? fills.fill8 : fills.fill4;
1203
+ const toFilled = rasters.toFilled(connectivity);
1204
+ let overAt = -1;
1205
+ let overSq = 0;
1206
+ let holes = 0;
1207
+ let holeAt = -1;
1208
+ for (let i = 0; i < covered.length; i++) {
1209
+ if (!covered[i]) continue;
1210
+ if (!filled[i]) {
1211
+ if (overAt === -1 || toFilled[i] > overSq) {
1212
+ overAt = i;
1213
+ overSq = toFilled[i];
1214
+ }
1215
+ } else if (!artBits[i]) {
1216
+ holes++;
1217
+ if (holeAt === -1) holeAt = i;
1218
+ }
1219
+ }
1220
+ return { overAt, overSq, holes, holeAt };
1221
+ };
1222
+ return { covered, toCovered, coveredArt, anyCovered, undercut: { at: undercutAt, sq: undercutSq }, silhouette, islandsTouched: touched.size, secondIsland };
1223
+ }
1224
+
1225
+ /**
1226
+ * Rasters are read only for the art they were taken from: the same mask
1227
+ * array, mask size, threshold and frame. The first field that differs is
1228
+ * refused by name, with the value the rasters were taken at and the value the
1229
+ * input requires.
1230
+ */
1231
+ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
1232
+ const want = input.art;
1233
+ const got = rasters.art;
1234
+ if (got === want) return;
1235
+ const who = `attachment ${nameOf(input.attachment)}`;
1236
+ const differs = (field: string, found: string, required: string): never =>
1237
+ refuse('REDUCE_ART_RASTERS_MISMATCH', `${who}: the art rasters were taken at ${field} ${found}; required ${field} ${required}, the input's — rasters are read only for the art they were taken from`);
1238
+ const size = (m: AlphaMask | undefined): string => (m === undefined ? 'undefined' : `${m.width}x${m.height}`);
1239
+ if (size(got.mask) !== size(want.mask)) differs('art.mask size', size(got.mask), size(want.mask));
1240
+ if (got.threshold !== want.threshold) differs('art.threshold', JSON.stringify(got.threshold), JSON.stringify(want.threshold));
1241
+ for (const field of ['pageScale', 'width', 'height'] as const) {
1242
+ if (got.frame?.[field] !== want.frame[field]) differs(`art.frame.${field}`, JSON.stringify(got.frame?.[field]), JSON.stringify(want.frame[field]));
1243
+ }
1244
+ if (got.mask.alpha !== want.mask.alpha) differs('art.mask.alpha', 'another array', 'the input\'s own array (the same object)');
1245
+ }
1246
+
1247
+ /** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
1248
+ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
1249
+ rasters.tally.uses++;
1015
1250
  const { attachment, art, source, targets } = input;
1016
1251
  const { mask, threshold, frame } = art;
1017
1252
  const scale = frame.pageScale;
@@ -1053,11 +1288,9 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1053
1288
  const w = mask.width;
1054
1289
  const h = mask.height;
1055
1290
  const grid = { width: w, height: h, pageScale: scale };
1056
- const artBits = artOf(mask, threshold);
1057
- let artCount = 0;
1058
- for (const bit of artBits) artCount += bit;
1291
+ const artBits = rasters.artBits();
1292
+ const artCount = rasters.artCount();
1059
1293
  const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
1060
- const covered = rasteriseTriangles(onGrid, source.triangles, w, h);
1061
1294
  const fit = targets.artFit;
1062
1295
  const pxIncrement = 1 / scale;
1063
1296
  const rasterArt = (connectivity: 4 | 8 | null): NonNullable<MeasureRow['art']> => ({ threshold, connectivity, samples: artCount });
@@ -1070,21 +1303,12 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1070
1303
  rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
1071
1304
  rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
1072
1305
  } else {
1073
- // Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers.
1074
- let coveredArt = 0;
1075
- for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
1076
- const toCovered = squaredDistanceToSet(covered, w, h);
1077
- const anyCovered = covered.some((c) => c === 1);
1306
+ // Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers. A reduction step
1307
+ // carries it from its last measurement (`StepRasters`), which reads the same values.
1308
+ const reading = steps === null ? coverageOf(onGrid, source.triangles, w, h, rasters) : steps.coverage(onGrid, source.triangles);
1309
+ const { coveredArt, anyCovered } = reading;
1078
1310
  // Undercut: the furthest uncovered art pixel from the covered set. Its pixel is coverage's worst too.
1079
- let undercutAt = -1;
1080
- let undercutSq = 0;
1081
- for (let i = 0; i < artBits.length; i++) {
1082
- if (!artBits[i] || covered[i]) continue;
1083
- if (undercutAt === -1 || toCovered[i] > undercutSq) {
1084
- undercutAt = i;
1085
- undercutSq = toCovered[i];
1086
- }
1087
- }
1311
+ const { at: undercutAt, sq: undercutSq } = reading.undercut;
1088
1312
  const missingWorst: WorstSample = undercutAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(undercutAt) } };
1089
1313
  rows.push(
1090
1314
  withRaster(
@@ -1108,33 +1332,9 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1108
1332
 
1109
1333
  // Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
1110
1334
  // where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
1111
- const fill8 = fillEnclosed(artBits, w, h, 8).filled;
1112
- const fill4 = fillEnclosed(artBits, w, h, 4).filled;
1113
- let fillsDiffer = false;
1114
- for (let i = 0; i < fill8.length; i++) {
1115
- if (fill8[i] !== fill4[i]) {
1116
- fillsDiffer = true;
1117
- break;
1118
- }
1119
- }
1120
- const silhouetteRows = (filled: Uint8Array, connectivity: 4 | 8, gated: boolean): void => {
1121
- const toFilled = squaredDistanceToSet(filled, w, h);
1122
- let overAt = -1;
1123
- let overSq = 0;
1124
- let holes = 0;
1125
- let holeAt = -1;
1126
- for (let i = 0; i < covered.length; i++) {
1127
- if (!covered[i]) continue;
1128
- if (!filled[i]) {
1129
- if (overAt === -1 || toFilled[i] > overSq) {
1130
- overAt = i;
1131
- overSq = toFilled[i];
1132
- }
1133
- } else if (!artBits[i]) {
1134
- holes++;
1135
- if (holeAt === -1) holeAt = i;
1136
- }
1137
- }
1335
+ const fillsDiffer = rasters.fills().differ;
1336
+ const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
1337
+ const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
1138
1338
  const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1139
1339
  rows.push(
1140
1340
  withRaster(
@@ -1146,21 +1346,15 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1146
1346
  );
1147
1347
  rows.push(withRaster(measuredRow(spec('MQ_HOLES', 'count'), holes, null, holeAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(holeAt) } }, false), rasterArt(connectivity), grid, 1));
1148
1348
  };
1149
- silhouetteRows(fill8, 8, true);
1150
- if (fillsDiffer) silhouetteRows(fill4, 4, false);
1349
+ silhouetteRows(8, true);
1350
+ if (fillsDiffer) silhouetteRows(4, false);
1151
1351
 
1152
1352
  // Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
1153
- const { label } = labelIslands(artBits, w, h);
1154
- const touched = new Set<number>();
1155
- for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
1156
- let joinedAt = -1;
1157
- if (touched.size >= 2) {
1158
- const second = [...touched].sort((p, q) => p - q)[1];
1159
- joinedAt = label.indexOf(second);
1160
- }
1353
+ const { label } = rasters.islands();
1354
+ const joinedAt = reading.islandsTouched >= 2 ? label.indexOf(reading.secondIsland) : -1;
1161
1355
  rows.push(
1162
1356
  withRaster(
1163
- measuredRow(spec('MQ_ISLANDS', 'count'), touched.size, null, joinedAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(joinedAt) } }, false),
1357
+ measuredRow(spec('MQ_ISLANDS', 'count'), reading.islandsTouched, null, joinedAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(joinedAt) } }, false),
1164
1358
  { threshold, connectivity: 4, samples: artCount },
1165
1359
  grid,
1166
1360
  1,
@@ -1174,19 +1368,19 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1174
1368
  if (input.referenceHull === null) {
1175
1369
  rows.push(unmeasuredRow(boundarySpec, 'not-measurable', `attachment ${nameOf(attachment)}: no referenceHull was given, so there is no polygon to measure the hull's deviation from`, targets.maxBoundaryDeviation !== null));
1176
1370
  } else {
1177
- const hd = hausdorff(hullPolygon, input.referenceHull);
1371
+ const reference = input.referenceHull;
1372
+ const hd = steps === null ? hausdorff(hullPolygon, reference) : steps.outline('reference', hullPolygon, reference, (memo) => hausdorffCarried(memo, hullPolygon, reference, steps));
1178
1373
  const bound = targets.maxBoundaryDeviation === null ? null : { op: '<=' as const, value: targets.maxBoundaryDeviation };
1179
1374
  rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
1180
1375
  }
1181
1376
  const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
1182
- try {
1183
- const traced = traceAlphaOutline(mask, threshold);
1184
- const outlinePx = prunePolygon(traced.outline.map(([x, y]): [number, number] => [x / scale, y / scale]));
1185
- const hd = hausdorff(hullPolygon, outlinePx);
1377
+ const traced = rasters.traced();
1378
+ if ('outline' in traced) {
1379
+ const tracedOutline = traced.outline;
1380
+ const hd = steps === null ? hausdorff(hullPolygon, tracedOutline) : steps.outline('traced', hullPolygon, tracedOutline, (memo) => hausdorffCarried(memo, hullPolygon, tracedOutline, steps));
1186
1381
  rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
1187
- } catch (err) {
1188
- if (!(err instanceof MeshError)) throw err;
1189
- rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${err.message}`, false));
1382
+ } else {
1383
+ rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
1190
1384
  }
1191
1385
 
1192
1386
  // --- triangles: sign, degeneracy, angle --------------------------------------------------
@@ -1233,7 +1427,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1233
1427
  );
1234
1428
 
1235
1429
  // --- regions (§5) ------------------------------------------------------------------------
1236
- rows.push(...regionRows(input, outline, hullPolygon));
1430
+ rows.push(...regionRows(input, outline, hullPolygon, rasters));
1237
1431
 
1238
1432
  const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
1239
1433
  const geometry = sectionOf(ordered);
@@ -1331,7 +1525,7 @@ interface Edge {
1331
1525
  * length most exceeds its own bound — so the row fails exactly when some edge
1332
1526
  * it covers is over — and the row names that edge and that bound.
1333
1527
  */
1334
- function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[]): Built[] {
1528
+ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
1335
1529
  const { attachment, source, art } = input;
1336
1530
  const who = `attachment ${nameOf(attachment)}`;
1337
1531
  const scale = art.frame.pageScale;
@@ -1407,7 +1601,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1407
1601
 
1408
1602
  const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
1409
1603
  const { mask, threshold } = art;
1410
- const artBits = artOf(mask, threshold);
1604
+ const artBits = rasters.artBits();
1411
1605
 
1412
1606
  for (const region of input.targets.regions) {
1413
1607
  const refused = refusals.get(region.name);