partforge 0.28.0 → 0.32.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.
@@ -0,0 +1,132 @@
1
+ // Rim bevel for extruded profiles — extrude({ profile, h, bevel }). Composed
2
+ // entirely from existing kernel ops (extrude + loft + intersect/cut) at the
3
+ // backend-shared front, so both backends get identical semantics for free and
4
+ // the probe sees no CAD-only op: a beveled extrusion stays on the fast Manifold
5
+ // backend instead of routing the whole part to OCCT. (OCCT's native chamfer is
6
+ // per-edge; on a many-point profile rim — a gear — one call costs seconds. The
7
+ // loft envelope costs one boolean regardless of point count.)
8
+ //
9
+ // Accepted profiles: point array, arc/curve contour, { outer, holes } region,
10
+ // or a Shape2D (multi-region Shape2Ds bevel each region and union). Curved
11
+ // profiles are MATERIALIZED to point rings first — the loft envelope needs
12
+ // rings in 1:1 point correspondence — so a beveled extrusion is faceted at the
13
+ // sampling LOD even in STEP export (the same fidelity class as loft rings).
14
+ // Arc contours sample at a fixed pure-JS LOD (backend-identical, like hull);
15
+ // a Shape2D materializes via its own backend's toRegions (hull's Shape2D
16
+ // parity class). The body is built from the SAME materialized rings as the
17
+ // envelope — mixing a curve-exact body with a faceted envelope would leave
18
+ // sliver artifacts where the smooth wall crosses the faceted one.
19
+ //
20
+ // Geometry: a 45° bevel, exactly what chamfer({d, edges:{inPlane}}) would cut.
21
+ // The outer rim insets toward the material; a hole's rim flares the other way
22
+ // (the opening is larger at the face). Outer bevels come from intersecting a
23
+ // loft envelope extended 1 mm past both faces (so its end caps never coincide
24
+ // with the extrusion's faces — coincident caps leave sliver-triangle shading
25
+ // artifacts). Hole bevels are separate per-rim flare cutters that meet the
26
+ // hole wall only along a ring (a curve, not a face), keeping the booleans away
27
+ // from coincident-face degeneracies on the B-rep backend.
28
+ import { offsetPolygon } from "./polygon.js";
29
+ import { tessellateProfile } from "./profile.js";
30
+ import { ringArea } from "./shape2d-regions.js";
31
+
32
+ // Fixed pure-JS sampling LOD for arc/curve contours — hull.js precedent: not a
33
+ // backend's own segment count, so both backends materialize bit-identically.
34
+ const BEVEL_SEGS = 64;
35
+
36
+ // Normalize `bevel` (number = both rims, {bottom, top} = per rim) and validate
37
+ // against the height. Exported for direct unit testing.
38
+ export function resolveBevel(bevel, h) {
39
+ let bottom, top;
40
+ if (typeof bevel === "number") { bottom = bevel; top = bevel; }
41
+ else if (bevel !== null && typeof bevel === "object") {
42
+ for (const key of Object.keys(bevel)) if (key !== "bottom" && key !== "top")
43
+ throw new Error(`extrude: unknown bevel option ${JSON.stringify(key)} (valid: bottom, top)`);
44
+ bottom = bevel.bottom ?? 0; top = bevel.top ?? 0;
45
+ } else throw new Error("extrude: bevel must be a number or { bottom?, top? }");
46
+ if (!(Number.isFinite(bottom) && bottom >= 0) || !(Number.isFinite(top) && top >= 0))
47
+ throw new Error("extrude: bevel distances must be finite numbers >= 0");
48
+ if (bottom + top >= h)
49
+ throw new Error("extrude: bevel must fit the height (bottom + top < h)");
50
+ return { bottom, top };
51
+ }
52
+
53
+ // offsetPolygon needs CCW input; toRegions/hand-written holes may wind either
54
+ // way. Tessellated arc contours close back onto their start point — drop that
55
+ // duplicate, or the offset ring's point count never matches and fit() gives up.
56
+ const ccw = (r) => {
57
+ const [x0, y0] = r[0], [xn, yn] = r[r.length - 1];
58
+ const ring = Math.abs(x0 - xn) < 1e-9 && Math.abs(y0 - yn) < 1e-9 ? r.slice(0, -1) : r;
59
+ return ringArea(ring) >= 0 ? ring : [...ring].reverse();
60
+ };
61
+
62
+ // The largest offset (inset for the outer rim, outset for a hole rim — the sign
63
+ // of `delta`) the ring can take, starting from the requested distance. Narrow
64
+ // features cap the bevel (the same geometric limit OCCT's chamfer hits — see
65
+ // ERROR-PATTERNS.md#chamfer-rescue-bisection), but each attempt here is pure JS
66
+ // on the 2-D outline, not a kernel op, so backing off is effectively free. The
67
+ // loop is deterministic, preserving build purity. `corners: "sharp"` keeps the
68
+ // offset 1:1 with the input points — loft stitching requires every ring to
69
+ // share the profile's exact point count (a mismatch is treated as a failed try).
70
+ const fit = (ring, delta, what) => {
71
+ const requested = Math.abs(delta), sign = Math.sign(delta);
72
+ let c = requested;
73
+ for (;;) {
74
+ try {
75
+ const off = offsetPolygon(ring, sign * c, { corners: "sharp" });
76
+ if (off.length === ring.length) {
77
+ if (c < requested)
78
+ console.warn(`partforge: extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
79
+ return { ring: off, c };
80
+ }
81
+ } catch { /* offset collapsed or self-intersected — try smaller */ }
82
+ c *= 0.85;
83
+ if (c < 0.05) {
84
+ console.warn(`partforge: extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
85
+ return null;
86
+ }
87
+ }
88
+ };
89
+
90
+ const outerRings = (outer, h, b, t) => {
91
+ const rings = [];
92
+ if (b) rings.push({ polygon: b.ring, z: -1 }, { polygon: b.ring, z: 0 }, { polygon: outer, z: b.c });
93
+ else rings.push({ polygon: outer, z: -1 });
94
+ if (t) rings.push({ polygon: outer, z: h - t.c }, { polygon: t.ring, z: h }, { polygon: t.ring, z: h + 1 });
95
+ else rings.push({ polygon: outer, z: h + 1 });
96
+ return rings;
97
+ };
98
+
99
+ const bevelRegion = (k, region, h, bottom, top) => {
100
+ const outer = ccw(region.outer);
101
+ const holes = (region.holes ?? []).map(ccw);
102
+ let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
103
+ const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
104
+ const t = top > 0 ? fit(outer, -top, "profile") : null;
105
+ if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t) }));
106
+ const cutters = [];
107
+ for (const hole of holes) {
108
+ const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
109
+ if (hb) cutters.push(k.loft({ rings: [
110
+ { polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
111
+ ] }));
112
+ const ht = top > 0 ? fit(hole, top, "hole") : null;
113
+ if (ht) cutters.push(k.loft({ rings: [
114
+ { polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
115
+ ] }));
116
+ }
117
+ return cutters.length ? s.cutAll(cutters) : s;
118
+ };
119
+
120
+ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
121
+ if (twist !== undefined || scaleTop !== undefined)
122
+ throw new Error("extrude: bevel cannot combine with twist or scaleTop");
123
+ const { bottom, top } = resolveBevel(bevel, h);
124
+ // bevel: 0 must hash identically to the plain call — hand the original
125
+ // profile straight through (curve-exact on OCCT, no materialization).
126
+ if (bottom === 0 && top === 0) return k.extrude({ profile, h });
127
+ const regions = profile != null && typeof profile.toRegions === "function"
128
+ ? profile.toRegions()
129
+ : [tessellateProfile(profile, BEVEL_SEGS)];
130
+ if (regions.length === 0) throw new Error("extrude: bevel profile produced no regions");
131
+ return regions.map((r) => bevelRegion(k, r, h, bottom, top)).reduce((a, x) => a.union(x));
132
+ }
@@ -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,11 +59,14 @@ 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.
65
- export function mount(part, { createWorker, elements = {}, onBuild, onPick,
69
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload,
66
70
  container: legacyContainer, controls: legacyControls } = {}) {
67
71
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
68
72
  const byId = (id) => document.getElementById(id);
@@ -126,7 +130,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
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,
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,
199
211
  missingParts,
200
212
  send: (missing) => {
201
213
  const needed = viewSubParts(part, view(), params);
202
- lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
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,
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
  }
@@ -287,12 +311,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
287
311
  }
288
312
  case "download-parts":
289
313
  ui.hideBusy();
290
- downloadParts(data, zipName);
314
+ downloadParts(data, zipName, onDownload);
291
315
  ui.setStatus(`${data.parts.length} part(s) downloaded`);
292
316
  break;
293
317
  case "download":
294
318
  ui.hideBusy();
295
- triggerDownload(data.data, data.filename, data.mime);
319
+ triggerDownload(data.data, data.filename, data.mime, onDownload);
296
320
  ui.setStatus(`${data.filename} downloaded`);
297
321
  break;
298
322
  case "needs-occt":
@@ -319,15 +343,37 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
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,
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
- if (overlayParent !== hit.mesh.parent) {
79
- hit.mesh.parent.add(overlay);
80
- overlayParent = hit.mesh.parent;
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 local frame, which equals the CAD frame here
42
- // (the mesh carries no local transform; only its parents rotate/recentre).
43
- normalLocal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : [0, 0, 0],
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
  }
@@ -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) if (subCache[name]) _box.union(subCache[name].boundingBox);
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,