partforge 0.10.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 +34 -0
- package/package.json +3 -2
- package/src/framework/app.css +1 -15
- package/src/framework/controls.js +59 -51
- package/src/framework/debug-overlay.js +1 -0
- package/src/framework/geometry-service.js +4 -1
- package/src/framework/mount.js +112 -33
- package/src/framework/regen-loop.js +5 -2
- package/src/framework/selection/pick-toggle.js +9 -0
- package/src/framework/status-ui.js +9 -12
- package/src/framework/tokens.css +18 -0
- package/src/framework/view-tabs.js +12 -4
- package/src/framework/viewer-controls.js +25 -14
- package/src/framework/viewer.js +38 -5
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.
|
|
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",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"./worker": "./src/framework/worker.js",
|
|
27
27
|
"./geometry": "./src/framework/geometry/polygon.js",
|
|
28
28
|
"./derive": "./src/framework/derive.js",
|
|
29
|
-
"./testing": "./src/testing.js"
|
|
29
|
+
"./testing": "./src/testing.js",
|
|
30
|
+
"./tokens.css": "./src/framework/tokens.css"
|
|
30
31
|
},
|
|
31
32
|
"bin": {
|
|
32
33
|
"partforge": "./bin/cli.js"
|
package/src/framework/app.css
CHANGED
|
@@ -2,22 +2,8 @@
|
|
|
2
2
|
busy overlay) + the light/dark palettes. Imported by framework/mount.js, so
|
|
3
3
|
every part-app gets it; each part's HTML only carries structural markup.
|
|
4
4
|
See docs/AUTHORING-PARTS.md. */
|
|
5
|
+
@import "./tokens.css"; /* palette + light overrides; also exported standalone as partforge/tokens.css */
|
|
5
6
|
|
|
6
|
-
:root {
|
|
7
|
-
color-scheme: dark;
|
|
8
|
-
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
9
|
-
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
10
|
-
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
11
|
-
--accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
12
|
-
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
13
|
-
}
|
|
14
|
-
:root[data-theme="light"] {
|
|
15
|
-
color-scheme: light;
|
|
16
|
-
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
17
|
-
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
18
|
-
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
19
|
-
--accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
20
|
-
}
|
|
21
7
|
* { box-sizing: border-box; }
|
|
22
8
|
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
23
9
|
font: 13px/1.4 -apple-system, system-ui, sans-serif; }
|
|
@@ -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 +
|
|
51
|
-
// One popover element,
|
|
52
|
-
// dismiss listeners are registered
|
|
53
|
-
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
document.addEventListener("keydown",
|
|
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
|
|
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 {
|
|
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);
|
|
@@ -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 {
|
|
15
|
+
return {
|
|
16
|
+
send: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
17
|
+
terminate: () => { workers.manifold.terminate(); workers.occt.terminate(); },
|
|
18
|
+
};
|
|
16
19
|
}
|
package/src/framework/mount.js
CHANGED
|
@@ -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).
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
79
|
+
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
55
80
|
onChange: () => { refreshView(); updateRelevance(); loop.kick(); },
|
|
56
81
|
});
|
|
57
|
-
const view = () =>
|
|
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
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
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) {
|
|
13
|
-
showBusy(
|
|
14
|
-
hideBusy() {
|
|
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: () =>
|
|
13
|
+
statusText: () => status.textContent,
|
|
17
14
|
};
|
|
18
15
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* partforge design tokens — the light/dark palette. Imported by app.css (so every
|
|
2
|
+
part-app gets it via mount) and exported as "partforge/tokens.css" for other
|
|
3
|
+
consumers (e.g. partforge-cloud's app chrome) to share one source of truth. */
|
|
4
|
+
:root {
|
|
5
|
+
color-scheme: dark;
|
|
6
|
+
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
7
|
+
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
8
|
+
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
9
|
+
--accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
10
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
11
|
+
}
|
|
12
|
+
:root[data-theme="light"] {
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
15
|
+
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
16
|
+
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
17
|
+
--accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
18
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
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(() => {
|
|
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
|
-
|
|
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
|
}
|