partforge 0.164.0 → 0.165.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 +50 -3
- package/docs/ERROR-PATTERNS.md +18 -0
- package/docs/KERNEL-CONTRACT.md +16 -0
- package/package.json +1 -1
- package/src/framework/geometry/kernel.js +6 -1
- package/src/framework/geometry/manifold-backend.js +20 -3
- package/src/framework/geometry/mesh-roundall.js +54 -0
- package/src/framework/geometry/occt-backend.js +11 -1
- package/src/framework/geometry/occt-repair.js +8 -1
- package/src/framework/geometry/occt-roundall.js +54 -21
- package/src/framework/geometry/op-options.js +2 -0
- package/src/framework/geometry/shape2d-sugar.js +12 -7
- package/types/kernel.d.ts +10 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1761,8 +1761,10 @@ const plate = k.shape2d(roundedRectProfile(40, 24, 4))
|
|
|
1761
1761
|
.extrude({ h: 3 }); // sugar for k.extrude({ profile: …, h: 3 }); .revolve({ degrees }) too
|
|
1762
1762
|
```
|
|
1763
1763
|
|
|
1764
|
-
A `Shape2D` also carries `.extrude({ h, twist?, scaleTop? })` and `.revolve({ degrees? })`
|
|
1765
|
-
sugar (equivalent to the `k.extrude`/`k.revolve` forms
|
|
1764
|
+
A `Shape2D` also carries `.extrude({ h, twist?, scaleTop?, bevel? })` and `.revolve({ degrees? })`
|
|
1765
|
+
sugar (equivalent to the `k.extrude`/`k.revolve` forms — every option is passed through, so
|
|
1766
|
+
`shape.extrude({ h, bevel: 1 })` bevels the rims exactly as `k.extrude` does; before partforge
|
|
1767
|
+
0.163.2 the sugar dropped `bevel` and any unknown key without a word), and `.regions()` — scission, which
|
|
1766
1768
|
returns each disjoint region as its own live `Shape2D` (vs `.toRegions()`, which returns raw
|
|
1767
1769
|
`{outer, holes}` data).
|
|
1768
1770
|
|
|
@@ -1774,7 +1776,7 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
|
|
|
1774
1776
|
|
|
1775
1777
|
(This achieves the same geometry as building the profiles separately and using `k.extrude({ profile: { outer, holes }, h })`, but the Shape2D path is more idiomatic for complex 2-D operations.)
|
|
1776
1778
|
|
|
1777
|
-
`Shape2D.offset(delta, { corners: "round" | "chamfer" | "sharp" })` grows (`delta>0`) or insets (`delta<0`) a shape. It runs backend-independently on the shared contour engine — lines and arcs offset exactly (arcs stay arcs), so results are backend-identical by construction, like every other `Shape2D` op; it throws if the offset collapses the shape. A region with holes offsets material-wise: the outer grows/shrinks by `delta`, holes by `-delta`, so a positive `delta` always adds material. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
|
|
1779
|
+
`Shape2D.offset(delta, { corners: "round" | "chamfer" | "sharp" })` grows (`delta>0`) or insets (`delta<0`) a shape. It runs backend-independently on the shared contour engine — lines and arcs offset exactly (arcs stay arcs), so results are backend-identical by construction, like every other `Shape2D` op; it throws if the offset collapses the shape. A region with holes offsets material-wise: the outer grows/shrinks by `delta`, holes by `-delta`, so a positive `delta` always adds material. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.) The 3-D counterpart is `Solid.offset(d)` — a ball offset of a whole solid (see [`offset(d)`](#offsetd--grow-or-shrink-a-solid)).
|
|
1778
1780
|
|
|
1779
1781
|
## Editing profiles
|
|
1780
1782
|
|
|
@@ -4766,6 +4768,7 @@ at runtime — no declaration needed either way:
|
|
|
4766
4768
|
| `s.fillet(radius)` · `s.fillet({ r, edges? })` | round edges (curve-following, exact); the bare-number scalar shorthand fillets **all** edges, the options form adds a selector |
|
|
4767
4769
|
| `s.chamfer(distance)` · `s.chamfer({ d, edges? })` | bevel edges; same scalar-shorthand-or-options-with-selector shape as `fillet` |
|
|
4768
4770
|
| `s.shell({ t, open })` | hollow inward, wall = `t`; `open` selector (`{inPlane,at}`/`{dir}`/`{near}`) chooses which face(s) to open. Closed (no-open-face) hollows are not supported. |
|
|
4771
|
+
| `s.offset(d)` · `s.offset({ d })` | grow (`d > 0`) or shrink (`d < 0`) the whole solid by `d` — a 3-D ball offset on both backends, imported meshes included; no OCCT routing. A closed constant-thickness skin is `body.cut(body.offset(-t))`. See [`offset(d)`](#offsetd--grow-or-shrink-a-solid) below. |
|
|
4769
4772
|
|
|
4770
4773
|
`edges` (fillet/chamfer) / `open` (shell) chooses which edges/faces (omit `edges` for **all** edges — `shell` always requires `open`):
|
|
4771
4774
|
|
|
@@ -4885,6 +4888,50 @@ Rules of thumb:
|
|
|
4885
4888
|
- A small ridge remaining where a thin rib was consumed is correct morphology
|
|
4886
4889
|
(the rib's dilation fillet survives the opening), not a bug.
|
|
4887
4890
|
|
|
4891
|
+
### `offset(d)` — grow or shrink a solid
|
|
4892
|
+
|
|
4893
|
+
`s.offset(2)` (or `s.offset({ d: 2 })`) grows the solid by 2 mm in every
|
|
4894
|
+
direction; `s.offset(-2)` shrinks it by 2 mm. Every point of the new surface is
|
|
4895
|
+
`|d|` from the old one — it is a true ball offset, the 3-D counterpart of
|
|
4896
|
+
`Shape2D.offset` — so it works on any solid, including an imported STL/3MF mesh,
|
|
4897
|
+
and it runs on both backends without routing. `offset(0)` is the identity.
|
|
4898
|
+
|
|
4899
|
+
Its main use is a skin of constant thickness `t`, which `shell` cannot make for
|
|
4900
|
+
a closed body or an imported mesh:
|
|
4901
|
+
|
|
4902
|
+
```js
|
|
4903
|
+
const helmet = k.import("helmet");
|
|
4904
|
+
const skin = helmet.cut(helmet.offset(-2)); // 2 mm thick everywhere, inside the helmet
|
|
4905
|
+
const cage = helmet.offset(1.5).cut(helmet); // 1.5 mm thick everywhere, outside it
|
|
4906
|
+
```
|
|
4907
|
+
|
|
4908
|
+
Do not build a skin from axis-shifted copies (`translate` by `±t` and union):
|
|
4909
|
+
that is six booleans, and the wall comes out only `t/√2` to `t/√3` thick on
|
|
4910
|
+
diagonal surfaces. `offset` is one op and holds `t` in every direction.
|
|
4911
|
+
|
|
4912
|
+
**Corners.** A ball offset rounds what it moves away from: on a grow, convex
|
|
4913
|
+
edges and corners become arcs of radius `d` (a box grows into a rounded box);
|
|
4914
|
+
on a shrink, concave edges become arcs of radius `|d|` and convex corners stay
|
|
4915
|
+
sharp (a box shrinks into a smaller sharp box). Flat faces stay flat and move
|
|
4916
|
+
`|d|` along their normal. For sharp grown corners, offset the 2-D profile
|
|
4917
|
+
(`Shape2D.offset(d, { corners: "sharp" })`) and extrude it instead.
|
|
4918
|
+
|
|
4919
|
+
**Thin features.** A shrink deeper than half a wall removes that wall, and a
|
|
4920
|
+
shrink past half the solid's thickness leaves nothing (an empty solid on
|
|
4921
|
+
Manifold). On OCCT (STEP export, or a sub-part that a `shell` routes there) a
|
|
4922
|
+
B-rep offset cannot change topology, so an offset it cannot build **throws**
|
|
4923
|
+
`offset-failed` — it never returns the un-offset solid, because a skin cut from
|
|
4924
|
+
that would be empty. See `docs/ERROR-PATTERNS.md`.
|
|
4925
|
+
|
|
4926
|
+
**Cost note.** The mesh backend computes the offset as a Minkowski sum (grow)
|
|
4927
|
+
or difference (shrink) with a ball. On a non-convex solid each input triangle
|
|
4928
|
+
is hulled with the ball, so time grows with the triangle count — about 3 ms per
|
|
4929
|
+
triangle on a desktop (a 576-triangle dome ≈ 2 s, 8,448 triangles ≈ 23 s). An
|
|
4930
|
+
imported scan of 100k triangles is minutes. Decimate a dense import before you
|
|
4931
|
+
offset it, and offset once and reuse the result rather than offsetting inside a
|
|
4932
|
+
loop. The ball's own tessellation follows the quality tier (preview is coarser,
|
|
4933
|
+
so the wall can run up to ~0.1 mm under `t` on a diagonal; print is tighter).
|
|
4934
|
+
|
|
4888
4935
|
### Cost on the OCCT path: fillet/chamfer scale with edge count — and order matters
|
|
4889
4936
|
|
|
4890
4937
|
These costs apply when a sub-part **does** run on OCCT (a shell, an unsupported edge
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -132,6 +132,24 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
132
132
|
- **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.
|
|
133
133
|
- **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.
|
|
134
134
|
|
|
135
|
+
## offset-failed
|
|
136
|
+
|
|
137
|
+
- **Symptom:** the build throws `offset-failed: offset(<d>) produced no valid solid on the OCCT backend — …` (STEP export, or a sub-part a `shell` routes to OCCT).
|
|
138
|
+
- **Cause:** the B-rep backend implements `Solid.offset` as one OCCT offset, which cannot change topology: a shrink at or past half the thinnest wall (or a grow that would close a gap or hole narrower than `2d`) has no valid B-rep result. Unlike `roundAll`, `offset` never skips — returning the un-offset solid would make `body.cut(body.offset(-t))` empty without any sign of a problem — so it throws.
|
|
139
|
+
- **Fix:** keep `|d|` below half the smallest wall/gap; or run the sub-part on Manifold (`meta.backend: "manifold"`, or drop the `shell` that routes it), whose Minkowski offset consumes thin features correctly. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "`offset(d)` — grow or shrink a solid".
|
|
140
|
+
|
|
141
|
+
## offset-slow
|
|
142
|
+
|
|
143
|
+
- **Symptom:** a build that calls `Solid.offset` takes many seconds or minutes, most often on an imported STL/3MF.
|
|
144
|
+
- **Cause:** the Manifold backend's offset is a Minkowski sum/difference with a ball; on a non-convex solid every input triangle is hulled with the ball, about 3 ms per triangle (8,448 triangles ≈ 23 s; a 100k-triangle scan is minutes).
|
|
145
|
+
- **Fix:** decimate the mesh before you import it, offset once and reuse the result, and do not offset inside a loop. Where the solid is an extrusion, offset the 2-D profile (`Shape2D.offset`) and extrude it instead — that is near-instant.
|
|
146
|
+
|
|
147
|
+
## solid-offset-not-a-function
|
|
148
|
+
|
|
149
|
+
- **Symptom:** `TypeError: <name>.offset is not a function` on a Solid (an extruded, imported or boolean result).
|
|
150
|
+
- **Cause:** partforge before 0.165 had `offset` only on `Shape2D`; `Solid.offset` was added in 0.165.0.
|
|
151
|
+
- **Fix:** update partforge to 0.165.0 or later. Do not fall back to unioning axis-shifted copies of the solid — that makes a wall only `t/√3` thick on diagonal surfaces.
|
|
152
|
+
|
|
135
153
|
## boolean-not-watertight
|
|
136
154
|
|
|
137
155
|
- **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -80,6 +80,15 @@ Its coverage and tolerance band are part of the contract:
|
|
|
80
80
|
labels upstream of the call do not survive through it (attribution uses the
|
|
81
81
|
fallback path), and the result shades with the default SMOOTH policy.
|
|
82
82
|
|
|
83
|
+
`offset` is required on every class too and is **parity-tolerant** while the
|
|
84
|
+
offset does not change topology (a shrink less than half the thinnest wall, a
|
|
85
|
+
grow that closes no gap): the mesh class computes it with one Minkowski pass
|
|
86
|
+
(roundAll's sphere tessellation rule, sized from `|d|`) and a B-rep class with
|
|
87
|
+
one native offset (`BRepOffsetAPI_MakeOffsetShape`, arc join). Past that, the
|
|
88
|
+
mesh class changes topology correctly and a B-rep class throws `offset-failed`
|
|
89
|
+
— unlike `roundAll`, skipping is NOT a permitted degrade, because the un-offset
|
|
90
|
+
solid is a wrong answer rather than a missing feature.
|
|
91
|
+
|
|
83
92
|
`roundAll` is required on **every** class (it is in `SOLID_OPS`, not
|
|
84
93
|
`OCCT_ONLY_OPS`) and is **parity-tolerant with a regime split**: while `r` is
|
|
85
94
|
strictly below the solid's smallest feature size (min wall, min hole
|
|
@@ -379,6 +388,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
|
|
|
379
388
|
| `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. |
|
|
380
389
|
| `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, circular-arc, planar-rim and curved-face 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. |
|
|
381
390
|
| `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. |
|
|
391
|
+
| `offset(d)` · `offset({d})` | 3-D ball offset — the solid counterpart of `Shape2D.offset`: `d > 0` grows the solid by `d` (Minkowski sum with a ball), `d < 0` shrinks it by `\|d\|` (Minkowski difference). Every point of the result's surface is `\|d\|` from the input's, within the class's tolerance band (the mesh class's faceted ball sits up to its sagitta inside the true ball, so diagonal faces land slightly short of `\|d\|`). Corners: a grow rounds convex edges/corners at radius `d` and keeps concave ones sharp; a shrink rounds concave edges at `\|d\|` and keeps convex ones sharp. Implemented natively on BOTH classes — never routes, never throws `KernelCapabilityError`; works on imported meshes on the mesh class. A shrink past the solid's half-thickness yields an empty solid on the mesh class. A B-rep class MUST NOT return the input unchanged on failure (a skin `body.cut(body.offset(-t))` would silently come out empty): it throws an error whose message starts `offset-failed:`. `offset(0)` is the identity on every class. Additive in 0.165 with no contract bump (the `loftSmooth` precedent). |
|
|
382
392
|
|
|
383
393
|
`quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
|
|
384
394
|
speed and a backend may bake it at kernel creation (Manifold does). A part must never
|
|
@@ -942,6 +952,12 @@ in `kernel.js` define the current surface; only breaking changes bump the versio
|
|
|
942
952
|
`cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
|
|
943
953
|
transfer. Renames are breaking changes with no offsetting benefit — don't.
|
|
944
954
|
|
|
955
|
+
**Additive within v4** (partforge 0.165): `Solid.offset` joins `SOLID_OPS` — a 3-D
|
|
956
|
+
ball offset (`d > 0` grows, `d < 0` shrinks), on both in-repo classes. No contract
|
|
957
|
+
bump, following the `loftSmooth`/`heightfield` precedent for an op both in-repo
|
|
958
|
+
backends implement; a third-party core kernel must add it to stay conforming
|
|
959
|
+
(Manifold's `minkowskiSum`/`minkowskiDifference` with a sphere is the reference).
|
|
960
|
+
|
|
945
961
|
**v3 → v4** (partforge 0.63): `Solid.roundAll` is added to `SOLID_OPS` (portable
|
|
946
962
|
morphological rounding — rounds every edge with a ball of radius `r`;
|
|
947
963
|
parity-tolerant, regime-split semantics, see
|
package/package.json
CHANGED
|
@@ -51,6 +51,9 @@ export const SOLID_OPS = [
|
|
|
51
51
|
"translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
|
|
52
52
|
"mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
|
|
53
53
|
"fillet", "chamfer", "shell", "roundAll",
|
|
54
|
+
// Additive in 0.165 (no CONTRACT_VERSION bump — the loftSmooth/heightfield
|
|
55
|
+
// precedent): both in-repo backends implement it.
|
|
56
|
+
"offset",
|
|
54
57
|
];
|
|
55
58
|
|
|
56
59
|
// Backend-optional Solid queries: Manifold mesh-topology numbers (measure.js
|
|
@@ -109,6 +112,8 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
109
112
|
* @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges; mesh-native incl. curved-face edges, OCCT fallback otherwise; fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
|
|
110
113
|
* @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges; mesh-native incl. curved-face edges, OCCT fallback otherwise; chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
|
|
111
114
|
* @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted for now (see file header)
|
|
115
|
+
* @property {(r:number|{r:number}) => Solid} roundAll morphological close-then-open with a ball of radius r: rounds every edge (both backends)
|
|
116
|
+
* @property {(d:number|{d:number}) => Solid} offset 3-D ball offset: grow (+d) / shrink (−d) the solid by |d| everywhere — convex edges round on a grow, concave on a shrink; both backends; the 2-D twin is Shape2D.offset
|
|
112
117
|
* @property {() => number} [genus] through-hole count (Manifold only)
|
|
113
118
|
* @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
|
|
114
119
|
*
|
|
@@ -135,7 +140,7 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
135
140
|
* @property {(tolerance:number) => Shape2D} simplify corner-preserving decimation/refit within tolerance
|
|
136
141
|
* @property {() => {index:number,point:number[],interiorAngleDeg:number,convex:boolean,segTypes:string[]}[]} corners corner list (the positional order fillet/chamfer `{indices}` index into)
|
|
137
142
|
* @property {(p:number[]) => boolean} contains is point [x,y] inside the shape (holes excluded)
|
|
138
|
-
* @property {(o?:{h:number,twist?:number,scaleTop?:number}) => Solid} extrude sugar for k.extrude({profile:this,…})
|
|
143
|
+
* @property {(o?:{h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude sugar for k.extrude({profile:this,…}); every option is forwarded
|
|
139
144
|
* @property {(o?:{degrees?:number}) => Solid} revolve sugar for k.revolve({profile:this,…})
|
|
140
145
|
*
|
|
141
146
|
* @typedef {Object} GeometryKernel
|
|
@@ -19,7 +19,7 @@ import { creasedNormals } from "./creased-normals.js";
|
|
|
19
19
|
import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
|
|
20
20
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
21
21
|
import { affineAt, IDENTITY, invertAffine, mapSurface } from "./blend-surfaces.js";
|
|
22
|
-
import { flatFacePlanes, meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
|
|
22
|
+
import { flatFacePlanes, meshOffset, meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
|
|
23
23
|
import { SEGS, circleSegs, doubleCurvatureSegs } from "./circle-segs.js";
|
|
24
24
|
import { checkBooleanResult } from "./boolean-gate.js";
|
|
25
25
|
import { KernelCapabilityError } from "./errors.js";
|
|
@@ -462,7 +462,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
462
462
|
// meshRoundAll's simplify tolerance protects), so only the face and its seam
|
|
463
463
|
// vertices are on a plane. Keyed on geometry like every descriptor (see
|
|
464
464
|
// blendSurfaces above), so it is never removed with the cache entry.
|
|
465
|
-
|
|
465
|
+
//
|
|
466
|
+
// offset() reuses it with `moveTo`: a single dilate/erode step moves each flat
|
|
467
|
+
// face along its normal instead of returning it to its plane, so the planes are
|
|
468
|
+
// moved to where the step put them before they are registered. `r` and `segs`
|
|
469
|
+
// are the ball the step used (the tolerance rule is the same).
|
|
470
|
+
const markFlatFaces = (input, out, r, segs = roundAllSegs(2 * r, quality), moveTo = null) => {
|
|
466
471
|
const gi = input.getMesh();
|
|
467
472
|
let planes;
|
|
468
473
|
try {
|
|
@@ -470,8 +475,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
470
475
|
} finally {
|
|
471
476
|
gi.delete?.();
|
|
472
477
|
}
|
|
478
|
+
if (moveTo) planes = planes.map(moveTo);
|
|
473
479
|
if (!planes.length) return out;
|
|
474
|
-
const segs = roundAllSegs(2 * r, quality);
|
|
475
480
|
const tol = Math.min(1e-3, 0.25 * r * (1 - Math.cos((2 * Math.PI) / segs)));
|
|
476
481
|
const go = out.getMesh();
|
|
477
482
|
try {
|
|
@@ -569,6 +574,18 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
569
574
|
return cached(h("roundAll", hash, r, quality), () =>
|
|
570
575
|
prismRoundAllFast(m, hash, r) ?? markFlatFaces(m, T(meshRoundAll(wasm, m, r, quality)), r));
|
|
571
576
|
},
|
|
577
|
+
// Ball offset (mesh-roundall.js meshOffset): one dilate or erode, never
|
|
578
|
+
// routed, never NEEDS_OCCT. An erode deeper than the solid's half-thickness
|
|
579
|
+
// consumes it — the empty result is the honest answer, not a failure.
|
|
580
|
+
offset: (d) => {
|
|
581
|
+
if (!Number.isFinite(d)) throw new Error(`offset: d must be a finite number (got ${d})`);
|
|
582
|
+
if (d === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
583
|
+
return cached(h("offset", hash, d, quality), () => {
|
|
584
|
+
const { out, support, segs: ballSegs } = meshOffset(wasm, m, d, quality);
|
|
585
|
+
const sign = Math.sign(d); // a dilate moves a face out by h(n), an erode in by h(n)
|
|
586
|
+
return markFlatFaces(m, T(out), Math.abs(d), ballSegs, (p) => ({ n: p.n, d: p.d + sign * support(p.n) }));
|
|
587
|
+
});
|
|
588
|
+
},
|
|
572
589
|
// batch difference: first minus the union of the rest, evaluated as one boolean
|
|
573
590
|
// tree — no materialized intermediate union (the unionRaw memory note applies)
|
|
574
591
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)), () => {
|
|
@@ -84,6 +84,60 @@ export function meshRoundAll(wasm, m, r, quality) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Solid.offset on the mesh backend: ONE step of the chain above — a dilate
|
|
89
|
+
// (d > 0, Minkowski sum) or an erode (d < 0, Minkowski difference) with a ball of
|
|
90
|
+
// radius |d|. A true ball offset, so every point of the result's surface sits |d|
|
|
91
|
+
// from the input's (within the ball's facet sagitta): convex edges and corners
|
|
92
|
+
// round at radius d on a dilate, concave ones round at |d| on an erode, and the
|
|
93
|
+
// other kind stays sharp. Same asOriginal-then-simplify cleanup as each roundAll
|
|
94
|
+
// step, for the same sliver reason; the tolerance is roundAll's too, so the rim
|
|
95
|
+
// ring that holds an offset face on its plane survives the simplify.
|
|
96
|
+
//
|
|
97
|
+
// Cost is the Minkowski's: on a non-convex input every triangle is hulled with
|
|
98
|
+
// the ball, so time grows with the input's triangle count — measured ~3 ms per
|
|
99
|
+
// input triangle on a dome (576 tris ≈ 2 s, 8,448 ≈ 23 s). The ball count is
|
|
100
|
+
// sized from |d| alone, the one ball the op uses.
|
|
101
|
+
export function meshOffset(wasm, m, d, quality) {
|
|
102
|
+
if (!Number.isFinite(d) || d === 0) throw new Error("offset: d must be a finite non-zero number (d = 0 is handled as the identity by the caller)");
|
|
103
|
+
const r = Math.abs(d);
|
|
104
|
+
const segs = roundAllSegs(r, quality);
|
|
105
|
+
const tol = Math.min(Math.max(r / 100, 0.01), 0.5 * r * (1 - Math.cos((2 * Math.PI) / segs)));
|
|
106
|
+
const ball = wasm.Manifold.sphere(r, segs);
|
|
107
|
+
try {
|
|
108
|
+
const raw = d > 0 ? m.minkowskiSum(ball) : m.minkowskiDifference(ball);
|
|
109
|
+
let orig;
|
|
110
|
+
try {
|
|
111
|
+
orig = raw.asOriginal();
|
|
112
|
+
} finally {
|
|
113
|
+
raw.delete?.();
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return { out: orig.simplify(tol), support: ballSupport(ball), segs };
|
|
117
|
+
} finally {
|
|
118
|
+
orig.delete?.();
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
ball.delete?.();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The faceted ball's support function h(n) = max over its vertices of n·v: how
|
|
126
|
+
// far the offset actually moves a planar face with outward normal n (a dilate
|
|
127
|
+
// puts it at n·x = d + h(n), an erode at d − h(n)). Equal to |d| where a ball
|
|
128
|
+
// vertex lines up with n (the axes), a sagitta short of it elsewhere — the flat-
|
|
129
|
+
// face shading below must place each plane where the face really landed.
|
|
130
|
+
function ballSupport(ball) {
|
|
131
|
+
const g = ball.getMesh();
|
|
132
|
+
const np = g.numProp, vp = Float64Array.from(g.vertProperties);
|
|
133
|
+
g.delete?.();
|
|
134
|
+
return (n) => {
|
|
135
|
+
let best = -Infinity;
|
|
136
|
+
for (let i = 0; i < vp.length; i += np) best = Math.max(best, n[0] * vp[i] + n[1] * vp[i + 1] + n[2] * vp[i + 2]);
|
|
137
|
+
return best;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
87
141
|
// ---------------------------------------------------------------------------
|
|
88
142
|
// Flat-face shading. The reference path's result is one fresh surface, shaded
|
|
89
143
|
// SMOOTH (35° crease), and the rounding ball's facets bend far less than that, so
|
|
@@ -25,7 +25,7 @@ import { groupInteractingRegions } from "./region-interaction.js";
|
|
|
25
25
|
import { finishKernel } from "./kernel-front.js";
|
|
26
26
|
import { makeProfileWarner } from "./profile-warnings.js";
|
|
27
27
|
import { createOcctRepair } from "./occt-repair.js";
|
|
28
|
-
import { occtRoundAll } from "./occt-roundall.js";
|
|
28
|
+
import { occtOffset, occtRoundAll } from "./occt-roundall.js";
|
|
29
29
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
30
30
|
import { emscriptenThrowEvidence, labelImportFailure } from "../kernel-error.js";
|
|
31
31
|
import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
|
|
@@ -412,6 +412,16 @@ export function createOcctKernel(replicad) {
|
|
|
412
412
|
return wrap(occtRoundAll(replicad, a._s, r, recordWarning), cloneLabels(a._labels), key);
|
|
413
413
|
});
|
|
414
414
|
},
|
|
415
|
+
// Never skips: a failed offset throws `offset-failed` (occt-roundall.js says why).
|
|
416
|
+
offset: (d) => {
|
|
417
|
+
if (!Number.isFinite(d)) throw new Error(`offset: d must be a finite number (got ${d})`);
|
|
418
|
+
const key = h("offset", hash, d);
|
|
419
|
+
return cached(key, () => {
|
|
420
|
+
const a = mat();
|
|
421
|
+
if (d === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
|
|
422
|
+
return wrap(occtOffset(replicad, a._s, d), cloneLabels(a._labels), key);
|
|
423
|
+
});
|
|
424
|
+
},
|
|
415
425
|
shell: (thickness, openFaces) => {
|
|
416
426
|
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
417
427
|
const key = h("shell", hash, thickness, selKey(openFaces));
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
// Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
|
|
16
16
|
// meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
|
|
17
17
|
// so weld vertices by position, then a closed solid has every edge shared by exactly
|
|
18
|
-
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
18
|
+
// two triangles. (A coarse mesh is enough — this is a topology check.) A triangle
|
|
19
|
+
// the weld collapses (two corners in one 1/32 mm cell — a sphere's pole fan, a
|
|
20
|
+
// sliver) has no area and bounds nothing, so it is left out of the count: kept,
|
|
21
|
+
// its self-edge counts once and its doubled edge three times, and every sphere
|
|
22
|
+
// read as open. A mesh the weld collapses ENTIRELY bounds nothing either, so it
|
|
23
|
+
// needs at least one surviving edge to count as closed.
|
|
19
24
|
export const isClosedSolid = (shape) => {
|
|
20
25
|
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
21
26
|
const P = m.vertices, T = m.triangles;
|
|
@@ -27,8 +32,10 @@ export const isClosedSolid = (shape) => {
|
|
|
27
32
|
const edges = new Map();
|
|
28
33
|
for (let t = 0; t < T.length / 3; t++) {
|
|
29
34
|
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
35
|
+
if (a === b || b === c || c === a) continue;
|
|
30
36
|
for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
|
|
31
37
|
}
|
|
38
|
+
if (edges.size === 0) return false;
|
|
32
39
|
for (const n of edges.values()) if (n !== 2) return false;
|
|
33
40
|
return true;
|
|
34
41
|
};
|
|
@@ -30,9 +30,15 @@ const VARIANTS = [
|
|
|
30
30
|
{ join: "int", inter: false },
|
|
31
31
|
{ join: "int", inter: true },
|
|
32
32
|
];
|
|
33
|
+
// Solid.offset's cascade: the Arc joins alone. An Intersection join extends the
|
|
34
|
+
// faces to sharp corners, which passes every gate yet is not a ball offset.
|
|
35
|
+
const ARC_VARIANTS = VARIANTS.filter((v) => v.join === "arc");
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
// One gated offset step: the variant cascade above, each candidate held to the
|
|
38
|
+
// IsDone / closed / positive-volume / volume-monotonic gate. Returns
|
|
39
|
+
// { solid, vol } or null when no variant survives. `variants` is the cascade
|
|
40
|
+
// order. Never frees `topoOwner` — the caller owns it.
|
|
41
|
+
function offsetStep(replicad, topoOwner, off, vol, variants = VARIANTS) {
|
|
36
42
|
const oc = replicad.getOC();
|
|
37
43
|
const tryOffset = (topo, offset, v) => {
|
|
38
44
|
const progress = new oc.Message_ProgressRange_1();
|
|
@@ -55,6 +61,26 @@ export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`p
|
|
|
55
61
|
progress.delete?.();
|
|
56
62
|
}
|
|
57
63
|
};
|
|
64
|
+
for (const v of variants) {
|
|
65
|
+
const cand = tryOffset(topoOwner.wrapped, off, v);
|
|
66
|
+
if (!cand) continue;
|
|
67
|
+
let cvol;
|
|
68
|
+
try { cvol = replicad.measureVolume(cand); } catch { cand.delete?.(); continue; }
|
|
69
|
+
if (!Number.isFinite(cvol) || cvol <= 0) { cand.delete?.(); continue; }
|
|
70
|
+
if (off > 0 && cvol < vol * 0.999) { cand.delete?.(); continue; } // dilation shrank: garbage
|
|
71
|
+
if (off < 0 && cvol > vol * 1.001) { cand.delete?.(); continue; } // erosion grew: garbage
|
|
72
|
+
// isClosedSolid meshes the candidate, and meshing OCCT offset garbage can
|
|
73
|
+
// throw — that is just another rejected candidate, not an escape hatch out
|
|
74
|
+
// of "roundAll never throws for geometry".
|
|
75
|
+
try { if (!isClosedSolid(cand)) { cand.delete?.(); continue; } }
|
|
76
|
+
catch { cand.delete?.(); continue; }
|
|
77
|
+
return { solid: cand, vol: cvol };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`partforge: ${msg}`)) {
|
|
83
|
+
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)");
|
|
58
84
|
let vol;
|
|
59
85
|
try {
|
|
60
86
|
vol = replicad.measureVolume(shape);
|
|
@@ -65,30 +91,37 @@ export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`p
|
|
|
65
91
|
}
|
|
66
92
|
let cur = shape;
|
|
67
93
|
for (const off of [r, -2 * r, r]) {
|
|
68
|
-
|
|
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
|
-
}
|
|
94
|
+
const next = offsetStep(replicad, cur, off, vol);
|
|
86
95
|
if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
|
|
87
96
|
if (!next) {
|
|
88
97
|
warn(`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
98
|
return shape.clone();
|
|
90
99
|
}
|
|
91
|
-
cur = next;
|
|
100
|
+
cur = next.solid;
|
|
101
|
+
vol = next.vol;
|
|
92
102
|
}
|
|
93
103
|
return cur;
|
|
94
104
|
}
|
|
105
|
+
|
|
106
|
+
// Solid.offset on the B-rep class: ONE gated offset step, Arc joins only, so the
|
|
107
|
+
// corners match the mesh class's ball offset (convex edges round on a grow,
|
|
108
|
+
// concave on a shrink). Unlike roundAll this never skips: an un-offset solid
|
|
109
|
+
// handed back as an offset is silently wrong geometry (a skin cut from it comes
|
|
110
|
+
// out empty), so a step no variant survives THROWS, with a greppable message
|
|
111
|
+
// (docs/ERROR-PATTERNS.md "offset-failed"). The usual trigger is an erode at or
|
|
112
|
+
// past half the thinnest wall — the B-rep offset cannot change topology, which
|
|
113
|
+
// the mesh class's Minkowski does.
|
|
114
|
+
export function occtOffset(replicad, shape, d) {
|
|
115
|
+
if (!Number.isFinite(d) || d === 0) throw new Error("offset: d must be a finite non-zero number (d = 0 is handled as the identity by the caller)");
|
|
116
|
+
let vol;
|
|
117
|
+
try {
|
|
118
|
+
vol = replicad.measureVolume(shape);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new Error(`offset-failed: offset(${d}) could not measure the input solid on the OCCT backend (${e?.message || e})`);
|
|
121
|
+
}
|
|
122
|
+
const next = offsetStep(replicad, shape, d, vol, ARC_VARIANTS);
|
|
123
|
+
if (!next) {
|
|
124
|
+
throw new Error(`offset-failed: offset(${d}) produced no valid solid on the OCCT backend — |d| is likely at/above half the thinnest wall, or the shape has faces OCCT cannot offset; the Manifold backend's offset handles both (meta.backend: "manifold")`);
|
|
125
|
+
}
|
|
126
|
+
return next.solid;
|
|
127
|
+
}
|
|
@@ -365,6 +365,8 @@ export const SOLID_OP_SPECS = {
|
|
|
365
365
|
return [req("shell", o, "t"), req("shell", o, "open")]; } },
|
|
366
366
|
roundAll: { toArgs: (o) => { checkKeys("roundAll", o, ["r"]);
|
|
367
367
|
return [req("roundAll", o, "r")]; } },
|
|
368
|
+
offset: { toArgs: (o) => { checkKeys("offset", o, ["d"]);
|
|
369
|
+
return [req("offset", o, "d")]; } },
|
|
368
370
|
};
|
|
369
371
|
|
|
370
372
|
// A zero-magnitude fillet/chamfer is the identity on every conformance class
|
|
@@ -14,12 +14,17 @@ export function addShape2dSugar(s, { shape2d, extrude, revolve }) {
|
|
|
14
14
|
// lifted and resolved by every boolean since — re-lifting them untrusted would
|
|
15
15
|
// re-run profile validation on machine-produced geometry on every call.
|
|
16
16
|
s.regions = () => s.toRegions().map((r) => shape2d.trusted(r));
|
|
17
|
-
// .extrude({ h, twist?, scaleTop? }) / .revolve({ degrees? }) → Solid. Sugar for
|
|
18
|
-
// k.extrude({ profile: shape, … }) / k.revolve({ profile: shape, … }).
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
// .extrude({ h, twist?, scaleTop?, bevel? }) / .revolve({ degrees? }) → Solid. Sugar for
|
|
18
|
+
// k.extrude({ profile: shape, … }) / k.revolve({ profile: shape, … }). Every option is
|
|
19
|
+
// forwarded as-is, so the kernel op's own key/required-arg validation fires: a missing
|
|
20
|
+
// `h` throws "extrude: h is required", an unknown key throws, and an option the kernel
|
|
21
|
+
// grows later (as `bevel` was) works here with no change. Never list the keys: a list
|
|
22
|
+
// silently drops whatever it leaves out. `profile` is refused — this shape is the profile.
|
|
23
|
+
const sugar = (op, fn) => (o = {}) => {
|
|
24
|
+
if (o.profile !== undefined) throw new Error(`Shape2D.${op}: don't pass profile — the shape you call .${op}() on is the profile`);
|
|
25
|
+
return fn({ ...o, profile: s });
|
|
26
|
+
};
|
|
27
|
+
s.extrude = sugar("extrude", extrude);
|
|
28
|
+
s.revolve = sugar("revolve", revolve);
|
|
24
29
|
return s;
|
|
25
30
|
}
|
package/types/kernel.d.ts
CHANGED
|
@@ -290,6 +290,16 @@ export interface Solid {
|
|
|
290
290
|
* never throws `KernelCapabilityError`. `roundAll(0)` is the identity.
|
|
291
291
|
*/
|
|
292
292
|
roundAll(r: number | { r: number }): Solid;
|
|
293
|
+
/**
|
|
294
|
+
* 3-D ball offset — the solid counterpart of `Shape2D.offset`. `d > 0` grows
|
|
295
|
+
* the solid by `d` everywhere (convex edges round at radius `d`); `d < 0`
|
|
296
|
+
* shrinks it by `|d|` (concave edges round, convex corners stay sharp). Works
|
|
297
|
+
* on both backends and on imported meshes; never routes. A constant-thickness
|
|
298
|
+
* skin is `body.cut(body.offset(-t))`. `offset(0)` is the identity. The mesh
|
|
299
|
+
* backend's cost grows with triangle count (~3 ms per triangle); on OCCT an
|
|
300
|
+
* offset that cannot be built throws `offset-failed`.
|
|
301
|
+
*/
|
|
302
|
+
offset(d: number | { d: number }): Solid;
|
|
293
303
|
/** Through-hole count (Manifold only). */
|
|
294
304
|
genus?(): number;
|
|
295
305
|
/** No geometry at all (Manifold only). */
|