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
|
@@ -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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
onDirty?.();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|