partforge 0.60.2 → 0.61.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.
@@ -2058,8 +2058,15 @@ branch that skips it) and the part drops back to Manifold automatically. A zero
2058
2058
  — `fillet(0)`, `chamfer({ d: 0 })` — is the **identity** on both backends (see
2059
2059
  KERNEL-CONTRACT.md), so an unguarded `s.fillet(p.r)` needs no `if (p.r > 0)` wrapper to
2060
2060
  get the fast preview back when the slider hits 0. (`shell` is the exception: `t: 0` is
2061
- degenerate, not identity, so a shell call always routes to OCCT.) Within one build the
2062
- part runs on a single backend — there is no per-op mixing.
2061
+ degenerate, not identity, so a shell call always routes to OCCT.)
2062
+
2063
+ **Preview routing is per sub-part.** Each sub-part is probed and routed independently, and
2064
+ a mixed part's regen fans out to both workers in parallel — a filleted body pays for OCCT
2065
+ while a plain lid rebuilds at Manifold speed beside it. This makes it worth isolating a
2066
+ CAD-op solid in its own sub-part rather than folding it into a bigger build. Two scopes
2067
+ still route whole-part (the max over the sub-parts): **exports** (one STL/STEP/3MF job
2068
+ builds everything in one worker) and the **CLI** (a single Node process boots exactly one
2069
+ kernel). Within one sub-part's build there is no per-op backend mixing.
2063
2070
 
2064
2071
  **Shading intent.** The kernel decides what shades smooth and where edge lines
2065
2072
  draw — spheres, cylinders and fillets are smooth by construction; boolean cut
@@ -52,7 +52,7 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
52
52
  ## probe-routed-to-occt
53
53
 
54
54
  - **Symptom:** A part builds far slower than expected (preview takes seconds instead of milliseconds), and the worker logs show it running on the `occt` worker.
55
- - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT. (`Shape2D.fillet`/`.chamfer` are the shared pure-JS implementation and do **not** route; the probe tracks which handle kind an op ran on. A magnitude that is provably `0` — `fillet(0)`, `chamfer({d: 0})` — is the identity and does not route either, so a fillet param dialed to 0 reverts the part to Manifold with no guard; a magnitude the probe can't prove zero — e.g. computed from a dummy query value — routes conservatively.)
55
+ - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes that sub-part to OCCT (preview routing is per sub-part; exports and the CLI route the whole part to the max over its sub-parts, since those jobs run in one worker/kernel). (`Shape2D.fillet`/`.chamfer` are the shared pure-JS implementation and do **not** route; the probe tracks which handle kind an op ran on. A magnitude that is provably `0` — `fillet(0)`, `chamfer({d: 0})` — is the identity and does not route either, so a fillet param dialed to 0 reverts the part to Manifold with no guard; a magnitude the probe can't prove zero — e.g. computed from a dummy query value — routes conservatively.)
56
56
  - **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). If the rounding is on a 2-D profile, `Shape2D.fillet` before extruding keeps the part on Manifold. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)". (Routing re-runs with live params on every regen — even after a runtime `needs-occt` reroute, which pins OCCT only until the params next change — so a stale backend choice never outlives a parameter edit.)
57
57
 
58
58
  ## fillet-chamfer-many-edges-slow
@@ -57,10 +57,12 @@ have gone unbuilt for three consecutive rebinds.
57
57
 
58
58
  `KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
59
59
  probe (`probe.js`) runs `build` against a fake kernel, and any use of an `OCCT_ONLY_OPS`
60
- op **on a Solid handle** routes the whole part to a B-rep-class kernel (the probe tracks
60
+ op **on a Solid handle** routes the build to a B-rep-class kernel (the probe tracks
61
61
  handle kinds, so the same names on a `Shape2D` — shared pure JS, backend-identical — do
62
- not route). A host with only a core kernel must surface the error ("this part needs a
63
- B-rep backend") rather than swallow it.
62
+ not route). Routing granularity is a host choice: the in-repo framework routes preview
63
+ builds per sub-part (each sub-part builds wholly on one kernel) and exports/CLI whole-
64
+ part; a single-kernel host routes everything whole-part. A host with only a core kernel
65
+ must surface the error ("this part needs a B-rep backend") rather than swallow it.
64
66
 
65
67
  ## Global semantics
66
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.60.2",
3
+ "version": "0.61.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,9 @@
1
+ import part from "./parts/mixed-smoke.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev/CI-only app for the mixed-backend smoke fixture — see parts/mixed-smoke.js.
5
+ // Handle stashed for scripts/check-app.mjs, same as the other smoke apps.
6
+ window.__pfRuntime = mount(part, {
7
+ createWorker: (name) =>
8
+ new Worker(new URL("./mixed-smoke-worker.js", import.meta.url), { type: "module", name }),
9
+ });
@@ -6,47 +6,82 @@ import { createProbeKernel } from "./geometry/probe.js";
6
6
  import { isZeroMagnitudeCadOp } from "./geometry/op-options.js";
7
7
  import { resolveDerived } from "./derive.js";
8
8
 
9
- export function detectBackend(part, params = {}) {
10
- if (part.meta?.backend) return part.meta.backend;
9
+ /**
10
+ * Per-sub-part routing: probe each sub-part's build independently and map it to
11
+ * its own backend, so a mixed part previews its plain sub-parts on fast Manifold
12
+ * while only the CAD-op ones pay for OCCT. Sub-parts build independently by
13
+ * construction (buildPosed builds one at a time), which is what makes splitting
14
+ * them across the two workers sound.
15
+ */
16
+ export function detectBackends(part, params = {}) {
17
+ const forced = part.meta?.backend;
11
18
  const p = { ...part.defaults, ...params };
12
19
  let d = {};
13
20
  // A throwing derive must not escape here — this runs on the main thread mid
14
21
  // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
15
22
  // build hits the same throw and posts a proper error for the UI.
16
23
  try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
17
- const { kernel, cadCalls } = createProbeKernel();
24
+ const backends = {};
18
25
  for (const name of Object.keys(part.parts)) {
26
+ if (forced) { backends[name] = forced; continue; }
27
+ const { kernel, cadCalls } = createProbeKernel();
19
28
  try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
29
+ // cadCalls holds only Solid-handle fillet/chamfer/shell — `Shape2D.fillet`/
30
+ // `.chamfer` are the shared pure-JS implementation (backend-identical) and must
31
+ // not drag a sub-part onto OCCT. A provably zero magnitude is the identity (see
32
+ // KERNEL-CONTRACT.md) and doesn't route either, so a fillet param dialed to 0
33
+ // drops the sub-part back onto Manifold with no `if (r > 0)` guard in the build.
34
+ backends[name] = cadCalls.some(({ op, args }) => !isZeroMagnitudeCadOp(op, args))
35
+ ? "occt"
36
+ : "manifold";
20
37
  }
21
- // cadCalls holds only Solid-handle fillet/chamfer/shell — `Shape2D.fillet`/
22
- // `.chamfer` are the shared pure-JS implementation (backend-identical) and must
23
- // not drag a part onto OCCT. A provably zero magnitude is the identity (see
24
- // KERNEL-CONTRACT.md) and doesn't route either, so a fillet param dialed to 0
25
- // drops the part back onto Manifold with no `if (r > 0)` guard in the build.
26
- for (const { op, args } of cadCalls) {
27
- if (!isZeroMagnitudeCadOp(op, args)) return "occt";
28
- }
29
- return "manifold";
38
+ return backends;
39
+ }
40
+
41
+ // Whole-part detection: the max over detectBackends. This is the routing for
42
+ // anything that must build every sub-part in ONE worker exports (a single
43
+ // STL/STEP/3MF job) and the single-process CLI, which can't mix kernels at all.
44
+ export function detectBackend(part, params = {}) {
45
+ return Object.values(detectBackends(part, params)).includes("occt") ? "occt" : "manifold";
30
46
  }
31
47
 
32
- // The mount-time backend chooser. detectBackend() re-runs per regen with live
48
+ // The mount-time backend chooser. detectBackends() re-runs per regen with live
33
49
  // params, so backend choice already follows the parameters in both directions —
34
50
  // this wrapper exists for the runtime backstop: when the probe under-detects
35
51
  // (a CAD-only call it can't reach — e.g. gated on a real geometry query the
36
52
  // probe answers with dummies) the Manifold build throws NEEDS_OCCT and the
37
53
  // worker asks for a reroute. That must not pin OCCT for the rest of the session,
38
54
  // or turning the OCCT-only feature off never reverts to Manifold. Instead the
39
- // reroute is latched per params snapshot: the exact params that failed skip the
40
- // doomed Manifold retry, and ANY param change re-consults the probe. A part the
41
- // probe chronically under-detects costs one cheap failed Manifold dispatch per
42
- // param change — the price of automatic reversion.
55
+ // reroute is latched per (sub-part, params snapshot): the exact combination that
56
+ // failed skips the doomed Manifold retry, and ANY param change re-consults the
57
+ // probe. A part the probe chronically under-detects costs one cheap failed
58
+ // Manifold dispatch per param change — the price of automatic reversion.
43
59
  export function createBackendPolicy(part, { forced = null } = {}) {
44
- let latchedParams = null; // JSON snapshot of the params proven at runtime to need OCCT
60
+ let latchedParams = null; // JSON snapshot of the params proven at runtime to need OCCT
61
+ let latchedNames = null; // the sub-parts that proved it (null = all of them)
62
+ const latched = (params, name) =>
63
+ latchedParams !== null && latchedParams === JSON.stringify(params) &&
64
+ (latchedNames === null || latchedNames.has(name));
45
65
  return {
46
- backendFor: (params) => forced
47
- ?? (latchedParams !== null && latchedParams === JSON.stringify(params)
48
- ? "occt"
49
- : detectBackend(part, params)),
50
- noteNeedsOcct: (params) => { latchedParams = JSON.stringify(params); },
66
+ // name backend for a preview generate; mount groups sub-parts by this.
67
+ backendsFor(params) {
68
+ const backends = detectBackends(part, params);
69
+ for (const name of Object.keys(backends)) {
70
+ if (forced) backends[name] = forced;
71
+ else if (latched(params, name)) backends[name] = "occt";
72
+ }
73
+ return backends;
74
+ },
75
+ // Whole-part max, for jobs that need one worker (exports).
76
+ backendFor(params) {
77
+ if (forced) return forced;
78
+ return Object.values(this.backendsFor(params)).includes("occt") ? "occt" : "manifold";
79
+ },
80
+ // `subparts` names the failed job's sub-parts; omitted (an export job, which
81
+ // doesn't carry them) it latches the whole part for these params.
82
+ noteNeedsOcct(params, subparts) {
83
+ latchedParams = JSON.stringify(params);
84
+ latchedNames = subparts ? new Set(subparts) : null;
85
+ },
51
86
  };
52
87
  }
@@ -244,7 +244,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
244
244
  post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
245
245
  }
246
246
  } catch (err) {
247
- if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId });
247
+ // `subparts` (generate jobs only) tells the reroute policy which sub-parts the
248
+ // failed job covered, so only those latch to OCCT — not the whole part.
249
+ if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
248
250
  else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
249
251
  } finally {
250
252
  kernel.cleanup?.();
@@ -402,6 +402,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
402
402
 
403
403
  // The regenerate state machine (ready gating / debounce / stale-redo) lives in
404
404
  // regen-loop.js; this send callback is the one place a build job is dispatched.
405
+ // Routing is per SUB-part: the missing set splits by backend and each group goes
406
+ // to its own worker in parallel, so a mixed part previews its plain sub-parts at
407
+ // Manifold speed while only the filleted ones wait on OCCT. The return value is
408
+ // the job count — the loop holds the cycle open until every group has replied.
405
409
  const loop = createRegenLoop({
406
410
  missingParts,
407
411
  send: (missing) => {
@@ -410,7 +414,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
410
414
  lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
411
415
  pendingPosed.clear(); // consumed — never counted against a second build
412
416
  ui.showBusy("generating");
413
- service.send({ type: "generate", subparts: missing, view: view(), params, cache: cachingOn }, backendFor());
417
+ const backends = backendPolicy.backendsFor(params);
418
+ let jobs = 0;
419
+ for (const backend of ["manifold", "occt"]) {
420
+ const subparts = missing.filter((n) => (backends[n] ?? "manifold") === backend);
421
+ if (subparts.length === 0) continue;
422
+ service.send({ type: "generate", subparts, view: view(), params, cache: cachingOn }, backend);
423
+ jobs++;
424
+ }
425
+ return jobs;
414
426
  },
415
427
  });
416
428
  cleanup.defer(() => loop.dispose());
@@ -509,7 +521,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
509
521
  // delivered, which buildDone() true guarantees is at the live params.
510
522
  fastPath.recordDelivered(m.name);
511
523
  }
512
- ui.hideBusy();
524
+ // A split dispatch answers in two meshes replies; the busy spinner
525
+ // stays up until the view has everything (the other worker's job may
526
+ // still be running — often OCCT, the slow one).
527
+ if (missingParts().length === 0) ui.hideBusy();
513
528
  refreshView();
514
529
  if (data.ms && missingParts().length === 0) {
515
530
  const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
@@ -517,11 +532,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
517
532
  }
518
533
  dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
519
534
  onBuild?.({ status: "success", ms: data.ms });
520
- if (!readySettled) { readySettled = true; resolveReady(); }
521
- // First-show autoplay: latched separately from `ready`, which the
522
- // error branch also settles a part whose first build fails but
523
- // whose retry succeeds still deserves its autoplay.
524
- if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
535
+ // ready/autoplay wait for the WHOLE view: a split dispatch delivers in
536
+ // two replies, and a host acting on `ready` (screenshotting, measuring)
537
+ // must never see half an assembly.
538
+ if (missingParts().length === 0) {
539
+ if (!readySettled) { readySettled = true; resolveReady(); }
540
+ // First-show autoplay: latched separately from `ready`, which the
541
+ // error branch also settles — a part whose first build fails but
542
+ // whose retry succeeds still deserves its autoplay.
543
+ if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
544
+ }
525
545
  } else if (lastAnimApplyVersion === loop.version()) {
526
546
  // Stale ONLY because animation frames kept bumping the version:
527
547
  // show the delivered meshes anyway — that IS best-effort playback —
@@ -553,10 +573,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
553
573
  ui.setStatus(`${data.filename} downloaded`);
554
574
  break;
555
575
  case "needs-occt":
556
- // Probe missed for the current params — rebuild on OCCT. The latch is
557
- // per params snapshot, so changing params re-consults the probe and the
558
- // part can drop back to Manifold when the OCCT-only feature goes away.
559
- backendPolicy.noteNeedsOcct(params);
576
+ // Probe missed for the current params — rebuild the failed job's
577
+ // sub-parts on OCCT (an export reply has no subparts and latches the
578
+ // whole part). The latch is per params snapshot, so changing params
579
+ // re-consults the probe and sub-parts drop back to Manifold when the
580
+ // OCCT-only feature goes away.
581
+ backendPolicy.noteNeedsOcct(params, data.subparts);
560
582
  loop.buildDone();
561
583
  loop.kick();
562
584
  break;
@@ -4,8 +4,10 @@
4
4
  //
5
5
  // Invariants (pinned by test/framework/regen-loop.test.js):
6
6
  // - nothing is sent until ready() (the worker announced its kernel);
7
- // - at most one build is in flight; kicks while generating are absorbed and the
8
- // caller re-kicks after buildDone();
7
+ // - at most one dispatch CYCLE is in flight; kicks while generating are absorbed
8
+ // and the caller re-kicks after the cycle's last buildDone(). `send` may fan a
9
+ // dispatch out to several workers (per-sub-part backend routing) and reports
10
+ // how many jobs it posted; each worker reply is one buildDone();
9
11
  // - markDirty() bumps the params version and debounces a kick, so dragging a
10
12
  // slider queues one build per pause, not one per pixel;
11
13
  // - a build that a mid-flight edit outdated is reported stale by buildDone()
@@ -14,19 +16,19 @@
14
16
  // fast-apply path kicks explicitly).
15
17
  export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
16
18
  let kernelReady = false;
17
- let generating = false;
19
+ let pending = 0; // worker replies still owed for the in-flight dispatch cycle
18
20
  let disposed = false;
19
21
  let paramsVersion = 0; // bumped on every settings edit
20
22
  let genVersion = -1; // the params version the in-flight build is building
21
23
  let timer = null;
22
24
 
23
25
  function kick() {
24
- if (disposed || !kernelReady || generating) return; // re-kicked when the current build finishes
26
+ if (disposed || !kernelReady || pending > 0) return; // re-kicked when the current cycle finishes
25
27
  const missing = missingParts();
26
28
  if (missing.length === 0) return;
27
- generating = true;
28
29
  genVersion = paramsVersion;
29
- send(missing);
30
+ // A send with no return value is the single-job case (one worker, one reply).
31
+ pending = send(missing) ?? 1;
30
32
  }
31
33
 
32
34
  return {
@@ -42,10 +44,12 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
42
44
  clearTimeout(timer);
43
45
  if (debounce) timer = setTimeout(kick, debounceMs);
44
46
  },
45
- // The build finished (meshes / needs-occt / error). Returns whether its result
46
- // is still current; the caller applies the meshes only on true, then kicks.
47
+ // One job of the cycle finished (meshes / needs-occt / error). Returns whether
48
+ // its result is still current; the caller applies the meshes only on true, then
49
+ // kicks (a no-op until the cycle's last reply). Guarded decrement: a reply
50
+ // outside any cycle (an export-triggered needs-occt) must not pre-close the next.
47
51
  buildDone() {
48
- generating = false;
52
+ if (pending > 0) pending--;
49
53
  return genVersion === paramsVersion;
50
54
  },
51
55
  version: () => paramsVersion,
@@ -0,0 +1,3 @@
1
+ import part from "./parts/mixed-smoke.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,36 @@
1
+ // CI fixture for per-sub-part backend routing (the mixed-backend twin of
2
+ // text-smoke.js): one filleted sub-part that routes to OCCT next to a plain one
3
+ // that stays on Manifold, so the smoke check exercises a split generate — two
4
+ // workers answering one regen cycle — in a real browser. Dial "Edge fillet" to 0
5
+ // and the whole part drops back to Manifold (zero magnitude is the identity).
6
+ export default {
7
+ meta: { title: "Mixed smoke", units: "mm" },
8
+ parameters: [
9
+ {
10
+ id: "body",
11
+ title: "Body",
12
+ advanced: [
13
+ { key: "w", label: "Width", unit: "mm", min: 10, max: 60, step: 1 },
14
+ { key: "r", label: "Edge fillet", unit: "mm", min: 0, max: 5, step: 0.5 },
15
+ ],
16
+ },
17
+ ],
18
+ defaults: { w: 30, r: 2 },
19
+ parts: {
20
+ body: {
21
+ label: "Body",
22
+ views: ["assembly"],
23
+ export: { name: "body" },
24
+ // Unguarded on purpose: fillet(0) is the identity, so r drives the routing.
25
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 10] }).fillet({ r: p.r, edges: { dir: "Z" } }),
26
+ },
27
+ lid: {
28
+ label: "Lid",
29
+ views: ["assembly"],
30
+ export: { name: "lid" },
31
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 2] }),
32
+ place: (s) => s.at([0, 0, 12]),
33
+ },
34
+ },
35
+ views: { assembly: { label: "Assembly" } },
36
+ };