partforge 0.62.1 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +23 -0
- package/docs/ERROR-PATTERNS.md +6 -0
- package/docs/KERNEL-CONTRACT.md +23 -1
- package/package.json +1 -1
- package/src/framework/geometry/kernel.js +2 -2
- package/src/framework/geometry/manifold-backend.js +9 -0
- package/src/framework/geometry/mesh-roundall.js +83 -0
- package/src/framework/geometry/occt-backend.js +9 -0
- package/src/framework/geometry/occt-roundall.js +94 -0
- package/src/framework/geometry/op-options.js +2 -0
- package/types/kernel.d.ts +6 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2093,6 +2093,29 @@ print faceted — or the reverse — set the hint rather than changing facet cou
|
|
|
2093
2093
|
> (Manifold-only topology); `render` works on both. Filleted parts now measure on
|
|
2094
2094
|
> Manifold with full topology.
|
|
2095
2095
|
|
|
2096
|
+
### `roundAll(r)` — round everything at once
|
|
2097
|
+
|
|
2098
|
+
`s.roundAll(2)` (or `s.roundAll({ r: 2 })`) rounds **every** edge of the solid
|
|
2099
|
+
— convex and concave — with radius ≈ 2 mm in one pass, on both backends, with
|
|
2100
|
+
no OCCT routing. Faces stay in place (within backend tolerance). It is the
|
|
2101
|
+
blunt, global counterpart to `fillet`/`chamfer`: there is no edge selection,
|
|
2102
|
+
and features smaller than the ball are **consumed** — walls thinner than `2r`
|
|
2103
|
+
melt away, holes narrower than `2r` seal shut. That makes it ideal for
|
|
2104
|
+
"soften this whole organic part" and wrong for parts where a specific edge
|
|
2105
|
+
must stay sharp (use `fillet` with a selector for that).
|
|
2106
|
+
|
|
2107
|
+
Rules of thumb:
|
|
2108
|
+
|
|
2109
|
+
- Keep `r` under half your thinnest wall unless you *want* melting.
|
|
2110
|
+
- Preview and STL/3MF export always work (mesh morphology). STEP export gets
|
|
2111
|
+
true B-rep arc surfaces while `r` is below the smallest feature size; at
|
|
2112
|
+
consuming radii OCCT cannot represent the melt and **skips the op whole**
|
|
2113
|
+
with a `roundall-skipped` warning — the STEP then has the un-rounded shape.
|
|
2114
|
+
- Relying on consumption? Add a `verify` volume assertion so a regression in
|
|
2115
|
+
the radius (or a backend skip) fails loudly.
|
|
2116
|
+
- A small ridge remaining where a thin rib was consumed is correct morphology
|
|
2117
|
+
(the rib's dilation fillet survives the opening), not a bug.
|
|
2118
|
+
|
|
2096
2119
|
### Cost on the OCCT path: fillet/chamfer scale with edge count — and order matters
|
|
2097
2120
|
|
|
2098
2121
|
These costs apply when a sub-part **does** run on OCCT (a shell, an unsupported edge
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -101,6 +101,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
101
101
|
- **Cause:** OCCT's native fillet cannot build the rim round-over at a degenerate boundary (e.g. a rim radius exactly equal to `round.side` on a stadium profile, `2·side == min(w, d)`) and would otherwise return invalid-but-nonempty geometry; the monotonicity/validity gate in `occt-repair.js`'s `safeOp` catches it and skips the feature rather than exporting invalid STEP.
|
|
102
102
|
- **Fix:** shrink the affected rim radius slightly below `round.side` (or below the degenerate boundary), or accept the sharp rim at that exact radius.
|
|
103
103
|
|
|
104
|
+
## roundall-skipped
|
|
105
|
+
|
|
106
|
+
- **Symptom:** console warning `partforge: roundall-skipped: offset step … produced no valid solid — r=… is likely at/above the smallest feature size; returning the un-rounded solid`, and the OCCT build (STEP export, CLI measure with the OCCT backend) shows sharp edges where `roundAll` was expected.
|
|
107
|
+
- **Cause:** the B-rep backend implements `roundAll` as a triple OCCT offset, which cannot change topology: a radius at or above the smallest local feature (thin wall < 2r, hole < 2r) has no valid B-rep offset result, so the op skips whole rather than emit garbage (KERNEL-CONTRACT.md, `roundAll` row). The mesh backend meanwhile performs true consumption — so preview and STEP legitimately differ in this regime.
|
|
108
|
+
- **Fix:** reduce `r` below half the smallest wall/hole radius if STEP fidelity matters; or accept the divergence (preview/STL are correct) and gate the part with a `verify` volume assertion so the behavior is intentional.
|
|
109
|
+
|
|
104
110
|
## boolean-not-watertight
|
|
105
111
|
|
|
106
112
|
- **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The partforge kernel contract
|
|
2
2
|
|
|
3
|
-
**Contract version:
|
|
3
|
+
**Contract version: 4** (introduced in partforge 0.63) — 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
|
|
|
@@ -68,6 +68,20 @@ Its coverage and tolerance band are part of the contract:
|
|
|
68
68
|
labels upstream of the call do not survive through it (attribution uses the
|
|
69
69
|
fallback path), and the result shades with the default SMOOTH policy.
|
|
70
70
|
|
|
71
|
+
`roundAll` is required on **every** class (it is in `SOLID_OPS`, not
|
|
72
|
+
`OCCT_ONLY_OPS`) and is **parity-tolerant with a regime split**: while `r` is
|
|
73
|
+
strictly below the solid's smallest feature size (min wall, min hole
|
|
74
|
+
diameter / 2), both classes produce the morphological result and volumes agree
|
|
75
|
+
within the mesh-tolerance band. That band is stated at the **export (print)
|
|
76
|
+
tier**, where the mesh class's tessellation is fine enough to compare against
|
|
77
|
+
B-rep arcs (box and L-bracket cases: ≤ 0.05%); the preview tier is coarser by
|
|
78
|
+
design and runs ~0.2% below the B-rep volume, which is tessellation, not a
|
|
79
|
+
parity failure. At consuming radii, only the core (mesh) class
|
|
80
|
+
performs true consumption; a B-rep class MUST either produce a valid solid or
|
|
81
|
+
skip the entire op (returning the input unchanged, warning `roundall-skipped`)
|
|
82
|
+
— emitting an invalid or semantically wrong solid is non-conforming. Authors
|
|
83
|
+
relying on consumption should gate it with a `verify` volume assertion.
|
|
84
|
+
|
|
71
85
|
**B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
|
|
72
86
|
OCCT/replicad backend is the reference.
|
|
73
87
|
|
|
@@ -304,6 +318,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
|
|
|
304
318
|
| `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). |
|
|
305
319
|
| `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. |
|
|
306
320
|
| `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. |
|
|
321
|
+
| `roundAll(r)` · `roundAll({r})` | Morphological close-then-open with a ball of radius `r`: rounds EVERY edge (convex and concave) at radius ≈ `r`; faces stay in place (within the class's tolerance band — the B-rep offset chain can drift ~0.1 mm); features smaller than the ball are consumed (walls < 2r melt, holes < 2r seal). Implemented natively on BOTH classes — never routes, never throws `KernelCapabilityError`. Parity-tolerant only while `r` is below the smallest feature size; at consuming radii the mesh class performs true consumption and a B-rep class MAY skip the whole op unchanged with a `roundall-skipped` warning (skip is the only permitted degrade). `roundAll(0)` is the identity on every class. |
|
|
307
322
|
|
|
308
323
|
`quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
|
|
309
324
|
speed and a backend may bake it at kernel creation (Manifold does). A part must never
|
|
@@ -680,6 +695,13 @@ in `kernel.js` define the current surface; only breaking changes bump the versio
|
|
|
680
695
|
`cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
|
|
681
696
|
transfer. Renames are breaking changes with no offsetting benefit — don't.
|
|
682
697
|
|
|
698
|
+
**v3 → v4** (partforge 0.63): `Solid.roundAll` is added to `SOLID_OPS` (portable
|
|
699
|
+
morphological rounding — rounds every edge with a ball of radius `r`;
|
|
700
|
+
parity-tolerant, regime-split semantics, see
|
|
701
|
+
[Conformance classes](#conformance-classes)). Additive for parts — nothing
|
|
702
|
+
existing calls it — but breaking for backend implementers: a core kernel must
|
|
703
|
+
now implement it (no stub, unlike the `OCCT_ONLY_OPS` ops).
|
|
704
|
+
|
|
683
705
|
**v2 → v3** (partforge 0.62): `Solid.fillet` and `Solid.chamfer` are implemented
|
|
684
706
|
natively on the mesh (core reference) kernel for straight and circular-arc edge
|
|
685
707
|
chains, and are **no longer probe-routed to OCCT** — `ROUTED_CAD_OPS` (`shell`) is
|
package/package.json
CHANGED
|
@@ -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 = 4;
|
|
20
20
|
|
|
21
21
|
// Ops every backend kernel must implement.
|
|
22
22
|
export const KERNEL_OPS = [
|
|
@@ -38,7 +38,7 @@ export const SOLID_OPS = [
|
|
|
38
38
|
"cut", "cutAll", "intersect", "union", "clone", "label", "boundingBox", "volume",
|
|
39
39
|
"translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
|
|
40
40
|
"mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
|
|
41
|
-
"fillet", "chamfer", "shell",
|
|
41
|
+
"fillet", "chamfer", "shell", "roundAll",
|
|
42
42
|
];
|
|
43
43
|
|
|
44
44
|
// Backend-optional Solid queries: Manifold mesh-topology numbers (measure.js
|
|
@@ -13,6 +13,7 @@ import { meshToStl } from "./mesh-stl.js";
|
|
|
13
13
|
import { creasedNormals } from "./creased-normals.js";
|
|
14
14
|
import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
|
|
15
15
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
16
|
+
import { meshRoundAll } from "./mesh-roundall.js";
|
|
16
17
|
import { KernelCapabilityError } from "./errors.js";
|
|
17
18
|
|
|
18
19
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
@@ -143,6 +144,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
143
144
|
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
144
145
|
meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
145
146
|
},
|
|
147
|
+
roundAll: (r) => {
|
|
148
|
+
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
149
|
+
// `quality` in the key is redundant but harmless — the cache lives on a
|
|
150
|
+
// per-quality kernel, so it can never collide across tiers (the OCCT twin
|
|
151
|
+
// key omits it for the same reason); it just spells out that the ball
|
|
152
|
+
// tessellation, and so the result, is tier-dependent.
|
|
153
|
+
return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
|
|
154
|
+
},
|
|
146
155
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
147
156
|
() => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
148
157
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Morphological whole-solid rounding on the mesh backend: close-then-open with
|
|
2
|
+
// a ball — dilate(+r), erode(2r), dilate(+r) via Manifold's native Minkowski
|
|
3
|
+
// sum/difference. Rounds EVERY edge (convex and concave) at radius ≈ r and
|
|
4
|
+
// consumes features smaller than the ball (walls < 2r melt, holes < 2r seal) —
|
|
5
|
+
// that is the op's contract, not a defect (docs/roundall-design.md).
|
|
6
|
+
//
|
|
7
|
+
// simplify() between steps is mandatory, not an optimization: the naive
|
|
8
|
+
// Minkowski (hull-of-triangle-pairs) emits sliver-degenerate meshes whose
|
|
9
|
+
// complexity compounds through the chain — the design spike measured 106s and
|
|
10
|
+
// 761k broken-topology triangles without it vs 2.4s and 340 clean triangles
|
|
11
|
+
// with it, on the same torture case. asOriginal() first, because simplify()
|
|
12
|
+
// will not collapse triangles across run (originalID) boundaries and the
|
|
13
|
+
// Minkowski output is stitched from many.
|
|
14
|
+
//
|
|
15
|
+
// This op must NEVER throw KernelCapabilityError / NEEDS_OCCT: the mesh
|
|
16
|
+
// backend is roundAll's reference implementation — rerouting to OCCT would
|
|
17
|
+
// trade a correct result for a skip (occt-roundall.js can only skip where
|
|
18
|
+
// morphology exceeds what B-rep offsets support).
|
|
19
|
+
//
|
|
20
|
+
// INVARIANT — both balls share ONE segment count. Minkowski support functions
|
|
21
|
+
// add, so the chain displaces a face with normal n by 2·h_r(n) − h_2r(n), where
|
|
22
|
+
// h is the ball's support. Manifold spheres built with equal `segs` are similar
|
|
23
|
+
// (the 2r ball is the r ball scaled by two), so h_2r = 2·h_r and the term is
|
|
24
|
+
// exactly zero in EVERY direction — the input's planar faces return to their
|
|
25
|
+
// original planes. Give the two balls different segment counts and the term is
|
|
26
|
+
// only zero where a vertex happens to line up: axis-aligned boxes still look
|
|
27
|
+
// right while off-axis faces drift (measured 0.07mm at preview, r=2). The count
|
|
28
|
+
// is sized from the erosion ball (2r), the larger of the two, so the coarser of
|
|
29
|
+
// the two facetings still meets the tier's sagitta tolerance.
|
|
30
|
+
|
|
31
|
+
// Sphere tessellation from the facet sagitta r·(1 − cos(π/segs)): pick the
|
|
32
|
+
// fewest segments that keep it under the quality tier's tolerance.
|
|
33
|
+
const SAGITTA_TOL = { preview: 0.05, print: 0.01 }; // mm
|
|
34
|
+
export function roundAllSegs(r, quality) {
|
|
35
|
+
const tol = SAGITTA_TOL[quality] ?? SAGITTA_TOL.preview;
|
|
36
|
+
if (!(r > tol)) return 12;
|
|
37
|
+
return Math.min(64, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - tol / r))));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function meshRoundAll(wasm, m, r, quality) {
|
|
41
|
+
if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
|
|
42
|
+
const segs = roundAllSegs(2 * r, quality); // ONE count for BOTH balls — see the invariant above
|
|
43
|
+
// Simplify tolerance: max(r/100, 0.01) mm, but never enough to collapse the
|
|
44
|
+
// rim ring of a rounded edge into the next ring up. That ring sits
|
|
45
|
+
// r·(1 − cos(2π/segs)) above the face plane, and it is what holds a planar
|
|
46
|
+
// face at its exact position; collapsing it shaves the face inward (print
|
|
47
|
+
// tier, r = 2: segs 45 puts the ring 0.0195 mm up, and a flat 0.02 tolerance
|
|
48
|
+
// pulled every face of a box in by 0.034 mm). Half that spacing keeps margin.
|
|
49
|
+
const tol = Math.min(Math.max(r / 100, 0.01), 0.5 * r * (1 - Math.cos((2 * Math.PI) / segs)));
|
|
50
|
+
const step = (input, sphere, op) => {
|
|
51
|
+
const raw = op === "sum" ? input.minkowskiSum(sphere) : input.minkowskiDifference(sphere);
|
|
52
|
+
let orig;
|
|
53
|
+
try {
|
|
54
|
+
orig = raw.asOriginal();
|
|
55
|
+
} finally {
|
|
56
|
+
raw.delete?.();
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return orig.simplify(tol);
|
|
60
|
+
} finally {
|
|
61
|
+
orig.delete?.();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const sphR = wasm.Manifold.sphere(r, segs);
|
|
65
|
+
const sph2R = wasm.Manifold.sphere(2 * r, segs);
|
|
66
|
+
try {
|
|
67
|
+
const a = step(m, sphR, "sum"); // dilate: rounds convex, seals holes < 2r
|
|
68
|
+
let b;
|
|
69
|
+
try {
|
|
70
|
+
b = step(a, sph2R, "diff"); // erode 2r: melts walls < 2r
|
|
71
|
+
} finally {
|
|
72
|
+
a.delete?.();
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return step(b, sphR, "sum"); // dilate back: final radius ≈ r everywhere
|
|
76
|
+
} finally {
|
|
77
|
+
b.delete?.();
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
sphR.delete?.();
|
|
81
|
+
sph2R.delete?.();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -21,6 +21,7 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
21
21
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
22
22
|
import { finishKernel } from "./kernel-front.js";
|
|
23
23
|
import { createOcctRepair } from "./occt-repair.js";
|
|
24
|
+
import { occtRoundAll } from "./occt-roundall.js";
|
|
24
25
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
25
26
|
import { resolveRings } from "./loft.js";
|
|
26
27
|
import { resolveSweepStations } from "./sweep.js";
|
|
@@ -223,6 +224,14 @@ export function createOcctKernel(replicad) {
|
|
|
223
224
|
return wrap(validChamfer(a._s, toEdgeFinder(selector), distance), cloneLabels(a._labels), key);
|
|
224
225
|
});
|
|
225
226
|
},
|
|
227
|
+
roundAll: (r) => {
|
|
228
|
+
const key = h("roundAll", hash, r);
|
|
229
|
+
return cached(key, () => {
|
|
230
|
+
const a = mat();
|
|
231
|
+
if (r === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
|
|
232
|
+
return wrap(occtRoundAll(replicad, a._s, r), cloneLabels(a._labels), key);
|
|
233
|
+
});
|
|
234
|
+
},
|
|
226
235
|
shell: (thickness, openFaces) => {
|
|
227
236
|
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
228
237
|
const key = h("shell", hash, thickness, selKey(openFaces));
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// B-rep side of roundAll: dilate(+r), erode(-2r), dilate(+r) via raw OCCT
|
|
2
|
+
// BRepOffsetAPI_MakeOffsetShape (replicad has no solid-offset wrapper). Each
|
|
3
|
+
// step runs a variant cascade — the design spike showed no single parameter
|
|
4
|
+
// combo survives all shapes: plain solids dilate with Arc/Intersection=false,
|
|
5
|
+
// the chained erosion only succeeds with Arc/Intersection=true, and concave
|
|
6
|
+
// post-boolean solids only dilate with the Intersection join (morphologically
|
|
7
|
+
// fine: the FINAL Arc dilation supplies the convex rounding).
|
|
8
|
+
//
|
|
9
|
+
// A candidate is accepted only if IsDone, non-null, closed (mesh check),
|
|
10
|
+
// finite positive volume, AND volume-monotonic (dilation must not shrink,
|
|
11
|
+
// erosion must not grow). The monotonic gate is load-bearing: OCCT offsets
|
|
12
|
+
// return BRepCheck-valid garbage — the spike saw an erosion "succeed" at 0.4%
|
|
13
|
+
// of the input volume, and a sealed hole come back as an 11% crater. A gated
|
|
14
|
+
// failure skips the WHOLE op (warn + clone), mirroring safeOp's fillet policy
|
|
15
|
+
// and for the same reason: OCCT offset failures are not monotonic in r, so
|
|
16
|
+
// searching for a "largest working radius" would converge on garbage.
|
|
17
|
+
// Feature-consuming radii (r at/above the smallest feature) are the expected
|
|
18
|
+
// skip trigger — true consumption is mesh-class-only (docs/roundall-design.md).
|
|
19
|
+
//
|
|
20
|
+
// Every WASM object made here is freed, same convention as occt-repair.js, so
|
|
21
|
+
// OCCT's heap doesn't grow across regenerates: up to twelve offset builders and
|
|
22
|
+
// progress ranges, every rejected candidate, and each superseded intermediate.
|
|
23
|
+
// The one thing never freed is the caller's `shape` — it belongs to the caller,
|
|
24
|
+
// and the skip path hands back a clone of it.
|
|
25
|
+
import { isClosedSolid } from "./occt-repair.js";
|
|
26
|
+
|
|
27
|
+
const VARIANTS = [
|
|
28
|
+
{ join: "arc", inter: false },
|
|
29
|
+
{ join: "arc", inter: true },
|
|
30
|
+
{ join: "int", inter: false },
|
|
31
|
+
{ join: "int", inter: true },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function occtRoundAll(replicad, shape, r) {
|
|
35
|
+
if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
|
|
36
|
+
const oc = replicad.getOC();
|
|
37
|
+
const tryOffset = (topo, offset, v) => {
|
|
38
|
+
const progress = new oc.Message_ProgressRange_1();
|
|
39
|
+
const mk = new oc.BRepOffsetAPI_MakeOffsetShape();
|
|
40
|
+
try {
|
|
41
|
+
mk.PerformByJoin(topo, offset, 1e-6,
|
|
42
|
+
oc.BRepOffset_Mode.BRepOffset_Skin, v.inter, false,
|
|
43
|
+
v.join === "arc" ? oc.GeomAbs_JoinType.GeomAbs_Arc : oc.GeomAbs_JoinType.GeomAbs_Intersection,
|
|
44
|
+
false, progress);
|
|
45
|
+
if (!mk.IsDone()) return null;
|
|
46
|
+
const s = mk.Shape();
|
|
47
|
+
// Wrap BEFORE the finally frees the builder — replicad's own idiom for this
|
|
48
|
+
// very algorithm (its `offset()` does `cast(offsetBuilder.Shape()); offsetBuilder.delete()`).
|
|
49
|
+
// The wrapper carries its own TopoDS_Shape handle, so the result outlives `mk`.
|
|
50
|
+
return s.IsNull() ? null : new replicad.Solid(s);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
} finally {
|
|
54
|
+
mk.delete?.();
|
|
55
|
+
progress.delete?.();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
let vol;
|
|
59
|
+
try {
|
|
60
|
+
vol = replicad.measureVolume(shape);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Can't gate what can't be measured — skip rather than run the cascade blind.
|
|
63
|
+
console.warn(`partforge: roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
|
|
64
|
+
return shape.clone(); // if the clone throws too, the caller's shape is unusable — let it propagate
|
|
65
|
+
}
|
|
66
|
+
let cur = shape;
|
|
67
|
+
for (const off of [r, -2 * r, r]) {
|
|
68
|
+
let next = null;
|
|
69
|
+
for (const v of VARIANTS) {
|
|
70
|
+
const cand = tryOffset(cur.wrapped, off, v);
|
|
71
|
+
if (!cand) continue;
|
|
72
|
+
let cvol;
|
|
73
|
+
try { cvol = replicad.measureVolume(cand); } catch { cand.delete?.(); continue; }
|
|
74
|
+
if (!Number.isFinite(cvol) || cvol <= 0) { cand.delete?.(); continue; }
|
|
75
|
+
if (off > 0 && cvol < vol * 0.999) { cand.delete?.(); continue; } // dilation shrank: garbage
|
|
76
|
+
if (off < 0 && cvol > vol * 1.001) { cand.delete?.(); continue; } // erosion grew: garbage
|
|
77
|
+
// isClosedSolid meshes the candidate, and meshing OCCT offset garbage can
|
|
78
|
+
// throw — that is just another rejected candidate, not an escape hatch out
|
|
79
|
+
// of "roundAll never throws for geometry".
|
|
80
|
+
try { if (!isClosedSolid(cand)) { cand.delete?.(); continue; } }
|
|
81
|
+
catch { cand.delete?.(); continue; }
|
|
82
|
+
next = cand;
|
|
83
|
+
vol = cvol;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
|
|
87
|
+
if (!next) {
|
|
88
|
+
console.warn(`partforge: roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
|
|
89
|
+
return shape.clone();
|
|
90
|
+
}
|
|
91
|
+
cur = next;
|
|
92
|
+
}
|
|
93
|
+
return cur;
|
|
94
|
+
}
|
|
@@ -289,6 +289,8 @@ export const SOLID_OP_SPECS = {
|
|
|
289
289
|
return [req("chamfer", o, "d"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
|
|
290
290
|
shell: { toArgs: (o) => { checkKeys("shell", o, ["t", "open"]);
|
|
291
291
|
return [req("shell", o, "t"), req("shell", o, "open")]; } },
|
|
292
|
+
roundAll: { toArgs: (o) => { checkKeys("roundAll", o, ["r"]);
|
|
293
|
+
return [req("roundAll", o, "r")]; } },
|
|
292
294
|
};
|
|
293
295
|
|
|
294
296
|
// A zero-magnitude fillet/chamfer is the identity on every conformance class
|
package/types/kernel.d.ts
CHANGED
|
@@ -269,6 +269,12 @@ export interface Solid {
|
|
|
269
269
|
* Closed (no open face) hollows are not supported.
|
|
270
270
|
*/
|
|
271
271
|
shell(o: { t: number; open: FaceSelector }): Solid;
|
|
272
|
+
/**
|
|
273
|
+
* Morphological close-then-open with a ball of radius `r`: rounds EVERY edge
|
|
274
|
+
* (convex and concave). Implemented natively on both backends — never routes,
|
|
275
|
+
* never throws `KernelCapabilityError`. `roundAll(0)` is the identity.
|
|
276
|
+
*/
|
|
277
|
+
roundAll(r: number | { r: number }): Solid;
|
|
272
278
|
/** Through-hole count (Manifold only). */
|
|
273
279
|
genus?(): number;
|
|
274
280
|
/** No geometry at all (Manifold only). */
|