partforge 0.11.0 → 0.12.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 CHANGED
@@ -88,6 +88,40 @@ Test your parts headlessly with `partforge/testing`
88
88
  (or `node scripts/check-app.mjs <entry>.html`) — it loads the app and verifies the kernel
89
89
  boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright install chromium`.
90
90
 
91
+ ### Embedding (0.12.0+)
92
+
93
+ `mount()` returns a runtime handle and accepts element references, so an
94
+ embedding app (React, iframe, multiple mounts) can size, await, and tear down
95
+ the viewer without global IDs:
96
+
97
+ ```js
98
+ const runtime = mount(part, {
99
+ createWorker,
100
+ elements: {
101
+ viewer, controls, // canvas host + param-panel host
102
+ status: { status, busy, phase }, // status chrome
103
+ tabs, // view-tab segmented control
104
+ exports: { stl, step, threeMf }, // export buttons
105
+ chrome: { pause, reframe, theme }, // viewer buttons
106
+ },
107
+ onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
108
+ onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
109
+ });
110
+ await runtime.ready; // first successful build (rejects on a first-build error)
111
+ runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
112
+ ```
113
+
114
+ Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
115
+ `#status`/`#busy`/`#phase`, `#part`, `#download`/`#download-step`/`#download-3mf`,
116
+ `#pause`/`#reframe`/`#theme`), so a classic host page needs no changes. The viewer
117
+ sizes from its container via ResizeObserver — no window coupling.
118
+
119
+ `onPick` arms click-to-select permanently: `label` is the feature label (falling
120
+ back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
121
+ sentence, `token` the compact form, `selection` the raw object. When `onPick` is
122
+ set, the `?pick` / `?pickserver` URL modes are ignored (one click listener ever
123
+ live); hover labels stay always-on.
124
+
91
125
  ## Authoring guide
92
126
 
93
127
  **[docs/AUTHORING-PARTS.md](docs/AUTHORING-PARTS.md)** is the full guide — the part
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,35 +47,51 @@ export function clampToRange(raw, min, max) {
47
47
  return Math.min(max, Math.max(min, v));
48
48
  }
49
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; }
50
+ // --- info glyph + per-panel popover -----------------------------------------
51
+ // One popover element per panel, shared by all its glyphs (only one open at a
52
+ // time). Document-level dismiss listeners are registered per panel and removed
53
+ // by panel.dispose().
54
+ function createInfoPopover() {
55
+ const pop = el("div", "popover");
56
+ pop.hidden = true;
57
+ document.body.append(pop);
58
+ let owner = null; // the glyph whose description is showing
59
+
60
+ function close() {
61
+ if (pop.hidden) return;
62
+ pop.hidden = true;
63
+ if (owner) { owner.setAttribute("aria-expanded", "false"); owner = null; }
67
64
  }
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(); });
65
+ const onDocClick = (e) => {
66
+ if (!pop.hidden && !pop.contains(e.target) && !e.target.closest?.(".info")) close();
67
+ };
68
+ const onDocKeydown = (e) => { if (e.key === "Escape") close(); };
69
+ document.addEventListener("click", onDocClick);
70
+ document.addEventListener("keydown", onDocKeydown);
71
+
72
+ return {
73
+ toggle(glyph, description) {
74
+ if (owner === glyph) { close(); return; } // toggle off
75
+ close();
76
+ pop.innerHTML = renderMarkdown(description);
77
+ pop.hidden = false;
78
+ owner = glyph;
79
+ glyph.setAttribute("aria-expanded", "true");
80
+ const r = glyph.getBoundingClientRect();
81
+ pop.style.top = `${r.bottom + 6}px`;
82
+ pop.style.left = `${Math.max(8, r.left - 8)}px`;
83
+ },
84
+ dispose() {
85
+ document.removeEventListener("click", onDocClick);
86
+ document.removeEventListener("keydown", onDocKeydown);
87
+ pop.remove();
88
+ },
89
+ };
74
90
  }
75
91
 
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) {
92
+ // Append a focusable ⓘ glyph to `container` that toggles the panel's shared
93
+ // popover with `description` (Markdown). No-op when description is empty.
94
+ function attachInfo(container, description, info) {
79
95
  if (typeof description !== "string" || !description.trim()) return;
80
96
  const glyph = document.createElement("button");
81
97
  glyph.type = "button";
@@ -83,19 +99,7 @@ function attachInfo(container, description) {
83
99
  glyph.textContent = "ⓘ";
84
100
  glyph.setAttribute("aria-label", "More info");
85
101
  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
- });
102
+ glyph.addEventListener("click", (e) => { e.stopPropagation(); info.toggle(glyph, description); });
99
103
  container.append(glyph);
100
104
  }
101
105
 
@@ -111,12 +115,12 @@ function el(tag, className, text) {
111
115
  // "number" — number box only (no slider)
112
116
  // The box accepts exact values (finer than `step`); typed values clamp to
113
117
  // [min, max] on commit (blur/Enter). Returns { wrap, sync }.
114
- function makeSlider(def, params, onChange) {
118
+ function makeSlider(def, params, onChange, info) {
115
119
  const numeric = def.control === "number";
116
120
  const wrap = el("div", "slider");
117
121
  const row = el("div", "row");
118
122
  const label = el("label", "", def.label);
119
- attachInfo(label, def.description);
123
+ attachInfo(label, def.description, info);
120
124
  row.append(label);
121
125
 
122
126
  // editable value box (+ optional unit suffix)
@@ -181,25 +185,29 @@ function advancedBlock() {
181
185
  }
182
186
 
183
187
  export function buildControls(root, parameters, params, onDirty) {
188
+ const info = createInfoPopover();
184
189
  const controls = []; // { key, el } per control element
185
190
  const sections = []; // { el, keys:Set } per rendered section
186
191
  for (const sec of parameters) {
187
192
  if (!sectionRenders(sec)) continue;
188
193
  const section = el("div", "section");
189
194
  const title = el("div", "sec-title", sec.title);
190
- attachInfo(title, sec.description);
195
+ attachInfo(title, sec.description, info);
191
196
  section.append(title);
192
197
  const keys = new Set();
193
198
  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);
199
+ if (sec.features) buildFeatureSection(section, sec, params, onDirty, register, info);
200
+ else buildPresetSection(section, sec, params, onDirty, register, info);
196
201
  root.append(section);
197
202
  sections.push({ el: section, keys });
198
203
  }
199
- return { applyRelevance: (relevant) => applyRelevance(relevant, controls, sections) };
204
+ return {
205
+ applyRelevance: (relevant) => applyRelevance(relevant, controls, sections),
206
+ dispose: () => { info.dispose(); root.replaceChildren(); },
207
+ };
200
208
  }
201
209
 
202
- function buildPresetSection(section, sec, params, onDirty, register) {
210
+ function buildPresetSection(section, sec, params, onDirty, register, info) {
203
211
  // preset picker, below the title, full width (omitted when the section has no presets)
204
212
  let preset = null;
205
213
  const presetNames = sec.presets ? Object.keys(sec.presets) : [];
@@ -222,7 +230,7 @@ function buildPresetSection(section, sec, params, onDirty, register) {
222
230
  box.type = "checkbox";
223
231
  box.checked = params[t.key] > 0;
224
232
  const lbl = el("span", "", t.label);
225
- attachInfo(lbl, t.description);
233
+ attachInfo(lbl, t.description, info);
226
234
  row.append(box, lbl);
227
235
  box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
228
236
  register(t.key, row);
@@ -234,7 +242,7 @@ function buildPresetSection(section, sec, params, onDirty, register) {
234
242
  if (advanced.length) {
235
243
  const { adv, toggle } = advancedBlock();
236
244
  for (const def of advanced) {
237
- const s = makeSlider(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); });
245
+ const s = makeSlider(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
238
246
  adv.append(s.wrap);
239
247
  syncs[def.key] = s.sync;
240
248
  register(def.key, s.wrap);
@@ -254,7 +262,7 @@ function buildPresetSection(section, sec, params, onDirty, register) {
254
262
  }
255
263
  }
256
264
 
257
- function buildFeatureSection(section, sec, params, onDirty, register) {
265
+ function buildFeatureSection(section, sec, params, onDirty, register, info) {
258
266
  // Everything lives under Advanced: each feature is a checkbox followed by its
259
267
  // own controls, which appear directly below it when the box is checked.
260
268
  const { adv, toggle } = advancedBlock();
@@ -266,14 +274,14 @@ function buildFeatureSection(section, sec, params, onDirty, register) {
266
274
  box.type = "checkbox";
267
275
  box.checked = params[feat.key] > 0;
268
276
  const featLabel = el("span", "", feat.label);
269
- attachInfo(featLabel, feat.description);
277
+ attachInfo(featLabel, feat.description, info);
270
278
  checkRow.append(box, featLabel);
271
279
  register(feat.key, checkRow);
272
280
 
273
281
  const group = el("div", "feat-group");
274
282
  const syncs = [];
275
283
  for (const def of feat.sliders.filter((d) => !d.hidden)) {
276
- const s = makeSlider(def, params, onDirty);
284
+ const s = makeSlider(def, params, onDirty, info);
277
285
  group.append(s.wrap);
278
286
  syncs.push(s.sync);
279
287
  register(def.key, s.wrap);
@@ -36,5 +36,6 @@ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
36
36
  `L2 ops: ${l2}\n` +
37
37
  `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
38
38
  },
39
+ detach: () => box.remove(),
39
40
  };
40
41
  }
@@ -12,5 +12,8 @@ export function createGeometryService({ createWorker, onMessage }) {
12
12
  // Post a job to the chosen backend's worker. The message's own `type` says what to
13
13
  // do (generate / export-stl / export-3mf / export-step); `backend` picks the worker
14
14
  // — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
15
- return { send: (msg, backend = "manifold") => workers[backend].postMessage(msg) };
15
+ return {
16
+ send: (msg, backend = "manifold") => workers[backend].postMessage(msg),
17
+ terminate: () => { workers.manifold.terminate(); workers.occt.terminate(); },
18
+ };
16
19
  }
@@ -14,7 +14,7 @@ import { createDebugOverlay } from "./debug-overlay.js";
14
14
  import { createRegenLoop } from "./regen-loop.js";
15
15
  import { createStatusUi } from "./status-ui.js";
16
16
  import { createViewTabs } from "./view-tabs.js";
17
- import { attachPickToggle, attachHoverLabels } from "./selection/index.js";
17
+ import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
18
18
  import { createPickRequestClient } from "./pick-request/index.js";
19
19
 
20
20
  // Mount a full parametric-part app from a PartDefinition. mount is WIRING: the
@@ -22,14 +22,43 @@ import { createPickRequestClient } from "./pick-request/index.js";
22
22
  // the schema-driven control panel, the regenerate state machine (regen-loop.js),
23
23
  // the view tabs (view-tabs.js), the status chrome (status-ui.js), the per-sub-part
24
24
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
25
- // so Vite can bundle the worker (see geometry-service.js). DOM element ids match the
26
- // host page: #app (viewer), #controls, #status, #download, #download-step, #busy,
27
- // #phase, #part (the view-tab segmented control).
28
- export function mount(part, { createWorker, container = document.getElementById("app"),
29
- controls = document.getElementById("controls") } = {}) {
30
- const viewer = createViewer(container, part);
31
- attachHoverLabels(viewer, { part }); // always-on hover inspection (no-op on touch-only devices)
32
- const ui = createStatusUi();
25
+ // so Vite can bundle the worker (see geometry-service.js).
26
+ //
27
+ // Embedding contract (0.12.0):
28
+ // const runtime = mount(part, { createWorker, elements, onBuild, onPick });
29
+ // await runtime.ready; // first successful build of the default view
30
+ // runtime.dispose(); // full teardown
31
+ // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
32
+ // exactly once here — submodules take element refs and never query the document.
33
+ // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
34
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
35
+ container: legacyContainer, controls: legacyControls } = {}) {
36
+ // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
37
+ const byId = (id) => document.getElementById(id);
38
+ const els = {
39
+ viewer: elements.viewer ?? legacyContainer ?? byId("app"),
40
+ controls: elements.controls ?? legacyControls ?? byId("controls"),
41
+ status: {
42
+ status: elements.status?.status ?? byId("status"),
43
+ busy: elements.status?.busy ?? byId("busy"),
44
+ phase: elements.status?.phase ?? byId("phase"),
45
+ },
46
+ tabs: elements.tabs ?? byId("part"),
47
+ exports: {
48
+ stl: elements.exports?.stl ?? byId("download"),
49
+ step: elements.exports?.step ?? byId("download-step"),
50
+ threeMf: elements.exports?.threeMf ?? byId("download-3mf"),
51
+ },
52
+ chrome: {
53
+ pause: elements.chrome?.pause ?? byId("pause"),
54
+ reframe: elements.chrome?.reframe ?? byId("reframe"),
55
+ theme: elements.chrome?.theme ?? byId("theme"),
56
+ },
57
+ };
58
+
59
+ const viewer = createViewer(els.viewer, part);
60
+ const hover = attachHoverLabels(viewer, { part }); // always-on hover inspection (no-op on touch-only devices)
61
+ const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
33
62
 
34
63
  // ?backend=occt|manifold forces the backend; otherwise it's detected per part.
35
64
  let forcedBackend = new URLSearchParams(location.search).get("backend");
@@ -45,20 +74,16 @@ export function mount(part, { createWorker, container = document.getElementById(
45
74
  ? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
46
75
  : null;
47
76
 
48
- const dlBtn = document.getElementById("download");
49
- const dlStepBtn = document.getElementById("download-step");
50
- const dl3mfBtn = document.getElementById("download-3mf");
51
-
52
77
  // View tabs (generated from part.views) + live params. A tab switch shows the
53
78
  // cached assembly instantly if it's current, else auto-builds what's missing.
54
- const tabs = createViewTabs(document.getElementById("part"), part, {
79
+ const tabsCtl = createViewTabs(els.tabs, part, {
55
80
  onChange: () => { refreshView(); updateRelevance(); loop.kick(); },
56
81
  });
57
- const view = () => tabs.current();
82
+ const view = () => tabsCtl.current();
58
83
  const params = { ...part.defaults };
59
84
 
60
85
  // Current selection context for the pickers: the active view + live params +
61
- // derived values. Shared by both ?pick modes below.
86
+ // derived values. Shared by every pick mode below.
62
87
  const getContext = () => {
63
88
  let derived = {};
64
89
  // A throwing derive must not crash the pick flow — proceed without derived context.
@@ -66,18 +91,30 @@ export function mount(part, { createWorker, container = document.getElementById(
66
91
  return { view: view(), params, derived };
67
92
  };
68
93
 
69
- // ?pick enables click-to-select: a toggle button + a transient toast. Off by
70
- // default — no button, no listener, no behavior change. Deleting this block and
71
- // the selection/ dir reverts the app exactly.
72
- if (qs.has("pick")) {
73
- attachPickToggle(viewer, { part, getContext });
94
+ // Click-to-select. Precedence (one click listener is ever live): the programmatic
95
+ // onPick option, else the ?pick clipboard toggle, else the ?pickserver client.
96
+ let picker = null; // { setActive, detach } armed permanently for onPick
97
+ let pickToggle = null; // { detach }
98
+ let pickClient = null; // { detach }
99
+ if (onPick) {
100
+ picker = attachPicker(viewer, {
101
+ part, getContext,
102
+ onPick: (selection) => onPick({
103
+ selection,
104
+ label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
105
+ prompt: formatSelection(selection, { style: "prompt" }),
106
+ token: formatSelection(selection, { style: "token" }),
107
+ }),
108
+ });
109
+ picker.setActive(true);
110
+ } else if (qs.has("pick")) {
111
+ pickToggle = attachPickToggle(viewer, { part, getContext });
74
112
  } else if (qs.has("pickserver")) {
75
113
  // Agent-driven mode: arm the picker only when the local pick-server asks for a
76
- // click. Mutually exclusive with the clipboard ?pick toggle (else-if), so only one
77
- // click listener is ever live. `?pickserver` or `?pickserver=http://host:port`.
114
+ // click. `?pickserver` or `?pickserver=http://host:port`.
78
115
  const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
79
116
  ? qs.get("pickserver") : "http://127.0.0.1:4518";
80
- createPickRequestClient({ serverUrl, viewer, part, getContext });
117
+ pickClient = createPickRequestClient({ serverUrl, viewer, part, getContext });
81
118
  }
82
119
 
83
120
  let framedView = null; // the view the camera was last framed to (null until first show)
@@ -106,6 +143,13 @@ export function mount(part, { createWorker, container = document.getElementById(
106
143
  },
107
144
  });
108
145
 
146
+ // First-build readiness: resolves on the first accepted meshes result, rejects on
147
+ // a first-build error. Guarded against unhandled rejection when never awaited.
148
+ let readySettled = false;
149
+ let resolveReady, rejectReady;
150
+ const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
151
+ ready.catch(() => {});
152
+
109
153
  // Reflect the active view. If every needed part is current, show it and enable
110
154
  // export. If stale (a regenerate is in flight), keep the old mesh visible so the
111
155
  // view doesn't flicker. If nothing's built yet, show nothing.
@@ -167,6 +211,8 @@ export function mount(part, { createWorker, container = document.getElementById(
167
211
  ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
168
212
  }
169
213
  dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
214
+ onBuild?.({ status: "success", ms: data.ms });
215
+ if (!readySettled) { readySettled = true; resolveReady(); }
170
216
  }
171
217
  loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
172
218
  break;
@@ -191,13 +237,15 @@ export function mount(part, { createWorker, container = document.getElementById(
191
237
  ui.hideBusy();
192
238
  ui.setStatus(`failed: ${data.message}`, true);
193
239
  refreshView();
240
+ onBuild?.({ status: "error", error: data.message });
241
+ if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
194
242
  break;
195
243
  }
196
244
  }
197
245
 
198
246
  const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
199
247
 
200
- const panel = buildControls(controls, part.parameters, params, onParamChange);
248
+ const panel = buildControls(els.controls, part.parameters, params, onParamChange);
201
249
  const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
202
250
  updateRelevance(); // initial view
203
251
 
@@ -215,21 +263,52 @@ export function mount(part, { createWorker, container = document.getElementById(
215
263
  loop.kick();
216
264
  }
217
265
 
218
- dlBtn.addEventListener("click", () => {
266
+ const onStlClick = () => {
219
267
  ui.showBusy("exporting STL");
220
268
  service.send({ type: "export-stl", view: view(), params, quality: "print" }, backendFor());
221
- });
269
+ };
270
+ els.exports.stl?.addEventListener("click", onStlClick);
222
271
 
223
- dlStepBtn.addEventListener("click", () => {
272
+ const onStepClick = () => {
224
273
  ui.showBusy("exporting STEP");
225
274
  service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
226
- });
275
+ };
276
+ els.exports.step?.addEventListener("click", onStepClick);
227
277
 
228
- dl3mfBtn?.addEventListener("click", () => {
278
+ const on3mfClick = () => {
229
279
  ui.showBusy("exporting 3MF");
230
280
  service.send({ type: "export-3mf", view: view(), params, quality: "print" }, backendFor());
231
- });
281
+ };
282
+ els.exports.threeMf?.addEventListener("click", on3mfClick);
283
+
284
+ // Optional host-page viewer chrome (pause / reframe / theme) + camera persistence.
285
+ const chrome = attachViewerControls(viewer, els.chrome);
286
+
287
+ // Full teardown of everything this mount created. Idempotent. A disposed runtime
288
+ // can never surface a late build result (workers are terminated, the loop is
289
+ // terminal), which is what makes cross-mount swap races safe for embedders.
290
+ let disposed = false;
291
+ function dispose() {
292
+ if (disposed) return;
293
+ disposed = true;
294
+ if (!readySettled) { readySettled = true; rejectReady(new Error("disposed before first build")); }
295
+ picker?.detach();
296
+ pickToggle?.detach();
297
+ pickClient?.detach();
298
+ hover.detach();
299
+ loop.dispose();
300
+ service.terminate();
301
+ els.exports.stl?.removeEventListener("click", onStlClick);
302
+ els.exports.step?.removeEventListener("click", onStepClick);
303
+ els.exports.threeMf?.removeEventListener("click", on3mfClick);
304
+ chrome.detach();
305
+ tabsCtl.detach();
306
+ panel.dispose();
307
+ dbg?.detach();
308
+ ui.hideBusy();
309
+ ui.setStatus("");
310
+ viewer.dispose();
311
+ }
232
312
 
233
- // Optional host-page viewer chrome (#pause / #reframe / #theme) + camera persistence.
234
- attachViewerControls(viewer);
313
+ return { ready, dispose };
235
314
  }
@@ -13,12 +13,13 @@
13
13
  export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
14
14
  let kernelReady = false;
15
15
  let generating = false;
16
+ let disposed = false;
16
17
  let paramsVersion = 0; // bumped on every settings edit
17
18
  let genVersion = -1; // the params version the in-flight build is building
18
19
  let timer = null;
19
20
 
20
21
  function kick() {
21
- if (!kernelReady || generating) return; // re-kicked when the current build finishes
22
+ if (disposed || !kernelReady || generating) return; // re-kicked when the current build finishes
22
23
  const missing = missingParts();
23
24
  if (missing.length === 0) return;
24
25
  generating = true;
@@ -28,7 +29,7 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
28
29
 
29
30
  return {
30
31
  kick,
31
- ready() { kernelReady = true; kick(); },
32
+ ready() { if (disposed) return; kernelReady = true; kick(); },
32
33
  markDirty() {
33
34
  paramsVersion++;
34
35
  clearTimeout(timer);
@@ -41,5 +42,7 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
41
42
  return genVersion === paramsVersion;
42
43
  },
43
44
  version: () => paramsVersion,
45
+ // Terminal: cancel the pending debounce and refuse all future sends.
46
+ dispose() { disposed = true; clearTimeout(timer); },
44
47
  };
45
48
  }
@@ -31,4 +31,13 @@ export function attachPickToggle(viewer, { part, getContext }) {
31
31
  });
32
32
 
33
33
  btn.addEventListener("click", () => picker.setActive(btn.classList.toggle("on")));
34
+
35
+ return {
36
+ detach: () => {
37
+ picker.detach();
38
+ clearTimeout(hideTimer);
39
+ btn.remove();
40
+ toast.remove();
41
+ },
42
+ };
34
43
  }
@@ -1,18 +1,15 @@
1
1
  // The status line, busy overlay, and export-button enabling — mount's host-page
2
- // chrome, as one small adapter. #status/#busy/#phase are required page elements;
3
- // export buttons are looked up by id and any that are absent are simply skipped.
4
- export function createStatusUi(doc = document) {
5
- const statusEl = doc.getElementById("status");
6
- const busyEl = doc.getElementById("busy");
7
- const phaseEl = doc.getElementById("phase");
8
- const exportBtns = ["download", "download-step", "download-3mf"]
9
- .map((id) => doc.getElementById(id)).filter(Boolean);
2
+ // chrome, as one small adapter. Element refs in (mount resolves defaults); no
3
+ // document queries here. status/busy/phase are required; export buttons are an
4
+ // array and any falsy entries are simply skipped.
5
+ export function createStatusUi({ status, busy, phase, exports = [] }) {
6
+ const exportBtns = exports.filter(Boolean);
10
7
 
11
8
  return {
12
- setStatus(msg, isErr = false) { statusEl.textContent = msg; statusEl.classList.toggle("err", isErr); },
13
- showBusy(phase) { phaseEl.textContent = `${phase}…`; busyEl.classList.add("show"); },
14
- hideBusy() { busyEl.classList.remove("show"); },
9
+ setStatus(msg, isErr = false) { status.textContent = msg; status.classList.toggle("err", isErr); },
10
+ showBusy(p) { phase.textContent = `${p}…`; busy.classList.add("show"); },
11
+ hideBusy() { busy.classList.remove("show"); },
15
12
  setExportEnabled(on) { exportBtns.forEach((b) => { b.disabled = !on; }); },
16
- statusText: () => statusEl.textContent,
13
+ statusText: () => status.textContent,
17
14
  };
18
15
  }
@@ -5,7 +5,8 @@ import { loadView, saveView } from "./view-state.js";
5
5
  // the #part div empty); a part without `views` keeps whatever buttons the page
6
6
  // hand-wrote. The active view persists across reloads via view-state.
7
7
  export function createViewTabs(el, part, { onChange }) {
8
- if (el && part.views) {
8
+ const generated = !!(el && part.views);
9
+ if (generated) {
9
10
  el.innerHTML = Object.entries(part.views)
10
11
  .map(([key, v], i) => `<button data-part="${key}"${i === 0 ? ' class="on"' : ""}>${v?.label ?? key}</button>`)
11
12
  .join("");
@@ -20,14 +21,21 @@ export function createViewTabs(el, part, { onChange }) {
20
21
  let view = savedBtn ? saved : defaultView;
21
22
  if (savedBtn) setActive(savedBtn);
22
23
 
23
- el.addEventListener("click", (e) => {
24
+ const onClick = (e) => {
24
25
  const btn = e.target.closest("button[data-part]");
25
26
  if (!btn) return;
26
27
  view = btn.dataset.part;
27
28
  saveView(view);
28
29
  setActive(btn);
29
30
  onChange(view);
30
- });
31
+ };
32
+ el.addEventListener("click", onClick);
31
33
 
32
- return { current: () => view };
34
+ return {
35
+ current: () => view,
36
+ detach: () => {
37
+ el.removeEventListener("click", onClick);
38
+ if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
39
+ },
40
+ };
33
41
  }
@@ -1,14 +1,10 @@
1
1
  import { loadRotating, saveRotating, saveCamera, loadTheme, saveTheme } from "./view-state.js";
2
2
 
3
- // Wire the optional viewer-chrome buttons on the host page (#pause / #reframe /
4
- // #theme) to the viewer, plus persist the camera pose. Each button is optional
5
- // omit it from the page and its behavior is simply absent. Self-contained: touches
6
- // only the viewer and the DOM, none of the part/params/regenerate state.
7
- export function attachViewerControls(viewer) {
8
- const pauseBtn = document.getElementById("pause");
9
- const reframeBtn = document.getElementById("reframe");
10
- const themeBtn = document.getElementById("theme");
11
-
3
+ // Wire the optional viewer-chrome buttons (pause / reframe / theme) to the viewer,
4
+ // plus persist the camera pose. Element refs in (mount resolves defaults); each
5
+ // button is optional pass nothing and its behavior is simply absent. Returns
6
+ // { detach } removing every listener this attached.
7
+ export function attachViewerControls(viewer, { pause: pauseBtn, reframe: reframeBtn, theme: themeBtn } = {}) {
12
8
  // Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
13
9
  // scene together; remember the choice across reloads.
14
10
  let theme = loadTheme();
@@ -20,7 +16,8 @@ export function attachViewerControls(viewer) {
20
16
  saveTheme(mode);
21
17
  }
22
18
  applyTheme(theme);
23
- themeBtn?.addEventListener("click", () => applyTheme(theme === "light" ? "dark" : "light"));
19
+ const onThemeClick = () => applyTheme(theme === "light" ? "dark" : "light");
20
+ themeBtn?.addEventListener("click", onThemeClick);
24
21
 
25
22
  // Pause/resume the idle auto-rotation.
26
23
  let rotating = loadRotating();
@@ -31,18 +28,32 @@ export function attachViewerControls(viewer) {
31
28
  pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
32
29
  };
33
30
  syncPause();
34
- pauseBtn?.addEventListener("click", () => {
31
+ const onPauseClick = () => {
35
32
  rotating = !rotating;
36
33
  viewer.setAutoRotate(rotating);
37
34
  syncPause();
38
35
  saveRotating(rotating);
39
- });
36
+ };
37
+ pauseBtn?.addEventListener("click", onPauseClick);
40
38
 
41
39
  // Re-fit the camera to the current view.
42
- reframeBtn?.addEventListener("click", () => viewer.frame());
40
+ const onReframeClick = () => viewer.frame();
41
+ reframeBtn?.addEventListener("click", onReframeClick);
43
42
 
44
43
  // Persist the camera when the user finishes an orbit/zoom, and right before a
45
44
  // reload (captures the latest pose, including auto-rotation drift).
46
45
  viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
47
- window.addEventListener("pagehide", () => saveCamera(viewer.getCameraState()));
46
+ const onPageHide = () => saveCamera(viewer.getCameraState());
47
+ window.addEventListener("pagehide", onPageHide);
48
+
49
+ return {
50
+ detach: () => {
51
+ themeBtn?.removeEventListener("click", onThemeClick);
52
+ pauseBtn?.removeEventListener("click", onPauseClick);
53
+ reframeBtn?.removeEventListener("click", onReframeClick);
54
+ window.removeEventListener("pagehide", onPageHide);
55
+ // the onCameraEnd listener lives on the OrbitControls object, which
56
+ // viewer.dispose() destroys — nothing to remove here
57
+ },
58
+ };
48
59
  }
@@ -87,7 +87,7 @@ export function createViewer(container, part) {
87
87
  // CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
88
88
  const EDGE_ANGLE = 35; // deg — OCCT fallback threshold (Manifold supplies seam-aware edges)
89
89
  const lineMaterial = new LineMaterial({ color: 0x1c232d, linewidth: 1.0 }); // ~10% lighter, 1 px
90
- lineMaterial.resolution.set(innerWidth, innerHeight);
90
+ lineMaterial.resolution.set(1, 1); // real size set by resize() below
91
91
  const subLines = Object.fromEntries(
92
92
  names.map((n) => [n, new LineSegments2(new LineSegmentsGeometry(), lineMaterial)])
93
93
  );
@@ -204,14 +204,16 @@ export function createViewer(container, part) {
204
204
  }
205
205
 
206
206
  // --- resize ---------------------------------------------------------------
207
+ // Size from the host container (not the window) so embedders control the pane.
207
208
  function resize() {
208
- const w = innerWidth, h = innerHeight;
209
+ const w = container.clientWidth || 300, h = container.clientHeight || 150;
209
210
  renderer.setSize(w, h);
210
211
  camera.aspect = w / h;
211
212
  camera.updateProjectionMatrix();
212
213
  lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
213
214
  }
214
- addEventListener("resize", resize);
215
+ const ro = new ResizeObserver(resize);
216
+ ro.observe(container);
215
217
  resize();
216
218
 
217
219
  // --- render loop ----------------------------------------------------------
@@ -235,6 +237,7 @@ export function createViewer(container, part) {
235
237
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
236
238
 
237
239
  // Transient marker at a world-space point — visual confirmation of a pick.
240
+ const flashTimers = new Set();
238
241
  function flashPoint(world) {
239
242
  const dot = new THREE.Mesh(
240
243
  new THREE.SphereGeometry(1.2, 16, 12),
@@ -243,8 +246,38 @@ export function createViewer(container, part) {
243
246
  dot.renderOrder = 999;
244
247
  dot.position.set(world[0], world[1], world[2]);
245
248
  scene.add(dot);
246
- setTimeout(() => { scene.remove(dot); dot.geometry.dispose(); dot.material.dispose(); }, 1200);
249
+ const t = setTimeout(() => {
250
+ flashTimers.delete(t);
251
+ scene.remove(dot); dot.geometry.dispose(); dot.material.dispose();
252
+ }, 1200);
253
+ flashTimers.add(t);
247
254
  }
248
255
 
249
- return { showAssembly, hideAssembly, setSubGeometry, hasSubMesh, subTriangles, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint };
256
+ // Full teardown: render loop, observers, controls, timers, GPU resources, DOM.
257
+ // Idempotent. Cached sub-part geometries and their edge lines are freed; the
258
+ // shared and per-part cloned materials tolerate double-dispose.
259
+ let disposed = false;
260
+ function dispose() {
261
+ if (disposed) return;
262
+ disposed = true;
263
+ ro.disconnect();
264
+ renderer.setAnimationLoop(null);
265
+ controls.dispose();
266
+ for (const t of flashTimers) clearTimeout(t);
267
+ flashTimers.clear();
268
+ for (const n of names) {
269
+ const g = subCache[n];
270
+ if (g) { g.userData.edges?.dispose(); g.dispose(); subCache[n] = null; }
271
+ subMesh[n].material?.dispose();
272
+ subMesh[n].geometry?.dispose(); // the initial empty BufferGeometry, if never replaced
273
+ }
274
+ material.dispose();
275
+ lineMaterial.dispose();
276
+ grid.geometry.dispose();
277
+ grid.material.dispose();
278
+ renderer.dispose();
279
+ renderer.domElement.remove();
280
+ }
281
+
282
+ return { showAssembly, hideAssembly, setSubGeometry, hasSubMesh, subTriangles, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint, dispose };
250
283
  }