partforge 0.6.0 → 0.7.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 +63 -14
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +48 -23
- package/package.json +1 -1
- package/src/app-planter.js +10 -0
- package/src/framework/app.css +94 -29
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/kernel-front.js +31 -0
- package/src/framework/geometry/kernel.js +53 -10
- package/src/framework/geometry/manifold-backend.js +48 -25
- package/src/framework/geometry/occt-backend.js +47 -96
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +120 -0
- package/src/planter-worker.js +3 -0
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
|
@@ -4,116 +4,68 @@
|
|
|
4
4
|
import { toEdgeFinder } from "./edge-selector.js";
|
|
5
5
|
import { toFaceFinder } from "./face-selector.js";
|
|
6
6
|
import { addSugar } from "./solid-sugar.js";
|
|
7
|
+
import { finishKernel } from "./kernel-front.js";
|
|
8
|
+
import { createOcctRepair } from "./occt-repair.js";
|
|
9
|
+
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
7
10
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
8
11
|
|
|
9
12
|
export function createOcctKernel(replicad) {
|
|
10
13
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
11
14
|
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
|
|
12
15
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
17
|
-
const isClosedSolid = (shape) => {
|
|
18
|
-
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
19
|
-
const P = m.vertices, T = m.triangles;
|
|
20
|
-
const id = new Map();
|
|
21
|
-
const vid = (i) => {
|
|
22
|
-
const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
|
|
23
|
-
let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
|
|
24
|
-
};
|
|
25
|
-
const edges = new Map();
|
|
26
|
-
for (let t = 0; t < T.length / 3; t++) {
|
|
27
|
-
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
28
|
-
for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
|
|
29
|
-
}
|
|
30
|
-
for (const n of edges.values()) if (n !== 2) return false;
|
|
31
|
-
return true;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// The true maximum chamfer for an edge depends on local angles and adjacent features,
|
|
35
|
-
// which is hard to predict analytically (and OCCT exposes no max-radius query). So
|
|
36
|
-
// VALIDATE the result instead of guessing: try the requested distance, and if it makes
|
|
37
|
-
// a closed solid, use it (valid large chamfers — e.g. on a pill — go through). If not,
|
|
38
|
-
// binary-search the largest distance that does. Discarded attempts are freed so OCCT's
|
|
39
|
-
// WASM heap doesn't grow across regenerates.
|
|
40
|
-
const validChamfer = (shape, finderFn, distance) => {
|
|
41
|
-
if (!(distance > 0)) return shape.clone();
|
|
42
|
-
const tryAt = (d) => {
|
|
43
|
-
const probe = shape.clone();
|
|
44
|
-
let res;
|
|
45
|
-
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
46
|
-
if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
|
|
47
|
-
res.delete?.();
|
|
48
|
-
return null;
|
|
49
|
-
};
|
|
50
|
-
let best = tryAt(distance);
|
|
51
|
-
if (best) return best; // requested distance is valid
|
|
52
|
-
let lo = 0, hi = distance, bestD = 0;
|
|
53
|
-
for (let i = 0; i < 6; i++) {
|
|
54
|
-
const mid = (lo + hi) / 2;
|
|
55
|
-
const res = tryAt(mid);
|
|
56
|
-
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
57
|
-
}
|
|
58
|
-
if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
|
|
59
|
-
return shape.clone(); // nothing valid — skip the chamfer
|
|
60
|
-
};
|
|
16
|
+
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
17
|
+
// see occt-repair.js for the policies and why they differ per op.
|
|
18
|
+
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
61
19
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// result, with a console warning so it's discoverable.
|
|
68
|
-
const safeOp = (shape, op, label) => {
|
|
69
|
-
const backup = shape.clone();
|
|
70
|
-
try {
|
|
71
|
-
const result = op(shape);
|
|
72
|
-
if (measureVolume(result) > 0) { backup.delete?.(); return result; }
|
|
73
|
-
result.delete?.();
|
|
74
|
-
console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
75
|
-
} catch (e) {
|
|
76
|
-
console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
|
|
77
|
-
}
|
|
78
|
-
return backup;
|
|
79
|
-
};
|
|
20
|
+
// Feature labels: each entry snapshots the labeled solid's geometry at the moment
|
|
21
|
+
// the label applies; transforms move the snapshots along, booleans merge the two
|
|
22
|
+
// sides' lists. At toMesh() time result faces are classified against the snapshots.
|
|
23
|
+
const cloneLabels = (ls) => ls.map((l) => ({ label: l.label, snapshot: l.snapshot.clone() }));
|
|
24
|
+
const mapLabels = (ls, f) => ls.map((l) => ({ label: l.label, snapshot: f(l.snapshot.clone()) }));
|
|
80
25
|
|
|
81
|
-
const wrap = (shape) => addSugar({
|
|
26
|
+
const wrap = (shape, labels = []) => addSugar({
|
|
82
27
|
_s: shape,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
28
|
+
_labels: labels,
|
|
29
|
+
label: (name) => wrap(shape, [...labels, { label: name, snapshot: shape.clone() }]),
|
|
30
|
+
cut: (t) => wrap(shape.cut(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
|
|
31
|
+
cutAll: (tools) => wrap(
|
|
32
|
+
shape.cut(makeCompound(tools.map((t) => t._s))),
|
|
33
|
+
[...cloneLabels(labels), ...tools.flatMap((t) => cloneLabels(t._labels ?? []))]
|
|
34
|
+
),
|
|
35
|
+
intersect: (t) => wrap(shape.intersect(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
|
|
36
|
+
clone: () => wrap(shape.clone(), cloneLabels(labels)),
|
|
86
37
|
boundingBox: () => {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
min: [...min], max: [...max], center: [...bb.center],
|
|
91
|
-
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
92
|
-
};
|
|
93
|
-
},
|
|
94
|
-
translate: (v) => wrap(shape.translate(v)),
|
|
95
|
-
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
|
|
96
|
-
mirror: (plane) => wrap(shape.mirror(plane)),
|
|
97
|
-
scale: (factor, center = [0, 0, 0]) => {
|
|
98
|
-
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
99
|
-
return wrap(shape.scale(factor, center));
|
|
38
|
+
const [min, max] = shape.boundingBox.bounds; // addSugar derives center/size
|
|
39
|
+
return { min: [...min], max: [...max] };
|
|
100
40
|
},
|
|
41
|
+
translate: (v) => wrap(shape.translate(v), mapLabels(labels, (s) => s.translate(v))),
|
|
42
|
+
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis), mapLabels(labels, (s) => s.rotate(deg, center, axis))),
|
|
43
|
+
mirror: (plane) => wrap(shape.mirror(plane), mapLabels(labels, (s) => s.mirror(plane))),
|
|
44
|
+
scale: (factor, center) => wrap(shape.scale(factor, center), mapLabels(labels, (s) => s.scale(factor, center))), // validated/defaulted by addSugar
|
|
101
45
|
toMesh: ({ quality = "preview" } = {}) => {
|
|
102
46
|
const m = shape.mesh(MESH[quality]);
|
|
103
|
-
|
|
47
|
+
const out = {
|
|
104
48
|
positions: Float32Array.from(m.vertices),
|
|
105
49
|
normals: new Float32Array(0), // let the main thread crease (matches prior look)
|
|
106
50
|
indices: Uint32Array.from(m.triangles),
|
|
107
51
|
triangles: m.triangles.length / 3,
|
|
108
52
|
};
|
|
53
|
+
if (labels.length) {
|
|
54
|
+
const soups = labels.map((l) => {
|
|
55
|
+
const lm = l.snapshot.clone().mesh(MESH.preview); // clone: mesh() must not disturb the kept snapshot
|
|
56
|
+
return { label: l.label, vertices: lm.vertices, triangles: lm.triangles };
|
|
57
|
+
});
|
|
58
|
+
Object.assign(out, classifyFaceGroups(m, soups));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
109
61
|
},
|
|
110
62
|
toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
|
|
111
|
-
fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`)),
|
|
112
|
-
chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance)),
|
|
63
|
+
fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(labels)),
|
|
64
|
+
chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance), cloneLabels(labels)),
|
|
113
65
|
shell: (thickness, openFaces) => {
|
|
114
66
|
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
115
67
|
// replicad shells inward with a positive thickness in this version, keeping outer dimensions.
|
|
116
|
-
return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`));
|
|
68
|
+
return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(labels));
|
|
117
69
|
},
|
|
118
70
|
volume: () => measureVolume(shape),
|
|
119
71
|
toIndexedMesh: () => {
|
|
@@ -131,9 +83,8 @@ export function createOcctKernel(replicad) {
|
|
|
131
83
|
return wrap(loft([w1, w2]));
|
|
132
84
|
};
|
|
133
85
|
|
|
134
|
-
// extrude a 2-D polygon from z=0
|
|
86
|
+
// extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
|
|
135
87
|
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
136
|
-
if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
|
|
137
88
|
let pen = draw(pts[0]);
|
|
138
89
|
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
139
90
|
const sketch = pen.close().sketchOnPlane("XY");
|
|
@@ -146,7 +97,6 @@ export function createOcctKernel(replicad) {
|
|
|
146
97
|
|
|
147
98
|
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
148
99
|
const revolve = (pts, { degrees = 360 } = {}) => {
|
|
149
|
-
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
150
100
|
let pen = draw(pts[0]);
|
|
151
101
|
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
152
102
|
const sketch = pen.close().sketchOnPlane("XZ");
|
|
@@ -162,13 +112,14 @@ export function createOcctKernel(replicad) {
|
|
|
162
112
|
return wrap(genericSweep(profile, spine, { frenet: true }));
|
|
163
113
|
};
|
|
164
114
|
|
|
165
|
-
return {
|
|
166
|
-
cylinder,
|
|
167
|
-
boredCylinder: ({ od, h, bore }) =>
|
|
168
|
-
cylinder(od / 2, od / 2, h).cut(cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2])),
|
|
115
|
+
return finishKernel({
|
|
116
|
+
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
169
117
|
box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
|
|
170
118
|
sphere: (r) => wrap(makeSphere(r)),
|
|
171
|
-
union: (solids) => wrap(
|
|
119
|
+
union: (solids) => wrap(
|
|
120
|
+
solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
|
|
121
|
+
solids.flatMap((s) => cloneLabels(s._labels ?? []))
|
|
122
|
+
),
|
|
172
123
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
173
|
-
};
|
|
124
|
+
});
|
|
174
125
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Failure recovery for native OCCT features (fillet/chamfer/shell), extracted from
|
|
2
|
+
// occt-backend.js so the backend reads as a clean contract mapping and the pure
|
|
3
|
+
// mesh-topology logic is unit-testable without booting OCCT.
|
|
4
|
+
//
|
|
5
|
+
// Two deliberately different rescue policies:
|
|
6
|
+
// - chamfer → validChamfer: try the requested distance, and if it breaks the
|
|
7
|
+
// solid, binary-search the largest distance that doesn't. An over-large chamfer
|
|
8
|
+
// fails by over-running its faces, which is (near enough) monotonic in the
|
|
9
|
+
// distance — so bisection is sound.
|
|
10
|
+
// - fillet (and shell) → safeOp: attempt once, skip the feature on failure.
|
|
11
|
+
// OCCT fillet failures are NOT monotonic in the radius (a radius equal to an
|
|
12
|
+
// adjacent fillet's can fail while larger ones succeed), so a binary search
|
|
13
|
+
// would converge on garbage; skipping keeps the part alive and warns.
|
|
14
|
+
|
|
15
|
+
// Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
|
|
16
|
+
// meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
|
|
17
|
+
// so weld vertices by position, then a closed solid has every edge shared by exactly
|
|
18
|
+
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
19
|
+
export const isClosedSolid = (shape) => {
|
|
20
|
+
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
21
|
+
const P = m.vertices, T = m.triangles;
|
|
22
|
+
const id = new Map();
|
|
23
|
+
const vid = (i) => {
|
|
24
|
+
const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
|
|
25
|
+
let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
|
|
26
|
+
};
|
|
27
|
+
const edges = new Map();
|
|
28
|
+
for (let t = 0; t < T.length / 3; t++) {
|
|
29
|
+
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
30
|
+
for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
|
|
31
|
+
}
|
|
32
|
+
for (const n of edges.values()) if (n !== 2) return false;
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function createOcctRepair(measureVolume) {
|
|
37
|
+
// The true maximum chamfer for an edge depends on local angles and adjacent features,
|
|
38
|
+
// which is hard to predict analytically (and OCCT exposes no max-radius query). So
|
|
39
|
+
// VALIDATE the result instead of guessing: try the requested distance, and if it makes
|
|
40
|
+
// a closed solid, use it (valid large chamfers — e.g. on a pill — go through). If not,
|
|
41
|
+
// binary-search the largest distance that does. Discarded attempts are freed so OCCT's
|
|
42
|
+
// WASM heap doesn't grow across regenerates.
|
|
43
|
+
const validChamfer = (shape, finderFn, distance) => {
|
|
44
|
+
if (!(distance > 0)) return shape.clone();
|
|
45
|
+
const tryAt = (d) => {
|
|
46
|
+
const probe = shape.clone();
|
|
47
|
+
let res;
|
|
48
|
+
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
49
|
+
if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
|
|
50
|
+
res.delete?.();
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
let best = tryAt(distance);
|
|
54
|
+
if (best) return best; // requested distance is valid
|
|
55
|
+
let lo = 0, hi = distance, bestD = 0;
|
|
56
|
+
for (let i = 0; i < 6; i++) {
|
|
57
|
+
const mid = (lo + hi) / 2;
|
|
58
|
+
const res = tryAt(mid);
|
|
59
|
+
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
60
|
+
}
|
|
61
|
+
if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
|
|
62
|
+
return shape.clone(); // nothing valid — skip the chamfer
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Native fillet/shell can throw or yield an empty solid for out-of-range radii
|
|
66
|
+
// or awkward edge interactions. Rather than letting the whole part vanish, attempt
|
|
67
|
+
// the op on a clone and fall back to the original shape (feature skipped) on a
|
|
68
|
+
// throw or empty result, with a console warning so it's discoverable.
|
|
69
|
+
const safeOp = (shape, op, label) => {
|
|
70
|
+
const backup = shape.clone();
|
|
71
|
+
try {
|
|
72
|
+
const result = op(shape);
|
|
73
|
+
if (measureVolume(result) > 0) { backup.delete?.(); return result; }
|
|
74
|
+
result.delete?.();
|
|
75
|
+
console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
|
|
78
|
+
}
|
|
79
|
+
return backup;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return { validChamfer, safeOp };
|
|
83
|
+
}
|
|
@@ -1,41 +1,48 @@
|
|
|
1
1
|
// Geometry-free backend detection. A probe kernel records every op a part's
|
|
2
2
|
// build() invokes (returning chainable no-op proxies, dummy values for queries);
|
|
3
|
-
// if an OCCT-only op was used, the part needs the OCCT backend.
|
|
4
|
-
|
|
3
|
+
// if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
|
|
4
|
+
// in kernel.js — the same list generates the Manifold backend's throwing stubs.
|
|
5
|
+
import { OCCT_ONLY_OPS } from "./kernel.js";
|
|
6
|
+
|
|
7
|
+
const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
|
|
5
8
|
|
|
6
9
|
export function createProbeKernel() {
|
|
7
10
|
const used = new Set();
|
|
8
11
|
const note = (name) => used.add(name);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
volume() { note("volume"); return 1; },
|
|
23
|
-
toMesh() { note("toMesh"); return { positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }; },
|
|
24
|
-
toSTL() { note("toSTL"); return new ArrayBuffer(0); },
|
|
25
|
-
toIndexedMesh() { note("toIndexedMesh"); return { positions: new Float32Array(9), indices: new Uint32Array(3) }; },
|
|
12
|
+
|
|
13
|
+
// Catch-all proxies: any method records its name and returns the chainable solid
|
|
14
|
+
// proxy, EXCEPT the queries below, which return realistic dummy values the build may
|
|
15
|
+
// read. Using a Proxy (rather than a hand-listed allowlist) means new kernel/solid
|
|
16
|
+
// methods never have to be mirrored here — the probe can't drift out of sync with the
|
|
17
|
+
// real backends. (That drift previously broke the panel's relevance dimming/hiding when
|
|
18
|
+
// the build-step vocabulary was added but not taught to the probe.)
|
|
19
|
+
const solidQueries = {
|
|
20
|
+
boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
|
|
21
|
+
volume: () => 1,
|
|
22
|
+
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }),
|
|
23
|
+
toSTL: () => new ArrayBuffer(0),
|
|
24
|
+
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
26
25
|
};
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
sphere() { note("sphere"); return proxy; },
|
|
31
|
-
box() { note("box"); return proxy; },
|
|
32
|
-
prism() { note("prism"); return proxy; },
|
|
33
|
-
revolve() { note("revolve"); return proxy; },
|
|
34
|
-
helixSweptTube() { note("helixSweptTube"); return proxy; },
|
|
35
|
-
union() { note("union"); return proxy; },
|
|
36
|
-
toSTEP() { note("toSTEP"); return Promise.resolve(new ArrayBuffer(0)); },
|
|
37
|
-
cleanup() {},
|
|
26
|
+
const kernelQueries = {
|
|
27
|
+
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
28
|
+
cleanup: () => {},
|
|
38
29
|
};
|
|
30
|
+
|
|
31
|
+
// `ignore` keeps the proxy from masquerading as a thenable/internal handle: symbols,
|
|
32
|
+
// `then` (so it's never await-unwrapped), and `_`-prefixed internals resolve to
|
|
33
|
+
// undefined rather than a chainable op.
|
|
34
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key[0] === "_";
|
|
35
|
+
|
|
36
|
+
const opProxy = (queries) => new Proxy({}, {
|
|
37
|
+
get(_t, key) {
|
|
38
|
+
if (ignore(key)) return undefined;
|
|
39
|
+
if (key in queries) return queries[key];
|
|
40
|
+
return (..._args) => { note(key); return proxy; };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const proxy = opProxy(solidQueries); // a solid handle: every op chains back to itself
|
|
45
|
+
const kernel = opProxy(kernelQueries); // factory ops (cylinder/box/prism/…) return a solid
|
|
39
46
|
return { kernel, used };
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// src/framework/geometry/solid-sugar.js
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// readable transform/placement
|
|
5
|
-
// rotate()/translate() primitives
|
|
6
|
-
//
|
|
2
|
+
// The backend-shared Solid front, defined ONCE over both geometry backends. Every
|
|
3
|
+
// Solid a backend's wrap() returns is passed through addSugar(), which:
|
|
4
|
+
// - attaches the readable transform/placement vocabulary (rotateX/along/at/…),
|
|
5
|
+
// composed purely from the solid's own rotate()/translate() primitives, so the
|
|
6
|
+
// sugar is geometry-identical on Manifold and OCCT alike;
|
|
7
|
+
// - validates arguments the backends would otherwise each check (scale factor);
|
|
8
|
+
// - derives boundingBox center/size from the backend's raw {min,max};
|
|
9
|
+
// - stubs any OCCT-only op the backend lacks with a KernelCapabilityError, so
|
|
10
|
+
// the needs-occt reroute works without hand-written per-backend stubs.
|
|
11
|
+
import { KernelCapabilityError } from "./errors.js";
|
|
12
|
+
import { OCCT_ONLY_OPS } from "./kernel.js";
|
|
13
|
+
|
|
7
14
|
const ORIGIN = [0, 0, 0];
|
|
8
15
|
const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
9
16
|
|
|
@@ -31,5 +38,25 @@ const SUGAR = {
|
|
|
31
38
|
};
|
|
32
39
|
|
|
33
40
|
export function addSugar(s) {
|
|
41
|
+
const rawScale = s.scale;
|
|
42
|
+
s.scale = (factor, center = ORIGIN) => {
|
|
43
|
+
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
44
|
+
return rawScale(factor, center);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const rawBoundingBox = s.boundingBox;
|
|
48
|
+
s.boundingBox = () => {
|
|
49
|
+
const { min, max } = rawBoundingBox();
|
|
50
|
+
return {
|
|
51
|
+
min, max,
|
|
52
|
+
center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
53
|
+
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const op of OCCT_ONLY_OPS) {
|
|
58
|
+
s[op] ??= () => { throw new KernelCapabilityError(`${op} requires the OCCT backend`); };
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
return Object.assign(s, SUGAR);
|
|
35
62
|
}
|
|
@@ -9,10 +9,8 @@ export function createGeometryService({ createWorker, onMessage }) {
|
|
|
9
9
|
const workers = { manifold: createWorker("manifold"), occt: createWorker("occt") };
|
|
10
10
|
workers.manifold.onmessage = onMessage;
|
|
11
11
|
workers.occt.onmessage = onMessage;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
exportStep: (msg) => workers.occt.postMessage(msg), // STEP is always OCCT
|
|
17
|
-
};
|
|
12
|
+
// Post a job to the chosen backend's worker. The message's own `type` says what to
|
|
13
|
+
// do (generate / export-stl / export-3mf / export-step); `backend` picks the worker
|
|
14
|
+
// — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
|
|
15
|
+
return { send: (msg, backend = "manifold") => workers[backend].postMessage(msg) };
|
|
18
16
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -19,25 +19,44 @@ export function exportSubParts(part, view, params) {
|
|
|
19
19
|
return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// Resolve a part's effective params + derived values for a build: the user's params
|
|
23
|
+
// layered over the part defaults, and derive() run once over the result.
|
|
24
|
+
export function resolveParams(part, params) {
|
|
25
|
+
const p = { ...part.defaults, ...params };
|
|
26
|
+
const d = part.derive ? part.derive(p) : {};
|
|
27
|
+
return { p, d };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build one sub-part and apply its optional place() for the given purpose/view.
|
|
31
|
+
// `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
|
|
32
|
+
// sub-part solid" — the worker, the collision check, and the test harness all call
|
|
33
|
+
// it, so display/export poses can never drift between the app and its tests.
|
|
34
|
+
export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
|
|
35
|
+
const sp = part.parts[name];
|
|
36
|
+
const solid = sp.build(kernel, p, d, onProgress);
|
|
37
|
+
return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
41
|
+
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
24
42
|
// { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
|
|
25
43
|
// { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
|
|
26
44
|
// { type:"export-step", view, params } → { type:"download", data, filename, mime }
|
|
45
|
+
// Each result branch declares its own transferables (the big binary buffers,
|
|
46
|
+
// zero-copy across the worker boundary) right where the buffers are created —
|
|
47
|
+
// so a new job type can't silently regress to structured-cloning its payload.
|
|
27
48
|
// Progress is posted as { type:"progress", phase }. Export builds thread the
|
|
28
49
|
// progress callback into build() so a part's own per-feature progress surfaces;
|
|
29
50
|
// preview generates stay quiet (no callback) to avoid flicker during slider drags.
|
|
51
|
+
const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
52
|
+
|
|
30
53
|
export async function handle(kernel, part, msg, post) {
|
|
31
54
|
const onProgress = (phase) => post({ type: "progress", phase });
|
|
32
|
-
const p =
|
|
33
|
-
const d = part.derive ? part.derive(p) : {};
|
|
55
|
+
const { p, d } = resolveParams(part, msg.params);
|
|
34
56
|
const label = (name) => part.parts[name].label ?? name;
|
|
35
57
|
const exportName = (name) => part.parts[name].export?.name ?? name;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const solid = sp.build(kernel, p, d, prog);
|
|
39
|
-
return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
|
|
40
|
-
};
|
|
58
|
+
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
59
|
+
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
41
60
|
|
|
42
61
|
try {
|
|
43
62
|
if (msg.type === "generate") {
|
|
@@ -48,37 +67,40 @@ export async function handle(kernel, part, msg, post) {
|
|
|
48
67
|
for (const name of msg.subparts) {
|
|
49
68
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
50
69
|
try {
|
|
51
|
-
const m =
|
|
52
|
-
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
|
|
70
|
+
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
71
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
|
|
53
72
|
} finally {
|
|
54
73
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
55
74
|
kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
|
-
|
|
77
|
+
const transfer = meshes.flatMap((m) =>
|
|
78
|
+
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
79
|
+
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
|
|
59
80
|
} else if (msg.type === "export-stl") {
|
|
60
81
|
const out = [];
|
|
61
82
|
for (const name of exportSubParts(part, msg.view, p)) {
|
|
62
83
|
onProgress(`building ${label(name)}`);
|
|
63
|
-
out.push({ name: exportName(name), data: await
|
|
84
|
+
out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: "print" }) });
|
|
64
85
|
}
|
|
65
|
-
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out });
|
|
86
|
+
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out }, out.map((p) => bufferOf(p.data)));
|
|
66
87
|
} else if (msg.type === "export-step") {
|
|
67
88
|
const solids = exportSubParts(part, msg.view, p).map((name) => {
|
|
68
89
|
onProgress(`building ${label(name)}`);
|
|
69
|
-
return { name: exportName(name), solid:
|
|
90
|
+
return { name: exportName(name), solid: posed(name, "export", onProgress) };
|
|
70
91
|
});
|
|
71
92
|
onProgress("writing STEP file");
|
|
72
93
|
const data = await kernel.toSTEP(solids);
|
|
73
|
-
post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" });
|
|
94
|
+
post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" }, [bufferOf(data)]);
|
|
74
95
|
} else if (msg.type === "export-3mf") {
|
|
75
96
|
const meshes = exportSubParts(part, msg.view, p).map((name) => {
|
|
76
97
|
onProgress(`building ${label(name)}`);
|
|
77
|
-
const { positions, indices } =
|
|
98
|
+
const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh();
|
|
78
99
|
return { name: exportName(name), positions, indices };
|
|
79
100
|
});
|
|
80
101
|
onProgress("writing 3MF file");
|
|
81
|
-
|
|
102
|
+
const data = meshTo3MF(meshes);
|
|
103
|
+
post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
|
|
82
104
|
}
|
|
83
105
|
} catch (err) {
|
|
84
106
|
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
|
|
2
|
+
|
|
3
|
+
// Tracks whether each sub-part's cached display mesh is still valid for the current
|
|
4
|
+
// params ("Layer 1" of the cache — skipping regeneration of sub-parts whose inputs
|
|
5
|
+
// didn't change). A sub-part's mesh is stamped with a relevance hash over just the
|
|
6
|
+
// params that sub-part reads; it's current while that hash is unchanged and the mesh
|
|
7
|
+
// is still in the viewer.
|
|
8
|
+
//
|
|
9
|
+
// The view/paramsVersion/caching state lives in mount and changes over time, so it's
|
|
10
|
+
// passed as getters. `params` is a stable object mutated in place, so it's passed by
|
|
11
|
+
// reference.
|
|
12
|
+
export function createMeshCache(part, viewer, { params, getView, getParamsVersion, isCaching }) {
|
|
13
|
+
const cacheHash = {}; // name -> relevance hash the cached mesh was built at
|
|
14
|
+
|
|
15
|
+
// Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
|
|
16
|
+
// runs probe builds, so we compute it once per change, not per sub-part.
|
|
17
|
+
let readsKey = null, readsMap = null;
|
|
18
|
+
const readsFor = () => {
|
|
19
|
+
const key = `${getParamsVersion()}|${getView()}`;
|
|
20
|
+
if (readsKey !== key) { readsKey = key; readsMap = subPartReadKeys(part, getView(), params); }
|
|
21
|
+
return readsMap;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
|
|
25
|
+
// over ALL params, so any edit invalidates it — the safe fallback).
|
|
26
|
+
const hashFor = (name) => {
|
|
27
|
+
if (!isCaching()) return `v${getParamsVersion()}`; // caching off: any edit invalidates every sub-part
|
|
28
|
+
const reads = readsFor();
|
|
29
|
+
const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(name) ?? Object.keys(params))];
|
|
30
|
+
return relevanceHash(keys, params);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
// A cached sub-part is current only if its relevance hash is unchanged.
|
|
35
|
+
isCurrent: (name) => viewer.hasSubMesh(name) && cacheHash[name] === hashFor(name),
|
|
36
|
+
// Stamp a freshly built mesh with the hash it was built at.
|
|
37
|
+
record: (name) => { cacheHash[name] = hashFor(name); },
|
|
38
|
+
// Drop a sub-part's stamp so it rebuilds next generate.
|
|
39
|
+
forget: (name) => { delete cacheHash[name]; },
|
|
40
|
+
};
|
|
41
|
+
}
|