partforge 0.60.1 → 0.60.2

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.
@@ -2052,6 +2052,15 @@ OCCT: rounding a profile before extruding keeps you on fast Manifold. Force the
2052
2052
  `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
2053
2053
  entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
2054
2054
 
2055
+ The probe re-runs with the **live parameters on every regen**, and routing works in both
2056
+ directions: turn a fillet on and the part moves to OCCT; dial it back to 0 (or take the
2057
+ branch that skips it) and the part drops back to Manifold automatically. A zero magnitude
2058
+ — `fillet(0)`, `chamfer({ d: 0 })` — is the **identity** on both backends (see
2059
+ KERNEL-CONTRACT.md), so an unguarded `s.fillet(p.r)` needs no `if (p.r > 0)` wrapper to
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.
2063
+
2055
2064
  **Shading intent.** The kernel decides what shades smooth and where edge lines
2056
2065
  draw — spheres, cylinders and fillets are smooth by construction; boolean cut
2057
2066
  seams always shade hard and draw a line; a loft's facets shade flat when its
@@ -52,8 +52,8 @@ 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.)
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)".
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.)
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
59
59
 
@@ -33,10 +33,16 @@ carries the conformance semantics and that one the usage guidance;
33
33
  **Core class.** A conforming core kernel implements every op in `KERNEL_OPS` and
34
34
  every `Solid` op in `SOLID_OPS`, *except* that the B-rep ops (`fillet`, `chamfer`,
35
35
  `shell` — the `OCCT_ONLY_OPS` list — and `toSTEP`) may instead throw
36
- `KernelCapabilityError`. The in-repo Manifold backend is the reference core kernel.
37
- Kernels built from this repo get the stubs for free: `addSugar()` generates the
38
- Solid-level stubs from `OCCT_ONLY_OPS`, and `finishKernel()` stubs `toSTEP` (a
39
- kernel-level op, so it is not in that Solid-op list).
36
+ `KernelCapabilityError`. Exception to the exception: `fillet(0)` / `chamfer({d: 0})`
37
+ (a magnitude that is exactly the number `0`, either calling convention) is the
38
+ **identity** on every class — it returns the solid unchanged and must not throw, so a
39
+ parametric radius dialed to 0 builds on a core kernel with no guard in the part.
40
+ `shell` has no identity form (`t: 0` means zero-thickness walls — degenerate, not
41
+ identity) and always throws on core. The in-repo Manifold backend is the reference
42
+ core kernel. Kernels built from this repo get the stubs for free: `addSugar()`
43
+ generates the Solid-level stubs (including the zero-magnitude identity) from
44
+ `OCCT_ONLY_OPS`, and `finishKernel()` stubs `toSTEP` (a kernel-level op, so it is
45
+ not in that Solid-op list).
40
46
 
41
47
  **B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
42
48
  OCCT/replicad backend is the reference.
@@ -266,7 +272,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
266
272
  | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` and `edges` are authoritative shading intent from both backends — see [Shading intent](#shading-intent-tomesh-normals-and-edges) below; `featureIds`/`features` are optional metadata. |
267
273
  | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
268
274
  | `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
269
- | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
275
+ | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`), *except* a zero magnitude — `fillet(0)` / `chamfer({d: 0})` — which is the identity on every class (returns the solid unchanged, never throws; `shell` excluded, `t: 0` is degenerate). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
270
276
 
271
277
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
272
278
  speed and a backend may bake it at kernel creation (Manifold does). A part must never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.60.1",
3
+ "version": "0.60.2",
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",
@@ -2,12 +2,10 @@
2
2
  // This is framework-level policy, not geometry-kernel plumbing: it knows the full
3
3
  // PartDefinition shape (meta.backend, defaults, parts[name].build), unlike
4
4
  // everything in geometry/, which is part-agnostic.
5
- import { OCCT_ONLY_OPS } from "./geometry/kernel.js";
6
5
  import { createProbeKernel } from "./geometry/probe.js";
6
+ import { isZeroMagnitudeCadOp } from "./geometry/op-options.js";
7
7
  import { resolveDerived } from "./derive.js";
8
8
 
9
- const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
10
-
11
9
  export function detectBackend(part, params = {}) {
12
10
  if (part.meta?.backend) return part.meta.backend;
13
11
  const p = { ...part.defaults, ...params };
@@ -16,13 +14,39 @@ export function detectBackend(part, params = {}) {
16
14
  // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
17
15
  // build hits the same throw and posts a proper error for the UI.
18
16
  try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
19
- const { kernel, solidUsed } = createProbeKernel();
17
+ const { kernel, cadCalls } = createProbeKernel();
20
18
  for (const name of Object.keys(part.parts)) {
21
19
  try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
22
20
  }
23
- // solidUsed, not used: `Shape2D.fillet`/`.chamfer` are the shared pure-JS
24
- // implementation (backend-identical) and must not drag a part onto OCCT — only
25
- // the same names called on a Solid are CAD-only.
26
- for (const op of solidUsed) if (OCCT_ONLY.has(op)) return "occt";
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
+ }
27
29
  return "manifold";
28
30
  }
31
+
32
+ // The mount-time backend chooser. detectBackend() re-runs per regen with live
33
+ // params, so backend choice already follows the parameters in both directions —
34
+ // this wrapper exists for the runtime backstop: when the probe under-detects
35
+ // (a CAD-only call it can't reach — e.g. gated on a real geometry query the
36
+ // probe answers with dummies) the Manifold build throws NEEDS_OCCT and the
37
+ // worker asks for a reroute. That must not pin OCCT for the rest of the session,
38
+ // 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.
43
+ export function createBackendPolicy(part, { forced = null } = {}) {
44
+ let latchedParams = null; // JSON snapshot of the params proven at runtime to need OCCT
45
+ 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); },
51
+ };
52
+ }
@@ -208,6 +208,9 @@ export function createOcctKernel(replicad) {
208
208
  const key = h("fillet", hash, radius, selKey(selector));
209
209
  return cached(key, () => {
210
210
  const a = mat();
211
+ // radius 0 is the identity per the contract — skip the kernel call so it
212
+ // can't trip the repair path's "feature skipped" warning.
213
+ if (radius === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
211
214
  return wrap(safeOp(a._s.clone(), (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(a._labels), key);
212
215
  });
213
216
  },
@@ -216,6 +219,7 @@ export function createOcctKernel(replicad) {
216
219
  // validChamfer probes on internal clones and never consumes its input.
217
220
  return cached(key, () => {
218
221
  const a = mat();
222
+ if (distance === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key); // identity, same as fillet(0)
219
223
  return wrap(validChamfer(a._s, toEdgeFinder(selector), distance), cloneLabels(a._labels), key);
220
224
  });
221
225
  },
@@ -280,8 +280,8 @@ export const KERNEL_OP_SPECS = {
280
280
  };
281
281
 
282
282
  // Solid ops under the options convention; addSugar() wraps these when the
283
- // backend provides them natively (OCCT). The Manifold KernelCapabilityError
284
- // stubs ignore arguments, so options-form calls still throw the routing error.
283
+ // backend provides them natively (OCCT). The Manifold stubs check only for the
284
+ // zero-magnitude identity case below, then throw the routing error unnormalized.
285
285
  export const SOLID_OP_SPECS = {
286
286
  fillet: { toArgs: (o) => { checkKeys("fillet", o, ["r", "edges"]);
287
287
  return [req("fillet", o, "r"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
@@ -290,3 +290,17 @@ export const SOLID_OP_SPECS = {
290
290
  shell: { toArgs: (o) => { checkKeys("shell", o, ["t", "open"]);
291
291
  return [req("shell", o, "t"), req("shell", o, "open")]; } },
292
292
  };
293
+
294
+ // A zero-magnitude fillet/chamfer is the identity on every conformance class
295
+ // (KERNEL-CONTRACT.md), so it neither routes a part to OCCT nor throws the
296
+ // Manifold capability error. True only when the magnitude is PROVABLY the number
297
+ // 0 in either calling convention — anything unprovable stays conservative
298
+ // (routes/throws). shell is excluded: t = 0 means zero-thickness walls, which is
299
+ // degenerate, not identity.
300
+ export function isZeroMagnitudeCadOp(op, args) {
301
+ const key = op === "fillet" ? "r" : op === "chamfer" ? "d" : null;
302
+ if (!key) return false;
303
+ const a0 = args[0];
304
+ const v = typeof a0 === "number" ? a0 : isPlainOptions(a0) ? a0[key] : undefined;
305
+ return v === 0;
306
+ }
@@ -15,7 +15,7 @@
15
15
  // lists, which test/kernel-contract.test.js pins to both backend implementations.
16
16
  import {
17
17
  KERNEL_OPS, KERNEL_OPTIONAL_OPS,
18
- SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
18
+ SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS,
19
19
  } from "./kernel.js";
20
20
  import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
21
21
 
@@ -92,15 +92,22 @@ function makeProbe(onCall) {
92
92
  return { kernel, proxy, shape2d };
93
93
  }
94
94
 
95
+ const CAD_OPS = new Set(OCCT_ONLY_OPS);
96
+
95
97
  export function createProbeKernel() {
96
98
  const used = new Set(); // every op name, any handle kind
97
99
  const solidUsed = new Set(); // ops recorded on kernel/Solid handles only — the
98
100
  // routing set: Shape2D.fillet must not look like Solid.fillet
99
- const { kernel } = makeProbe((scope, key) => {
101
+ const cadCalls = []; // Solid-handle fillet/chamfer/shell calls WITH args —
102
+ // routing needs the magnitude (fillet(0) is identity, stays on Manifold)
103
+ const { kernel } = makeProbe((scope, key, args) => {
100
104
  used.add(key);
101
- if (scope !== "shape2d") solidUsed.add(key);
105
+ if (scope !== "shape2d") {
106
+ solidUsed.add(key);
107
+ if (CAD_OPS.has(key)) cadCalls.push({ op: key, args });
108
+ }
102
109
  });
103
- return { kernel, used, solidUsed };
110
+ return { kernel, used, solidUsed, cadCalls };
104
111
  }
105
112
 
106
113
  export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
@@ -12,7 +12,7 @@
12
12
  // the needs-occt reroute works without hand-written per-backend stubs.
13
13
  import { KernelCapabilityError } from "./errors.js";
14
14
  import { OCCT_ONLY_OPS } from "./kernel.js";
15
- import { isPlainOptions, SOLID_OP_SPECS } from "./op-options.js";
15
+ import { isPlainOptions, isZeroMagnitudeCadOp, SOLID_OP_SPECS } from "./op-options.js";
16
16
 
17
17
  const ORIGIN = [0, 0, 0];
18
18
  const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
@@ -59,14 +59,21 @@ export function addSugar(s) {
59
59
 
60
60
  // Options-object calling convention for the multi-param B-rep ops. Wrap only
61
61
  // when the backend provides the op natively (OCCT); the Manifold stubs below
62
- // ignore their arguments, so options-form calls still throw the routing error.
62
+ // read their arguments just enough to spot the zero-magnitude identity case,
63
+ // and throw the routing error for everything else.
63
64
  for (const [op, { toArgs }] of Object.entries(SOLID_OP_SPECS)) {
64
65
  const raw = s[op];
65
66
  if (raw) s[op] = (...a) => raw(...(a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0]) : a));
66
67
  }
67
68
 
68
69
  for (const op of OCCT_ONLY_OPS) {
69
- s[op] ??= () => { throw new KernelCapabilityError(`${op} requires the OCCT backend`); };
70
+ // Zero magnitude is the identity per the contract return a fresh handle
71
+ // (same idiom as along("+Z")) so an unguarded `.fillet(p.r)` at r = 0 builds
72
+ // on the core kernel instead of throwing the routing error.
73
+ s[op] ??= (...a) => {
74
+ if (isZeroMagnitudeCadOp(op, a)) return s.translate(ORIGIN);
75
+ throw new KernelCapabilityError(`${op} requires the OCCT backend`);
76
+ };
70
77
  }
71
78
 
72
79
  return Object.assign(s, SUGAR);
@@ -13,7 +13,7 @@ import { createMeshCache } from "./mesh-cache.js";
13
13
  import { createGeometryService } from "./geometry-service.js";
14
14
  import { viewSubParts } from "./part-model.js";
15
15
  import { resolveDerived } from "./derive.js";
16
- import { detectBackend } from "./backend-select.js";
16
+ import { createBackendPolicy } from "./backend-select.js";
17
17
  import { createDebugOverlay } from "./debug-overlay.js";
18
18
  import { createRegenLoop } from "./regen-loop.js";
19
19
  import { createPoseFastPath } from "./pose-fast-path.js";
@@ -253,10 +253,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
253
253
  cleanup.defer(() => ui.setStatus(""));
254
254
  cleanup.defer(() => ui.hideBusy());
255
255
 
256
- // ?backend=occt|manifold forces the backend; otherwise it's detected per part.
256
+ // ?backend=occt|manifold forces the backend; otherwise it's re-detected per
257
+ // regen with the live params (so a fillet dialed to 0 reverts to Manifold),
258
+ // with a params-keyed latch for runtime needs-occt reroutes — see
259
+ // createBackendPolicy for why the latch must not outlive a param change.
257
260
  let forcedBackend = new URLSearchParams(location.search).get("backend");
258
261
  if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
259
- const backendFor = () => forcedBackend ?? detectBackend(part, params);
262
+ const backendPolicy = createBackendPolicy(part, { forced: forcedBackend });
263
+ const backendFor = () => backendPolicy.backendFor(params);
260
264
 
261
265
  // ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
262
266
  const qs = new URLSearchParams(location.search);
@@ -549,7 +553,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
549
553
  ui.setStatus(`${data.filename} downloaded`);
550
554
  break;
551
555
  case "needs-occt":
552
- forcedBackend = "occt"; // probe missed; this part needs OCCT stick to it
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);
553
560
  loop.buildDone();
554
561
  loop.kick();
555
562
  break;