partforge 0.31.0 → 0.33.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/README.md CHANGED
@@ -128,6 +128,20 @@ so **the rail must be a direct child of the positioned `.pf-shell`** unless
128
128
  the host also supplies `elements.shell` to point at the real containing block
129
129
  (e.g. when a wrapper div sits between them, as is common in a React layout).
130
130
 
131
+ `runtime.setParams(partial)` edits parameters programmatically — the entry point
132
+ for animating a part from host code:
133
+
134
+ ```js
135
+ runtime.setParams({ openAngle: 45 }); // merges into the live params, syncs the panel
136
+ ```
137
+
138
+ The partial is merged into the current params and the control panel updates to
139
+ match. Keys the part doesn't define are silently ignored. When every changed
140
+ parameter only moves geometry (a rotation or translation in `place()`), the
141
+ viewer re-poses the meshes it already has — instantly, with no worker rebuild;
142
+ `onBuild` does not fire for those pose-only edits. Anything that changes the
143
+ geometry itself rebuilds as usual.
144
+
131
145
  `onPick` arms click-to-select permanently: `label` is the feature label (falling
132
146
  back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
133
147
  sentence, `token` the compact form, `selection` the raw object. When `onPick` is
@@ -342,7 +342,9 @@ This holds on **both backends** — and on OCCT, `translate`/`rotate` are additi
342
342
  re-running any B-rep work. A parameter that only feeds a final placement rotation (a
343
343
  lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on the
344
344
  slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
345
- rather than baking them into the geometry earlier.
345
+ rather than baking them into the geometry earlier. In the app, such pose-only edits
346
+ skip the worker entirely — the viewer re-poses the cached mesh — so they stay smooth
347
+ even at animation rates (see `runtime.setParams`).
346
348
 
347
349
  ---
348
350
 
@@ -1217,6 +1219,63 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
1217
1219
  > `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
1218
1220
  > topology); `render` works on both.
1219
1221
 
1222
+ ### Cost: fillet/chamfer scale with edge count — and order matters
1223
+
1224
+ OCCT fillet/chamfer cost is **per selected edge**, on top of the OCCT boolean tax the
1225
+ routing already imposes on the rest of the part. Two habits keep it tolerable:
1226
+
1227
+ - **Fillet/chamfer as early as possible, on the simplest solid.** A fillet on a bare
1228
+ primitive is ~15× cheaper than the same fillet after a dozen boolean cuts have
1229
+ multiplied the face count — and because the solid cache keys each op by its input's
1230
+ content hash, an early fillet is a cache **hit** when a downstream parameter changes,
1231
+ while a fillet-last build re-pays the whole op on every slider step of every parameter.
1232
+ - **Never point a rim selector at a many-point extruded profile.** `edges: {inPlane}` on
1233
+ a gear-like extrusion selects *every* polygon edge (hundreds); one chamfer call then
1234
+ costs seconds — and if the distance doesn't fit the tooth lands, the failure-rescue
1235
+ bisection re-runs it ~8× (`ERROR-PATTERNS.md#chamfer-rescue-bisection`). Use the loft
1236
+ bevel below instead.
1237
+
1238
+ ### Beveling profile rims: extrude's bevel option
1239
+
1240
+ For an **extruded profile** (gear, star, bracket outline — any `k.extrude` of a polygon),
1241
+ a top/bottom rim bevel doesn't need `chamfer` at all — it's built into `extrude`:
1242
+
1243
+ ```js
1244
+ k.extrude({ profile: prof, h: 5, bevel: 0.6 }); // 45° bevel, both rims
1245
+ k.extrude({ profile: prof, h: 5, bevel: { top: 0.6 } }); // one rim only
1246
+ ```
1247
+
1248
+ Same 45° bevel a rim `chamfer` would cut, but it desugars into extrude + loft +
1249
+ intersect at the shared kernel front, so the part **stays on the fast Manifold
1250
+ backend** (no CAD-only op for the probe to find) and costs one boolean regardless of
1251
+ profile point count. Measured on a 24-tooth involute gear: ~0.1 s on Manifold vs
1252
+ ~40 s for the equivalent OCCT `chamfer` (576-edge rim × the rescue bisection).
1253
+
1254
+ Every profile form works: point arrays, arc profiles, `{outer, holes}` regions
1255
+ (hole rims flare outward — the opening is larger at the face, as a chamfer would
1256
+ cut it), and `Shape2D` (multi-region shapes bevel each region and union). One
1257
+ fidelity caveat: curved profiles are **materialized to point rings** first — the
1258
+ loft envelope needs matched points — so a beveled extrusion is faceted at the
1259
+ sampling LOD even in STEP export. Arc contours sample at a fixed LOD identically
1260
+ on both backends; a `Shape2D` materializes at its own backend's LOD. If you need
1261
+ arc-exact STEP walls, that's the one case native `chamfer` still buys you (at
1262
+ its OCCT cost).
1263
+
1264
+ Rules (throws otherwise — `ERROR-PATTERNS.md#extrude-bevel-invalid`): no
1265
+ `twist`/`scaleTop`, and `bottom + top < h` — clamp from your height parameter, e.g.
1266
+ `bevel: Math.min(p.chamfer, p.thickness / 2 - 0.2)`. A bevel that would pinch a
1267
+ narrow feature shut (a gear's tooth land) is deterministically reduced to the
1268
+ largest offset the rim can take, with a console warning
1269
+ (`ERROR-PATTERNS.md#extrude-bevel-reduced`).
1270
+
1271
+ Under the hood it insets the profile with `offsetPolygon(prof, -c, { corners:
1272
+ "sharp" })` and intersects with a loft envelope extended past both faces (so the
1273
+ envelope's own end caps never coincide with the extrusion's faces — coincident caps
1274
+ leave sliver-triangle shading artifacts). The same construction works by hand when
1275
+ you need a variant the option doesn't cover. This bevels a **whole rim**; for
1276
+ selective edges on a solid that's already OCCT-routed, plain `chamfer` with a tight
1277
+ selector is still the right tool.
1278
+
1220
1279
  ---
1221
1280
 
1222
1281
  ## Conventions & gotchas
@@ -55,6 +55,34 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
55
55
  - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT.
56
56
  - **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
57
57
 
58
+ ## fillet-chamfer-many-edges-slow
59
+
60
+ - **Symptom:** A part that fillets or chamfers the rim of an extruded profile (a gear, a star, any many-point polygon) takes many seconds — even tens of seconds — per build, with no error anywhere.
61
+ - **Cause:** OCCT fillet/chamfer cost scales with the number of selected edges, and an `inPlane` rim selector on a many-point extruded profile selects every polygon edge (hundreds for a gear), so one op call costs seconds — and re-runs on every parameter change.
62
+ - **Fix:** Use `extrude`'s `bevel` option instead of `chamfer` — same geometry, stays on the fast Manifold backend. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
63
+
64
+ ## chamfer-rescue-bisection
65
+
66
+ - **Symptom:** `partforge: chamfer` warning saying the distance `over-ran the geometry — reduced to` a smaller one (or `has no valid distance`), with an attempt count and elapsed seconds, alongside slow builds.
67
+ - **Cause:** The requested chamfer distance doesn't fit the geometry (it over-runs an adjacent face or a short edge), so the failure-rescue bisection in `occt-repair.js` re-runs the full chamfer up to 7 more times to find the largest valid distance — multiplying an already-expensive op by ~8× on every build, since the result is only cached per exact input hash.
68
+ - **Fix:** Lower the chamfer parameter to at most the printed valid distance (the rescue then never fires), clamp it in `build` from the geometry that limits it, or — for extruded profile rims — switch to `extrude`'s `bevel` option, which finds its own limit in pure JS. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
69
+
70
+ Variant literals under this entry: `partforge: chamfer <d> over-ran the geometry — reduced to <d'> (largest valid; <n> attempts, <t>s — see ERROR-PATTERNS.md#chamfer-rescue-bisection)`, `partforge: chamfer <d> has no valid distance for this geometry — feature skipped (<n> attempts, <t>s — see ERROR-PATTERNS.md#chamfer-rescue-bisection)`.
71
+
72
+ ## extrude-bevel-invalid
73
+
74
+ - **Symptom:** `extrude: bevel must fit the height (bottom + top < h)` or `extrude: bevel cannot combine with twist or scaleTop` thrown from a build.
75
+ - **Cause:** `extrude`'s `bevel` option desugars into offset-loft envelopes, which need an untwisted straight extrusion and room for both bevels inside the height.
76
+ - **Fix:** Clamp the bevel from the height parameter (e.g. `Math.min(c, h / 2 - 0.2)`) and drop `twist`/`scaleTop`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Beveling profile rims: extrude's bevel option".
77
+
78
+ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: bevel must be a number or { bottom?, top? }`, `extrude: bevel distances must be finite numbers >= 0`.
79
+
80
+ ## extrude-bevel-reduced
81
+
82
+ - **Symptom:** `partforge: extrude bevel` warning saying the requested distance `exceeds what the profile can take — reduced to` a smaller one (or `has no valid offset for this profile — rim left square`; `hole` in place of `profile` when a hole's flare is the limit).
83
+ - **Cause:** Offsetting the rim by the bevel distance would pinch a narrow feature (a tooth land, a thin bar, a thin web beside a hole) shut, so the bevel deterministically backs off to the largest offset the outline can take — the same geometric limit OCCT's chamfer hits, resolved in pure JS instead of kernel re-runs.
84
+ - **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature.
85
+
58
86
  ## boolean-not-watertight
59
87
 
60
88
  - **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
@@ -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.31.0",
3
+ "version": "0.33.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": {