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 +4 -3
- package/bin/cli.js +22 -1
- package/docs/AUTHORING-PARTS.md +48 -35
- package/docs/ERROR-PATTERNS.md +16 -4
- package/docs/KERNEL-CONTRACT.md +65 -15
- package/package.json +1 -1
- package/src/framework/backend-select.js +4 -6
- package/src/framework/geometry/creased-normals.js +13 -1
- package/src/framework/geometry/kernel.js +14 -7
- package/src/framework/geometry/manifold-backend.js +37 -0
- package/src/framework/geometry/mesh-fillet.js +576 -0
- package/src/framework/geometry/probe.js +9 -10
- package/src/framework/lint/rules-build.js +6 -5
- package/src/framework/mount.js +2 -2
- package/src/parts/filleted-box.js +3 -2
- package/src/parts/mixed-smoke.js +14 -7
- package/types/kernel.d.ts +9 -6
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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);
|
|
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
|
|
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
|
|
2020
|
-
|
|
2021
|
-
|
|
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**:
|
|
2036
|
-
|
|
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`
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
backend-identical 2-D implementations — see "Editing profiles")
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
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
|
|
2065
|
-
while a plain lid rebuilds at Manifold speed beside it.
|
|
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
|
|
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
|
-
>
|
|
2079
|
-
>
|
|
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
|
-
|
|
2082
|
-
> topology); `render` works on both.
|
|
2096
|
+
### Cost on the OCCT path: fillet/chamfer scale with edge count — and order matters
|
|
2083
2097
|
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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** `
|
|
56
|
-
- **Fix:** Remove
|
|
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
|
|
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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The partforge kernel contract
|
|
2
2
|
|
|
3
|
-
**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.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
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
|
|
60
|
-
op **on a Solid handle** routes the build to a
|
|
61
|
-
handle kinds, so the same names on a
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 (
|
|
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
|
@@ -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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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
|
|
91
|
-
* @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges
|
|
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))),
|