partforge 0.61.0 → 0.62.1

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 CHANGED
@@ -42,9 +42,10 @@ preset, or be hidden, so the interface stays simple while the part stays deeply
42
42
  Two geometry backends run in Web Workers, and partforge routes each part to whichever it
43
43
  needs:
44
44
 
45
- - **[Manifold](https://github.com/elalish/manifold)** — fast preview meshes and STL / 3MF.
46
- - **[Replicad](https://replicad.xyz)** (OpenCASCADE-in-WebAssembly) exact B-rep for STEP
47
- export and native fillet / chamfer.
45
+ - **[Manifold](https://github.com/elalish/manifold)** — fast preview meshes and STL / 3MF,
46
+ including mesh-native fillet and chamfer for straight and circular edges.
47
+ - **[Replicad](https://replicad.xyz)** (OpenCASCADE-in-WebAssembly) exact B-rep for STEP,
48
+ shell, and automatic fallback for blend geometry Manifold cannot handle.
48
49
 
49
50
  The viewer is [three.js](https://threejs.org).
50
51
 
package/bin/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // parsed strictly per command with util.parseArgs, so a typo'd flag or a missing
5
5
  // option value fails loudly instead of being silently ignored.
6
6
  import { parseArgs } from "node:util";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { pathToFileURL } from "node:url";
8
9
  import { resolve, dirname } from "node:path";
9
10
  import { writeFileSync, mkdirSync } from "node:fs";
@@ -35,6 +36,18 @@ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
35
36
  // Without --json there is no purity contract: human lines print as each stage
36
37
  // completes, so a later crash's message lands after them, not instead of them.
37
38
  function crash(cmd, e, jsonMode) {
39
+ // The mesh backend signals an edge class it can't blend (helical edge, varying
40
+ // dihedral, …) with NEEDS_OCCT. The two WASM kernels must never boot in one
41
+ // process, so the fallback is a re-exec of this exact command with the backend
42
+ // pinned to OCCT via the environment. One retry only — PARTFORGE_BACKEND set
43
+ // means we ARE the retry.
44
+ if (e?.code === "NEEDS_OCCT" && !process.env.PARTFORGE_BACKEND) {
45
+ console.error(`${cmd}: ${e.message} — retrying on the OCCT backend`);
46
+ const r = spawnSync(process.execPath, process.argv.slice(1), {
47
+ stdio: "inherit", env: { ...process.env, PARTFORGE_BACKEND: "occt" },
48
+ });
49
+ process.exit(r.status ?? 1);
50
+ }
38
51
  const message = e?.message || String(e);
39
52
  const m = matchPattern(message);
40
53
  if (jsonMode) {
@@ -63,7 +76,15 @@ async function loadPart(partPath, usage) {
63
76
  return part;
64
77
  }
65
78
 
66
- const bootKernel = (part) => (detectBackend(part) === "occt" ? bootOcctKernel() : bootManifoldKernel());
79
+ // Pass the part's declared fonts through, mirroring the worker path (jobs.js)
80
+ // otherwise a part using a named font builds in the browser but dies headlessly
81
+ // with `text2d: unknown font …`.
82
+ const bootKernel = (part) => {
83
+ const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
84
+ return backend === "occt"
85
+ ? bootOcctKernel({ fonts: part.fonts })
86
+ : bootManifoldKernel({ fonts: part.fonts });
87
+ };
67
88
 
68
89
  const commands = {
69
90
  async lint(args) {
@@ -15,7 +15,7 @@ Two worked examples to read alongside this guide: **`src/parts/demo.js`** (a
15
15
  parametric spacer — the smallest complete part) and **`src/parts/planter.js`** (a
16
16
  faceted planter — facets, taper, twist, even walls, an optional feature, a `derive`,
17
17
  and a `verify` block). **`src/parts/filleted-box.js`** is the worked example for the
18
- OCCT-only fillet/chamfer/shell ops.
18
+ portable Solid fillet/chamfer API and the OCCT-only shell op.
19
19
 
20
20
  ---
21
21
 
@@ -310,7 +310,7 @@ and the detection rule.
310
310
  | `k.loft({ rings, ruled?, closed?, shading? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls. `shading?: "smooth" \| "faceted"` overrides facet/smooth shading inference (default: <32-side rings shade as flat facets, drawing no same-surface lines at all — not even their own cap rims — though cut seams against other solids still draw; ≥32 sides shade smooth) |
311
311
  | `k.sweep({ profile, path, cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
312
312
  | `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
313
- | `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); stays on Manifold (no OCCT routing, unlike `fillet`); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
313
+ | `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); built as one hand-meshed ring stack (no booleans at all, cheaper than `fillet`'s cutters); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
314
314
  | `k.roundedCylinder({ r\|d, h, center?, round })` | cylinder with rounded rims — `round` = number (both) or `{ top?, bottom? }`; `round: r` with `top+bottom = h` gives a sphere (capsule when `h > 2r`); one lathe revolve, curve-exact in STEP |
315
315
  | `k.torus({ rMajor, rMinor })` | torus centered at the origin (tube centerline in z=0); `0 < rMinor < rMajor` |
316
316
  | `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
@@ -2013,12 +2013,16 @@ the requirement that exposed the failure.
2013
2013
 
2014
2014
  ---
2015
2015
 
2016
- ## Fillet & chamfer (automatic OCCT backend)
2016
+ ## Fillet, chamfer & shell
2017
2017
 
2018
2018
  Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
2019
- **OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold. But Manifold has no
2020
- fillet, so if your `build` calls a **CAD-only op** the framework automatically routes the
2021
- whole part to OCCT no declaration needed:
2019
+ **OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold and since
2020
+ contract v3 that **includes fillet and chamfer**: the mesh backend blends straight
2021
+ edges and circular-arc edges (bore rims, cylinder rims, the arcs where fillets meet a
2022
+ face) natively, at exact radius to within tessellation. Only `shell` still routes a
2023
+ sub-part to OCCT up front; a fillet/chamfer on an edge class the mesh backend can't
2024
+ blend (helical edges, varying dihedral) reroutes that sub-part to OCCT automatically
2025
+ at runtime — no declaration needed either way:
2022
2026
 
2023
2027
  | Op | Meaning |
2024
2028
  |---|---|
@@ -2032,8 +2036,9 @@ whole part to OCCT — no declaration needed:
2032
2036
  - `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
2033
2037
  - `{ near: [x,y,z] }` — edges passing through a point
2034
2038
  - a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier — **OCCT-only
2035
- escape hatch**: fine for a part that's happy to stay in this repo, but non-portable
2036
- (parts meant to travel must use the object forms — see `KERNEL-CONTRACT.md`)
2039
+ escape hatch**: it forces the sub-part onto OCCT (the mesh backend reroutes on
2040
+ sight of it) and is non-portable — parts meant to travel must use the object forms
2041
+ (see `KERNEL-CONTRACT.md`)
2037
2042
 
2038
2043
  ```js
2039
2044
  let s = k.box({ min: [0, 0, 0], max: [40, 30, 16] });
@@ -2044,29 +2049,38 @@ s = s.chamfer({ d: 1, edges: { inPlane: "XY", at: 0 } }); // bevel the base
2044
2049
  See `src/parts/filleted-box.js` for the worked example.
2045
2050
 
2046
2051
  **Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
2047
- of your `build` to see whether it uses a CAD-only op **on a Solid**, and routes accordingly —
2048
- Manifold for everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). The
2049
- probe tracks which handle kind each op ran on, so `Shape2D.fillet`/`.chamfer` (the shared,
2050
- backend-identical 2-D implementations — see "Editing profiles") do *not* route a part to
2051
- OCCT: rounding a profile before extruding keeps you on fast Manifold. Force the backend with
2052
- `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
2053
- entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
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.)
2052
+ of your `build`; a `shell` call **on a Solid** routes that sub-part to OCCT up front, and
2053
+ everything else fillet and chamfer included stays on fast Manifold. The probe tracks
2054
+ which handle kind each op ran on, so `Shape2D.fillet`/`.chamfer` (the shared,
2055
+ backend-identical 2-D implementations — see "Editing profiles") never look like Solid
2056
+ ops. Force the backend with `meta.backend: "occt" | "manifold"` if you ever need to.
2057
+ STEP export always builds on OCCT, so a filleted part gets exact B-rep blends in its
2058
+ STEP even though it previews (and STL/3MF-exports) from the mesh blend the two agree
2059
+ to within tessellation on the supported edge classes, with one visible exception:
2060
+ orthogonal box-style corners where three filleted edges meet get a true sphere-octant
2061
+ cap, but other blend junctions are mitred on the mesh where OCCT builds a vertex
2062
+ blend.
2063
+
2064
+ If the mesh backend hits an edge class it can't blend, it signals `NEEDS_OCCT` and the
2065
+ framework reroutes **just that sub-part** to OCCT for those exact parameters
2066
+ dialing the parameter away re-tries Manifold automatically. A zero magnitude
2067
+ `fillet(0)`, `chamfer({ d: 0 })` — is the **identity** on both backends (see
2068
+ KERNEL-CONTRACT.md), so an unguarded `s.fillet(p.r)` needs no `if (p.r > 0)` wrapper.
2069
+ (`shell` is the exception: `t: 0` is degenerate, not identity, so a shell call always
2070
+ routes to OCCT.)
2071
+
2072
+ **Clamp your radii.** The mesh fillet does **not** validate feasibility — an oversized
2073
+ radius self-intersects its cutters and yields a wrong shape rather than a skipped
2074
+ feature (OCCT skips instead). Clamp magnitudes against local geometry the way
2075
+ `filleted-box.js` does: `Math.min(p.fillet, halfWidth - 0.5, p.h - 0.5)`.
2062
2076
 
2063
2077
  **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
2078
+ a mixed part's regen fans out to both workers in parallel — a shelled body pays for OCCT
2079
+ while a plain lid rebuilds at Manifold speed beside it. Two scopes
2067
2080
  still route whole-part (the max over the sub-parts): **exports** (one STL/STEP/3MF job
2068
2081
  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.
2082
+ kernel; on a mesh-side `NEEDS_OCCT` it re-runs itself once with the backend pinned to
2083
+ OCCT). Within one sub-part's build there is no per-op backend mixing.
2070
2084
 
2071
2085
  **Shading intent.** The kernel decides what shades smooth and where edge lines
2072
2086
  draw — spheres, cylinders and fillets are smooth by construction; boolean cut
@@ -2075,16 +2089,15 @@ rings have fewer than 32 sides (`shading: "smooth"|"faceted"` on `k.loft`
2075
2089
  overrides the inference either way). If your part previews smooth but would
2076
2090
  print faceted — or the reverse — set the hint rather than changing facet counts.
2077
2091
 
2078
- > Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
2079
- > `fillet`/`chamfer` on a sweep-heavy part design those edges in, or keep the part on Manifold.
2092
+ > `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT-run parts
2093
+ > (Manifold-only topology); `render` works on both. Filleted parts now measure on
2094
+ > Manifold with full topology.
2080
2095
 
2081
- > `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
2082
- > topology); `render` works on both.
2096
+ ### Cost on the OCCT path: fillet/chamfer scale with edge count — and order matters
2083
2097
 
2084
- ### Cost: fillet/chamfer scale with edge count and order matters
2085
-
2086
- OCCT fillet/chamfer cost is **per selected edge**, on top of the OCCT boolean tax the
2087
- routing already imposes on the rest of the part. Two habits keep it tolerable:
2098
+ These costs apply when a sub-part **does** run on OCCT (a shell, an unsupported edge
2099
+ class, a pinned backend, or STEP export). OCCT fillet/chamfer cost is **per selected
2100
+ edge**, on top of the OCCT boolean tax. Two habits keep it tolerable:
2088
2101
 
2089
2102
  - **Fillet/chamfer as early as possible, on the simplest solid.** A fillet on a bare
2090
2103
  primitive is ~15× cheaper than the same fillet after a dozen boolean cuts have
@@ -52,13 +52,13 @@ 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 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
- - **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.)
55
+ - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `shell` call it reaches — including a branch the real build would not take, since queries return dummies — routes that sub-part to OCCT. Fillet and chamfer are not probe-routed: they start on Manifold and reroute only if the mesh backend reports an unsupported edge class. Preview routing is per sub-part; exports and the CLI route the whole part to the max over its sub-parts because those jobs run in one worker/kernel.
56
+ - **Fix:** Remove or guard the unnecessary `shell` call, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet, chamfer & shell". Routing re-runs with live params on every regen; a runtime `needs-occt` fallback is latched only for the current parameters, so a stale backend choice never outlives a parameter edit.
57
57
 
58
58
  ## fillet-chamfer-many-edges-slow
59
59
 
60
- - **Symptom:** A part that fillets or chamfers the rim of an extruded profile (a gear, a star, any many-point polygon) takes many seconds — even tens of seconds — per build, with no error anywhere.
61
- - **Cause:** OCCT fillet/chamfer cost scales with the number of selected edges, and an `inPlane` rim selector on a many-point extruded profile selects every polygon edge (hundreds for a gear), so one op call costs seconds — and re-runs on every parameter change.
60
+ - **Symptom:** A part on the OCCT path (unsupported-edge fallback, forced backend, CLI fallback, or STEP export) fillets or chamfers the rim of an extruded profile and takes many seconds — even tens of seconds — per build, with no error anywhere.
61
+ - **Cause:** OCCT fillet/chamfer cost scales with the number of selected edges, and an `inPlane` rim selector on a many-point extruded profile selects every polygon edge (hundreds for a gear), so one op call costs seconds — and re-runs on every parameter change while that path is active.
62
62
  - **Fix:** Use `extrude`'s `bevel` option instead of `chamfer` — same geometry, stays on the fast Manifold backend. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
63
63
 
64
64
  ## chamfer-rescue-bisection
@@ -539,6 +539,18 @@ exposure is a `build()` that queries a twisted solid's box itself, which is the
539
539
  normal idiom for placing something relative to a solid and now silently disagrees
540
540
  between the Manifold preview and the OCCT STEP export.
541
541
 
542
+ ## mesh-fillet-unsupported-edge
543
+
544
+ - **Symptom:** `fillet: ` or `chamfer: ` followed by an edge-class reason — e.g. `edge curve is not circular`, `flank angle varies along the arc`, `selector matched no sharp edges`, `~180° knife edge` — thrown as a `KernelCapabilityError`, or a preview sub-part silently rebuilding on the slow OCCT worker.
545
+ - **Cause:** The mesh backend's native fillet/chamfer (`mesh-fillet.js`) covers straight and circular-arc sharp-edge chains; the selected edges fall outside that (helical edge, varying dihedral, non-circular curve, or nothing sharp matched), so the op signals `NEEDS_OCCT` and the framework reroutes that sub-part (the CLI re-execs once with `PARTFORGE_BACKEND=occt`).
546
+ - **Fix:** Usually nothing — the reroute is the designed degrade and the OCCT result is correct, just slower. To stay on Manifold, restructure so the blend lands on a supported edge class (design the rounding into the profile, or fillet before the boolean that curves the edge). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet, chamfer & shell".
547
+
548
+ ## mesh-fillet-oversized-radius
549
+
550
+ - **Symptom:** A Manifold-built fillet/chamfer produces a mangled or over-cut shape (no error), where the same part on OCCT would skip the feature with a `fillet(…) failed` warning.
551
+ - **Cause:** The mesh fillet does not validate radius feasibility — a magnitude larger than the local geometry self-intersects its cutter solids and the booleans happily apply them.
552
+ - **Fix:** Clamp the magnitude against local dimensions in the part (`Math.min(p.fillet, halfWidth - 0.5, …)` — see `src/parts/filleted-box.js`), which is required practice on the mesh class per [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) § "Mesh degrade policy".
553
+
542
554
  # Hardware library
543
555
 
544
556
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -1,6 +1,6 @@
1
1
  # The partforge kernel contract
2
2
 
3
- **Contract version: 2** (introduced in partforge 0.59) — mirrored by `CONTRACT_VERSION`
3
+ **Contract version: 3** (introduced in partforge 0.62) — mirrored by `CONTRACT_VERSION`
4
4
  in `src/framework/geometry/kernel.js` and asserted by `test/kernel-contract.test.js`;
5
5
  see [Versioning](#versioning) for what may change under which version bump.
6
6
 
@@ -14,7 +14,7 @@ geometry. There is deliberately no separate file format or DSL.
14
14
  The contract has two halves:
15
15
 
16
16
  - **Machine-checked:** the op lists in `src/framework/geometry/kernel.js`
17
- (`KERNEL_OPS`, `SOLID_OPS`, `OCCT_ONLY_OPS`, `*_OPTIONAL_OPS`) and their `@typedef`
17
+ (`KERNEL_OPS`, `SOLID_OPS`, `OCCT_ONLY_OPS`, `ROUTED_CAD_OPS`, `*_OPTIONAL_OPS`) and their `@typedef`
18
18
  signatures. `test/kernel-contract.test.js` and the OCCT twin in
19
19
  `test/occt-backend.test.js` assert each backend exposes exactly these ops, so the list
20
20
  cannot silently drift from the implementations. **Those lists are normative.**
@@ -38,11 +38,35 @@ every `Solid` op in `SOLID_OPS`, *except* that the B-rep ops (`fillet`, `chamfer
38
38
  **identity** on every class — it returns the solid unchanged and must not throw, so a
39
39
  parametric radius dialed to 0 builds on a core kernel with no guard in the part.
40
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).
41
+ identity) and always throws on core. Kernels built from this repo get the stubs for
42
+ free: `addSugar()` generates the Solid-level stubs (including the zero-magnitude
43
+ identity) for whichever `OCCT_ONLY_OPS` a backend leaves undefined, and
44
+ `finishKernel()` stubs `toSTEP` (a kernel-level op, so it is not in that Solid-op
45
+ list).
46
+
47
+ **The in-repo Manifold backend is the reference core kernel, and since contract v3 it
48
+ implements `fillet` and `chamfer` natively** (`mesh-fillet.js` — tangent-tool CSG).
49
+ Its coverage and tolerance band are part of the contract:
50
+
51
+ - **Edge classes:** straight sharp edges with planar flanks, and circular-arc sharp
52
+ edges whose flanks are surfaces of revolution about the arc axis (bore rims,
53
+ cylinder rims, the arcs where blends meet a face) — full circles included. Convex
54
+ edges subtract a cutter; concave edges union a filler. Any other edge class
55
+ (helical edges, varying dihedral, non-circular curves, function selectors) throws
56
+ `KernelCapabilityError` so the host can reroute that build to a B-rep kernel.
57
+ - **Tolerance band, not identity:** the blend surface is the exact rolling-ball
58
+ (fillet) or setback-chord (chamfer) surface to within tessellation, plus
59
+ micron-scale robustness allowances (tool overshoot past tangency and seam-grazing
60
+ guards, all ≤ ~1e-3 mm). Volumes agree with the B-rep result to ~0.1% on covered
61
+ edge classes. **Corners:** where exactly three selected straight convex chains meet
62
+ at a mutually orthogonal vertex (a box corner), the fillet caps it with the
63
+ rolling-ball sphere octant; every other junction of blended chains is a **mitre**
64
+ (the blend surfaces intersect), where the B-rep class builds a kernel-specific
65
+ vertex blend instead — parity at such corners is approximate, like `roundedBox`'s
66
+ documented corner carve-out.
67
+ - **New surfaces:** like the B-rep op, a mesh blend produces new surfaces — feature
68
+ labels upstream of the call do not survive through it (attribution uses the
69
+ fallback path), and the result shades with the default SMOOTH policy.
46
70
 
47
71
  **B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
48
72
  OCCT/replicad backend is the reference.
@@ -56,13 +80,18 @@ a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache part
56
80
  have gone unbuilt for three consecutive rebinds.
57
81
 
58
82
  `KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
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 build to a B-rep-class kernel (the probe tracks
61
- handle kinds, so the same names on a `Shape2D` — shared pure JS, backend-identical — do
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.
83
+ probe (`probe.js`) runs `build` against a fake kernel, and any use of a
84
+ `ROUTED_CAD_OPS` op (`shell`, since v3) **on a Solid handle** routes the build to a
85
+ B-rep-class kernel up front (the probe tracks handle kinds, so the same names on a
86
+ `Shape2D` shared pure JS, backend-identical do not route). `fillet`/`chamfer` are
87
+ **not probe-routed anymore**: the core kernel attempts them, and throws
88
+ `KernelCapabilityError` only for an edge class it cannot blend which the in-repo
89
+ framework's runtime reroute latch (`backend-select.js`) converts into a per-sub-part
90
+ OCCT fallback, and the CLI into a re-exec on the OCCT kernel. Routing granularity is a
91
+ host choice: the in-repo framework routes preview builds per sub-part (each sub-part
92
+ builds wholly on one kernel) and exports/CLI whole-part; a single-kernel host routes
93
+ everything whole-part. A host with only a core kernel must surface the error ("this
94
+ part needs a B-rep backend") rather than swallow it.
66
95
 
67
96
  ## Global semantics
68
97
 
@@ -274,7 +303,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
274
303
  | `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. |
275
304
  | `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). |
276
305
  | `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. |
277
- | `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. |
306
+ | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | `fillet`/`chamfer`: implemented on BOTH in-repo classes since v3 — exactly on B-rep, tolerance-band on the mesh class for straight and circular-arc edge chains (see [Conformance classes](#conformance-classes)); an edge class the mesh kernel cannot blend throws `KernelCapabilityError` and reroutes. Zero magnitude — `fillet(0)` / `chamfer({d: 0})` — 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` remains B-rep-only (core throws), hollows inward keeping outer dimensions; `open` (face selector) is required. |
278
307
 
279
308
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
280
309
  speed and a backend may bake it at kernel creation (Manifold does). A part must never
@@ -337,6 +366,15 @@ garbage). A failing chamfer instead binary-searches the largest valid distance.
337
366
  conforming B-rep kernel must degrade this way — a fillet request must never brick the
338
367
  build, and authors should expect all-or-nothing filleting per call, not per edge.
339
368
 
369
+ **Mesh degrade policy** (`mesh-fillet.js`): the mesh class degrades by *rerouting*, not
370
+ skipping — an unsupported edge class or an empty selection throws
371
+ `KernelCapabilityError` and the framework retries the build on the B-rep kernel, which
372
+ then applies its own repair policy. One asymmetry is deliberate: the mesh class does
373
+ **not** validate radius feasibility (an oversized radius yields self-intersecting tools
374
+ and a wrong shape rather than a skipped feature), so parts should clamp magnitudes
375
+ against local geometry the way `filleted-box.js` does — good practice on both classes,
376
+ mandatory on this one.
377
+
340
378
  ## Shape2D (2-D booleans)
341
379
 
342
380
  `k.shape2d(profile)` (`KERNEL_OPS`) lifts a point list, `{outer,
@@ -642,6 +680,18 @@ in `kernel.js` define the current surface; only breaking changes bump the versio
642
680
  `cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
643
681
  transfer. Renames are breaking changes with no offsetting benefit — don't.
644
682
 
683
+ **v2 → v3** (partforge 0.62): `Solid.fillet` and `Solid.chamfer` are implemented
684
+ natively on the mesh (core reference) kernel for straight and circular-arc edge
685
+ chains, and are **no longer probe-routed to OCCT** — `ROUTED_CAD_OPS` (`shell`) is
686
+ the remaining probe-routing set, and unsupported edge classes reroute at runtime via
687
+ `KernelCapabilityError`. Semantics change for existing parts: a part using fillet or
688
+ chamfer now previews (and STL/3MF-exports) from the mesh kernel's tolerance-band
689
+ blend instead of paying for the OCCT worker; STEP export still uses OCCT's exact
690
+ blends. Behavioral deltas to re-measure when migrating: vertex junctions between
691
+ blended chains are mitred rather than corner-blended, radius feasibility is not
692
+ validated on the mesh class (clamp in the part), and a fillet/chamfer that previously
693
+ *failed and was skipped* by OCCT's repair policy may now build (mesh) or reroute.
694
+
645
695
  **v1 → v2** (partforge 0.59): `Shape2D.offset` moved off the two per-backend 2-D
646
696
  engines (Clipper2 via `CrossSection` on Manifold, replicad's `Drawing.offset` on
647
697
  OCCT) onto the single native contour-offset engine described above. Semantics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.61.0",
3
+ "version": "0.62.1",
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",
@@ -26,11 +26,9 @@ export function detectBackends(part, params = {}) {
26
26
  if (forced) { backends[name] = forced; continue; }
27
27
  const { kernel, cadCalls } = createProbeKernel();
28
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.
29
+ // cadCalls currently holds only Solid.shell calls. Solid fillet/chamfer start
30
+ // on Manifold and use the runtime capability backstop below only when the mesh
31
+ // backend cannot blend the selected edge class; Shape2D variants are shared JS.
34
32
  backends[name] = cadCalls.some(({ op, args }) => !isZeroMagnitudeCadOp(op, args))
35
33
  ? "occt"
36
34
  : "manifold";
@@ -51,7 +49,7 @@ export function detectBackend(part, params = {}) {
51
49
  // (a CAD-only call it can't reach — e.g. gated on a real geometry query the
52
50
  // probe answers with dummies) the Manifold build throws NEEDS_OCCT and the
53
51
  // worker asks for a reroute. That must not pin OCCT for the rest of the session,
54
- // or turning the OCCT-only feature off never reverts to Manifold. Instead the
52
+ // or turning the unsupported feature off never reverts to Manifold. Instead the
55
53
  // reroute is latched per (sub-part, params snapshot): the exact combination that
56
54
  // failed skips the doomed Manifold retry, and ANY param change re-consults the
57
55
  // probe. A part the probe chronically under-detects costs one cheap failed
@@ -38,15 +38,20 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
38
38
  for (let r = 0; r < roid.length; r++)
39
39
  for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
40
40
 
41
- // per-triangle face normals
41
+ // per-triangle face normals, plus each triangle's minimum height (2·area /
42
+ // longest edge) — the "thinness" the feature-edge pass gates on below
42
43
  const fn = new Float32Array(nTri * 3);
44
+ const thin = new Float32Array(nTri);
43
45
  for (let t = 0; t < nTri; t++) {
44
46
  const a = tris[t * 3] * np, b = tris[t * 3 + 1] * np, c = tris[t * 3 + 2] * np;
45
47
  const ux = vp[b] - vp[a], uy = vp[b + 1] - vp[a + 1], uz = vp[b + 2] - vp[a + 2];
46
48
  const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
49
+ const wx = vp[c] - vp[b], wy = vp[c + 1] - vp[b + 1], wz = vp[c + 2] - vp[b + 2];
47
50
  const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
48
51
  const L = Math.hypot(nx, ny, nz) || 1;
49
52
  fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L;
53
+ const longest = Math.max(ux * ux + uy * uy + uz * uz, vx * vx + vy * vy + vz * vz, wx * wx + wy * wy + wz * wz);
54
+ thin[t] = longest > 0 ? L / Math.sqrt(longest) : 0; // |cross| / maxEdge = min height
50
55
  }
51
56
 
52
57
  // canonical vertex → incident triangles
@@ -94,6 +99,13 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
94
99
  const prev = seenEdge.get(key);
95
100
  if (prev === undefined) { seenEdge.set(key, t); continue; }
96
101
  seenEdge.delete(key);
102
+ // Sub-visible slivers never emit feature lines: a CSG junction between two
103
+ // independently tessellated tangent surfaces (e.g. a corner sphere meeting
104
+ // its edge-fillet cylinders) can leave micron-wide wall strips whose FACES
105
+ // are invisible but whose long boundary edges would otherwise draw at full
106
+ // line weight. A triangle thinner than MIN_EDGE cannot be seen, so its
107
+ // edges are noise by definition — same threshold the segment filter uses.
108
+ if (thin[prev] < MIN_EDGE || thin[t] < MIN_EDGE) continue;
97
109
  const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
98
110
  // A multi-hole cap triangulation can contain an opposite-wound bridge:
99
111
  // its two normals disagree by 180 degrees even though both triangles lie
@@ -16,7 +16,7 @@
16
16
  // The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
17
17
  // must match this number (asserted in kernel-contract.test.js). Bump only on a
18
18
  // breaking contract change — see the doc's Versioning section.
19
- export const CONTRACT_VERSION = 2;
19
+ export const CONTRACT_VERSION = 3;
20
20
 
21
21
  // Ops every backend kernel must implement.
22
22
  export const KERNEL_OPS = [
@@ -57,12 +57,19 @@ export const SHAPE2D_OPS = [
57
57
  "isEmpty",
58
58
  ];
59
59
 
60
- // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
61
- // a part to OCCT when its build uses one of these, and the Manifold backend
62
- // generates its KernelCapabilityError stubs from the same list adding an op here
63
- // wires up both automatically.
60
+ // Solid ops a core (mesh) kernel MAY lack. solid-sugar generates KernelCapability-
61
+ // Error stubs for whichever of these a backend leaves undefined. The in-repo
62
+ // Manifold backend now implements fillet and chamfer natively (mesh-fillet.js
63
+ // straight and circular-arc edge chains), so only `shell` is stubbed there.
64
64
  export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
65
65
 
66
+ // The subset the geometry-free probe still routes to a B-rep kernel up front.
67
+ // fillet/chamfer are NOT probe-routed anymore: the mesh backend attempts them
68
+ // and throws KernelCapabilityError (code NEEDS_OCCT) only for edge classes it
69
+ // cannot blend, which the framework's runtime reroute latch converts into a
70
+ // per-sub-part OCCT fallback (backend-select.js).
71
+ export const ROUTED_CAD_OPS = ["shell"];
72
+
66
73
  /**
67
74
  * @typedef {Object} Solid An opaque handle to a backend solid. `_`-prefixed keys are backend internals.
68
75
  * @property {(tool: Solid) => Solid} cut
@@ -87,8 +94,8 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
87
94
  * `normals`/`edges` are authoritative shading intent from both backends — see docs/KERNEL-CONTRACT.md "Shading intent"; quality is advisory — the Manifold kernel bakes it at creation
88
95
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
89
96
  * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
90
- * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
91
- * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges (OCCT only); chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
97
+ * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges; mesh-native for straight/circular chains with automatic OCCT fallback; fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
98
+ * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges; mesh-native for straight/circular chains with automatic OCCT fallback; chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
92
99
  * @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted for now (see file header)
93
100
  * @property {() => number} [genus] through-hole count (Manifold only)
94
101
  * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
@@ -12,6 +12,8 @@ import { finishKernel } from "./kernel-front.js";
12
12
  import { meshToStl } from "./mesh-stl.js";
13
13
  import { creasedNormals } from "./creased-normals.js";
14
14
  import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
15
+ import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
16
+ import { KernelCapabilityError } from "./errors.js";
15
17
 
16
18
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
17
19
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -102,10 +104,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
102
104
  return { positions, indices };
103
105
  }
104
106
 
107
+ // Mesh fillet/chamfer (mesh-fillet.js): the tool solids are built through the
108
+ // kernel's own cached loft/revolve/boolean ops, so the whole op memoizes at
109
+ // this boundary — and the result is simplify()ed to drop the zero-area sliver
110
+ // triangles booleans legitimately leave behind (they are invisible but their
111
+ // degenerate normals poison the crease pass into drawing phantom edge lines).
112
+ // Unsupported edge classes (helical edges, varying dihedral, …) surface as
113
+ // KernelCapabilityError so the framework reroutes that sub-part to OCCT.
114
+ // simplify() will not collapse triangles across run (originalID) boundaries,
115
+ // and the boolean's sliver triangles sit exactly on them — so the result is
116
+ // re-originaled first. That folds every surface into one fresh original,
117
+ // which is also the documented B-rep semantic: fillet/chamfer produce new
118
+ // surfaces, so feature-label attribution downstream of the op uses the
119
+ // fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
120
+ const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
121
+ const meshCadOp = (op, run) => {
122
+ try {
123
+ return T(T(run()._m.asOriginal()).simplify(SIMPLIFY_EPS));
124
+ } catch (e) {
125
+ if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
126
+ throw e;
127
+ }
128
+ };
129
+
105
130
  const wrap = (m, hash) => addSugar({
106
131
  _m: m,
107
132
  _hash: hash,
108
133
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
134
+ fillet: (r, selector) => {
135
+ if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
136
+ if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
137
+ return cached(h("fillet", hash, r, selector ?? null, segs), () =>
138
+ meshCadOp("fillet", () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
139
+ },
140
+ chamfer: (d, selector) => {
141
+ if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
142
+ if (d === 0) return wrap(m, hash);
143
+ return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
144
+ meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
145
+ },
109
146
  cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
110
147
  () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
111
148
  intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),