partforge 0.1.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.
@@ -0,0 +1,73 @@
1
+ import { meshTo3MF } from "./geometry/threemf.js";
2
+
3
+ // Names of the sub-parts a view shows: declared in the view and enabled for these
4
+ // params. Order follows Object.keys(part.parts) (definition order).
5
+ export function viewSubParts(part, view, params) {
6
+ return Object.keys(part.parts).filter((name) => {
7
+ const sp = part.parts[name];
8
+ const inView = sp.views.includes(view);
9
+ const on = sp.enabled ? !!sp.enabled(params) : true;
10
+ return inView && on;
11
+ });
12
+ }
13
+
14
+ // Handle one geometry job, posting results/progress via `post`. Backend-agnostic
15
+ // and part-agnostic: every part specific comes through `part`.
16
+ // { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
17
+ // { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
18
+ // { type:"export-step", view, params } → { type:"download", data, filename, mime }
19
+ // Progress is posted as { type:"progress", phase }. Export builds thread the
20
+ // progress callback into build() so a part's own per-feature progress surfaces;
21
+ // preview generates stay quiet (no callback) to avoid flicker during slider drags.
22
+ export async function handle(kernel, part, msg, post) {
23
+ const onProgress = (phase) => post({ type: "progress", phase });
24
+ const p = { ...part.defaults, ...msg.params };
25
+ const d = part.derive ? part.derive(p) : {};
26
+ const label = (name) => part.parts[name].label ?? name;
27
+ const exportName = (name) => part.parts[name].export?.name ?? name;
28
+ const buildPosed = (name, purpose, view, prog) => {
29
+ const sp = part.parts[name];
30
+ const solid = sp.build(kernel, p, d, prog);
31
+ return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
32
+ };
33
+
34
+ try {
35
+ if (msg.type === "generate") {
36
+ const t0 = Date.now();
37
+ const meshes = [];
38
+ 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?.();
42
+ }
43
+ post({ type: "meshes", meshes, ms: Date.now() - t0 });
44
+ } else if (msg.type === "export-stl") {
45
+ const out = [];
46
+ for (const name of viewSubParts(part, msg.view, p)) {
47
+ onProgress(`building ${label(name)}`);
48
+ out.push({ name: exportName(name), data: await buildPosed(name, "export", msg.view, onProgress).toSTL({ quality: "print" }) });
49
+ }
50
+ post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out });
51
+ } else if (msg.type === "export-step") {
52
+ const solids = viewSubParts(part, msg.view, p).map((name) => {
53
+ onProgress(`building ${label(name)}`);
54
+ return { name: exportName(name), solid: buildPosed(name, "export", msg.view, onProgress) };
55
+ });
56
+ onProgress("writing STEP file");
57
+ const data = await kernel.toSTEP(solids);
58
+ post({ type: "download", data, filename: `${msg.view}.step`, mime: "application/step" });
59
+ } else if (msg.type === "export-3mf") {
60
+ const meshes = viewSubParts(part, msg.view, p).map((name) => {
61
+ onProgress(`building ${label(name)}`);
62
+ const { positions, indices } = buildPosed(name, "export", msg.view, onProgress).toIndexedMesh();
63
+ return { name: exportName(name), positions, indices };
64
+ });
65
+ onProgress("writing 3MF file");
66
+ post({ type: "download", data: meshTo3MF(meshes), filename: `${msg.view}.3mf`, mime: "model/3mf" });
67
+ }
68
+ } catch (err) {
69
+ post({ type: "error", message: String(err?.message || err) });
70
+ } finally {
71
+ kernel.cleanup?.();
72
+ }
73
+ }
@@ -0,0 +1,227 @@
1
+ import "./app.css"; // shared chrome styles — every part-app gets them via mount
2
+ import { zipSync } from "fflate";
3
+ import { createViewer } from "./viewer.js";
4
+ import { buildControls } from "./controls.js";
5
+ import { createGeometryService } from "./geometry-service.js";
6
+ import { viewSubParts } from "./jobs.js";
7
+
8
+ // Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
9
+ // panel + the two geometry workers + the auto-regenerating view/cache loop +
10
+ // STL/STEP export. The app supplies `createWorker(name)` so Vite can bundle the
11
+ // worker (see geometry-service.js). DOM element ids match the host page:
12
+ // #app (viewer), #controls, #status, #download, #download-step, #busy, #phase,
13
+ // #part (the view-tab segmented control).
14
+ export function mount(part, { createWorker, container = document.getElementById("app"),
15
+ controls = document.getElementById("controls") } = {}) {
16
+ const names = Object.keys(part.parts);
17
+ const viewer = createViewer(container, part);
18
+
19
+ // ?backend=occt routes preview generate through the OCCT worker (dev toggle).
20
+ const occtPreview = new URLSearchParams(location.search).get("backend") === "occt";
21
+
22
+ const statusEl = document.getElementById("status");
23
+ const dlBtn = document.getElementById("download");
24
+ const dlStepBtn = document.getElementById("download-step");
25
+ const dl3mfBtn = document.getElementById("download-3mf");
26
+ const busyEl = document.getElementById("busy");
27
+ const phaseEl = document.getElementById("phase");
28
+ const partSeg = document.getElementById("part");
29
+ let kernelReady = false;
30
+
31
+ const exportBtns = [dlBtn, dlStepBtn, dl3mfBtn].filter(Boolean);
32
+ const setExportEnabled = (on) => exportBtns.forEach((b) => { b.disabled = !on; });
33
+
34
+ const setStatus = (msg, isErr = false) => { statusEl.textContent = msg; statusEl.classList.toggle("err", isErr); };
35
+ const showBusy = (phase) => { phaseEl.textContent = `${phase}…`; busyEl.classList.add("show"); };
36
+ const hideBusy = () => busyEl.classList.remove("show");
37
+
38
+ // --- per-sub-part mesh cache + auto-regenerating view composition ----------
39
+ // The host page owns the view-tab markup (#part buttons, data-part = view name);
40
+ // `part.views` documents the available views and their labels for that page.
41
+ // The initial view is whichever tab the page marks active (else the first tab).
42
+ const params = { ...part.defaults };
43
+ let view = partSeg.querySelector("button.on")?.dataset.part ?? partSeg.querySelector("button")?.dataset.part;
44
+ let framedView = null; // the view the camera was last framed to (null until first show)
45
+ let generating = false;
46
+ let paramsVersion = 0; // bumped on every settings edit
47
+ let genVersion = -1; // the params version the in-flight generate is building
48
+ let genTimer = null; // debounce timer for auto-regenerate
49
+ const cacheVersion = Object.fromEntries(names.map((n) => [n, -1])); // params version each was built at
50
+
51
+ // A cached sub-part is current only if it was built at the latest params version.
52
+ const isCurrent = (n) => viewer._subCache[n] && cacheVersion[n] === paramsVersion;
53
+ const missingParts = () => viewSubParts(part, view, params).filter((n) => !isCurrent(n));
54
+
55
+ // Reflect the active view. If every needed part is current, show it and enable
56
+ // export. If stale (a regenerate is in flight), keep the old mesh visible so the
57
+ // view doesn't flicker. If nothing's built yet, show nothing.
58
+ // Show the assembly, framing the camera only the first time we show a given view
59
+ // (initial load / tab switch) — never on a regenerate, so zoom/orbit are kept.
60
+ function showView(needed) {
61
+ const frame = view !== framedView;
62
+ viewer.showAssembly(needed, { frame });
63
+ if (frame) framedView = view;
64
+ }
65
+
66
+ function refreshView() {
67
+ const needed = viewSubParts(part, view, params);
68
+ if (needed.every(isCurrent)) {
69
+ showView(needed);
70
+ setExportEnabled(true);
71
+ const tris = needed.reduce((s, n) => s + viewer._subCache[n].userData.triangles, 0);
72
+ setStatus(`${tris.toLocaleString()} triangles`);
73
+ } else if (needed.every((n) => viewer._subCache[n])) {
74
+ showView(needed); // stale but present — keep it visible during regenerate
75
+ setExportEnabled(false);
76
+ } else {
77
+ viewer.hideAssembly();
78
+ setExportEnabled(false);
79
+ }
80
+ }
81
+
82
+ showBusy("booting kernel"); // visible from first paint until the kernel is ready
83
+
84
+ // --- download helpers ------------------------------------------------------
85
+ function triggerDownload(arrayBuffer, filename, mime) {
86
+ const url = URL.createObjectURL(new Blob([arrayBuffer], { type: mime }));
87
+ const a = document.createElement("a");
88
+ a.href = url;
89
+ a.download = filename;
90
+ a.click();
91
+ URL.revokeObjectURL(url);
92
+ }
93
+
94
+ function onDownloadParts({ parts, ext, mime }) {
95
+ if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime);
96
+ const entries = {};
97
+ for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
98
+ triggerDownload(zipSync(entries, { level: 0 }), `${part.meta?.title ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-"), "application/zip");
99
+ }
100
+
101
+ // --- shared message handler ------------------------------------------------
102
+ function onWorkerMessage({ data }) {
103
+ switch (data.type) {
104
+ case "ready":
105
+ kernelReady = true;
106
+ maybeGenerate(); // auto-build the default view (keeps the busy spinner up)
107
+ break;
108
+ case "progress":
109
+ showBusy(data.phase);
110
+ setStatus(`${data.phase}…`);
111
+ break;
112
+ case "meshes": {
113
+ generating = false;
114
+ if (genVersion !== paramsVersion) { maybeGenerate(); break; } // changed mid-build → redo
115
+ for (const m of data.meshes) {
116
+ if (viewer._subCache[m.name]) { viewer._subCache[m.name].userData.edges?.dispose(); viewer._subCache[m.name].dispose(); }
117
+ viewer.setSubGeometry(m.name, m);
118
+ cacheVersion[m.name] = genVersion;
119
+ }
120
+ hideBusy();
121
+ refreshView();
122
+ if (data.ms && missingParts().length === 0) {
123
+ setStatus(`${statusEl.textContent} · ${(data.ms / 1000).toFixed(1)} s`);
124
+ }
125
+ maybeGenerate(); // active view may still need parts (tab switched during build)
126
+ break;
127
+ }
128
+ case "download-parts":
129
+ hideBusy();
130
+ onDownloadParts(data);
131
+ setStatus(`${data.parts.length} part(s) downloaded`);
132
+ break;
133
+ case "download":
134
+ hideBusy();
135
+ triggerDownload(data.data, data.filename, data.mime);
136
+ setStatus(`${data.filename} downloaded`);
137
+ break;
138
+ case "error":
139
+ generating = false;
140
+ hideBusy();
141
+ setStatus(`failed: ${data.message}`, true);
142
+ refreshView();
143
+ break;
144
+ }
145
+ }
146
+
147
+ const service = createGeometryService({ createWorker, onMessage: onWorkerMessage, occtPreview });
148
+
149
+ buildControls(controls, part.parameters, params, onParamChange);
150
+
151
+ function onParamChange() {
152
+ paramsVersion++; // every edit invalidates the caches (by version)
153
+ refreshView(); // keep showing the now-stale mesh (no flicker); disable export
154
+ scheduleGenerate();
155
+ }
156
+
157
+ // Debounce auto-regeneration so dragging a slider doesn't queue a build per pixel.
158
+ function scheduleGenerate() {
159
+ clearTimeout(genTimer);
160
+ genTimer = setTimeout(maybeGenerate, 180);
161
+ }
162
+
163
+ // Build whatever the active view is missing — automatic, no Generate button.
164
+ function maybeGenerate() {
165
+ if (!kernelReady || generating) return; // retried when the current build finishes
166
+ const missing = missingParts();
167
+ if (missing.length === 0) return;
168
+ generating = true;
169
+ genVersion = paramsVersion;
170
+ showBusy("generating");
171
+ service.generate({ type: "generate", subparts: missing, view, params });
172
+ }
173
+
174
+ partSeg.addEventListener("click", (e) => {
175
+ const btn = e.target.closest("button[data-part]");
176
+ if (!btn) return;
177
+ view = btn.dataset.part;
178
+ for (const b of partSeg.children) b.classList.toggle("on", b === btn);
179
+ refreshView(); // instant if the view's parts are cached + current
180
+ maybeGenerate(); // else auto-build the missing pieces
181
+ });
182
+
183
+ dlBtn.addEventListener("click", () => {
184
+ showBusy("exporting STL");
185
+ service.exportStl({ type: "export-stl", view, params });
186
+ });
187
+
188
+ dlStepBtn.addEventListener("click", () => {
189
+ showBusy("exporting STEP");
190
+ service.exportStep({ type: "export-step", view, params });
191
+ });
192
+
193
+ dl3mfBtn?.addEventListener("click", () => {
194
+ showBusy("exporting 3MF");
195
+ service.export3mf({ type: "export-3mf", view, params });
196
+ });
197
+
198
+ // --- viewer controls (optional host-page buttons: #pause / #reframe / #theme) --
199
+ const pauseBtn = document.getElementById("pause");
200
+ const reframeBtn = document.getElementById("reframe");
201
+ const themeBtn = document.getElementById("theme");
202
+
203
+ // Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
204
+ // scene together; remember the choice across reloads.
205
+ let theme = localStorage.getItem("theme") || "dark";
206
+ function applyTheme(mode) {
207
+ theme = mode;
208
+ document.documentElement.dataset.theme = mode;
209
+ viewer.setTheme(mode);
210
+ themeBtn?.classList.toggle("on", mode === "light");
211
+ localStorage.setItem("theme", mode);
212
+ }
213
+ applyTheme(theme);
214
+ themeBtn?.addEventListener("click", () => applyTheme(theme === "light" ? "dark" : "light"));
215
+
216
+ // Pause/resume the idle auto-rotation.
217
+ let rotating = true;
218
+ pauseBtn?.addEventListener("click", () => {
219
+ rotating = !rotating;
220
+ viewer.setAutoRotate(rotating);
221
+ pauseBtn.textContent = rotating ? "⏸" : "▶";
222
+ pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
223
+ });
224
+
225
+ // Re-fit the camera to the current view.
226
+ reframeBtn?.addEventListener("click", () => viewer.frame());
227
+ }
@@ -0,0 +1,204 @@
1
+ import * as THREE from "three";
2
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
+ import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
4
+ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
+ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
6
+ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
+
8
+ export function createViewer(container, part) {
9
+ const names = Object.keys(part.parts);
10
+
11
+ // --- renderer / scene / camera --------------------------------------------
12
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
13
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
14
+ container.appendChild(renderer.domElement);
15
+
16
+ const scene = new THREE.Scene();
17
+
18
+ // Light/dark scene palettes (the page chrome is themed separately, via CSS on the
19
+ // host page). A part can override the dark background through meta.background.
20
+ const THEME = {
21
+ dark: { bg: part.meta?.background ?? 0x15181d, grid: [0x2c333d, 0x222831], line: 0x1c232d },
22
+ light: { bg: 0xe9edf2, grid: [0xc4ccd6, 0xd6dce4], line: 0x33414f },
23
+ };
24
+ scene.background = new THREE.Color(THEME.dark.bg);
25
+
26
+ const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
27
+ camera.position.set(18, 12, 18);
28
+
29
+ const controls = new OrbitControls(camera, renderer.domElement);
30
+ controls.enableDamping = true;
31
+ controls.autoRotate = true;
32
+ controls.autoRotateSpeed = 1.6;
33
+
34
+ // --- lights + grid --------------------------------------------------------
35
+ scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x202024, 1.1));
36
+ const key = new THREE.DirectionalLight(0xffffff, 1.4);
37
+ key.position.set(8, 14, 10);
38
+ scene.add(key);
39
+ // 1 cm grid (mm units): 200 mm wide, 20 divisions -> 10 mm squares.
40
+ let grid = new THREE.GridHelper(200, 20, THEME.dark.grid[0], THEME.dark.grid[1]);
41
+ scene.add(grid);
42
+
43
+ // --- material + part groups -----------------------------------------------
44
+ const material = new THREE.MeshStandardMaterial({
45
+ color: 0x9fb4cc,
46
+ metalness: 0.25,
47
+ roughness: 0.55,
48
+ flatShading: false,
49
+ polygonOffset: true, // push the surface back so edge lines sit cleanly on top
50
+ polygonOffsetFactor: 1,
51
+ polygonOffsetUnits: 1,
52
+ });
53
+
54
+ // Sub-parts are meshed independently in a shared frame and cached, so any view
55
+ // is composed from cached pieces. `pivot` stands the part's Z axis up (parts are
56
+ // modelled Z-up; this faces the camera); `partsGroup` is recentred per view so
57
+ // the visible assembly sits at the origin.
58
+ const pivot = new THREE.Group();
59
+ pivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical
60
+ scene.add(pivot);
61
+ const partsGroup = new THREE.Group();
62
+ pivot.add(partsGroup);
63
+
64
+ const subMesh = Object.fromEntries(
65
+ names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(), material)])
66
+ );
67
+ for (const m of Object.values(subMesh)) {
68
+ m.visible = false;
69
+ partsGroup.add(m);
70
+ }
71
+
72
+ // CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
73
+ const EDGE_ANGLE = 35; // deg — OCCT fallback threshold (Manifold supplies seam-aware edges)
74
+ const lineMaterial = new LineMaterial({ color: 0x1c232d, linewidth: 1.0 }); // ~10% lighter, 1 px
75
+ lineMaterial.resolution.set(innerWidth, innerHeight);
76
+ const subLines = Object.fromEntries(
77
+ names.map((n) => [n, new LineSegments2(new LineSegmentsGeometry(), lineMaterial)])
78
+ );
79
+ for (const l of Object.values(subLines)) {
80
+ l.visible = false;
81
+ partsGroup.add(l);
82
+ }
83
+
84
+ // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
85
+ // round body and helical groove read smooth while bore rims, drum faces, and
86
+ // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
87
+ // softer. (Worker normals are ignored; we recompute per the crease threshold.)
88
+ const CREASE_ANGLE = Math.PI / 6; // 30°
89
+
90
+ // --- geometry builder -----------------------------------------------------
91
+ // BufferGeometry from a worker mesh payload — kept in its shared-frame coords
92
+ // (NOT recentred) so the pieces assemble in the right relative positions.
93
+ function buildGeometry({ positions, normals, indices, triangles, edges }) {
94
+ const geo = new THREE.BufferGeometry();
95
+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
96
+ if (indices?.length) geo.setIndex(new THREE.BufferAttribute(indices, 1)); // Manifold is non-indexed
97
+ const triCount = triangles ?? (indices ? indices.length : positions.length / 3) / 3;
98
+ let out;
99
+ if (normals?.length) {
100
+ // kernel-computed normals (Manifold) — smooth within a surface, hard at cut seams
101
+ geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
102
+ geo.computeBoundingBox();
103
+ out = geo;
104
+ } else {
105
+ // fallback (no kernel normals, e.g. OCCT): crease from the triangle soup
106
+ out = toCreasedNormals(geo, CREASE_ANGLE);
107
+ out.computeBoundingBox();
108
+ }
109
+ out.userData.triangles = triCount;
110
+ // feature edge lines: Manifold supplies seam-aware segments; else derive by angle
111
+ const lg = new LineSegmentsGeometry();
112
+ if (edges?.length) lg.setPositions(edges);
113
+ else lg.fromEdgesGeometry(new THREE.EdgesGeometry(out, EDGE_ANGLE));
114
+ out.userData.edges = lg;
115
+ return out;
116
+ }
117
+
118
+ // --- sub-part geometry cache ----------------------------------------------
119
+ const subCache = Object.fromEntries(names.map((n) => [n, null]));
120
+
121
+ function setSubGeometry(name, payload) {
122
+ subCache[name] = buildGeometry(payload);
123
+ }
124
+
125
+ // --- show / hide assembly -------------------------------------------------
126
+ const _box = new THREE.Box3();
127
+
128
+ // Recentre the assembly on the pivot and frame the camera to the named parts.
129
+ function frameTo(visibleNames) {
130
+ _box.makeEmpty();
131
+ for (const name of visibleNames) if (subCache[name]) _box.union(subCache[name].boundingBox);
132
+ if (_box.isEmpty()) return;
133
+ const center = _box.getCenter(new THREE.Vector3());
134
+ partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
135
+ const size = _box.getSize(new THREE.Vector3());
136
+ const r = Math.max(size.x, size.y, size.z) || 12;
137
+ camera.position.setLength(r * 2.6 + 6);
138
+ controls.target.set(0, 0, 0);
139
+ }
140
+
141
+ // Show exactly the named sub-parts (from the cache). When `frame` is set, also
142
+ // frame the camera to them — done only on the initial show and on view (tab)
143
+ // changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
144
+ function showAssembly(visibleNames, { frame = false } = {}) {
145
+ for (const [name, mesh] of Object.entries(subMesh)) {
146
+ const on = visibleNames.includes(name);
147
+ if (on) {
148
+ mesh.geometry = subCache[name]; // cached geometries reused, not disposed
149
+ subLines[name].geometry = subCache[name].userData.edges;
150
+ }
151
+ mesh.visible = on;
152
+ subLines[name].visible = on;
153
+ }
154
+ if (frame) frameTo(visibleNames);
155
+ }
156
+
157
+ // Re-frame whatever is currently visible (the reframe button).
158
+ function frame() {
159
+ frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
160
+ }
161
+
162
+ function setAutoRotate(on) { controls.autoRotate = on; }
163
+
164
+ // Swap the scene background, grid, and edge-line colors for the given theme.
165
+ function setTheme(mode) {
166
+ const t = THEME[mode] ?? THEME.dark;
167
+ scene.background = new THREE.Color(t.bg);
168
+ scene.remove(grid);
169
+ grid = new THREE.GridHelper(200, 20, t.grid[0], t.grid[1]);
170
+ scene.add(grid);
171
+ lineMaterial.color.set(t.line);
172
+ }
173
+
174
+ function hideAssembly() {
175
+ for (const m of Object.values(subMesh)) m.visible = false;
176
+ for (const l of Object.values(subLines)) l.visible = false;
177
+ }
178
+
179
+ // --- resize ---------------------------------------------------------------
180
+ function resize() {
181
+ const w = innerWidth, h = innerHeight;
182
+ renderer.setSize(w, h);
183
+ camera.aspect = w / h;
184
+ camera.updateProjectionMatrix();
185
+ lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
186
+ }
187
+ addEventListener("resize", resize);
188
+ resize();
189
+
190
+ // --- render loop ----------------------------------------------------------
191
+ renderer.setAnimationLoop(() => {
192
+ controls.update();
193
+ renderer.render(scene, camera);
194
+ });
195
+
196
+ // --- dispose --------------------------------------------------------------
197
+ function dispose() {
198
+ renderer.setAnimationLoop(null);
199
+ renderer.dispose();
200
+ container.removeChild(renderer.domElement);
201
+ }
202
+
203
+ return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, _subCache: subCache };
204
+ }
@@ -0,0 +1,76 @@
1
+ // Worker runtime shared by every part. The host spawns this entry twice, named
2
+ // "manifold" (preview + STL) and "occt" (STEP), via the Worker `name` option.
3
+ // Each instance lazily imports only its own backend, so OCCT's ~11 MB WASM loads
4
+ // only in the worker that needs it, and only on first use.
5
+ import { handle } from "./jobs.js";
6
+
7
+ async function manifoldKernels() {
8
+ const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
9
+ import("manifold-3d"),
10
+ import("./geometry/manifold-backend.js"),
11
+ ]);
12
+ const wasm = await Module();
13
+ wasm.setup();
14
+ return {
15
+ preview: createManifoldKernel(wasm, { quality: "preview" }), // fast interactive view
16
+ print: createManifoldKernel(wasm, { quality: "print" }), // high-res STL export
17
+ };
18
+ }
19
+
20
+ async function occtKernel() {
21
+ const [{ default: opencascade }, wasmUrlMod, replicad, { createOcctKernel }] = await Promise.all([
22
+ import("replicad-opencascadejs/src/replicad_single.js"),
23
+ import("replicad-opencascadejs/src/replicad_single.wasm?url"),
24
+ import("replicad"),
25
+ import("./geometry/occt-backend.js"),
26
+ ]);
27
+ const OC = await opencascade({ locateFile: () => wasmUrlMod.default });
28
+ replicad.setOC(OC);
29
+ return createOcctKernel(replicad);
30
+ }
31
+
32
+ // Transfer the big binary buffers (zero-copy) instead of structured-cloning them.
33
+ function transferOf(m) {
34
+ if (m.type === "meshes") {
35
+ const t = [];
36
+ for (const x of m.meshes) {
37
+ t.push(x.positions.buffer);
38
+ if (x.normals?.buffer) t.push(x.normals.buffer);
39
+ if (x.indices?.buffer) t.push(x.indices.buffer);
40
+ if (x.edges?.buffer) t.push(x.edges.buffer);
41
+ }
42
+ return t;
43
+ }
44
+ if (m.type === "download-parts") return m.parts.map((p) => (ArrayBuffer.isView(p.data) ? p.data.buffer : p.data));
45
+ if (m.type === "download") return [m.data];
46
+ return [];
47
+ }
48
+
49
+ export function runWorker(part) {
50
+ const backend = self.name === "occt" ? "occt" : "manifold";
51
+ let manifold = null; // { preview, print }
52
+ let occt = null;
53
+ let booting = null;
54
+
55
+ // Manifold is cheap to boot — bring it up eagerly and signal readiness.
56
+ if (backend === "manifold") {
57
+ booting = manifoldKernels().then((m) => { manifold = m; postMessage({ type: "ready" }); });
58
+ }
59
+
60
+ self.onmessage = async (e) => {
61
+ let kernel;
62
+ if (backend === "manifold") {
63
+ await booting;
64
+ const printJob = e.data.type === "export-stl" || e.data.type === "export-3mf"; // high-res mesh exports
65
+ kernel = printJob ? manifold.print : manifold.preview;
66
+ } else {
67
+ if (!occt) {
68
+ postMessage({ type: "progress", phase: "loading exact kernel" }); // feedback during cold boot
69
+ booting = booting ?? occtKernel().then((k) => (occt = k));
70
+ await booting;
71
+ }
72
+ kernel = occt;
73
+ }
74
+ await handle(kernel, part, e.data, (m) => postMessage(m, transferOf(m)));
75
+ };
76
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // partforge — app entry (DOM). Apps call mount(); viewSubParts is handy for app-side
2
+ // view logic. This entry pulls in the viewer/controls (which use `document`), so it
3
+ // must NOT be imported from a part's build functions — those run in a Web Worker.
4
+ // Part build functions import geometry helpers from "partforge/geometry" instead.
5
+ export { mount } from "./framework/index.js";
6
+ export { viewSubParts } from "./framework/jobs.js";
@@ -0,0 +1,40 @@
1
+ // Example PartDefinition — a parametric spacer. Proof that a new part is just a new
2
+ // script: the framework (viewer, controls, workers, STL/STEP export) is reused
3
+ // unchanged. Mount it with its own app/worker entry exactly as the drum does.
4
+ export default {
5
+ meta: { title: "Spacer", units: "mm", background: 0x15181d },
6
+ parameters: [
7
+ {
8
+ id: "body",
9
+ title: "Body",
10
+ presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } },
11
+ advanced: [
12
+ { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
13
+ { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
14
+ { key: "h", label: "Height", unit: "mm", min: 2, max: 60, step: 1 },
15
+ ],
16
+ },
17
+ {
18
+ id: "flange",
19
+ title: "Flange",
20
+ features: [
21
+ { label: "Base flange", key: "flange_d", on: 16,
22
+ sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
23
+ ],
24
+ },
25
+ ],
26
+ defaults: { od: 8, bore: 3.4, h: 10, flange_d: 0 },
27
+ parts: {
28
+ spacer: {
29
+ label: "Spacer",
30
+ views: ["spacer"],
31
+ export: { name: "spacer" },
32
+ build: (k, p) => {
33
+ 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, 2)]);
35
+ return s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 4).translate([0, 0, -2]));
36
+ },
37
+ },
38
+ },
39
+ views: { spacer: { label: "Spacer" } },
40
+ };
@@ -0,0 +1,21 @@
1
+ // test/helpers.js — shared test utilities (mesh volume + bbox from a flat mesh).
2
+ // `indices` is optional: when omitted the positions are treated as a flat,
3
+ // non-indexed triangle soup (3 vertices per triangle).
4
+ export function meshVolume(positions, indices) {
5
+ const n = indices ? indices.length : positions.length / 3;
6
+ let V = 0;
7
+ for (let i = 0; i < n; i += 3) {
8
+ const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
9
+ V += (positions[a] * (positions[b + 1] * positions[c + 2] - positions[b + 2] * positions[c + 1])
10
+ - positions[a + 1] * (positions[b] * positions[c + 2] - positions[b + 2] * positions[c])
11
+ + positions[a + 2] * (positions[b] * positions[c + 1] - positions[b + 1] * positions[c])) / 6;
12
+ }
13
+ return Math.abs(V);
14
+ }
15
+ export function bboxSize(positions) {
16
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
17
+ for (let i = 0; i < positions.length; i += 3) for (let a = 0; a < 3; a++) {
18
+ lo[a] = Math.min(lo[a], positions[i + a]); hi[a] = Math.max(hi[a], positions[i + a]);
19
+ }
20
+ return [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
21
+ }
@@ -0,0 +1,18 @@
1
+ // Boots OCCT/replicad in a Node test process and returns a ready OCCT GeometryKernel.
2
+ // (Manifold must NOT be booted in the same process — they crash together.)
3
+ import { createRequire } from "module";
4
+ import { fileURLToPath } from "url";
5
+ import path from "path";
6
+ import fs from "fs";
7
+ import { createOcctKernel } from "../framework/geometry/occt-backend.js";
8
+
9
+ export async function bootOcctKernel() {
10
+ const require = createRequire(import.meta.url);
11
+ globalThis.require = globalThis.require ?? require;
12
+ globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
13
+ const { default: init } = await import("replicad-opencascadejs/src/replicad_single.js");
14
+ const OC = await init({ wasmBinary: fs.readFileSync(require.resolve("replicad-opencascadejs/src/replicad_single.wasm")) });
15
+ const replicad = await import("replicad");
16
+ replicad.setOC(OC);
17
+ return createOcctKernel(replicad);
18
+ }