partforge 0.56.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.56.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",
@@ -249,11 +249,19 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
249
249
  scrubWrap.addEventListener("pointerleave", onWrapPointerLeave);
250
250
  scrubWrap.addEventListener("pointercancel", onWrapPointerLeave);
251
251
 
252
+ // Late-bound hook into the placement section below (a plain call would hit
253
+ // the TDZ on its `let placementRaf` — setup invokes syncStructure before the
254
+ // placement block runs). Wired to schedulePlacement once that block exists,
255
+ // so a view switch that shows/hides the bar re-publishes --pf-anim-clear
256
+ // even where ResizeObserver is absent.
257
+ let onStructureChanged = null;
258
+
252
259
  // Per-view + per-animation chrome: which chooser shows, the picker's options,
253
260
  // title, ⓘ description, pager labels, scrubber ticks. A view with no
254
261
  // animations hides the whole bar rather than showing an empty transport.
255
262
  function syncStructure() {
256
263
  bar.style.display = current ? "" : "none";
264
+ onStructureChanged?.();
257
265
  hideChapterBubble();
258
266
  if (!current) return;
259
267
  const paged = animations.length > 1;
@@ -536,10 +544,20 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
536
544
  bar.style.maxWidth = "";
537
545
  bar.style.overflow = "";
538
546
  bar.classList.remove("pf-squeezed");
539
- const vb = viewbarEl?.getBoundingClientRect();
547
+ const stageRect = container.getBoundingClientRect();
540
548
  const barRect = bar.getBoundingClientRect();
549
+ // Publish the bar's vertical claim on the stage as --pf-anim-clear: the
550
+ // distance from the stage's bottom edge to the bar's top, 0px when the bar
551
+ // is hidden (a view with no animations). Hosts that float their own chrome
552
+ // at the stage's bottom-centre (partforge-cloud's status/forging stack)
553
+ // read it to sit above the bar instead of under it; with no bar mounted
554
+ // the property is never set and a var() fallback of 0px applies.
555
+ const clear = bar.style.display === "none"
556
+ ? 0
557
+ : Math.max(0, Math.round(stageRect.bottom - barRect.top));
558
+ container.style.setProperty("--pf-anim-clear", `${clear}px`);
559
+ const vb = viewbarEl?.getBoundingClientRect();
541
560
  if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
542
- const stageRect = container.getBoundingClientRect();
543
561
  const plan = planAnimBarPlacement({
544
562
  stageWidth: stageRect.width,
545
563
  barWidth: barRect.width,
@@ -581,6 +599,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
581
599
  placementObserver.observe(bar);
582
600
  if (viewbarEl) placementObserver.observe(viewbarEl);
583
601
  }
602
+ onStructureChanged = schedulePlacement; // see the hook's declaration above
584
603
  schedulePlacement();
585
604
 
586
605
  const runtime = {
@@ -662,6 +681,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
662
681
  scrubWrap.removeEventListener("pointercancel", onWrapPointerLeave);
663
682
  hideChapterBubble(); // also clears hoverInside
664
683
  chapterBubble.remove(); // a stage child, so the bar taking itself out misses it
684
+ container.style.removeProperty("--pf-anim-clear"); // no bar, no claim
665
685
  bar.remove();
666
686
  },
667
687
  __viewer: viewer, // test hook only
@@ -234,7 +234,13 @@
234
234
  #viewbar's, so a host that re-anchors this bar still inherits its chrome.
235
235
  animation-controls.js may inline-override left/transform/max-width (and
236
236
  overflow while width-capped) to hold a 10px gap to #viewbar, and clears
237
- the overrides whenever centered placement fits. */
237
+ the overrides whenever centered placement fits.
238
+
239
+ animation-controls.js also publishes --pf-anim-clear on the STAGE element:
240
+ the px distance from the stage's bottom edge to the visible bar's top (0px
241
+ while the active view has no animations; unset when no view declares any).
242
+ A host floating its own chrome at the stage's bottom-centre should anchor it
243
+ at calc(var(--pf-anim-clear, 0px) + <gap>) to stack above the bar. */
238
244
  .pf-anim-bar {
239
245
  position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
240
246
  z-index: 15; max-width: calc(100% - 24px);
@@ -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) =>
@@ -453,8 +453,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
453
453
  if (needed.every(isCurrent)) {
454
454
  showView(needed);
455
455
  ui.setExportEnabled(true);
456
- const tris = needed.reduce((s, n) => s + viewer.subTriangles(n), 0);
457
- ui.setStatus(`${tris.toLocaleString()} triangles`);
456
+ // The line used to read "N triangles" here; that debug readout now goes
457
+ // to the console (see the `meshes` case). Clearing keeps a stale
458
+ // "phase…" from outliving the build it described.
459
+ ui.setStatus("");
458
460
  } else if (needed.every((n) => viewer.hasSubMesh(n))) {
459
461
  showView(needed); // stale but present — keep it visible during regenerate
460
462
  ui.setExportEnabled(false);
@@ -498,7 +500,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
498
500
  ui.hideBusy();
499
501
  refreshView();
500
502
  if (data.ms && missingParts().length === 0) {
501
- ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
503
+ const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
504
+ console.debug(`partforge: built ${tris.toLocaleString()} triangles in ${(data.ms / 1000).toFixed(1)} s`);
502
505
  }
503
506
  dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
504
507
  onBuild?.({ status: "success", ms: data.ms });
@@ -545,8 +548,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
545
548
  case "error":
546
549
  loop.buildDone();
547
550
  ui.hideBusy();
548
- ui.setStatus(`failed: ${data.message}`, true);
551
+ // refreshView FIRST: its all-current branch clears the status line,
552
+ // so writing the failure after it keeps the message on screen.
549
553
  refreshView();
554
+ ui.setStatus(`failed: ${data.message}`, true);
550
555
  onBuild?.({ status: "error", error: data.message });
551
556
  if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
552
557
  break;
@@ -10,6 +10,5 @@ export function createStatusUi({ status, busy, phase, exports = [] }) {
10
10
  showBusy(p) { phase.textContent = `${p}…`; busy.classList.add("show"); },
11
11
  hideBusy() { busy.classList.remove("show"); },
12
12
  setExportEnabled(on) { exportBtns.forEach((b) => { b.disabled = !on; }); },
13
- statusText: () => status.textContent,
14
13
  };
15
14
  }
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