partforge 0.143.1 → 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 +1 -1
- package/src/framework/geometry/manifold-backend.js +38 -0
- package/src/framework/geometry/occt-backend.js +1 -0
- package/src/framework/geometry/solid-hash.js +41 -1
- package/src/framework/jobs.js +29 -4
- package/src/framework/oracle/changes.js +208 -0
- package/src/framework/oracle/gaps.js +21 -8
- package/src/framework/oracle/measure.js +55 -10
- package/src/framework/oracle/op-graph.js +49 -0
- package/src/framework/stale-touch-guard.js +58 -37
- package/src/framework/viewer.js +5 -3
- package/src/oracle.js +4 -0
- package/types/oracle.d.ts +3 -0
- package/types/testing.d.ts +103 -0
package/package.json
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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); }
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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 <
|
|
31
|
-
for (let j = i + 1; j <
|
|
32
|
-
const
|
|
33
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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)
|
|
310
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
3
|
-
//
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
//
|
|
29
|
-
// throws for an id the browser has already forgotten, aborting
|
|
30
|
-
// half way (
|
|
31
|
-
//
|
|
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(
|
|
35
|
-
const doc =
|
|
43
|
+
export function guardStaleTouches(root) {
|
|
44
|
+
const doc = root.ownerDocument;
|
|
36
45
|
if (!doc) return () => {};
|
|
37
46
|
const noop = () => {};
|
|
38
|
-
const touchesDown = new
|
|
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
|
-
|
|
55
|
+
cancelStale(down, pointerId);
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
|
-
if (event.target
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
|
209
|
-
// controls
|
|
210
|
-
|
|
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,
|
package/types/testing.d.ts
CHANGED
|
@@ -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
|
/**
|