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 +14 -0
- package/docs/AUTHORING-PARTS.md +60 -1
- package/docs/ERROR-PATTERNS.md +28 -0
- package/docs/KERNEL-CONTRACT.md +437 -0
- package/package.json +2 -1
- package/src/framework/controls.js +25 -6
- package/src/framework/debug-overlay.js +10 -2
- package/src/framework/geometry/kernel-front.js +11 -0
- package/src/framework/geometry/kernel.js +8 -5
- package/src/framework/geometry/manifold-backend.js +1 -0
- package/src/framework/geometry/occt-backend.js +1 -0
- package/src/framework/geometry/occt-repair.js +9 -1
- package/src/framework/geometry/op-options.js +4 -1
- package/src/framework/geometry/pose.js +35 -3
- package/src/framework/geometry/rim-bevel.js +132 -0
- package/src/framework/geometry/solid-cache.js +17 -1
- package/src/framework/jobs.js +15 -2
- package/src/framework/mount.js +53 -7
- package/src/framework/pose-fast-path.js +49 -0
- package/src/framework/pose-probe.js +139 -0
- package/src/framework/selection/hover.js +6 -3
- package/src/framework/selection/raycast.js +17 -4
- package/src/framework/viewer.js +24 -1
- package/src/framework/worker.js +101 -19
|
@@ -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) => {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
26
|
-
//
|
|
25
|
+
// Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
|
|
26
|
+
// Both in-repo backends implement the brackets (only `cleanup` is Manifold-specific —
|
|
27
|
+
// OCCT's replicad shapes need no dispose bookkeeping). jobs.js calls all of these via
|
|
28
|
+
// `?.`, so a third-party backend may simply omit them.
|
|
27
29
|
export const KERNEL_OPTIONAL_OPS = [
|
|
28
|
-
"beginSubPart", "endSubPart", "cacheStats", "resetCacheStats", "cleanup",
|
|
30
|
+
"beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
|
|
29
31
|
];
|
|
30
32
|
|
|
31
33
|
// Ops every Solid must implement (including the sugar addSugar() attaches).
|
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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; }
|
package/src/framework/jobs.js
CHANGED
|
@@ -45,6 +45,9 @@ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress
|
|
|
45
45
|
// { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
|
|
46
46
|
// { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
|
|
47
47
|
// { type:"export-step", view, params } → { type:"download", data, filename, mime }
|
|
48
|
+
// A generate also accepts `opts.isStale` — a caller-supplied predicate checked at each
|
|
49
|
+
// sub-part boundary — and answers { type:"superseded" } instead of `meshes` when it
|
|
50
|
+
// stops early (a build that ended without meshes, not an error; see KERNEL-CONTRACT.md).
|
|
48
51
|
// Each result branch declares its own transferables (the big binary buffers,
|
|
49
52
|
// zero-copy across the worker boundary) right where the buffers are created —
|
|
50
53
|
// so a new job type can't silently regress to structured-cloning its payload.
|
|
@@ -53,7 +56,8 @@ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress
|
|
|
53
56
|
// preview generates stay quiet (no callback) to avoid flicker during slider drags.
|
|
54
57
|
const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
55
58
|
|
|
56
|
-
export async function handle(kernel, part, msg, post) {
|
|
59
|
+
export async function handle(kernel, part, msg, post, opts = {}) {
|
|
60
|
+
const isStale = opts.isStale ?? (() => false);
|
|
57
61
|
const onProgress = (phase) => post({ type: "progress", phase });
|
|
58
62
|
const label = (name) => part.parts[name].label ?? name;
|
|
59
63
|
const exportName = (name) => part.parts[name].export?.name ?? name;
|
|
@@ -78,7 +82,7 @@ export async function handle(kernel, part, msg, post) {
|
|
|
78
82
|
const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
|
|
79
83
|
const meshes = [];
|
|
80
84
|
kernel.resetCacheStats?.(); // count hits/misses for just this job
|
|
81
|
-
for (const name of msg.subparts) {
|
|
85
|
+
for (const [i, name] of msg.subparts.entries()) {
|
|
82
86
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
83
87
|
try {
|
|
84
88
|
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
@@ -87,6 +91,15 @@ export async function handle(kernel, part, msg, post) {
|
|
|
87
91
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
88
92
|
kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
|
|
89
93
|
}
|
|
94
|
+
// Cooperative cancel: yield one macrotask so queued messages (a newer
|
|
95
|
+
// generate, a part rebind) can be seen, then stop at this boundary if
|
|
96
|
+
// this build is stale. The last sub-part skips the yield — nothing
|
|
97
|
+
// follows it. Completed sub-parts have already committed their cache
|
|
98
|
+
// brackets, so an abort here strands nothing.
|
|
99
|
+
if (i < msg.subparts.length - 1) {
|
|
100
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
101
|
+
if (isStale()) return void post({ type: "superseded" });
|
|
102
|
+
}
|
|
90
103
|
}
|
|
91
104
|
const transfer = meshes.flatMap((m) =>
|
|
92
105
|
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|