partforge 0.6.0 → 0.7.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 +63 -14
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +48 -23
- package/package.json +1 -1
- package/src/app-planter.js +10 -0
- package/src/framework/app.css +94 -29
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/kernel-front.js +31 -0
- package/src/framework/geometry/kernel.js +53 -10
- package/src/framework/geometry/manifold-backend.js +48 -25
- package/src/framework/geometry/occt-backend.js +47 -96
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +120 -0
- package/src/planter-worker.js +3 -0
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
package/src/framework/mount.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import "./app.css"; // shared chrome styles — every part-app gets them via mount
|
|
2
|
-
import {
|
|
2
|
+
import { triggerDownload, downloadParts } from "./download.js";
|
|
3
3
|
import { createViewer } from "./viewer.js";
|
|
4
|
-
import {
|
|
4
|
+
import { attachViewerControls } from "./viewer-controls.js";
|
|
5
|
+
import { loadCamera } from "./view-state.js";
|
|
5
6
|
import { buildControls } from "./controls.js";
|
|
6
|
-
import { relevantParamKeys
|
|
7
|
+
import { relevantParamKeys } from "./param-deps.js";
|
|
8
|
+
import { createMeshCache } from "./mesh-cache.js";
|
|
7
9
|
import { createGeometryService } from "./geometry-service.js";
|
|
8
10
|
import { viewSubParts } from "./jobs.js";
|
|
9
11
|
import { detectBackend } from "./geometry/probe.js";
|
|
10
12
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
11
|
-
import {
|
|
13
|
+
import { createRegenLoop } from "./regen-loop.js";
|
|
14
|
+
import { createStatusUi } from "./status-ui.js";
|
|
15
|
+
import { createViewTabs } from "./view-tabs.js";
|
|
16
|
+
import { attachPickToggle, attachHoverLabels } from "./selection/index.js";
|
|
12
17
|
import { createPickRequestClient } from "./pick-request/index.js";
|
|
13
18
|
|
|
14
|
-
// Mount a full parametric-part app from a PartDefinition
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// Mount a full parametric-part app from a PartDefinition. mount is WIRING: the
|
|
20
|
+
// pieces it composes each live (and are tested) in their own module — the viewer,
|
|
21
|
+
// the schema-driven control panel, the regenerate state machine (regen-loop.js),
|
|
22
|
+
// the view tabs (view-tabs.js), the status chrome (status-ui.js), the per-sub-part
|
|
23
|
+
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
24
|
+
// so Vite can bundle the worker (see geometry-service.js). DOM element ids match the
|
|
25
|
+
// host page: #app (viewer), #controls, #status, #download, #download-step, #busy,
|
|
26
|
+
// #phase, #part (the view-tab segmented control).
|
|
20
27
|
export function mount(part, { createWorker, container = document.getElementById("app"),
|
|
21
28
|
controls = document.getElementById("controls") } = {}) {
|
|
22
|
-
const names = Object.keys(part.parts);
|
|
23
29
|
const viewer = createViewer(container, part);
|
|
30
|
+
attachHoverLabels(viewer, { part }); // always-on hover inspection (no-op on touch-only devices)
|
|
31
|
+
const ui = createStatusUi();
|
|
24
32
|
|
|
25
33
|
// ?backend=occt|manifold forces the backend; otherwise it's detected per part.
|
|
26
34
|
let forcedBackend = new URLSearchParams(location.search).get("backend");
|
|
@@ -36,121 +44,61 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
36
44
|
? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
|
|
37
45
|
: null;
|
|
38
46
|
|
|
39
|
-
const statusEl = document.getElementById("status");
|
|
40
47
|
const dlBtn = document.getElementById("download");
|
|
41
48
|
const dlStepBtn = document.getElementById("download-step");
|
|
42
49
|
const dl3mfBtn = document.getElementById("download-3mf");
|
|
43
|
-
const busyEl = document.getElementById("busy");
|
|
44
|
-
const phaseEl = document.getElementById("phase");
|
|
45
|
-
const partSeg = document.getElementById("part");
|
|
46
|
-
let kernelReady = false;
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
// --- per-sub-part mesh cache + auto-regenerating view composition ----------
|
|
56
|
-
// The view-tab buttons are generated from `part.views` (the single source of truth):
|
|
57
|
-
// each entry becomes a <button data-part=view>label</button> inside #part, with the
|
|
58
|
-
// first view marked active by default. (A saved view, below, overrides the default.)
|
|
59
|
-
if (partSeg && part.views) {
|
|
60
|
-
partSeg.innerHTML = Object.entries(part.views)
|
|
61
|
-
.map(([key, v], i) => `<button data-part="${key}"${i === 0 ? ' class="on"' : ""}>${v?.label ?? key}</button>`)
|
|
62
|
-
.join("");
|
|
63
|
-
}
|
|
64
|
-
// The initial view is whichever tab is marked active (else the first tab).
|
|
51
|
+
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
52
|
+
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
53
|
+
const tabs = createViewTabs(document.getElementById("part"), part, {
|
|
54
|
+
onChange: () => { refreshView(); updateRelevance(); loop.kick(); },
|
|
55
|
+
});
|
|
56
|
+
const view = () => tabs.current();
|
|
65
57
|
const params = { ...part.defaults };
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (savedBtn) for (const b of partSeg.children) b.classList.toggle("on", b === savedBtn);
|
|
58
|
+
|
|
59
|
+
// Current selection context for the pickers: the active view + live params +
|
|
60
|
+
// derived values. Shared by both ?pick modes below.
|
|
61
|
+
const getContext = () => ({ view: view(), params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} });
|
|
71
62
|
|
|
72
63
|
// ?pick enables click-to-select: a toggle button + a transient toast. Off by
|
|
73
64
|
// default — no button, no listener, no behavior change. Deleting this block and
|
|
74
65
|
// the selection/ dir reverts the app exactly.
|
|
75
66
|
if (qs.has("pick")) {
|
|
76
|
-
|
|
77
|
-
btn.id = "pf-pick";
|
|
78
|
-
btn.textContent = "Pick";
|
|
79
|
-
btn.title = "Click a surface to copy a selection token";
|
|
80
|
-
Object.assign(btn.style, {
|
|
81
|
-
position: "fixed", left: "12px", bottom: "12px", zIndex: 9999,
|
|
82
|
-
font: "12px system-ui, sans-serif", padding: "6px 10px", cursor: "pointer",
|
|
83
|
-
});
|
|
84
|
-
document.body.appendChild(btn);
|
|
85
|
-
|
|
86
|
-
const toast = document.createElement("div");
|
|
87
|
-
Object.assign(toast.style, {
|
|
88
|
-
position: "fixed", left: "12px", bottom: "48px", zIndex: 9999, maxWidth: "60ch",
|
|
89
|
-
font: "12px ui-monospace, monospace", padding: "6px 10px", borderRadius: "4px",
|
|
90
|
-
background: "rgba(20,24,29,0.92)", color: "#d8e0ea", display: "none",
|
|
91
|
-
whiteSpace: "pre-wrap", wordBreak: "break-word",
|
|
92
|
-
});
|
|
93
|
-
document.body.appendChild(toast);
|
|
94
|
-
|
|
95
|
-
const picker = attachPicker(viewer, {
|
|
96
|
-
part,
|
|
97
|
-
getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
|
|
98
|
-
onPick: (selection) => {
|
|
99
|
-
const token = formatSelection(selection, { style: "token" });
|
|
100
|
-
navigator.clipboard?.writeText(token);
|
|
101
|
-
toast.textContent = `copied: ${token}`;
|
|
102
|
-
toast.style.display = "block";
|
|
103
|
-
setTimeout(() => { toast.style.display = "none"; }, 4000);
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
btn.addEventListener("click", () => {
|
|
108
|
-
const isActive = btn.classList.toggle("on");
|
|
109
|
-
picker.setActive(isActive);
|
|
110
|
-
btn.style.outline = isActive ? "2px solid #ffcc33" : "";
|
|
111
|
-
});
|
|
67
|
+
attachPickToggle(viewer, { part, getContext });
|
|
112
68
|
} else if (qs.has("pickserver")) {
|
|
113
69
|
// Agent-driven mode: arm the picker only when the local pick-server asks for a
|
|
114
70
|
// click. Mutually exclusive with the clipboard ?pick toggle (else-if), so only one
|
|
115
71
|
// click listener is ever live. `?pickserver` or `?pickserver=http://host:port`.
|
|
116
72
|
const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
|
|
117
73
|
? qs.get("pickserver") : "http://127.0.0.1:4518";
|
|
118
|
-
createPickRequestClient({
|
|
119
|
-
serverUrl,
|
|
120
|
-
viewer,
|
|
121
|
-
part,
|
|
122
|
-
getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
|
|
123
|
-
});
|
|
74
|
+
createPickRequestClient({ serverUrl, viewer, part, getContext });
|
|
124
75
|
}
|
|
125
76
|
|
|
126
77
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
127
78
|
let cameraRestored = false; // saved camera applied once, on the first frame after load
|
|
128
|
-
let generating = false;
|
|
129
|
-
let paramsVersion = 0; // bumped on every settings edit
|
|
130
|
-
let genVersion = -1; // the params version the in-flight generate is building
|
|
131
|
-
let genTimer = null; // debounce timer for auto-regenerate
|
|
132
|
-
const cacheHash = {}; // n -> relevance hash each sub-part's cached mesh was built at
|
|
133
79
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
80
|
+
// Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
|
|
81
|
+
// time, so they're passed as getters; params is a stable in-place-mutated object.
|
|
82
|
+
const cache = createMeshCache(part, viewer, {
|
|
83
|
+
params,
|
|
84
|
+
getView: view,
|
|
85
|
+
getParamsVersion: () => loop.version(),
|
|
86
|
+
isCaching: () => cachingOn,
|
|
87
|
+
});
|
|
88
|
+
const isCurrent = cache.isCurrent;
|
|
89
|
+
const missingParts = () => viewSubParts(part, view(), params).filter((n) => !isCurrent(n));
|
|
90
|
+
|
|
91
|
+
// The regenerate state machine (ready gating / debounce / stale-redo) lives in
|
|
92
|
+
// regen-loop.js; this send callback is the one place a build job is dispatched.
|
|
93
|
+
const loop = createRegenLoop({
|
|
94
|
+
missingParts,
|
|
95
|
+
send: (missing) => {
|
|
96
|
+
const needed = viewSubParts(part, view(), params);
|
|
97
|
+
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
|
|
98
|
+
ui.showBusy("generating");
|
|
99
|
+
service.send({ type: "generate", subparts: missing, view: view(), params, cache: cachingOn }, backendFor());
|
|
100
|
+
},
|
|
101
|
+
});
|
|
154
102
|
|
|
155
103
|
// Reflect the active view. If every needed part is current, show it and enable
|
|
156
104
|
// export. If stale (a regenerate is in flight), keep the old mesh visible so the
|
|
@@ -158,10 +106,10 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
158
106
|
// Show the assembly, framing the camera only the first time we show a given view
|
|
159
107
|
// (initial load / tab switch) — never on a regenerate, so zoom/orbit are kept.
|
|
160
108
|
function showView(needed) {
|
|
161
|
-
const frame = view !== framedView;
|
|
109
|
+
const frame = view() !== framedView;
|
|
162
110
|
viewer.showAssembly(needed, { frame });
|
|
163
111
|
if (frame) {
|
|
164
|
-
framedView = view;
|
|
112
|
+
framedView = view();
|
|
165
113
|
if (!cameraRestored) {
|
|
166
114
|
const cam = loadCamera();
|
|
167
115
|
if (cam) viewer.setCameraState(cam);
|
|
@@ -171,87 +119,71 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
171
119
|
}
|
|
172
120
|
|
|
173
121
|
function refreshView() {
|
|
174
|
-
const needed = viewSubParts(part, view, params);
|
|
122
|
+
const needed = viewSubParts(part, view(), params);
|
|
175
123
|
if (needed.every(isCurrent)) {
|
|
176
124
|
showView(needed);
|
|
177
|
-
setExportEnabled(true);
|
|
178
|
-
const tris = needed.reduce((s, n) => s + viewer.
|
|
179
|
-
setStatus(`${tris.toLocaleString()} triangles`);
|
|
180
|
-
} else if (needed.every((n) => viewer.
|
|
125
|
+
ui.setExportEnabled(true);
|
|
126
|
+
const tris = needed.reduce((s, n) => s + viewer.subTriangles(n), 0);
|
|
127
|
+
ui.setStatus(`${tris.toLocaleString()} triangles`);
|
|
128
|
+
} else if (needed.every((n) => viewer.hasSubMesh(n))) {
|
|
181
129
|
showView(needed); // stale but present — keep it visible during regenerate
|
|
182
|
-
setExportEnabled(false);
|
|
130
|
+
ui.setExportEnabled(false);
|
|
183
131
|
} else {
|
|
184
132
|
viewer.hideAssembly();
|
|
185
|
-
setExportEnabled(false);
|
|
133
|
+
ui.setExportEnabled(false);
|
|
186
134
|
}
|
|
187
135
|
}
|
|
188
136
|
|
|
189
|
-
showBusy("booting kernel"); // visible from first paint until the kernel is ready
|
|
137
|
+
ui.showBusy("booting kernel"); // visible from first paint until the kernel is ready
|
|
190
138
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
const url = URL.createObjectURL(new Blob([arrayBuffer], { type: mime }));
|
|
194
|
-
const a = document.createElement("a");
|
|
195
|
-
a.href = url;
|
|
196
|
-
a.download = filename;
|
|
197
|
-
a.click();
|
|
198
|
-
URL.revokeObjectURL(url);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function onDownloadParts({ parts, ext, mime }) {
|
|
202
|
-
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime);
|
|
203
|
-
const entries = {};
|
|
204
|
-
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
205
|
-
triggerDownload(zipSync(entries, { level: 0 }), `${part.meta?.title ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-"), "application/zip");
|
|
206
|
-
}
|
|
139
|
+
// Bundle filename for a multi-part export (single parts download under their own name).
|
|
140
|
+
const zipName = `${part.meta?.title ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-");
|
|
207
141
|
|
|
208
142
|
// --- shared message handler ------------------------------------------------
|
|
209
143
|
function onWorkerMessage({ data }) {
|
|
210
144
|
switch (data.type) {
|
|
211
145
|
case "ready":
|
|
212
|
-
|
|
213
|
-
maybeGenerate(); // auto-build the default view (keeps the busy spinner up)
|
|
146
|
+
loop.ready(); // auto-build the default view (keeps the busy spinner up)
|
|
214
147
|
break;
|
|
215
148
|
case "progress":
|
|
216
|
-
showBusy(data.phase);
|
|
217
|
-
setStatus(`${data.phase}…`);
|
|
149
|
+
ui.showBusy(data.phase);
|
|
150
|
+
ui.setStatus(`${data.phase}…`);
|
|
218
151
|
break;
|
|
219
152
|
case "meshes": {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
153
|
+
if (loop.buildDone()) { // stale results (params changed mid-build) are discarded
|
|
154
|
+
for (const m of data.meshes) {
|
|
155
|
+
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
156
|
+
cache.record(m.name);
|
|
157
|
+
}
|
|
158
|
+
ui.hideBusy();
|
|
159
|
+
refreshView();
|
|
160
|
+
if (data.ms && missingParts().length === 0) {
|
|
161
|
+
ui.setStatus(`${ui.statusText()} · ${(data.ms / 1000).toFixed(1)} s`);
|
|
162
|
+
}
|
|
163
|
+
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
|
|
231
164
|
}
|
|
232
|
-
|
|
233
|
-
maybeGenerate(); // active view may still need parts (tab switched during build)
|
|
165
|
+
loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
|
|
234
166
|
break;
|
|
235
167
|
}
|
|
236
168
|
case "download-parts":
|
|
237
|
-
hideBusy();
|
|
238
|
-
|
|
239
|
-
setStatus(`${data.parts.length} part(s) downloaded`);
|
|
169
|
+
ui.hideBusy();
|
|
170
|
+
downloadParts(data, zipName);
|
|
171
|
+
ui.setStatus(`${data.parts.length} part(s) downloaded`);
|
|
240
172
|
break;
|
|
241
173
|
case "download":
|
|
242
|
-
hideBusy();
|
|
174
|
+
ui.hideBusy();
|
|
243
175
|
triggerDownload(data.data, data.filename, data.mime);
|
|
244
|
-
setStatus(`${data.filename} downloaded`);
|
|
176
|
+
ui.setStatus(`${data.filename} downloaded`);
|
|
245
177
|
break;
|
|
246
178
|
case "needs-occt":
|
|
247
179
|
forcedBackend = "occt"; // probe missed; this part needs OCCT — stick to it
|
|
248
|
-
|
|
249
|
-
|
|
180
|
+
loop.buildDone();
|
|
181
|
+
loop.kick();
|
|
250
182
|
break;
|
|
251
183
|
case "error":
|
|
252
|
-
|
|
253
|
-
hideBusy();
|
|
254
|
-
setStatus(`failed: ${data.message}`, true);
|
|
184
|
+
loop.buildDone();
|
|
185
|
+
ui.hideBusy();
|
|
186
|
+
ui.setStatus(`failed: ${data.message}`, true);
|
|
255
187
|
refreshView();
|
|
256
188
|
break;
|
|
257
189
|
}
|
|
@@ -260,107 +192,38 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
260
192
|
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
|
|
261
193
|
|
|
262
194
|
const panel = buildControls(controls, part.parameters, params, onParamChange);
|
|
263
|
-
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view, params));
|
|
195
|
+
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
|
|
264
196
|
updateRelevance(); // initial view
|
|
265
197
|
|
|
266
198
|
function onParamChange() {
|
|
267
|
-
|
|
268
|
-
refreshView();
|
|
199
|
+
loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
|
|
200
|
+
refreshView(); // keep showing the now-stale mesh (no flicker); disable export
|
|
269
201
|
updateRelevance();
|
|
270
|
-
scheduleGenerate();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Debounce auto-regeneration so dragging a slider doesn't queue a build per pixel.
|
|
274
|
-
function scheduleGenerate() {
|
|
275
|
-
clearTimeout(genTimer);
|
|
276
|
-
genTimer = setTimeout(maybeGenerate, 180);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Build whatever the active view is missing — automatic, no Generate button.
|
|
280
|
-
function maybeGenerate() {
|
|
281
|
-
if (!kernelReady || generating) return; // retried when the current build finishes
|
|
282
|
-
const needed = viewSubParts(part, view, params);
|
|
283
|
-
const missing = needed.filter((n) => !isCurrent(n));
|
|
284
|
-
if (missing.length === 0) return;
|
|
285
|
-
generating = true;
|
|
286
|
-
genVersion = paramsVersion;
|
|
287
|
-
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
|
|
288
|
-
showBusy("generating");
|
|
289
|
-
service.generate({ type: "generate", subparts: missing, view, params, cache: cachingOn }, backendFor());
|
|
290
202
|
}
|
|
291
203
|
|
|
292
204
|
// Re-run the active view under the current caching setting, so toggling the
|
|
293
205
|
// ?debug switch updates the readout for the same design without a param change.
|
|
294
206
|
function forceRegen() {
|
|
295
|
-
for (const n of viewSubParts(part, view, params))
|
|
207
|
+
for (const n of viewSubParts(part, view(), params)) cache.forget(n);
|
|
296
208
|
refreshView();
|
|
297
|
-
|
|
209
|
+
loop.kick();
|
|
298
210
|
}
|
|
299
211
|
|
|
300
|
-
partSeg.addEventListener("click", (e) => {
|
|
301
|
-
const btn = e.target.closest("button[data-part]");
|
|
302
|
-
if (!btn) return;
|
|
303
|
-
view = btn.dataset.part;
|
|
304
|
-
saveView(view);
|
|
305
|
-
for (const b of partSeg.children) b.classList.toggle("on", b === btn);
|
|
306
|
-
refreshView(); // instant if the view's parts are cached + current
|
|
307
|
-
updateRelevance();
|
|
308
|
-
maybeGenerate(); // else auto-build the missing pieces
|
|
309
|
-
});
|
|
310
|
-
|
|
311
212
|
dlBtn.addEventListener("click", () => {
|
|
312
|
-
showBusy("exporting STL");
|
|
313
|
-
service.
|
|
213
|
+
ui.showBusy("exporting STL");
|
|
214
|
+
service.send({ type: "export-stl", view: view(), params, quality: "print" }, backendFor());
|
|
314
215
|
});
|
|
315
216
|
|
|
316
217
|
dlStepBtn.addEventListener("click", () => {
|
|
317
|
-
showBusy("exporting STEP");
|
|
318
|
-
service.
|
|
218
|
+
ui.showBusy("exporting STEP");
|
|
219
|
+
service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
|
|
319
220
|
});
|
|
320
221
|
|
|
321
222
|
dl3mfBtn?.addEventListener("click", () => {
|
|
322
|
-
showBusy("exporting 3MF");
|
|
323
|
-
service.
|
|
223
|
+
ui.showBusy("exporting 3MF");
|
|
224
|
+
service.send({ type: "export-3mf", view: view(), params, quality: "print" }, backendFor());
|
|
324
225
|
});
|
|
325
226
|
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
const reframeBtn = document.getElementById("reframe");
|
|
329
|
-
const themeBtn = document.getElementById("theme");
|
|
330
|
-
|
|
331
|
-
// Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
|
|
332
|
-
// scene together; remember the choice across reloads.
|
|
333
|
-
let theme = localStorage.getItem("theme") || "dark";
|
|
334
|
-
function applyTheme(mode) {
|
|
335
|
-
theme = mode;
|
|
336
|
-
document.documentElement.dataset.theme = mode;
|
|
337
|
-
viewer.setTheme(mode);
|
|
338
|
-
themeBtn?.classList.toggle("on", mode === "light");
|
|
339
|
-
localStorage.setItem("theme", mode);
|
|
340
|
-
}
|
|
341
|
-
applyTheme(theme);
|
|
342
|
-
themeBtn?.addEventListener("click", () => applyTheme(theme === "light" ? "dark" : "light"));
|
|
343
|
-
|
|
344
|
-
// Pause/resume the idle auto-rotation.
|
|
345
|
-
let rotating = loadRotating();
|
|
346
|
-
viewer.setAutoRotate(rotating);
|
|
347
|
-
if (pauseBtn) {
|
|
348
|
-
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
349
|
-
pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
|
|
350
|
-
}
|
|
351
|
-
pauseBtn?.addEventListener("click", () => {
|
|
352
|
-
rotating = !rotating;
|
|
353
|
-
viewer.setAutoRotate(rotating);
|
|
354
|
-
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
355
|
-
pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
|
|
356
|
-
saveRotating(rotating);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// Re-fit the camera to the current view.
|
|
360
|
-
reframeBtn?.addEventListener("click", () => viewer.frame());
|
|
361
|
-
|
|
362
|
-
// Persist the camera when the user finishes an orbit/zoom, and right before a
|
|
363
|
-
// reload (captures the latest pose, including auto-rotation drift).
|
|
364
|
-
viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
|
|
365
|
-
window.addEventListener("pagehide", () => saveCamera(viewer.getCameraState()));
|
|
227
|
+
// Optional host-page viewer chrome (#pause / #reframe / #theme) + camera persistence.
|
|
228
|
+
attachViewerControls(viewer);
|
|
366
229
|
}
|
|
@@ -20,32 +20,23 @@ function recorder(obj, seen) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function relevantParamKeys(part, view, params) {
|
|
23
|
+
// The union of every on-screen sub-part's read set (that's exactly what
|
|
24
|
+
// subPartReadKeys computes, derive-input folding included)...
|
|
25
|
+
const reads = subPartReadKeys(part, view, params);
|
|
26
|
+
if (reads === RELEVANT_ALL) return RELEVANT_ALL; // analysis failed → everything relevant
|
|
23
27
|
try {
|
|
24
28
|
const relevant = new Set();
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// gate params: a param read by a view sub-part's enabled() changes what's on screen.
|
|
29
|
+
for (const keys of reads.values()) for (const k of keys) relevant.add(k);
|
|
30
|
+
// ...plus the gate params of EVERY in-view sub-part, on or off: toggling one
|
|
31
|
+
// changes what's on screen, so its enabled() inputs are relevant even when the
|
|
32
|
+
// sub-part is currently hidden (on-screen ones are already in `reads`).
|
|
31
33
|
for (const name of Object.keys(part.parts)) {
|
|
32
34
|
const sp = part.parts[name];
|
|
33
35
|
if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
// direct build reads across the on-screen (enabled) sub-parts.
|
|
37
|
-
const { kernel } = createProbeKernel();
|
|
38
|
-
let anyDerivedRead = false;
|
|
39
|
-
for (const name of viewSubParts(part, view, params)) {
|
|
40
|
-
const dSeen = new Set();
|
|
41
|
-
part.parts[name].build(kernel, recorder(params, relevant), recorder(derived, dSeen));
|
|
42
|
-
if (dSeen.size > 0) anyDerivedRead = true;
|
|
43
|
-
}
|
|
44
|
-
if (anyDerivedRead) for (const k of deriveInputs) relevant.add(k);
|
|
45
|
-
|
|
46
37
|
return relevant;
|
|
47
38
|
} catch {
|
|
48
|
-
return RELEVANT_ALL;
|
|
39
|
+
return RELEVANT_ALL;
|
|
49
40
|
}
|
|
50
41
|
}
|
|
51
42
|
|
|
@@ -58,6 +58,16 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
|
58
58
|
}
|
|
59
59
|
batch = createBatch(body.prompts);
|
|
60
60
|
pending = { res, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
|
|
61
|
+
// A dropped client (Ctrl-C'd CLI) must free the slot immediately, not wedge
|
|
62
|
+
// every new request behind 409-busy until timeoutMs. Drop `pending` first so
|
|
63
|
+
// finish() never writes to the dead socket. (This also fires after a normal
|
|
64
|
+
// finish(), where `pending` is already null — the guard makes it a no-op.)
|
|
65
|
+
res.on("close", () => {
|
|
66
|
+
if (pending?.res !== res) return;
|
|
67
|
+
clearTimeout(pending.timer);
|
|
68
|
+
pending = null;
|
|
69
|
+
if (batch) { cancel(batch); finish(); }
|
|
70
|
+
});
|
|
61
71
|
sse("prompt", view(batch));
|
|
62
72
|
return; // held open until finish()
|
|
63
73
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The auto-regenerate state machine (no DOM, no workers — pure orchestration).
|
|
2
|
+
// mount.js wires it to the geometry service: `missingParts` reports what the
|
|
3
|
+
// active view still needs, `send` dispatches one build job for those parts.
|
|
4
|
+
//
|
|
5
|
+
// Invariants (pinned by test/framework/regen-loop.test.js):
|
|
6
|
+
// - nothing is sent until ready() (the worker announced its kernel);
|
|
7
|
+
// - at most one build is in flight; kicks while generating are absorbed and the
|
|
8
|
+
// caller re-kicks after buildDone();
|
|
9
|
+
// - markDirty() bumps the params version and debounces a kick, so dragging a
|
|
10
|
+
// slider queues one build per pause, not one per pixel;
|
|
11
|
+
// - a build that a mid-flight edit outdated is reported stale by buildDone()
|
|
12
|
+
// (return false → the caller discards the meshes and kicks a rebuild).
|
|
13
|
+
export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
14
|
+
let kernelReady = false;
|
|
15
|
+
let generating = false;
|
|
16
|
+
let paramsVersion = 0; // bumped on every settings edit
|
|
17
|
+
let genVersion = -1; // the params version the in-flight build is building
|
|
18
|
+
let timer = null;
|
|
19
|
+
|
|
20
|
+
function kick() {
|
|
21
|
+
if (!kernelReady || generating) return; // re-kicked when the current build finishes
|
|
22
|
+
const missing = missingParts();
|
|
23
|
+
if (missing.length === 0) return;
|
|
24
|
+
generating = true;
|
|
25
|
+
genVersion = paramsVersion;
|
|
26
|
+
send(missing);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
kick,
|
|
31
|
+
ready() { kernelReady = true; kick(); },
|
|
32
|
+
markDirty() {
|
|
33
|
+
paramsVersion++;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
timer = setTimeout(kick, debounceMs);
|
|
36
|
+
},
|
|
37
|
+
// The build finished (meshes / needs-occt / error). Returns whether its result
|
|
38
|
+
// is still current; the caller applies the meshes only on true, then kicks.
|
|
39
|
+
buildDone() {
|
|
40
|
+
generating = false;
|
|
41
|
+
return genVersion === paramsVersion;
|
|
42
|
+
},
|
|
43
|
+
version: () => paramsVersion,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -9,17 +9,13 @@ const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(","
|
|
|
9
9
|
|
|
10
10
|
function tokenStyle(s) {
|
|
11
11
|
const head = `@${s.subPart}`;
|
|
12
|
-
const feat = s.feature
|
|
13
|
-
? ` · ${s.feature.kind === "cylinder"
|
|
14
|
-
? `cyl-face r=${s.feature.radius} axis=${s.feature.axis}`
|
|
15
|
-
: `${s.feature.kind}-face`}`
|
|
16
|
-
: "";
|
|
12
|
+
const feat = s.feature ? ` · ${s.feature.label}` : "";
|
|
17
13
|
return `${head}${feat} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
16
|
function promptStyle(s) {
|
|
21
17
|
const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
22
|
-
const feat = s.feature ? `
|
|
18
|
+
const feat = s.feature ? ` **${s.feature.label}**,` : "";
|
|
23
19
|
return `On sub-part **${s.subPart}**, the user pointed at${feat} local point (${s.point.join(", ")}), `
|
|
24
20
|
+ `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
|
|
25
21
|
}
|