partforge 0.57.0 → 0.58.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.
@@ -1195,7 +1195,7 @@ plate = plate.fillet(p.cornerR, { corners: "convex" });
1195
1195
  | `simplifyProfile(input, tolerance)` | corner-preserving: splits at corners, refits each smooth run within `tolerance` mm, rejoins — corners survive exactly, arcs entering it return as cubics |
1196
1196
  | `validateProfile(input)` | `{ok, issues: [{type, contourIndex, segmentIndex?, point?, message}]}`; never throws — `type` is `self-intersection`, `winding`, `nesting`, or `degenerate` |
1197
1197
 
1198
- Two rules worth internalizing before reaching for any of this:
1198
+ Three rules worth internalizing before reaching for any of this:
1199
1199
 
1200
1200
  - **Fillet after booleans if STEP `CIRCLE` fidelity matters.** Booleans run through
1201
1201
  paper.js, which is cubic-only — an arc entering a boolean returns as a cubic
@@ -1206,6 +1206,13 @@ Two rules worth internalizing before reaching for any of this:
1206
1206
  on a narrow profile can produce arcs that cross the far side). `validateProfile`
1207
1207
  never throws, so it's cheap to call after any edit and inspect `issues` before
1208
1208
  committing to the result.
1209
+ - **Guard vanishing features with `isEmpty()`.** A boolean chain can legitimately
1210
+ produce an *empty* shape (an `intersect` of shapes a parameter drove apart, a `cut`
1211
+ that removed everything). The empty shape is a fine 2-D value — further booleans,
1212
+ transforms and `offset` all work — but `extrude`/`revolve` throw on it, identically
1213
+ on both backends. If a parameter can drive a feature to nothing, write the guard
1214
+ explicitly: `if (!pocket.isEmpty()) body = body.cut(pocket.extrude({ h }))`.
1215
+ (Symptom-keyed: `ERROR-PATTERNS.md#extrude-empty-shape2d`.)
1209
1216
 
1210
1217
  A practical trap with the broad selectors: `"all"`/`"convex"`/`"concave"` match **every**
1211
1218
  matching corner, including ones you didn't mean to touch. Union a curve-native outline
@@ -1224,7 +1231,8 @@ New, all delegating to the pure functions above over the shape's stored contours
1224
1231
  `translate([dx,dy])`, `rotate(deg, center?)`, `scale(s | [sx,sy], center?)`,
1225
1232
  `mirror(axis)`, `toContours()` (the stored contour IR, deep-copied — the one readback
1226
1233
  that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(dist,
1227
- opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`.
1234
+ opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`, `isEmpty()` (no
1235
+ regions left — see the vanishing-features rule above).
1228
1236
 
1229
1237
  ## Convex hull
1230
1238
 
@@ -332,6 +332,12 @@ Variant literal for a curve-adjacent corner (note the semicolon form, not parent
332
332
  - **Cause:** Two regions in the profile occupy overlapping area without one being declared a hole of the other.
333
333
  - **Fix:** Union the two regions into one shape, or restructure the overlapping region as a `holes` entry of its container. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles" — run `validateProfile` after mutations.
334
334
 
335
+ ## extrude-empty-shape2d
336
+
337
+ - **Symptom:** `extrude: the profile Shape2D is empty — nothing to build (a cut/intersect may have removed everything; guard with .isEmpty())` (or the `revolve:` twin), often only at certain parameter values.
338
+ - **Cause:** A 2-D boolean chain legitimately produced an empty shape — an `intersect` of disjoint shapes, or a `cut` that removed everything — and the part handed it to `extrude`/`revolve`. Both backends reject this identically; a silently empty solid would just move the mystery downstream (missing geometry, failing `verify` volume gates).
339
+ - **Fix:** If the emptiness is a surprise, check the boolean operands' placement (`boundingBox()` on each side). If it's a legitimate vanishing feature (a parameter can drive it to nothing), guard the materialization: `if (!pocket.isEmpty()) body = body.cut(pocket.extrude({ h }))`. See [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) § "Empty shapes".
340
+
335
341
  ## curve-fill-resolved-hole-uncontained
336
342
 
337
343
  - **Symptom:** `curve-fill: resolved hole has no containing outer`
@@ -374,7 +374,7 @@ cross-backend note below.
374
374
  | Op | Contract |
375
375
  |---|---|
376
376
  | `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). Curve-exact and backend-identical (paper.js). |
377
- | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. The one backend-specific op: curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. |
377
+ | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. The one backend-specific op: curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. Empty in → empty out (short-circuits before the backend). |
378
378
  | `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². Curve-exact. |
379
379
  | `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds, curve-exact (no `center`/`size`, unlike `Solid.boundingBox`). |
380
380
  | `toRegions()` | Materialize into `{outer, holes}[]` point-ring region arrays (`assembleRegions`), tessellating curves at the backend's LOD; a boolean result may be several disjoint regions. |
@@ -386,10 +386,24 @@ cross-backend note below.
386
386
  | `simplify(tolerance)` | Corner-preserving decimation/refit within `tolerance` mm — dense point rings become fewer segments (and refit arcs/cubics) without moving corners. |
387
387
  | `corners()` | The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`. This positional order is what `fillet`/`chamfer`'s `{indices}` selects into. |
388
388
  | `contains([x,y])` | Point-in-shape test (inside an outer, not inside a hole). |
389
- | `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` `Solid`. |
390
- | `revolve({degrees?})` | Sugar for `k.revolve({profile: this, …})` → `Solid`. |
389
+ | `isEmpty()` | `true` when the shape has no regions at all — a `cut`/`intersect` legitimately removed everything. Pure JS on the stored IR, backend-identical. See "Empty shapes" below. |
390
+ | `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. Throws on an empty shape (see "Empty shapes"). |
391
+ | `revolve({degrees?})` | Sugar for `k.revolve({profile: this, …})` → `Solid`. Throws on an empty shape (see "Empty shapes"). |
391
392
  | `clone()` | Independent copy. Every op returns a NEW `Shape2D`; no operand is ever mutated. |
392
393
 
394
+ **Empty shapes.** An empty `Shape2D` is a legal 2-D value, and every 2-D op is total
395
+ on it: booleans treat it as the identity/absorbing element, transforms and `offset`
396
+ return it unchanged, `area()` is 0, `toRegions()` is `[]`. What it cannot do is become
397
+ 3-D: `extrude` and `revolve` (either calling form, on both backends) throw
398
+ `"<op>: the profile Shape2D is empty — nothing to build (a cut/intersect may have
399
+ removed everything; guard with .isEmpty())"`. The check runs in the shared op-spec
400
+ layer before any backend materialization, so the two backends agree by construction.
401
+ A part whose parameters can drive a feature to nothing guards explicitly:
402
+ `if (!pocket.isEmpty()) body = body.cut(pocket.extrude({ h }))`. (Before this was
403
+ pinned, Manifold silently built an empty solid where OCCT threw — behavior no part
404
+ could rely on portably, so defining it follows the reference backend and is not a
405
+ contract break.)
406
+
393
407
  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`.
394
408
 
395
409
  `offset` is therefore **parity-relevant**: on OCCT the result carries exact arcs, on Manifold it is faceted at mesh LOD, and measure-parity holds within the tessellation tolerance as LOD converges (not a parity waiver). The three tessellating readbacks — `toRegions()`, `simple()`, `regions()` — are LOD-dependent for the same reason: they hand back point rings sampled at the backend's own segment count, so the two backends' output differs in vertex count and by the chord error, converging as LOD rises. Those four ops are the whole LOD-dependent surface; everything else is backend-identical.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.57.0",
3
+ "version": "0.58.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",
@@ -51,6 +51,7 @@ export const SHAPE2D_OPS = [
51
51
  "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
52
52
  "extrude", "revolve",
53
53
  "translate", "rotate", "scale", "mirror", "toContours", "fillet", "chamfer", "simplify", "corners", "contains",
54
+ "isEmpty",
54
55
  ];
55
56
 
56
57
  // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
@@ -94,7 +95,8 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
94
95
  * @property {(other: Shape2D|number[][]) => Shape2D} cut
95
96
  * @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
96
97
  * @property {(other: Shape2D|number[][]) => Shape2D} intersect
97
- * @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; the one backend-specific op (Clipper2 vs OCCT) — throws when the shape collapses
98
+ * @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; the one backend-specific op (Clipper2 vs OCCT) — throws when the shape collapses; empty in → empty out
99
+ * @property {() => boolean} isEmpty true when the shape has no regions (a cut/intersect removed everything); guard before extrude/revolve, which throw on an empty profile
98
100
  * @property {() => number} area net area (outers minus holes), mm² — curve-exact, not tessellated
99
101
  * @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds (curve-exact)
100
102
  * @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into point-ring region arrays (tessellated at the backend's LOD)
@@ -220,6 +220,16 @@ const checkScaleTop = (op) => (_profile, _h, opts) => {
220
220
  if ((opts?.scaleTop ?? 1) < 0) throw new Error(`${op}: scaleTop must be ≥ 0`);
221
221
  };
222
222
 
223
+ // An empty Shape2D (a cut/intersect can legitimately remove everything) is a
224
+ // valid 2-D value, but 3-D materialization must reject it identically on both
225
+ // backends — Manifold would silently build an empty solid, OCCT would throw a
226
+ // backend-specific error. `?.` because the probe's fake handle has no _regions.
227
+ const checkNonEmptyProfile = (op, profile) => {
228
+ if (profile && profile._shape2d && profile._regions?.length === 0)
229
+ throw new Error(`${op}: the profile Shape2D is empty — nothing to build ` +
230
+ "(a cut/intersect may have removed everything; guard with .isEmpty())");
231
+ };
232
+
223
233
  // Ops that were always options-only have no positional form to normalize —
224
234
  // toArgs validates keys/required and passes the object through unchanged, so a
225
235
  // typo'd key fails loudly instead of destructuring to undefined → NaN geometry.
@@ -236,8 +246,12 @@ export const KERNEL_OP_SPECS = {
236
246
  sphere: { toArgs: sphereArgs },
237
247
  box: { toArgs: boxArgs },
238
248
  prism: { toArgs: prismArgs, check: checkScaleTop("prism") },
239
- extrude: { toArgs: extrudeArgs, check: checkScaleTop("extrude") },
249
+ extrude: { toArgs: extrudeArgs, check: (profile, h, opts) => {
250
+ checkNonEmptyProfile("extrude", profile);
251
+ checkScaleTop("extrude")(profile, h, opts);
252
+ } },
240
253
  revolve: { toArgs: revolveArgs, check: (pts) => {
254
+ checkNonEmptyProfile("revolve", pts);
241
255
  if (pts && pts._shape2d) {
242
256
  // The B-rep backend's Drawing bounding box is tolerance-padded (1e-6 on
243
257
  // every side, measured), so a lathe profile touching the revolve axis at
@@ -66,8 +66,12 @@ export function makeShape2dFactory({ segs, offsetRegions, extrude, revolve }) {
66
66
  // rings are explicitly closed. Both backends' readbacks close explicitly today, so this
67
67
  // is a no-op in practice; it's here so the storage invariant (every stored ring
68
68
  // explicitly closed — see closeContourGap's own comment) holds unconditionally.
69
- offset: (delta, opts = {}) => make(offsetRegions(regions, delta, opts)
69
+ // Empty in → empty out without calling the hook: the 2-D ops stay total on the
70
+ // empty shape on both backends (the OCCT hook would otherwise choke on a null
71
+ // Drawing); only 3-D materialization (extrude/revolve) rejects it.
72
+ offset: (delta, opts = {}) => regions.length === 0 ? make([]) : make(offsetRegions(regions, delta, opts)
70
73
  .map((rg) => ({ outer: closeContourGap(rg.outer), holes: rg.holes.map(closeContourGap) }))),
74
+ isEmpty: () => regions.length === 0,
71
75
  area: () => profileArea(regions),
72
76
  boundingBox: () => profileBounds(regions),
73
77
  toRegions: () => assembleRegions(regions.flatMap((rg) =>
package/types/kernel.d.ts CHANGED
@@ -199,9 +199,11 @@ export interface Shape2D {
199
199
  corners(): Corner2D[];
200
200
  /** Is `[x, y]` inside the shape (inside an outer, not inside a hole)? */
201
201
  contains(p: Point2): boolean;
202
- /** Sugar for `k.extrude({ profile: this, ... })`. */
202
+ /** No regions left (a cut/intersect removed everything)? Guard before `extrude`/`revolve`, which throw on an empty shape. */
203
+ isEmpty(): boolean;
204
+ /** Sugar for `k.extrude({ profile: this, ... })`. Throws if the shape is empty — guard with `isEmpty()`. */
203
205
  extrude(opts: { h: number; twist?: number; scaleTop?: number }): Solid;
204
- /** Sugar for `k.revolve({ profile: this, ... })`. */
206
+ /** Sugar for `k.revolve({ profile: this, ... })`. Throws if the shape is empty — guard with `isEmpty()`. */
205
207
  revolve(opts?: { degrees?: number }): Solid;
206
208
  }
207
209