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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/bin/cli.js +73 -0
  3. package/docs/AUTHORING-PARTS.md +200 -3
  4. package/package.json +15 -1
  5. package/src/app-filleted-box.js +7 -0
  6. package/src/filleted-box-worker.js +3 -0
  7. package/src/framework/app.css +26 -1
  8. package/src/framework/controls.js +153 -36
  9. package/src/framework/debug-overlay.js +40 -0
  10. package/src/framework/geometry/edge-selector.js +17 -0
  11. package/src/framework/geometry/errors.js +10 -0
  12. package/src/framework/geometry/face-selector.js +19 -0
  13. package/src/framework/geometry/kernel.js +9 -1
  14. package/src/framework/geometry/manifold-backend.js +76 -18
  15. package/src/framework/geometry/occt-backend.js +118 -4
  16. package/src/framework/geometry/polygon.js +117 -0
  17. package/src/framework/geometry/probe.js +52 -0
  18. package/src/framework/geometry/solid-cache.js +39 -0
  19. package/src/framework/geometry/solid-hash.js +21 -0
  20. package/src/framework/geometry-service.js +8 -10
  21. package/src/framework/jobs.js +24 -8
  22. package/src/framework/markdown.js +41 -0
  23. package/src/framework/mount.js +144 -18
  24. package/src/framework/param-deps.js +81 -0
  25. package/src/framework/selection/format.js +31 -0
  26. package/src/framework/selection/index.js +5 -0
  27. package/src/framework/selection/pick.js +48 -0
  28. package/src/framework/selection/resolve.js +54 -0
  29. package/src/framework/view-state.js +55 -0
  30. package/src/framework/viewer.js +42 -3
  31. package/src/parts/demo.js +29 -11
  32. package/src/parts/filleted-box.js +46 -0
  33. package/src/testing/build.js +17 -0
  34. package/src/testing/measure.js +53 -0
  35. package/src/testing/mesh.js +27 -0
  36. package/src/testing/render.js +159 -0
  37. 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
- return wrap(pen.close().sketchOnPlane("XY").extrude(h));
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, box: (min, max) => wrap(makeBox(min, max)), prism, helixSweptTube,
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, occtPreview = false }) {
9
- const preview = createWorker("manifold"); // preview meshes + STL
10
- const exporter = createWorker("occt"); // STEP (and preview when occtPreview)
11
- preview.onmessage = onMessage;
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) => genWorker.postMessage(msg),
16
- exportStl: (msg) => preview.postMessage(msg),
17
- export3mf: (msg) => preview.postMessage(msg), // mesh export → Manifold worker
18
- exportStep: (msg) => exporter.postMessage(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
  }
@@ -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
- const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
40
- meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
41
- kernel.cleanup?.();
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 viewSubParts(part, msg.view, p)) {
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 = viewSubParts(part, msg.view, p).map((name) => {
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 = viewSubParts(part, msg.view, p).map((name) => {
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: "error", message: String(err?.message || err) });
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
+ }