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.
- package/docs/KERNEL-CONTRACT.md +437 -0
- package/package.json +2 -1
- package/src/framework/geometry/kernel.js +7 -4
- package/src/framework/geometry/manifold-backend.js +1 -0
- package/src/framework/geometry/occt-backend.js +39 -22
- package/src/framework/geometry/solid-cache.js +17 -1
- package/src/framework/jobs.js +15 -2
- package/src/framework/worker.js +101 -19
|
@@ -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.
|
|
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
|
|
26
|
-
//
|
|
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 (
|
|
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
|
-
|
|
454
|
-
|
|
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; }
|
package/src/framework/jobs.js
CHANGED
|
@@ -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));
|
package/src/framework/worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
116
|
+
} finally {
|
|
117
|
+
pumping = false;
|
|
73
118
|
}
|
|
74
|
-
|
|
75
|
-
|
|
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
|
}
|