partforge 0.143.0 → 0.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.143.0",
3
+ "version": "0.144.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -764,6 +764,44 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
764
764
  build,
765
765
  );
766
766
  },
767
+ // The change tracker's boolean diff (oracle/changes.js): two plain meshes in,
768
+ // added (new − old) and removed (old − new) material out, split into connected
769
+ // pieces. Never cached — it runs outside any beginSubPart bracket — and every
770
+ // WASM object is T()-tracked, so the caller's cleanup() frees all of it.
771
+ _meshDiff: (oldMesh, newMesh, { maxTriangles = 300000, minVolume = 0 } = {}) => {
772
+ const tris = (mesh) => (mesh.indices?.length ?? 0) / 3;
773
+ if (tris(oldMesh) + tris(newMesh) > maxTriangles) return { ok: false, reason: "too-large" };
774
+ let a, b;
775
+ try {
776
+ a = T(manifoldFromMesh(wasm, oldMesh.positions, oldMesh.indices));
777
+ b = T(manifoldFromMesh(wasm, newMesh.positions, newMesh.indices));
778
+ } catch {
779
+ return { ok: false, reason: "not-watertight" };
780
+ }
781
+ const regions = (mm) => {
782
+ const pieces = mm.decompose().map((p) => T(p));
783
+ return pieces
784
+ .map((p) => {
785
+ const { min, max } = p.boundingBox();
786
+ return {
787
+ mm3: p.volume(),
788
+ at: [0, 1, 2].map((i) => (min[i] + max[i]) / 2),
789
+ size: [0, 1, 2].map((i) => max[i] - min[i]),
790
+ };
791
+ })
792
+ .filter((r) => r.mm3 >= minVolume)
793
+ .sort((x, y) => y.mm3 - x.mm3);
794
+ };
795
+ const added = T(b.subtract(a));
796
+ const removed = T(a.subtract(b));
797
+ return {
798
+ ok: true,
799
+ addedMm3: added.volume(),
800
+ removedMm3: removed.volume(),
801
+ added: regions(added),
802
+ removed: regions(removed),
803
+ };
804
+ },
767
805
  // Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
768
806
  // holes regardless of their winding (outer + holes, no per-hole boolean cut).
769
807
  // A Shape2D `profile` (curve-native, possibly multi-region) materializes through
@@ -287,6 +287,7 @@ export function createOcctKernel(replicad) {
287
287
  _s: shape,
288
288
  _labels: labels,
289
289
  _hash: hash,
290
+ _baseHash: baseHash,
290
291
  _pose: pose,
291
292
  _mat: () => mat(),
292
293
  label: (name) => {
@@ -51,6 +51,29 @@ function makeProbe(onCall) {
51
51
  toSTL: () => new ArrayBuffer(0),
52
52
  toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
53
53
  };
54
+ // Solid's optional mesh-topology queries return numbers/booleans, not a handle.
55
+ Object.assign(solidQueries, { genus: () => 0, isEmpty: () => false });
56
+ // Shape2D ops that return DATA rather than another Shape2D. Without an entry here an
57
+ // op falls through to the chaining branch and hands back the Shape2D handle itself,
58
+ // so ordinary array/number use of the result (`.corners().map(…)`, `.area() * 2`)
59
+ // was recorded as an unknown Shape2D op and failed lint as `unknown-solid-op`.
60
+ // `regions()` returns live Shape2Ds, so its dummy is an array holding the handle.
61
+ // The dummy region is a unit square in the contour IR (closed: last point = first).
62
+ const square = [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]];
63
+ const shape2dQueries = {
64
+ ...solidQueries,
65
+ isEmpty: () => false,
66
+ area: () => 1,
67
+ boundingBox: () => ({ min: [0, 0], max: [1, 1], center: [0.5, 0.5], size: [1, 1] }),
68
+ toRegions: () => [{ outer: square.map((p) => [...p]), holes: [] }],
69
+ simple: () => ({ outer: square.map((p) => [...p]), holes: [] }),
70
+ toContours: () => [{ outer: { start: [0, 0], segments: square.slice(1).map((p) => ({ to: [...p] })) }, holes: [] }],
71
+ corners: () => [0, 1, 2, 3].map((i) => ({
72
+ index: i, position: i, point: [...square[i]], interiorAngleDeg: 90, convex: true, segTypes: ["line", "line"],
73
+ })),
74
+ contains: () => false,
75
+ regions: () => [shape2d],
76
+ };
54
77
  const kernelQueries = {
55
78
  toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
56
79
  cleanup: () => {},
@@ -82,10 +105,8 @@ function makeProbe(onCall) {
82
105
  },
83
106
  });
84
107
 
85
- // Shape2D handles reuse solidQueries: boundingBox/volume/… dummies chain the same
86
- // either way, and a 3-component bbox is as good a dummy as a 2-component one.
87
108
  const proxy = opProxy(solidQueries, "solid", () => proxy); // a solid handle: every op chains back to itself
88
- const shape2d = opProxy(solidQueries, "shape2d",
109
+ const shape2d = opProxy(shape2dQueries, "shape2d",
89
110
  (key) => (SOLID_YIELDING_SHAPE2D_OPS.has(key) ? proxy : shape2d));
90
111
  const kernel = opProxy(kernelQueries, "kernel", // factory ops (cylinder/box/prism/…) return a solid
91
112
  (key) => (SHAPE2D_YIELDING_KERNEL_OPS.has(key) ? shape2d : proxy));
@@ -2,8 +2,43 @@
2
2
  // folds via FNV-1a → base36. Solid operands are passed as their own (already
3
3
  // computed) short `_hash` string, so composing two solids stays O(1) and the
4
4
  // resulting key length stays bounded no matter how deep the build graph is.
5
+
6
+ // Optional hash-graph recording for the oracle's change tracker
7
+ // (oracle/changes.js). Off by default — `h` pays one null check. While on, every
8
+ // call records its result with the op name and the operand hashes it consumed, so
9
+ // the tracker can tell WHICH op first produced a different hash between two builds.
10
+ // Single-threaded by construction (one worker, one synchronous build).
11
+ let recording = null; // { map, limit, overflow }
12
+
13
+ export function startHashRecording({ limit = 20000 } = {}) {
14
+ recording = { map: new Map(), limit, overflow: false };
15
+ }
16
+
17
+ export function stopHashRecording() {
18
+ const r = recording;
19
+ recording = null;
20
+ if (!r || r.overflow) return null;
21
+ return r.map;
22
+ }
23
+
24
+ const stringLeaves = (x, out) => {
25
+ if (typeof x === "string") out.push(x);
26
+ else if (Array.isArray(x)) for (const y of x) stringLeaves(y, out);
27
+ return out;
28
+ };
29
+
5
30
  export function h(...parts) {
6
- return fnv(parts.map(canon).join("|"));
31
+ const out = fnv(parts.map(canon).join("|"));
32
+ if (recording && !recording.overflow) {
33
+ const { map } = recording;
34
+ if (map.size >= recording.limit) recording.overflow = true;
35
+ else if (!map.has(out)) {
36
+ const op = typeof parts[0] === "string" ? parts[0] : null;
37
+ const inputs = stringLeaves(parts.slice(1), []).filter((s) => map.has(s));
38
+ map.set(out, { op, inputs, label: op === "label" && typeof parts[2] === "string" ? parts[2] : null });
39
+ }
40
+ }
41
+ return out;
7
42
  }
8
43
 
9
44
  function canon(x) {
@@ -14,6 +49,11 @@ function canon(x) {
14
49
 
15
50
  // FNV-1a folded to 32-bit space (collision risk acceptable: retained only for one
16
51
  // sub-part's build graph ~3–15 nodes, rebuilt each round, no accumulation).
52
+ // These hashes are now also compared ACROSS inspect rounds: the oracle's change
53
+ // tracker (oracle/changes.js) reads an equal final hash as "unchanged", and its
54
+ // measure memo reuses that sub-part's facts. A collision there would report a
55
+ // changed sub-part as unchanged and serve its stale measurements — the same
56
+ // trust the solid cache already extends to these keys, and no wider.
17
57
  function fnv(s) {
18
58
  let hsh = 0x811c9dc5;
19
59
  for (let i = 0; i < s.length; i++) { hsh ^= s.charCodeAt(i); hsh = Math.imul(hsh, 0x01000193); }
@@ -10,11 +10,21 @@ import { parse3MF } from "./geometry/threemf-parse.js";
10
10
 
11
11
  const EXT = { step: "step", stp: "step", stl: "stl", "3mf": "3mf" };
12
12
 
13
+ // Font files declared as an import. Without this a TTF fell through to the STL
14
+ // fallback below and failed as "binary STL truncated: header says 3848966777
15
+ // triangles" — an error about a mesh the author never meant (cloud feedback #148).
16
+ const FONT_EXT = new Set(["ttf", "otf", "woff", "woff2"]);
17
+ const FONT_MAGIC = ["\x00\x01\x00\x00", "OTTO", "true", "ttcf", "wOFF", "wOF2"];
18
+
13
19
  export function detectFormat(source, bytes) {
14
20
  const path = source instanceof URL ? source.pathname : typeof source === "string" ? source.split("?")[0] : null;
15
21
  const ext = path?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase();
16
- if (ext && EXT[ext]) return EXT[ext];
17
22
  const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
23
+ const magic = u8 && u8.length >= 4 ? String.fromCharCode(...u8.slice(0, 4)) : "";
24
+ if ((ext && FONT_EXT.has(ext)) || FONT_MAGIC.includes(magic)) {
25
+ throw new Error(`a font file${path ? ` ("${path}")` : ""} is declared under \`imports\` — declare it under \`fonts\` and use it with text2d`);
26
+ }
27
+ if (ext && EXT[ext]) return EXT[ext];
18
28
  if (u8 && u8.length > 0) {
19
29
  const head = String.fromCharCode(...u8.slice(0, 64));
20
30
  if (head.startsWith("ISO-10303-21")) return "step";
@@ -33,8 +33,15 @@ const loadInspect = () => Promise.all([
33
33
  import("./oracle/build.js"),
34
34
  import("./oracle/measure.js"),
35
35
  import("./oracle/verify.js"),
36
+ import("./oracle/changes.js"),
36
37
  ]);
37
38
 
39
+ // The change tracker's baseline lives for the worker's lifetime (a tab, a headless
40
+ // pool member) — one per worker, not per job, so an `inspect` can diff against the
41
+ // PREVIOUS one under the same `changesKey`. Created lazily on first use so a
42
+ // session that never passes `changesKey` never allocates it.
43
+ let changeTracker = null;
44
+
38
45
  // The cut & print kit loads the same way, on the first export-bundle job: its writers,
39
46
  // layout and README (export/*) and the process exporters behind them are dead weight to
40
47
  // a session that never downloads a kit. test/worker-layering.test.js keeps every one of
@@ -597,12 +604,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
597
604
  // unrecognized value must never quietly buy less checking than the caller
598
605
  // asked for.
599
606
  const quick = msg.checks === "quick";
600
- const [{ buildView }, { measure }, { verify }] = await loadInspect();
607
+ const [{ buildView }, { measure }, { verify }, { createChangeTracker }] = await loadInspect();
601
608
  const view = msg.view ?? Object.keys(part.views)[0];
609
+ // Only the defaults-params inspect is tracked: `msg.params` non-empty means a
610
+ // parameterized inspect, which is a different geometry and must never become
611
+ // (or be diffed against) the tracked baseline.
612
+ const tracking = typeof msg.changesKey === "string" && msg.changesKey.length > 0
613
+ && Object.keys(msg.params ?? {}).length === 0;
602
614
  const reads = newReadSink();
603
- const built = buildView(kernel, part, view, msg.params ?? {}, { reads });
615
+ // Nothing may sit between begin() and the try below: a throw there would
616
+ // leave the hash recording running with no abort().
617
+ if (tracking) { changeTracker ??= createChangeTracker(); changeTracker.begin(msg.changesKey, view); }
618
+ let built;
619
+ try {
620
+ built = buildView(kernel, part, view, msg.params ?? {}, { reads });
621
+ } catch (e) {
622
+ if (tracking) changeTracker.abort();
623
+ throw e;
624
+ }
625
+ if (tracking) changeTracker.endBuild(built);
604
626
  const measured = measure(kernel, part, view, msg.params ?? {},
605
- { minWall: !quick, gaps: !quick, built, reads });
627
+ { minWall: !quick, gaps: !quick, built, reads, ...(tracking ? { memo: changeTracker.memo } : {}) });
606
628
  const report = {
607
629
  measure: measured,
608
630
  verify: verify(kernel, part, {
@@ -622,7 +644,10 @@ export async function handle(kernel, part, msg, post, opts = {}) {
622
644
  // an inspect with no `matchTargets` answers on exactly the shape it always has.
623
645
  const match = await scoreMatchTargets(built, msg.matchTargets, onProgress);
624
646
  if (match) report.match = match;
625
- post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
647
+ // Past measure()'s own kernel.cleanup(): finish() reads only `_hash` strings,
648
+ // shape hashes, placement matrices, and JS-owned mesh/volume data, never a live solid.
649
+ const changeFields = tracking ? changeTracker.finish(kernel, view, built, measured) : {};
650
+ post({ type: "report", ...report, ...changeFields }, match?.map((m) => m.delta.data.buffer) ?? []);
626
651
  } else if (opts.jobs && Object.hasOwn(opts.jobs, msg.type)) {
627
652
  await opts.jobs[msg.type](kernel, part, msg, post, { isStale: opts.isStale });
628
653
  }
@@ -0,0 +1,208 @@
1
+ // What changed since the previous inspect of this view — the agent's
2
+ // "did my edit do what I meant" signal (spec: partforge-cloud
3
+ // docs/superpowers/specs/2026-09-30-geometry-diff-design.md). One tracker per
4
+ // kernel lifetime (a worker, a headless pool); best-effort by design: a retired
5
+ // worker, a new key or a new view simply has no baseline. Also hosts the measure
6
+ // memo for sub-parts whose final hash did not change.
7
+ //
8
+ // Call order inside one inspect: begin → buildView → endBuild(built) → measure →
9
+ // finish. endBuild is where every LIVE-solid read happens (hashes and the indexed
10
+ // mesh the boolean diff needs): measure ends with kernel.cleanup(), which frees
11
+ // the posed solids, so finish works only from what endBuild captured plus
12
+ // measure's volumes.
13
+ import { startHashRecording, stopHashRecording } from "../geometry/solid-hash.js";
14
+ import { changedRoots } from "./op-graph.js";
15
+ import { bounds } from "./mesh.js";
16
+ import { composePose } from "../geometry/pose.js";
17
+
18
+ // The share of the consumer's whole-report timeout this tracker may spend,
19
+ // measured from begin() — buildView, endBuild's re-meshing and measure all run
20
+ // before finish, so a budget counted from finish alone could push the report
21
+ // past partforge-cloud's REPORT_TIMEOUT_MS (8 s), which discards match, measure
22
+ // and verify together. 6 s leaves verify and match scoring the rest.
23
+ export const REPORT_SHARE_MS = 6000;
24
+
25
+ const centre = (positions) => {
26
+ const b = bounds(positions);
27
+ return [0, 1, 2].map((i) => (b.min[i] + b.max[i]) / 2);
28
+ };
29
+ const shapeHashOf = (s) => s?._canon?.hash ?? s?._baseHash ?? null;
30
+ // The rotation block of a solid's placement — the trailing rigid transforms on
31
+ // top of its shape hash: Manifold's canon chain ({op, ...}) or OCCT's pending
32
+ // pose ({t, ...}). Composed rather than compared entry by entry, so two chains
33
+ // that land on the same orientation agree. null when the backend exposes none.
34
+ const rotationOf = (s) => {
35
+ const steps = s?._canon?.chain?.map(({ op, ...r }) => ({ t: op, ...r })) ?? s?._pose;
36
+ if (!Array.isArray(steps)) return null;
37
+ const m = composePose(steps);
38
+ return [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
39
+ };
40
+ const rotationChanged = (a, b) => !!a && !!b && a.some((v, i) => Math.abs(v - b[i]) > 1e-6);
41
+ const triangleCount = (mesh) => (mesh?.indices ? mesh.indices.length / 3 : (mesh?.positions?.length ?? 0) / 9);
42
+ // The boolean diff needs an INDEXED mesh. buildView's mesh is Manifold's
43
+ // non-indexed soup, so a Manifold solid is re-meshed; an OCCT display mesh is
44
+ // already indexed (and OCCT has no boolean diff), so it is copied rather than
45
+ // re-tessellated at export quality.
46
+ const indexedMeshOf = (solid, mesh) => {
47
+ const m = mesh?.indices ? mesh : solid.toIndexedMesh();
48
+ return { positions: Float32Array.from(m.positions), indices: Uint32Array.from(m.indices) };
49
+ };
50
+
51
+ export function createChangeTracker({ now = Date.now, budgetMs = 2000, maxTriangles = 300000 } = {}) {
52
+ let key = null;
53
+ let view = null; // the view this round is building (from begin)
54
+ let baseline = null; // { view, hashes:Set|null (null: the record overflowed), subparts: Map(name -> entry) }
55
+ let record = null;
56
+ // Map(name -> {hash, shape, rotation, centre, mesh, volume}) from endBuild;
57
+ // `mesh` is null for a sub-part too big to diff (over maxTriangles/2).
58
+ let captured = null;
59
+ let startedAt = null; // begin()'s clock reading: the report deadline counts from here
60
+ let memoPrev = new Map(), memoNext = new Map();
61
+ let pairPrev = new Map(), pairNext = new Map();
62
+ const mk = (hash, k2) => `${hash}\u0000${k2}`; // facts depend on geometry, not the name
63
+ const pk = (a, ha, b, hb) => `${a}\u0000${ha}\u0000${b}\u0000${hb}`;
64
+
65
+ // Stored under a structuredClone: the caller's `facts`/`gap` object is also
66
+ // handed out as part of the oracle's REPORT, which a consumer is free to
67
+ // mutate. Without the clone, that mutation reaches through the same object
68
+ // reference into the memo and poisons a later hit; cloning on write is what
69
+ // keeps the memo's copy independent of whatever happens to the report.
70
+ // Cloned on read too, for the same reason in the other direction: a hit is
71
+ // handed out in the report. Every clone is guarded — a value structuredClone
72
+ // refuses is simply not memoized (or reads as a miss), so measure measures
73
+ // instead of throwing.
74
+ const clone = (v) => { try { return structuredClone(v); } catch { return undefined; } };
75
+ const memo = {
76
+ get: (name, hash, k2) => {
77
+ const v = memoPrev.get(mk(hash, k2));
78
+ const c = v === undefined ? undefined : clone(v);
79
+ return c ? { ...c, name } : undefined;
80
+ },
81
+ set: (name, hash, k2, facts) => { const c = clone(facts); if (c !== undefined) memoNext.set(mk(hash, k2), c); },
82
+ getPair: (a, ha, b, hb) => { const v = pairPrev.get(pk(a, ha, b, hb)); return v === undefined ? undefined : clone(v); },
83
+ setPair: (a, ha, b, hb, gap) => { const c = clone(gap); if (c !== undefined) pairNext.set(pk(a, ha, b, hb), c); },
84
+ };
85
+
86
+ const reset = () => { baseline = null; memoPrev = new Map(); pairPrev = new Map(); };
87
+ const clearRound = () => { record = null; captured = null; startedAt = null; memoNext = new Map(); pairNext = new Map(); };
88
+ const prevFor = (v) => (baseline?.view === v ? baseline : null);
89
+
90
+ return {
91
+ memo,
92
+ begin(k, v) {
93
+ if (k !== key) { key = k; reset(); }
94
+ view = v;
95
+ clearRound();
96
+ startedAt = now();
97
+ startHashRecording();
98
+ },
99
+ endBuild(built) {
100
+ record = stopHashRecording();
101
+ captured = null;
102
+ if (!Array.isArray(built)) return;
103
+ try {
104
+ const prev = prevFor(view);
105
+ captured = new Map();
106
+ for (const { name, solid, mesh } of built) {
107
+ const hash = solid?._hash ?? null;
108
+ const old = prev?.subparts.get(name);
109
+ // An unchanged sub-part keeps the stored entry: no re-mesh for it.
110
+ if (old && hash && old.hash === hash) { captured.set(name, { ...old }); continue; }
111
+ // Re-meshing is uncounted by finish's budget, so a sub-part too big for
112
+ // the boolean diff anyway (the pair is refused past maxTriangles) is not
113
+ // captured at all: next round it falls back to the volume-delta verdict.
114
+ const big = triangleCount(mesh) > maxTriangles / 2;
115
+ captured.set(name, {
116
+ hash, shape: shapeHashOf(solid), rotation: rotationOf(solid),
117
+ centre: centre(mesh.positions), mesh: big ? null : indexedMeshOf(solid, mesh),
118
+ });
119
+ }
120
+ } catch {
121
+ captured = null;
122
+ }
123
+ },
124
+ abort() { stopHashRecording(); clearRound(); },
125
+ finish(kernel, v, built, measured) {
126
+ try {
127
+ // No capture means no baseline can be stored for this build: forget the
128
+ // old one too, so the next round reports {} rather than diffing two builds back.
129
+ if (!Array.isArray(built) || !captured || v !== view) { reset(); return {}; }
130
+ const start = now();
131
+ // Whichever ends first: the diff budget, or the report's share counted from begin().
132
+ const deadline = Math.min(start + budgetMs, (startedAt ?? start) + REPORT_SHARE_MS);
133
+ const vol = new Map((measured?.subparts ?? []).map((s) => [s.name, s.volume]));
134
+ const prev = prevFor(v);
135
+ const out = [];
136
+ let unchanged = 0, skipped = null, verdicts = 0, timedOut = false;
137
+ for (const [name, entry] of captured) {
138
+ entry.volume = vol.get(name) ?? null;
139
+ if (!prev) continue;
140
+ const old = prev.subparts.get(name);
141
+ if (!old) { out.push({ name, verdict: "new" }); verdicts++; continue; }
142
+ const { hash, shape, mesh, rotation } = entry;
143
+ if (hash && hash === old.hash) { unchanged++; verdicts++; continue; }
144
+ // Both graphs are needed: against a missing baseline graph every op reads as new.
145
+ const ops = record?.size && prev.hashes && hash ? changedRoots(record, hash, prev.hashes) : [];
146
+ const withOps = (o) => (ops.length ? { ...o, changedOps: ops } : o);
147
+ if (shape && shape === old.shape) {
148
+ // `moved` is the bounding-box centre's offset, so a rotation about the
149
+ // centre reads as ≈0 — `rotated` is what says the orientation changed.
150
+ const a = old.centre, b = entry.centre;
151
+ const rotated = rotationChanged(old.rotation, rotation);
152
+ out.push(withOps({ name, verdict: "moved", moved: [0, 1, 2].map((i) => b[i] - a[i]), ...(rotated ? { rotated: true } : {}) }));
153
+ verdicts++;
154
+ continue;
155
+ }
156
+ // Budget spent: this build is still stored whole as the next baseline
157
+ // (captured holds every entry), but nothing more is diffed.
158
+ if (timedOut || now() > deadline) { timedOut = true; continue; }
159
+ const delta = { volumeDeltaMm3: (entry.volume ?? 0) - (old.volume ?? 0) };
160
+ // Either side too big to have been captured: the diff would refuse it anyway.
161
+ if (!mesh || !old.mesh) {
162
+ if (typeof kernel?._meshDiff === "function") skipped ??= "too-large";
163
+ out.push(withOps({ name, verdict: "reshaped", ...delta })); verdicts++; continue;
164
+ }
165
+ if (typeof kernel?._meshDiff !== "function") {
166
+ out.push(withOps({ name, verdict: "reshaped", ...delta })); verdicts++; continue;
167
+ }
168
+ const floor = Math.max(0.5, 1e-4 * Math.abs(entry.volume ?? 0));
169
+ // A throw (a WASM fault, OOM) is not one of the reported skip reasons: it
170
+ // falls back to the volume delta and the loop goes on, so the baseline
171
+ // still rotates to this build.
172
+ let d;
173
+ try { d = kernel._meshDiff(old.mesh, mesh, { maxTriangles, minVolume: floor }); } catch { d = null; }
174
+ if (!d?.ok) {
175
+ if (d) skipped ??= d.reason;
176
+ out.push(withOps({ name, verdict: "reshaped", ...delta })); verdicts++; continue;
177
+ }
178
+ const add = d.addedMm3 > floor, rem = d.removedMm3 > floor;
179
+ const regions = [
180
+ ...d.added.map((r) => ({ change: "added", ...r })),
181
+ ...d.removed.map((r) => ({ change: "removed", ...r })),
182
+ ].sort((x, y) => y.mm3 - x.mm3).slice(0, 3);
183
+ out.push(withOps({
184
+ name, verdict: add && !rem ? "added" : rem && !add ? "removed" : "reshaped",
185
+ addedMm3: d.addedMm3, removedMm3: d.removedMm3, ...(regions.length ? { regions } : {}),
186
+ }));
187
+ verdicts++;
188
+ }
189
+ if (prev) for (const name of prev.subparts.keys()) if (!captured.has(name)) { out.push({ name, verdict: "deleted" }); verdicts++; }
190
+ baseline = { view: v, hashes: record ? new Set(record.keys()) : null, subparts: captured };
191
+ memoPrev = memoNext; pairPrev = pairNext;
192
+ if (!prev) return {};
193
+ if (timedOut && verdicts === 0) return { changesSkipped: "timeout" };
194
+ if (timedOut) skipped ??= "timeout";
195
+ // "unchanged" is a claim about EVERY sub-part, so a timed-out one rules it out.
196
+ const changes = out.length === 0 && !timedOut
197
+ ? { unchanged: true, subparts: [], unchangedSubparts: unchanged }
198
+ : { subparts: out, unchangedSubparts: unchanged };
199
+ return skipped ? { changes, changesSkipped: skipped } : { changes };
200
+ } catch {
201
+ reset();
202
+ return {};
203
+ } finally {
204
+ clearRound();
205
+ }
206
+ },
207
+ };
208
+ }
@@ -20,17 +20,30 @@ export const pairKey = (a, b) => [a, b].sort().join("×");
20
20
  // `bvhCache` is an optional caller-owned Map — see cachedBVH for the doctrine;
21
21
  // measure() draws min-wall's index out of the same one, so each sub-part mesh is
22
22
  // indexed once, not twice.
23
+ // `prior(a, b) → {distance, at}|undefined` lets a caller skip a pair's distance
24
+ // query entirely (the measure memo: both sub-parts' hashes matched a stored
25
+ // pair). `onPair(a, b, gap)` is called for every pair, computed or reused, so
26
+ // the caller can re-store what it just confirmed or learned. BVHs are built
27
+ // lazily, one per sub-part, only for a sub-part that has at least one pair not
28
+ // covered by `prior` — an all-unchanged view builds no BVH at all.
23
29
  // → [{ a, b, distance, at: [x,y,z] }]
24
- export function meshGaps(built, { bvhCache } = {}) {
30
+ export function meshGaps(built, { bvhCache, prior, onPair } = {}) {
25
31
  const hasTris = (m) => (m.indices ? m.indices.length > 0 : m.positions.length > 0);
26
- const bvhs = built
27
- .filter(({ mesh }) => hasTris(mesh))
28
- .map(({ name, mesh }) => ({ name, bvh: cachedBVH(mesh, bvhCache) }));
32
+ const items = built.filter(({ mesh }) => hasTris(mesh));
33
+ const bvhOf = new Map();
34
+ const bvh = (it) => {
35
+ if (!bvhOf.has(it.name)) bvhOf.set(it.name, cachedBVH(it.mesh, bvhCache));
36
+ return bvhOf.get(it.name);
37
+ };
29
38
  const out = [];
30
- for (let i = 0; i < bvhs.length; i++) {
31
- for (let j = i + 1; j < bvhs.length; j++) {
32
- const { distance, at } = bvhs[i].bvh.distanceTo(bvhs[j].bvh);
33
- out.push({ a: bvhs[i].name, b: bvhs[j].name, distance, at });
39
+ for (let i = 0; i < items.length; i++) {
40
+ for (let j = i + 1; j < items.length; j++) {
41
+ const a = items[i].name, b = items[j].name;
42
+ const reused = prior?.(a, b);
43
+ const { distance, at } = reused ?? bvh(items[i]).distanceTo(bvh(items[j]));
44
+ const g = { a, b, distance, at };
45
+ onPair?.(a, b, { distance, at });
46
+ out.push(g);
34
47
  }
35
48
  }
36
49
  return out;
@@ -135,8 +135,9 @@ const transformPoint = (m, [x, y, z]) => [
135
135
  // is carried into the export pose by a rigid matrix — no second build — and the bed is
136
136
  // that pose's own lowest Z; `at` comes back in display coordinates, where every other
137
137
  // finding of this view sits. An unreadable or identical pose measures as shown.
138
- function printPoseOverhang(mesh, sp, { view, p, d, maxAngle, displayBedZ }) {
139
- const m = p ? displayToPrintMatrix(sp, { view, p, d }) : null;
138
+ // `m` is displayToPrintMatrix's answer, resolved by the caller: the measure memo
139
+ // keys on it, since the pose comes from place() and is not part of the solid's hash.
140
+ function printPoseOverhang(mesh, m, { maxAngle, displayBedZ }) {
140
141
  if (!m) return overhang(mesh, { maxAngle, bedZ: displayBedZ });
141
142
  const positions = Float32Array.from(mesh.positions);
142
143
  transformPositions(positions, m);
@@ -297,6 +298,42 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
297
298
  const sheetBudget = { left: opts.sheetBudgetMs ?? SHEET_CHECK_BUDGET_MS, now: opts.now ?? Date.now };
298
299
  const subparts = built.map(({ name, solid, mesh }) => {
299
300
  const sheet = isSheetPart(part.parts[name]);
301
+ // A sub-part that is never printed (`exportable: false` — a reference ghost, a
302
+ // probe slab, a placeholder) is not judged for overhang, and neither is a sheet
303
+ // part (laser-cut, never printed). For one that is, the display→print matrix
304
+ // (printPoseOverhang) is read off place() with the resolved params — geometry-free,
305
+ // so it is resolved here, ahead of the memo, which must key on it.
306
+ const printed = part.parts[name]?.exportable !== false && !sheet;
307
+ const printMatrix = overhangAngle != null && printed && poseParams?.p
308
+ ? displayToPrintMatrix(part.parts[name], { view, p: poseParams.p, d: poseParams.d })
309
+ : null;
310
+ // Memo key = the inputs this sub-part's facts actually depend on, besides its
311
+ // own geometry hash: whether min-wall ran, at what sample budget and against
312
+ // which declared band, the overhang angle, and whether the sub-part is
313
+ // exportable (it gates overhangArea/overhangAngle/overhangAt via `printed`,
314
+ // below — without it, toggling `exportable` on unchanged geometry would
315
+ // return a stale overhang reading, or a non-exportable ghost would share a
316
+ // real sub-part's entry), and the display→print matrix the overhang reading
317
+ // poses the mesh by (it comes from place() and the params, not the geometry, so
318
+ // a param that moves only the print pose leaves the hash unchanged). Withheld for sheet parts, a declared `reference`
319
+ // sub-part (deviation reads another import), any sub-part when the view
320
+ // holds a sheet part (the budgeted 2-D pass and print-pose sizes are
321
+ // call-scoped, not per-sub-part cacheable), and a solid with no `_hash`
322
+ // (nothing to key reuse on).
323
+ const memoKey = opts.memo && !sheetView && !sheet && !part.parts[name]?.reference && solid?._hash
324
+ ? JSON.stringify({
325
+ mw: !!opts.minWall, s: minWallSamples ?? null, band: wallBands[name] ?? null,
326
+ oh: overhangAngle ?? null, ex: printed,
327
+ pm: printMatrix ? Array.from(printMatrix) : null,
328
+ })
329
+ : null;
330
+ if (memoKey) {
331
+ const hit = opts.memo.get(name, solid._hash, memoKey);
332
+ // Re-store on a hit: the memo rotates prev→next per round (changes.js), so
333
+ // a hit read only from `prev` and never re-written would be reused every
334
+ // OTHER round, not every round an unchanged sub-part is measured.
335
+ if (hit) { subBounds.push(hit.bounds); opts.memo.set(name, solid._hash, memoKey, hit); return hit; }
336
+ }
300
337
  const b = bounds(mesh.positions);
301
338
  subBounds.push(b);
302
339
  // Resolved lazily and only when asked for: without min-wall, a single-sub-part
@@ -306,13 +343,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
306
343
  ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
307
344
  : null;
308
345
  // One pass over the triangles, no index — cheap enough for every lap. The bed
309
- // is this sub-part's own lowest Z in its PRINT pose (printPoseOverhang). A
310
- // sub-part that is never printed (`exportable: false` — a reference ghost, a
311
- // probe slab, a placeholder) is not judged. A sheet part is laser-cut, never
312
- // printed: no overhang reading for it either.
313
- const printed = part.parts[name]?.exportable !== false && !sheet;
346
+ // is this sub-part's own lowest Z in its PRINT pose (printPoseOverhang); `printed`
347
+ // (above) says whether it is judged at all.
314
348
  const oh = overhangAngle != null && printed
315
- ? printPoseOverhang(mesh, part.parts[name], { view, ...poseParams, maxAngle: overhangAngle, displayBedZ: b.min[2] })
349
+ ? printPoseOverhang(mesh, printMatrix, { maxAngle: overhangAngle, displayBedZ: b.min[2] })
316
350
  : null;
317
351
  const vol = solid.volume();
318
352
  // Deviation-from-reference: only for a sub-part that declares `reference:
@@ -335,7 +369,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
335
369
  bboxDelta: [0, 1, 2].map((i) => Math.max(Math.abs(b.min[i] - rb.min[i]), Math.abs(b.max[i] - rb.max[i]))),
336
370
  };
337
371
  }
338
- return {
372
+ const facts = {
339
373
  name,
340
374
  bbox: size(b),
341
375
  bounds: { min: b.min, max: b.max },
@@ -373,6 +407,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
373
407
  ...(printBboxes?.[name]?.size ? { printBbox: printBboxes[name].size } : {}),
374
408
  ...(printBboxes?.[name]?.error ? { printBboxError: printBboxes[name].error } : {}),
375
409
  };
410
+ if (memoKey) opts.memo.set(name, solid._hash, memoKey, facts);
411
+ return facts;
376
412
  });
377
413
 
378
414
  // Declared probes, evaluated regardless of view (they are part-level facts —
@@ -394,7 +430,16 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
394
430
  // pairGapChecks reads an empty table as "measured, and this pair has no distance"
395
431
  // and fails a declared gate on it, while an absent table reads as no reading.
396
432
  const measuredGaps = opts.gaps !== false;
397
- const gaps = measuredGaps ? (built.length > 1 ? meshGaps(built, { bvhCache }) : []) : undefined;
433
+ const hashOf = new Map(built.map(({ name, solid }) => [name, solid?._hash ?? null]));
434
+ const gaps = measuredGaps
435
+ ? (built.length > 1
436
+ ? meshGaps(built, {
437
+ bvhCache,
438
+ prior: opts.memo ? (a, b) => (hashOf.get(a) && hashOf.get(b) ? opts.memo.getPair(a, hashOf.get(a), b, hashOf.get(b)) : undefined) : undefined,
439
+ onPair: opts.memo ? (a, b, g) => { if (hashOf.get(a) && hashOf.get(b)) opts.memo.setPair(a, hashOf.get(a), b, hashOf.get(b), g); } : undefined,
440
+ })
441
+ : [])
442
+ : undefined;
398
443
 
399
444
  // Rebuilds with the same kernel and cleans up at its end — every solid fact
400
445
  // above is already read, so this is safe.
@@ -0,0 +1,49 @@
1
+ // Which operation FIRST produced a different result between two builds. The hash
2
+ // graph comes from solid-hash.js's recorder; a hash absent from the previous build
3
+ // is a changed op, and a changed op whose inputs are all unchanged is a ROOT —
4
+ // everything downstream changed only because a root did. Pure; no kernel.
5
+
6
+ export function ancestry(record, finalHash) {
7
+ const seen = new Set();
8
+ const stack = [finalHash];
9
+ while (stack.length) {
10
+ const k = stack.pop();
11
+ if (seen.has(k)) continue;
12
+ seen.add(k);
13
+ for (const i of record.get(k)?.inputs ?? []) stack.push(i);
14
+ }
15
+ return seen;
16
+ }
17
+
18
+ export function changedRoots(record, finalHash, baselineHashes, { max = 5 } = {}) {
19
+ const anc = ancestry(record, finalHash);
20
+ const isNew = (k) => record.has(k) && !baselineHashes.has(k);
21
+ // Forward edges inside this sub-part's ancestry, for the label search.
22
+ const consumers = new Map();
23
+ for (const k of anc) for (const i of record.get(k)?.inputs ?? []) {
24
+ if (!consumers.has(i)) consumers.set(i, []);
25
+ consumers.get(i).push(k);
26
+ }
27
+ const labelFor = (k) => {
28
+ const queue = [k];
29
+ const seen = new Set();
30
+ while (queue.length) {
31
+ const c = queue.shift();
32
+ if (seen.has(c)) continue;
33
+ seen.add(c);
34
+ const e = record.get(c);
35
+ if (e?.op === "label" && e.label) return e.label;
36
+ for (const n of consumers.get(c) ?? []) queue.push(n);
37
+ }
38
+ return null;
39
+ };
40
+ const out = [];
41
+ for (const [k, e] of record) {
42
+ if (out.length >= max) break;
43
+ if (!anc.has(k) || !isNew(k)) continue;
44
+ if (e.inputs.some(isNew)) continue;
45
+ const label = labelFor(k);
46
+ out.push(label ? { op: e.op ?? "op", label } : { op: e.op ?? "op" });
47
+ }
48
+ return out;
49
+ }
@@ -1,63 +1,63 @@
1
1
  // src/framework/stale-touch-guard.js
2
- // Keeps the camera controls from holding onto a finger that has already left
3
- // the screen.
2
+ // Keeps the viewer from holding onto a finger that has already left the
3
+ // screen.
4
4
  //
5
5
  // OrbitControls tracks every pointer that goes down on the canvas and forgets
6
- // one only on its pointerup or pointercancel. When the page is embedded in a
7
- // host that runs its own gestures (a chat app's WKWebView, which scrolls the
8
- // conversation on a vertical drag), the host can take a drag over and the page
9
- // never hears that finger lift. The controls then keep it as down forever: the
10
- // next one-finger drag reads as a two-finger pinch (it zooms), and with two
11
- // stuck, no drag moves the camera at all.
6
+ // one only on its pointerup or pointercancel, and the view cube, the cutaway
7
+ // handles and measure mode track pointers the same way on their own elements.
8
+ // When the page is embedded in a host that runs its own gestures (a chat app's
9
+ // WKWebView, which scrolls the conversation on a vertical drag), the host can
10
+ // take a drag over and the page never hears that finger lift. Whatever it went
11
+ // down on then keeps it as down forever: for the controls the next one-finger
12
+ // drag reads as a two-finger pinch (it zooms), and with two stuck, no drag
13
+ // moves the camera at all; the view cube keeps its press, so a hover over it
14
+ // spins the camera.
12
15
  //
13
- // Two measures, both on the canvas's document and both passive:
16
+ // Two measures, both on the root's document and both passive:
14
17
  //
15
18
  // 1. Do-nothing touchstart/touchend/touchcancel listeners. WebKit on iOS hands
16
19
  // a touch to the page only where the page listens for TOUCH events, and the
17
- // controls listen for pointer events alone, so without these the host's
20
+ // viewer listens for pointer events alone, so without these the host's
18
21
  // gesture recognizers could claim a drag the page itself was handling.
19
22
  //
20
- // 2. Stale fingers are cancelled on arrival. A touch pointerdown marked
21
- // isPrimary is the first finger of a brand-new gesture, so no other touch
22
- // can really be down; every touch id still recorded as down on the canvas is
23
- // sent the pointercancel it missed, from a capture listener on the document
24
- // (which runs before the controls' own listener on the canvas). A second
23
+ // 2. Stale fingers are cancelled on arrival. Every touch that goes down inside
24
+ // the viewer's root is recorded with the element it went down on (a touch
25
+ // outside the root is the host's business and is never recorded). A touch
26
+ // pointerdown marked isPrimary is the first finger of a brand-new gesture,
27
+ // so no other touch can really be down; every touch id still recorded is
28
+ // sent the pointercancel it missed, on the element it went down on and
29
+ // carrying the isPrimary it went down with (the view cube ignores a
30
+ // non-primary pointer, cancel included), from a capture listener on the
31
+ // document (which runs before any listener inside the root). A second
25
32
  // finger of the same gesture is never primary, so pinch and two-finger pan
26
33
  // are untouched.
27
34
  //
28
- // The controls release pointer capture for the id they remove, and that call
29
- // throws for an id the browser has already forgotten, aborting their cleanup
30
- // half way (the move listeners and the gesture state would stay live). So the
31
- // canvas's releasePointerCapture is wrapped to ignore that one failure.
35
+ // Whatever receives the cancel releases pointer capture for that id, and that
36
+ // call throws for an id the browser has already forgotten, aborting the
37
+ // cleanup half way (OrbitControls would keep its move listeners and gesture
38
+ // state live; the view cube would keep its press). So while a cancel is being
39
+ // dispatched, its element's releasePointerCapture ignores that one failure,
40
+ // and is put back the moment the dispatch returns.
32
41
  const TOUCH_TYPES = ["touchstart", "touchend", "touchcancel"];
33
42
 
34
- export function guardStaleTouches(element) {
35
- const doc = element.ownerDocument;
43
+ export function guardStaleTouches(root) {
44
+ const doc = root.ownerDocument;
36
45
  if (!doc) return () => {};
37
46
  const noop = () => {};
38
- const touchesDown = new Set();
39
-
40
- const release = element.releasePointerCapture;
41
- if (typeof release === "function") {
42
- element.releasePointerCapture = function releaseIfHeld(pointerId) {
43
- try {
44
- release.call(this, pointerId);
45
- } catch {
46
- // Already released: the finger is gone, which is all the caller wanted.
47
- }
48
- };
49
- }
47
+ const touchesDown = new Map(); // pointerId -> { target, isPrimary } it went down with
50
48
 
51
49
  const onDown = (event) => {
52
50
  if (event.pointerType !== "touch") return;
53
51
  if (event.isPrimary && touchesDown.size) {
54
- for (const pointerId of [...touchesDown]) {
52
+ for (const [pointerId, down] of [...touchesDown]) {
55
53
  if (pointerId === event.pointerId) continue;
56
54
  touchesDown.delete(pointerId);
57
- element.dispatchEvent(new PointerEvent("pointercancel", { pointerId, pointerType: "touch", bubbles: true }));
55
+ cancelStale(down, pointerId);
58
56
  }
59
57
  }
60
- if (event.target === element) touchesDown.add(event.pointerId);
58
+ if (root.contains(event.target)) {
59
+ touchesDown.set(event.pointerId, { target: event.target, isPrimary: event.isPrimary });
60
+ }
61
61
  };
62
62
  const onEnd = (event) => { touchesDown.delete(event.pointerId); };
63
63
 
@@ -71,7 +71,28 @@ export function guardStaleTouches(element) {
71
71
  doc.removeEventListener("pointerdown", onDown, { capture: true });
72
72
  doc.removeEventListener("pointerup", onEnd, { capture: true });
73
73
  doc.removeEventListener("pointercancel", onEnd, { capture: true });
74
- if (typeof release === "function") element.releasePointerCapture = release;
75
74
  touchesDown.clear();
76
75
  };
77
76
  }
77
+
78
+ function cancelStale({ target, isPrimary }, pointerId) {
79
+ const release = target.releasePointerCapture;
80
+ const ownRelease = Object.hasOwn(target, "releasePointerCapture");
81
+ if (typeof release === "function") {
82
+ target.releasePointerCapture = function releaseIfHeld(id) {
83
+ try {
84
+ release.call(this, id);
85
+ } catch {
86
+ // Already released: the finger is gone, which is all the caller wanted.
87
+ }
88
+ };
89
+ }
90
+ try {
91
+ target.dispatchEvent(new PointerEvent("pointercancel", { pointerId, pointerType: "touch", isPrimary, bubbles: true }));
92
+ } finally {
93
+ if (typeof release === "function") {
94
+ if (ownRelease) target.releasePointerCapture = release;
95
+ else delete target.releasePointerCapture;
96
+ }
97
+ }
98
+ }
@@ -205,9 +205,11 @@ export function createViewer(container, part) {
205
205
 
206
206
  const controls = new OrbitControls(activeCamera, renderer.domElement);
207
207
  controls.enableDamping = true;
208
- // A finger a host gesture swallowed would otherwise stay "down" in the
209
- // controls forever (every later drag zooms) — see stale-touch-guard.js.
210
- const disposeTouchGuard = guardStaleTouches(renderer.domElement);
208
+ // A finger a host gesture swallowed would otherwise stay "down" forever in
209
+ // whatever it went down on — the controls (every later drag zooms), the view
210
+ // cube, the cutaway handles, measure mode — so the guard watches the whole
211
+ // container they all live in. See stale-touch-guard.js.
212
+ const disposeTouchGuard = guardStaleTouches(container);
211
213
 
212
214
  // --- render on demand -------------------------------------------------------
213
215
  // The loop ticks every animation frame (controls, tweens, frame listeners —
package/src/oracle.js CHANGED
@@ -19,6 +19,10 @@ export { meshVolume, bboxSize, bounds, meshArea } from "./framework/oracle/mesh.
19
19
  export { buildView } from "./framework/oracle/build.js";
20
20
  export { measure } from "./framework/oracle/measure.js";
21
21
  export { verify } from "./framework/oracle/verify.js";
22
+ // Change tracking — the `inspect` job's opt-in "what changed since my last
23
+ // inspect of this view" signal, re-exported so a downstream harness can run the
24
+ // same tracker outside the job loop (e.g. across a scripted sequence of builds).
25
+ export { createChangeTracker } from "./framework/oracle/changes.js";
22
26
  export { buildBVH, meshTriangles } from "./framework/oracle/bvh.js";
23
27
  export { minWall } from "./framework/oracle/min-wall.js";
24
28
  export { overhang } from "./framework/oracle/overhang.js";
package/types/oracle.d.ts CHANGED
@@ -11,6 +11,9 @@ export {
11
11
  measure, verify, buildView,
12
12
  type MeasureReport, type SubPartFacts, type SheetFacts, type AggregateFacts, type BuiltSubPart,
13
13
  type VerifyReport, type VerifyCaseResult, type VerifyCheck, type CheckStatus,
14
+ // change tracking
15
+ createChangeTracker,
16
+ type ChangeTracker, type ChangesReport, type SubPartChange, type ChangeRegion, type ChangeOpRef,
14
17
  // mesh facts, gaps, BVH, min wall
15
18
  meshVolume, bboxSize, bounds, meshArea, assemblyGaps, meshGaps, buildBVH, meshTriangles, minWall, overhang,
16
19
  type Gap, type BVH,
@@ -73,6 +73,16 @@ export interface WorkerJob {
73
73
  * relative order of the targets sharing that kind, never by index.
74
74
  */
75
75
  matchTargets?: MatchTarget[];
76
+ /**
77
+ * `inspect`: opt into change tracking. Opaque — the worker keeps one baseline
78
+ * per key, per worker lifetime, best-effort (a retired worker, a new key, or a
79
+ * new `view` simply has no baseline yet). A new key forgets whatever baseline
80
+ * the previous one held. Only an inspect with no (or empty) `params` is tracked
81
+ * — a parameterized inspect is a different geometry and never becomes, or is
82
+ * diffed against, the baseline. When present, `report.changes`/
83
+ * `report.changesSkipped` are added; absent, the report is unchanged.
84
+ */
85
+ changesKey?: string;
76
86
  }
77
87
 
78
88
  /**
@@ -485,6 +495,13 @@ export function measure(
485
495
  * build of the same view you are asking about.
486
496
  */
487
497
  built?: BuiltSubPart[];
498
+ /**
499
+ * A `ChangeTracker`'s `memo` — reuses a previous round's facts (and sub-part
500
+ * pair gap distances) for any sub-part whose final hash did not change,
501
+ * instead of re-deriving them. Opaque; hand in `changeTracker.memo` and
502
+ * nothing else.
503
+ */
504
+ memo?: unknown;
488
505
  },
489
506
  ): MeasureReport;
490
507
 
@@ -574,6 +591,92 @@ export function verify(
574
591
  },
575
592
  ): VerifyReport;
576
593
 
594
+ // --- change tracking ---------------------------------------------------------
595
+
596
+ /** One root operation behind a sub-part's changed final hash, named by the op graph. */
597
+ export interface ChangeOpRef {
598
+ op: string;
599
+ label?: string;
600
+ }
601
+
602
+ /** One connected region of material a mesh boolean diff found added or removed. */
603
+ export interface ChangeRegion {
604
+ change: "added" | "removed";
605
+ mm3: number;
606
+ at: [number, number, number];
607
+ size: [number, number, number];
608
+ }
609
+
610
+ export interface SubPartChange {
611
+ name: string;
612
+ verdict: "new" | "deleted" | "moved" | "added" | "removed" | "reshaped";
613
+ /**
614
+ * `verdict: "moved"` only: the sub-part's new bounding-box centre minus its old
615
+ * one, in mm. A rotation about that centre reads as ≈[0, 0, 0] — see `rotated`.
616
+ */
617
+ moved?: [number, number, number];
618
+ /** `verdict: "moved"` only, present (true) when the placement's orientation changed too. */
619
+ rotated?: boolean;
620
+ /** `verdict: "reshaped" | "added" | "removed"`: new volume minus old, in mm³. */
621
+ volumeDeltaMm3?: number;
622
+ /** `verdict: "added" | "removed" | "reshaped"`, when a mesh boolean diff ran. */
623
+ addedMm3?: number;
624
+ removedMm3?: number;
625
+ /** Up to 3 largest added/removed regions from the mesh boolean diff, largest first. */
626
+ regions?: ChangeRegion[];
627
+ /** The named root operations behind the changed hash, when the op graph covers it. */
628
+ changedOps?: ChangeOpRef[];
629
+ }
630
+
631
+ export interface ChangesReport {
632
+ /** True only when every sub-part's final hash matched the previous build's. */
633
+ unchanged?: true;
634
+ subparts: SubPartChange[];
635
+ unchangedSubparts: number;
636
+ }
637
+
638
+ /**
639
+ * Per-worker, best-effort "what changed since the last inspect of this view"
640
+ * tracker behind `inspect`'s `changesKey`. One tracker holds exactly one
641
+ * baseline at a time (per `begin`'s `key`); a new key or a new `view` simply has
642
+ * no baseline yet, which is reported as `{}` from `finish`, not an error.
643
+ *
644
+ * Call order for one round: `begin` → `buildView` → `endBuild(built)` →
645
+ * (optionally `measure` with `{ memo: tracker.memo }`) → `finish`. A failure
646
+ * before `endBuild`/`finish` calls `abort()` instead, which forgets the round
647
+ * without touching the stored baseline.
648
+ */
649
+ export interface ChangeTracker {
650
+ /** Hand to `measure`'s `memo` option to reuse facts for unchanged sub-parts. */
651
+ readonly memo: unknown;
652
+ /** Start a round. `key` identifies the baseline; a different key forgets it. */
653
+ begin(key: string, view: string): void;
654
+ /** Capture the just-built view. Call right after `buildView`, before `kernel.cleanup()`. */
655
+ endBuild(built: BuiltSubPart[]): void;
656
+ /** The round failed before `finish`: discard it without touching the baseline. */
657
+ abort(): void;
658
+ /**
659
+ * Diff this build against the stored baseline and roll it forward as the next
660
+ * baseline. Call after `measure()` — `measure` calls `kernel.cleanup()`
661
+ * internally, which is fine: `finish` reads only hash strings, JS-owned mesh
662
+ * arrays, and `measured.subparts[].volume`, never a live solid.
663
+ */
664
+ finish(
665
+ kernel: GeometryKernel,
666
+ view: string,
667
+ built: BuiltSubPart[],
668
+ measured: MeasureReport,
669
+ ): { changes?: ChangesReport; changesSkipped?: string };
670
+ }
671
+
672
+ export function createChangeTracker(opts?: {
673
+ now?: () => number;
674
+ /** Milliseconds the mesh boolean diff may spend across one `finish()` call (default 2000). */
675
+ budgetMs?: number;
676
+ /** Sub-parts whose combined old+new triangle count exceeds this skip the mesh diff (default 300000). */
677
+ maxTriangles?: number;
678
+ }): ChangeTracker;
679
+
577
680
  // --- silhouette match scoring -----------------------------------------------
578
681
 
579
682
  /**