partforge 0.19.0 → 0.20.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.
Files changed (37) hide show
  1. package/docs/AUTHORING-PARTS.md +97 -1
  2. package/docs/ERROR-PATTERNS.md +6 -0
  3. package/package.json +3 -1
  4. package/src/app-bracket.js +9 -0
  5. package/src/app-nameplate.js +9 -0
  6. package/src/app-text-smoke.js +10 -0
  7. package/src/bracket-worker.js +3 -0
  8. package/src/framework/app.css +23 -6
  9. package/src/framework/cutaway-controls.js +155 -0
  10. package/src/framework/cutaway-gizmo.js +686 -0
  11. package/src/framework/cutaway-math.js +53 -0
  12. package/src/framework/cutaway-render.js +338 -0
  13. package/src/framework/cutaway.js +469 -0
  14. package/src/framework/fonts.js +36 -0
  15. package/src/framework/geometry/curve-fill.js +86 -0
  16. package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
  17. package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
  18. package/src/framework/geometry/fonts/default-font.js +3 -0
  19. package/src/framework/geometry/kernel-front.js +53 -0
  20. package/src/framework/geometry/kernel.js +1 -1
  21. package/src/framework/geometry/text2d.js +98 -0
  22. package/src/framework/geometry-service.js +21 -2
  23. package/src/framework/jobs.js +9 -0
  24. package/src/framework/mount.js +278 -223
  25. package/src/framework/selection/hover.js +102 -36
  26. package/src/framework/selection/raycast.js +4 -1
  27. package/src/framework/tooltip.js +282 -0
  28. package/src/framework/viewer-controls.js +25 -2
  29. package/src/framework/viewer-lighting.js +13 -0
  30. package/src/framework/viewer.js +83 -10
  31. package/src/nameplate-worker.js +3 -0
  32. package/src/parts/bracket.js +76 -0
  33. package/src/parts/nameplate.js +67 -0
  34. package/src/parts/text-smoke.js +21 -0
  35. package/src/testing/manifold.js +6 -2
  36. package/src/testing/occt.js +6 -2
  37. package/src/text-smoke-worker.js +3 -0
@@ -2,6 +2,8 @@ import "./app.css"; // shared chrome styles — every part-app gets them via mou
2
2
  import { triggerDownload, downloadParts } from "./download.js";
3
3
  import { createViewer } from "./viewer.js";
4
4
  import { attachViewerControls } from "./viewer-controls.js";
5
+ import { attachCutawayControls } from "./cutaway-controls.js";
6
+ import { createTooltipPresenter } from "./tooltip.js";
5
7
  import { loadCamera } from "./view-state.js";
6
8
  import { buildControls } from "./controls.js";
7
9
  import { relevantParamKeys } from "./param-deps.js";
@@ -17,6 +19,28 @@ import { createViewTabs } from "./view-tabs.js";
17
19
  import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
18
20
  import { createPickRequestClient } from "./pick-request/index.js";
19
21
 
22
+ function createCleanupStack() {
23
+ const cleanups = [];
24
+ let disposed = false;
25
+ return {
26
+ defer(cleanup) {
27
+ cleanups.push(cleanup);
28
+ },
29
+ dispose() {
30
+ if (disposed) return;
31
+ disposed = true;
32
+ const errors = [];
33
+ while (cleanups.length) {
34
+ try { cleanups.pop()(); } catch (error) { errors.push(error); }
35
+ }
36
+ if (errors.length === 1) throw errors[0];
37
+ if (errors.length > 1) {
38
+ throw new AggregateError(errors, "partforge mount cleanup failed");
39
+ }
40
+ },
41
+ };
42
+ }
43
+
20
44
  // Mount a full parametric-part app from a PartDefinition. mount is WIRING: the
21
45
  // pieces it composes each live (and are tested) in their own module — the viewer,
22
46
  // the schema-driven control panel, the regenerate state machine (regen-loop.js),
@@ -53,262 +77,293 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
53
77
  pause: elements.chrome?.pause ?? byId("pause"),
54
78
  reframe: elements.chrome?.reframe ?? byId("reframe"),
55
79
  theme: elements.chrome?.theme ?? byId("theme"),
80
+ cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
56
81
  },
57
82
  };
58
83
 
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] });
84
+ const cleanup = createCleanupStack();
85
+ try {
86
+ const viewer = createViewer(els.viewer, part);
87
+ cleanup.defer(() => viewer.dispose());
88
+ const tooltip = createTooltipPresenter({ id: null });
89
+ cleanup.defer(() => tooltip.dispose());
90
+ const cutawayChrome = attachCutawayControls(viewer, {
91
+ cutaway: els.chrome.cutaway,
92
+ }, { tooltip });
93
+ cleanup.defer(() => cutawayChrome.detach());
94
+ const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
95
+ cleanup.defer(() => hover.detach());
96
+ const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
97
+ cleanup.defer(() => ui.setStatus(""));
98
+ cleanup.defer(() => ui.hideBusy());
62
99
 
63
- // ?backend=occt|manifold forces the backend; otherwise it's detected per part.
64
- let forcedBackend = new URLSearchParams(location.search).get("backend");
65
- if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
66
- const backendFor = () => forcedBackend ?? detectBackend(part, params);
100
+ // ?backend=occt|manifold forces the backend; otherwise it's detected per part.
101
+ let forcedBackend = new URLSearchParams(location.search).get("backend");
102
+ if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
103
+ const backendFor = () => forcedBackend ?? detectBackend(part, params);
67
104
 
68
- // ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
69
- const qs = new URLSearchParams(location.search);
70
- const debug = qs.has("debug");
71
- let cachingOn = !(debug && qs.has("nocache"));
72
- let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
73
- const dbg = debug
74
- ? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
75
- : null;
105
+ // ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
106
+ const qs = new URLSearchParams(location.search);
107
+ const debug = qs.has("debug");
108
+ let cachingOn = !(debug && qs.has("nocache"));
109
+ let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
110
+ const dbg = debug
111
+ ? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
112
+ : null;
113
+ if (dbg) cleanup.defer(() => dbg.detach());
76
114
 
77
- // View tabs (generated from part.views) + live params. A tab switch shows the
78
- // cached assembly instantly if it's current, else auto-builds what's missing.
79
- const tabsCtl = createViewTabs(els.tabs, part, {
80
- onChange: () => { refreshView(); updateRelevance(); loop.kick(); },
81
- });
82
- const view = () => tabsCtl.current();
83
- const params = { ...part.defaults };
115
+ // View tabs (generated from part.views) + live params. A tab switch shows the
116
+ // cached assembly instantly if it's current, else auto-builds what's missing.
117
+ const tabsCtl = createViewTabs(els.tabs, part, {
118
+ onChange: () => { cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
119
+ });
120
+ cleanup.defer(() => tabsCtl.detach());
121
+ const view = () => tabsCtl.current();
122
+ const params = { ...part.defaults };
84
123
 
85
- // Current selection context for the pickers: the active view + live params +
86
- // derived values. Shared by every pick mode below.
87
- const getContext = () => {
88
- let derived = {};
89
- // A throwing derive must not crash the pick flow — proceed without derived context.
90
- try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
91
- return { view: view(), params, derived };
92
- };
124
+ // Current selection context for the pickers: the active view + live params +
125
+ // derived values. Shared by every pick mode below.
126
+ const getContext = () => {
127
+ let derived = {};
128
+ // A throwing derive must not crash the pick flow — proceed without derived context.
129
+ try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
130
+ return { view: view(), params, derived };
131
+ };
93
132
 
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 });
112
- } else if (qs.has("pickserver")) {
113
- // Agent-driven mode: arm the picker only when the local pick-server asks for a
114
- // click. `?pickserver` or `?pickserver=http://host:port`.
115
- const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
116
- ? qs.get("pickserver") : "http://127.0.0.1:4518";
117
- pickClient = createPickRequestClient({ serverUrl, viewer, part, getContext });
118
- }
133
+ // Click-to-select. Precedence (one click listener is ever live): the programmatic
134
+ // onPick option, else the ?pick clipboard toggle, else the ?pickserver client.
135
+ let picker = null; // { setActive, detach } — armed permanently for onPick
136
+ let pickToggle = null; // { detach }
137
+ let pickClient = null; // { detach }
138
+ if (onPick) {
139
+ picker = attachPicker(viewer, {
140
+ part, getContext,
141
+ onPick: (selection) => onPick({
142
+ selection,
143
+ label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
144
+ prompt: formatSelection(selection, { style: "prompt" }),
145
+ token: formatSelection(selection, { style: "token" }),
146
+ }),
147
+ });
148
+ cleanup.defer(() => picker.detach());
149
+ picker.setActive(true);
150
+ } else if (qs.has("pick")) {
151
+ pickToggle = attachPickToggle(viewer, { part, getContext });
152
+ cleanup.defer(() => pickToggle.detach());
153
+ } else if (qs.has("pickserver")) {
154
+ // Agent-driven mode: arm the picker only when the local pick-server asks for a
155
+ // click. `?pickserver` or `?pickserver=http://host:port`.
156
+ const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
157
+ ? qs.get("pickserver") : "http://127.0.0.1:4518";
158
+ pickClient = createPickRequestClient({ serverUrl, viewer, part, getContext });
159
+ cleanup.defer(() => pickClient.detach());
160
+ }
119
161
 
120
- let framedView = null; // the view the camera was last framed to (null until first show)
121
- let cameraRestored = false; // saved camera applied once, on the first frame after load
162
+ let framedView = null; // the view the camera was last framed to (null until first show)
163
+ let cameraRestored = false; // saved camera applied once, on the first frame after load
122
164
 
123
- // Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
124
- // time, so they're passed as getters; params is a stable in-place-mutated object.
125
- const cache = createMeshCache(part, viewer, {
126
- params,
127
- getView: view,
128
- getParamsVersion: () => loop.version(),
129
- isCaching: () => cachingOn,
130
- });
131
- const isCurrent = cache.isCurrent;
132
- const missingParts = () => viewSubParts(part, view(), params).filter((n) => !isCurrent(n));
165
+ // Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
166
+ // time, so they're passed as getters; params is a stable in-place-mutated object.
167
+ const cache = createMeshCache(part, viewer, {
168
+ params,
169
+ getView: view,
170
+ getParamsVersion: () => loop.version(),
171
+ isCaching: () => cachingOn,
172
+ });
173
+ const isCurrent = cache.isCurrent;
174
+ const missingParts = () => viewSubParts(part, view(), params).filter((n) => !isCurrent(n));
133
175
 
134
- // The regenerate state machine (ready gating / debounce / stale-redo) lives in
135
- // regen-loop.js; this send callback is the one place a build job is dispatched.
136
- const loop = createRegenLoop({
137
- missingParts,
138
- send: (missing) => {
139
- const needed = viewSubParts(part, view(), params);
140
- lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
141
- ui.showBusy("generating");
142
- service.send({ type: "generate", subparts: missing, view: view(), params, cache: cachingOn }, backendFor());
143
- },
144
- });
176
+ // The regenerate state machine (ready gating / debounce / stale-redo) lives in
177
+ // regen-loop.js; this send callback is the one place a build job is dispatched.
178
+ const loop = createRegenLoop({
179
+ missingParts,
180
+ send: (missing) => {
181
+ const needed = viewSubParts(part, view(), params);
182
+ lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
183
+ ui.showBusy("generating");
184
+ service.send({ type: "generate", subparts: missing, view: view(), params, cache: cachingOn }, backendFor());
185
+ },
186
+ });
187
+ cleanup.defer(() => loop.dispose());
145
188
 
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(() => {});
189
+ // First-build readiness: resolves on the first accepted meshes result, rejects on
190
+ // a first-build error. Guarded against unhandled rejection when never awaited.
191
+ let readySettled = false;
192
+ let resolveReady, rejectReady;
193
+ const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
194
+ ready.catch(() => {});
195
+ cleanup.defer(() => {
196
+ if (readySettled) return;
197
+ readySettled = true;
198
+ rejectReady(new Error("disposed before first build"));
199
+ });
152
200
 
153
- // Reflect the active view. If every needed part is current, show it and enable
154
- // export. If stale (a regenerate is in flight), keep the old mesh visible so the
155
- // view doesn't flicker. If nothing's built yet, show nothing.
156
- // Show the assembly, framing the camera only the first time we show a given view
157
- // (initial load / tab switch) — never on a regenerate, so zoom/orbit are kept.
158
- function showView(needed) {
159
- const frame = view() !== framedView;
160
- viewer.showAssembly(needed, { frame });
161
- if (frame) {
162
- framedView = view();
163
- if (!cameraRestored) {
164
- const cam = loadCamera();
165
- if (cam) viewer.setCameraState(cam);
166
- cameraRestored = true;
201
+ // Reflect the active view. If every needed part is current, show it and enable
202
+ // export. If stale (a regenerate is in flight), keep the old mesh visible so the
203
+ // view doesn't flicker. If nothing's built yet, show nothing.
204
+ // Show the assembly, framing the camera only the first time we show a given view
205
+ // (initial load / tab switch) — never on a regenerate, so zoom/orbit are kept.
206
+ function showView(needed) {
207
+ const frame = view() !== framedView;
208
+ viewer.showAssembly(needed, { frame });
209
+ if (frame) {
210
+ framedView = view();
211
+ if (!cameraRestored) {
212
+ const cam = loadCamera();
213
+ if (cam) viewer.setCameraState(cam);
214
+ cameraRestored = true;
215
+ }
167
216
  }
168
217
  }
169
- }
170
218
 
171
- function refreshView() {
172
- const needed = viewSubParts(part, view(), params);
173
- if (needed.every(isCurrent)) {
174
- showView(needed);
175
- ui.setExportEnabled(true);
176
- const tris = needed.reduce((s, n) => s + viewer.subTriangles(n), 0);
177
- ui.setStatus(`${tris.toLocaleString()} triangles`);
178
- } else if (needed.every((n) => viewer.hasSubMesh(n))) {
179
- showView(needed); // stale but present — keep it visible during regenerate
180
- ui.setExportEnabled(false);
181
- } else {
182
- viewer.hideAssembly();
183
- ui.setExportEnabled(false);
219
+ function refreshView() {
220
+ const needed = viewSubParts(part, view(), params);
221
+ if (needed.every(isCurrent)) {
222
+ showView(needed);
223
+ ui.setExportEnabled(true);
224
+ const tris = needed.reduce((s, n) => s + viewer.subTriangles(n), 0);
225
+ ui.setStatus(`${tris.toLocaleString()} triangles`);
226
+ } else if (needed.every((n) => viewer.hasSubMesh(n))) {
227
+ showView(needed); // stale but present — keep it visible during regenerate
228
+ ui.setExportEnabled(false);
229
+ } else {
230
+ viewer.hideAssembly();
231
+ ui.setExportEnabled(false);
232
+ }
184
233
  }
185
- }
186
234
 
187
- ui.showBusy("booting kernel"); // visible from first paint until the kernel is ready
235
+ ui.showBusy("booting kernel"); // visible from first paint until the kernel is ready
188
236
 
189
- // Bundle filename for a multi-part export (single parts download under their own name).
190
- const zipName = `${part.meta?.title ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-");
237
+ // Bundle filename for a multi-part export (single parts download under their own name).
238
+ const zipName = `${part.meta?.title ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-");
191
239
 
192
- // --- shared message handler ------------------------------------------------
193
- function onWorkerMessage({ data }) {
194
- switch (data.type) {
195
- case "ready":
196
- loop.ready(); // auto-build the default view (keeps the busy spinner up)
197
- break;
198
- case "progress":
199
- ui.showBusy(data.phase);
200
- ui.setStatus(`${data.phase}…`);
201
- break;
202
- case "meshes": {
203
- if (loop.buildDone()) { // stale results (params changed mid-build) are discarded
204
- for (const m of data.meshes) {
205
- viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
206
- cache.record(m.name);
240
+ // --- shared message handler ------------------------------------------------
241
+ function onWorkerMessage({ data }) {
242
+ switch (data.type) {
243
+ case "ready":
244
+ loop.ready(); // auto-build the default view (keeps the busy spinner up)
245
+ break;
246
+ case "progress":
247
+ ui.showBusy(data.phase);
248
+ ui.setStatus(`${data.phase}…`);
249
+ break;
250
+ case "meshes": {
251
+ if (loop.buildDone()) { // stale results (params changed mid-build) are discarded
252
+ for (const m of data.meshes) {
253
+ viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
254
+ cache.record(m.name);
255
+ }
256
+ ui.hideBusy();
257
+ refreshView();
258
+ if (data.ms && missingParts().length === 0) {
259
+ ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
260
+ }
261
+ dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
262
+ onBuild?.({ status: "success", ms: data.ms });
263
+ if (!readySettled) { readySettled = true; resolveReady(); }
207
264
  }
265
+ loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
266
+ break;
267
+ }
268
+ case "download-parts":
269
+ ui.hideBusy();
270
+ downloadParts(data, zipName);
271
+ ui.setStatus(`${data.parts.length} part(s) downloaded`);
272
+ break;
273
+ case "download":
208
274
  ui.hideBusy();
275
+ triggerDownload(data.data, data.filename, data.mime);
276
+ ui.setStatus(`${data.filename} downloaded`);
277
+ break;
278
+ case "needs-occt":
279
+ forcedBackend = "occt"; // probe missed; this part needs OCCT — stick to it
280
+ loop.buildDone();
281
+ loop.kick();
282
+ break;
283
+ case "error":
284
+ loop.buildDone();
285
+ ui.hideBusy();
286
+ ui.setStatus(`failed: ${data.message}`, true);
209
287
  refreshView();
210
- if (data.ms && missingParts().length === 0) {
211
- ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
212
- }
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(); }
216
- }
217
- loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
218
- break;
288
+ onBuild?.({ status: "error", error: data.message });
289
+ if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
290
+ break;
219
291
  }
220
- case "download-parts":
221
- ui.hideBusy();
222
- downloadParts(data, zipName);
223
- ui.setStatus(`${data.parts.length} part(s) downloaded`);
224
- break;
225
- case "download":
226
- ui.hideBusy();
227
- triggerDownload(data.data, data.filename, data.mime);
228
- ui.setStatus(`${data.filename} downloaded`);
229
- break;
230
- case "needs-occt":
231
- forcedBackend = "occt"; // probe missed; this part needs OCCT — stick to it
232
- loop.buildDone();
233
- loop.kick();
234
- break;
235
- case "error":
236
- loop.buildDone();
237
- ui.hideBusy();
238
- ui.setStatus(`failed: ${data.message}`, true);
239
- refreshView();
240
- onBuild?.({ status: "error", error: data.message });
241
- if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
242
- break;
243
292
  }
244
- }
245
293
 
246
- const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
294
+ const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
295
+ cleanup.defer(() => service.terminate());
247
296
 
248
- const panel = buildControls(els.controls, part.parameters, params, onParamChange);
249
- const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
250
- updateRelevance(); // initial view
297
+ const panel = buildControls(els.controls, part.parameters, params, onParamChange);
298
+ cleanup.defer(() => panel.dispose());
299
+ const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
300
+ updateRelevance(); // initial view
251
301
 
252
- function onParamChange() {
253
- loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
254
- refreshView(); // keep showing the now-stale mesh (no flicker); disable export
255
- updateRelevance();
256
- }
302
+ function onParamChange() {
303
+ loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
304
+ refreshView(); // keep showing the now-stale mesh (no flicker); disable export
305
+ updateRelevance();
306
+ }
257
307
 
258
- // Re-run the active view under the current caching setting, so toggling the
259
- // ?debug switch updates the readout for the same design without a param change.
260
- function forceRegen() {
261
- for (const n of viewSubParts(part, view(), params)) cache.forget(n);
262
- refreshView();
263
- loop.kick();
264
- }
308
+ // Re-run the active view under the current caching setting, so toggling the
309
+ // ?debug switch updates the readout for the same design without a param change.
310
+ function forceRegen() {
311
+ for (const n of viewSubParts(part, view(), params)) cache.forget(n);
312
+ refreshView();
313
+ loop.kick();
314
+ }
265
315
 
266
- const onStlClick = () => {
267
- ui.showBusy("exporting STL");
268
- service.send({ type: "export-stl", view: view(), params, quality: "print" }, backendFor());
269
- };
270
- els.exports.stl?.addEventListener("click", onStlClick);
316
+ const onStlClick = () => {
317
+ ui.showBusy("exporting STL");
318
+ service.send({ type: "export-stl", view: view(), params, quality: "print" }, backendFor());
319
+ };
320
+ if (els.exports.stl) {
321
+ els.exports.stl.addEventListener("click", onStlClick);
322
+ cleanup.defer(() => els.exports.stl.removeEventListener("click", onStlClick));
323
+ }
271
324
 
272
- const onStepClick = () => {
273
- ui.showBusy("exporting STEP");
274
- service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
275
- };
276
- els.exports.step?.addEventListener("click", onStepClick);
325
+ const onStepClick = () => {
326
+ ui.showBusy("exporting STEP");
327
+ service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
328
+ };
329
+ if (els.exports.step) {
330
+ els.exports.step.addEventListener("click", onStepClick);
331
+ cleanup.defer(() => els.exports.step.removeEventListener("click", onStepClick));
332
+ }
277
333
 
278
- const on3mfClick = () => {
279
- ui.showBusy("exporting 3MF");
280
- service.send({ type: "export-3mf", view: view(), params, quality: "print" }, backendFor());
281
- };
282
- els.exports.threeMf?.addEventListener("click", on3mfClick);
334
+ const on3mfClick = () => {
335
+ ui.showBusy("exporting 3MF");
336
+ service.send({ type: "export-3mf", view: view(), params, quality: "print" }, backendFor());
337
+ };
338
+ if (els.exports.threeMf) {
339
+ els.exports.threeMf.addEventListener("click", on3mfClick);
340
+ cleanup.defer(() => els.exports.threeMf.removeEventListener("click", on3mfClick));
341
+ }
283
342
 
284
- // Optional host-page viewer chrome (pause / reframe / theme) + camera persistence.
285
- const chrome = attachViewerControls(viewer, els.chrome);
343
+ // Optional host-page viewer chrome (pause / reframe / theme) + camera persistence.
344
+ const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
345
+ cleanup.defer(() => chrome.detach());
286
346
 
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
- }
347
+ // Full teardown of everything this mount created. Idempotent. A disposed runtime
348
+ // can never surface a late build result (workers are terminated, the loop is
349
+ // terminal), which is what makes cross-mount swap races safe for embedders.
350
+ function dispose() {
351
+ cleanup.dispose();
352
+ }
312
353
 
313
- return { ready, dispose };
354
+ return { ready, dispose };
355
+ } catch (error) {
356
+ try {
357
+ cleanup.dispose();
358
+ } catch (cleanupError) {
359
+ const cleanupErrors = cleanupError instanceof AggregateError
360
+ ? cleanupError.errors
361
+ : [cleanupError];
362
+ throw new AggregateError(
363
+ [error, ...cleanupErrors],
364
+ "partforge mount construction and cleanup failed",
365
+ );
366
+ }
367
+ throw error;
368
+ }
314
369
  }