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
@@ -6,9 +6,40 @@
6
6
  // enables it and reveals those controls right below it.
7
7
  // All controls mutate the shared `params` object and call onDirty() on change.
8
8
 
9
+ import { renderMarkdown } from "./markdown.js";
10
+
9
11
  // Short numeric string without float noise (4 dp max) for the value box.
10
12
  const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
11
13
 
14
+ // --- relevance (dim controls / hide sections that don't affect on-screen parts) ---
15
+ // `relevant` is a Set of param keys, or any non-Set value (e.g. RELEVANT_ALL) → show all.
16
+ function applyRelevance(relevant, controls, sections) {
17
+ const showAll = !(relevant instanceof Set);
18
+ for (const { key, el: node } of controls) {
19
+ const irrelevant = !showAll && !relevant.has(key);
20
+ node.classList.toggle("irrelevant", irrelevant);
21
+ if (irrelevant) node.title = "Doesn't affect the parts in the current view";
22
+ else node.removeAttribute("title");
23
+ }
24
+ for (const { el: node, keys } of sections) {
25
+ const anyRelevant = showAll || [...keys].some((k) => relevant.has(k));
26
+ node.classList.toggle("section-hidden", !anyRelevant);
27
+ }
28
+ }
29
+
30
+ // --- visibility (hidden controls/sections) --------------------------------
31
+ export const visibleAdvanced = (sec) => (sec.advanced ?? []).filter((d) => !d.hidden);
32
+ export const visibleFeatures = (sec) => (sec.features ?? []).filter((f) => !f.hidden);
33
+ // Standalone toggle checkboxes a preset section can show (outside the Advanced fold),
34
+ // e.g. preview switches. Each: { key, label, on?, description?, hidden? }.
35
+ export const visibleToggles = (sec) => (sec.toggles ?? []).filter((t) => !t.hidden);
36
+ export function sectionRenders(sec) {
37
+ if (sec.hidden) return false;
38
+ if (sec.features) return visibleFeatures(sec).length > 0;
39
+ const hasPresets = sec.presets && Object.keys(sec.presets).length > 0;
40
+ return !!hasPresets || visibleAdvanced(sec).length > 0 || visibleToggles(sec).length > 0;
41
+ }
42
+
12
43
  // Parse a typed value → clamped to [min, max], or null if not a finite number.
13
44
  export function clampToRange(raw, min, max) {
14
45
  const v = parseFloat(raw);
@@ -16,6 +47,58 @@ export function clampToRange(raw, min, max) {
16
47
  return Math.min(max, Math.max(min, v));
17
48
  }
18
49
 
50
+ // --- info glyph + shared popover ------------------------------------------
51
+ // One popover element, reused across glyphs (only one open at a time). Global
52
+ // dismiss listeners are registered once at module load.
53
+ let popover = null;
54
+ function ensurePopover() {
55
+ if (!popover || !popover.isConnected) {
56
+ popover = document.createElement("div");
57
+ popover.className = "popover";
58
+ popover.hidden = true;
59
+ document.body.append(popover);
60
+ }
61
+ return popover;
62
+ }
63
+ function closePopover() {
64
+ if (popover && !popover.hidden) {
65
+ popover.hidden = true;
66
+ if (popover._owner) { popover._owner.setAttribute("aria-expanded", "false"); popover._owner = null; }
67
+ }
68
+ }
69
+ if (typeof document !== "undefined") {
70
+ document.addEventListener("click", (e) => {
71
+ if (popover && !popover.hidden && !popover.contains(e.target) && !e.target.closest?.(".info")) closePopover();
72
+ });
73
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") closePopover(); });
74
+ }
75
+
76
+ // Append a focusable ⓘ glyph to `container` that toggles the shared popover with
77
+ // `description` (Markdown). No-op when description is empty.
78
+ function attachInfo(container, description) {
79
+ if (typeof description !== "string" || !description.trim()) return;
80
+ const glyph = document.createElement("button");
81
+ glyph.type = "button";
82
+ glyph.className = "info";
83
+ glyph.textContent = "ⓘ";
84
+ glyph.setAttribute("aria-label", "More info");
85
+ glyph.setAttribute("aria-expanded", "false");
86
+ glyph.addEventListener("click", (e) => {
87
+ e.stopPropagation();
88
+ const pop = ensurePopover();
89
+ if (pop._owner === glyph) { closePopover(); return; } // toggle off
90
+ closePopover();
91
+ pop.innerHTML = renderMarkdown(description);
92
+ pop.hidden = false;
93
+ pop._owner = glyph;
94
+ glyph.setAttribute("aria-expanded", "true");
95
+ const r = glyph.getBoundingClientRect();
96
+ pop.style.top = `${r.bottom + 6}px`;
97
+ pop.style.left = `${Math.max(8, r.left - 8)}px`;
98
+ });
99
+ container.append(glyph);
100
+ }
101
+
19
102
  function el(tag, className, text) {
20
103
  const node = document.createElement(tag);
21
104
  if (className) node.className = className;
@@ -32,7 +115,9 @@ function makeSlider(def, params, onChange) {
32
115
  const numeric = def.control === "number";
33
116
  const wrap = el("div", "slider");
34
117
  const row = el("div", "row");
35
- row.append(el("label", "", def.label));
118
+ const label = el("label", "", def.label);
119
+ attachInfo(label, def.description);
120
+ row.append(label);
36
121
 
37
122
  // editable value box (+ optional unit suffix)
38
123
  const val = el("div", "val");
@@ -96,70 +181,102 @@ function advancedBlock() {
96
181
  }
97
182
 
98
183
  export function buildControls(root, parameters, params, onDirty) {
184
+ const controls = []; // { key, el } per control element
185
+ const sections = []; // { el, keys:Set } per rendered section
99
186
  for (const sec of parameters) {
187
+ if (!sectionRenders(sec)) continue;
100
188
  const section = el("div", "section");
101
- section.append(el("div", "sec-title", sec.title));
102
- if (sec.features) buildFeatureSection(section, sec, params, onDirty);
103
- else buildPresetSection(section, sec, params, onDirty);
189
+ const title = el("div", "sec-title", sec.title);
190
+ attachInfo(title, sec.description);
191
+ section.append(title);
192
+ const keys = new Set();
193
+ const register = (key, node) => { controls.push({ key, el: node }); keys.add(key); };
194
+ if (sec.features) buildFeatureSection(section, sec, params, onDirty, register);
195
+ else buildPresetSection(section, sec, params, onDirty, register);
104
196
  root.append(section);
197
+ sections.push({ el: section, keys });
105
198
  }
199
+ return { applyRelevance: (relevant) => applyRelevance(relevant, controls, sections) };
106
200
  }
107
201
 
108
- function buildPresetSection(section, sec, params, onDirty) {
109
- // preset picker, below the title, full width
110
- const preset = document.createElement("select");
111
- preset.className = "preset";
112
- const presetNames = Object.keys(sec.presets);
113
- for (const name of [...presetNames, "Custom"]) {
114
- const o = document.createElement("option");
115
- o.value = name;
116
- o.textContent = name;
117
- preset.append(o);
202
+ function buildPresetSection(section, sec, params, onDirty, register) {
203
+ // preset picker, below the title, full width (omitted when the section has no presets)
204
+ let preset = null;
205
+ const presetNames = sec.presets ? Object.keys(sec.presets) : [];
206
+ if (presetNames.length) {
207
+ preset = document.createElement("select");
208
+ preset.className = "preset";
209
+ for (const name of [...presetNames, "Custom"]) {
210
+ const o = document.createElement("option");
211
+ o.value = name; o.textContent = name; preset.append(o);
212
+ }
213
+ preset.value = presetNames[0];
214
+ section.append(preset);
118
215
  }
119
- preset.value = presetNames[0];
120
- section.append(preset);
121
216
 
122
- const { adv, toggle } = advancedBlock();
217
+ // standalone toggle checkboxes (e.g. preview switches), shown below the preset and
218
+ // outside the Advanced fold so they stay visible. Independent of the preset selector.
219
+ for (const t of visibleToggles(sec)) {
220
+ const row = el("label", "feat");
221
+ const box = document.createElement("input");
222
+ box.type = "checkbox";
223
+ box.checked = params[t.key] > 0;
224
+ const lbl = el("span", "", t.label);
225
+ attachInfo(lbl, t.description);
226
+ row.append(box, lbl);
227
+ box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
228
+ register(t.key, row);
229
+ section.append(row);
230
+ }
231
+
232
+ const advanced = visibleAdvanced(sec);
123
233
  const syncs = {};
124
- for (const def of sec.advanced) {
125
- const s = makeSlider(def, params, () => {
126
- preset.value = "Custom";
127
- onDirty?.();
128
- });
129
- adv.append(s.wrap);
130
- syncs[def.key] = s.sync;
234
+ if (advanced.length) {
235
+ const { adv, toggle } = advancedBlock();
236
+ for (const def of advanced) {
237
+ const s = makeSlider(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); });
238
+ adv.append(s.wrap);
239
+ syncs[def.key] = s.sync;
240
+ register(def.key, s.wrap);
241
+ }
242
+ section.append(toggle, adv);
131
243
  }
132
- section.append(toggle, adv);
133
244
 
134
245
  // applying a preset overwrites its keys and refreshes this section's sliders
135
- preset.addEventListener("change", () => {
136
- const bundle = sec.presets[preset.value];
137
- if (!bundle) return; // "Custom"
138
- Object.assign(params, bundle);
139
- for (const key in syncs) if (key in params) syncs[key]();
140
- onDirty?.();
141
- });
246
+ if (preset) {
247
+ preset.addEventListener("change", () => {
248
+ const bundle = sec.presets[preset.value];
249
+ if (!bundle) return; // "Custom"
250
+ Object.assign(params, bundle);
251
+ for (const key in syncs) if (key in params) syncs[key]();
252
+ onDirty?.();
253
+ });
254
+ }
142
255
  }
143
256
 
144
- function buildFeatureSection(section, sec, params, onDirty) {
257
+ function buildFeatureSection(section, sec, params, onDirty, register) {
145
258
  // Everything lives under Advanced: each feature is a checkbox followed by its
146
259
  // own controls, which appear directly below it when the box is checked.
147
260
  const { adv, toggle } = advancedBlock();
148
261
  section.append(toggle, adv);
149
262
 
150
- for (const feat of sec.features) {
263
+ for (const feat of visibleFeatures(sec)) {
151
264
  const checkRow = el("label", "feat");
152
265
  const box = document.createElement("input");
153
266
  box.type = "checkbox";
154
267
  box.checked = params[feat.key] > 0;
155
- checkRow.append(box, el("span", "", feat.label));
268
+ const featLabel = el("span", "", feat.label);
269
+ attachInfo(featLabel, feat.description);
270
+ checkRow.append(box, featLabel);
271
+ register(feat.key, checkRow);
156
272
 
157
273
  const group = el("div", "feat-group");
158
274
  const syncs = [];
159
- for (const def of feat.sliders) {
275
+ for (const def of feat.sliders.filter((d) => !d.hidden)) {
160
276
  const s = makeSlider(def, params, onDirty);
161
277
  group.append(s.wrap);
162
278
  syncs.push(s.sync);
279
+ register(def.key, s.wrap);
163
280
  }
164
281
  group.classList.toggle("hidden", !box.checked);
165
282
 
@@ -0,0 +1,40 @@
1
+ // src/framework/debug-overlay.js
2
+ // A dev-only overlay (shown under ?debug) with a caching on/off toggle and a
3
+ // readout of the last build's time + what the cache did. Self-contained: it
4
+ // creates its own DOM and knows nothing about geometry — mount.js wires it in.
5
+ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
6
+ const box = document.createElement("div");
7
+ box.id = "pf-debug";
8
+ Object.assign(box.style, {
9
+ position: "fixed", bottom: "12px", right: "12px", zIndex: "9999",
10
+ font: "12px ui-monospace, monospace", background: "rgba(0,0,0,0.7)",
11
+ color: "#e6e6e6", padding: "8px 10px", borderRadius: "6px",
12
+ lineHeight: "1.5", whiteSpace: "pre",
13
+ });
14
+
15
+ const label = document.createElement("label");
16
+ label.style.cssText = "display:block;cursor:pointer;margin-bottom:4px";
17
+ const cb = document.createElement("input");
18
+ cb.type = "checkbox";
19
+ cb.checked = initialCachingOn;
20
+ cb.style.marginRight = "6px";
21
+ label.append(cb, document.createTextNode("Caching"));
22
+
23
+ const readout = document.createElement("div");
24
+ readout.textContent = "build: —";
25
+
26
+ box.append(label, readout);
27
+ document.body.appendChild(box);
28
+
29
+ cb.addEventListener("change", () => onToggle?.(cb.checked));
30
+
31
+ return {
32
+ update({ ms, hits = 0, misses = 0, skipped = 0, rebuilt = 0 } = {}) {
33
+ const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
34
+ readout.textContent =
35
+ `build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
36
+ `L2 ops: ${l2}\n` +
37
+ `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,17 @@
1
+ // Map partforge's declarative edge selector onto a replicad EdgeFinder filter.
2
+ // undefined → undefined (all edges)
3
+ // (e) => e... → passed through (raw replicad finder escape hatch)
4
+ // { dir, inPlane, at, near } → a filter applying the given criteria (AND)
5
+ const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
6
+
7
+ export function toEdgeFinder(selector) {
8
+ if (selector == null) return undefined;
9
+ if (typeof selector === "function") return selector;
10
+ return (e) => {
11
+ let f = e;
12
+ if (selector.dir != null) f = f.inDirection(Array.isArray(selector.dir) ? selector.dir : AXIS[selector.dir]);
13
+ if (selector.inPlane != null) f = f.inPlane(selector.inPlane, selector.at);
14
+ if (selector.near != null) f = f.containsPoint(selector.near);
15
+ return f;
16
+ };
17
+ }
@@ -0,0 +1,10 @@
1
+ // Thrown by a backend that can't perform a requested op (e.g. Manifold has no
2
+ // fillet/chamfer). The framework catches `.code === "NEEDS_OCCT"` and reroutes
3
+ // the part to the OCCT backend.
4
+ export class KernelCapabilityError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "KernelCapabilityError";
8
+ this.code = "NEEDS_OCCT";
9
+ }
10
+ }
@@ -0,0 +1,19 @@
1
+ // Map partforge's declarative face selector onto a replicad FaceFinder filter.
2
+ // undefined / null → undefined (all faces)
3
+ // (f) => f... → passed through (raw replicad finder escape hatch)
4
+ // { dir, inPlane, at, near } → a filter applying the given criteria (AND)
5
+ // dir picks faces whose normal runs along that axis (i.e. parallel to the
6
+ // perpendicular plane): X→YZ, Y→XZ, Z→XY.
7
+ const PERP_PLANE = { X: "YZ", Y: "XZ", Z: "XY" };
8
+
9
+ export function toFaceFinder(selector) {
10
+ if (selector == null) return undefined;
11
+ if (typeof selector === "function") return selector;
12
+ return (f) => {
13
+ let r = f;
14
+ if (selector.dir != null) r = r.parallelTo(PERP_PLANE[selector.dir]);
15
+ if (selector.inPlane != null) r = r.inPlane(selector.inPlane, selector.at);
16
+ if (selector.near != null) r = r.containsPoint(selector.near);
17
+ return r;
18
+ };
19
+ }
@@ -3,12 +3,17 @@
3
3
 
4
4
  /**
5
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)
6
7
  * @property {(tool: Solid) => Solid} cut
7
8
  * @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
8
9
  * @property {(other: Solid) => Solid} intersect boolean intersection (Manifold)
10
+ * @property {() => Solid} clone independent copy (replicad consumes solids on transform)
11
+ * @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
9
13
  * @property {(v: number[]) => Solid} translate
10
14
  * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
11
15
  * @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
16
+ * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
12
17
  * @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
13
18
  * @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices:Uint32Array, triangles:number}} toMesh
14
19
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
@@ -16,8 +21,11 @@
16
21
  *
17
22
  * @typedef {Object} GeometryKernel
18
23
  * @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
24
+ * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
25
+ * @property {(r:number) => Solid} sphere sphere centred at the origin
19
26
  * @property {(min:number[], max:number[]) => Solid} box
20
- * @property {(points2D:number[][], h:number) => Solid} prism extrude polygon from z=0
27
+ * @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
28
+ * @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
21
29
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
22
30
  * @property {(solids:Solid[]) => Solid} union
23
31
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only
@@ -1,4 +1,7 @@
1
1
  import { helixTube } from "./helix-tube.js";
2
+ import { KernelCapabilityError } from "./errors.js";
3
+ import { h } from "./solid-hash.js";
4
+ import { createSolidCache } from "./solid-cache.js";
2
5
 
3
6
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
4
7
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -21,6 +24,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
21
24
  const T = (obj) => { tracked.push(obj); return obj; };
22
25
  const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
23
26
 
27
+ const cache = createSolidCache();
28
+ // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
29
+ // tracks the result, and returns the triple the cache needs to pin/dispose it.
30
+ const cached = (hash, computeM) => cache.lookup(hash, () => {
31
+ const m = computeM(); // already T()-tracked by the op
32
+ return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
33
+ });
34
+
24
35
  // Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
25
36
  // transient mesh handle.
26
37
  function meshOut(m, asStl) {
@@ -47,41 +58,88 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
47
58
  return { positions, indices };
48
59
  }
49
60
 
50
- const wrap = (m) => ({
61
+ const wrap = (m, hash) => ({
51
62
  _m: m,
52
- cut: (t) => wrap(T(m.subtract(t._m))),
53
- cutAll: (tools) => wrap(T(m.subtract(unionRaw(tools.map((t) => t._m))))),
54
- intersect: (t) => wrap(T(m.intersect(t._m))),
63
+ _hash: hash,
64
+ cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
65
+ cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
66
+ () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
67
+ intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
68
+ clone: () => wrap(m, hash),
69
+ boundingBox: () => {
70
+ const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
71
+ const min = [...b.min], max = [...b.max];
72
+ return {
73
+ min, max,
74
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
75
+ size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
76
+ };
77
+ },
55
78
  volume: () => m.volume(),
56
- translate: (v) => wrap(T(m.translate(v))),
79
+ genus: () => m.genus(),
80
+ isEmpty: () => m.isEmpty(),
81
+ translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
57
82
  rotate: (deg, center, axis) => {
58
83
  const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
59
84
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
60
85
  const b = T(a.rotate(euler));
61
- return wrap(T(b.translate(center)));
86
+ return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
87
+ },
88
+ mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
89
+ scale: (factor, center = [0, 0, 0]) => {
90
+ if (!(factor > 0)) throw new Error("scale: factor must be > 0");
91
+ const a = T(m.translate([-center[0], -center[1], -center[2]]));
92
+ const b = T(a.scale([factor, factor, factor]));
93
+ return wrap(T(b.translate(center)), h("scale", hash, factor, center));
62
94
  },
63
- mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane]))),
64
95
  toMesh: () => meshOut(m, false),
65
96
  toSTL: () => Promise.resolve(meshOut(m, true)),
66
97
  toIndexedMesh: () => indexedMeshOut(m),
98
+ fillet: () => { throw new KernelCapabilityError("fillet requires the OCCT backend"); },
99
+ chamfer: () => { throw new KernelCapabilityError("chamfer requires the OCCT backend"); },
100
+ shell: () => { throw new KernelCapabilityError("shell requires the OCCT backend"); },
67
101
  });
68
102
 
69
103
  return {
70
- cylinder: (rb, rt, h, { center = false } = {}) => wrap(T(Manifold.cylinder(h, rb, rt, segs, center))),
104
+ cylinder: (rb, rt, h2, { center = false } = {}) =>
105
+ wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
106
+ // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
107
+ // node — its internal cylinders/cut are never retained. The template for
108
+ // future compounds: build internals with T(), return the final tracked solid.
109
+ boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore, segs), () => {
110
+ const body = T(Manifold.cylinder(height, od / 2, od / 2, segs, false));
111
+ const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2, segs, false));
112
+ const tool = T(tool0.translate([0, 0, -2])); // raw ops: track each result
113
+ return T(body.subtract(tool));
114
+ }),
115
+ sphere: (r) => wrap(T(Manifold.sphere(r, segs)), h("sphere", r, segs)),
71
116
  box: (min, max) => {
72
117
  const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
73
- return wrap(T(cube.translate(min)));
74
- },
75
- prism: (pts, h) => {
76
- const cs = T(CrossSection.ofPolygons([pts]));
77
- return wrap(T(cs.extrude(h)));
118
+ return wrap(T(cube.translate(min)), h("box", min, max));
78
119
  },
79
- helixSweptTube: (o) => wrap(T(helixTube(wasm, { ...o, ...tube }))),
80
- union: (solids) => wrap(unionRaw(solids.map((s) => s._m))), // unionRaw already tracks its result
120
+ prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
121
+ cached(h("prism", pts, height, twist, scaleTop, segs), () => {
122
+ if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
123
+ const cs = T(CrossSection.ofPolygons([pts]));
124
+ if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
125
+ const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
126
+ return T(cs.extrude(height, nDiv, twist, scaleTop));
127
+ }),
128
+ helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
129
+ revolve: (pts, { degrees = 360 } = {}) =>
130
+ cached(h("revolve", pts, degrees, segs), () => {
131
+ for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
132
+ return T(Manifold.revolve([pts], segs, degrees));
133
+ }),
134
+ union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
81
135
  toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
82
- // Free every WASM object created since the last cleanup. Call after each job
83
- // once its meshes/buffers have been copied out (meshOut already did).
84
- cleanup: () => { for (const o of tracked) o.delete?.(); tracked.length = 0; },
136
+ beginSubPart: (name) => cache.begin(name),
137
+ endSubPart: () => cache.end(),
138
+ cacheStats: () => cache.stats(),
139
+ resetCacheStats: () => cache.resetStats(),
140
+ // Free every WASM object created since the last cleanup EXCEPT solids the cache
141
+ // still pins (they must survive for the next build to resume from them).
142
+ cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
85
143
  };
86
144
  }
87
145