spine-rigc 2.20.1 → 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.1",
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
  /**
@@ -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 } from './meshrasters.ts';
72
+ import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
73
73
  import { cropToSpineY } from './transform.ts';
74
74
 
75
75
  // ---------------------------------------------------------------------------
@@ -852,13 +852,15 @@ function pointToBoundary(p: Pt, poly: readonly Pt[]): { d: number; edge: number
852
852
  * value already found is dropped; the rest are halved. No sampling density is
853
853
  * chosen, so nothing here can miss a maximum between two samples.
854
854
  */
855
- 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 } {
856
856
  const m = b.length;
857
- const toEach = (p: Pt): Float64Array => {
858
- const out = new Float64Array(m);
859
- for (let j = 0; j < m; j++) out[j] = distanceToSegment(p, b[j], b[(j + 1) % m]);
860
- return out;
861
- };
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
+ });
862
864
  const minOf = (g: Float64Array): number => {
863
865
  let v = Infinity;
864
866
  for (let j = 0; j < m; j++) v = Math.min(v, g[j]);
@@ -905,13 +907,136 @@ function directedHausdorff(a: readonly Pt[], b: readonly Pt[]): { d: number; edg
905
907
  * taken on when the worst point is the candidate's, else the candidate edge
906
908
  * nearest the reference's worst point.
907
909
  */
908
- function hausdorff(candidate: readonly Pt[], reference: readonly Pt[]): { d: number; candidateEdge: number } {
909
- const forward = directedHausdorff(candidate, reference);
910
- 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);
911
913
  if (forward.d >= backward.d) return { d: forward.d, candidateEdge: forward.edge };
912
914
  return { d: backward.d, candidateEdge: pointToBoundary(backward.at, candidate).edge };
913
915
  }
914
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
+
915
1040
  // ---------------------------------------------------------------------------
916
1041
  // rows
917
1042
  // ---------------------------------------------------------------------------
@@ -1008,7 +1133,7 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
1008
1133
  */
1009
1134
  export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1010
1135
  validateInput(input);
1011
- return measureValidated(input, artRastersOf(input.art));
1136
+ return measureValidated(input, artRastersOf(input.art), null);
1012
1137
  }
1013
1138
 
1014
1139
  /**
@@ -1027,7 +1152,74 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1027
1152
  export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1028
1153
  validateInput(input);
1029
1154
  checkArtRasters(input, rasters);
1030
- return measureValidated(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 };
1031
1223
  }
1032
1224
 
1033
1225
  /**
@@ -1053,7 +1245,7 @@ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
1053
1245
  }
1054
1246
 
1055
1247
  /** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
1056
- function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1248
+ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters, steps: StepRasters | null): MeshQualityReport {
1057
1249
  rasters.tally.uses++;
1058
1250
  const { attachment, art, source, targets } = input;
1059
1251
  const { mask, threshold, frame } = art;
@@ -1099,7 +1291,6 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQua
1099
1291
  const artBits = rasters.artBits();
1100
1292
  const artCount = rasters.artCount();
1101
1293
  const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
1102
- const covered = rasteriseTriangles(onGrid, source.triangles, w, h);
1103
1294
  const fit = targets.artFit;
1104
1295
  const pxIncrement = 1 / scale;
1105
1296
  const rasterArt = (connectivity: 4 | 8 | null): NonNullable<MeasureRow['art']> => ({ threshold, connectivity, samples: artCount });
@@ -1112,21 +1303,12 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQua
1112
1303
  rows.push(withRaster(unmeasuredRow(spec('MQ_HOLES', 'count'), 'not-measurable', why, false), rasterArt(8), grid, 1));
1113
1304
  rows.push(withRaster(unmeasuredRow(spec('MQ_ISLANDS', 'count'), 'not-measurable', why, false), rasterArt(4), grid, 1));
1114
1305
  } else {
1115
- // Coverage — `measureAuthoredMeshFit`'s, unchanged: art pixel centres a triangle covers.
1116
- let coveredArt = 0;
1117
- for (let i = 0; i < artBits.length; i++) if (artBits[i] && covered[i]) coveredArt++;
1118
- const toCovered = squaredDistanceToSet(covered, w, h);
1119
- 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;
1120
1310
  // Undercut: the furthest uncovered art pixel from the covered set. Its pixel is coverage's worst too.
1121
- let undercutAt = -1;
1122
- let undercutSq = 0;
1123
- for (let i = 0; i < artBits.length; i++) {
1124
- if (!artBits[i] || covered[i]) continue;
1125
- if (undercutAt === -1 || toCovered[i] > undercutSq) {
1126
- undercutAt = i;
1127
- undercutSq = toCovered[i];
1128
- }
1129
- }
1311
+ const { at: undercutAt, sq: undercutSq } = reading.undercut;
1130
1312
  const missingWorst: WorstSample = undercutAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(undercutAt) } };
1131
1313
  rows.push(
1132
1314
  withRaster(
@@ -1150,25 +1332,9 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQua
1150
1332
 
1151
1333
  // Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
1152
1334
  // where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
1153
- const { fill8, fill4, differ: fillsDiffer } = rasters.fills();
1154
- const silhouetteRows = (filled: Uint8Array, connectivity: 4 | 8, gated: boolean): void => {
1155
- const toFilled = rasters.toFilled(connectivity);
1156
- let overAt = -1;
1157
- let overSq = 0;
1158
- let holes = 0;
1159
- let holeAt = -1;
1160
- for (let i = 0; i < covered.length; i++) {
1161
- if (!covered[i]) continue;
1162
- if (!filled[i]) {
1163
- if (overAt === -1 || toFilled[i] > overSq) {
1164
- overAt = i;
1165
- overSq = toFilled[i];
1166
- }
1167
- } else if (!artBits[i]) {
1168
- holes++;
1169
- if (holeAt === -1) holeAt = i;
1170
- }
1171
- }
1335
+ const fillsDiffer = rasters.fills().differ;
1336
+ const silhouetteRows = (connectivity: 4 | 8, gated: boolean): void => {
1337
+ const { overAt, overSq, holes, holeAt } = reading.silhouette(connectivity);
1172
1338
  const bound = gated && fit !== null ? { op: '<=' as const, value: fit.maxOvershoot } : null;
1173
1339
  rows.push(
1174
1340
  withRaster(
@@ -1180,21 +1346,15 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQua
1180
1346
  );
1181
1347
  rows.push(withRaster(measuredRow(spec('MQ_HOLES', 'count'), holes, null, holeAt === -1 ? NOTHING_WORSE : { at: { pixel: pixelOf(holeAt) } }, false), rasterArt(connectivity), grid, 1));
1182
1348
  };
1183
- silhouetteRows(fill8, 8, true);
1184
- if (fillsDiffer) silhouetteRows(fill4, 4, false);
1349
+ silhouetteRows(8, true);
1350
+ if (fillsDiffer) silhouetteRows(4, false);
1185
1351
 
1186
1352
  // Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
1187
1353
  const { label } = rasters.islands();
1188
- const touched = new Set<number>();
1189
- for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
1190
- let joinedAt = -1;
1191
- if (touched.size >= 2) {
1192
- const second = [...touched].sort((p, q) => p - q)[1];
1193
- joinedAt = label.indexOf(second);
1194
- }
1354
+ const joinedAt = reading.islandsTouched >= 2 ? label.indexOf(reading.secondIsland) : -1;
1195
1355
  rows.push(
1196
1356
  withRaster(
1197
- 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),
1198
1358
  { threshold, connectivity: 4, samples: artCount },
1199
1359
  grid,
1200
1360
  1,
@@ -1208,14 +1368,16 @@ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQua
1208
1368
  if (input.referenceHull === null) {
1209
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));
1210
1370
  } else {
1211
- 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));
1212
1373
  const bound = targets.maxBoundaryDeviation === null ? null : { op: '<=' as const, value: targets.maxBoundaryDeviation };
1213
1374
  rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
1214
1375
  }
1215
1376
  const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
1216
1377
  const traced = rasters.traced();
1217
1378
  if ('outline' in traced) {
1218
- const hd = hausdorff(hullPolygon, traced.outline);
1379
+ const tracedOutline = traced.outline;
1380
+ const hd = steps === null ? hausdorff(hullPolygon, tracedOutline) : steps.outline('traced', hullPolygon, tracedOutline, (memo) => hausdorffCarried(memo, hullPolygon, tracedOutline, steps));
1219
1381
  rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
1220
1382
  } else {
1221
1383
  rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
@@ -33,7 +33,7 @@
33
33
  *
34
34
  * Not re-exported by `src/mesh.ts`: nothing here is on `spine-rigc/mesh`.
35
35
  */
36
- import { artOf, fillEnclosed, labelIslands, MeshError, prunePolygon, squaredDistanceToSet, traceAlphaOutline } from './mesh.ts';
36
+ import { artOf, distancePassesOf, fillEnclosed, labelIslands, MeshError, pixelCentreInTriangle, prunePolygon, squaredDistanceToSet, traceAlphaOutline, triangleRasterBox } from './mesh.ts';
37
37
  import type { ArtInput } from './meshquality.ts';
38
38
 
39
39
  /** How many times each quantity was computed, and how many measurements were handed the object. */
@@ -154,3 +154,468 @@ export function artRastersOf(art: ArtInput): ArtRasters {
154
154
  },
155
155
  };
156
156
  }
157
+
158
+ /**
159
+ * Overshoot and holes against one filled silhouette: the covered centre
160
+ * outside it furthest from it (the first in pixel order among equals; −1 when
161
+ * none) with its squared distance, and the covered centres inside it that are
162
+ * not art — how many, and the first in pixel order (−1 when none).
163
+ */
164
+ export interface SilhouetteReading {
165
+ overAt: number;
166
+ overSq: number;
167
+ holes: number;
168
+ holeAt: number;
169
+ }
170
+
171
+ /**
172
+ * What one step's coverage reads, from either path: the covered pixel centres,
173
+ * their distance transform, the covered art pixels, whether any centre is
174
+ * covered, the uncovered art pixel furthest from the covered set (the first in
175
+ * pixel order among equals; −1 when none) with its squared distance, the
176
+ * silhouette reading at either connectivity, and the 4-connected art islands
177
+ * the coverage touches — how many, and the second-smallest label among them
178
+ * (0 when fewer than two).
179
+ */
180
+ export interface CoverageReading {
181
+ covered: Uint8Array;
182
+ toCovered: Float64Array;
183
+ coveredArt: number;
184
+ anyCovered: boolean;
185
+ undercut: { at: number; sq: number };
186
+ silhouette(connectivity: 4 | 8): SilhouetteReading;
187
+ islandsTouched: number;
188
+ secondIsland: number;
189
+ }
190
+
191
+ /** How a step-rasters object came by each reading, and what it redid. */
192
+ export interface StepRasterTally {
193
+ /** Coverage readings handed out. */
194
+ readings: number;
195
+ /** Readings built from nothing: the first, and any whose triangles differ from the last in more than they share. */
196
+ rebuilt: number;
197
+ /** Readings carried from the last one by the triangles that differ. */
198
+ carried: number;
199
+ /** Triangles drawn (taken away or added) by carried readings. */
200
+ trianglesDrawn: number;
201
+ /** Triangles a carried reading did not draw because the last reading held them already. */
202
+ trianglesKept: number;
203
+ /** Pixel centres whose coverage changed between readings. */
204
+ pixelsFlipped: number;
205
+ /** Columns and rows of the distance transform carried readings redid. */
206
+ columnsRedone: number;
207
+ rowsRedone: number;
208
+ /** Outline deviations measured, and handed back because the outline and the polygon it is read against equal the last ones. */
209
+ outlinesMeasured: number;
210
+ outlinesReused: number;
211
+ /** Point-to-edge distance lists an outline measurement asked for, and how each was had: handed back whole, carried edge by edge from the last list of the same point, or computed. */
212
+ distanceLists: number;
213
+ distanceListsReused: number;
214
+ distanceListsCarried: number;
215
+ /** Edges a carried list took from the last list of its point, and edges it computed because the edge is new. */
216
+ edgesCarried: number;
217
+ edgesComputed: number;
218
+ }
219
+
220
+ /**
221
+ * A fault planted on purpose in the carried path, for the mesh-quality
222
+ * controls to show they catch it: one added triangle not drawn, one changed
223
+ * column or row of the distance transform not redone, or an outline reading
224
+ * reused when only the vertex counts match. `reduceMesh` plants none.
225
+ */
226
+ export type StepRasterPlant = 'skip-a-triangle' | 'skip-a-column' | 'skip-a-row' | 'stale-outline' | 'skip-an-edge';
227
+
228
+ /**
229
+ * One outline slot's carried distances (`src/meshquality.ts`, `hausdorff`): a
230
+ * directed Hausdorff reading asks, for points along one polygon, the distance
231
+ * to every edge of the other, and those lists are kept from the last reading
232
+ * of the slot by the point they were asked for, keyed by its exact
233
+ * coordinates.
234
+ *
235
+ * - `toAgainst` — lists of distances to the edges of the polygon the outline
236
+ * is read against (fixed for a reduction). Kept while that polygon is equal;
237
+ * an entry not asked for by the last two readings is dropped.
238
+ * - `toOutline` — lists of distances to the outline's own edges, which a step
239
+ * changes. Kept for the last reading's outline only, with its edges
240
+ * (`outlineEdges`, keyed by both ends), so the next reading copies each
241
+ * distance whose edge it still has and computes the rest.
242
+ */
243
+ export interface OutlineMemo {
244
+ against: Float64Array | null;
245
+ toAgainst: Map<string, { list: Float64Array; used: number }>;
246
+ outlineEdges: Map<string, number> | null;
247
+ toOutline: Map<string, Float64Array>;
248
+ readings: number;
249
+ }
250
+
251
+ /**
252
+ * The per-step state of one reduction (issue #1246): the last coverage
253
+ * reading and the last outline deviations, so the next measurement redoes only
254
+ * what its mesh changed. Like `ArtRasters` it is a value made by the caller,
255
+ * passed explicitly and dropped with the call; nothing is kept at module level.
256
+ *
257
+ * ## Why every reading equals the full one
258
+ *
259
+ * A reduction step changes the triangles of one vertex's link and nothing
260
+ * else, yet the full measurement redraws every triangle and redoes the whole
261
+ * distance transform. Here each quantity is carried from the last reading by a
262
+ * rule under which it is the same function of the same input:
263
+ *
264
+ * - **Coverage** is held as a count per pixel centre of the triangles whose
265
+ * centre test (`triangleRasterBox`, `pixelCentreInTriangle` — the rule
266
+ * `rasteriseTriangles` reads) holds there. The triangles are compared with
267
+ * the last reading's as a multiset keyed by their three corners' exact
268
+ * coordinates in the order given (a zero's sign included); a triangle taken
269
+ * away is drawn with −1 and one added with +1, so every count is the exact
270
+ * number of triangles covering that centre, and a centre is covered exactly
271
+ * when its count is positive — `rasteriseTriangles`'s union.
272
+ * - **The distance transform** is `squaredDistanceToSet`'s two passes
273
+ * (`distancePassesOf`). A column's pass reads that column of the coverage
274
+ * alone, so only columns holding a flipped centre are redone; a row's pass
275
+ * reads that row of the column results alone, so only rows where a redone
276
+ * column's value changed are redone. Every other value is the output of the
277
+ * same pass over the same input.
278
+ * - **Covered art and islands** are integer counts updated at each flipped
279
+ * centre: art pixels covered, centres covered, and covered pixels per island
280
+ * label; the islands touched are the labels whose count is positive.
281
+ * - **Outline deviations** (`outline`) are handed back only when the outline
282
+ * and the polygon it is read against are equal, coordinate by coordinate
283
+ * with a zero's sign, to the last ones of that slot — the same pure function
284
+ * of the same input.
285
+ *
286
+ * A reading whose triangles differ from the last in more triangles than they
287
+ * share is built from nothing, as the first one is; which way a reading is
288
+ * built changes its cost and never its values.
289
+ */
290
+ export interface StepRasters {
291
+ readonly rasters: ArtRasters;
292
+ readonly tally: StepRasterTally;
293
+ /** The coverage reading of these triangles over the drawing-pixel `points`; its arrays are valid until the next reading. */
294
+ coverage(points: Array<[number, number]>, triangles: number[]): CoverageReading;
295
+ /** The fault planted for a control, or null. */
296
+ readonly plant: StepRasterPlant | null;
297
+ /**
298
+ * `measure(memo)`'s value, or the last one of `slot` when `outline` and
299
+ * `against` equal the last ones exactly; `memo` is the slot's carried
300
+ * distances, which `measure` reads and keeps current.
301
+ */
302
+ outline<T>(slot: string, outline: ReadonlyArray<readonly [number, number]>, against: ReadonlyArray<readonly [number, number]>, measure: (memo: OutlineMemo) => T): T;
303
+ }
304
+
305
+ /** A coordinate as a key: its shortest round-trip decimal, with a negative zero told from a positive one. */
306
+ function coordKey(v: number): string {
307
+ return v === 0 && 1 / v < 0 ? '-0' : String(v);
308
+ }
309
+
310
+ /** Equal coordinate by coordinate, a zero's sign included. */
311
+ function samePolygon(a: ReadonlyArray<readonly [number, number]>, b: Float64Array): boolean {
312
+ if (a.length * 2 !== b.length) return false;
313
+ for (let i = 0; i < a.length; i++) if (!Object.is(a[i][0], b[i * 2]) || !Object.is(a[i][1], b[i * 2 + 1])) return false;
314
+ return true;
315
+ }
316
+
317
+ function flat(p: ReadonlyArray<readonly [number, number]>): Float64Array {
318
+ const out = new Float64Array(p.length * 2);
319
+ for (let i = 0; i < p.length; i++) {
320
+ out[i * 2] = p[i][0];
321
+ out[i * 2 + 1] = p[i][1];
322
+ }
323
+ return out;
324
+ }
325
+
326
+ type Corners = [readonly [number, number], readonly [number, number], readonly [number, number]];
327
+
328
+ interface HeldTriangles {
329
+ corners: Corners;
330
+ n: number;
331
+ }
332
+
333
+ interface CoverageState {
334
+ held: Map<string, HeldTriangles>;
335
+ /** Triangles covering each pixel centre. */
336
+ count: Int32Array;
337
+ covered: Uint8Array;
338
+ /** The column pass of the distance transform, and the transform. */
339
+ columns: Float64Array;
340
+ dist: Float64Array;
341
+ coveredArt: number;
342
+ coveredTotal: number;
343
+ /** Covered pixels per island label (index 0 unused). */
344
+ islandHits: Int32Array;
345
+ islandsTouched: number;
346
+ /** Art pixels not covered. */
347
+ uncoveredArt: Set<number>;
348
+ /** Per connectivity, once read: covered centres outside the filled silhouette, and covered centres inside it that are not art. */
349
+ silhouettes: Map<4 | 8, { outside: Set<number>; holes: Set<number> }>;
350
+ }
351
+
352
+ /** Step rasters over `rasters`' art. Reads nothing until the first reading. */
353
+ export function stepRastersOf(rasters: ArtRasters, plant: StepRasterPlant | null = null): StepRasters {
354
+ const tally: StepRasterTally = {
355
+ readings: 0,
356
+ rebuilt: 0,
357
+ carried: 0,
358
+ trianglesDrawn: 0,
359
+ trianglesKept: 0,
360
+ pixelsFlipped: 0,
361
+ columnsRedone: 0,
362
+ rowsRedone: 0,
363
+ outlinesMeasured: 0,
364
+ outlinesReused: 0,
365
+ distanceLists: 0,
366
+ distanceListsReused: 0,
367
+ distanceListsCarried: 0,
368
+ edgesCarried: 0,
369
+ edgesComputed: 0,
370
+ };
371
+ const { width: w, height: h } = rasters.art.mask;
372
+ let state: CoverageState | null = null;
373
+ const outlines = new Map<string, { outline: Float64Array; against: Float64Array; value: unknown }>();
374
+ const memos = new Map<string, OutlineMemo>();
375
+
376
+ /** Add `sign` to the count of every centre the triangle covers, pushing each such centre to `touched`. */
377
+ const draw = (count: Int32Array, corners: Corners, sign: number, touched: number[] | null): void => {
378
+ const [a, b, c] = corners;
379
+ const box = triangleRasterBox(a, b, c, w, h);
380
+ if (box === null) return;
381
+ for (let y = box.minY; y <= box.maxY; y++) {
382
+ for (let x = box.minX; x <= box.maxX; x++) {
383
+ if (!pixelCentreInTriangle(x, y, a, b, c, box.orient)) continue;
384
+ count[y * w + x] += sign;
385
+ if (touched !== null) touched.push(y * w + x);
386
+ }
387
+ }
388
+ };
389
+
390
+ const readingOf = (s: CoverageState): CoverageReading => {
391
+ let second = 0;
392
+ if (s.islandsTouched >= 2) {
393
+ let seen = 0;
394
+ for (let l = 1; l < s.islandHits.length; l++) {
395
+ if (s.islandHits[l] > 0 && ++seen === 2) {
396
+ second = l;
397
+ break;
398
+ }
399
+ }
400
+ }
401
+ // The worst of a set by the full scan's rule: the largest value, the smallest index among equals.
402
+ const worstOf = (set: Set<number>, value: Float64Array): { at: number; sq: number } => {
403
+ let at = -1;
404
+ let sq = 0;
405
+ for (const i of set) {
406
+ const v = value[i];
407
+ if (at === -1 || v > sq || (v === sq && i < at)) {
408
+ at = i;
409
+ sq = v;
410
+ }
411
+ }
412
+ return { at, sq };
413
+ };
414
+ const silhouette = (connectivity: 4 | 8): SilhouetteReading => {
415
+ let sets = s.silhouettes.get(connectivity);
416
+ if (sets === undefined) {
417
+ const fills = rasters.fills();
418
+ const filled = connectivity === 8 ? fills.fill8 : fills.fill4;
419
+ const artBits = rasters.artBits();
420
+ sets = { outside: new Set(), holes: new Set() };
421
+ for (let i = 0; i < s.covered.length; i++) {
422
+ if (!s.covered[i]) continue;
423
+ if (!filled[i]) sets.outside.add(i);
424
+ else if (!artBits[i]) sets.holes.add(i);
425
+ }
426
+ s.silhouettes.set(connectivity, sets);
427
+ }
428
+ const over = worstOf(sets.outside, rasters.toFilled(connectivity));
429
+ let holeAt = -1;
430
+ for (const i of sets.holes) if (holeAt === -1 || i < holeAt) holeAt = i;
431
+ return { overAt: over.at, overSq: over.sq, holes: sets.holes.size, holeAt };
432
+ };
433
+ return {
434
+ covered: s.covered,
435
+ toCovered: s.dist,
436
+ coveredArt: s.coveredArt,
437
+ anyCovered: s.coveredTotal > 0,
438
+ undercut: worstOf(s.uncoveredArt, s.dist),
439
+ silhouette,
440
+ islandsTouched: s.islandsTouched,
441
+ secondIsland: second,
442
+ };
443
+ };
444
+
445
+ const rebuild = (held: Map<string, HeldTriangles>): CoverageState => {
446
+ tally.rebuilt++;
447
+ const artBits = rasters.artBits();
448
+ const { label, sizes } = rasters.islands();
449
+ const count = new Int32Array(w * h);
450
+ for (const { corners, n } of held.values()) for (let k = 0; k < n; k++) draw(count, corners, 1, null);
451
+ const covered = new Uint8Array(w * h);
452
+ const islandHits = new Int32Array(sizes.length + 1);
453
+ let coveredArt = 0;
454
+ let coveredTotal = 0;
455
+ let islandsTouched = 0;
456
+ const uncoveredArt = new Set<number>();
457
+ for (let i = 0; i < covered.length; i++) {
458
+ if (count[i] <= 0) {
459
+ if (artBits[i]) uncoveredArt.add(i);
460
+ continue;
461
+ }
462
+ covered[i] = 1;
463
+ coveredTotal++;
464
+ if (artBits[i]) coveredArt++;
465
+ if (label[i] && islandHits[label[i]]++ === 0) islandsTouched++;
466
+ }
467
+ const passes = distancePassesOf(w, h);
468
+ const columns = new Float64Array(w * h);
469
+ for (let x = 0; x < w; x++) {
470
+ const out = passes.column(covered, x);
471
+ for (let y = 0; y < h; y++) columns[y * w + x] = out[y];
472
+ }
473
+ const dist = new Float64Array(w * h);
474
+ for (let y = 0; y < h; y++) {
475
+ const out = passes.row(columns, y);
476
+ for (let x = 0; x < w; x++) dist[y * w + x] = out[x];
477
+ }
478
+ return { held, count, covered, columns, dist, coveredArt, coveredTotal, islandHits, islandsTouched, uncoveredArt, silhouettes: new Map() };
479
+ };
480
+
481
+ /** Carry `s` to the triangles `held` by drawing the ones that differ, then redo the distance transform where it changed. */
482
+ const carry = (s: CoverageState, held: Map<string, HeldTriangles>, removed: Corners[], added: Corners[]): void => {
483
+ tally.carried++;
484
+ tally.trianglesDrawn += removed.length + added.length;
485
+ const touched: number[] = [];
486
+ for (const corners of removed) draw(s.count, corners, -1, touched);
487
+ added.forEach((corners, k) => {
488
+ if (plant === 'skip-a-triangle' && k === 0) return;
489
+ draw(s.count, corners, 1, touched);
490
+ });
491
+ s.held = held;
492
+ // Flip the centres whose count crossed zero, keeping the integer counts with them.
493
+ const artBits = rasters.artBits();
494
+ const { label } = rasters.islands();
495
+ const fills = s.silhouettes.size > 0 ? rasters.fills() : null;
496
+ const dirtyColumns = new Uint8Array(w);
497
+ for (const i of touched) {
498
+ const now = s.count[i] > 0 ? 1 : 0;
499
+ if (now === s.covered[i]) continue;
500
+ s.covered[i] = now;
501
+ tally.pixelsFlipped++;
502
+ dirtyColumns[i % w] = 1;
503
+ const d = now === 1 ? 1 : -1;
504
+ s.coveredTotal += d;
505
+ if (artBits[i]) {
506
+ s.coveredArt += d;
507
+ if (now === 1) s.uncoveredArt.delete(i);
508
+ else s.uncoveredArt.add(i);
509
+ }
510
+ for (const [connectivity, sets] of s.silhouettes) {
511
+ if (now === 0) {
512
+ sets.outside.delete(i);
513
+ sets.holes.delete(i);
514
+ continue;
515
+ }
516
+ const filled = connectivity === 8 ? fills!.fill8 : fills!.fill4;
517
+ if (!filled[i]) sets.outside.add(i);
518
+ else if (!artBits[i]) sets.holes.add(i);
519
+ }
520
+ const l = label[i];
521
+ if (l) {
522
+ const before = s.islandHits[l];
523
+ s.islandHits[l] += d;
524
+ if (before === 0) s.islandsTouched++;
525
+ else if (s.islandHits[l] === 0) s.islandsTouched--;
526
+ }
527
+ }
528
+ // Redo the columns holding a flipped centre, then the rows where a column value changed.
529
+ const passes = distancePassesOf(w, h);
530
+ const dirtyRows = new Uint8Array(h);
531
+ let skipColumn = plant === 'skip-a-column';
532
+ for (let x = 0; x < w; x++) {
533
+ if (!dirtyColumns[x]) continue;
534
+ if (skipColumn) {
535
+ skipColumn = false;
536
+ continue;
537
+ }
538
+ tally.columnsRedone++;
539
+ const out = passes.column(s.covered, x);
540
+ for (let y = 0; y < h; y++) {
541
+ if (Object.is(s.columns[y * w + x], out[y])) continue;
542
+ s.columns[y * w + x] = out[y];
543
+ dirtyRows[y] = 1;
544
+ }
545
+ }
546
+ let skipRow = plant === 'skip-a-row';
547
+ for (let y = 0; y < h; y++) {
548
+ if (!dirtyRows[y]) continue;
549
+ if (skipRow) {
550
+ skipRow = false;
551
+ continue;
552
+ }
553
+ tally.rowsRedone++;
554
+ const out = passes.row(s.columns, y);
555
+ for (let x = 0; x < w; x++) s.dist[y * w + x] = out[x];
556
+ }
557
+ };
558
+
559
+ return {
560
+ rasters,
561
+ tally,
562
+ coverage: (points, triangles) => {
563
+ tally.readings++;
564
+ const held = new Map<string, HeldTriangles>();
565
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
566
+ const corners: Corners = [points[triangles[t]], points[triangles[t + 1]], points[triangles[t + 2]]];
567
+ const key = `${coordKey(corners[0][0])},${coordKey(corners[0][1])},${coordKey(corners[1][0])},${coordKey(corners[1][1])},${coordKey(corners[2][0])},${coordKey(corners[2][1])}`;
568
+ const entry = held.get(key);
569
+ if (entry === undefined) held.set(key, { corners, n: 1 });
570
+ else entry.n++;
571
+ }
572
+ if (state === null) {
573
+ state = rebuild(held);
574
+ return readingOf(state);
575
+ }
576
+ // The multiset difference against the last reading's triangles.
577
+ const removed: Corners[] = [];
578
+ const added: Corners[] = [];
579
+ let kept = 0;
580
+ for (const [key, { corners, n }] of held) {
581
+ const was = state.held.get(key)?.n ?? 0;
582
+ kept += Math.min(was, n);
583
+ for (let k = was; k < n; k++) added.push(corners);
584
+ }
585
+ for (const [key, { corners, n }] of state.held) {
586
+ const now = held.get(key)?.n ?? 0;
587
+ for (let k = now; k < n; k++) removed.push(corners);
588
+ }
589
+ if (removed.length + added.length > kept) {
590
+ state = rebuild(held);
591
+ return readingOf(state);
592
+ }
593
+ tally.trianglesKept += kept;
594
+ carry(state, held, removed, added);
595
+ return readingOf(state);
596
+ },
597
+ plant,
598
+ outline: <T>(slot: string, outline: ReadonlyArray<readonly [number, number]>, against: ReadonlyArray<readonly [number, number]>, measure: (memo: OutlineMemo) => T): T => {
599
+ const last = outlines.get(slot);
600
+ if (last !== undefined) {
601
+ const same =
602
+ plant === 'stale-outline'
603
+ ? last.outline.length === outline.length * 2 && last.against.length === against.length * 2
604
+ : samePolygon(outline, last.outline) && samePolygon(against, last.against);
605
+ if (same) {
606
+ tally.outlinesReused++;
607
+ return last.value as T;
608
+ }
609
+ }
610
+ tally.outlinesMeasured++;
611
+ let memo = memos.get(slot);
612
+ if (memo === undefined) {
613
+ memo = { against: null, toAgainst: new Map(), outlineEdges: null, toOutline: new Map(), readings: 0 };
614
+ memos.set(slot, memo);
615
+ }
616
+ const value = measure(memo);
617
+ outlines.set(slot, { outline: flat(outline), against: flat(against), value });
618
+ return value;
619
+ },
620
+ };
621
+ }
package/src/meshreduce.ts CHANGED
@@ -26,7 +26,9 @@
26
26
  * Each candidate step is measured by `measureMeshQuality` (`src/meshquality.ts`)
27
27
  * — no row is re-implemented here; the call reads it as `measureMeshQualityWith`
28
28
  * over the art's rasters taken once at admission (`src/meshrasters.ts`, issue
29
- * #1240), which returns the same report — and taken only when every row the caller's
29
+ * #1240), and as `measureMeshQualityStep` carried from the last step's
30
+ * measurement (`StepRasters`, issue #1246), each of which returns the same
31
+ * report — and taken only when every row the caller's
30
32
  * contract requires is `pass`: the art fit at `targets.artFit`, the boundary
31
33
  * deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
32
34
  * angle when declared, orientation and degeneracy at 0, and every region's
@@ -87,6 +89,7 @@ import {
87
89
  import {
88
90
  BAND_CONTACT_TOLERANCE,
89
91
  edgeIsHeldByRegion,
92
+ measureMeshQualityStep,
90
93
  measureMeshQualityWith,
91
94
  type AttachmentRef,
92
95
  type ArtFitBounds,
@@ -103,7 +106,7 @@ import {
103
106
  type SourceMesh,
104
107
  type Termination,
105
108
  } from './meshquality.ts';
106
- import { artRastersOf, type ArtRasters } from './meshrasters.ts';
109
+ import { artRastersOf, stepRastersOf, type ArtRasters, type StepRasters } from './meshrasters.ts';
107
110
 
108
111
  // ---------------------------------------------------------------------------
109
112
  // the result
@@ -716,6 +719,8 @@ interface Run {
716
719
  sourceEdges: Set<string>;
717
720
  /** The art's rasters, taken once for the call and read by every measurement in it (issue #1240). */
718
721
  rasters: ArtRasters;
722
+ /** The step state each removal and insertion is measured through, carried from the last measurement (issue #1246); null measures every step in full. */
723
+ stepRasters: StepRasters | null;
719
724
  }
720
725
 
721
726
  /** A measurement of a canonical mesh against the result's full contract. */
@@ -736,7 +741,7 @@ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQual
736
741
  boneOrder: input.boneOrder,
737
742
  preset: input.preset,
738
743
  };
739
- return measureMeshQualityWith(measureInput, run.rasters);
744
+ return run.stepRasters === null ? measureMeshQualityWith(measureInput, run.rasters) : measureMeshQualityStep(measureInput, run.stepRasters);
740
745
  }
741
746
 
742
747
  /** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
@@ -1172,7 +1177,8 @@ function tryRemoval(run: Run, v: number): string | null {
1172
1177
  */
1173
1178
  export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1174
1179
  validateReduction(input);
1175
- return reduceValidated(input, artRastersOf(input.art));
1180
+ const rasters = artRastersOf(input.art);
1181
+ return reduceValidated(input, rasters, stepRastersOf(rasters));
1176
1182
  }
1177
1183
 
1178
1184
  /**
@@ -1180,19 +1186,29 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1180
1186
  * `src/meshrasters.ts`) — the result is `reduceMesh`'s for the same input, byte
1181
1187
  * for byte, and every measurement of the call reads the one object, so its
1182
1188
  * `tally` is the count of what the call computed. Rasters taken from another
1183
- * art are refused at admission (`REDUCE_ART_RASTERS_MISMATCH`).
1189
+ * art are refused at admission (`REDUCE_ART_RASTERS_MISMATCH`). Each step is
1190
+ * measured through `steps` (`stepRastersOf(rasters)` unless given), carried
1191
+ * from the last measurement (issue #1246); `null` measures every step in full,
1192
+ * which is the path the carried one is held equal to. Step rasters made over
1193
+ * another rasters object are refused by the same code.
1184
1194
  *
1185
1195
  * Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
1186
1196
  * module with `export *`, and a symbol that is merely exported is not promised
1187
1197
  * (RELEASING.md, *The import surface*).
1188
1198
  */
1189
- export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters): MeshReductionResult {
1199
+ export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null = stepRastersOf(rasters)): MeshReductionResult {
1190
1200
  validateReduction(input);
1191
- return reduceValidated(input, rasters);
1201
+ if (steps !== null && steps.rasters !== rasters) {
1202
+ refuse('REDUCE_ART_RASTERS_MISMATCH', `attachment ${nameOf(input.attachment)}: the step rasters were made over another rasters object; required step rasters made over the rasters passed beside them (stepRastersOf(rasters))`);
1203
+ }
1204
+ return reduceValidated(input, rasters, steps);
1192
1205
  }
1193
1206
 
1194
- /** The operation, over an input `validateReduction` accepted; the art's rasters are computed at most once, in `rasters`. */
1195
- function reduceValidated(input: MeshReductionInput, rasters: ArtRasters): MeshReductionResult {
1207
+ /**
1208
+ * The operation, over an input `validateReduction` accepted; the art's rasters are computed at most once, in
1209
+ * `rasters`, and every refinement and removal step is measured through `steps` when it is given (issue #1246).
1210
+ */
1211
+ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters, steps: StepRasters | null): MeshReductionResult {
1196
1212
  const who = `attachment ${nameOf(input.attachment)}`;
1197
1213
  const src = input.source;
1198
1214
  const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
@@ -1273,6 +1289,7 @@ function reduceValidated(input: MeshReductionInput, rasters: ArtRasters): MeshRe
1273
1289
  ...protectionOf(input, work, sourceEdges),
1274
1290
  sourceEdges,
1275
1291
  rasters,
1292
+ stepRasters: steps,
1276
1293
  };
1277
1294
 
1278
1295
  const refined = refineRegions(run);