partforge 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ # The partforge kernel contract
2
+
3
+ **Contract version: 1** (introduced in partforge 0.9) — mirrored by `CONTRACT_VERSION`
4
+ in `src/framework/geometry/kernel.js` and asserted by `test/kernel-contract.test.js`;
5
+ see [Versioning](#versioning) for what may change under which version bump.
6
+
7
+ This document is the portable seam of partforge. A part's `build(k, p, d)` is a pure ESM
8
+ function written against the kernel `k` and the `Solid` handles it returns — no framework
9
+ imports, no DOM, no backend types. That means **the kernel interface is the interchange
10
+ format**: any host that implements this contract can run any partforge part, and an LLM
11
+ given this document plus one exemplar part has everything it needs to write correct
12
+ geometry. There is deliberately no separate file format or DSL.
13
+
14
+ The contract has two halves:
15
+
16
+ - **Machine-checked:** the op lists in `src/framework/geometry/kernel.js`
17
+ (`KERNEL_OPS`, `SOLID_OPS`, `OCCT_ONLY_OPS`, `*_OPTIONAL_OPS`) and their `@typedef`
18
+ signatures. `test/kernel-contract.test.js` and the OCCT twin in
19
+ `test/occt-backend.test.js` assert each backend exposes exactly these ops, so the list
20
+ cannot silently drift from the implementations. **Those lists are normative.**
21
+ - **Prose (this doc):** the semantics an implementer or generator cannot read off a
22
+ signature — coordinate conventions, value semantics, validation rules, error taxonomy,
23
+ what parts may and may not rely on across backends.
24
+
25
+ Audience: backend/host implementers, and anyone (human or LLM) generating parts outside
26
+ this repo. For *authoring guidance* — usage tables, worked snippets, control-panel schema
27
+ — read `docs/AUTHORING-PARTS.md`. Where the two overlap (the op tables), this doc
28
+ carries the conformance semantics and that one the usage guidance;
29
+ `test/kernel-contract.test.js` keeps this doc's op coverage in sync with the code.
30
+
31
+ ## Conformance classes
32
+
33
+ **Core class.** A conforming core kernel implements every op in `KERNEL_OPS` and
34
+ every `Solid` op in `SOLID_OPS`, *except* that the B-rep ops (`fillet`, `chamfer`,
35
+ `shell` — the `OCCT_ONLY_OPS` list — and `toSTEP`) may instead throw
36
+ `KernelCapabilityError`. The in-repo Manifold backend is the reference core kernel.
37
+ Kernels built from this repo get the stubs for free: `addSugar()` generates the
38
+ Solid-level stubs from `OCCT_ONLY_OPS`, and `finishKernel()` stubs `toSTEP` (a
39
+ kernel-level op, so it is not in that Solid-op list).
40
+
41
+ **B-rep class.** Core plus native `fillet`/`chamfer`/`shell` and `toSTEP`. The in-repo
42
+ OCCT/replicad backend is the reference.
43
+
44
+ **Optional ops.** `KERNEL_OPTIONAL_OPS` (`beginSubPart`/`endSubPart`/`sweepCache`/
45
+ `cacheStats`/`resetCacheStats`/`cleanup`) and `SOLID_OPTIONAL_OPS` (`genus`/`isEmpty`) may
46
+ be omitted entirely; callers in the framework guard with `?.`/`typeof`. A host that omits
47
+ them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothing else.
48
+ `sweepCache()` is the cache's rebind hygiene hook: called once when a worker is rebound to
49
+ a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
50
+ have gone unbuilt for three consecutive rebinds.
51
+
52
+ `KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
53
+ probe (`probe.js`) runs `build` against a fake kernel, and any use of an `OCCT_ONLY_OPS`
54
+ op routes the whole part to a B-rep-class kernel. A host with only a core kernel must
55
+ surface the error ("this part needs a B-rep backend") rather than swallow it.
56
+
57
+ ## Global semantics
58
+
59
+ These hold for every op on every backend. A part may assume them; an implementation must
60
+ provide them.
61
+
62
+ - **Units are millimetres.** Everywhere, including `volume()` (mm³) and mesh output.
63
+ - **Angles are degrees.** Everywhere (`rotate*`, `twist`, `revolve` `degrees`, loft ring
64
+ `rotate`, `arcDeg` helpers).
65
+ - **Coordinates are right-handed, Z-up.** Primitives build along **+Z from z = 0**
66
+ (`cylinder`, `prism`, `extrude` extrude upward; `revolve` spins `[[r, z], …]` about the
67
+ Z axis). The idiom is *build canonical at the origin, then orient/place*
68
+ (`.along(dir).at(v)`).
69
+ - **2-D contours are `[[x, y], …]` point lists, CCW = material.** Holes in an `extrude`
70
+ profile are additional contours; winding of holes is normalized by the backend. The
71
+ symbolic-arc alternative is an **arc profile** `{ start, segments: [{ to, via? }, …] }`
72
+ (produced by `roundedProfile`), where a segment with `via` is a three-point circular
73
+ arc; B-rep backends must carry these arcs exactly (real CIRCLE edges in STEP), mesh
74
+ backends tessellate them. Cubic Bézier segments (`{to, c1, c2}`, built via `pathProfile().cubicTo(…)`)
75
+ follow the same rule: exact spline B-rep on OCCT (→ STEP), adaptively faceted at
76
+ the mesh `segs` LOD on Manifold. Measure-parity (volume/bbox) holds within
77
+ tolerance as facets converge; this is not a parity waiver.
78
+ - **Ops never mutate — but they MAY consume.** Every op returns a new `Solid` and never
79
+ mutates one in place. Whether the *inputs stay valid* is backend-dependent: the mesh
80
+ backend leaves them usable, but the B-rep backend's engine (replicad) deletes the
81
+ operand of a transform or boolean. The portable rule is therefore: **never reuse a
82
+ `Solid` after passing it to a transform or boolean — `.clone()` first if you need it
83
+ again** (failure signature: ERROR-PATTERNS.md `replicad-consumed-operand`). `clone()`
84
+ must return an independent handle on every backend; a backend MAY additionally provide
85
+ full value semantics, but a portable part must not rely on it.
86
+ - **Purity and determinism: identical arguments must produce identical geometry.** No
87
+ randomness, clocks, or hidden global state in an implementation. partforge's solid
88
+ cache memoizes by a content hash of `(op, args)`; a nondeterministic op silently
89
+ poisons the cache.
90
+ - **Validation** (a conforming implementation enforces all of these; in-repo the kernel
91
+ front checks the `prism`/`extrude`/`revolve` rules, `addSugar` the `scale` rule, and
92
+ the B-rep backend the `shell` rule): `prism`/`extrude` `scaleTop ≥ 0`; `revolve`
93
+ profile radii `≥ 0`; `scale` `factor > 0`; `shell` requires `open` (a fully
94
+ closed hollow is not supported).
95
+ - **Error taxonomy:** invalid arguments throw plain `Error` with a message naming the op
96
+ (`"prism: scaleTop must be ≥ 0"`); a whole op a backend class lacks throws
97
+ `KernelCapabilityError` (from `geometry/errors.js`) — the routing signal. A
98
+ backend-divergent *option* (`loft`/`sweep` `closed: true` on a B-rep kernel) throws a
99
+ plain `Error` naming the limitation, not `KernelCapabilityError`: option misuse is not
100
+ reroutable, and a host must fail loudly rather than silently ignore the option. Beyond
101
+ those, nothing else is thrown for well-formed input — a fillet the engine cannot
102
+ compute falls under the repair policy below, not a part-visible error class.
103
+
104
+ ## Calling convention
105
+
106
+ **Detection rule (normative):** a call is **options form** when the op receives
107
+ **exactly one argument and it is a plain object** — not an `Array`, not a `Solid`.
108
+ Any other arity or first argument is legacy positional form. "Plain object" means
109
+ `Object.getPrototypeOf(x) === Object.prototype || null`, which excludes arrays,
110
+ `Solid` handles (backend handles carry methods/prototypes), and typed arrays. This
111
+ one rule disambiguates every op with no key-sniffing — the load-bearing case:
112
+ `extrude({outer, holes}, h)` is positional (two arguments); `extrude({profile, h})`
113
+ is options (one plain object).
114
+
115
+ Options form is canonical — the form this document, `AUTHORING-PARTS.md`, and every
116
+ in-repo part teach and use. Legacy positional forms remain accepted (silently — no
117
+ runtime warning) until contract v2 removes them; a conforming implementation must
118
+ accept both, and this repo's `finishKernel()`/`addSugar()` provide the normalization
119
+ for free.
120
+
121
+ ### Kernel factory ops (options-canonical; legacy positional accepted)
122
+
123
+ | Op | Canonical options form | Legacy positional (until v2) |
124
+ |---|---|---|
125
+ | `cylinder` | `{r\|d, h, center?}` straight · `{r1, r2, h, center?}` or `{d1, d2, h, center?}` cone | `(rBottom, rTop, h, {center?})` |
126
+ | `sphere` | `{r\|d}` — `sphere(5)` stays valid, undeprecated | `(r)` |
127
+ | `box` | `{size:[x,y,z], center?}` (centered X/Y, base z=0; `center:true` also centers Z) · `{min, max}` | `(min, max)` |
128
+ | `prism` | `{points, h, twist?, scaleTop?}` | `(points2D, h, {twist?,scaleTop?})` |
129
+ | `extrude` | `{profile, h, twist?, scaleTop?, bevel?}` — `profile` = points array, `{outer, holes}`, or arc profile; `bevel` has no positional form | `(profile, h, {twist?,scaleTop?})` |
130
+ | `revolve` | `{profile, degrees?}` | `(points2D, {degrees?})` |
131
+ | `loft` | `{rings, ruled?, closed?}` | `(rings, {ruled?,closed?})` |
132
+ | `sweep` | `{profile, path, closed?, cornerRadius?, ruled?, smooth?}` | `(profile2D, path3D, opts?)` |
133
+
134
+ `boredCylinder` and `helixSweptTube` were always options-only (no positional
135
+ legacy form exists); they get the same unknown-key / required-key validation as
136
+ the ops above.
137
+ `union(solids[])` and `toSTEP(named[])` take a single array — unchanged.
138
+
139
+ ### Solid ops
140
+
141
+ | Op | Canonical form(s) | Notes |
142
+ |---|---|---|
143
+ | `fillet` | `fillet(3)` · `fillet({r, edges?})` | options form replaces `fillet(3, selector)` |
144
+ | `chamfer` | `chamfer(1)` · `chamfer({d, edges?})` | ditto |
145
+ | `shell` | `shell({t, open})` | replaces `(thickness, openFaces)`; `open` was already required |
146
+ | everything else | unchanged | `translate/at/along/rotate*/rotateAbout/mirror/scale/cut/cutAll/intersect/union/clone/label` + queries |
147
+
148
+ ### Cylinder key rules
149
+
150
+ - Straight: exactly one of `r` / `d`. Cone: `r1`+`r2` or `d1`+`d2` (no mixing
151
+ radius and diameter across ends; no mixing straight and cone keys).
152
+ - `h` required everywhere.
153
+ - Diameter keys are sugar: normalized to radii before the backend sees them.
154
+
155
+ ### `box({size})` placement
156
+
157
+ `{size:[x,y,z]}` is centered in X and Y with its base at `z = 0` — the same
158
+ canonical placement `cylinder` already has (build canonical at the origin, then
159
+ orient/place). `{center:true}` additionally centers Z. `{min, max}` remains for
160
+ explicit corners and is unaffected.
161
+
162
+ Scalar shorthands are permanent, not legacy: `sphere(5)`, `fillet(3)`, and
163
+ `chamfer(1)` stay valid and undeprecated — they take a single number with no
164
+ transposition risk, so there is no options-form pressure to replace them (only
165
+ `fillet`/`chamfer`'s two-argument selector call is superseded, by
166
+ `fillet({r, edges})` / `chamfer({d, edges})`).
167
+
168
+ ## Kernel ops (make solids)
169
+
170
+ Signatures are normative in `kernel.js`'s `@typedef GeometryKernel`; this table fixes
171
+ the behavior. Signatures are shown in the canonical options form — the legacy
172
+ positional equivalents live in the [Calling convention](#calling-convention) table
173
+ above. All ops return a `Solid`.
174
+
175
+ | Op | Contract |
176
+ |---|---|
177
+ | `cylinder({r\|d, h, center?})` · `cylinder({r1, r2, h, center?})` \| `{d1, d2, h}` | Cylinder along +Z from z = 0 (straight: exactly one of `r`/`d`); the cone form (`r1`/`r2` or `d1`/`d2` ends) gives a frustum. `center: true` centers on z = 0. |
178
+ | `boredCylinder({od, h, bore})` | Compound: cylinder of diameter `od` with a through-bore `bore`. Semantically identical to the composition in `kernel-front.js`; a backend may override only for caching, never for different geometry. |
179
+ | `sphere({r\|d})` | Sphere centered at the origin; bare `sphere(r)` stays valid. |
180
+ | `box({size, center?})` · `box({min, max})` | Axis-aligned box: `{size:[x,y,z]}` centered in X/Y with base at z = 0 (`center: true` also centers Z), or explicit `[x,y,z]` `{min, max}` corners. |
181
+ | `prism({points, h, twist?, scaleTop?})` | Extrude one CCW contour (point list or arc profile) from z = 0. `twist` = total degrees over the height; `scaleTop` = uniform top scale (1 straight, 0 → apex). |
182
+ | `extrude({profile, h, twist?, scaleTop?, bevel?})` | Same, for a polygon-with-holes region — `profile` is `{outer, holes?}` (bare contour = outer only) — in one op, no per-hole boolean. `profile` may also be a `Shape2D` (see below). `bevel` (number = both rims, `{bottom?, top?}` = per rim) cuts a 45° rim bevel; it desugars at the shared front into extrude + loft + intersect/cut, so it is backend-identical by construction and is **not** a CAD-only op (no OCCT routing). Every profile form works — point array, arc profile, `{outer, holes}` (hole rims flare outward), or `Shape2D` (multi-region bevels each and unions) — but curved profiles are **materialized to point rings** first, so a beveled extrusion is faceted at the sampling LOD even in STEP (arc contours at a fixed pure-JS LOD, backend-identical; a `Shape2D` at its backend's own LOD — `hull`'s parity class). No `twist`/`scaleTop`, and `bottom + top < h` or it throws; a bevel a rim's narrow features cannot take is deterministically reduced with a console warning (`ERROR-PATTERNS.md#extrude-bevel-reduced`). |
183
+ | `revolve({profile, degrees?})` | Revolve a lathe profile `[[r, z], …]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. |
184
+ | `loft({rings, ruled?, closed?})` | Stack polygon cross-sections (per-ring `z`/`rotate`/`scale`, equal vertex counts) with ruled walls and capped ends. Must self-correct a fully inverted result (CW rings / descending z) to an outward solid. |
185
+ | `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
186
+ | `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). |
187
+ | `union(solids[])` | Boolean union of one or more solids. |
188
+ | `text2d(string, {size, font?, align?, valign?, lineHeight?, tracking?, kerning?})` | Outline-font text → `Shape2D`. `size` = cap height (mm). `font` = declared name / inline bytes / default. Build-time; curve-exact on OCCT, faceted on Manifold. |
189
+ | `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
190
+ | `hullChain(inputs[])` | Swept hull over an ordered sequence of ≥2 inputs (same input forms as `hull`): the union of `hull([inᵢ, inᵢ₊₁])` for each consecutive pair — e.g. a tapered link connecting a row of circles. Throws with fewer than 2 inputs. |
191
+ | `toSTEP(named[])` | `[{name, solid}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. |
192
+
193
+ `hull`/`hullChain` parity: point-list and curve-contour inputs hull bit-identically
194
+ across backends (pure-JS sampling, no backend materialization involved). A `Shape2D`
195
+ input samples via its own backend materialization (`.toRegions()`), so a hull that
196
+ includes a `Shape2D` input agrees only within the tessellation tolerance of that
197
+ backend's curve faceting — the same class of parity as the 2-D boolean ops above, not
198
+ a waiver of `CONTRACT_VERSION` (still 1; this op is additive).
199
+
200
+ **Backend-divergent options** (a portable part must treat these as declared here):
201
+ `loft` `closed: true` (capless loop) and `sweep` `closed: true` are supported **only by
202
+ mesh backends** (Manifold); B-rep kernels throw a plain `Error` naming the limitation
203
+ (see the error taxonomy). `loft` `ruled: false` (smooth C2 walls) and `sweep`
204
+ `smooth: true` (native swept B-rep) are honored only by B-rep kernels; mesh kernels
205
+ render the ruled form. `sweep` `closed: true` loops must be planar. Where both backends build the same
206
+ shape they do it **by construction, not by tolerance**: sweep elbows loft the identical
207
+ station list (`sweep.js`) on both backends.
208
+
209
+ ## Solid ops (combine / transform / query / output)
210
+
211
+ Normative signatures: `kernel.js`'s `@typedef Solid`.
212
+
213
+ | Op | Contract |
214
+ |---|---|
215
+ | `cut(tool)` / `cutAll(tools[])` / `intersect(other)` / `union(other)` | Boolean subtract (single / batched), intersection, and union. |
216
+ | `translate(v)` · `rotate(deg, center, axis)` · `mirror("XY"\|"XZ"\|"YZ")` · `scale(factor, center?)` | Transforms — but only two are **rigid** (pose): `translate`/`rotate` move a solid without altering it (position + orientation, shape and handedness preserved). `mirror` **reflects** — it returns the opposite-handed (chiral) solid, which no rotation can reproduce; `scale` **resizes**. So `mirror`/`scale` change the solid *itself*, not just where it sits — think of them as build operations, and never as the difference between a display pose and an export pose (see AUTHORING-PARTS.md `place`). `translate`/`rotate` are the primitives; the placement sugar below is composed *purely from them* (`solid-sugar.js`), so it is geometry-identical on every backend and a host gets it for free via `addSugar()`. |
217
+ | `rotateX(deg)` / `rotateY(deg)` / `rotateZ(deg)` · `rotateAbout({axis, deg, through?})` · `along(dir)` · `at(v)` | The readable placement vocabulary parts actually use. `along` maps the canonical +Z build axis to `"±X"\|"±Y"\|"±Z"`. |
218
+ | `clone()` | Independent handle (see value semantics). |
219
+ | `label(name)` | Name this solid's surface for feature attribution; must survive transforms and booleans; equal names merge into one feature. Affects mesh metadata only, never geometry. |
220
+ | `boundingBox()` | `{min, max, center, size}`; `center`/`size` are derived by `addSugar` from the backend's `{min, max}`. |
221
+ | `volume()` | Solid volume in mm³. |
222
+ | `genus()` / `isEmpty()` | Optional (`SOLID_OPTIONAL_OPS`): mesh-topology queries — through-hole count / no-geometry test. The mesh backend provides them; OCCT has no cheap equivalent. |
223
+ | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` may be empty (`length 0`) to delegate creasing to the viewer; `edges` (feature-line segments) and the feature fields are optional metadata. |
224
+ | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
225
+ | `toIndexedMesh()` | `{positions, indices}` indexed mesh (3MF path). |
226
+ | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
227
+
228
+ `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
229
+ speed and a backend may bake it at kernel creation (Manifold does). A part must never
230
+ depend on triangle counts, segment counts, or normals being present.
231
+
232
+ **Selectors** (`fillet`/`chamfer` `edges` selector, `shell` `open` face selector) are
233
+ declarative objects, criteria AND-combined:
234
+
235
+ ```js
236
+ { dir: "X"|"Y"|"Z", // edges along / faces normal-to this axis — edge
237
+ // selectors ALSO accept an [x,y,z] vector; face
238
+ // selectors (shell open) accept ONLY the strings
239
+ inPlane: "XY"|"XZ"|"YZ", at: number, // in the given plane at offset `at`
240
+ near: [x,y,z] } // containing this point
241
+ ```
242
+
243
+ `undefined` selects all edges/faces. A raw replicad finder function is also accepted
244
+ in-repo (AUTHORING-PARTS.md offers it for parts that are content to stay OCCT-bound),
245
+ but it is
246
+ inherently backend-specific: portable parts **MUST** use the object form, and a host
247
+ **MAY** reject function selectors.
248
+
249
+ **B-rep repair policy** (`occt-repair.js`): a failing fillet or shell is skipped **as a
250
+ whole** — attempted once, and on failure the shape reverts to its pre-op state (OCCT
251
+ fillet failures are not monotonic in the radius, so per-edge retry would converge on
252
+ garbage). A failing chamfer instead binary-searches the largest valid distance. A
253
+ conforming B-rep kernel must degrade this way — a fillet request must never brick the
254
+ build, and authors should expect all-or-nothing filleting per call, not per edge.
255
+
256
+ ## Shape2D (2-D booleans)
257
+
258
+ `k.shape2d(profile)` (`KERNEL_OPS`) lifts a point list, `{outer,
259
+ holes?}` region, or arc/curve contour into a `Shape2D` — an opaque 2-D boolean
260
+ value. Idempotent: `shape2d(x)` returns `x`
261
+ unchanged if `x` is already a `Shape2D`. `_`-prefixed keys are backend internals.
262
+ Normative signatures: `kernel.js`'s `@typedef
263
+ Shape2D`; the full public surface is `SHAPE2D_OPS`. **Both backends implement it**:
264
+ Manifold wraps a `CrossSection` (each method returns a fresh content-hash-cached
265
+ value, same caching/dispose discipline as `Solid`); OCCT wraps a replicad `Drawing`
266
+ (curve-preserving, so a curved boolean survives to exact STEP — content-hashed so
267
+ downstream `Solid` ops can key on it, but itself uncached; OCCT's `Solid` ops go
268
+ through the same solid cache as Manifold's, with rigid transforms kept pose-lazy so
269
+ re-posing a cached solid re-runs no B-rep work). The `kernel-front.js` `KernelCapabilityError` stub for `shape2d` is
270
+ now a dead / future-backend safety net only (both current backends define the op),
271
+ not an OCCT limitation.
272
+
273
+ | Op | Contract |
274
+ |---|---|
275
+ | `union(other)` / `cut(other)` / `cutAll(others[])` / `intersect(other)` | 2-D boolean ops; `other` may be a `Shape2D` or a raw profile (lifted via `shape2d` first). |
276
+ | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. Curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. |
277
+ | `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². |
278
+ | `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds (no `center`/`size`, unlike `Solid.boundingBox`). |
279
+ | `toRegions()` | Materialize into `{outer, holes}[]` region arrays (`assembleRegions`); a boolean result may be several disjoint regions. |
280
+ | `simple()` | `toRegions()` unwrapped — throws unless the result is exactly one region. |
281
+ | `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. |
282
+ | `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. |
283
+ | `revolve({degrees?})` | Sugar for `k.revolve({profile: this, …})` → `Solid`. |
284
+ | `clone()` | Independent handle. |
285
+
286
+ On `offset`: `round`, `sharp`, and `chamfer` all agree across both backends **for convex corners with interior angle ≥ 90°** (the common case: rectangles, hexagons, rounded-rects, pentagons, …). `chamfer` is a true 45° bevel — a straight chord across the corner — matching OCCT to float precision there (a 10×10 square offset +1 gives 142.0 on both; a pentagon 298.920 on both). Manifold has no native bevel join, so it renders `chamfer` as a Round join forced to a single chord per corner (`circularSegments=4`). **At acute (<90° interior) convex corners** — triangles, star points, V-notches — Clipper2 emits 2 chords rather than 1, so Manifold's chamfer bulges ~0.4% beyond OCCT's single-chord bevel (e.g. an equilateral triangle: Manifold 235.46 vs OCCT 234.50). `round` and `sharp` are exact across backends at every angle; prefer them, or accept the small acute-corner difference on `chamfer`.
287
+
288
+ 2-D boolean ops are a **parity-relevant operation**: on OCCT they carry exact circular arcs and Bézier curves; on Manifold they facet curves to mesh LOD. Measure-parity (area, bounding box) holds within the tessellation tolerance as LOD converges — this is not a parity waiver.
289
+
290
+ A `Shape2D` may be passed directly as the `profile` to `extrude` — Manifold
291
+ extrudes its `CrossSection` directly (no re-tessellation) and OCCT extrudes its
292
+ `Drawing` directly, including any holes it already carries.
293
+
294
+ ## The 2-D helper library
295
+
296
+ `partforge/geometry` ships pure-JS helpers of two kinds. The **contour builders**
297
+ (`piePolygon`, `hexPolygon`, `regularPolygon`, `roundedRectPolygon`, `ellipsePolygon`,
298
+ `slotPolygon`, `starPolygon`, `ringSectorPolygon`, `circleProfile`, `cornerArc`,
299
+ `filletPolygon`, `roundedProfile`) are pure functions from numbers to plain CCW point
300
+ lists or arc profiles — *data already in this contract's input format*, with no kernel
301
+ dependency at all. The **solid patterns** (`linearPattern`, `circularPattern`) take a
302
+ `Solid` and call only ops from the tables above (`clone`/`translate`/`rotate`/
303
+ `boundingBox`). The **profile transform** (`offsetPolygon`) takes a point list or
304
+ `{outer, holes}` region and grows or shrinks it by a delta in mm — printer-clearance
305
+ offsetting with round/chamfer/sharp corner styles — validating its input and result and
306
+ throwing rather than ever returning degenerate (self-intersecting or collapsed)
307
+ geometry. All three kinds are therefore portable by construction: a host implements
308
+ the kernel and the helpers come along unmodified. (`test/kernel-contract.test.js`
309
+ asserts every `polygon.js` export is named here.)
310
+
311
+ - `pathProfile` — fluent builder for a curve-native path contour (`lineTo` /
312
+ `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
313
+ facet at mesh LOD on Manifold.
314
+
315
+ ## Worker rebind
316
+
317
+ The op tables above are the portable seam for *geometry*; this section is the matching
318
+ seam for *worker lifetime*. A host that shows one part after another (an embedder, the
319
+ cloud runner) can keep a single worker across the swap and reuse its booted WASM kernel
320
+ and warm solid cache instead of paying the boot cost again.
321
+
322
+ `runWorker(part)` (`src/framework/worker.js`) returns a rebind handle —
323
+ `{ setPart(newPart) }` — and that handle is the whole interface. The framework defines
324
+ **no rebind *message***: a host that talks to its worker over its own re-init protocol
325
+ maps that protocol onto `setPart` itself.
326
+
327
+ `setPart(newPart)` does four things, synchronously, on the worker's own turn:
328
+
329
+ - **Swaps the part** for jobs that arrive *after* the call. Jobs already queued keep the
330
+ part that was current when their message arrived — a job always runs against the part
331
+ it was sent for, never against a part that replaced it mid-flight.
332
+ - **Bumps the generate epoch**, which is what makes earlier builds stale (below).
333
+ - **Sweeps each booted kernel's solid cache** — one `sweepCache()` per booted kernel,
334
+ never inside a `beginSubPart`/`endSubPart` bracket. See the Optional ops paragraph
335
+ under [Conformance classes](#conformance-classes) for what the sweep evicts; a host
336
+ whose kernel omits the op simply keeps every partition.
337
+ - **Re-posts `{type:"ready"}`**, so a remounting host gates its first generate on
338
+ readiness exactly as it would on a freshly spawned worker.
339
+
340
+ **Epoch guard.** Generates supersede each other; exports (`export-stl`/`export-step`/
341
+ `export-3mf`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
342
+ export because an edit landed would be wrong. A generate that is stale by the time the
343
+ job pump reaches it is skipped and never builds at all. A generate already running
344
+ re-checks staleness at each sub-part boundary and, if it has been superseded, stops
345
+ there and posts `{type:"superseded"}` **instead of** `{type:"meshes"}` — a build that
346
+ ended without producing meshes, and not an error. A generate with no boundary left to
347
+ stop at — one that goes stale during its *final* sub-part, or a single-sub-part generate
348
+ that goes stale once the pump has dequeued it — runs to completion, and the worker then
349
+ discards its result the same way, posting `{type:"superseded"}` in place of the meshes it
350
+ built. So **a `meshes` post is current as of the moment it is posted**: it is never a
351
+ previous part's geometry surfacing after a rebind, and a host may take it as the build
352
+ outcome for the part it currently has mounted.
353
+
354
+ **Host-side rule for `superseded`.** partforge's own `mount()` does not handle a
355
+ `superseded` message, and does not need to: in its single-mount flow the regen loop
356
+ serializes generates, so no generate is ever in flight when the next one is sent and the
357
+ message is unreachable. An **embedding host that rebinds via `setPart` must** do one of
358
+ two things:
359
+
360
+ - detach the old message listener before rebinding — the partforge-cloud pattern. A
361
+ rebound worker's next mount sends its first generate *after* `setPart`, so that
362
+ generate can never be stale, and any `superseded` from the previous mount lands on a
363
+ listener that is already gone; or
364
+ - handle `superseded` explicitly as "this build ended without meshes" — clear the busy
365
+ state, keep the current geometry, and wait for the next result. A host that instead
366
+ lets it fall through a `meshes`-only handler leaves a spinner up forever.
367
+
368
+ **Only `meshes` is epoch-gated**, so the second option is the weaker one. A stale build's
369
+ other posts — `progress`, `error`, `needs-occt` — are not gated and still reach a listener
370
+ that survived the rebind: a stale `error` would mark a perfectly good new part failed, and
371
+ a stale `needs-occt` would stickily flip the host's backend for a part that never asked for
372
+ it. Handling `superseded` fixes the stuck spinner but not that crosstalk, which is why
373
+ detaching the listener is the recommended pattern.
374
+
375
+ **Cancellation granularity is the sub-part.** The guard is checked only between
376
+ sub-parts (one macrotask yield each), so a single long WASM op — a big boolean, an OCCT
377
+ fillet — runs to completion no matter how stale it is. That is by design: WASM kernel
378
+ calls are not interruptible, and a build that abandons a sub-part mid-bracket would
379
+ strand pinned cache entries. Hosts should size responsiveness expectations against the
380
+ slowest single sub-part, not the whole build. Cancellation is therefore about *work
381
+ avoided*, never about correctness of what is posted: work already under way may finish,
382
+ but its output is still gated behind the epoch before it leaves the worker.
383
+
384
+ ## Versioning
385
+
386
+ The contract version is the number at the top of this document, mirrored by
387
+ `CONTRACT_VERSION` in `kernel.js` (the parity test asserts the two match). The op lists
388
+ in `kernel.js` define the current surface; only breaking changes bump the version:
389
+
390
+ - **Additive** (new kernel/Solid op, new optional field on an options object, new
391
+ optional mesh-output field): contract version unchanged, minor npm release. Old parts
392
+ run everywhere; new parts need hosts that implement the new op.
393
+ - **Breaking** (changed signature or semantics, removed op, new *required* argument,
394
+ tightened validation that rejects previously valid input): contract version bump,
395
+ **major npm release**, and a migration note added here. Removal without a major bump
396
+ is forbidden.
397
+ - The naming vocabulary is frozen deliberately: where a name was arbitrary it matches
398
+ the OpenSCAD/Manifold/CadQuery consensus (`union`, `translate`, `rotate`, `mirror`;
399
+ `cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
400
+ transfer. Renames are breaking changes with no offsetting benefit — don't.
401
+
402
+ ## Why not an existing CAD language
403
+
404
+ Considered and rejected as the part format (2026-07; revisit if the landscape shifts):
405
+
406
+ - **CadQuery** — largest corpus after OpenSCAD, but its workplane-stack + string-selector
407
+ model is B-rep-native and cannot be implemented on the mesh backend; Python besides.
408
+ - **KCL (Zoo)** — designed for LLM generation, but young, sketch-plane-shaped, and tied
409
+ to one vendor's engine; adopting it costs the dual-backend seam.
410
+ - **replicad** — already the OCCT backend; part of partforge's value is papering over
411
+ its consuming-transform semantics. Matching downward would re-expose them.
412
+ - **OpenSCAD** — closest semantic cousin (Manifold is its modern engine) and the largest
413
+ LLM prior; unadoptable as syntax (own language, no fillets/STEP), so we align
414
+ *vocabulary* instead.
415
+
416
+ The recurring constraint: every op here is implementable on **both** a mesh-CSG kernel
417
+ and a B-rep kernel (see `docs/geometry-backend-strategy.md` for why that dual-backend
418
+ property is worth protecting — OCCT booleans are ~75–1400× slower). Generation *safety*
419
+ comes not from a restricted DSL but from the verify loop (`measure`/`verify` gates:
420
+ `bbox`, `volume`, `holes`, `watertight`, overlaps — plus `minWall` *warnings*, which
421
+ report but never fail) — a generator gets machine-checkable
422
+ pass/fail feedback per part, which a syntax could never provide.
423
+
424
+ ## Conformance checklist for a new backend or host
425
+
426
+ 1. Implement `KERNEL_OPS` + `SOLID_OPS` (stub `OCCT_ONLY_OPS`/`toSTEP` with
427
+ `KernelCapabilityError` if core class); route through `finishKernel()`/`addSugar()`
428
+ if building in-repo to inherit validation, sugar, and stubs.
429
+ 2. Pass `test/kernel-contract.test.js` (op-list parity) — add an equivalent for an
430
+ out-of-repo host.
431
+ 3. Honor the global semantics above (units, Z-up, CCW, value semantics, determinism).
432
+ 4. Run **every part in `src/parts/`** through `npx partforge measure` unmodified — the
433
+ directory, not this prose, is the acceptance suite (today that includes
434
+ `faceted-vase.js`, the `loft` exerciser, and — B-rep class — `filleted-box.js`).
435
+ Caveat: a part with no `verify` block (`filleted-box.js` today) exercises only the
436
+ default measure gates, so B-rep implementers should also render it and export STEP
437
+ rather than trust the exit code alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
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",
@@ -19,6 +19,7 @@
19
19
  "skills/partforge/SKILL.md",
20
20
  "docs/AUTHORING-PARTS.md",
21
21
  "docs/ERROR-PATTERNS.md",
22
+ "docs/KERNEL-CONTRACT.md",
22
23
  "README.md"
23
24
  ],
24
25
  "exports": {
@@ -22,10 +22,12 @@ export const KERNEL_OPS = [
22
22
  "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
23
23
  ];
24
24
 
25
- // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
26
- // jobs.js calls all of these via `?.`, so a backend may simply omit them.
25
+ // Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
26
+ // Both in-repo backends implement the brackets (only `cleanup` is Manifold-specific
27
+ // OCCT's replicad shapes need no dispose bookkeeping). jobs.js calls all of these via
28
+ // `?.`, so a third-party backend may simply omit them.
27
29
  export const KERNEL_OPTIONAL_OPS = [
28
- "beginSubPart", "endSubPart", "cacheStats", "resetCacheStats", "cleanup",
30
+ "beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
29
31
  ];
30
32
 
31
33
  // Ops every Solid must implement (including the sugar addSugar() attaches).
@@ -109,8 +111,9 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
109
111
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
110
112
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
111
113
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
112
- * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
114
+ * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (both backends)
113
115
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
116
+ * @property {() => void} [sweepCache] drop cache partitions idle for 3 rebinds; call once per setPart, never mid-bracket
114
117
  * @property {() => {hits:number,misses:number}} [cacheStats]
115
118
  * @property {() => void} [resetCacheStats]
116
119
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
@@ -246,6 +246,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
246
246
  shape2d,
247
247
  beginSubPart: (name) => cache.begin(name),
248
248
  endSubPart: () => cache.end(),
249
+ sweepCache: () => cache.sweep(),
249
250
  cacheStats: () => cache.stats(),
250
251
  resetCacheStats: () => cache.resetStats(),
251
252
  // Free every WASM object created since the last cleanup EXCEPT solids the cache
@@ -32,6 +32,41 @@ import { composePose, transformPositions } from "./pose.js";
32
32
  import { meshToStl } from "./mesh-stl.js";
33
33
  const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
34
34
 
35
+ // Run replicad's `exportSTEP` (via `runExport`) and return the STEP bytes as a
36
+ // standalone ArrayBuffer WITHOUT touching a Blob — the sandbox worker on
37
+ // Safari/Firefox cannot read a Blob. replicad writes the STEP text to OCCT's
38
+ // virtual FS and reads it back with oc.FS.readFile; we wrap that read to capture
39
+ // the bytes, then hand them straight back. Two Safari-specific hazards, both
40
+ // handled here:
41
+ // * The captured Uint8Array is a view into the WASM heap, so we copy it
42
+ // immediately (.slice()) — the post-write cleanup can move/free the heap.
43
+ // * On Safari's OCCT build, that cleanup THROWS a destructor-signature
44
+ // mismatch AFTER the file is fully written and read ("...Write Done", then a
45
+ // RuntimeError: rawDestructor). The bytes are already captured, so the export
46
+ // succeeded — swallow the cleanup crash and return them. Only rethrow if
47
+ // nothing was captured (a genuine export failure).
48
+ // Exported for direct unit testing of the crash-tolerance (the fatal path only
49
+ // reproduces in real Safari).
50
+ export function stepBytesViaFsCapture(oc, runExport) {
51
+ const realRead = oc.FS.readFile; // restore this exact ref (no bind accumulation across exports)
52
+ let captured = null;
53
+ oc.FS.readFile = (path, ...rest) => {
54
+ const bytes = realRead.call(oc.FS, path, ...rest);
55
+ if (typeof path === "string" && path.toLowerCase().endsWith(".step")) captured = bytes.slice();
56
+ return bytes;
57
+ };
58
+ try {
59
+ runExport();
60
+ } catch (e) {
61
+ if (!captured) throw e; // failed before producing any STEP bytes — a real error
62
+ // else: post-write cleanup crashed after the file was captured; ignore it.
63
+ } finally {
64
+ oc.FS.readFile = realRead;
65
+ }
66
+ if (!captured || captured.byteLength === 0) throw new Error("STEP export produced no bytes");
67
+ return captured.buffer;
68
+ }
69
+
35
70
  export function createOcctKernel(replicad) {
36
71
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
37
72
  makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane, getOC } = replicad;
@@ -449,30 +484,12 @@ export function createOcctKernel(replicad) {
449
484
  });
450
485
  },
451
486
  shape2d,
452
- toSTEP: (named) => {
453
- // Blob-free STEP: replicad's exportSTEP writes the STEP text to OCCT's
454
- // virtual FS, reads it, and wraps it in a Blob. Safari's sandbox worker
455
- // cannot read a Blob, so we intercept the FS read to capture the raw
456
- // Uint8Array before it is wrapped, and return an ArrayBuffer instead. The
457
- // interception is synchronous (exportSTEP is sync) and restored in finally.
458
- const oc = getOC();
459
- const realRead = oc.FS.readFile.bind(oc.FS);
460
- let captured = null;
461
- oc.FS.readFile = (path, ...rest) => {
462
- const bytes = realRead(path, ...rest);
463
- if (typeof path === "string" && path.toLowerCase().endsWith(".step")) captured = bytes;
464
- return bytes;
465
- };
466
- try {
467
- exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s })));
468
- } finally {
469
- oc.FS.readFile = realRead;
470
- }
471
- if (!captured) throw new Error("STEP export produced no bytes");
472
- return Promise.resolve(captured.buffer.slice(captured.byteOffset, captured.byteOffset + captured.byteLength));
473
- },
487
+ toSTEP: (named) =>
488
+ Promise.resolve(stepBytesViaFsCapture(getOC(), () =>
489
+ exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))))),
474
490
  beginSubPart: (name) => cache.begin(name),
475
491
  endSubPart: () => cache.end(),
492
+ sweepCache: () => cache.sweep(),
476
493
  cacheStats: () => cache.stats(),
477
494
  resetCacheStats: () => cache.resetStats(),
478
495
  });
@@ -6,11 +6,13 @@
6
6
  export function createSolidCache() {
7
7
  const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
8
8
  const pinned = new Set(); // every live `pin` across all sub-parts
9
+ const lastBuilt = new Map(); // name -> rebind generation of the partition's last begin()
10
+ let generation = 0; // bumped only by sweep() (i.e. per part rebind)
9
11
  let name = null, active = null, prev = null;
10
12
  let hits = 0, misses = 0;
11
13
 
12
14
  return {
13
- begin(n) { name = n; prev = caches.get(n) ?? new Map(); active = new Map(); },
15
+ begin(n) { name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map(); },
14
16
 
15
17
  end() {
16
18
  if (name == null) return;
@@ -21,6 +23,20 @@ export function createSolidCache() {
21
23
  name = null; active = prev = null;
22
24
  },
23
25
 
26
+ // Rebind hygiene: called once per setPart() (never mid-bracket — the worker's
27
+ // job queue is serial). Partitions a rebind renamed or deleted would otherwise
28
+ // pin their last build's solids until worker death; three idle generations is
29
+ // the eviction line, so recently-viewed views stay warm across edits.
30
+ sweep() {
31
+ generation++;
32
+ for (const [n, entries] of caches) {
33
+ if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
34
+ for (const entry of entries.values()) { pinned.delete(entry.pin); entry.dispose(); }
35
+ caches.delete(n);
36
+ lastBuilt.delete(n);
37
+ }
38
+ },
39
+
24
40
  lookup(hash, make) {
25
41
  if (name == null) return make().value; // not bracketed → no caching
26
42
  if (active.has(hash)) { hits++; return active.get(hash).value; }
@@ -45,6 +45,9 @@ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress
45
45
  // { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
46
46
  // { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
47
47
  // { type:"export-step", view, params } → { type:"download", data, filename, mime }
48
+ // A generate also accepts `opts.isStale` — a caller-supplied predicate checked at each
49
+ // sub-part boundary — and answers { type:"superseded" } instead of `meshes` when it
50
+ // stops early (a build that ended without meshes, not an error; see KERNEL-CONTRACT.md).
48
51
  // Each result branch declares its own transferables (the big binary buffers,
49
52
  // zero-copy across the worker boundary) right where the buffers are created —
50
53
  // so a new job type can't silently regress to structured-cloning its payload.
@@ -53,7 +56,8 @@ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress
53
56
  // preview generates stay quiet (no callback) to avoid flicker during slider drags.
54
57
  const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
55
58
 
56
- export async function handle(kernel, part, msg, post) {
59
+ export async function handle(kernel, part, msg, post, opts = {}) {
60
+ const isStale = opts.isStale ?? (() => false);
57
61
  const onProgress = (phase) => post({ type: "progress", phase });
58
62
  const label = (name) => part.parts[name].label ?? name;
59
63
  const exportName = (name) => part.parts[name].export?.name ?? name;
@@ -78,7 +82,7 @@ export async function handle(kernel, part, msg, post) {
78
82
  const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
79
83
  const meshes = [];
80
84
  kernel.resetCacheStats?.(); // count hits/misses for just this job
81
- for (const name of msg.subparts) {
85
+ for (const [i, name] of msg.subparts.entries()) {
82
86
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
83
87
  try {
84
88
  const m = posed(name, "display").toMesh({ quality: "preview" });
@@ -87,6 +91,15 @@ export async function handle(kernel, part, msg, post) {
87
91
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
88
92
  kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
89
93
  }
94
+ // Cooperative cancel: yield one macrotask so queued messages (a newer
95
+ // generate, a part rebind) can be seen, then stop at this boundary if
96
+ // this build is stale. The last sub-part skips the yield — nothing
97
+ // follows it. Completed sub-parts have already committed their cache
98
+ // brackets, so an abort here strands nothing.
99
+ if (i < msg.subparts.length - 1) {
100
+ await new Promise((r) => setTimeout(r, 0));
101
+ if (isStale()) return void post({ type: "superseded" });
102
+ }
90
103
  }
91
104
  const transfer = meshes.flatMap((m) =>
92
105
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
@@ -2,6 +2,12 @@
2
2
  // "manifold" (preview + STL) and "occt" (STEP), via the Worker `name` option.
3
3
  // Each instance lazily imports only its own backend, so OCCT's ~11 MB WASM loads
4
4
  // only in the worker that needs it, and only on first use.
5
+ //
6
+ // runWorker() returns a rebind handle — { setPart(newPart) } — so a host that
7
+ // swaps parts (an embedder, the cloud runner) can keep this worker and its warm
8
+ // kernel instead of tearing it down. The rebind contract (what setPart
9
+ // guarantees about epochs, cache sweeps, and the re-posted ready) is normative
10
+ // in docs/KERNEL-CONTRACT.md.
5
11
  import { handle } from "./jobs.js";
6
12
  import { lintPart } from "../lint.js";
7
13
 
@@ -35,10 +41,19 @@ export function runWorker(part) {
35
41
  let manifold = null; // { preview, print }
36
42
  let occt = null;
37
43
  let booting = null;
44
+ let current = part; // rebindable via the returned handle's setPart()
45
+ let epoch = 0; // bumped per incoming generate and per setPart
46
+ const queue = []; // { data, part, epoch } — jobs run against the part current at arrival
47
+ let pumping = false;
38
48
 
39
49
  // Manifold is cheap to boot — bring it up eagerly and signal readiness.
40
50
  if (backend === "manifold") {
41
51
  booting = manifoldKernels().then((m) => { manifold = m; postMessage({ type: "ready" }); });
52
+ // A failed boot is reported to the host by the first job that awaits `booting`
53
+ // (the pump's error boundary posts it). This no-op handler only keeps the eager
54
+ // rejection from surfacing as an unhandled rejection before that job arrives —
55
+ // `booting` itself still rejects for kernelFor.
56
+ booting.catch(() => {});
42
57
  } else {
43
58
  // OCCT boots lazily (its ~11 MB WASM loads on the first job), but the worker can
44
59
  // accept jobs as soon as its module graph is up — messages queue in the port.
@@ -48,30 +63,97 @@ export function runWorker(part) {
48
63
  postMessage({ type: "ready" });
49
64
  }
50
65
 
51
- self.onmessage = async (e) => {
52
- // Lint is geometry-free by construction, so answer it before touching — or
53
- // booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
54
- // the branches below await that boot, so routing lint through them would drag
55
- // in OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
56
- if (e.data?.type === "lint") {
57
- postMessage({ type: "lint-report", report: lintPart(part, { params: e.data.params }) });
58
- return;
59
- }
60
- let kernel;
66
+ async function kernelFor(data) {
61
67
  if (backend === "manifold") {
62
68
  await booting;
63
69
  // The sender declares the job's mesh quality; the worker knows nothing about
64
70
  // job-type semantics (mount marks STL/3MF exports quality:"print").
65
- kernel = e.data.quality === "print" ? manifold.print : manifold.preview;
66
- } else {
67
- if (!occt) {
68
- postMessage({ type: "progress", phase: "loading exact kernel" }); // feedback during cold boot
69
- booting = booting ?? occtKernel().then((k) => (occt = k));
70
- await booting;
71
+ return data.quality === "print" ? manifold.print : manifold.preview;
72
+ }
73
+ if (!occt) {
74
+ postMessage({ type: "progress", phase: "loading exact kernel" }); // feedback during cold boot
75
+ booting = booting ?? occtKernel().then((k) => (occt = k));
76
+ await booting;
77
+ }
78
+ return occt;
79
+ }
80
+
81
+ // Serial pump: exactly one job at a time. Jobs yield between sub-parts
82
+ // (jobs.js) so newer messages can enqueue — without this queue two handle()
83
+ // calls could interleave on the same kernel.
84
+ async function pump() {
85
+ if (pumping) return;
86
+ pumping = true;
87
+ try {
88
+ while (queue.length) {
89
+ const job = queue.shift();
90
+ // Error boundary around the whole body. handle() reports build failures itself,
91
+ // but kernelFor can reject — a WASM asset that 404s, an OOM during boot — and an
92
+ // escaping rejection would kill the pump: this job AND everything queued behind
93
+ // it would be dropped with no reply at all, leaving the host waiting on a message
94
+ // that never comes until its own timeout fires.
95
+ try {
96
+ // A generate superseded while it sat in the queue never builds at all.
97
+ if (job.epoch !== null && job.epoch !== epoch) continue;
98
+ const kernel = await kernelFor(job.data);
99
+ // handle() declares each message's transferables (the big binary buffers).
100
+ const post = (m, transfer = []) => postMessage(m, transfer);
101
+ if (job.epoch === null) { await handle(kernel, job.part, job.data, post); continue; }
102
+ const isStale = () => job.epoch !== epoch;
103
+ // Post gate. The boundary check cannot catch a generate that goes stale during
104
+ // its FINAL sub-part — there is no boundary after it — nor a single-sub-part
105
+ // generate that goes stale once dequeued. Both would otherwise post the OLD
106
+ // part's meshes after a rebind. Downgrading them to `superseded` keeps the
107
+ // contract simple: a `meshes` post is current as of the moment it is posted.
108
+ const gated = (m, transfer = []) =>
109
+ (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
110
+ await handle(kernel, job.part, job.data, gated, { isStale });
111
+ } catch (err) {
112
+ // Same shape jobs.js posts for a failed build, so hosts need no new branch.
113
+ postMessage({ type: "error", message: String(err?.message || err) });
114
+ }
71
115
  }
72
- kernel = occt;
116
+ } finally {
117
+ pumping = false;
73
118
  }
74
- // handle() declares each message's transferables (the big binary buffers).
75
- await handle(kernel, part, e.data, (m, transfer = []) => postMessage(m, transfer));
119
+ }
120
+
121
+ self.onmessage = (e) => {
122
+ // Lint is geometry-free by construction, so answer it before touching — or
123
+ // booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
124
+ // the pump awaits that boot, so routing lint through the queue would drag in
125
+ // OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
126
+ if (e.data?.type === "lint") {
127
+ postMessage({ type: "lint-report", report: lintPart(current, { params: e.data.params }) });
128
+ return;
129
+ }
130
+ // Only generates supersede each other; exports/inspect always run (cancelling
131
+ // a user's export because an edit landed would be wrong).
132
+ const supersedes = e.data?.type === "generate";
133
+ if (supersedes) epoch++;
134
+ queue.push({ data: e.data, part: current, epoch: supersedes ? epoch : null });
135
+ void pump();
136
+ };
137
+
138
+ return {
139
+ // Rebind contract (docs/KERNEL-CONTRACT.md): swap the part, cancel stale
140
+ // builds, sweep idle cache partitions, and re-post ready so a remounting
141
+ // host gates its first generate exactly as on a fresh worker.
142
+ //
143
+ // Never runs mid-bracket, so the sweep is always safe: setPart runs
144
+ // synchronously on the worker's own turn, and jobs.js opens and closes each
145
+ // sub-part's beginSubPart/endSubPart bracket inside a single synchronous
146
+ // turn (its only awaits are between sub-parts). The serial pump keeps at
147
+ // most one handle() in flight, so there is no second bracket to land in
148
+ // either. An in-flight generate sees the bumped epoch at its next sub-part
149
+ // boundary and stops there.
150
+ setPart(newPart) {
151
+ current = newPart;
152
+ epoch++;
153
+ manifold?.preview.sweepCache?.();
154
+ manifold?.print.sweepCache?.();
155
+ occt?.sweepCache?.();
156
+ postMessage({ type: "ready" });
157
+ },
76
158
  };
77
159
  }