spine-rigc 2.19.1 → 2.20.1

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.
@@ -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 } from './meshrasters.ts';
77
73
  import { cropToSpineY } from './transform.ts';
78
74
 
79
75
  // ---------------------------------------------------------------------------
@@ -299,6 +295,8 @@ export interface MeasureRow {
299
295
  raster?: RasterSensitivity;
300
296
  /** Sampled rows — correction 2. */
301
297
  sampling?: { domain: string; count: number };
298
+ /** Motion rows only (stage C, `src/meshcompare.ts`): how the row's value was taken over the schedule. */
299
+ motion?: MotionRowDetail;
302
300
  }
303
301
 
304
302
  /** Correction 2: the spatial grid and the value's own granularity are two fields. */
@@ -353,9 +351,10 @@ export interface MotionSchedule {
353
351
 
354
352
  /**
355
353
  * The schedule a motion section was measured under. The contract names this
356
- * type without defining it; until stage C does, it is the schedule as given.
354
+ * type without defining it; stage C (issue #1230) defines it as the schedule as
355
+ * given plus what was walked from it (`ScheduleWalked`, at the end of this file).
357
356
  */
358
- export type ScheduleUsed = MotionSchedule;
357
+ export type ScheduleUsed = MotionSchedule & ScheduleWalked;
359
358
 
360
359
  /** One candidate's evidence — correction 1. `reduce` and `measure` carry exactly one. */
361
360
  export interface CandidateReport {
@@ -421,8 +420,8 @@ export interface EffectiveSettings {
421
420
  frame: SourceFrame;
422
421
  maskSize: [number, number];
423
422
  minArtSamples: number;
424
- /** P9: each region's own floor, in the order the regions were given. */
425
- regions: Array<{ name: string; minArtSamples: number }>;
423
+ /** P9: each region's own floor, in the order the regions were given — and, on a `compare`, its polygon. */
424
+ regions: Array<{ name: string; minArtSamples: number; polygon?: Array<[number, number]> }>;
426
425
  }>;
427
426
  sourceBounds: ArtFitBounds | null;
428
427
  referenceArtFit: ArtFitBounds | null;
@@ -1009,6 +1008,53 @@ function compareRows(a: MeasureRow, b: MeasureRow): number {
1009
1008
  */
1010
1009
  export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1011
1010
  validateInput(input);
1011
+ return measureValidated(input, artRastersOf(input.art));
1012
+ }
1013
+
1014
+ /**
1015
+ * `measureMeshQuality` over art rasters the caller already holds
1016
+ * (`src/meshrasters.ts`) — what `reduceMesh` calls for every measurement of
1017
+ * one call, so the quantities of the art alone are computed once per call
1018
+ * rather than once per step (issue #1240). The report is the one
1019
+ * `measureMeshQuality` returns for the same input, byte for byte; rasters
1020
+ * taken from another art are refused (`REDUCE_ART_RASTERS_MISMATCH`), never
1021
+ * read.
1022
+ *
1023
+ * Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
1024
+ * module with `export *`, and a symbol that is merely exported is not promised
1025
+ * (RELEASING.md, *The import surface*).
1026
+ */
1027
+ export function measureMeshQualityWith(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1028
+ validateInput(input);
1029
+ checkArtRasters(input, rasters);
1030
+ return measureValidated(input, rasters);
1031
+ }
1032
+
1033
+ /**
1034
+ * Rasters are read only for the art they were taken from: the same mask
1035
+ * array, mask size, threshold and frame. The first field that differs is
1036
+ * refused by name, with the value the rasters were taken at and the value the
1037
+ * input requires.
1038
+ */
1039
+ function checkArtRasters(input: MeshMeasureInput, rasters: ArtRasters): void {
1040
+ const want = input.art;
1041
+ const got = rasters.art;
1042
+ if (got === want) return;
1043
+ const who = `attachment ${nameOf(input.attachment)}`;
1044
+ const differs = (field: string, found: string, required: string): never =>
1045
+ 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`);
1046
+ const size = (m: AlphaMask | undefined): string => (m === undefined ? 'undefined' : `${m.width}x${m.height}`);
1047
+ if (size(got.mask) !== size(want.mask)) differs('art.mask size', size(got.mask), size(want.mask));
1048
+ if (got.threshold !== want.threshold) differs('art.threshold', JSON.stringify(got.threshold), JSON.stringify(want.threshold));
1049
+ for (const field of ['pageScale', 'width', 'height'] as const) {
1050
+ if (got.frame?.[field] !== want.frame[field]) differs(`art.frame.${field}`, JSON.stringify(got.frame?.[field]), JSON.stringify(want.frame[field]));
1051
+ }
1052
+ if (got.mask.alpha !== want.mask.alpha) differs('art.mask.alpha', 'another array', 'the input\'s own array (the same object)');
1053
+ }
1054
+
1055
+ /** The measurement proper, over an input `validateInput` accepted and rasters taken from its art. */
1056
+ function measureValidated(input: MeshMeasureInput, rasters: ArtRasters): MeshQualityReport {
1057
+ rasters.tally.uses++;
1012
1058
  const { attachment, art, source, targets } = input;
1013
1059
  const { mask, threshold, frame } = art;
1014
1060
  const scale = frame.pageScale;
@@ -1050,9 +1096,8 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1050
1096
  const w = mask.width;
1051
1097
  const h = mask.height;
1052
1098
  const grid = { width: w, height: h, pageScale: scale };
1053
- const artBits = artOf(mask, threshold);
1054
- let artCount = 0;
1055
- for (const bit of artBits) artCount += bit;
1099
+ const artBits = rasters.artBits();
1100
+ const artCount = rasters.artCount();
1056
1101
  const onGrid: Array<[number, number]> = source.points.map(([x, y]) => [x * scale, y * scale]);
1057
1102
  const covered = rasteriseTriangles(onGrid, source.triangles, w, h);
1058
1103
  const fit = targets.artFit;
@@ -1105,17 +1150,9 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1105
1150
 
1106
1151
  // Overshoot and holes against the filled silhouette: 8-connected background over ALL art (P12) — and,
1107
1152
  // where the legacy 4-connected fill differs (a diagonal pinch), the labelled 4-connected reading beside it.
1108
- const fill8 = fillEnclosed(artBits, w, h, 8).filled;
1109
- const fill4 = fillEnclosed(artBits, w, h, 4).filled;
1110
- let fillsDiffer = false;
1111
- for (let i = 0; i < fill8.length; i++) {
1112
- if (fill8[i] !== fill4[i]) {
1113
- fillsDiffer = true;
1114
- break;
1115
- }
1116
- }
1153
+ const { fill8, fill4, differ: fillsDiffer } = rasters.fills();
1117
1154
  const silhouetteRows = (filled: Uint8Array, connectivity: 4 | 8, gated: boolean): void => {
1118
- const toFilled = squaredDistanceToSet(filled, w, h);
1155
+ const toFilled = rasters.toFilled(connectivity);
1119
1156
  let overAt = -1;
1120
1157
  let overSq = 0;
1121
1158
  let holes = 0;
@@ -1147,7 +1184,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1147
1184
  if (fillsDiffer) silhouetteRows(fill4, 4, false);
1148
1185
 
1149
1186
  // Islands: the 4-connected art islands (the tracer's, `labelIslands`) the triangles cover a pixel of.
1150
- const { label } = labelIslands(artBits, w, h);
1187
+ const { label } = rasters.islands();
1151
1188
  const touched = new Set<number>();
1152
1189
  for (let i = 0; i < label.length; i++) if (label[i] && covered[i]) touched.add(label[i]);
1153
1190
  let joinedAt = -1;
@@ -1176,14 +1213,12 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1176
1213
  rows.push(measuredRow(boundarySpec, r6(hd.d), bound, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, true));
1177
1214
  }
1178
1215
  const traceSpec = spec('MQ_TRACE_DEVIATION', 'px');
1179
- try {
1180
- const traced = traceAlphaOutline(mask, threshold);
1181
- const outlinePx = prunePolygon(traced.outline.map(([x, y]): [number, number] => [x / scale, y / scale]));
1182
- const hd = hausdorff(hullPolygon, outlinePx);
1216
+ const traced = rasters.traced();
1217
+ if ('outline' in traced) {
1218
+ const hd = hausdorff(hullPolygon, traced.outline);
1183
1219
  rows.push(measuredRow(traceSpec, r6(hd.d), null, hd.d === 0 ? NOTHING_WORSE : { at: { edge: edgeOfHull(hd.candidateEdge) } }, false));
1184
- } catch (err) {
1185
- if (!(err instanceof MeshError)) throw err;
1186
- rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${err.message}`, false));
1220
+ } else {
1221
+ rows.push(unmeasuredRow(traceSpec, 'not-measurable', `attachment ${nameOf(attachment)}: the tracer refused the art at alpha >= ${threshold}: ${traced.refused}`, false));
1187
1222
  }
1188
1223
 
1189
1224
  // --- triangles: sign, degeneracy, angle --------------------------------------------------
@@ -1230,7 +1265,7 @@ export function measureMeshQuality(input: MeshMeasureInput): MeshQualityReport {
1230
1265
  );
1231
1266
 
1232
1267
  // --- regions (§5) ------------------------------------------------------------------------
1233
- rows.push(...regionRows(input, outline, hullPolygon));
1268
+ rows.push(...regionRows(input, outline, hullPolygon, rasters));
1234
1269
 
1235
1270
  const ordered = rows.slice().sort((a, b) => compareRows(a.row, b.row));
1236
1271
  const geometry = sectionOf(ordered);
@@ -1328,7 +1363,7 @@ interface Edge {
1328
1363
  * length most exceeds its own bound — so the row fails exactly when some edge
1329
1364
  * it covers is over — and the row names that edge and that bound.
1330
1365
  */
1331
- function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[]): Built[] {
1366
+ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon: readonly Pt[], rasters: ArtRasters): Built[] {
1332
1367
  const { attachment, source, art } = input;
1333
1368
  const who = `attachment ${nameOf(attachment)}`;
1334
1369
  const scale = art.frame.pageScale;
@@ -1404,7 +1439,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
1404
1439
 
1405
1440
  const floorOf = (name: string): number => input.regionArtSamples.find((f) => f.region === name)!.minArtSamples;
1406
1441
  const { mask, threshold } = art;
1407
- const artBits = artOf(mask, threshold);
1442
+ const artBits = rasters.artBits();
1408
1443
 
1409
1444
  for (const region of input.targets.regions) {
1410
1445
  const refused = refusals.get(region.name);
@@ -1576,7 +1611,7 @@ function effectiveJson(e: EffectiveSettings): Json {
1576
1611
  frame: frameJson(a.frame),
1577
1612
  maskSize: pair(a.maskSize),
1578
1613
  minArtSamples: a.minArtSamples,
1579
- regions: a.regions.map((r) => ({ name: r.name, minArtSamples: r.minArtSamples })),
1614
+ regions: a.regions.map((r): Json => (r.polygon === undefined ? { name: r.name, minArtSamples: r.minArtSamples } : { name: r.name, minArtSamples: r.minArtSamples, polygon: r.polygon.map(pair) })),
1580
1615
  })),
1581
1616
  sourceBounds: fitJson(e.sourceBounds),
1582
1617
  referenceArtFit: fitJson(e.referenceArtFit),
@@ -1631,6 +1666,7 @@ function rowJson(r: MeasureRow): Json {
1631
1666
  };
1632
1667
  }
1633
1668
  if (r.sampling !== undefined) out.sampling = { domain: r.sampling.domain, count: r.sampling.count };
1669
+ if (r.motion !== undefined) out.motion = motionDetailJson(r.motion);
1634
1670
  return out;
1635
1671
  }
1636
1672
 
@@ -1651,7 +1687,7 @@ function candidateJson(c: CandidateReport): Json {
1651
1687
  id: c.id,
1652
1688
  counts: countsJson(c.counts),
1653
1689
  geometry: c.geometry === null ? null : sectionJson(c.geometry),
1654
- motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleJson(c.motion.schedule) },
1690
+ motion: c.motion === null ? null : { ...sectionJson(c.motion), schedule: scheduleUsedJson(c.motion.schedule) },
1655
1691
  accepted: c.accepted,
1656
1692
  };
1657
1693
  if (c.perFrame !== undefined) out.perFrame = c.perFrame.map((p) => ({ code: p.code, frame: p.frame, value: p.value }));
@@ -1702,3 +1738,97 @@ export function writeMeshQualityReport(report: MeshQualityReport): string {
1702
1738
  };
1703
1739
  return `${JSON.stringify(doc, null, 2)}\n`;
1704
1740
  }
1741
+
1742
+ // ---------------------------------------------------------------------------
1743
+ // stage C — the motion section's own fields (issue #1230, `src/meshcompare.ts`)
1744
+ // ---------------------------------------------------------------------------
1745
+ //
1746
+ // Additive: nothing above reads these, a `measure` and a `reduce` never write
1747
+ // them, and every one is written only when present.
1748
+
1749
+ /** One group of frames a motion row was also taken over — a phase, or a role (P11). */
1750
+ export interface MotionReading {
1751
+ /** The group's worst value; null when no frame of the group drew the attachment. */
1752
+ value: number | null;
1753
+ /** The value against the row's bound, by the row's own rule; `undeclared` with no bound, `not-measurable` with no value. */
1754
+ state: MeasureState;
1755
+ /** The frame the group's worst value was taken at, by `FrameRef.id`. */
1756
+ frame: string | null;
1757
+ }
1758
+
1759
+ /**
1760
+ * How a motion row's value was taken over the schedule (stage C). The row's own
1761
+ * `value` is the worst over every frame measured; these keep apart what the
1762
+ * contract says must not be folded together.
1763
+ */
1764
+ export interface MotionRowDetail {
1765
+ /** Frames at which the attachment was drawn and measured, and the ids of those at which its slot drew something else or nothing. */
1766
+ frames: { measured: number; notDrawn: string[] };
1767
+ /**
1768
+ * §4 *Transition in time*: the row taken per sample phase — `grid` and `irr`
1769
+ * for the frames a rate generated, `null` for the setup frame and for frames
1770
+ * the caller gave as explicit times (no phase applies to those).
1771
+ */
1772
+ byPhase: Array<{ phase: 'grid' | 'irr' | null } & MotionReading>;
1773
+ /** When one phase passes and another fails: the worst frame of each, by id, and a sentence naming both. Null otherwise. */
1774
+ phasesDisagree: { pass: string; fail: string; sentence: string } | null;
1775
+ /** P11: the row per role. `heldOut` is null when no held-out frame was measured — no held-out claim is made. */
1776
+ byRole: { baseline: MotionReading | null; selection: MotionReading | null; heldOut: MotionReading | null };
1777
+ /**
1778
+ * Correction 4, world rows: the declared setup map of the attachment's slot
1779
+ * bone — its world 2×2 at the setup pose, `[a, b, c, d]` as the core poses it
1780
+ * from the bones' declared fields — and its two singular scales. Never a
1781
+ * single world-per-pixel ratio, and never fitted to output vertices.
1782
+ */
1783
+ setupMap?: { bone: string; linear: [number, number, number, number]; singularScales: [number, number] };
1784
+ /** §3: samples no non-degenerate UV triangle carries, listed by UV — in the reference's mesh and in this candidate's. */
1785
+ uncarried?: { reference: Array<[number, number]>; candidate: Array<[number, number]> };
1786
+ /** §3, `MQ_INVERSION` on a slot `invariants.deformMayFold` names: every reversed triangle with its frame — listed, never zeroed. */
1787
+ folds?: Array<{ triangle: number; frame: string }>;
1788
+ /** Triangles that have no area at the setup pose (the A39 band), so no sign and no stretch is read off them. */
1789
+ degenerateAtSetup?: number;
1790
+ }
1791
+
1792
+ /** What a motion section walked from the schedule it was given (`ScheduleUsed`). */
1793
+ export interface ScheduleWalked {
1794
+ /** Every frame the schedule names, in walk order, each with its role (P11). */
1795
+ walked: FrameRef[];
1796
+ /** The roles among those frames — what the section has evidence for. */
1797
+ roles: Array<FrameRef['role']>;
1798
+ /** P11: true only when at least one held-out frame, disjoint from `selection`, was walked. */
1799
+ heldOutClaim: boolean;
1800
+ /** P10: every walk resets physics at time 0 and steps from there, the same steps for the reference and every candidate. */
1801
+ reset: 'physics reset at time 0';
1802
+ /** Each walk: the animation, the phase (null for explicit times), and how many steps reached its last frame. */
1803
+ walks: Array<{ animation: string; phase: 'grid' | 'irr' | null; steps: number }>;
1804
+ }
1805
+
1806
+ function readingJson(r: MotionReading | null): Json {
1807
+ return r === null ? null : { value: r.value, state: r.state, frame: r.frame };
1808
+ }
1809
+
1810
+ function motionDetailJson(m: MotionRowDetail): Json {
1811
+ const out: { [key: string]: Json } = {
1812
+ frames: { measured: m.frames.measured, notDrawn: [...m.frames.notDrawn] },
1813
+ byPhase: m.byPhase.map((p) => ({ phase: p.phase, value: p.value, state: p.state, frame: p.frame })),
1814
+ phasesDisagree: m.phasesDisagree === null ? null : { pass: m.phasesDisagree.pass, fail: m.phasesDisagree.fail, sentence: m.phasesDisagree.sentence },
1815
+ byRole: { baseline: readingJson(m.byRole.baseline), selection: readingJson(m.byRole.selection), heldOut: readingJson(m.byRole.heldOut) },
1816
+ };
1817
+ if (m.setupMap !== undefined) out.setupMap = { bone: m.setupMap.bone, linear: [...m.setupMap.linear], singularScales: pair(m.setupMap.singularScales) };
1818
+ if (m.uncarried !== undefined) out.uncarried = { reference: m.uncarried.reference.map(pair), candidate: m.uncarried.candidate.map(pair) };
1819
+ if (m.folds !== undefined) out.folds = m.folds.map((f) => ({ triangle: f.triangle, frame: f.frame }));
1820
+ if (m.degenerateAtSetup !== undefined) out.degenerateAtSetup = m.degenerateAtSetup;
1821
+ return out;
1822
+ }
1823
+
1824
+ function scheduleUsedJson(s: ScheduleUsed): Json {
1825
+ const given = scheduleJson(s) as { [key: string]: Json };
1826
+ return {
1827
+ ...given,
1828
+ walked: s.walked.map(frameRefJson),
1829
+ roles: [...s.roles],
1830
+ heldOutClaim: s.heldOutClaim,
1831
+ reset: s.reset,
1832
+ walks: s.walks.map((w) => ({ animation: w.animation, phase: w.phase, steps: w.steps })),
1833
+ };
1834
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The art-derived rasters one mesh measurement reads — every quantity of
3
+ * `measureMeshQuality`'s raster rows (`src/meshquality.ts`) whose only input is
4
+ * the art: the art bits at the threshold, the filled silhouette at both
5
+ * background connectivities and each one's distance transform, the
6
+ * 4-connected island labels, and the traced outline in drawing pixels.
7
+ *
8
+ * ## Why it is a value and not a cache
9
+ *
10
+ * `reduceMesh` measures one candidate per step against art that never changes
11
+ * within the call, and recomputing these per step was nearly all of its cost
12
+ * (issue #1240: 98.98 % of a 1101-step call's samples in the measurement, at
13
+ * least 40 % in functions of the art alone). So the call takes them once, at
14
+ * admission, and hands this object to every measurement it makes. There is no
15
+ * module-level cache and no hidden state: the object is created by the caller,
16
+ * passed explicitly, and dropped with the call. `measureMeshQuality` itself
17
+ * makes a fresh one per call, so its behaviour is the uncached one.
18
+ *
19
+ * Each quantity is computed on first read and kept, so a measurement that
20
+ * never reaches a row (a source that is not one loop, art under its sample
21
+ * floor) computes no more than it did before. `tally` counts every
22
+ * computation and every measurement handed the object — the count a control
23
+ * reads to see that a reduction computed each quantity once.
24
+ *
25
+ * ## What it is taken at, and the refusal
26
+ *
27
+ * The object holds the `ArtInput` it was made from. A measurement handed it
28
+ * for a different art is refused, not served stale numbers: the mask array,
29
+ * the mask's size, the threshold and the frame's scale and size are each
30
+ * compared, and a difference is `REDUCE_ART_RASTERS_MISMATCH` naming the field,
31
+ * the value the rasters were taken at and the value the input requires
32
+ * (`checkArtRasters`, `src/meshquality.ts`).
33
+ *
34
+ * Not re-exported by `src/mesh.ts`: nothing here is on `spine-rigc/mesh`.
35
+ */
36
+ import { artOf, fillEnclosed, labelIslands, MeshError, prunePolygon, squaredDistanceToSet, traceAlphaOutline } from './mesh.ts';
37
+ import type { ArtInput } from './meshquality.ts';
38
+
39
+ /** How many times each quantity was computed, and how many measurements were handed the object. */
40
+ export interface ArtRasterTally {
41
+ /** Measurements that read the object (each after its own check). */
42
+ uses: number;
43
+ artBits: number;
44
+ fills: number;
45
+ toFilled8: number;
46
+ toFilled4: number;
47
+ islands: number;
48
+ traced: number;
49
+ }
50
+
51
+ /** The traced art outline in drawing pixels, pruned — or the tracer's refusal, as its message. */
52
+ export type TracedArt = { outline: Array<[number, number]> } | { refused: string };
53
+
54
+ export interface ArtRasters {
55
+ /** The art these were taken from: its mask array, size, threshold and frame. */
56
+ readonly art: ArtInput;
57
+ readonly tally: ArtRasterTally;
58
+ /** `artOf(mask, threshold)`: 1 where alpha >= threshold. */
59
+ artBits(): Uint8Array;
60
+ /** The number of art pixels in `artBits()`. */
61
+ artCount(): number;
62
+ /** `fillEnclosed` of the art at both background connectivities, and whether they differ anywhere. */
63
+ fills(): { fill8: Uint8Array; fill4: Uint8Array; differ: boolean };
64
+ /** `squaredDistanceToSet` of the filled silhouette at one connectivity. */
65
+ toFilled(connectivity: 4 | 8): Float64Array;
66
+ /** `labelIslands` of the art: 4-connected labels, 1-based, and each island's size. */
67
+ islands(): { label: Int32Array; sizes: number[] };
68
+ /** `traceAlphaOutline` at the threshold, carried to drawing pixels by `pageScale` and pruned. */
69
+ traced(): TracedArt;
70
+ }
71
+
72
+ /**
73
+ * The rasters of one art, each computed on first read. Reads nothing at
74
+ * construction, so it may be made before the art is validated; every reader
75
+ * checks it against a validated input first.
76
+ */
77
+ export function artRastersOf(art: ArtInput): ArtRasters {
78
+ const tally: ArtRasterTally = { uses: 0, artBits: 0, fills: 0, toFilled8: 0, toFilled4: 0, islands: 0, traced: 0 };
79
+ let bits: { bits: Uint8Array; count: number } | null = null;
80
+ let fills: { fill8: Uint8Array; fill4: Uint8Array; differ: boolean } | null = null;
81
+ let toFilled8: Float64Array | null = null;
82
+ let toFilled4: Float64Array | null = null;
83
+ let islands: { label: Int32Array; sizes: number[] } | null = null;
84
+ let traced: TracedArt | null = null;
85
+ const artBitsOf = (): { bits: Uint8Array; count: number } => {
86
+ if (bits === null) {
87
+ tally.artBits++;
88
+ const b = artOf(art.mask, art.threshold);
89
+ let count = 0;
90
+ for (const bit of b) count += bit;
91
+ bits = { bits: b, count };
92
+ }
93
+ return bits;
94
+ };
95
+ const fillsOf = (): { fill8: Uint8Array; fill4: Uint8Array; differ: boolean } => {
96
+ if (fills === null) {
97
+ tally.fills++;
98
+ const { width: w, height: h } = art.mask;
99
+ const b = artBitsOf().bits;
100
+ const fill8 = fillEnclosed(b, w, h, 8).filled;
101
+ const fill4 = fillEnclosed(b, w, h, 4).filled;
102
+ let differ = false;
103
+ for (let i = 0; i < fill8.length; i++) {
104
+ if (fill8[i] !== fill4[i]) {
105
+ differ = true;
106
+ break;
107
+ }
108
+ }
109
+ fills = { fill8, fill4, differ };
110
+ }
111
+ return fills;
112
+ };
113
+ return {
114
+ art,
115
+ tally,
116
+ artBits: () => artBitsOf().bits,
117
+ artCount: () => artBitsOf().count,
118
+ fills: fillsOf,
119
+ toFilled: (connectivity) => {
120
+ const { width: w, height: h } = art.mask;
121
+ if (connectivity === 8) {
122
+ if (toFilled8 === null) {
123
+ tally.toFilled8++;
124
+ toFilled8 = squaredDistanceToSet(fillsOf().fill8, w, h);
125
+ }
126
+ return toFilled8;
127
+ }
128
+ if (toFilled4 === null) {
129
+ tally.toFilled4++;
130
+ toFilled4 = squaredDistanceToSet(fillsOf().fill4, w, h);
131
+ }
132
+ return toFilled4;
133
+ },
134
+ islands: () => {
135
+ if (islands === null) {
136
+ tally.islands++;
137
+ islands = labelIslands(artBitsOf().bits, art.mask.width, art.mask.height);
138
+ }
139
+ return islands;
140
+ },
141
+ traced: () => {
142
+ if (traced === null) {
143
+ tally.traced++;
144
+ const scale = art.frame.pageScale;
145
+ try {
146
+ const t = traceAlphaOutline(art.mask, art.threshold);
147
+ traced = { outline: prunePolygon(t.outline.map(([x, y]): [number, number] => [x / scale, y / scale])) };
148
+ } catch (err) {
149
+ if (!(err instanceof MeshError)) throw err;
150
+ traced = { refused: err.message };
151
+ }
152
+ }
153
+ return traced;
154
+ },
155
+ };
156
+ }
package/src/meshreduce.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  * Mesh reduction and local refinement — stage B2 of the contract in
3
3
  * docs/MESH_REDUCTION.md (issue #1221, stage B: issue #1224).
4
4
  *
5
- * One operation is exported, `reduceMesh`, and it is two operations composed:
5
+ * One operation is exported, `reduceMesh` — `reduceMeshWith` is the same
6
+ * operation over art rasters the caller made — and it is two operations
7
+ * composed:
6
8
  *
7
9
  * 1. **Refinement** (`refineRegions`) inserts vertices inside the union of the
8
10
  * declared regions and their bands until §5's `L(R)` holds on every edge
@@ -22,7 +24,9 @@
22
24
  * ## What every step is held to
23
25
  *
24
26
  * Each candidate step is measured by `measureMeshQuality` (`src/meshquality.ts`)
25
- * — no row is re-implemented here — and taken only when every row the caller's
27
+ * — no row is re-implemented here; the call reads it as `measureMeshQualityWith`
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
26
30
  * contract requires is `pass`: the art fit at `targets.artFit`, the boundary
27
31
  * deviation from the source hull at `targets.maxBoundaryDeviation`, the minimum
28
32
  * angle when declared, orientation and degeneracy at 0, and every region's
@@ -71,8 +75,6 @@ import {
71
75
  distanceToSegment,
72
76
  earClip,
73
77
  findSelfIntersection,
74
- labelIslands,
75
- artOf,
76
78
  MeshError,
77
79
  MeshReductionError,
78
80
  meshEdges,
@@ -85,7 +87,7 @@ import {
85
87
  import {
86
88
  BAND_CONTACT_TOLERANCE,
87
89
  edgeIsHeldByRegion,
88
- measureMeshQuality,
90
+ measureMeshQualityWith,
89
91
  type AttachmentRef,
90
92
  type ArtFitBounds,
91
93
  type CandidateReport,
@@ -101,6 +103,7 @@ import {
101
103
  type SourceMesh,
102
104
  type Termination,
103
105
  } from './meshquality.ts';
106
+ import { artRastersOf, type ArtRasters } from './meshrasters.ts';
104
107
 
105
108
  // ---------------------------------------------------------------------------
106
109
  // the result
@@ -711,6 +714,8 @@ interface Run {
711
714
  protectedVertices: Set<number>;
712
715
  protectedEdges: Array<[number, number]>;
713
716
  sourceEdges: Set<string>;
717
+ /** The art's rasters, taken once for the call and read by every measurement in it (issue #1240). */
718
+ rasters: ArtRasters;
714
719
  }
715
720
 
716
721
  /** A measurement of a canonical mesh against the result's full contract. */
@@ -731,7 +736,7 @@ function measureAgainstTargets(run: Run, mesh: SourceMesh, id: string): MeshQual
731
736
  boneOrder: input.boneOrder,
732
737
  preset: input.preset,
733
738
  };
734
- return measureMeshQuality(measureInput);
739
+ return measureMeshQualityWith(measureInput, run.rasters);
735
740
  }
736
741
 
737
742
  /** The order constraints are named in when several fail on one step: structure, then shape, then art, then density. */
@@ -1167,26 +1172,50 @@ function tryRemoval(run: Run, v: number): string | null {
1167
1172
  */
1168
1173
  export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1169
1174
  validateReduction(input);
1175
+ return reduceValidated(input, artRastersOf(input.art));
1176
+ }
1177
+
1178
+ /**
1179
+ * `reduceMesh` over art rasters the caller made (`artRastersOf`,
1180
+ * `src/meshrasters.ts`) — the result is `reduceMesh`'s for the same input, byte
1181
+ * for byte, and every measurement of the call reads the one object, so its
1182
+ * `tally` is the count of what the call computed. Rasters taken from another
1183
+ * art are refused at admission (`REDUCE_ART_RASTERS_MISMATCH`).
1184
+ *
1185
+ * Internal: it is on `spine-rigc/mesh` only because that entry re-exports this
1186
+ * module with `export *`, and a symbol that is merely exported is not promised
1187
+ * (RELEASING.md, *The import surface*).
1188
+ */
1189
+ export function reduceMeshWith(input: MeshReductionInput, rasters: ArtRasters): MeshReductionResult {
1190
+ validateReduction(input);
1191
+ return reduceValidated(input, rasters);
1192
+ }
1193
+
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 {
1170
1196
  const who = `attachment ${nameOf(input.attachment)}`;
1171
1197
  const src = input.source;
1172
1198
  const sourceHull: Array<[number, number]> = src.points.slice(0, Math.max(0, src.hull)).map(([x, y]): [number, number] => [x, y]);
1173
1199
 
1174
1200
  // The source, measured against its own admissibility bounds (correction 3) — this is also what refuses a
1175
1201
  // malformed art, mesh, region list or sample floor, by measureMeshQuality's own codes.
1176
- const admit = measureMeshQuality({
1177
- id: 'source',
1178
- attachment: input.attachment,
1179
- art: input.art,
1180
- source: src,
1181
- targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
1182
- referenceHull: null,
1183
- minArtSamples: input.minArtSamples,
1184
- regionArtSamples: input.regionArtSamples,
1185
- protect: input.protect,
1186
- influences: input.influences,
1187
- boneOrder: input.boneOrder,
1188
- preset: input.preset,
1189
- });
1202
+ const admit = measureMeshQualityWith(
1203
+ {
1204
+ id: 'source',
1205
+ attachment: input.attachment,
1206
+ art: input.art,
1207
+ source: src,
1208
+ targets: { artFit: input.sourceBounds, maxBoundaryDeviation: null, regions: input.targets.regions },
1209
+ referenceHull: null,
1210
+ minArtSamples: input.minArtSamples,
1211
+ regionArtSamples: input.regionArtSamples,
1212
+ protect: input.protect,
1213
+ influences: input.influences,
1214
+ boneOrder: input.boneOrder,
1215
+ preset: input.preset,
1216
+ },
1217
+ rasters,
1218
+ );
1190
1219
  const effective = effectiveOf(input, admit.effective, sourceHull);
1191
1220
  const sourceCounts = admit.sourceCounts;
1192
1221
  const noMesh = (termination: Termination): MeshReductionResult => ({
@@ -1215,7 +1244,7 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1215
1244
  );
1216
1245
  }
1217
1246
  }
1218
- checkIslands(input, who);
1247
+ checkIslands(input, who, rasters);
1219
1248
  checkDeformBeforeWork(input);
1220
1249
 
1221
1250
  const boneRank = new Map((input.boneOrder ?? []).map((b, i) => [b, i]));
@@ -1243,6 +1272,7 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1243
1272
  keyed: keyedSourceVertices(input),
1244
1273
  ...protectionOf(input, work, sourceEdges),
1245
1274
  sourceEdges,
1275
+ rasters,
1246
1276
  };
1247
1277
 
1248
1278
  const refined = refineRegions(run);
@@ -1268,11 +1298,10 @@ export function reduceMesh(input: MeshReductionInput): MeshReductionResult {
1268
1298
  }
1269
1299
 
1270
1300
  /** §4: full coverage asked of a source that touches no pixel of some art island is refused, never met by deleting art. */
1271
- function checkIslands(input: MeshReductionInput, who: string): void {
1301
+ function checkIslands(input: MeshReductionInput, who: string, rasters: ArtRasters): void {
1272
1302
  if (input.targets.artFit.minCoverage !== 1) return;
1273
- const { mask, threshold, frame } = input.art;
1274
- const bits = artOf(mask, threshold);
1275
- const { label, sizes } = labelIslands(bits, mask.width, mask.height);
1303
+ const { mask, frame } = input.art;
1304
+ const { label, sizes } = rasters.islands();
1276
1305
  const onGrid = input.source.points.map(([x, y]): [number, number] => [x * frame.pageScale, y * frame.pageScale]);
1277
1306
  const covered = rasteriseTriangles(onGrid, input.source.triangles, mask.width, mask.height);
1278
1307
  const touched = new Set<number>();