partforge 0.1.0 → 0.3.3
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 +182 -2
- package/package.json +12 -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/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 +5 -0
- package/src/framework/geometry/manifold-backend.js +21 -0
- package/src/framework/geometry/occt-backend.js +103 -2
- package/src/framework/geometry/polygon.js +103 -0
- package/src/framework/geometry/probe.js +50 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +13 -4
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +57 -13
- package/src/framework/param-deps.js +50 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +28 -2
- 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
package/src/framework/mount.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import "./app.css"; // shared chrome styles — every part-app gets them via mount
|
|
2
2
|
import { zipSync } from "fflate";
|
|
3
3
|
import { createViewer } from "./viewer.js";
|
|
4
|
+
import { loadRotating, saveRotating, loadCamera, saveCamera, loadView, saveView } from "./view-state.js";
|
|
4
5
|
import { buildControls } from "./controls.js";
|
|
6
|
+
import { relevantParamKeys } from "./param-deps.js";
|
|
5
7
|
import { createGeometryService } from "./geometry-service.js";
|
|
6
8
|
import { viewSubParts } from "./jobs.js";
|
|
9
|
+
import { detectBackend } from "./geometry/probe.js";
|
|
7
10
|
|
|
8
11
|
// Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
|
|
9
12
|
// panel + the two geometry workers + the auto-regenerating view/cache loop +
|
|
@@ -16,8 +19,10 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
16
19
|
const names = Object.keys(part.parts);
|
|
17
20
|
const viewer = createViewer(container, part);
|
|
18
21
|
|
|
19
|
-
// ?backend=occt
|
|
20
|
-
|
|
22
|
+
// ?backend=occt|manifold forces the backend; otherwise it's detected per part.
|
|
23
|
+
let forcedBackend = new URLSearchParams(location.search).get("backend");
|
|
24
|
+
if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
|
|
25
|
+
const backendFor = () => forcedBackend ?? detectBackend(part, params);
|
|
21
26
|
|
|
22
27
|
const statusEl = document.getElementById("status");
|
|
23
28
|
const dlBtn = document.getElementById("download");
|
|
@@ -36,12 +41,23 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
36
41
|
const hideBusy = () => busyEl.classList.remove("show");
|
|
37
42
|
|
|
38
43
|
// --- per-sub-part mesh cache + auto-regenerating view composition ----------
|
|
39
|
-
// The
|
|
40
|
-
//
|
|
41
|
-
//
|
|
44
|
+
// The view-tab buttons are generated from `part.views` (the single source of truth):
|
|
45
|
+
// each entry becomes a <button data-part=view>label</button> inside #part, with the
|
|
46
|
+
// first view marked active by default. (A saved view, below, overrides the default.)
|
|
47
|
+
if (partSeg && part.views) {
|
|
48
|
+
partSeg.innerHTML = Object.entries(part.views)
|
|
49
|
+
.map(([key, v], i) => `<button data-part="${key}"${i === 0 ? ' class="on"' : ""}>${v?.label ?? key}</button>`)
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
52
|
+
// The initial view is whichever tab is marked active (else the first tab).
|
|
42
53
|
const params = { ...part.defaults };
|
|
43
|
-
|
|
54
|
+
const defaultView = partSeg.querySelector("button.on")?.dataset.part ?? partSeg.querySelector("button")?.dataset.part;
|
|
55
|
+
const savedView = loadView();
|
|
56
|
+
const savedBtn = savedView ? [...partSeg.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === savedView) : null;
|
|
57
|
+
let view = savedBtn ? savedView : defaultView;
|
|
58
|
+
if (savedBtn) for (const b of partSeg.children) b.classList.toggle("on", b === savedBtn);
|
|
44
59
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
60
|
+
let cameraRestored = false; // saved camera applied once, on the first frame after load
|
|
45
61
|
let generating = false;
|
|
46
62
|
let paramsVersion = 0; // bumped on every settings edit
|
|
47
63
|
let genVersion = -1; // the params version the in-flight generate is building
|
|
@@ -60,7 +76,14 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
60
76
|
function showView(needed) {
|
|
61
77
|
const frame = view !== framedView;
|
|
62
78
|
viewer.showAssembly(needed, { frame });
|
|
63
|
-
if (frame)
|
|
79
|
+
if (frame) {
|
|
80
|
+
framedView = view;
|
|
81
|
+
if (!cameraRestored) {
|
|
82
|
+
const cam = loadCamera();
|
|
83
|
+
if (cam) viewer.setCameraState(cam);
|
|
84
|
+
cameraRestored = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
function refreshView() {
|
|
@@ -135,6 +158,11 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
135
158
|
triggerDownload(data.data, data.filename, data.mime);
|
|
136
159
|
setStatus(`${data.filename} downloaded`);
|
|
137
160
|
break;
|
|
161
|
+
case "needs-occt":
|
|
162
|
+
forcedBackend = "occt"; // probe missed; this part needs OCCT — stick to it
|
|
163
|
+
generating = false;
|
|
164
|
+
maybeGenerate();
|
|
165
|
+
break;
|
|
138
166
|
case "error":
|
|
139
167
|
generating = false;
|
|
140
168
|
hideBusy();
|
|
@@ -144,13 +172,16 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
144
172
|
}
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage
|
|
175
|
+
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
|
|
148
176
|
|
|
149
|
-
buildControls(controls, part.parameters, params, onParamChange);
|
|
177
|
+
const panel = buildControls(controls, part.parameters, params, onParamChange);
|
|
178
|
+
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view, params));
|
|
179
|
+
updateRelevance(); // initial view
|
|
150
180
|
|
|
151
181
|
function onParamChange() {
|
|
152
182
|
paramsVersion++; // every edit invalidates the caches (by version)
|
|
153
183
|
refreshView(); // keep showing the now-stale mesh (no flicker); disable export
|
|
184
|
+
updateRelevance();
|
|
154
185
|
scheduleGenerate();
|
|
155
186
|
}
|
|
156
187
|
|
|
@@ -168,21 +199,23 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
168
199
|
generating = true;
|
|
169
200
|
genVersion = paramsVersion;
|
|
170
201
|
showBusy("generating");
|
|
171
|
-
service.generate({ type: "generate", subparts: missing, view, params });
|
|
202
|
+
service.generate({ type: "generate", subparts: missing, view, params }, backendFor());
|
|
172
203
|
}
|
|
173
204
|
|
|
174
205
|
partSeg.addEventListener("click", (e) => {
|
|
175
206
|
const btn = e.target.closest("button[data-part]");
|
|
176
207
|
if (!btn) return;
|
|
177
208
|
view = btn.dataset.part;
|
|
209
|
+
saveView(view);
|
|
178
210
|
for (const b of partSeg.children) b.classList.toggle("on", b === btn);
|
|
179
211
|
refreshView(); // instant if the view's parts are cached + current
|
|
212
|
+
updateRelevance();
|
|
180
213
|
maybeGenerate(); // else auto-build the missing pieces
|
|
181
214
|
});
|
|
182
215
|
|
|
183
216
|
dlBtn.addEventListener("click", () => {
|
|
184
217
|
showBusy("exporting STL");
|
|
185
|
-
service.exportStl({ type: "export-stl", view, params });
|
|
218
|
+
service.exportStl({ type: "export-stl", view, params }, backendFor());
|
|
186
219
|
});
|
|
187
220
|
|
|
188
221
|
dlStepBtn.addEventListener("click", () => {
|
|
@@ -192,7 +225,7 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
192
225
|
|
|
193
226
|
dl3mfBtn?.addEventListener("click", () => {
|
|
194
227
|
showBusy("exporting 3MF");
|
|
195
|
-
service.export3mf({ type: "export-3mf", view, params });
|
|
228
|
+
service.export3mf({ type: "export-3mf", view, params }, backendFor());
|
|
196
229
|
});
|
|
197
230
|
|
|
198
231
|
// --- viewer controls (optional host-page buttons: #pause / #reframe / #theme) --
|
|
@@ -214,14 +247,25 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
214
247
|
themeBtn?.addEventListener("click", () => applyTheme(theme === "light" ? "dark" : "light"));
|
|
215
248
|
|
|
216
249
|
// Pause/resume the idle auto-rotation.
|
|
217
|
-
let rotating =
|
|
250
|
+
let rotating = loadRotating();
|
|
251
|
+
viewer.setAutoRotate(rotating);
|
|
252
|
+
if (pauseBtn) {
|
|
253
|
+
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
254
|
+
pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
|
|
255
|
+
}
|
|
218
256
|
pauseBtn?.addEventListener("click", () => {
|
|
219
257
|
rotating = !rotating;
|
|
220
258
|
viewer.setAutoRotate(rotating);
|
|
221
259
|
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
222
260
|
pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
|
|
261
|
+
saveRotating(rotating);
|
|
223
262
|
});
|
|
224
263
|
|
|
225
264
|
// Re-fit the camera to the current view.
|
|
226
265
|
reframeBtn?.addEventListener("click", () => viewer.frame());
|
|
266
|
+
|
|
267
|
+
// Persist the camera when the user finishes an orbit/zoom, and right before a
|
|
268
|
+
// reload (captures the latest pose, including auto-rotation drift).
|
|
269
|
+
viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
|
|
270
|
+
window.addEventListener("pagehide", () => saveCamera(viewer.getCameraState()));
|
|
227
271
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Which raw parameters affect the parts on screen in the active view. A removable
|
|
2
|
+
// relevance layer: the panel uses this to dim controls / hide sections that don't
|
|
3
|
+
// affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
|
|
4
|
+
// probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
|
|
5
|
+
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
|
+
import { viewSubParts } from "./jobs.js";
|
|
7
|
+
|
|
8
|
+
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
9
|
+
|
|
10
|
+
// A read-recording Proxy over a shallow clone of `obj`: records each top-level
|
|
11
|
+
// property key read into `seen`, returns the real value so the build's conditionals
|
|
12
|
+
// evaluate correctly, and never mutates the original `obj` (writes hit the clone).
|
|
13
|
+
function recorder(obj, seen) {
|
|
14
|
+
return new Proxy({ ...obj }, {
|
|
15
|
+
get(target, key) {
|
|
16
|
+
if (typeof key === "string") seen.add(key);
|
|
17
|
+
return Reflect.get(target, key);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function relevantParamKeys(part, view, params) {
|
|
23
|
+
try {
|
|
24
|
+
const relevant = new Set();
|
|
25
|
+
|
|
26
|
+
// derive: record which raw params feed it; produce real derived values once.
|
|
27
|
+
const deriveInputs = new Set();
|
|
28
|
+
const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
|
|
29
|
+
|
|
30
|
+
// gate params: a param read by a view sub-part's enabled() changes what's on screen.
|
|
31
|
+
for (const name of Object.keys(part.parts)) {
|
|
32
|
+
const sp = part.parts[name];
|
|
33
|
+
if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// direct build reads across the on-screen (enabled) sub-parts.
|
|
37
|
+
const { kernel } = createProbeKernel();
|
|
38
|
+
let anyDerivedRead = false;
|
|
39
|
+
for (const name of viewSubParts(part, view, params)) {
|
|
40
|
+
const dSeen = new Set();
|
|
41
|
+
part.parts[name].build(kernel, recorder(params, relevant), recorder(derived, dSeen));
|
|
42
|
+
if (dSeen.size > 0) anyDerivedRead = true;
|
|
43
|
+
}
|
|
44
|
+
if (anyDerivedRead) for (const k of deriveInputs) relevant.add(k);
|
|
45
|
+
|
|
46
|
+
return relevant;
|
|
47
|
+
} catch {
|
|
48
|
+
return RELEVANT_ALL; // couldn't analyze — treat everything as relevant
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Persist a little viewer UI state across browser reloads (notably Vite dev
|
|
2
|
+
// auto-refresh) in localStorage. All keys are global. Reads/writes are guarded:
|
|
3
|
+
// if localStorage is unavailable (private mode, disabled) or a value is corrupt,
|
|
4
|
+
// reads return the documented default and writes are no-ops — persistence never
|
|
5
|
+
// throws. Theme is persisted separately (in mount.js) and is not handled here.
|
|
6
|
+
|
|
7
|
+
const KEY = {
|
|
8
|
+
rotating: "partforge:rotating",
|
|
9
|
+
camera: "partforge:camera",
|
|
10
|
+
view: "partforge:view",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function read(key) {
|
|
14
|
+
try { return localStorage.getItem(key); } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
function write(key, value) {
|
|
17
|
+
try { localStorage.setItem(key, value); } catch { /* storage unavailable — no-op */ }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isVec3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n));
|
|
21
|
+
|
|
22
|
+
export function loadRotating() {
|
|
23
|
+
const raw = read(KEY.rotating);
|
|
24
|
+
if (raw === "false") return false;
|
|
25
|
+
if (raw === "true") return true;
|
|
26
|
+
return true; // default: auto-rotate on (matches the viewer's default)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveRotating(on) {
|
|
30
|
+
write(KEY.rotating, on ? "true" : "false");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadCamera() {
|
|
34
|
+
const raw = read(KEY.camera);
|
|
35
|
+
if (!raw) return null;
|
|
36
|
+
let parsed;
|
|
37
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
38
|
+
if (parsed && isVec3(parsed.pos) && isVec3(parsed.target)) {
|
|
39
|
+
return { pos: parsed.pos, target: parsed.target };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveCamera(state) {
|
|
45
|
+
if (!state || !isVec3(state.pos) || !isVec3(state.target)) return;
|
|
46
|
+
write(KEY.camera, JSON.stringify({ pos: state.pos, target: state.target }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadView() {
|
|
50
|
+
return read(KEY.view); // raw string or null; caller validates against available tabs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function saveView(name) {
|
|
54
|
+
if (typeof name === "string" && name) write(KEY.view, name);
|
|
55
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -61,8 +61,20 @@ export function createViewer(container, part) {
|
|
|
61
61
|
const partsGroup = new THREE.Group();
|
|
62
62
|
pivot.add(partsGroup);
|
|
63
63
|
|
|
64
|
+
// Per-sub-part material: parts share the default material unless they declare
|
|
65
|
+
// `display: { color?, opacity? }` (e.g. a reference/ghost part shown in a
|
|
66
|
+
// distinct colour and/or semi-transparent so it reads as "not a printed part").
|
|
67
|
+
function materialFor(name) {
|
|
68
|
+
const disp = part.parts[name].display;
|
|
69
|
+
if (!disp || (disp.color == null && disp.opacity == null)) return material;
|
|
70
|
+
const m = material.clone();
|
|
71
|
+
if (disp.color != null) m.color = new THREE.Color(disp.color);
|
|
72
|
+
if (disp.opacity != null && disp.opacity < 1) { m.transparent = true; m.opacity = disp.opacity; m.depthWrite = false; }
|
|
73
|
+
return m;
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
const subMesh = Object.fromEntries(
|
|
65
|
-
names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(),
|
|
77
|
+
names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(), materialFor(n))])
|
|
66
78
|
);
|
|
67
79
|
for (const m of Object.values(subMesh)) {
|
|
68
80
|
m.visible = false;
|
|
@@ -193,6 +205,20 @@ export function createViewer(container, part) {
|
|
|
193
205
|
renderer.render(scene, camera);
|
|
194
206
|
});
|
|
195
207
|
|
|
208
|
+
// --- camera state (read/write for persistence; mount.js owns storage) -------
|
|
209
|
+
function getCameraState() {
|
|
210
|
+
return {
|
|
211
|
+
pos: [camera.position.x, camera.position.y, camera.position.z],
|
|
212
|
+
target: [controls.target.x, controls.target.y, controls.target.z],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function setCameraState({ pos, target }) {
|
|
216
|
+
camera.position.set(pos[0], pos[1], pos[2]);
|
|
217
|
+
controls.target.set(target[0], target[1], target[2]);
|
|
218
|
+
controls.update();
|
|
219
|
+
}
|
|
220
|
+
function onCameraEnd(cb) { controls.addEventListener("end", cb); }
|
|
221
|
+
|
|
196
222
|
// --- dispose --------------------------------------------------------------
|
|
197
223
|
function dispose() {
|
|
198
224
|
renderer.setAnimationLoop(null);
|
|
@@ -200,5 +226,5 @@ export function createViewer(container, part) {
|
|
|
200
226
|
container.removeChild(renderer.domElement);
|
|
201
227
|
}
|
|
202
228
|
|
|
203
|
-
return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, _subCache: subCache };
|
|
229
|
+
return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, _subCache: subCache };
|
|
204
230
|
}
|
package/src/parts/demo.js
CHANGED
|
@@ -1,38 +1,56 @@
|
|
|
1
|
-
// Example PartDefinition — a parametric spacer.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Example PartDefinition — a parametric spacer. Doubles as the worked example for
|
|
2
|
+
// docs/AUTHORING-PARTS.md "Designing the control panel": a description on every
|
|
3
|
+
// control, a hidden internal constant, and a derive() that turns raw inputs into the
|
|
4
|
+
// dependent dimensions the build consumes. The framework (viewer, controls, workers,
|
|
5
|
+
// STL/STEP export) is reused unchanged.
|
|
4
6
|
export default {
|
|
5
7
|
meta: { title: "Spacer", units: "mm", background: 0x15181d },
|
|
6
8
|
parameters: [
|
|
7
9
|
{
|
|
8
10
|
id: "body",
|
|
9
11
|
title: "Body",
|
|
12
|
+
description: "The spacer barrel and its through-bore. Pick a preset for a common screw size, or open **Advanced** to set exact dimensions.",
|
|
10
13
|
presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } },
|
|
11
14
|
advanced: [
|
|
12
|
-
{ key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5
|
|
13
|
-
|
|
14
|
-
{ key: "
|
|
15
|
+
{ key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
|
|
16
|
+
description: "Barrel outer diameter. Keep it comfortably larger than the bore so a wall remains. See the [authoring guide](https://github.com/scottsykora/partforge/blob/main/docs/AUTHORING-PARTS.md)." },
|
|
17
|
+
{ key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number",
|
|
18
|
+
description: "Nominal screw clearance hole. A fixed print clearance is added automatically (see `derive`), so enter the *nominal* size." },
|
|
19
|
+
{ key: "h", label: "Height", unit: "mm", min: 2, max: 60, step: 1,
|
|
20
|
+
description: "Spacer length along the axis." },
|
|
21
|
+
{ key: "flange_h", label: "Flange thickness", unit: "mm", min: 1, max: 5, step: 0.5, hidden: true,
|
|
22
|
+
description: "Internal: flange plate thickness, fixed by the design. Hidden from the end user, but still drives the geometry." },
|
|
15
23
|
],
|
|
16
24
|
},
|
|
17
25
|
{
|
|
18
26
|
id: "flange",
|
|
19
27
|
title: "Flange",
|
|
28
|
+
description: "Optional base flange — a wider seating plate at one end.",
|
|
20
29
|
features: [
|
|
21
30
|
{ label: "Base flange", key: "flange_d", on: 16,
|
|
22
|
-
|
|
31
|
+
description: "Adds a `flange_h`-thick plate of this diameter at the base.",
|
|
32
|
+
sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1,
|
|
33
|
+
description: "Outer diameter of the base flange." }] },
|
|
23
34
|
],
|
|
24
35
|
},
|
|
25
36
|
],
|
|
26
|
-
defaults: { od: 8, bore: 3.4, h: 10, flange_d: 0 },
|
|
37
|
+
defaults: { od: 8, bore: 3.4, h: 10, flange_d: 0, flange_h: 2 },
|
|
38
|
+
// derive(): turn raw inputs into the dependent dimensions the build needs, so one
|
|
39
|
+
// input drives the geometry consistently — here the bore gains a fixed print
|
|
40
|
+
// clearance and the cut tool is sized to pierce the whole part.
|
|
41
|
+
derive: (p) => ({
|
|
42
|
+
boreR: (p.bore + 0.2) / 2, // nominal bore + 0.2 mm print clearance, as a radius
|
|
43
|
+
cutH: p.h + 4, // through-cut tool, taller than the part
|
|
44
|
+
}),
|
|
27
45
|
parts: {
|
|
28
46
|
spacer: {
|
|
29
47
|
label: "Spacer",
|
|
30
48
|
views: ["spacer"],
|
|
31
49
|
export: { name: "spacer" },
|
|
32
|
-
build: (k, p) => {
|
|
50
|
+
build: (k, p, d) => {
|
|
33
51
|
let s = k.cylinder(p.od / 2, p.od / 2, p.h);
|
|
34
|
-
if (p.flange_d > 0) s = k.union([s, k.cylinder(p.flange_d / 2, p.flange_d / 2,
|
|
35
|
-
return s.cut(k.cylinder(
|
|
52
|
+
if (p.flange_d > 0) s = k.union([s, k.cylinder(p.flange_d / 2, p.flange_d / 2, p.flange_h)]);
|
|
53
|
+
return s.cut(k.cylinder(d.boreR, d.boreR, d.cutH).translate([0, 0, -2]));
|
|
36
54
|
},
|
|
37
55
|
},
|
|
38
56
|
},
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Example part exercising native CAD ops — it auto-routes to the OCCT backend
|
|
2
|
+
// because it uses fillet (and chamfer when enabled). Vertical edges are rounded
|
|
3
|
+
// and an optional bore drilled; the base chamfer is off by default (turn it up to
|
|
4
|
+
// try chamfering, kept off in defaults so the gating build uses only the fillet).
|
|
5
|
+
export default {
|
|
6
|
+
meta: { title: "Filleted Box", units: "mm", background: 0x15181d },
|
|
7
|
+
parameters: [
|
|
8
|
+
{
|
|
9
|
+
id: "box", title: "Box",
|
|
10
|
+
advanced: [
|
|
11
|
+
{ key: "w", label: "Width", unit: "mm", min: 10, max: 80, step: 1 },
|
|
12
|
+
{ key: "d", label: "Depth", unit: "mm", min: 10, max: 80, step: 1 },
|
|
13
|
+
{ key: "h", label: "Height", unit: "mm", min: 5, max: 40, step: 1 },
|
|
14
|
+
{ key: "fillet", label: "Edge fillet", unit: "mm", min: 0, max: 10, step: 0.5 },
|
|
15
|
+
{ key: "top", label: "Top fillet", unit: "mm", min: 0, max: 5, step: 0.5 },
|
|
16
|
+
{ key: "chamfer", label: "Base chamfer", unit: "mm", min: 0, max: 10, step: 0.5 },
|
|
17
|
+
{ key: "bore", label: "Bore", unit: "mm", min: 0, max: 24, step: 0.5 },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
defaults: { w: 40, d: 30, h: 16, fillet: 3, top: 2, chamfer: 0, bore: 8 },
|
|
22
|
+
parts: {
|
|
23
|
+
body: {
|
|
24
|
+
label: "Body",
|
|
25
|
+
views: ["box"],
|
|
26
|
+
build: (k, p) => {
|
|
27
|
+
let s = k.box([0, 0, 0], [p.w, p.d, p.h]);
|
|
28
|
+
const half = Math.min(p.w, p.d) / 2;
|
|
29
|
+
// Round the vertical edges, then the top rim — each clamped to the box so a
|
|
30
|
+
// radius can't exceed the available material.
|
|
31
|
+
const vFillet = Math.min(p.fillet, half - 0.5, p.h - 0.5);
|
|
32
|
+
if (vFillet > 0) s = s.fillet(vFillet, { dir: "Z" }); // 4 vertical edges
|
|
33
|
+
const topFillet = Math.min(p.top, half - vFillet - 0.5, p.h / 2 - 0.5);
|
|
34
|
+
if (topFillet > 0) s = s.fillet(topFillet, { inPlane: "XY", at: p.h }); // top rim — curves all the way around
|
|
35
|
+
// Base chamfer AFTER the fillets, so it cuts a clean curve across the rounded
|
|
36
|
+
// corners. No manual limit needed: the backend auto-clamps a chamfer to half
|
|
37
|
+
// the shortest edge it touches (here the fillets' bottom arcs), so it stops at
|
|
38
|
+
// its valid maximum instead of mangling the bottom face.
|
|
39
|
+
if (p.chamfer > 0) s = s.chamfer(p.chamfer, { inPlane: "XY", at: 0 }); // base edges
|
|
40
|
+
if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).translate([p.w / 2, p.d / 2, -1]));
|
|
41
|
+
return s;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
views: { box: { label: "Box" } },
|
|
46
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { viewSubParts } from "../framework/jobs.js";
|
|
2
|
+
|
|
3
|
+
// Build every sub-part of a view in its display (assembly) pose with the given
|
|
4
|
+
// Manifold kernel, returning live solids + copied-out meshes. Mirrors the
|
|
5
|
+
// `generate` path in jobs.js, but keeps solids LIVE (does NOT call
|
|
6
|
+
// kernel.cleanup()) so callers can read exact solid facts (volume/genus/empty)
|
|
7
|
+
// before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
|
|
8
|
+
export function buildView(kernel, part, view, params = {}) {
|
|
9
|
+
const p = { ...part.defaults, ...params };
|
|
10
|
+
const d = part.derive ? part.derive(p) : {};
|
|
11
|
+
return viewSubParts(part, view, p).map((name) => {
|
|
12
|
+
const sp = part.parts[name];
|
|
13
|
+
let solid = sp.build(kernel, p, d);
|
|
14
|
+
if (sp.place) solid = sp.place(solid, { view, purpose: "display", p, d });
|
|
15
|
+
return { name, solid, mesh: solid.toMesh() };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildView } from "./build.js";
|
|
2
|
+
import { assemblyOverlaps } from "../framework/assembly.js";
|
|
3
|
+
import { bounds, meshArea } from "./mesh.js";
|
|
4
|
+
|
|
5
|
+
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
6
|
+
const unionBounds = (list) => list.reduce(
|
|
7
|
+
(acc, b) => ({ min: acc.min.map((v, i) => Math.min(v, b.min[i])), max: acc.max.map((v, i) => Math.max(v, b.max[i])) }),
|
|
8
|
+
{ min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
// Headless geometric report for one view of a part (Manifold-only). Reads exact
|
|
12
|
+
// solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
|
|
13
|
+
// the assembly overlap check. All solid facts are read BEFORE assemblyOverlaps,
|
|
14
|
+
// which frees the shared kernel's objects at its end.
|
|
15
|
+
// → { part, view, subparts[], aggregate, overlaps[], ok }
|
|
16
|
+
export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}) {
|
|
17
|
+
const built = buildView(kernel, part, view, params);
|
|
18
|
+
const subBounds = [];
|
|
19
|
+
const subparts = built.map(({ name, solid, mesh }) => {
|
|
20
|
+
const b = bounds(mesh.positions);
|
|
21
|
+
subBounds.push(b);
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
bbox: size(b),
|
|
25
|
+
volume: solid.volume(),
|
|
26
|
+
surfaceArea: meshArea(mesh.positions, mesh.indices),
|
|
27
|
+
triangleCount: mesh.triangles,
|
|
28
|
+
watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
|
|
29
|
+
holes: typeof solid.genus === "function" ? solid.genus() : null,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Rebuilds with the same kernel and cleans up at its end — every solid fact
|
|
34
|
+
// above is already read, so this is safe.
|
|
35
|
+
const canIntersect = built.length > 0 && typeof built[0].solid.intersect === "function";
|
|
36
|
+
const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params) : [];
|
|
37
|
+
kernel.cleanup?.();
|
|
38
|
+
|
|
39
|
+
const aggregate = {
|
|
40
|
+
bbox: subparts.length ? size(unionBounds(subBounds)) : [0, 0, 0],
|
|
41
|
+
volume: subparts.reduce((a, s) => a + s.volume, 0),
|
|
42
|
+
surfaceArea: subparts.reduce((a, s) => a + s.surfaceArea, 0),
|
|
43
|
+
triangleCount: subparts.reduce((a, s) => a + s.triangleCount, 0),
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
part: part.meta?.title ?? view,
|
|
47
|
+
view,
|
|
48
|
+
subparts,
|
|
49
|
+
aggregate,
|
|
50
|
+
overlaps,
|
|
51
|
+
ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/testing/mesh.js
CHANGED
|
@@ -19,3 +19,30 @@ export function bboxSize(positions) {
|
|
|
19
19
|
}
|
|
20
20
|
return [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// Axis-aligned bounds of a flat position array (x,y,z per vertex).
|
|
24
|
+
export function bounds(positions) {
|
|
25
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
26
|
+
for (let i = 0; i < positions.length; i += 3) for (let a = 0; a < 3; a++) {
|
|
27
|
+
const v = positions[i + a];
|
|
28
|
+
if (v < min[a]) min[a] = v;
|
|
29
|
+
if (v > max[a]) max[a] = v;
|
|
30
|
+
}
|
|
31
|
+
return { min, max };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Surface area (mm²) of a triangle mesh. `indices` is optional: when omitted the
|
|
35
|
+
// positions are a non-indexed soup (3 consecutive verts per triangle, Manifold);
|
|
36
|
+
// when given, positions is a vertex array indexed by triangle (OCCT/replicad).
|
|
37
|
+
export function meshArea(positions, indices) {
|
|
38
|
+
let area = 0;
|
|
39
|
+
const n = indices ? indices.length : positions.length / 3;
|
|
40
|
+
for (let i = 0; i < n; i += 3) {
|
|
41
|
+
const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
|
|
42
|
+
const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = positions[b + 2] - positions[a + 2];
|
|
43
|
+
const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = positions[c + 2] - positions[a + 2];
|
|
44
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
45
|
+
area += Math.hypot(nx, ny, nz) / 2;
|
|
46
|
+
}
|
|
47
|
+
return area;
|
|
48
|
+
}
|