partforge 0.6.1 → 0.8.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 (50) hide show
  1. package/README.md +61 -15
  2. package/bin/cli.js +82 -58
  3. package/docs/AUTHORING-PARTS.md +98 -26
  4. package/package.json +1 -1
  5. package/src/app-faceted-vase.js +10 -0
  6. package/src/faceted-vase-worker.js +3 -0
  7. package/src/framework/app.css +26 -0
  8. package/src/framework/assembly.js +6 -9
  9. package/src/framework/download.js +23 -0
  10. package/src/framework/geometry/feature-attribution.js +102 -0
  11. package/src/framework/geometry/helix-tube.js +10 -20
  12. package/src/framework/geometry/kernel-front.js +37 -0
  13. package/src/framework/geometry/kernel.js +56 -10
  14. package/src/framework/geometry/loft.js +79 -0
  15. package/src/framework/geometry/manifold-backend.js +67 -25
  16. package/src/framework/geometry/mesh-build.js +53 -0
  17. package/src/framework/geometry/occt-backend.js +117 -106
  18. package/src/framework/geometry/occt-repair.js +83 -0
  19. package/src/framework/geometry/polygon.js +89 -0
  20. package/src/framework/geometry/probe.js +37 -30
  21. package/src/framework/geometry/profile.js +96 -0
  22. package/src/framework/geometry/solid-sugar.js +32 -5
  23. package/src/framework/geometry/sweep.js +151 -0
  24. package/src/framework/geometry-service.js +4 -6
  25. package/src/framework/jobs.js +40 -18
  26. package/src/framework/mesh-cache.js +41 -0
  27. package/src/framework/mount.js +103 -240
  28. package/src/framework/param-deps.js +9 -18
  29. package/src/framework/pick-request/server.js +10 -0
  30. package/src/framework/regen-loop.js +45 -0
  31. package/src/framework/selection/format.js +2 -6
  32. package/src/framework/selection/hover.js +128 -0
  33. package/src/framework/selection/index.js +3 -0
  34. package/src/framework/selection/pick-toggle.js +34 -0
  35. package/src/framework/selection/pick.js +7 -30
  36. package/src/framework/selection/raycast.js +43 -0
  37. package/src/framework/selection/resolve.js +3 -8
  38. package/src/framework/status-ui.js +18 -0
  39. package/src/framework/view-state.js +11 -1
  40. package/src/framework/view-tabs.js +33 -0
  41. package/src/framework/viewer-controls.js +48 -0
  42. package/src/framework/viewer.js +9 -9
  43. package/src/framework/worker.js +12 -20
  44. package/src/parts/faceted-vase.js +75 -0
  45. package/src/parts/filleted-box.js +1 -1
  46. package/src/parts/planter.js +4 -3
  47. package/src/testing/build.js +3 -6
  48. package/src/testing/manifold.js +11 -0
  49. package/src/testing.js +1 -0
  50. package/src/framework/geometry/fuzzy-cut.js +0 -32
@@ -1,26 +1,34 @@
1
1
  import "./app.css"; // shared chrome styles — every part-app gets them via mount
2
- import { zipSync } from "fflate";
2
+ import { triggerDownload, downloadParts } from "./download.js";
3
3
  import { createViewer } from "./viewer.js";
4
- import { loadRotating, saveRotating, loadCamera, saveCamera, loadView, saveView } from "./view-state.js";
4
+ import { attachViewerControls } from "./viewer-controls.js";
5
+ import { loadCamera } from "./view-state.js";
5
6
  import { buildControls } from "./controls.js";
6
- import { relevantParamKeys, subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
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 { attachPicker, formatSelection } from "./selection/index.js";
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: 3-D viewer + control
15
- // panel + the two geometry workers + the auto-regenerating view/cache loop +
16
- // STL/STEP export. The app supplies `createWorker(name)` so Vite can bundle the
17
- // worker (see geometry-service.js). DOM element ids match the host page:
18
- // #app (viewer), #controls, #status, #download, #download-step, #busy, #phase,
19
- // #part (the view-tab segmented control).
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
- const exportBtns = [dlBtn, dlStepBtn, dl3mfBtn].filter(Boolean);
49
- const setExportEnabled = (on) => exportBtns.forEach((b) => { b.disabled = !on; });
50
-
51
- const setStatus = (msg, isErr = false) => { statusEl.textContent = msg; statusEl.classList.toggle("err", isErr); };
52
- const showBusy = (phase) => { phaseEl.textContent = `${phase}…`; busyEl.classList.add("show"); };
53
- const hideBusy = () => busyEl.classList.remove("show");
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
- const defaultView = partSeg.querySelector("button.on")?.dataset.part ?? partSeg.querySelector("button")?.dataset.part;
67
- const savedView = loadView();
68
- const savedBtn = savedView ? [...partSeg.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === savedView) : null;
69
- let view = savedBtn ? savedView : defaultView;
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
- const btn = document.createElement("button");
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
- // Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
135
- // runs probe builds, so we compute it once per change, not per sub-part.
136
- let _readsKey = null, _readsMap = null;
137
- const readsFor = () => {
138
- const key = `${paramsVersion}|${view}`;
139
- if (_readsKey !== key) { _readsKey = key; _readsMap = subPartReadKeys(part, view, params); }
140
- return _readsMap;
141
- };
142
- // The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
143
- // over ALL params, so any edit invalidates it — the safe fallback).
144
- const hashFor = (n) => {
145
- if (!cachingOn) return `v${paramsVersion}`; // caching off: any edit invalidates every sub-part (Layer 1 off)
146
- const reads = readsFor();
147
- const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(n) ?? Object.keys(params))];
148
- return relevanceHash(keys, params);
149
- };
150
-
151
- // A cached sub-part is current only if its relevance hash is unchanged.
152
- const isCurrent = (n) => !!viewer._subCache[n] && cacheHash[n] === hashFor(n);
153
- const missingParts = () => viewSubParts(part, view, params).filter((n) => !isCurrent(n));
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._subCache[n].userData.triangles, 0);
179
- setStatus(`${tris.toLocaleString()} triangles`);
180
- } else if (needed.every((n) => viewer._subCache[n])) {
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
- // --- download helpers ------------------------------------------------------
192
- function triggerDownload(arrayBuffer, filename, mime) {
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
- kernelReady = true;
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
- generating = false;
221
- if (genVersion !== paramsVersion) { maybeGenerate(); break; } // changed mid-build → redo
222
- for (const m of data.meshes) {
223
- if (viewer._subCache[m.name]) { viewer._subCache[m.name].userData.edges?.dispose(); viewer._subCache[m.name].dispose(); }
224
- viewer.setSubGeometry(m.name, m);
225
- cacheHash[m.name] = hashFor(m.name);
226
- }
227
- hideBusy();
228
- refreshView();
229
- if (data.ms && missingParts().length === 0) {
230
- setStatus(`${statusEl.textContent} · ${(data.ms / 1000).toFixed(1)} s`);
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
- dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
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
- onDownloadParts(data);
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
- generating = false;
249
- maybeGenerate();
180
+ loop.buildDone();
181
+ loop.kick();
250
182
  break;
251
183
  case "error":
252
- generating = false;
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
- paramsVersion++; // every edit invalidates the caches (by version)
268
- refreshView(); // keep showing the now-stale mesh (no flicker); disable export
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)) delete cacheHash[n];
207
+ for (const n of viewSubParts(part, view(), params)) cache.forget(n);
296
208
  refreshView();
297
- maybeGenerate();
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.exportStl({ type: "export-stl", view, params }, backendFor());
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.exportStep({ type: "export-step", view, params });
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.export3mf({ type: "export-3mf", view, params }, backendFor());
223
+ ui.showBusy("exporting 3MF");
224
+ service.send({ type: "export-3mf", view: view(), params, quality: "print" }, backendFor());
324
225
  });
325
226
 
326
- // --- viewer controls (optional host-page buttons: #pause / #reframe / #theme) --
327
- const pauseBtn = document.getElementById("pause");
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
- // derive: record which raw params feed it; produce real derived values once.
27
- const deriveInputs = new Set();
28
- const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
29
-
30
- // gate params: a param read by a view sub-part's enabled() changes what's on screen.
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; // couldn't analyze — treat everything as relevant
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 ? ` a ${s.feature.kind} face,` : "";
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
  }