partforge 0.1.0 → 0.4.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 +3 -1
- package/bin/cli.js +73 -0
- package/docs/AUTHORING-PARTS.md +200 -3
- package/package.json +15 -1
- package/src/app-filleted-box.js +7 -0
- package/src/filleted-box-worker.js +3 -0
- package/src/framework/app.css +26 -1
- package/src/framework/controls.js +153 -36
- package/src/framework/debug-overlay.js +40 -0
- package/src/framework/geometry/edge-selector.js +17 -0
- package/src/framework/geometry/errors.js +10 -0
- package/src/framework/geometry/face-selector.js +19 -0
- package/src/framework/geometry/kernel.js +9 -1
- package/src/framework/geometry/manifold-backend.js +76 -18
- package/src/framework/geometry/occt-backend.js +118 -4
- package/src/framework/geometry/polygon.js +117 -0
- package/src/framework/geometry/probe.js +52 -0
- package/src/framework/geometry/solid-cache.js +39 -0
- package/src/framework/geometry/solid-hash.js +21 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +24 -8
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +144 -18
- package/src/framework/param-deps.js +81 -0
- package/src/framework/selection/format.js +31 -0
- package/src/framework/selection/index.js +5 -0
- package/src/framework/selection/pick.js +48 -0
- package/src/framework/selection/resolve.js +54 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +42 -3
- package/src/parts/demo.js +29 -11
- package/src/parts/filleted-box.js +46 -0
- package/src/testing/build.js +17 -0
- package/src/testing/measure.js +53 -0
- package/src/testing/mesh.js +27 -0
- package/src/testing/render.js +159 -0
- package/src/testing.js +3 -0
|
@@ -1,19 +1,102 @@
|
|
|
1
1
|
// OCCT backend via replicad. Same GeometryKernel shape as the Manifold backend,
|
|
2
2
|
// and the only backend with toSTEP(). This is where today's drum.js kernel calls
|
|
3
3
|
// (makeCylinder, makeHelix+genericSweep, draw/extrude, cut/fuse) now live.
|
|
4
|
+
import { toEdgeFinder } from "./edge-selector.js";
|
|
5
|
+
import { toFaceFinder } from "./face-selector.js";
|
|
4
6
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
5
7
|
|
|
6
8
|
export function createOcctKernel(replicad) {
|
|
7
9
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
8
|
-
makeCompound, loft, draw, exportSTEP } = replicad;
|
|
10
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
|
|
11
|
+
|
|
12
|
+
// Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
|
|
13
|
+
// meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
|
|
14
|
+
// so weld vertices by position, then a closed solid has every edge shared by exactly
|
|
15
|
+
// two triangles. (A coarse mesh is enough — this is a topology check.)
|
|
16
|
+
const isClosedSolid = (shape) => {
|
|
17
|
+
const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
|
|
18
|
+
const P = m.vertices, T = m.triangles;
|
|
19
|
+
const id = new Map();
|
|
20
|
+
const vid = (i) => {
|
|
21
|
+
const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
|
|
22
|
+
let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
|
|
23
|
+
};
|
|
24
|
+
const edges = new Map();
|
|
25
|
+
for (let t = 0; t < T.length / 3; t++) {
|
|
26
|
+
const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
|
|
27
|
+
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); }
|
|
28
|
+
}
|
|
29
|
+
for (const n of edges.values()) if (n !== 2) return false;
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The true maximum chamfer for an edge depends on local angles and adjacent features,
|
|
34
|
+
// which is hard to predict analytically (and OCCT exposes no max-radius query). So
|
|
35
|
+
// VALIDATE the result instead of guessing: try the requested distance, and if it makes
|
|
36
|
+
// a closed solid, use it (valid large chamfers — e.g. on a pill — go through). If not,
|
|
37
|
+
// binary-search the largest distance that does. Discarded attempts are freed so OCCT's
|
|
38
|
+
// WASM heap doesn't grow across regenerates.
|
|
39
|
+
const validChamfer = (shape, finderFn, distance) => {
|
|
40
|
+
if (!(distance > 0)) return shape.clone();
|
|
41
|
+
const tryAt = (d) => {
|
|
42
|
+
const probe = shape.clone();
|
|
43
|
+
let res;
|
|
44
|
+
try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
|
|
45
|
+
if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
|
|
46
|
+
res.delete?.();
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
let best = tryAt(distance);
|
|
50
|
+
if (best) return best; // requested distance is valid
|
|
51
|
+
let lo = 0, hi = distance, bestD = 0;
|
|
52
|
+
for (let i = 0; i < 6; i++) {
|
|
53
|
+
const mid = (lo + hi) / 2;
|
|
54
|
+
const res = tryAt(mid);
|
|
55
|
+
if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
|
|
56
|
+
}
|
|
57
|
+
if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
|
|
58
|
+
return shape.clone(); // nothing valid — skip the chamfer
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Native fillet/chamfer can throw or yield an empty solid for out-of-range radii
|
|
62
|
+
// or awkward edge interactions — and OCCT's failures aren't monotonic in the
|
|
63
|
+
// radius (e.g. a radius that equals an adjacent fillet's can fail while larger
|
|
64
|
+
// ones succeed). Rather than letting the whole part vanish, attempt the op on a
|
|
65
|
+
// clone and fall back to the original shape (feature skipped) on a throw or empty
|
|
66
|
+
// result, with a console warning so it's discoverable.
|
|
67
|
+
const safeOp = (shape, op, label) => {
|
|
68
|
+
const backup = shape.clone();
|
|
69
|
+
try {
|
|
70
|
+
const result = op(shape);
|
|
71
|
+
if (measureVolume(result) > 0) { backup.delete?.(); return result; }
|
|
72
|
+
result.delete?.();
|
|
73
|
+
console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
|
|
76
|
+
}
|
|
77
|
+
return backup;
|
|
78
|
+
};
|
|
9
79
|
|
|
10
80
|
const wrap = (shape) => ({
|
|
11
81
|
_s: shape,
|
|
12
82
|
cut: (t) => wrap(shape.cut(t._s)),
|
|
13
83
|
cutAll: (tools) => wrap(shape.cut(makeCompound(tools.map((t) => t._s)))),
|
|
84
|
+
clone: () => wrap(shape.clone()),
|
|
85
|
+
boundingBox: () => {
|
|
86
|
+
const bb = shape.boundingBox; // replicad BoundingBox: .bounds [[min],[max]], .center
|
|
87
|
+
const [min, max] = bb.bounds;
|
|
88
|
+
return {
|
|
89
|
+
min: [...min], max: [...max], center: [...bb.center],
|
|
90
|
+
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
91
|
+
};
|
|
92
|
+
},
|
|
14
93
|
translate: (v) => wrap(shape.translate(v)),
|
|
15
94
|
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
|
|
16
95
|
mirror: (plane) => wrap(shape.mirror(plane)),
|
|
96
|
+
scale: (factor, center = [0, 0, 0]) => {
|
|
97
|
+
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
98
|
+
return wrap(shape.scale(factor, center));
|
|
99
|
+
},
|
|
17
100
|
toMesh: ({ quality = "preview" } = {}) => {
|
|
18
101
|
const m = shape.mesh(MESH[quality]);
|
|
19
102
|
return {
|
|
@@ -24,6 +107,18 @@ export function createOcctKernel(replicad) {
|
|
|
24
107
|
};
|
|
25
108
|
},
|
|
26
109
|
toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
|
|
110
|
+
fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`)),
|
|
111
|
+
chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance)),
|
|
112
|
+
shell: (thickness, openFaces) => {
|
|
113
|
+
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
114
|
+
// replicad shells inward with a positive thickness in this version, keeping outer dimensions.
|
|
115
|
+
return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`));
|
|
116
|
+
},
|
|
117
|
+
volume: () => measureVolume(shape),
|
|
118
|
+
toIndexedMesh: () => {
|
|
119
|
+
const m = shape.mesh(MESH.preview);
|
|
120
|
+
return { positions: Float32Array.from(m.vertices), indices: Uint32Array.from(m.triangles) };
|
|
121
|
+
},
|
|
27
122
|
});
|
|
28
123
|
|
|
29
124
|
// cylinder OR frustum (loft of two circles) when rb !== rt
|
|
@@ -36,10 +131,25 @@ export function createOcctKernel(replicad) {
|
|
|
36
131
|
};
|
|
37
132
|
|
|
38
133
|
// extrude a 2-D polygon from z=0
|
|
39
|
-
const prism = (pts, h) => {
|
|
134
|
+
const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
|
|
135
|
+
if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
|
|
136
|
+
let pen = draw(pts[0]);
|
|
137
|
+
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
138
|
+
const sketch = pen.close().sketchOnPlane("XY");
|
|
139
|
+
if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
|
|
140
|
+
const cfg = {};
|
|
141
|
+
if (twist !== 0) cfg.twistAngle = twist;
|
|
142
|
+
if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
|
|
143
|
+
return wrap(sketch.extrude(h, cfg));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
|
|
147
|
+
const revolve = (pts, { degrees = 360 } = {}) => {
|
|
148
|
+
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
40
149
|
let pen = draw(pts[0]);
|
|
41
150
|
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
42
|
-
|
|
151
|
+
const sketch = pen.close().sketchOnPlane("XZ");
|
|
152
|
+
return wrap(sketch.revolve([0, 0, 1], { angle: degrees }));
|
|
43
153
|
};
|
|
44
154
|
|
|
45
155
|
// circle profile swept along a helix (frenet)
|
|
@@ -52,7 +162,11 @@ export function createOcctKernel(replicad) {
|
|
|
52
162
|
};
|
|
53
163
|
|
|
54
164
|
return {
|
|
55
|
-
cylinder,
|
|
165
|
+
cylinder,
|
|
166
|
+
boredCylinder: ({ od, h, bore }) =>
|
|
167
|
+
cylinder(od / 2, od / 2, h).cut(cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2])),
|
|
168
|
+
box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
|
|
169
|
+
sphere: (r) => wrap(makeSphere(r)),
|
|
56
170
|
union: (solids) => wrap(solids.map((s) => s._s).reduce((a, b) => a.fuse(b))),
|
|
57
171
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
58
172
|
};
|
|
@@ -21,3 +21,120 @@ export function hexPolygon(r) {
|
|
|
21
21
|
}
|
|
22
22
|
return pts;
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// Rectangle w×h centred at the origin with radius-r corners (r clamped to min(w,h)/2).
|
|
26
|
+
export function roundedRectPolygon(w, h, r, segs = 8) {
|
|
27
|
+
r = Math.min(r, Math.min(w, h) / 2);
|
|
28
|
+
const hw = w / 2, hh = h / 2;
|
|
29
|
+
if (r <= 0) return [[hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]];
|
|
30
|
+
const corners = [
|
|
31
|
+
[hw - r, hh - r, 0], // top-right
|
|
32
|
+
[-(hw - r), hh - r, Math.PI / 2], // top-left
|
|
33
|
+
[-(hw - r), -(hh - r), Math.PI], // bottom-left
|
|
34
|
+
[hw - r, -(hh - r), (3 * Math.PI) / 2], // bottom-right
|
|
35
|
+
];
|
|
36
|
+
const pts = [];
|
|
37
|
+
for (const [cx, cy, a0] of corners)
|
|
38
|
+
for (let i = 0; i <= segs; i++) {
|
|
39
|
+
const a = a0 + (Math.PI / 2) * (i / segs);
|
|
40
|
+
pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
|
|
41
|
+
}
|
|
42
|
+
return pts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Regular n-gon, circumradius r. Vertex up by default; flat:true puts a flat edge up.
|
|
46
|
+
export function regularPolygon(n, r, { flat = false } = {}) {
|
|
47
|
+
const base = Math.PI / 2 + (flat ? Math.PI / n : 0);
|
|
48
|
+
const pts = [];
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const a = base + (2 * Math.PI * i) / n;
|
|
51
|
+
pts.push([r * Math.cos(a), r * Math.sin(a)]);
|
|
52
|
+
}
|
|
53
|
+
return pts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ellipse with semi-axes rx, ry.
|
|
57
|
+
export function ellipsePolygon(rx, ry, segs = 48) {
|
|
58
|
+
const pts = [];
|
|
59
|
+
for (let i = 0; i < segs; i++) {
|
|
60
|
+
const a = (2 * Math.PI * i) / segs;
|
|
61
|
+
pts.push([rx * Math.cos(a), ry * Math.sin(a)]);
|
|
62
|
+
}
|
|
63
|
+
return pts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stadium/obround slot: two r-radius semicircles whose centres are `length` apart
|
|
67
|
+
// (overall length = length + 2r), centred at the origin, long axis along X.
|
|
68
|
+
export function slotPolygon(length, r, segs = 16) {
|
|
69
|
+
const hl = length / 2;
|
|
70
|
+
const pts = [];
|
|
71
|
+
for (let i = 0; i <= segs; i++) { const a = -Math.PI / 2 + Math.PI * (i / segs); pts.push([hl + r * Math.cos(a), r * Math.sin(a)]); }
|
|
72
|
+
for (let i = 0; i <= segs; i++) { const a = Math.PI / 2 + Math.PI * (i / segs); pts.push([-hl + r * Math.cos(a), r * Math.sin(a)]); }
|
|
73
|
+
return pts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Star with `points` tips, alternating outer/inner radius. First tip points up.
|
|
77
|
+
export function starPolygon(points, outerR, innerR) {
|
|
78
|
+
const pts = [];
|
|
79
|
+
for (let i = 0; i < points * 2; i++) {
|
|
80
|
+
const a = Math.PI / 2 + (Math.PI * i) / points;
|
|
81
|
+
const rr = i % 2 === 0 ? outerR : innerR;
|
|
82
|
+
pts.push([rr * Math.cos(a), rr * Math.sin(a)]);
|
|
83
|
+
}
|
|
84
|
+
return pts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Annular sector as a single closed contour (outer arc, then inner arc back).
|
|
88
|
+
// arcDeg must be < 360 — a full annulus is a contour-with-hole; cut an inner
|
|
89
|
+
// cylinder from an outer one for a full ring.
|
|
90
|
+
export function ringSectorPolygon(innerR, outerR, arcDeg, segs = 32) {
|
|
91
|
+
if (arcDeg >= 360) throw new Error("ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)");
|
|
92
|
+
const a = (arcDeg * Math.PI) / 180;
|
|
93
|
+
const steps = Math.max(2, Math.ceil((segs * arcDeg) / 360));
|
|
94
|
+
const pts = [];
|
|
95
|
+
for (let i = 0; i <= steps; i++) { const t = (a * i) / steps; pts.push([outerR * Math.cos(t), outerR * Math.sin(t)]); }
|
|
96
|
+
for (let i = steps; i >= 0; i--) { const t = (a * i) / steps; pts.push([innerR * Math.cos(t), innerR * Math.sin(t)]); }
|
|
97
|
+
return pts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
|
|
101
|
+
|
|
102
|
+
// `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
|
|
103
|
+
// Returns a Solid[] — feed to k.union(...) (features) or s.cutAll(...) (holes).
|
|
104
|
+
export function linearPattern(solid, count, step) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let i = 0; i < count; i++)
|
|
107
|
+
out.push(solid.clone().translate([step[0] * i, step[1] * i, step[2] * i]));
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `count` copies spaced angle/count degrees apart around `axis` through `center`.
|
|
112
|
+
// rotateCopies:true re-orients each copy to face along the circle; false places it
|
|
113
|
+
// at the orbital position with its original orientation (for radially symmetric tools).
|
|
114
|
+
export function circularPattern(solid, count, { center = [0, 0, 0], axis = "Z", angle = 360, rotateCopies = true } = {}) {
|
|
115
|
+
const ax = Array.isArray(axis) ? axis : PATTERN_AXIS[axis];
|
|
116
|
+
const out = [];
|
|
117
|
+
for (let i = 0; i < count; i++) {
|
|
118
|
+
const deg = (angle / count) * i;
|
|
119
|
+
const placed = solid.clone().rotate(deg, center, ax);
|
|
120
|
+
if (rotateCopies) { out.push(placed); continue; }
|
|
121
|
+
// cancel the orientation change by counter-rotating about the copy's own centre
|
|
122
|
+
const c = placed.boundingBox().center;
|
|
123
|
+
out.push(placed.rotate(-deg, c, ax));
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// CCW circle of radius r centered at [cx, cy]. A shared 2-D profile primitive:
|
|
129
|
+
// compose with the kernel's profile ops — e.g. revolve(circleProfile(minorR,
|
|
130
|
+
// [majorR, 0])) is a torus, prism(circleProfile(r), h) a cylinder.
|
|
131
|
+
export function circleProfile(r, center = [0, 0], segs = 48) {
|
|
132
|
+
if (!(r > 0)) throw new Error("circleProfile: r must be > 0");
|
|
133
|
+
const [cx, cy] = center;
|
|
134
|
+
const pts = [];
|
|
135
|
+
for (let i = 0; i < segs; i++) {
|
|
136
|
+
const a = (2 * Math.PI * i) / segs;
|
|
137
|
+
pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
|
|
138
|
+
}
|
|
139
|
+
return pts;
|
|
140
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Geometry-free backend detection. A probe kernel records every op a part's
|
|
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
|
+
export const OCCT_ONLY = new Set(["fillet", "chamfer", "shell"]);
|
|
5
|
+
|
|
6
|
+
export function createProbeKernel() {
|
|
7
|
+
const used = new Set();
|
|
8
|
+
const note = (name) => used.add(name);
|
|
9
|
+
const proxy = {
|
|
10
|
+
cut() { note("cut"); return proxy; },
|
|
11
|
+
cutAll() { note("cutAll"); return proxy; },
|
|
12
|
+
intersect() { note("intersect"); return proxy; },
|
|
13
|
+
clone() { note("clone"); return proxy; },
|
|
14
|
+
boundingBox() { note("boundingBox"); return { min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }; },
|
|
15
|
+
translate() { note("translate"); return proxy; },
|
|
16
|
+
rotate() { note("rotate"); return proxy; },
|
|
17
|
+
mirror() { note("mirror"); return proxy; },
|
|
18
|
+
scale() { note("scale"); return proxy; },
|
|
19
|
+
fillet() { note("fillet"); return proxy; },
|
|
20
|
+
chamfer() { note("chamfer"); return proxy; },
|
|
21
|
+
shell() { note("shell"); return proxy; },
|
|
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) }; },
|
|
26
|
+
};
|
|
27
|
+
const kernel = {
|
|
28
|
+
cylinder() { note("cylinder"); return proxy; },
|
|
29
|
+
boredCylinder() { note("boredCylinder"); return proxy; },
|
|
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() {},
|
|
38
|
+
};
|
|
39
|
+
return { kernel, used };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function detectBackend(part, params = {}) {
|
|
43
|
+
if (part.meta?.backend) return part.meta.backend;
|
|
44
|
+
const p = { ...part.defaults, ...params };
|
|
45
|
+
const d = part.derive ? part.derive(p) : {};
|
|
46
|
+
const { kernel, used } = createProbeKernel();
|
|
47
|
+
for (const name of Object.keys(part.parts)) {
|
|
48
|
+
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
|
49
|
+
}
|
|
50
|
+
for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
|
|
51
|
+
return "manifold";
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Worker-side cache of boundary-op solids, partitioned per sub-part. Retention is
|
|
2
|
+
// bounded to the CURRENT build's graph: each begin()/end() bracket rebuilds a
|
|
3
|
+
// sub-part's retained set from scratch, disposing any entry not re-used this round.
|
|
4
|
+
// WASM-agnostic — it stores opaque {value, pin, dispose} triples supplied by the
|
|
5
|
+
// caller (the Manifold backend), so it is unit-testable with plain objects.
|
|
6
|
+
export function createSolidCache() {
|
|
7
|
+
const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
|
|
8
|
+
const pinned = new Set(); // every live `pin` across all sub-parts
|
|
9
|
+
let name = null, active = null, prev = null;
|
|
10
|
+
let hits = 0, misses = 0;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
begin(n) { name = n; prev = caches.get(n) ?? new Map(); active = new Map(); },
|
|
14
|
+
|
|
15
|
+
end() {
|
|
16
|
+
if (name == null) return;
|
|
17
|
+
for (const [hash, entry] of prev) {
|
|
18
|
+
if (!active.has(hash)) { pinned.delete(entry.pin); entry.dispose(); } // evict
|
|
19
|
+
}
|
|
20
|
+
caches.set(name, active);
|
|
21
|
+
name = null; active = prev = null;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
lookup(hash, make) {
|
|
25
|
+
if (name == null) return make().value; // not bracketed → no caching
|
|
26
|
+
if (active.has(hash)) { hits++; return active.get(hash).value; }
|
|
27
|
+
if (prev.has(hash)) { hits++; const e = prev.get(hash); active.set(hash, e); return e.value; }
|
|
28
|
+
misses++;
|
|
29
|
+
const entry = make();
|
|
30
|
+
active.set(hash, entry);
|
|
31
|
+
pinned.add(entry.pin);
|
|
32
|
+
return entry.value;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
isPinned: (pin) => pinned.has(pin),
|
|
36
|
+
stats: () => ({ hits, misses }),
|
|
37
|
+
resetStats: () => { hits = 0; misses = 0; },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Stable content hash for cached solids. Serializes scalar args canonically and
|
|
2
|
+
// folds via FNV-1a → base36. Solid operands are passed as their own (already
|
|
3
|
+
// computed) short `_hash` string, so composing two solids stays O(1) and the
|
|
4
|
+
// resulting key length stays bounded no matter how deep the build graph is.
|
|
5
|
+
export function h(...parts) {
|
|
6
|
+
return fnv(parts.map(canon).join("|"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function canon(x) {
|
|
10
|
+
if (Array.isArray(x)) return "[" + x.map(canon).join(",") + "]";
|
|
11
|
+
if (x && typeof x === "object") return "{" + Object.keys(x).sort().map((k) => k + ":" + canon(x[k])).join(",") + "}";
|
|
12
|
+
return String(x);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// FNV-1a folded to 32-bit space (collision risk acceptable: retained only for one
|
|
16
|
+
// sub-part's build graph ~3–15 nodes, rebuilt each round, no accumulation).
|
|
17
|
+
function fnv(s) {
|
|
18
|
+
let hsh = 0x811c9dc5;
|
|
19
|
+
for (let i = 0; i < s.length; i++) { hsh ^= s.charCodeAt(i); hsh = Math.imul(hsh, 0x01000193); }
|
|
20
|
+
return (hsh >>> 0).toString(36);
|
|
21
|
+
}
|
|
@@ -5,16 +5,14 @@
|
|
|
5
5
|
// `new Worker(new URL("./part-worker.js", import.meta.url), { type:"module", name })`
|
|
6
6
|
// pattern INLINE — Vite only bundles a worker (and its backend chunks) when it sees
|
|
7
7
|
// that literal call, so the framework can't construct it from a passed-in URL.
|
|
8
|
-
export function createGeometryService({ createWorker, onMessage
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
exporter.onmessage = onMessage;
|
|
13
|
-
const genWorker = occtPreview ? exporter : preview;
|
|
8
|
+
export function createGeometryService({ createWorker, onMessage }) {
|
|
9
|
+
const workers = { manifold: createWorker("manifold"), occt: createWorker("occt") };
|
|
10
|
+
workers.manifold.onmessage = onMessage;
|
|
11
|
+
workers.occt.onmessage = onMessage;
|
|
14
12
|
return {
|
|
15
|
-
generate: (msg) =>
|
|
16
|
-
exportStl: (msg) =>
|
|
17
|
-
export3mf: (msg) =>
|
|
18
|
-
exportStep: (msg) =>
|
|
13
|
+
generate: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
14
|
+
exportStl: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
15
|
+
export3mf: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
16
|
+
exportStep: (msg) => workers.occt.postMessage(msg), // STEP is always OCCT
|
|
19
17
|
};
|
|
20
18
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -11,6 +11,14 @@ export function viewSubParts(part, view, params) {
|
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Sub-parts to include in an EXPORT of this view: the visible sub-parts, minus any
|
|
15
|
+
// flagged `exportable: false` (reference/preview-only parts — motor ghosts, bearing
|
|
16
|
+
// placeholders, etc.). They still show in the viewer; they're just never written to
|
|
17
|
+
// an STL/STEP/3MF file, so the user never has to toggle them off before exporting.
|
|
18
|
+
export function exportSubParts(part, view, params) {
|
|
19
|
+
return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
// Handle one geometry job, posting results/progress via `post`. Backend-agnostic
|
|
15
23
|
// and part-agnostic: every part specific comes through `part`.
|
|
16
24
|
// { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
|
|
@@ -34,22 +42,29 @@ export async function handle(kernel, part, msg, post) {
|
|
|
34
42
|
try {
|
|
35
43
|
if (msg.type === "generate") {
|
|
36
44
|
const t0 = Date.now();
|
|
45
|
+
const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
|
|
37
46
|
const meshes = [];
|
|
47
|
+
kernel.resetCacheStats?.(); // count hits/misses for just this job
|
|
38
48
|
for (const name of msg.subparts) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
50
|
+
try {
|
|
51
|
+
const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
|
|
52
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
|
|
53
|
+
} finally {
|
|
54
|
+
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
55
|
+
kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
|
|
56
|
+
}
|
|
42
57
|
}
|
|
43
|
-
post({ type: "meshes", meshes, ms: Date.now() - t0 });
|
|
58
|
+
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() });
|
|
44
59
|
} else if (msg.type === "export-stl") {
|
|
45
60
|
const out = [];
|
|
46
|
-
for (const name of
|
|
61
|
+
for (const name of exportSubParts(part, msg.view, p)) {
|
|
47
62
|
onProgress(`building ${label(name)}`);
|
|
48
63
|
out.push({ name: exportName(name), data: await buildPosed(name, "export", msg.view, onProgress).toSTL({ quality: "print" }) });
|
|
49
64
|
}
|
|
50
65
|
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out });
|
|
51
66
|
} else if (msg.type === "export-step") {
|
|
52
|
-
const solids =
|
|
67
|
+
const solids = exportSubParts(part, msg.view, p).map((name) => {
|
|
53
68
|
onProgress(`building ${label(name)}`);
|
|
54
69
|
return { name: exportName(name), solid: buildPosed(name, "export", msg.view, onProgress) };
|
|
55
70
|
});
|
|
@@ -57,7 +72,7 @@ export async function handle(kernel, part, msg, post) {
|
|
|
57
72
|
const data = await kernel.toSTEP(solids);
|
|
58
73
|
post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" });
|
|
59
74
|
} else if (msg.type === "export-3mf") {
|
|
60
|
-
const meshes =
|
|
75
|
+
const meshes = exportSubParts(part, msg.view, p).map((name) => {
|
|
61
76
|
onProgress(`building ${label(name)}`);
|
|
62
77
|
const { positions, indices } = buildPosed(name, "export", msg.view, onProgress).toIndexedMesh();
|
|
63
78
|
return { name: exportName(name), positions, indices };
|
|
@@ -66,7 +81,8 @@ export async function handle(kernel, part, msg, post) {
|
|
|
66
81
|
post({ type: "download", data: meshTo3MF(meshes), filename: `${msg.view}.3mf`, mime: "model/3mf" });
|
|
67
82
|
}
|
|
68
83
|
} catch (err) {
|
|
69
|
-
post({ type: "
|
|
84
|
+
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
|
|
85
|
+
else post({ type: "error", message: String(err?.message || err) });
|
|
70
86
|
} finally {
|
|
71
87
|
kernel.cleanup?.();
|
|
72
88
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Render a (trusted, author-authored) Markdown description to sanitized HTML for
|
|
2
|
+
// display in a control/section info popover. Main-thread only — it imports a DOM
|
|
3
|
+
// sanitizer, so it must NOT be imported from the geometry worker.
|
|
4
|
+
import { marked } from "marked";
|
|
5
|
+
import createDOMPurify from "dompurify";
|
|
6
|
+
|
|
7
|
+
const CONFIG = {
|
|
8
|
+
ALLOWED_TAGS: ["a", "img", "p", "br", "strong", "em", "code", "pre", "blockquote",
|
|
9
|
+
"ul", "ol", "li", "h1", "h2", "h3", "h4", "table", "thead", "tbody", "tr", "th", "td", "hr", "del"],
|
|
10
|
+
ALLOWED_ATTR: ["href", "src", "alt", "title", "target", "rel"],
|
|
11
|
+
// links: http(s)/mailto; images: https or data:image/. (Union applied to all URI attrs.)
|
|
12
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|data:image\/)/i,
|
|
13
|
+
FORBID_ATTR: ["style"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// DOMPurify is initialized lazily so that window is available at call time.
|
|
17
|
+
// Eager module-level init runs before happy-dom has finished setting up its
|
|
18
|
+
// global window, causing DOMPurify.isSupported to be false.
|
|
19
|
+
let _purify = null;
|
|
20
|
+
function getPurify() {
|
|
21
|
+
if (_purify) return _purify;
|
|
22
|
+
// Pass window explicitly: works in a real browser and in the happy-dom
|
|
23
|
+
// per-file test environment (where window is global but may not be set yet
|
|
24
|
+
// at module-evaluation time).
|
|
25
|
+
_purify = createDOMPurify(window);
|
|
26
|
+
// Links open in a new tab and cannot reach window.opener.
|
|
27
|
+
_purify.addHook("afterSanitizeAttributes", (node) => {
|
|
28
|
+
if (node.tagName === "A" && node.getAttribute("href")) {
|
|
29
|
+
node.setAttribute("target", "_blank");
|
|
30
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return _purify;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src: a CommonMark string. Returns sanitized HTML. Empty/blank/non-string → "".
|
|
37
|
+
export function renderMarkdown(src) {
|
|
38
|
+
if (typeof src !== "string" || !src.trim()) return "";
|
|
39
|
+
const raw = marked.parse(src, { async: false });
|
|
40
|
+
return getPurify().sanitize(raw, CONFIG);
|
|
41
|
+
}
|