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.
@@ -218,6 +218,7 @@ export function buildControls(root, parameters, params, onDirty) {
218
218
  const info = createInfoPopover();
219
219
  const controls = []; // { key, el } per control element
220
220
  const sections = []; // { el, keys:Set } per rendered section
221
+ const syncFns = []; // { key, sync } for every widget that can re-read params
221
222
  for (const sec of parameters) {
222
223
  if (!sectionRenders(sec)) continue;
223
224
  const section = el("div", "section");
@@ -225,7 +226,11 @@ export function buildControls(root, parameters, params, onDirty) {
225
226
  attachInfo(title, sec.description, info);
226
227
  section.append(title);
227
228
  const keys = new Set();
228
- const register = (key, node) => { controls.push({ key, el: node }); keys.add(key); };
229
+ const register = (key, node, sync) => {
230
+ controls.push({ key, el: node });
231
+ keys.add(key);
232
+ if (sync) syncFns.push({ key, sync });
233
+ };
229
234
  if (sec.features) buildFeatureSection(section, sec, params, onDirty, register, info);
230
235
  else buildPresetSection(section, sec, params, onDirty, register, info);
231
236
  root.append(section);
@@ -233,6 +238,12 @@ export function buildControls(root, parameters, params, onDirty) {
233
238
  }
234
239
  return {
235
240
  applyRelevance: (relevant) => applyRelevance(relevant, controls, sections),
241
+ // Re-read params into the widgets — all of them, or just `keys`. The
242
+ // programmatic twin of a user edit (setParams); never fires onDirty.
243
+ syncValues: (keys) => {
244
+ const only = keys && new Set(keys);
245
+ for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
246
+ },
236
247
  dispose: () => { info.dispose(); root.replaceChildren(); },
237
248
  };
238
249
  }
@@ -263,7 +274,7 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
263
274
  attachInfo(lbl, t.description, info);
264
275
  row.append(box, lbl);
265
276
  box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
266
- register(t.key, row);
277
+ register(t.key, row, () => { box.checked = params[t.key] > 0; });
267
278
  section.append(row);
268
279
  }
269
280
 
@@ -274,8 +285,11 @@ function buildPresetSection(section, sec, params, onDirty, register, info) {
274
285
  for (const def of advanced) {
275
286
  const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
276
287
  adv.append(s.wrap);
277
- syncs[def.key] = s.sync;
278
- register(def.key, s.wrap);
288
+ syncs[def.key] = s.sync; // raw: preset APPLICATION must not mark itself Custom
289
+ // A programmatic edit diverges from the preset exactly as a user edit does,
290
+ // so the picker falls back to Custom — leaving a stale preset name selected
291
+ // would also make it unre-appliable (no change event for the current option).
292
+ register(def.key, s.wrap, () => { s.sync(); if (preset) preset.value = "Custom"; });
279
293
  }
280
294
  section.append(toggle, adv);
281
295
  }
@@ -306,7 +320,12 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
306
320
  const featLabel = el("span", "", feat.label);
307
321
  attachInfo(featLabel, feat.description, info);
308
322
  checkRow.append(box, featLabel);
309
- register(feat.key, checkRow);
323
+ // `group` is created just below — the sync only ever runs after this
324
+ // function returns, so the closure is safely bound by then.
325
+ register(feat.key, checkRow, () => {
326
+ box.checked = params[feat.key] > 0;
327
+ group.classList.toggle("hidden", !box.checked);
328
+ });
310
329
 
311
330
  const group = el("div", "feat-group");
312
331
  const syncs = [];
@@ -314,7 +333,7 @@ function buildFeatureSection(section, sec, params, onDirty, register, info) {
314
333
  const s = makeParameterControl(def, params, onDirty, info);
315
334
  group.append(s.wrap);
316
335
  syncs.push(s.sync);
317
- register(def.key, s.wrap);
336
+ register(def.key, s.wrap, s.sync);
318
337
  }
319
338
  group.classList.toggle("hidden", !box.checked);
320
339
 
@@ -43,13 +43,21 @@ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
43
43
 
44
44
  cb.addEventListener("change", () => onToggle?.(cb.checked));
45
45
 
46
+ // What's currently on screen. update() MERGES over it, so a caller reporting one
47
+ // figure doesn't blank the others: a pose-only edit ran no build and no L2 ops,
48
+ // and would otherwise wipe the last build's timing back to "—" on its way to
49
+ // showing a posed count.
50
+ const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0 };
51
+
46
52
  return {
47
- update({ ms, hits = 0, misses = 0, skipped = 0, rebuilt = 0 } = {}) {
53
+ update(counts = {}) {
54
+ Object.assign(shown, counts);
55
+ const { ms, hits, misses, skipped, rebuilt, posed } = shown;
48
56
  const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
49
57
  readout.textContent =
50
58
  `build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
51
59
  `L2 ops: ${l2}\n` +
52
- `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
60
+ `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed`;
53
61
  },
54
62
  detach: () => box.remove(),
55
63
  };
@@ -25,6 +25,7 @@ const opentype = typeof opentypeNamespace.parse === "function"
25
25
  import { KernelCapabilityError } from "./errors.js";
26
26
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
27
27
  import { textGlyphs } from "./text2d.js";
28
+ import { beveledExtrude } from "./rim-bevel.js";
28
29
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
29
30
  import { convexHull, hullPoints } from "./hull.js";
30
31
 
@@ -45,6 +46,16 @@ export function finishKernel(k) {
45
46
  };
46
47
  }
47
48
 
49
+ // extrude({ bevel }) desugars here — before the spec-wrapped op ever sees it —
50
+ // into extrude + loft + intersect (rim-bevel.js), so both backends share one
51
+ // implementation and the probe records no CAD-only op. beveledExtrude calls
52
+ // back into the wrapped k.extrude/k.loft, so caching and validation apply.
53
+ const specExtrude = k.extrude;
54
+ k.extrude = (...a) =>
55
+ a.length === 1 && isPlainOptions(a[0]) && a[0].bevel !== undefined
56
+ ? beveledExtrude(k, a[0])
57
+ : specExtrude(...a);
58
+
48
59
  k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
49
60
  k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
50
61
 
@@ -22,10 +22,12 @@ export const KERNEL_OPS = [
22
22
  "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
23
23
  ];
24
24
 
25
- // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
26
- // jobs.js calls all of these via `?.`, so a backend may simply omit them.
25
+ // Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
26
+ // Both in-repo backends implement the brackets (only `cleanup` is Manifold-specific
27
+ // OCCT's replicad shapes need no dispose bookkeeping). jobs.js calls all of these via
28
+ // `?.`, so a third-party backend may simply omit them.
27
29
  export const KERNEL_OPTIONAL_OPS = [
28
- "beginSubPart", "endSubPart", "cacheStats", "resetCacheStats", "cleanup",
30
+ "beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
29
31
  ];
30
32
 
31
33
  // Ops every Solid must implement (including the sugar addSugar() attaches).
@@ -99,7 +101,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
99
101
  * @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
100
102
  * @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted until v2
101
103
  * @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
102
- * @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number}) => Solid} extrude polygon-with-holes region from z=0; legacy (profile,h,opts) accepted until v2
104
+ * @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted until v2
103
105
  * @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections; legacy (rings,opts) accepted until v2
104
106
  * @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
105
107
  * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
@@ -109,8 +111,9 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
109
111
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
110
112
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
111
113
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
112
- * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
114
+ * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (both backends)
113
115
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
116
+ * @property {() => void} [sweepCache] drop cache partitions idle for 3 rebinds; call once per setPart, never mid-bracket
114
117
  * @property {() => {hits:number,misses:number}} [cacheStats]
115
118
  * @property {() => void} [resetCacheStats]
116
119
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
@@ -246,6 +246,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
246
246
  shape2d,
247
247
  beginSubPart: (name) => cache.begin(name),
248
248
  endSubPart: () => cache.end(),
249
+ sweepCache: () => cache.sweep(),
249
250
  cacheStats: () => cache.stats(),
250
251
  resetCacheStats: () => cache.resetStats(),
251
252
  // Free every WASM object created since the last cleanup EXCEPT solids the cache
@@ -473,6 +473,7 @@ export function createOcctKernel(replicad) {
473
473
  },
474
474
  beginSubPart: (name) => cache.begin(name),
475
475
  endSubPart: () => cache.end(),
476
+ sweepCache: () => cache.sweep(),
476
477
  cacheStats: () => cache.stats(),
477
478
  resetCacheStats: () => cache.resetStats(),
478
479
  });
@@ -42,7 +42,9 @@ export function createOcctRepair(measureVolume) {
42
42
  // WASM heap doesn't grow across regenerates.
43
43
  const validChamfer = (shape, finderFn, distance) => {
44
44
  if (!(distance > 0)) return shape.clone();
45
+ let attempts = 0;
45
46
  const tryAt = (d) => {
47
+ attempts++;
46
48
  const probe = shape.clone();
47
49
  let res;
48
50
  try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
@@ -50,6 +52,7 @@ export function createOcctRepair(measureVolume) {
50
52
  res.delete?.();
51
53
  return null;
52
54
  };
55
+ const t0 = performance.now();
53
56
  let best = tryAt(distance);
54
57
  if (best) return best; // requested distance is valid
55
58
  let lo = 0, hi = distance, bestD = 0;
@@ -58,7 +61,12 @@ export function createOcctRepair(measureVolume) {
58
61
  const res = tryAt(mid);
59
62
  if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
60
63
  }
61
- if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
64
+ // The rescue re-ran the chamfer per attempt on a many-edge selection that
65
+ // multiplies an already-expensive op by ~8x, so make the cost loud enough to
66
+ // act on (lower the distance, or bevel profile rims with a loft instead).
67
+ const cost = `${attempts} attempts, ${((performance.now() - t0) / 1000).toFixed(1)}s — see ERROR-PATTERNS.md#chamfer-rescue-bisection`;
68
+ if (best) { console.warn(`partforge: chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
69
+ console.warn(`partforge: chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
62
70
  return shape.clone(); // nothing valid — skip the chamfer
63
71
  };
64
72
 
@@ -103,7 +103,10 @@ export function prismArgs(o) {
103
103
  }
104
104
 
105
105
  export function extrudeArgs(o) {
106
- checkKeys("extrude", o, ["profile", "h", "twist", "scaleTop"]);
106
+ // `bevel` is accepted here so the validating probe (lint) doesn't flag it, but
107
+ // it never reaches the positional backend op — kernel-front.js desugars a
108
+ // bevel call into extrude + loft + intersect before this normalizer runs.
109
+ checkKeys("extrude", o, ["profile", "h", "twist", "scaleTop", "bevel"]);
107
110
  return [req("extrude", o, "profile"), req("extrude", o, "h"), ...tail(o, ["twist", "scaleTop"])];
108
111
  }
109
112
 
@@ -3,11 +3,16 @@
3
3
  // the underlying B-rep shape; composePose folds them (in application order) into
4
4
  // one column-major mat4, and transformPositions re-poses a cached tessellation's
5
5
  // vertices with it. Pure JS — unit-testable without booting a kernel.
6
+ //
7
+ // The same math backs the viewer's pose fast path: when a param change only
8
+ // moves a sub-part, `poseDelta` (via `invertRigid`) gives the matrix carrying an
9
+ // already-delivered mesh from the pose it was built at to the new one, so the
10
+ // viewer re-poses instead of rebuilding.
6
11
 
7
12
  const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
8
13
 
9
14
  // column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]
10
- function mul(A, B) {
15
+ function mulMat4(A, B) {
11
16
  const o = new Array(16);
12
17
  for (let c = 0; c < 4; c++)
13
18
  for (let r = 0; r < 4; r++)
@@ -28,13 +33,13 @@ function rotationAbout(deg, center, axis) {
28
33
  x * z * C + y * s, y * z * C - x * s, c + z * z * C, 0,
29
34
  0, 0, 0, 1,
30
35
  ];
31
- return mul(translation(center), mul(R, translation([-center[0], -center[1], -center[2]])));
36
+ return mulMat4(translation(center), mulMat4(R, translation([-center[0], -center[1], -center[2]])));
32
37
  }
33
38
 
34
39
  const stepMatrix = (s) => (s.t === "translate" ? translation(s.v) : rotationAbout(s.deg, s.center, s.axis));
35
40
 
36
41
  // Fold steps so the EARLIEST step applies first: p' = Mn · … · M1 · p.
37
- export const composePose = (steps) => steps.reduce((m, s) => mul(stepMatrix(s), m), IDENTITY);
42
+ export const composePose = (steps) => steps.reduce((m, s) => mulMat4(stepMatrix(s), m), IDENTITY);
38
43
 
39
44
  // Apply a mat4 to an interleaved xyz Float32Array in place.
40
45
  export function transformPositions(positions, m) {
@@ -45,3 +50,30 @@ export function transformPositions(positions, m) {
45
50
  positions[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14];
46
51
  }
47
52
  }
53
+
54
+ // Invert a rigid mat4 (rotation + translation only): Rᵀ, t' = −Rᵀ·t. Valid only
55
+ // for matrices produced by composePose — transposing the 3x3 block inverts a
56
+ // rotation, so any scale or shear in it yields garbage rather than an inverse.
57
+ export function invertRigid(m) {
58
+ const r0 = m[0], r1 = m[1], r2 = m[2],
59
+ r4 = m[4], r5 = m[5], r6 = m[6],
60
+ r8 = m[8], r9 = m[9], r10 = m[10],
61
+ tx = m[12], ty = m[13], tz = m[14];
62
+ return [
63
+ r0, r4, r8, 0,
64
+ r1, r5, r9, 0,
65
+ r2, r6, r10, 0,
66
+ -(r0 * tx + r1 * ty + r2 * tz),
67
+ -(r4 * tx + r5 * ty + r6 * tz),
68
+ -(r8 * tx + r9 * ty + r10 * tz),
69
+ 1,
70
+ ];
71
+ }
72
+
73
+ // The matrix that carries a mesh delivered at `oldSteps` to the pose `newSteps`:
74
+ // compose(new) · compose(old)⁻¹. Both step lists come from the pose probe.
75
+ export const poseDelta = (newSteps, oldSteps) => {
76
+ const target = composePose(newSteps);
77
+ const inv = invertRigid(composePose(oldSteps));
78
+ return mulMat4(target, inv);
79
+ };
@@ -0,0 +1,132 @@
1
+ // Rim bevel for extruded profiles — extrude({ profile, h, bevel }). Composed
2
+ // entirely from existing kernel ops (extrude + loft + intersect/cut) at the
3
+ // backend-shared front, so both backends get identical semantics for free and
4
+ // the probe sees no CAD-only op: a beveled extrusion stays on the fast Manifold
5
+ // backend instead of routing the whole part to OCCT. (OCCT's native chamfer is
6
+ // per-edge; on a many-point profile rim — a gear — one call costs seconds. The
7
+ // loft envelope costs one boolean regardless of point count.)
8
+ //
9
+ // Accepted profiles: point array, arc/curve contour, { outer, holes } region,
10
+ // or a Shape2D (multi-region Shape2Ds bevel each region and union). Curved
11
+ // profiles are MATERIALIZED to point rings first — the loft envelope needs
12
+ // rings in 1:1 point correspondence — so a beveled extrusion is faceted at the
13
+ // sampling LOD even in STEP export (the same fidelity class as loft rings).
14
+ // Arc contours sample at a fixed pure-JS LOD (backend-identical, like hull);
15
+ // a Shape2D materializes via its own backend's toRegions (hull's Shape2D
16
+ // parity class). The body is built from the SAME materialized rings as the
17
+ // envelope — mixing a curve-exact body with a faceted envelope would leave
18
+ // sliver artifacts where the smooth wall crosses the faceted one.
19
+ //
20
+ // Geometry: a 45° bevel, exactly what chamfer({d, edges:{inPlane}}) would cut.
21
+ // The outer rim insets toward the material; a hole's rim flares the other way
22
+ // (the opening is larger at the face). Outer bevels come from intersecting a
23
+ // loft envelope extended 1 mm past both faces (so its end caps never coincide
24
+ // with the extrusion's faces — coincident caps leave sliver-triangle shading
25
+ // artifacts). Hole bevels are separate per-rim flare cutters that meet the
26
+ // hole wall only along a ring (a curve, not a face), keeping the booleans away
27
+ // from coincident-face degeneracies on the B-rep backend.
28
+ import { offsetPolygon } from "./polygon.js";
29
+ import { tessellateProfile } from "./profile.js";
30
+ import { ringArea } from "./shape2d-regions.js";
31
+
32
+ // Fixed pure-JS sampling LOD for arc/curve contours — hull.js precedent: not a
33
+ // backend's own segment count, so both backends materialize bit-identically.
34
+ const BEVEL_SEGS = 64;
35
+
36
+ // Normalize `bevel` (number = both rims, {bottom, top} = per rim) and validate
37
+ // against the height. Exported for direct unit testing.
38
+ export function resolveBevel(bevel, h) {
39
+ let bottom, top;
40
+ if (typeof bevel === "number") { bottom = bevel; top = bevel; }
41
+ else if (bevel !== null && typeof bevel === "object") {
42
+ for (const key of Object.keys(bevel)) if (key !== "bottom" && key !== "top")
43
+ throw new Error(`extrude: unknown bevel option ${JSON.stringify(key)} (valid: bottom, top)`);
44
+ bottom = bevel.bottom ?? 0; top = bevel.top ?? 0;
45
+ } else throw new Error("extrude: bevel must be a number or { bottom?, top? }");
46
+ if (!(Number.isFinite(bottom) && bottom >= 0) || !(Number.isFinite(top) && top >= 0))
47
+ throw new Error("extrude: bevel distances must be finite numbers >= 0");
48
+ if (bottom + top >= h)
49
+ throw new Error("extrude: bevel must fit the height (bottom + top < h)");
50
+ return { bottom, top };
51
+ }
52
+
53
+ // offsetPolygon needs CCW input; toRegions/hand-written holes may wind either
54
+ // way. Tessellated arc contours close back onto their start point — drop that
55
+ // duplicate, or the offset ring's point count never matches and fit() gives up.
56
+ const ccw = (r) => {
57
+ const [x0, y0] = r[0], [xn, yn] = r[r.length - 1];
58
+ const ring = Math.abs(x0 - xn) < 1e-9 && Math.abs(y0 - yn) < 1e-9 ? r.slice(0, -1) : r;
59
+ return ringArea(ring) >= 0 ? ring : [...ring].reverse();
60
+ };
61
+
62
+ // The largest offset (inset for the outer rim, outset for a hole rim — the sign
63
+ // of `delta`) the ring can take, starting from the requested distance. Narrow
64
+ // features cap the bevel (the same geometric limit OCCT's chamfer hits — see
65
+ // ERROR-PATTERNS.md#chamfer-rescue-bisection), but each attempt here is pure JS
66
+ // on the 2-D outline, not a kernel op, so backing off is effectively free. The
67
+ // loop is deterministic, preserving build purity. `corners: "sharp"` keeps the
68
+ // offset 1:1 with the input points — loft stitching requires every ring to
69
+ // share the profile's exact point count (a mismatch is treated as a failed try).
70
+ const fit = (ring, delta, what) => {
71
+ const requested = Math.abs(delta), sign = Math.sign(delta);
72
+ let c = requested;
73
+ for (;;) {
74
+ try {
75
+ const off = offsetPolygon(ring, sign * c, { corners: "sharp" });
76
+ if (off.length === ring.length) {
77
+ if (c < requested)
78
+ console.warn(`partforge: extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
79
+ return { ring: off, c };
80
+ }
81
+ } catch { /* offset collapsed or self-intersected — try smaller */ }
82
+ c *= 0.85;
83
+ if (c < 0.05) {
84
+ console.warn(`partforge: extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
85
+ return null;
86
+ }
87
+ }
88
+ };
89
+
90
+ const outerRings = (outer, h, b, t) => {
91
+ const rings = [];
92
+ if (b) rings.push({ polygon: b.ring, z: -1 }, { polygon: b.ring, z: 0 }, { polygon: outer, z: b.c });
93
+ else rings.push({ polygon: outer, z: -1 });
94
+ if (t) rings.push({ polygon: outer, z: h - t.c }, { polygon: t.ring, z: h }, { polygon: t.ring, z: h + 1 });
95
+ else rings.push({ polygon: outer, z: h + 1 });
96
+ return rings;
97
+ };
98
+
99
+ const bevelRegion = (k, region, h, bottom, top) => {
100
+ const outer = ccw(region.outer);
101
+ const holes = (region.holes ?? []).map(ccw);
102
+ let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
103
+ const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
104
+ const t = top > 0 ? fit(outer, -top, "profile") : null;
105
+ if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t) }));
106
+ const cutters = [];
107
+ for (const hole of holes) {
108
+ const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
109
+ if (hb) cutters.push(k.loft({ rings: [
110
+ { polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
111
+ ] }));
112
+ const ht = top > 0 ? fit(hole, top, "hole") : null;
113
+ if (ht) cutters.push(k.loft({ rings: [
114
+ { polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
115
+ ] }));
116
+ }
117
+ return cutters.length ? s.cutAll(cutters) : s;
118
+ };
119
+
120
+ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
121
+ if (twist !== undefined || scaleTop !== undefined)
122
+ throw new Error("extrude: bevel cannot combine with twist or scaleTop");
123
+ const { bottom, top } = resolveBevel(bevel, h);
124
+ // bevel: 0 must hash identically to the plain call — hand the original
125
+ // profile straight through (curve-exact on OCCT, no materialization).
126
+ if (bottom === 0 && top === 0) return k.extrude({ profile, h });
127
+ const regions = profile != null && typeof profile.toRegions === "function"
128
+ ? profile.toRegions()
129
+ : [tessellateProfile(profile, BEVEL_SEGS)];
130
+ if (regions.length === 0) throw new Error("extrude: bevel profile produced no regions");
131
+ return regions.map((r) => bevelRegion(k, r, h, bottom, top)).reduce((a, x) => a.union(x));
132
+ }
@@ -6,11 +6,13 @@
6
6
  export function createSolidCache() {
7
7
  const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
8
8
  const pinned = new Set(); // every live `pin` across all sub-parts
9
+ const lastBuilt = new Map(); // name -> rebind generation of the partition's last begin()
10
+ let generation = 0; // bumped only by sweep() (i.e. per part rebind)
9
11
  let name = null, active = null, prev = null;
10
12
  let hits = 0, misses = 0;
11
13
 
12
14
  return {
13
- begin(n) { name = n; prev = caches.get(n) ?? new Map(); active = new Map(); },
15
+ begin(n) { name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map(); },
14
16
 
15
17
  end() {
16
18
  if (name == null) return;
@@ -21,6 +23,20 @@ export function createSolidCache() {
21
23
  name = null; active = prev = null;
22
24
  },
23
25
 
26
+ // Rebind hygiene: called once per setPart() (never mid-bracket — the worker's
27
+ // job queue is serial). Partitions a rebind renamed or deleted would otherwise
28
+ // pin their last build's solids until worker death; three idle generations is
29
+ // the eviction line, so recently-viewed views stay warm across edits.
30
+ sweep() {
31
+ generation++;
32
+ for (const [n, entries] of caches) {
33
+ if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
34
+ for (const entry of entries.values()) { pinned.delete(entry.pin); entry.dispose(); }
35
+ caches.delete(n);
36
+ lastBuilt.delete(n);
37
+ }
38
+ },
39
+
24
40
  lookup(hash, make) {
25
41
  if (name == null) return make().value; // not bracketed → no caching
26
42
  if (active.has(hash)) { hits++; return active.get(hash).value; }
@@ -45,6 +45,9 @@ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress
45
45
  // { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
46
46
  // { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
47
47
  // { type:"export-step", view, params } → { type:"download", data, filename, mime }
48
+ // A generate also accepts `opts.isStale` — a caller-supplied predicate checked at each
49
+ // sub-part boundary — and answers { type:"superseded" } instead of `meshes` when it
50
+ // stops early (a build that ended without meshes, not an error; see KERNEL-CONTRACT.md).
48
51
  // Each result branch declares its own transferables (the big binary buffers,
49
52
  // zero-copy across the worker boundary) right where the buffers are created —
50
53
  // so a new job type can't silently regress to structured-cloning its payload.
@@ -53,7 +56,8 @@ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress
53
56
  // preview generates stay quiet (no callback) to avoid flicker during slider drags.
54
57
  const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
55
58
 
56
- export async function handle(kernel, part, msg, post) {
59
+ export async function handle(kernel, part, msg, post, opts = {}) {
60
+ const isStale = opts.isStale ?? (() => false);
57
61
  const onProgress = (phase) => post({ type: "progress", phase });
58
62
  const label = (name) => part.parts[name].label ?? name;
59
63
  const exportName = (name) => part.parts[name].export?.name ?? name;
@@ -78,7 +82,7 @@ export async function handle(kernel, part, msg, post) {
78
82
  const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
79
83
  const meshes = [];
80
84
  kernel.resetCacheStats?.(); // count hits/misses for just this job
81
- for (const name of msg.subparts) {
85
+ for (const [i, name] of msg.subparts.entries()) {
82
86
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
83
87
  try {
84
88
  const m = posed(name, "display").toMesh({ quality: "preview" });
@@ -87,6 +91,15 @@ export async function handle(kernel, part, msg, post) {
87
91
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
88
92
  kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
89
93
  }
94
+ // Cooperative cancel: yield one macrotask so queued messages (a newer
95
+ // generate, a part rebind) can be seen, then stop at this boundary if
96
+ // this build is stale. The last sub-part skips the yield — nothing
97
+ // follows it. Completed sub-parts have already committed their cache
98
+ // brackets, so an abort here strands nothing.
99
+ if (i < msg.subparts.length - 1) {
100
+ await new Promise((r) => setTimeout(r, 0));
101
+ if (isStale()) return void post({ type: "superseded" });
102
+ }
90
103
  }
91
104
  const transfer = meshes.flatMap((m) =>
92
105
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));