partforge 0.6.1 → 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 +61 -15
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +48 -23
- package/package.json +1 -1
- package/src/framework/app.css +26 -0
- 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 +45 -24
- 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 +4 -3
- 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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The backend-shared kernel front. Each backend builds its primitive mapping and
|
|
2
|
+
// returns finishKernel(kernel), which layers on everything that is NOT
|
|
3
|
+
// backend-specific:
|
|
4
|
+
// - argument validation (previously copy-pasted into both backends);
|
|
5
|
+
// - default compound-op compositions — a backend only overrides one when it has
|
|
6
|
+
// a reason to (Manifold's boredCylinder hashes atomically for its solid cache);
|
|
7
|
+
// - a KernelCapabilityError stub for toSTEP when the backend can't write B-rep.
|
|
8
|
+
// The per-Solid twin of this layer is addSugar() in solid-sugar.js.
|
|
9
|
+
import { KernelCapabilityError } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
export function finishKernel(k) {
|
|
12
|
+
const rawPrism = k.prism;
|
|
13
|
+
k.prism = (pts, h, opts) => {
|
|
14
|
+
if ((opts?.scaleTop ?? 1) < 0) throw new Error("prism: scaleTop must be ≥ 0");
|
|
15
|
+
return rawPrism(pts, h, opts);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const rawRevolve = k.revolve;
|
|
19
|
+
k.revolve = (pts, opts) => {
|
|
20
|
+
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
21
|
+
return rawRevolve(pts, opts);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Compound: bored-through cylinder (tool overshoots 2 mm each end for a clean cut).
|
|
25
|
+
k.boredCylinder ??= ({ od, h, bore }) =>
|
|
26
|
+
k.cylinder(od / 2, od / 2, h).cut(k.cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2]));
|
|
27
|
+
|
|
28
|
+
k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
|
|
29
|
+
|
|
30
|
+
return k;
|
|
31
|
+
}
|
|
@@ -1,15 +1,48 @@
|
|
|
1
|
-
// The GeometryKernel contract
|
|
2
|
-
//
|
|
1
|
+
// The GeometryKernel contract. The op lists below are DATA, not just docs: the
|
|
2
|
+
// parity tests (test/kernel-contract.test.js and the OCCT twin in
|
|
3
|
+
// test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
|
|
4
|
+
// contract can't silently drift from the implementations — the drift class that
|
|
5
|
+
// once broke the probe kernel (see probe.js). The @typedefs document signatures.
|
|
6
|
+
// (2-D polygon helpers live in ./polygon.js.)
|
|
7
|
+
|
|
8
|
+
// Ops every backend kernel must implement.
|
|
9
|
+
export const KERNEL_OPS = [
|
|
10
|
+
"cylinder", "boredCylinder", "sphere", "box", "prism", "revolve",
|
|
11
|
+
"helixSweptTube", "union", "toSTEP",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
|
|
15
|
+
// jobs.js calls all of these via `?.`, so a backend may simply omit them.
|
|
16
|
+
export const KERNEL_OPTIONAL_OPS = [
|
|
17
|
+
"beginSubPart", "endSubPart", "cacheStats", "resetCacheStats", "cleanup",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Ops every Solid must implement (including the sugar addSugar() attaches).
|
|
21
|
+
export const SOLID_OPS = [
|
|
22
|
+
"cut", "cutAll", "intersect", "clone", "label", "boundingBox", "volume",
|
|
23
|
+
"translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
|
|
24
|
+
"mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
|
|
25
|
+
"fillet", "chamfer", "shell",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Backend-optional Solid queries: Manifold mesh-topology numbers (measure.js
|
|
29
|
+
// guards with `typeof`); OCCT has no cheap equivalent.
|
|
30
|
+
export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
|
|
31
|
+
|
|
32
|
+
// Solid ops only OCCT implements natively. Single source of truth: probe.js routes
|
|
33
|
+
// a part to OCCT when its build uses one of these, and the Manifold backend
|
|
34
|
+
// generates its KernelCapabilityError stubs from the same list — adding an op here
|
|
35
|
+
// wires up both automatically.
|
|
36
|
+
export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
3
37
|
|
|
4
38
|
/**
|
|
5
|
-
* @typedef {Object} Solid An opaque handle to a backend solid.
|
|
6
|
-
* @property {string} _hash content hash (Manifold backend only; drives the worker solid cache)
|
|
39
|
+
* @typedef {Object} Solid An opaque handle to a backend solid. `_`-prefixed keys are backend internals.
|
|
7
40
|
* @property {(tool: Solid) => Solid} cut
|
|
8
41
|
* @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
|
|
9
|
-
* @property {(other: Solid) => Solid} intersect boolean intersection (
|
|
42
|
+
* @property {(other: Solid) => Solid} intersect boolean intersection (both backends)
|
|
10
43
|
* @property {() => Solid} clone independent copy (replicad consumes solids on transform)
|
|
44
|
+
* @property {(name: string) => Solid} label name this solid's surface for hover/pick feature attribution (survives transforms + booleans; same name on several solids merges into one feature)
|
|
11
45
|
* @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
|
|
12
|
-
* @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
|
|
13
46
|
* @property {(v: number[]) => Solid} translate
|
|
14
47
|
* @property {(deg: number, center: number[], axis: number[]) => Solid} rotate internal primitive — prefer rotateX/Y/Z / rotateAbout
|
|
15
48
|
* @property {(deg: number) => Solid} rotateX rotate about world X through the origin
|
|
@@ -20,10 +53,16 @@
|
|
|
20
53
|
* @property {(v:number[]) => Solid} at place an origin-built solid at point v (alias of translate)
|
|
21
54
|
* @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
|
|
22
55
|
* @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
|
|
23
|
-
* @property {() => number} volume solid volume in mm³ (
|
|
24
|
-
* @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices
|
|
56
|
+
* @property {() => number} volume solid volume in mm³ (both backends; used by collision/overlap tests)
|
|
57
|
+
* @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices?:Uint32Array, triangles:number, edges?:Float32Array}} toMesh
|
|
58
|
+
* `edges` = feature-edge line segments (Manifold); quality is advisory — the Manifold kernel bakes it at creation
|
|
25
59
|
* @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
|
|
26
|
-
* @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
|
|
60
|
+
* @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
|
|
61
|
+
* @property {(radius:number, selector?:object) => Solid} fillet round edges (OCCT only; Manifold throws KernelCapabilityError)
|
|
62
|
+
* @property {(distance:number, selector?:object) => Solid} chamfer bevel edges (OCCT only; Manifold throws KernelCapabilityError)
|
|
63
|
+
* @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
|
|
64
|
+
* @property {() => number} [genus] through-hole count (Manifold only)
|
|
65
|
+
* @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
|
|
27
66
|
*
|
|
28
67
|
* @typedef {Object} GeometryKernel
|
|
29
68
|
* @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
|
|
@@ -34,6 +73,10 @@
|
|
|
34
73
|
* @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
|
|
35
74
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
36
75
|
* @property {(solids:Solid[]) => Solid} union
|
|
37
|
-
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only
|
|
76
|
+
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
|
|
77
|
+
* @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
|
|
78
|
+
* @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
|
|
79
|
+
* @property {() => {hits:number,misses:number}} [cacheStats]
|
|
80
|
+
* @property {() => void} [resetCacheStats]
|
|
38
81
|
* @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
|
|
39
82
|
*/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { helixTube } from "./helix-tube.js";
|
|
2
|
-
import { KernelCapabilityError } from "./errors.js";
|
|
3
2
|
import { h } from "./solid-hash.js";
|
|
4
3
|
import { createSolidCache } from "./solid-cache.js";
|
|
5
4
|
import { addSugar } from "./solid-sugar.js";
|
|
5
|
+
import { finishKernel } from "./kernel-front.js";
|
|
6
6
|
|
|
7
7
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
8
8
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -38,6 +38,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
38
38
|
const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
|
|
39
39
|
|
|
40
40
|
const cache = createSolidCache();
|
|
41
|
+
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
41
42
|
// Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
|
|
42
43
|
// tracks the result, and returns the triple the cache needs to pin/dispose it.
|
|
43
44
|
const cached = (hash, computeM) => cache.lookup(hash, () => {
|
|
@@ -49,7 +50,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
49
50
|
// transient mesh handle.
|
|
50
51
|
function meshOut(m, asStl) {
|
|
51
52
|
const g = m.getMesh();
|
|
52
|
-
const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180));
|
|
53
|
+
const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180), featureLabels);
|
|
53
54
|
g.delete?.();
|
|
54
55
|
return r;
|
|
55
56
|
}
|
|
@@ -79,14 +80,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
79
80
|
() => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
80
81
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
81
82
|
clone: () => wrap(m, hash),
|
|
83
|
+
// Name this solid's surface for hover/pick feature attribution. asOriginal()
|
|
84
|
+
// stamps a fresh originalID that survives transforms and booleans, so every
|
|
85
|
+
// surviving triangle of this surface can be traced back to the label. The
|
|
86
|
+
// registry entry lives exactly as long as the cache pins the solid — eviction
|
|
87
|
+
// disposes both, so the registry can't grow unboundedly across regenerates.
|
|
88
|
+
label: (name) => {
|
|
89
|
+
const lh = h("label", hash, name);
|
|
90
|
+
return cache.lookup(lh, () => {
|
|
91
|
+
const o = T(m.asOriginal());
|
|
92
|
+
const id = o.originalID();
|
|
93
|
+
featureLabels.set(id, name);
|
|
94
|
+
return { value: wrap(o, lh), pin: o, dispose: () => { featureLabels.delete(id); o.delete?.(); } };
|
|
95
|
+
});
|
|
96
|
+
},
|
|
82
97
|
boundingBox: () => {
|
|
83
|
-
const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
min, max,
|
|
87
|
-
center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
88
|
-
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
89
|
-
};
|
|
98
|
+
const b = m.boundingBox(); // { min: Vec3, max: Vec3 } — addSugar derives center/size
|
|
99
|
+
return { min: [...b.min], max: [...b.max] };
|
|
90
100
|
},
|
|
91
101
|
volume: () => m.volume(),
|
|
92
102
|
genus: () => m.genus(),
|
|
@@ -101,8 +111,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
101
111
|
return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
|
|
102
112
|
},
|
|
103
113
|
mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
|
|
104
|
-
scale: (factor, center
|
|
105
|
-
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
114
|
+
scale: (factor, center) => { // factor validated (and center defaulted) by addSugar
|
|
106
115
|
const a = T(m.translate([-center[0], -center[1], -center[2]]));
|
|
107
116
|
const b = T(a.scale([factor, factor, factor]));
|
|
108
117
|
return wrap(T(b.translate(center)), h("scale", hash, factor, center));
|
|
@@ -110,12 +119,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
110
119
|
toMesh: () => meshOut(m, false),
|
|
111
120
|
toSTL: () => Promise.resolve(meshOut(m, true)),
|
|
112
121
|
toIndexedMesh: () => indexedMeshOut(m),
|
|
113
|
-
fillet: () => { throw new KernelCapabilityError("fillet requires the OCCT backend"); },
|
|
114
|
-
chamfer: () => { throw new KernelCapabilityError("chamfer requires the OCCT backend"); },
|
|
115
|
-
shell: () => { throw new KernelCapabilityError("shell requires the OCCT backend"); },
|
|
116
122
|
});
|
|
117
123
|
|
|
118
|
-
return {
|
|
124
|
+
return finishKernel({
|
|
119
125
|
cylinder: (rb, rt, h2, { center = false } = {}) =>
|
|
120
126
|
wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
|
|
121
127
|
// Compound op: hashed ATOMICALLY from its own args, so it is a single cache
|
|
@@ -134,7 +140,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
134
140
|
},
|
|
135
141
|
prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
136
142
|
cached(h("prism", pts, height, twist, scaleTop, segs), () => {
|
|
137
|
-
if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
|
|
138
143
|
const cs = T(CrossSection.ofPolygons([pts]));
|
|
139
144
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
140
145
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
@@ -144,12 +149,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
144
149
|
}),
|
|
145
150
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
146
151
|
revolve: (pts, { degrees = 360 } = {}) =>
|
|
147
|
-
cached(h("revolve", pts, degrees, segs), () =>
|
|
148
|
-
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
149
|
-
return T(Manifold.revolve([pts], segs, degrees));
|
|
150
|
-
}),
|
|
152
|
+
cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees))),
|
|
151
153
|
union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
152
|
-
toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
|
|
153
154
|
beginSubPart: (name) => cache.begin(name),
|
|
154
155
|
endSubPart: () => cache.end(),
|
|
155
156
|
cacheStats: () => cache.stats(),
|
|
@@ -157,7 +158,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
157
158
|
// Free every WASM object created since the last cleanup EXCEPT solids the cache
|
|
158
159
|
// still pins (they must survive for the next build to resume from them).
|
|
159
160
|
cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
|
|
160
|
-
};
|
|
161
|
+
});
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
// Build a non-indexed mesh with normals that are smooth within a single original
|
|
@@ -166,7 +167,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
166
167
|
// only over incident triangles of the SAME original surface that also meet within
|
|
167
168
|
// `sharpCos` — so cut seams stay crisp at any angle (even near-tangent), and a
|
|
168
169
|
// surface's own sharp edges (e.g. a face meeting a side) stay crisp too.
|
|
169
|
-
function creasedNormals(g, sharpCos) {
|
|
170
|
+
function creasedNormals(g, sharpCos, featureLabels) {
|
|
170
171
|
const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
|
|
171
172
|
const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
|
|
172
173
|
|
|
@@ -245,7 +246,27 @@ function creasedNormals(g, sharpCos) {
|
|
|
245
246
|
}
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
|
|
249
|
+
// Per-triangle feature attribution: map each triangle's original-surface id
|
|
250
|
+
// through the label registry. Same label string → same feature entry, so a
|
|
251
|
+
// pattern of solids labeled alike reads as one feature.
|
|
252
|
+
let featureIds = null, features = null;
|
|
253
|
+
if (featureLabels?.size) {
|
|
254
|
+
const indexOf = new Map(); // label string -> 1-based feature index
|
|
255
|
+
features = [];
|
|
256
|
+
featureIds = new Uint16Array(nTri);
|
|
257
|
+
for (let t = 0; t < nTri; t++) {
|
|
258
|
+
const label = featureLabels.get(triOID[t]);
|
|
259
|
+
if (label === undefined) continue;
|
|
260
|
+
let fi = indexOf.get(label);
|
|
261
|
+
if (fi === undefined) { features.push(label); fi = features.length; indexOf.set(label, fi); }
|
|
262
|
+
featureIds[t] = fi;
|
|
263
|
+
}
|
|
264
|
+
if (features.length === 0) { featureIds = features = null; } // labels exist in the kernel, none in THIS mesh
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const out = { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
|
|
268
|
+
if (featureIds) { out.featureIds = featureIds; out.features = features; }
|
|
269
|
+
return out;
|
|
249
270
|
}
|
|
250
271
|
|
|
251
272
|
function stlFromMesh(g) {
|
|
@@ -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
|
|