partforge 0.143.1 → 0.144.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.143.1",
3
+ "version": "0.144.1",
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",
@@ -22,6 +22,12 @@
22
22
  // { kind: "spine", pts, closed, r }
23
23
  // general chain (mesh-fillet generalTool): the
24
24
  // ball-centre polyline itself; r is the radius
25
+ // { kind: "planes", planes: [{ n, d }], tol }
26
+ // roundAll's flat faces (mesh-roundall.js
27
+ // flatFacePlanes): a point within tol of one plane
28
+ // (n·x = d) takes its normal; anywhere else — the
29
+ // rounded band off its seams — there is none, and
30
+ // creased-normals keeps the facet average
25
31
  //
26
32
  // A path's polyline usually STANDS FOR a smooth curve (a circle's rim, an outline's
27
33
  // rounded corner): its per-segment cylinders would shade faceted around the curve,
@@ -105,10 +111,32 @@ export function mapSurface(desc, M) {
105
111
  M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
106
112
  return { kind: "spine", closed: desc.closed, pts: desc.pts.map((p) => applyPt(M, p)), r: desc.r * s };
107
113
  }
114
+ case "planes": {
115
+ const s = Math.cbrt(Math.abs(
116
+ M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
117
+ return { kind: "planes", tol: desc.tol * s, planes: desc.planes.map(({ n, d }) => {
118
+ const n2 = norm(applyDir(M, n)) ?? n;
119
+ const p = applyPt(M, [n[0] * d, n[1] * d, n[2] * d]);
120
+ return { n: n2, d: n2[0] * p[0] + n2[1] * p[1] + n2[2] * p[2] };
121
+ }) };
122
+ }
108
123
  default: throw new Error(`blend surface: unknown kind ${desc.kind}`);
109
124
  }
110
125
  }
111
126
 
127
+ // The plane x lies on, or null — off every plane, or on two at once (where two
128
+ // flat faces would meet, which a rounded result never shows, so no answer is right).
129
+ function planeNormal(desc, x) {
130
+ let hit = null;
131
+ for (const pl of desc.planes) {
132
+ const n = pl.n;
133
+ if (Math.abs(n[0] * x[0] + n[1] * x[1] + n[2] * x[2] - pl.d) > desc.tol) continue;
134
+ if (hit) return null;
135
+ hit = n;
136
+ }
137
+ return hit;
138
+ }
139
+
112
140
  // Nearest spine point to x, in the descriptor's own frame.
113
141
  function spinePoint(desc, x) {
114
142
  switch (desc.kind) {
@@ -240,6 +268,7 @@ function nearestSegment(desc, x) {
240
268
  // Unit spine→point direction at x in the descriptor's frame, or null where undefined.
241
269
  export function surfaceNormal(desc, x) {
242
270
  if (desc.kind === "path") return pathNormal(desc, x);
271
+ if (desc.kind === "planes") return planeNormal(desc, x);
243
272
  if (desc.kind === "spine") {
244
273
  const { bi, bt } = nearestSegment(desc, x);
245
274
  if (bi < 0) return null;
@@ -18,8 +18,8 @@ import { meshToStl } from "./mesh-stl.js";
18
18
  import { creasedNormals } from "./creased-normals.js";
19
19
  import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
20
20
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
21
- import { affineAt, invertAffine, mapSurface } from "./blend-surfaces.js";
22
- import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
21
+ import { affineAt, IDENTITY, invertAffine, mapSurface } from "./blend-surfaces.js";
22
+ import { flatFacePlanes, meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
23
23
  import { SEGS, circleSegs, doubleCurvatureSegs } from "./circle-segs.js";
24
24
  import { checkBooleanResult } from "./boolean-gate.js";
25
25
  import { KernelCapabilityError } from "./errors.js";
@@ -294,12 +294,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
294
294
  // round-trip, so the descriptor's original frame stays valid). Folding the band
295
295
  // into a single blend id, as this used to, lost every descriptor with it: a labeled
296
296
  // fillet shaded with facet normals again.
297
+ //
298
+ // "Blend" here is any surface that must keep an id of its own: a fillet band (the
299
+ // boundaryLines policy) or a surface carrying an analytic descriptor at all —
300
+ // roundAll's flat-face planes ride on a SMOOTH surface, and folding that into the
301
+ // base id would drop them and bring the flat-face shading gradient back on every
302
+ // labeled roundAll. Each keeps the policy it had (none, for the roundAll surface).
303
+ const keepsOwnId = (oid) => !!oidPolicies.get(oid)?.boundaryLines || blendSurfaces.has(oid);
297
304
  const reserveBlendRestamp = (oids) => {
298
- const blends = [...oids].filter((oid) => !!oidPolicies.get(oid)?.boundaryLines);
305
+ const blends = [...oids].filter(keepsOwnId);
299
306
  const baseId = Manifold.reserveIDs(1 + blends.length);
300
307
  const blendIdFor = new Map(blends.map((oid, i) => [oid, baseId + 1 + i]));
301
308
  for (const [oid, id2] of blendIdFor) {
302
- oidPolicies.set(id2, BLEND);
309
+ const pol = oidPolicies.get(oid);
310
+ if (pol !== undefined) oidPolicies.set(id2, pol);
303
311
  const surf = blendSurfaces.get(oid);
304
312
  if (surf) blendSurfaces.set(id2, surf);
305
313
  }
@@ -405,7 +413,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
405
413
  const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
406
414
  if (![...new Set(g0.runOriginalID)].some(isBlend)) return T(filleted._m.asOriginal());
407
415
  const { baseId, blendIdFor } = reserveBlendRestamp(new Set(g0.runOriginalID));
408
- g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendIdFor.get(o) : baseId));
416
+ g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => blendIdFor.get(o) ?? baseId);
409
417
  // The constructor re-welds, and that can pinch a latent sliver off a
410
418
  // component as fresh femto-debris (the same rebirth dropDebris's own
411
419
  // compose() loop guards against) — sweep the reconstruction too.
@@ -423,6 +431,39 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
423
431
  }
424
432
  };
425
433
 
434
+ // The reference roundAll's flat faces (mesh-roundall.js flatFacePlanes): the
435
+ // input's flat faces, which the chain returns exactly to their planes, become a
436
+ // "planes" descriptor on the result's surface so creased-normals shades them —
437
+ // and the band's seams beside them — with the plane's own normal. The tolerance
438
+ // sits well under the first ring of band vertices off each face (the same ring
439
+ // meshRoundAll's simplify tolerance protects), so only the face and its seam
440
+ // vertices are on a plane. Keyed on geometry like every descriptor (see
441
+ // blendSurfaces above), so it is never removed with the cache entry.
442
+ const markFlatFaces = (input, out, r) => {
443
+ const gi = input.getMesh();
444
+ let planes;
445
+ try {
446
+ planes = flatFacePlanes(gi, { policyFor: (oid) => oidPolicies.get(oid) });
447
+ } finally {
448
+ gi.delete?.();
449
+ }
450
+ if (!planes.length) return out;
451
+ const segs = roundAllSegs(2 * r, quality);
452
+ const tol = Math.min(1e-3, 0.25 * r * (1 - Math.cos((2 * Math.PI) / segs)));
453
+ const go = out.getMesh();
454
+ try {
455
+ const roid = go.runOriginalID, rt = go.runTransform;
456
+ for (let i = 0; i < roid.length; i++) {
457
+ // a fresh original carries no runTransform at all — identity, as creased-normals reads it
458
+ const inv = rt && rt.length >= (i + 1) * 12 ? invertAffine(affineAt(rt, i)) : IDENTITY;
459
+ if (inv) blendSurfaces.set(roid[i], mapSurface({ kind: "planes", planes, tol }, inv));
460
+ }
461
+ } finally {
462
+ go.delete?.();
463
+ }
464
+ return out;
465
+ };
466
+
426
467
  // Replay a recorded transform chain onto a solid. Each record maps back to the op
427
468
  // that produced it, so the replayed solid rebuilds the same chain on its own canon.
428
469
  const replay = (solid, chain) => chain.reduce(
@@ -503,7 +544,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
503
544
  // key omits it for the same reason); it just spells out that the ball
504
545
  // tessellation, and so the result, is tier-dependent.
505
546
  return cached(h("roundAll", hash, r, quality), () =>
506
- prismRoundAllFast(m, hash, r) ?? T(meshRoundAll(wasm, m, r, quality)));
547
+ prismRoundAllFast(m, hash, r) ?? markFlatFaces(m, T(meshRoundAll(wasm, m, r, quality)), r));
507
548
  },
508
549
  // batch difference: first minus the union of the rest, evaluated as one boolean
509
550
  // tree — no materialized intermediate union (the unionRaw memory note applies)
@@ -538,7 +579,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
538
579
  // fresh so cached-solid reuse under another label cannot collide — the same
539
580
  // guarantee asOriginal() gives the plain path below.
540
581
  const g0 = m.getMesh();
541
- const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
542
582
  const oids0 = new Set(g0.runOriginalID);
543
583
  // Sector-aware re-stamp: a provenance-sectored loft (the `sector` policy
544
584
  // marker) must keep every run's identity — folding them (either the plain
@@ -561,14 +601,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
561
601
  featureLabels.set(newId, name);
562
602
  const pol = oidPolicies.get(oldId);
563
603
  if (pol !== undefined) oidPolicies.set(newId, pol);
604
+ const surf = blendSurfaces.get(oldId); // analytic normals ride the 1:1 stamp too
605
+ if (surf) blendSurfaces.set(newId, surf);
564
606
  setIds.push(newId);
565
607
  }
566
608
  return { value: wrap(o, lh), pin: o, dispose: () => {
567
- for (const id2 of setIds) { featureLabels.delete(id2); oidPolicies.delete(id2); }
609
+ for (const id2 of setIds) { featureLabels.delete(id2); oidPolicies.delete(id2); blendSurfaces.delete(id2); }
568
610
  o.delete?.();
569
611
  } };
570
612
  }
571
- if ([...oids0].some(isBlend)) {
613
+ if ([...oids0].some(keepsOwnId)) {
572
614
  const { baseId, blendIdFor, blendIds } = reserveBlendRestamp(oids0);
573
615
  // base-group policy: the same triangle-weighted majority vote as the plain
574
616
  // path, but over the NON-blend runs only — blend runs would elect BLEND for
@@ -578,7 +620,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
578
620
  const weightByKey = new Map();
579
621
  let bestWeight = -1, basePol;
580
622
  for (let r = 0; r < roid.length; r++) {
581
- if (isBlend(roid[r])) continue;
623
+ if (blendIdFor.has(roid[r])) continue;
582
624
  const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
583
625
  const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
584
626
  const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
@@ -586,7 +628,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
586
628
  const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
587
629
  if (better) { bestWeight = weight; basePol = pol; }
588
630
  }
589
- g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendIdFor.get(o2) : baseId));
631
+ g0.runOriginalID = Uint32Array.from(roid, (o2) => blendIdFor.get(o2) ?? baseId);
590
632
  const o = T(new Manifold(g0));
591
633
  g0.delete?.();
592
634
  featureLabels.set(baseId, name);
@@ -764,6 +806,44 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
764
806
  build,
765
807
  );
766
808
  },
809
+ // The change tracker's boolean diff (oracle/changes.js): two plain meshes in,
810
+ // added (new − old) and removed (old − new) material out, split into connected
811
+ // pieces. Never cached — it runs outside any beginSubPart bracket — and every
812
+ // WASM object is T()-tracked, so the caller's cleanup() frees all of it.
813
+ _meshDiff: (oldMesh, newMesh, { maxTriangles = 300000, minVolume = 0 } = {}) => {
814
+ const tris = (mesh) => (mesh.indices?.length ?? 0) / 3;
815
+ if (tris(oldMesh) + tris(newMesh) > maxTriangles) return { ok: false, reason: "too-large" };
816
+ let a, b;
817
+ try {
818
+ a = T(manifoldFromMesh(wasm, oldMesh.positions, oldMesh.indices));
819
+ b = T(manifoldFromMesh(wasm, newMesh.positions, newMesh.indices));
820
+ } catch {
821
+ return { ok: false, reason: "not-watertight" };
822
+ }
823
+ const regions = (mm) => {
824
+ const pieces = mm.decompose().map((p) => T(p));
825
+ return pieces
826
+ .map((p) => {
827
+ const { min, max } = p.boundingBox();
828
+ return {
829
+ mm3: p.volume(),
830
+ at: [0, 1, 2].map((i) => (min[i] + max[i]) / 2),
831
+ size: [0, 1, 2].map((i) => max[i] - min[i]),
832
+ };
833
+ })
834
+ .filter((r) => r.mm3 >= minVolume)
835
+ .sort((x, y) => y.mm3 - x.mm3);
836
+ };
837
+ const added = T(b.subtract(a));
838
+ const removed = T(a.subtract(b));
839
+ return {
840
+ ok: true,
841
+ addedMm3: added.volume(),
842
+ removedMm3: removed.volume(),
843
+ added: regions(added),
844
+ removed: regions(removed),
845
+ };
846
+ },
767
847
  // Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
768
848
  // holes regardless of their winding (outer + holes, no per-hole boolean cut).
769
849
  // A Shape2D `profile` (curve-native, possibly multi-region) materializes through
@@ -29,6 +29,7 @@
29
29
  // the two facetings still meets the tier's sagitta tolerance.
30
30
 
31
31
  import { segsForSagitta } from "./circle-segs.js";
32
+ import { SMOOTH, cosDeg } from "./shading-policy.js";
32
33
 
33
34
  // Sphere tessellation from the facet sagitta r·(1 − cos(π/segs)): pick the
34
35
  // fewest segments that keep it under the quality tier's tolerance — its own
@@ -83,6 +84,121 @@ export function meshRoundAll(wasm, m, r, quality) {
83
84
  }
84
85
  }
85
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Flat-face shading. The reference path's result is one fresh surface, shaded
89
+ // SMOOTH (35° crease), and the rounding ball's facets bend far less than that, so
90
+ // a flat face used to share its corner normals with the band facets around it.
91
+ // The vertex sum is unweighted, so a large face split into two triangles — a
92
+ // sloped palm plate's top — took a several-degree tilt at each corner and showed
93
+ // it as a shading gradient across the whole face (cloud feedback #167).
94
+ //
95
+ // The cure rests on the invariant above: the chain returns every planar face of
96
+ // the input to its own plane. So the input's flat faces, found here, say exactly
97
+ // where the result is flat; the backend registers them as an analytic "planes"
98
+ // descriptor (blend-surfaces.js), and creased-normals then shades every vertex on
99
+ // one of those planes — the band's seam vertices too, since the band is tangent
100
+ // there — with the plane's normal, the same way a fillet band's seam agrees with
101
+ // its face.
102
+ //
103
+ // "Flat" means flat the way the INPUT shades it: a coplanar group counts only if
104
+ // every edge bounding it is one creased-normals would shade hard (a bend past the
105
+ // surface's crease angle, or a cut seam). A rod's wall facets, or a slot's sides
106
+ // running tangentially into its round ends, meet their neighbours softly — the
107
+ // input already shades them as part of a curved surface, and pinning them to
108
+ // their own planes would facet the rounded result.
109
+ //
110
+ // `g` is a MeshGL (or the same fields on plain arrays); `policyFor(oid)` returns
111
+ // that surface's shading policy. Returns [{ n, d }] with n·x = d on the plane.
112
+ const PLANE_COS = cosDeg(0.05); // coplanar neighbours: a boolean split, not a bend
113
+ export function flatFacePlanes(g, { policyFor = () => SMOOTH } = {}) {
114
+ const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
115
+ const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
116
+ const remap = new Uint32Array(nVert);
117
+ for (let i = 0; i < nVert; i++) remap[i] = i;
118
+ const mf = g.mergeFromVert, mt = g.mergeToVert;
119
+ if (mf && mt) for (let i = 0; i < mf.length; i++) remap[mf[i]] = mt[i];
120
+ const triOID = new Uint32Array(nTri);
121
+ const ri = g.runIndex, roid = g.runOriginalID;
122
+ if (ri && roid) for (let r = 0; r < roid.length; r++)
123
+ for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
124
+
125
+ // facet normals, areas and centroids; a degenerate triangle has no normal and
126
+ // takes no part — it neither joins a face nor disqualifies one
127
+ const fn = new Float64Array(nTri * 3), area = new Float64Array(nTri), cen = new Float64Array(nTri * 3);
128
+ let span = 0;
129
+ for (let t = 0; t < nTri; t++) {
130
+ const a = tris[t * 3] * np, b = tris[t * 3 + 1] * np, c = tris[t * 3 + 2] * np;
131
+ const ux = vp[b] - vp[a], uy = vp[b + 1] - vp[a + 1], uz = vp[b + 2] - vp[a + 2];
132
+ const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
133
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
134
+ const L = Math.hypot(nx, ny, nz);
135
+ area[t] = L / 2;
136
+ if (L > 0) { fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L; }
137
+ for (let k = 0; k < 3; k++) cen[t * 3 + k] = (vp[a + k] + vp[b + k] + vp[c + k]) / 3;
138
+ span = Math.max(span, Math.abs(vp[a]), Math.abs(vp[a + 1]), Math.abs(vp[a + 2]));
139
+ }
140
+ const live = (t) => area[t] > 1e-9;
141
+ const dot = (t, u) => fn[t * 3] * fn[u * 3] + fn[t * 3 + 1] * fn[u * 3 + 1] + fn[t * 3 + 2] * fn[u * 3 + 2];
142
+
143
+ // edge → incident triangles, on welded vertex ids
144
+ const edges = new Map();
145
+ for (let t = 0; t < nTri; t++) for (let k = 0; k < 3; k++) {
146
+ const p = remap[tris[t * 3 + k]], q = remap[tris[t * 3 + ((k + 1) % 3)]];
147
+ const key = p < q ? p * nVert + q : q * nVert + p;
148
+ const list = edges.get(key);
149
+ if (list) list.push(t); else edges.set(key, [t]);
150
+ }
151
+
152
+ // coplanar groups (union-find over shared edges)
153
+ const parent = new Int32Array(nTri);
154
+ for (let t = 0; t < nTri; t++) parent[t] = t;
155
+ const find = (t) => { while (parent[t] !== t) { parent[t] = parent[parent[t]]; t = parent[t]; } return t; };
156
+ for (const list of edges.values()) {
157
+ if (list.length !== 2) continue;
158
+ const [t, u] = list;
159
+ if (live(t) && live(u) && dot(t, u) > PLANE_COS) parent[find(t)] = find(u);
160
+ }
161
+
162
+ // a group is a flat face only if every edge leaving it would shade hard
163
+ const soft = new Uint8Array(nTri); // indexed by group root
164
+ const hard = (t, u) => {
165
+ const pt = policyFor(triOID[t]) ?? SMOOTH, pu = policyFor(triOID[u]) ?? SMOOTH;
166
+ if (triOID[t] !== triOID[u] && !(pt.boundaryLines || pu.boundaryLines)) return true;
167
+ return dot(t, u) < cosDeg(pt.creaseAngle);
168
+ };
169
+ for (const list of edges.values()) {
170
+ if (list.length !== 2) continue; // non-manifold: no smooth neighbour to share with
171
+ const [t, u] = list;
172
+ if (!live(t) || !live(u) || find(t) === find(u)) continue;
173
+ if (!hard(t, u)) soft[find(t)] = 1;
174
+ if (!hard(u, t)) soft[find(u)] = 1;
175
+ }
176
+
177
+ // one area-weighted plane per surviving group, merged where two groups share a
178
+ // plane (a degenerate triangle can split one face into two)
179
+ const acc = new Map();
180
+ for (let t = 0; t < nTri; t++) {
181
+ if (!live(t)) continue;
182
+ const root = find(t);
183
+ if (soft[root]) continue;
184
+ let e = acc.get(root);
185
+ if (!e) acc.set(root, (e = { n: [0, 0, 0], c: [0, 0, 0], a: 0 }));
186
+ for (let k = 0; k < 3; k++) { e.n[k] += fn[t * 3 + k] * area[t]; e.c[k] += cen[t * 3 + k] * area[t]; }
187
+ e.a += area[t];
188
+ }
189
+ const dTol = Math.max(1e-6, span * 1e-7);
190
+ const planes = [];
191
+ for (const e of acc.values()) {
192
+ const L = Math.hypot(...e.n);
193
+ if (!(L > 0)) continue;
194
+ const n = e.n.map((x) => x / L);
195
+ const d = (n[0] * e.c[0] + n[1] * e.c[1] + n[2] * e.c[2]) / e.a;
196
+ const dup = planes.find((p) => p.n[0] * n[0] + p.n[1] * n[1] + p.n[2] * n[2] > PLANE_COS && Math.abs(p.d - d) < dTol);
197
+ if (!dup) planes.push({ n, d });
198
+ }
199
+ return planes;
200
+ }
201
+
86
202
  // ---------------------------------------------------------------------------
87
203
  // Prism detection for the fast path (manifold-backend.js). The Minkowski chain
88
204
  // above is seconds-per-thousand-triangles, but roundAll's expensive real-world
@@ -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) => {
@@ -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); }
@@ -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
  /**