partforge 0.31.0 → 0.33.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/README.md +14 -0
- package/docs/AUTHORING-PARTS.md +60 -1
- package/docs/ERROR-PATTERNS.md +28 -0
- package/docs/KERNEL-CONTRACT.md +437 -0
- package/package.json +2 -1
- package/src/framework/controls.js +25 -6
- package/src/framework/debug-overlay.js +10 -2
- package/src/framework/geometry/kernel-front.js +11 -0
- package/src/framework/geometry/kernel.js +8 -5
- package/src/framework/geometry/manifold-backend.js +1 -0
- package/src/framework/geometry/occt-backend.js +1 -0
- package/src/framework/geometry/occt-repair.js +9 -1
- package/src/framework/geometry/op-options.js +4 -1
- package/src/framework/geometry/pose.js +35 -3
- package/src/framework/geometry/rim-bevel.js +132 -0
- package/src/framework/geometry/solid-cache.js +17 -1
- package/src/framework/jobs.js +15 -2
- package/src/framework/mount.js +53 -7
- package/src/framework/pose-fast-path.js +49 -0
- package/src/framework/pose-probe.js +139 -0
- package/src/framework/selection/hover.js +6 -3
- package/src/framework/selection/raycast.js +17 -4
- package/src/framework/viewer.js +24 -1
- package/src/framework/worker.js +101 -19
package/src/framework/mount.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveDerived } from "./derive.js";
|
|
|
15
15
|
import { detectBackend } from "./geometry/probe.js";
|
|
16
16
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
17
17
|
import { createRegenLoop } from "./regen-loop.js";
|
|
18
|
+
import { createPoseFastPath } from "./pose-fast-path.js";
|
|
18
19
|
import { createStatusUi } from "./status-ui.js";
|
|
19
20
|
import { createViewTabs } from "./view-tabs.js";
|
|
20
21
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
@@ -22,8 +23,8 @@ import { createPickRequestClient } from "./pick-request/index.js";
|
|
|
22
23
|
|
|
23
24
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
24
25
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
25
|
-
export function makeHandle({ ready, dispose, viewer }) {
|
|
26
|
-
return { ready, dispose, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
|
|
26
|
+
export function makeHandle({ ready, dispose, viewer, setParams }) {
|
|
27
|
+
return { ready, dispose, setParams, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function createCleanupStack() {
|
|
@@ -58,7 +59,10 @@ function createCleanupStack() {
|
|
|
58
59
|
// Embedding contract (0.12.0):
|
|
59
60
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick });
|
|
60
61
|
// await runtime.ready; // first successful build of the default view
|
|
62
|
+
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
61
63
|
// runtime.dispose(); // full teardown
|
|
64
|
+
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
65
|
+
// those are repaired in the viewer and produce no build at all.
|
|
62
66
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
63
67
|
// exactly once here — submodules take element refs and never query the document.
|
|
64
68
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
@@ -126,7 +130,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
126
130
|
const qs = new URLSearchParams(location.search);
|
|
127
131
|
const debug = qs.has("debug");
|
|
128
132
|
let cachingOn = !(debug && qs.has("nocache"));
|
|
129
|
-
let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
|
|
133
|
+
let lastGen = { skipped: 0, rebuilt: 0, posed: 0 }; // Layer-0/1 counts for the most recent generate
|
|
134
|
+
// Sub-parts the pose fast path has repaired since the last build was dispatched.
|
|
135
|
+
// A SET of names, not a running total: a slider drag re-repairs the same
|
|
136
|
+
// sub-part on every input event, and "247 posed" for a one-sub-part app would
|
|
137
|
+
// be nonsense. A build dispatched in the SAME dirty cycle takes the count into
|
|
138
|
+
// its report — a mixed edit re-poses one sub-part and rebuilds another, and the
|
|
139
|
+
// overlay should say so. Anything that kicks a build for an unrelated reason
|
|
140
|
+
// (view switch / forceRegen) clears it first, so it can't be miscredited.
|
|
141
|
+
const pendingPosed = new Set();
|
|
130
142
|
const dbg = debug
|
|
131
143
|
? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
|
|
132
144
|
: null;
|
|
@@ -135,7 +147,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
135
147
|
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
136
148
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
137
149
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
138
|
-
onChange: () => { cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
|
|
150
|
+
onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
|
|
139
151
|
});
|
|
140
152
|
cleanup.defer(() => tabsCtl.detach());
|
|
141
153
|
const view = () => tabsCtl.current();
|
|
@@ -199,13 +211,21 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
199
211
|
missingParts,
|
|
200
212
|
send: (missing) => {
|
|
201
213
|
const needed = viewSubParts(part, view(), params);
|
|
202
|
-
|
|
214
|
+
// for the overlay; this build reports the poses repaired in its own cycle.
|
|
215
|
+
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
|
|
216
|
+
pendingPosed.clear(); // consumed — never counted against a second build
|
|
203
217
|
ui.showBusy("generating");
|
|
204
218
|
service.send({ type: "generate", subparts: missing, view: view(), params, cache: cachingOn }, backendFor());
|
|
205
219
|
},
|
|
206
220
|
});
|
|
207
221
|
cleanup.defer(() => loop.dispose());
|
|
208
222
|
|
|
223
|
+
// Pose fast path (Layer 0): a param edit that only re-poses a sub-part is
|
|
224
|
+
// repaired synchronously in the viewer — no debounce, no worker job.
|
|
225
|
+
const fastPath = createPoseFastPath(part, viewer, cache, {
|
|
226
|
+
params, getView: view, getParamsVersion: () => loop.version(),
|
|
227
|
+
});
|
|
228
|
+
|
|
209
229
|
// First-build readiness: resolves on the first accepted meshes result, rejects on
|
|
210
230
|
// a first-build error. Guarded against unhandled rejection when never awaited.
|
|
211
231
|
let readySettled = false;
|
|
@@ -272,13 +292,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
272
292
|
for (const m of data.meshes) {
|
|
273
293
|
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
274
294
|
cache.record(m.name);
|
|
295
|
+
// Stamp the fast path's pose baseline. Only here, inside the
|
|
296
|
+
// non-stale branch: the stamp must describe the geometry actually
|
|
297
|
+
// delivered, which buildDone() true guarantees is at the live params.
|
|
298
|
+
fastPath.recordDelivered(m.name);
|
|
275
299
|
}
|
|
276
300
|
ui.hideBusy();
|
|
277
301
|
refreshView();
|
|
278
302
|
if (data.ms && missingParts().length === 0) {
|
|
279
303
|
ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
|
|
280
304
|
}
|
|
281
|
-
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
|
|
305
|
+
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
|
|
282
306
|
onBuild?.({ status: "success", ms: data.ms });
|
|
283
307
|
if (!readySettled) { readySettled = true; resolveReady(); }
|
|
284
308
|
}
|
|
@@ -319,15 +343,37 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
319
343
|
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
|
|
320
344
|
updateRelevance(); // initial view
|
|
321
345
|
|
|
346
|
+
// The ONLY caller of fastPath.repair(). It must never move into the regen /
|
|
347
|
+
// forceRegen path: forceRegen() forgets cache stamps WITHOUT bumping the params
|
|
348
|
+
// version, so a repair there would re-stamp everything current off the memoized
|
|
349
|
+
// probe and the forced rebuild would silently no-op.
|
|
322
350
|
function onParamChange() {
|
|
323
351
|
loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
|
|
352
|
+
// Pose-only edits: re-posed + re-stamped current, no job. Skipped entirely
|
|
353
|
+
// when caching is off — ?debug&nocache is there to measure true uncached
|
|
354
|
+
// rebuilds, which the fast path would otherwise hide.
|
|
355
|
+
const posed = cachingOn ? fastPath.repair() : [];
|
|
356
|
+
if (posed.length) {
|
|
357
|
+
for (const name of posed) pendingPosed.add(name);
|
|
358
|
+
dbg?.update({ posed: pendingPosed.size }); // partial: merges over the last build's numbers
|
|
359
|
+
}
|
|
324
360
|
refreshView(); // keep showing the now-stale mesh (no flicker); disable export
|
|
325
361
|
updateRelevance();
|
|
326
362
|
}
|
|
327
363
|
|
|
364
|
+
// Programmatic param entry point — the animation-system hook. Same change
|
|
365
|
+
// path as a slider edit: pose-only changes repair synchronously (no worker
|
|
366
|
+
// job, no debounce); geometry changes fall through to the regen loop.
|
|
367
|
+
function setParams(partial) {
|
|
368
|
+
Object.assign(params, partial);
|
|
369
|
+
panel.syncValues(Object.keys(partial));
|
|
370
|
+
onParamChange();
|
|
371
|
+
}
|
|
372
|
+
|
|
328
373
|
// Re-run the active view under the current caching setting, so toggling the
|
|
329
374
|
// ?debug switch updates the readout for the same design without a param change.
|
|
330
375
|
function forceRegen() {
|
|
376
|
+
pendingPosed.clear(); // this rebuild is not the pose edit's — don't credit it
|
|
331
377
|
for (const n of viewSubParts(part, view(), params)) cache.forget(n);
|
|
332
378
|
refreshView();
|
|
333
379
|
loop.kick();
|
|
@@ -371,7 +417,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
371
417
|
cleanup.dispose();
|
|
372
418
|
}
|
|
373
419
|
|
|
374
|
-
return makeHandle({ ready, dispose, viewer });
|
|
420
|
+
return makeHandle({ ready, dispose, viewer, setParams });
|
|
375
421
|
} catch (error) {
|
|
376
422
|
try {
|
|
377
423
|
cleanup.dispose();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Decision layer of the pose fast path (no DOM, no three.js — viewer/cache are
|
|
2
|
+
// injected). At mesh delivery each subpart is STAMPED with its probe result at
|
|
3
|
+
// the delivered params; on a later edit, a stale subpart whose baseHash is
|
|
4
|
+
// unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
|
|
5
|
+
// delivered pose) and is re-stamped current — no worker job. Everything else
|
|
6
|
+
// falls through to the normal regen loop.
|
|
7
|
+
import { viewSubParts } from "./jobs.js";
|
|
8
|
+
import { probePoses } from "./pose-probe.js";
|
|
9
|
+
import { poseDelta } from "./geometry/pose.js";
|
|
10
|
+
|
|
11
|
+
export function createPoseFastPath(part, viewer, cache, { params, getView, getParamsVersion }) {
|
|
12
|
+
const stamps = {}; // name -> probe entry captured when that subpart's mesh was delivered
|
|
13
|
+
|
|
14
|
+
// Memoize the probe per (paramsVersion, view) — same discipline as
|
|
15
|
+
// createMeshCache's readsFor; params is the live in-place-mutated object.
|
|
16
|
+
let probeKey = null, probeMap = null;
|
|
17
|
+
const probeFor = () => {
|
|
18
|
+
const key = `${getParamsVersion()}|${getView()}`;
|
|
19
|
+
if (probeKey !== key) { probeKey = key; probeMap = probePoses(part, getView(), params); }
|
|
20
|
+
return probeMap;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
// Stamp a freshly delivered mesh with its probe result at the current params.
|
|
25
|
+
// (The caller only records on non-stale builds — buildDone() guarantees the
|
|
26
|
+
// live params are the ones the worker built with.)
|
|
27
|
+
recordDelivered(name) {
|
|
28
|
+
stamps[name] = probeFor().get(name);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// Re-pose every visible stale subpart whose base geometry is unchanged.
|
|
32
|
+
// Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
|
|
33
|
+
// count: a slider drag repairs the same subpart on every input event, so only
|
|
34
|
+
// the caller's set union across a drag is a meaningful "how many were posed".
|
|
35
|
+
repair() {
|
|
36
|
+
const posed = [];
|
|
37
|
+
const poses = probeFor();
|
|
38
|
+
for (const name of viewSubParts(part, getView(), params)) {
|
|
39
|
+
if (cache.isCurrent(name) || !viewer.hasSubMesh(name)) continue;
|
|
40
|
+
const now = poses.get(name), was = stamps[name];
|
|
41
|
+
if (!now?.trusted || !was?.trusted || now.baseHash !== was.baseHash) continue;
|
|
42
|
+
viewer.setSubPose(name, poseDelta(now.pose, was.pose));
|
|
43
|
+
cache.record(name); // current again at these params — regen loop sees nothing missing
|
|
44
|
+
posed.push(name);
|
|
45
|
+
}
|
|
46
|
+
return posed;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Geometry-free pose probe: run a subpart's build()+place() against a stub kernel
|
|
2
|
+
// whose token solids carry (a) a content-hash chain built with the shared h() and
|
|
3
|
+
// (b) pending rigid pose steps, mirroring the backends' pose-lazy bookkeeping.
|
|
4
|
+
// The fast path compares probe results ACROSS PARAM CHANGES ONLY — probe hashes
|
|
5
|
+
// are never compared to backend hashes, so they only need to be stable and to
|
|
6
|
+
// fold every geometry-affecting argument.
|
|
7
|
+
//
|
|
8
|
+
// Trust model: any query op (boundingBox/volume/…) during a build marks that
|
|
9
|
+
// subpart untrusted — a query result could feed geometry OR pose, and the probe
|
|
10
|
+
// returns dummies, so neither hash stability nor pose values can be believed.
|
|
11
|
+
// A FUNCTION passed as (or nested inside) an op argument is untrusted for the
|
|
12
|
+
// same reason the OCCT backend refuses to hash function selectors (see `selKey`
|
|
13
|
+
// in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
|
|
14
|
+
// same source text at every value of `p.z`, so hashing it would hold baseHash
|
|
15
|
+
// stable while the real geometry changed — precisely the false-positive the fast
|
|
16
|
+
// path must never make. Untrusted subparts simply take the normal regen path.
|
|
17
|
+
import { h } from "./geometry/solid-hash.js";
|
|
18
|
+
import { addSugar } from "./geometry/solid-sugar.js";
|
|
19
|
+
import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
20
|
+
import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
|
|
21
|
+
import { viewSubParts, resolveParams } from "./jobs.js";
|
|
22
|
+
|
|
23
|
+
const NAN3 = () => [NaN, NaN, NaN];
|
|
24
|
+
|
|
25
|
+
function makeProbeSession() {
|
|
26
|
+
const state = { count: 0, queried: false, unhashable: false };
|
|
27
|
+
const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
|
|
28
|
+
|
|
29
|
+
// Queries return dummies AND poison trust (see module comment).
|
|
30
|
+
const QUERY_DUMMIES = {
|
|
31
|
+
boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
|
|
32
|
+
volume: () => NaN,
|
|
33
|
+
genus: () => NaN,
|
|
34
|
+
isEmpty: () => false,
|
|
35
|
+
area: () => NaN,
|
|
36
|
+
toRegions: () => [],
|
|
37
|
+
simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
|
|
38
|
+
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
|
|
39
|
+
toSTL: () => new ArrayBuffer(0),
|
|
40
|
+
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Operand tokens fold into a hash key by their own (pose-folded) hash; plain
|
|
44
|
+
// data canonicalizes via h(). Functions can't be hashed at all (see the module
|
|
45
|
+
// comment), so they poison trust AND get a per-call unique key — belt and
|
|
46
|
+
// braces, so the hash can't collide even before the trust check is consulted.
|
|
47
|
+
//
|
|
48
|
+
// The walk mirrors h()'s `canon` exactly (array → elements, other object → own
|
|
49
|
+
// enumerable values), because a function nested inside an options object —
|
|
50
|
+
// `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
|
|
51
|
+
// by canon, not by the top-level argument check.
|
|
52
|
+
let unhashable = 0;
|
|
53
|
+
const argKey = (a) => {
|
|
54
|
+
if (a && a.__poseToken) return a.__folded();
|
|
55
|
+
if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
|
|
56
|
+
if (Array.isArray(a)) return a.map(argKey);
|
|
57
|
+
if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
|
|
58
|
+
return a;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function token(hash, pose) {
|
|
62
|
+
const folded = () => (pose.length ? h("posed", hash, pose) : hash);
|
|
63
|
+
const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
|
|
64
|
+
const t = {
|
|
65
|
+
__poseToken: true,
|
|
66
|
+
__folded: folded,
|
|
67
|
+
_hash: hash,
|
|
68
|
+
_pose: pose,
|
|
69
|
+
// The rigid vocabulary stays out of the hash: recorded as pending steps,
|
|
70
|
+
// exactly like the OCCT backend's pose-lazy wrap. All transform sugar
|
|
71
|
+
// (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
|
|
72
|
+
translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
|
|
73
|
+
rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
|
|
74
|
+
clone: () => t, // tokens are immutable — sharing is safe
|
|
75
|
+
// regions() is a data-returning query in disguise: on a real backend the
|
|
76
|
+
// scission ARRAY LENGTH is param-dependent data, so a build branching on
|
|
77
|
+
// `regions().length` could hold baseHash stable while geometry changed.
|
|
78
|
+
// It therefore poisons trust like any other query; the single token is
|
|
79
|
+
// still returned so op chains on regions()[0] don't crash mid-probe.
|
|
80
|
+
regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
|
|
81
|
+
};
|
|
82
|
+
for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
|
|
83
|
+
t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
|
|
84
|
+
// Every other contract op folds pose + args into a fresh hash. Generated from
|
|
85
|
+
// the kernel-contract lists so new ops can never silently drift out of the probe.
|
|
86
|
+
for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
|
|
87
|
+
t[op] ??= foldOp(op);
|
|
88
|
+
return addSugar(t);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Kernel: catch-all factory — any op makes a fresh token hashed from its args.
|
|
92
|
+
const kernelQueries = {
|
|
93
|
+
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
94
|
+
cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
|
|
95
|
+
cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
|
|
96
|
+
};
|
|
97
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
|
|
98
|
+
const kernel = new Proxy({}, {
|
|
99
|
+
get(_t, key) {
|
|
100
|
+
if (ignore(key)) return undefined;
|
|
101
|
+
if (key in kernelQueries) return kernelQueries[key];
|
|
102
|
+
return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return { kernel, state };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
|
|
110
|
+
const stepsFinite = (steps) => steps.every((st) =>
|
|
111
|
+
st.t === "translate"
|
|
112
|
+
? finiteVec(st.v)
|
|
113
|
+
: Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
|
|
114
|
+
|
|
115
|
+
// Probe every subpart the view shows. Never throws; a failing/queried/weird
|
|
116
|
+
// subpart yields { trusted: false } and the others still probe.
|
|
117
|
+
export function probePoses(part, view, params) {
|
|
118
|
+
const out = new Map();
|
|
119
|
+
let resolved;
|
|
120
|
+
try { resolved = resolveParams(part, params); }
|
|
121
|
+
catch {
|
|
122
|
+
for (const name of viewSubParts(part, view, params)) out.set(name, { trusted: false });
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
const { p, d } = resolved;
|
|
126
|
+
for (const name of viewSubParts(part, view, params)) {
|
|
127
|
+
try {
|
|
128
|
+
const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
|
|
129
|
+
const sp = part.parts[name];
|
|
130
|
+
let s = sp.build(kernel, p, d);
|
|
131
|
+
if (sp.place) s = sp.place(s, { view, purpose: "display", p, d });
|
|
132
|
+
const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
|
|
133
|
+
out.set(name, ok ? { baseHash: s._hash, pose: s._pose, trusted: true } : { trusted: false });
|
|
134
|
+
} catch {
|
|
135
|
+
out.set(name, { trusted: false });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
@@ -75,9 +75,12 @@ export function attachHoverLabels(
|
|
|
75
75
|
emptyOverlayGeometry?.dispose();
|
|
76
76
|
emptyOverlayGeometry = null;
|
|
77
77
|
overlay.geometry = geometry;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// Parent to the sub-part mesh, not to the group: the overlay geometry is a
|
|
79
|
+
// subset of the mesh's own (delivered-frame) vertices, so it must inherit
|
|
80
|
+
// whatever fast-path pose viewer.setSubPose has written onto that mesh.
|
|
81
|
+
if (overlayParent !== hit.mesh) {
|
|
82
|
+
hit.mesh.add(overlay);
|
|
83
|
+
overlayParent = hit.mesh;
|
|
81
84
|
}
|
|
82
85
|
overlay.visible = true;
|
|
83
86
|
}
|
|
@@ -7,13 +7,25 @@ const raycaster = new THREE.Raycaster();
|
|
|
7
7
|
const ndc = new THREE.Vector2();
|
|
8
8
|
|
|
9
9
|
// Invert the mesh's world transform (pivot rotation + per-view recentring) to recover
|
|
10
|
-
// shared-frame CAD coords — the same frame build() models in.
|
|
10
|
+
// shared-frame CAD coords — the same frame build() models in. `worldToLocal` also
|
|
11
|
+
// undoes the mesh's own local matrix, which carries the viewer's fast-path pose
|
|
12
|
+
// (setSubPose), so re-apply that matrix to land in the CURRENT shared frame rather
|
|
13
|
+
// than the frame the delivered mesh was baked in.
|
|
11
14
|
export function worldToSubPartLocal(mesh, world) {
|
|
12
15
|
const v = Array.isArray(world) ? new THREE.Vector3(world[0], world[1], world[2]) : world.clone();
|
|
13
16
|
mesh.worldToLocal(v);
|
|
17
|
+
v.applyMatrix4(mesh.matrix);
|
|
14
18
|
return [v.x, v.y, v.z];
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
const _normal = new THREE.Vector3();
|
|
22
|
+
|
|
23
|
+
// A geometry-frame direction in shared-frame CAD coords (see pointLocal above).
|
|
24
|
+
function normalInSubPartFrame(mesh, normal) {
|
|
25
|
+
_normal.copy(normal).transformDirection(mesh.matrix);
|
|
26
|
+
return [_normal.x, _normal.y, _normal.z];
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
// The feature carried by a mesh triangle, or null (unlabeled / no attribution data).
|
|
18
30
|
export function featureAt(mesh, triIndex) {
|
|
19
31
|
const { featureIds, features } = mesh.geometry.userData;
|
|
@@ -38,9 +50,10 @@ export function raycastViewer(viewer, clientX, clientY) {
|
|
|
38
50
|
triIndex: hit.faceIndex,
|
|
39
51
|
pointWorld: hit.point,
|
|
40
52
|
pointLocal: worldToSubPartLocal(hit.object, hit.point),
|
|
41
|
-
// face.normal is in the geometry's
|
|
42
|
-
//
|
|
43
|
-
|
|
53
|
+
// face.normal is in the geometry's own frame; the mesh's local matrix (identity,
|
|
54
|
+
// or a rigid fast-path pose) carries it into the shared CAD frame. The pose is
|
|
55
|
+
// rigid, so rotating the direction by that matrix is exact.
|
|
56
|
+
normalLocal: hit.face ? normalInSubPartFrame(hit.object, hit.face.normal) : [0, 0, 0],
|
|
44
57
|
feature: featureAt(hit.object, hit.faceIndex),
|
|
45
58
|
};
|
|
46
59
|
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -209,6 +209,7 @@ export function createViewer(container, part) {
|
|
|
209
209
|
const subCache = Object.fromEntries(names.map((n) => [n, null]));
|
|
210
210
|
|
|
211
211
|
function setSubGeometry(name, payload) {
|
|
212
|
+
setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
|
|
212
213
|
const prev = subCache[name];
|
|
213
214
|
const next = buildGeometry(payload);
|
|
214
215
|
subCache[name] = next;
|
|
@@ -218,17 +219,38 @@ export function createViewer(container, part) {
|
|
|
218
219
|
if (prev) { prev.userData.edges?.dispose(); prev.dispose(); }
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
// Presentational rigid pose for one sub-part (the pose fast path): applied to
|
|
223
|
+
// the mesh and its edge lines. `null` clears. Column-major mat16 (pose.js /
|
|
224
|
+
// three.js Matrix4 convention). Never affects exports or geometry — the worker
|
|
225
|
+
// owns real placement; this only re-poses the delivered mesh.
|
|
226
|
+
function setSubPose(name, mat16) {
|
|
227
|
+
for (const obj of [subMesh[name], subLines[name]]) {
|
|
228
|
+
if (!obj) continue;
|
|
229
|
+
obj.matrixAutoUpdate = false;
|
|
230
|
+
if (mat16) obj.matrix.fromArray(mat16);
|
|
231
|
+
else obj.matrix.identity();
|
|
232
|
+
obj.matrixWorldNeedsUpdate = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
221
236
|
// Cache queries for the app's regenerate loop (so it never reaches into subCache).
|
|
222
237
|
const hasSubMesh = (name) => !!subCache[name];
|
|
223
238
|
const subTriangles = (name) => subCache[name]?.userData.triangles ?? 0;
|
|
224
239
|
|
|
225
240
|
// --- show / hide assembly -------------------------------------------------
|
|
226
241
|
const _box = new THREE.Box3();
|
|
242
|
+
const _posedBox = new THREE.Box3();
|
|
227
243
|
|
|
228
244
|
// Recentre the assembly on the pivot and frame the camera to the named parts.
|
|
245
|
+
// Cached bounding boxes are in the delivered mesh's own frame, so any fast-path
|
|
246
|
+
// pose has to be applied before the union or framing ignores the re-posing.
|
|
229
247
|
function frameTo(visibleNames) {
|
|
230
248
|
_box.makeEmpty();
|
|
231
|
-
for (const name of visibleNames)
|
|
249
|
+
for (const name of visibleNames) {
|
|
250
|
+
if (!subCache[name]) continue;
|
|
251
|
+
_posedBox.copy(subCache[name].boundingBox).applyMatrix4(subMesh[name].matrix);
|
|
252
|
+
_box.union(_posedBox);
|
|
253
|
+
}
|
|
232
254
|
if (_box.isEmpty()) return;
|
|
233
255
|
const center = _box.getCenter(new THREE.Vector3());
|
|
234
256
|
partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
|
|
@@ -457,6 +479,7 @@ export function createViewer(container, part) {
|
|
|
457
479
|
showAssembly,
|
|
458
480
|
hideAssembly,
|
|
459
481
|
setSubGeometry,
|
|
482
|
+
setSubPose,
|
|
460
483
|
hasSubMesh,
|
|
461
484
|
subTriangles,
|
|
462
485
|
frame,
|
package/src/framework/worker.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
// "manifold" (preview + STL) and "occt" (STEP), via the Worker `name` option.
|
|
3
3
|
// Each instance lazily imports only its own backend, so OCCT's ~11 MB WASM loads
|
|
4
4
|
// only in the worker that needs it, and only on first use.
|
|
5
|
+
//
|
|
6
|
+
// runWorker() returns a rebind handle — { setPart(newPart) } — so a host that
|
|
7
|
+
// swaps parts (an embedder, the cloud runner) can keep this worker and its warm
|
|
8
|
+
// kernel instead of tearing it down. The rebind contract (what setPart
|
|
9
|
+
// guarantees about epochs, cache sweeps, and the re-posted ready) is normative
|
|
10
|
+
// in docs/KERNEL-CONTRACT.md.
|
|
5
11
|
import { handle } from "./jobs.js";
|
|
6
12
|
import { lintPart } from "../lint.js";
|
|
7
13
|
|
|
@@ -35,10 +41,19 @@ export function runWorker(part) {
|
|
|
35
41
|
let manifold = null; // { preview, print }
|
|
36
42
|
let occt = null;
|
|
37
43
|
let booting = null;
|
|
44
|
+
let current = part; // rebindable via the returned handle's setPart()
|
|
45
|
+
let epoch = 0; // bumped per incoming generate and per setPart
|
|
46
|
+
const queue = []; // { data, part, epoch } — jobs run against the part current at arrival
|
|
47
|
+
let pumping = false;
|
|
38
48
|
|
|
39
49
|
// Manifold is cheap to boot — bring it up eagerly and signal readiness.
|
|
40
50
|
if (backend === "manifold") {
|
|
41
51
|
booting = manifoldKernels().then((m) => { manifold = m; postMessage({ type: "ready" }); });
|
|
52
|
+
// A failed boot is reported to the host by the first job that awaits `booting`
|
|
53
|
+
// (the pump's error boundary posts it). This no-op handler only keeps the eager
|
|
54
|
+
// rejection from surfacing as an unhandled rejection before that job arrives —
|
|
55
|
+
// `booting` itself still rejects for kernelFor.
|
|
56
|
+
booting.catch(() => {});
|
|
42
57
|
} else {
|
|
43
58
|
// OCCT boots lazily (its ~11 MB WASM loads on the first job), but the worker can
|
|
44
59
|
// accept jobs as soon as its module graph is up — messages queue in the port.
|
|
@@ -48,30 +63,97 @@ export function runWorker(part) {
|
|
|
48
63
|
postMessage({ type: "ready" });
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
// Lint is geometry-free by construction, so answer it before touching — or
|
|
53
|
-
// booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
|
|
54
|
-
// the branches below await that boot, so routing lint through them would drag
|
|
55
|
-
// in OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
|
|
56
|
-
if (e.data?.type === "lint") {
|
|
57
|
-
postMessage({ type: "lint-report", report: lintPart(part, { params: e.data.params }) });
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
let kernel;
|
|
66
|
+
async function kernelFor(data) {
|
|
61
67
|
if (backend === "manifold") {
|
|
62
68
|
await booting;
|
|
63
69
|
// The sender declares the job's mesh quality; the worker knows nothing about
|
|
64
70
|
// job-type semantics (mount marks STL/3MF exports quality:"print").
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
return data.quality === "print" ? manifold.print : manifold.preview;
|
|
72
|
+
}
|
|
73
|
+
if (!occt) {
|
|
74
|
+
postMessage({ type: "progress", phase: "loading exact kernel" }); // feedback during cold boot
|
|
75
|
+
booting = booting ?? occtKernel().then((k) => (occt = k));
|
|
76
|
+
await booting;
|
|
77
|
+
}
|
|
78
|
+
return occt;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Serial pump: exactly one job at a time. Jobs yield between sub-parts
|
|
82
|
+
// (jobs.js) so newer messages can enqueue — without this queue two handle()
|
|
83
|
+
// calls could interleave on the same kernel.
|
|
84
|
+
async function pump() {
|
|
85
|
+
if (pumping) return;
|
|
86
|
+
pumping = true;
|
|
87
|
+
try {
|
|
88
|
+
while (queue.length) {
|
|
89
|
+
const job = queue.shift();
|
|
90
|
+
// Error boundary around the whole body. handle() reports build failures itself,
|
|
91
|
+
// but kernelFor can reject — a WASM asset that 404s, an OOM during boot — and an
|
|
92
|
+
// escaping rejection would kill the pump: this job AND everything queued behind
|
|
93
|
+
// it would be dropped with no reply at all, leaving the host waiting on a message
|
|
94
|
+
// that never comes until its own timeout fires.
|
|
95
|
+
try {
|
|
96
|
+
// A generate superseded while it sat in the queue never builds at all.
|
|
97
|
+
if (job.epoch !== null && job.epoch !== epoch) continue;
|
|
98
|
+
const kernel = await kernelFor(job.data);
|
|
99
|
+
// handle() declares each message's transferables (the big binary buffers).
|
|
100
|
+
const post = (m, transfer = []) => postMessage(m, transfer);
|
|
101
|
+
if (job.epoch === null) { await handle(kernel, job.part, job.data, post); continue; }
|
|
102
|
+
const isStale = () => job.epoch !== epoch;
|
|
103
|
+
// Post gate. The boundary check cannot catch a generate that goes stale during
|
|
104
|
+
// its FINAL sub-part — there is no boundary after it — nor a single-sub-part
|
|
105
|
+
// generate that goes stale once dequeued. Both would otherwise post the OLD
|
|
106
|
+
// part's meshes after a rebind. Downgrading them to `superseded` keeps the
|
|
107
|
+
// contract simple: a `meshes` post is current as of the moment it is posted.
|
|
108
|
+
const gated = (m, transfer = []) =>
|
|
109
|
+
(m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
|
|
110
|
+
await handle(kernel, job.part, job.data, gated, { isStale });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Same shape jobs.js posts for a failed build, so hosts need no new branch.
|
|
113
|
+
postMessage({ type: "error", message: String(err?.message || err) });
|
|
114
|
+
}
|
|
71
115
|
}
|
|
72
|
-
|
|
116
|
+
} finally {
|
|
117
|
+
pumping = false;
|
|
73
118
|
}
|
|
74
|
-
|
|
75
|
-
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
self.onmessage = (e) => {
|
|
122
|
+
// Lint is geometry-free by construction, so answer it before touching — or
|
|
123
|
+
// booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
|
|
124
|
+
// the pump awaits that boot, so routing lint through the queue would drag in
|
|
125
|
+
// OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
|
|
126
|
+
if (e.data?.type === "lint") {
|
|
127
|
+
postMessage({ type: "lint-report", report: lintPart(current, { params: e.data.params }) });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Only generates supersede each other; exports/inspect always run (cancelling
|
|
131
|
+
// a user's export because an edit landed would be wrong).
|
|
132
|
+
const supersedes = e.data?.type === "generate";
|
|
133
|
+
if (supersedes) epoch++;
|
|
134
|
+
queue.push({ data: e.data, part: current, epoch: supersedes ? epoch : null });
|
|
135
|
+
void pump();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
// Rebind contract (docs/KERNEL-CONTRACT.md): swap the part, cancel stale
|
|
140
|
+
// builds, sweep idle cache partitions, and re-post ready so a remounting
|
|
141
|
+
// host gates its first generate exactly as on a fresh worker.
|
|
142
|
+
//
|
|
143
|
+
// Never runs mid-bracket, so the sweep is always safe: setPart runs
|
|
144
|
+
// synchronously on the worker's own turn, and jobs.js opens and closes each
|
|
145
|
+
// sub-part's beginSubPart/endSubPart bracket inside a single synchronous
|
|
146
|
+
// turn (its only awaits are between sub-parts). The serial pump keeps at
|
|
147
|
+
// most one handle() in flight, so there is no second bracket to land in
|
|
148
|
+
// either. An in-flight generate sees the bumped epoch at its next sub-part
|
|
149
|
+
// boundary and stops there.
|
|
150
|
+
setPart(newPart) {
|
|
151
|
+
current = newPart;
|
|
152
|
+
epoch++;
|
|
153
|
+
manifold?.preview.sweepCache?.();
|
|
154
|
+
manifold?.print.sweepCache?.();
|
|
155
|
+
occt?.sweepCache?.();
|
|
156
|
+
postMessage({ type: "ready" });
|
|
157
|
+
},
|
|
76
158
|
};
|
|
77
159
|
}
|