partforge 0.164.1 → 0.165.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.
@@ -1776,7 +1776,7 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
1776
1776
 
1777
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.)
1778
1778
 
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.)
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)).
1780
1780
 
1781
1781
  ## Editing profiles
1782
1782
 
@@ -4768,6 +4768,7 @@ at runtime — no declaration needed either way:
4768
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 |
4769
4769
  | `s.chamfer(distance)` · `s.chamfer({ d, edges? })` | bevel edges; same scalar-shorthand-or-options-with-selector shape as `fillet` |
4770
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. |
4771
4772
 
4772
4773
  `edges` (fillet/chamfer) / `open` (shell) chooses which edges/faces (omit `edges` for **all** edges — `shell` always requires `open`):
4773
4774
 
@@ -4887,6 +4888,50 @@ Rules of thumb:
4887
4888
  - A small ridge remaining where a thin rib was consumed is correct morphology
4888
4889
  (the rib's dilation fillet survives the opening), not a bug.
4889
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
+
4890
4935
  ### Cost on the OCCT path: fillet/chamfer scale with edge count — and order matters
4891
4936
 
4892
4937
  These costs apply when a sub-part **does** run on OCCT (a shell, an unsupported edge
@@ -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.
@@ -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
@@ -97,13 +106,17 @@ relying on consumption should gate it with a `verify` volume assertion.
97
106
  **B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
98
107
  OCCT/replicad backend is the reference.
99
108
 
100
- **Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/
109
+ **Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/`dropCache`/
101
110
  `cacheStats`/`resetCacheStats`/`cleanup`) and `SOLID_OPTIONAL_OPS` (`genus`/`isEmpty`) may
102
111
  be omitted entirely; callers in the framework guard with `?.`/`typeof`. A host that omits
103
112
  them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothing else.
104
113
  `sweepCache()` is the cache's rebind hygiene hook: called once when a worker is rebound to
105
114
  a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
106
- have gone unbuilt for three consecutive rebinds.
115
+ have gone unbuilt for three consecutive rebinds. `dropCache()` drops every partition at
116
+ once, under the same never-mid-bracket rule: the worker calls it on each booted kernel
117
+ before an export job, because a display build's retained solids and the export's own
118
+ build share one WASM heap, and on a very large part the cache alone can leave too little
119
+ heap for the export. The next display build after an export is cold.
107
120
 
108
121
  `beginSubPart`/`endSubPart` brackets MAY nest: only the outermost pair opens and
109
122
  commits a round, and an inner pair is a balanced no-op. Nesting is real rather than
@@ -379,6 +392,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
379
392
  | `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
393
  | `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
394
  | `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. |
395
+ | `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
396
 
383
397
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
384
398
  speed and a backend may bake it at kernel creation (Manifold does). A part must never
@@ -942,6 +956,12 @@ in `kernel.js` define the current surface; only breaking changes bump the versio
942
956
  `cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
943
957
  transfer. Renames are breaking changes with no offsetting benefit — don't.
944
958
 
959
+ **Additive within v4** (partforge 0.165): `Solid.offset` joins `SOLID_OPS` — a 3-D
960
+ ball offset (`d > 0` grows, `d < 0` shrinks), on both in-repo classes. No contract
961
+ bump, following the `loftSmooth`/`heightfield` precedent for an op both in-repo
962
+ backends implement; a third-party core kernel must add it to stay conforming
963
+ (Manifold's `minkowskiSum`/`minkowskiDifference` with a sphere is the reference).
964
+
945
965
  **v3 → v4** (partforge 0.63): `Solid.roundAll` is added to `SOLID_OPS` (portable
946
966
  morphological rounding — rounds every edge with a ball of radius `r`;
947
967
  parity-tolerant, regime-split semantics, see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.164.1",
3
+ "version": "0.165.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,16 +47,26 @@ export function bedWidthFor(part) {
47
47
  }
48
48
  }
49
49
 
50
- // `pieces`: [{ name, solid, ...rest }] in export order. Map-form pieces are laid out
51
- // together; legacy ones keep their export pose, and so do sheet pieces — a sheet's pose IS
52
- // its placement, so sheet exports come out assembled. Returns a new array, same order.
50
+ // Map-form pieces are laid out together; legacy ones keep their export pose, and so do
51
+ // sheet pieces — a sheet's pose IS its placement, so sheet exports come out assembled.
52
+ export function laidOutOnBed(part, name) {
53
+ const sp = part.parts[name];
54
+ return formOf(sp) === "map" && !isSheetPart(sp);
55
+ }
56
+
57
+ // `boxes`: [{ name, box }] for the laid-out pieces, in export order → Map(name → offset).
58
+ // Bounds alone are enough, which is what lets the 3MF export free each piece as soon as
59
+ // it is meshed and shift the mesh afterwards (feedback #212).
60
+ export function exportLayoutOffsets(part, boxes) {
61
+ if (boxes.length === 0) return new Map();
62
+ return bedLayout(boxes.map(({ name, box }) => ({ key: name, ...box })), { width: bedWidthFor(part) });
63
+ }
64
+
65
+ // `pieces`: [{ name, solid, ...rest }] in export order. Returns a new array, same order,
66
+ // laid-out pieces translated (see laidOutOnBed).
53
67
  export function layoutExportPieces(part, pieces) {
54
- const laidOut = (sp) => formOf(sp) === "map" && !isSheetPart(sp);
55
- const mapPieces = pieces.filter(({ name }) => laidOut(part.parts[name]));
68
+ const mapPieces = pieces.filter(({ name }) => laidOutOnBed(part, name));
56
69
  if (mapPieces.length === 0) return pieces;
57
- const offsets = bedLayout(
58
- mapPieces.map(({ name, solid }) => ({ key: name, ...solid.boundingBox() })),
59
- { width: bedWidthFor(part) },
60
- );
70
+ const offsets = exportLayoutOffsets(part, mapPieces.map(({ name, solid }) => ({ name, box: solid.boundingBox() })));
61
71
  return pieces.map((pc) => (offsets.has(pc.name) ? { ...pc, solid: pc.solid.translate(offsets.get(pc.name)) } : pc));
62
72
  }
@@ -392,6 +392,7 @@ export async function buildBundle({ kernel, part, msg, p, d, posed, label, expor
392
392
  pp.triangles = indices.length / 3;
393
393
  }
394
394
  builtPrints.push(pp);
395
+ kernel.cleanup?.(); // pp keeps only JS-owned data; free this piece's solids before the next (feedback #212)
395
396
  }
396
397
  const prints = dedupe(builtPrints, (x) => x.hash, (a, b) => a.triangles === b.triangles && a.color === b.color
397
398
  && near(a.volume, b.volume) && a.size.every((x, i) => near(x, b.size[i])))
@@ -36,7 +36,7 @@ export const KERNEL_OPS = [
36
36
  // OCCT's replicad shapes need no dispose bookkeeping). jobs.js calls all of these via
37
37
  // `?.`, so a third-party backend may simply omit them.
38
38
  export const KERNEL_OPTIONAL_OPS = [
39
- "beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
39
+ "beginSubPart", "endSubPart", "sweepCache", "dropCache", "cacheStats", "resetCacheStats", "cleanup",
40
40
  // Drains the feature-skip warnings recorded since the last drain — a fillet or
41
41
  // chamfer the geometry defeated and the backend skipped rather than failed the
42
42
  // build over. Both backends implement it; a host that never calls it sees the
@@ -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
  *
@@ -165,6 +170,7 @@ export const ROUTED_CAD_OPS = ["shell"];
165
170
  * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (both backends)
166
171
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
167
172
  * @property {() => void} [sweepCache] drop cache partitions idle for 3 rebinds; call once per setPart, never mid-bracket
173
+ * @property {() => void} [dropCache] drop EVERY cache partition (the worker calls it before an export); never mid-bracket
168
174
  * @property {() => {hits:number,misses:number}} [cacheStats]
169
175
  * @property {() => void} [resetCacheStats]
170
176
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
@@ -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
- const markFlatFaces = (input, out, r) => {
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)), () => {
@@ -1046,6 +1063,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
1046
1063
  beginSubPart: (name) => cache.begin(name),
1047
1064
  endSubPart: () => cache.end(),
1048
1065
  sweepCache: () => cache.sweep(),
1066
+ dropCache: () => cache.clear(),
1049
1067
  cacheStats: () => cache.stats(),
1050
1068
  resetCacheStats: () => cache.resetStats(),
1051
1069
  // Drain the feature-skip warnings recorded since the last drain (see
@@ -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));
@@ -849,6 +859,7 @@ export function createOcctKernel(replicad) {
849
859
  beginSubPart: (name) => cache.begin(name),
850
860
  endSubPart: () => cache.end(),
851
861
  sweepCache: () => cache.sweep(),
862
+ dropCache: () => cache.clear(),
852
863
  cacheStats: () => cache.stats(),
853
864
  resetCacheStats: () => cache.resetStats(),
854
865
  // Drain the feature-skip warnings recorded since the last drain — the
@@ -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
- export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`partforge: ${msg}`)) {
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)");
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
- 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
- }
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
@@ -66,6 +66,20 @@ export function createSolidCache() {
66
66
  }
67
67
  },
68
68
 
69
+ // Drop EVERY partition, whatever its age — the next build of each sub-part is
70
+ // cold. The worker calls this before an export (feedback #212): the display
71
+ // build's retained solids and the export's own print-quality build share one
72
+ // WASM heap, and on a 105-plate forge the display cache alone held ~1.75 GB,
73
+ // so the export ran out of heap after a few plates on a phone. Like sweep(),
74
+ // never mid-bracket — the worker's job queue is serial.
75
+ clear() {
76
+ for (const entries of caches.values()) {
77
+ for (const [hash, entry] of entries) release(hash, entry); // refcounted: each partition lets go once
78
+ }
79
+ caches.clear();
80
+ lastBuilt.clear();
81
+ },
82
+
69
83
  lookup(hash, make) {
70
84
  if (name == null) return make().value; // not bracketed → no caching
71
85
  if (active.has(hash)) { hits++; return active.get(hash).value; }
@@ -163,6 +163,22 @@ async function scoreMatchTargets(built, targets, onProgress) {
163
163
  }
164
164
  }
165
165
 
166
+ // The WASM traps that mean the kernel ran out of heap (or was corrupted by doing so).
167
+ const HEAP_TRAP_RE = /out of bounds memory access|memory access out of bounds|Cannot enlarge memory|out of memory/i;
168
+
169
+ // A piece of a many-piece export failed: say WHICH piece, how far the export got, and
170
+ // — when it is the heap that ran out — the one remedy the user can apply. The raw
171
+ // message stays LAST, after a colon, so a host's own trap classifier still matches it.
172
+ function exportPieceError(thrown, { kernel, piece, index, count }) {
173
+ const err = describeKernelThrow(thrown, { evidence: kernel._throwEvidence?.(thrown) });
174
+ if (count < 2 || err?.code) return err; // one piece: the error already names it; coded errors are routed, not shown
175
+ const raw = String(err?.message || err);
176
+ const advice = HEAP_TRAP_RE.test(raw)
177
+ ? " ran out of memory — export fewer parts at a time (each export needs room for the parts it builds)"
178
+ : " failed";
179
+ return new Error(`exporting "${piece}" (part ${index + 1} of ${count})${advice}: ${raw}`, { cause: thrown });
180
+ }
181
+
166
182
  export async function handle(kernel, part, msg, post, opts = {}) {
167
183
  const isStale = opts.isStale ?? (() => false);
168
184
  const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
@@ -500,9 +516,18 @@ export async function handle(kernel, part, msg, post, opts = {}) {
500
516
  const names = selected();
501
517
  if (names.length === 0) throw new Error("no exportable parts selected");
502
518
  const out = [];
503
- for (const name of names) {
519
+ for (const [i, name] of names.entries()) {
504
520
  onProgress(`building ${label(name)}`);
505
- out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
521
+ try {
522
+ out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
523
+ } catch (thrown) {
524
+ throw exportPieceError(thrown, { kernel, piece: label(name), index: i, count: names.length });
525
+ }
526
+ // Each file is JS-owned once toSTL returns, so this piece's solids can go now.
527
+ // Without it a many-piece export held every piece's print-quality build until
528
+ // the job ended — the 105-plate forge of feedback #212 ran out of heap at
529
+ // about the 100th plate.
530
+ kernel.cleanup?.();
506
531
  }
507
532
  post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out, jobId: msg.jobId },
508
533
  out.map((pp) => bufferOf(pp.data)));
@@ -523,12 +548,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
523
548
  } else if (msg.type === "export-3mf") {
524
549
  const names = selected();
525
550
  if (names.length === 0) throw new Error("no exportable parts selected");
526
- const { layoutExportPieces } = await loadBedLayout();
527
- const meshes = layoutExportPieces(part, names.map((name) => {
551
+ // One piece in the kernel at a time, as export-stl: build, read the bounds the bed
552
+ // layout needs, mesh into JS-owned arrays, free — then lay out from the bounds and
553
+ // shift the meshes (translation only, so this is the layout layoutExportPieces makes).
554
+ const { laidOutOnBed, exportLayoutOffsets } = await loadBedLayout();
555
+ const built = [];
556
+ for (const [i, name] of names.entries()) {
528
557
  onProgress(`building ${label(name)}`);
529
- return { name, solid: posed(name, "export", onProgress) };
530
- })).map(({ name, solid }) => {
531
- const { positions, indices } = solid.toIndexedMesh({ quality: msg.quality ?? "print" });
558
+ try {
559
+ const solid = posed(name, "export", onProgress);
560
+ const box = laidOutOnBed(part, name) ? solid.boundingBox() : null;
561
+ const { positions, indices } = solid.toIndexedMesh({ quality: msg.quality ?? "print" });
562
+ built.push({ name, box, positions, indices });
563
+ } catch (thrown) {
564
+ throw exportPieceError(thrown, { kernel, piece: label(name), index: i, count: names.length });
565
+ }
566
+ kernel.cleanup?.();
567
+ }
568
+ const offsets = exportLayoutOffsets(part, built.filter((b) => b.box));
569
+ const meshes = built.map(({ name, positions, indices }) => {
570
+ const o = offsets.get(name);
571
+ if (o) for (let j = 0; j < positions.length; j += 3) { positions[j] += o[0]; positions[j + 1] += o[1]; positions[j + 2] += o[2]; }
532
572
  return { name: exportName(name), positions, indices, color: printColor(part.parts[name]?.display) };
533
573
  });
534
574
  onProgress("writing 3MF file");
@@ -30,6 +30,15 @@ async function manifoldKernels() {
30
30
  };
31
31
  }
32
32
 
33
+ // Jobs that build every selected sub-part again at export quality. Before one runs,
34
+ // the worker drops its kernels' display caches (dropCache): the cache's retained
35
+ // solids and the export's build share one WASM heap, and on a very large part the
36
+ // display build alone held ~1.75 GB — on a phone, enough that exporting three of a
37
+ // 105-plate forge's plates ran out of heap (feedback #212). The cost is one cold
38
+ // display build after an export; exports are rare and deliberate, a failed one
39
+ // costs the user the whole job.
40
+ const EXPORT_JOBS = new Set(["export-stl", "export-step", "export-3mf", "export-bundle"]);
41
+
33
42
  async function occtKernel() {
34
43
  const [{ default: opencascade }, wasmUrlMod, replicad, { createOcctKernel }] = await Promise.all([
35
44
  import("replicad-opencascadejs/src/replicad_single.js"),
@@ -115,6 +124,11 @@ export function runWorker(part, opts = {}) {
115
124
  // A generate superseded while it sat in the queue never builds at all.
116
125
  if (job.epoch !== null && job.epoch !== epoch) continue;
117
126
  kernel = await kernelFor(job.data);
127
+ if (EXPORT_JOBS.has(job.data?.type)) {
128
+ manifold?.preview.dropCache?.();
129
+ manifold?.print.dropCache?.();
130
+ occt?.dropCache?.();
131
+ }
118
132
  // handle() declares each message's transferables (the big binary buffers).
119
133
  const post = (m, transfer = []) => postMessage(m, transfer);
120
134
  if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
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). */
@@ -632,6 +642,8 @@ export interface GeometryKernel {
632
642
  endSubPart?(): void;
633
643
  /** Drop cache partitions idle for 3 rebinds. Never call mid-bracket. */
634
644
  sweepCache?(): void;
645
+ /** Drop every cache partition — the worker calls this before an export job. Never call mid-bracket. */
646
+ dropCache?(): void;
635
647
  cacheStats?(): { hits: number; misses: number };
636
648
  resetCacheStats?(): void;
637
649
  /** Free per-job WASM objects (Manifold backend); call after each job. */