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.
@@ -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 routes preview generate through the OCCT worker (dev toggle).
20
- const occtPreview = new URLSearchParams(location.search).get("backend") === "occt";
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 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).
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
- let view = partSeg.querySelector("button.on")?.dataset.part ?? partSeg.querySelector("button")?.dataset.part;
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) framedView = view;
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, occtPreview });
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 = true;
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
+ }
@@ -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(), material)])
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. 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.
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
- { 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
+ { 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
- sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
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, 2)]);
35
- return s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 4).translate([0, 0, -2]));
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
+ }
@@ -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
+ }