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.
- package/docs/AUTHORING-PARTS.md +46 -1
- package/docs/ERROR-PATTERNS.md +18 -0
- package/docs/KERNEL-CONTRACT.md +22 -2
- package/package.json +1 -1
- package/src/framework/export/bed-layout.js +19 -9
- package/src/framework/export/bundle.js +1 -0
- package/src/framework/geometry/kernel.js +7 -1
- package/src/framework/geometry/manifold-backend.js +21 -3
- package/src/framework/geometry/mesh-roundall.js +54 -0
- package/src/framework/geometry/occt-backend.js +12 -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/solid-cache.js +14 -0
- package/src/framework/jobs.js +47 -7
- package/src/framework/worker.js +14 -0
- package/types/kernel.d.ts +12 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
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
|
|
@@ -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
|
@@ -47,16 +47,26 @@ export function bedWidthFor(part) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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; }
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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");
|
package/src/framework/worker.js
CHANGED
|
@@ -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. */
|