partforge 0.1.0 → 0.4.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 +3 -1
- package/bin/cli.js +73 -0
- package/docs/AUTHORING-PARTS.md +200 -3
- package/package.json +15 -1
- package/src/app-filleted-box.js +7 -0
- package/src/filleted-box-worker.js +3 -0
- package/src/framework/app.css +26 -1
- package/src/framework/controls.js +153 -36
- package/src/framework/debug-overlay.js +40 -0
- package/src/framework/geometry/edge-selector.js +17 -0
- package/src/framework/geometry/errors.js +10 -0
- package/src/framework/geometry/face-selector.js +19 -0
- package/src/framework/geometry/kernel.js +9 -1
- package/src/framework/geometry/manifold-backend.js +76 -18
- package/src/framework/geometry/occt-backend.js +118 -4
- package/src/framework/geometry/polygon.js +117 -0
- package/src/framework/geometry/probe.js +52 -0
- package/src/framework/geometry/solid-cache.js +39 -0
- package/src/framework/geometry/solid-hash.js +21 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +24 -8
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +144 -18
- package/src/framework/param-deps.js +81 -0
- package/src/framework/selection/format.js +31 -0
- package/src/framework/selection/index.js +5 -0
- package/src/framework/selection/pick.js +48 -0
- package/src/framework/selection/resolve.js +54 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +42 -3
- package/src/parts/demo.js +29 -11
- package/src/parts/filleted-box.js +46 -0
- package/src/testing/build.js +17 -0
- package/src/testing/measure.js +53 -0
- package/src/testing/mesh.js +27 -0
- package/src/testing/render.js +159 -0
- package/src/testing.js +3 -0
package/src/framework/mount.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import "./app.css"; // shared chrome styles — every part-app gets them via mount
|
|
2
2
|
import { zipSync } from "fflate";
|
|
3
3
|
import { createViewer } from "./viewer.js";
|
|
4
|
+
import { loadRotating, saveRotating, loadCamera, saveCamera, loadView, saveView } from "./view-state.js";
|
|
4
5
|
import { buildControls } from "./controls.js";
|
|
6
|
+
import { relevantParamKeys, subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
|
|
5
7
|
import { createGeometryService } from "./geometry-service.js";
|
|
6
8
|
import { viewSubParts } from "./jobs.js";
|
|
9
|
+
import { detectBackend } from "./geometry/probe.js";
|
|
10
|
+
import { createDebugOverlay } from "./debug-overlay.js";
|
|
11
|
+
import { attachPicker, formatSelection } from "./selection/index.js";
|
|
7
12
|
|
|
8
13
|
// Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
|
|
9
14
|
// panel + the two geometry workers + the auto-regenerating view/cache loop +
|
|
@@ -16,8 +21,19 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
16
21
|
const names = Object.keys(part.parts);
|
|
17
22
|
const viewer = createViewer(container, part);
|
|
18
23
|
|
|
19
|
-
// ?backend=occt
|
|
20
|
-
|
|
24
|
+
// ?backend=occt|manifold forces the backend; otherwise it's detected per part.
|
|
25
|
+
let forcedBackend = new URLSearchParams(location.search).get("backend");
|
|
26
|
+
if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
|
|
27
|
+
const backendFor = () => forcedBackend ?? detectBackend(part, params);
|
|
28
|
+
|
|
29
|
+
// ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
|
|
30
|
+
const qs = new URLSearchParams(location.search);
|
|
31
|
+
const debug = qs.has("debug");
|
|
32
|
+
let cachingOn = !(debug && qs.has("nocache"));
|
|
33
|
+
let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
|
|
34
|
+
const dbg = debug
|
|
35
|
+
? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
|
|
36
|
+
: null;
|
|
21
37
|
|
|
22
38
|
const statusEl = document.getElementById("status");
|
|
23
39
|
const dlBtn = document.getElementById("download");
|
|
@@ -36,20 +52,91 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
36
52
|
const hideBusy = () => busyEl.classList.remove("show");
|
|
37
53
|
|
|
38
54
|
// --- per-sub-part mesh cache + auto-regenerating view composition ----------
|
|
39
|
-
// The
|
|
40
|
-
//
|
|
41
|
-
//
|
|
55
|
+
// The view-tab buttons are generated from `part.views` (the single source of truth):
|
|
56
|
+
// each entry becomes a <button data-part=view>label</button> inside #part, with the
|
|
57
|
+
// first view marked active by default. (A saved view, below, overrides the default.)
|
|
58
|
+
if (partSeg && part.views) {
|
|
59
|
+
partSeg.innerHTML = Object.entries(part.views)
|
|
60
|
+
.map(([key, v], i) => `<button data-part="${key}"${i === 0 ? ' class="on"' : ""}>${v?.label ?? key}</button>`)
|
|
61
|
+
.join("");
|
|
62
|
+
}
|
|
63
|
+
// The initial view is whichever tab is marked active (else the first tab).
|
|
42
64
|
const params = { ...part.defaults };
|
|
43
|
-
|
|
65
|
+
const defaultView = partSeg.querySelector("button.on")?.dataset.part ?? partSeg.querySelector("button")?.dataset.part;
|
|
66
|
+
const savedView = loadView();
|
|
67
|
+
const savedBtn = savedView ? [...partSeg.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === savedView) : null;
|
|
68
|
+
let view = savedBtn ? savedView : defaultView;
|
|
69
|
+
if (savedBtn) for (const b of partSeg.children) b.classList.toggle("on", b === savedBtn);
|
|
70
|
+
|
|
71
|
+
// ?pick enables click-to-select: a toggle button + a transient toast. Off by
|
|
72
|
+
// default — no button, no listener, no behavior change. Deleting this block and
|
|
73
|
+
// the selection/ dir reverts the app exactly.
|
|
74
|
+
if (qs.has("pick")) {
|
|
75
|
+
const btn = document.createElement("button");
|
|
76
|
+
btn.id = "pf-pick";
|
|
77
|
+
btn.textContent = "Pick";
|
|
78
|
+
btn.title = "Click a surface to copy a selection token";
|
|
79
|
+
Object.assign(btn.style, {
|
|
80
|
+
position: "fixed", left: "12px", bottom: "12px", zIndex: 9999,
|
|
81
|
+
font: "12px system-ui, sans-serif", padding: "6px 10px", cursor: "pointer",
|
|
82
|
+
});
|
|
83
|
+
document.body.appendChild(btn);
|
|
84
|
+
|
|
85
|
+
const toast = document.createElement("div");
|
|
86
|
+
Object.assign(toast.style, {
|
|
87
|
+
position: "fixed", left: "12px", bottom: "48px", zIndex: 9999, maxWidth: "60ch",
|
|
88
|
+
font: "12px ui-monospace, monospace", padding: "6px 10px", borderRadius: "4px",
|
|
89
|
+
background: "rgba(20,24,29,0.92)", color: "#d8e0ea", display: "none",
|
|
90
|
+
whiteSpace: "pre-wrap", wordBreak: "break-word",
|
|
91
|
+
});
|
|
92
|
+
document.body.appendChild(toast);
|
|
93
|
+
|
|
94
|
+
const picker = attachPicker(viewer, {
|
|
95
|
+
part,
|
|
96
|
+
getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
|
|
97
|
+
onPick: (selection) => {
|
|
98
|
+
const token = formatSelection(selection, { style: "token" });
|
|
99
|
+
navigator.clipboard?.writeText(token);
|
|
100
|
+
toast.textContent = `copied: ${token}`;
|
|
101
|
+
toast.style.display = "block";
|
|
102
|
+
setTimeout(() => { toast.style.display = "none"; }, 4000);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
btn.addEventListener("click", () => {
|
|
107
|
+
const isActive = btn.classList.toggle("on");
|
|
108
|
+
picker.setActive(isActive);
|
|
109
|
+
btn.style.outline = isActive ? "2px solid #ffcc33" : "";
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
44
113
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
114
|
+
let cameraRestored = false; // saved camera applied once, on the first frame after load
|
|
45
115
|
let generating = false;
|
|
46
116
|
let paramsVersion = 0; // bumped on every settings edit
|
|
47
117
|
let genVersion = -1; // the params version the in-flight generate is building
|
|
48
118
|
let genTimer = null; // debounce timer for auto-regenerate
|
|
49
|
-
const
|
|
119
|
+
const cacheHash = {}; // n -> relevance hash each sub-part's cached mesh was built at
|
|
50
120
|
|
|
51
|
-
//
|
|
52
|
-
|
|
121
|
+
// Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
|
|
122
|
+
// runs probe builds, so we compute it once per change, not per sub-part.
|
|
123
|
+
let _readsKey = null, _readsMap = null;
|
|
124
|
+
const readsFor = () => {
|
|
125
|
+
const key = `${paramsVersion}|${view}`;
|
|
126
|
+
if (_readsKey !== key) { _readsKey = key; _readsMap = subPartReadKeys(part, view, params); }
|
|
127
|
+
return _readsMap;
|
|
128
|
+
};
|
|
129
|
+
// The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
|
|
130
|
+
// over ALL params, so any edit invalidates it — the safe fallback).
|
|
131
|
+
const hashFor = (n) => {
|
|
132
|
+
if (!cachingOn) return `v${paramsVersion}`; // caching off: any edit invalidates every sub-part (Layer 1 off)
|
|
133
|
+
const reads = readsFor();
|
|
134
|
+
const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(n) ?? Object.keys(params))];
|
|
135
|
+
return relevanceHash(keys, params);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A cached sub-part is current only if its relevance hash is unchanged.
|
|
139
|
+
const isCurrent = (n) => !!viewer._subCache[n] && cacheHash[n] === hashFor(n);
|
|
53
140
|
const missingParts = () => viewSubParts(part, view, params).filter((n) => !isCurrent(n));
|
|
54
141
|
|
|
55
142
|
// Reflect the active view. If every needed part is current, show it and enable
|
|
@@ -60,7 +147,14 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
60
147
|
function showView(needed) {
|
|
61
148
|
const frame = view !== framedView;
|
|
62
149
|
viewer.showAssembly(needed, { frame });
|
|
63
|
-
if (frame)
|
|
150
|
+
if (frame) {
|
|
151
|
+
framedView = view;
|
|
152
|
+
if (!cameraRestored) {
|
|
153
|
+
const cam = loadCamera();
|
|
154
|
+
if (cam) viewer.setCameraState(cam);
|
|
155
|
+
cameraRestored = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
64
158
|
}
|
|
65
159
|
|
|
66
160
|
function refreshView() {
|
|
@@ -115,13 +209,14 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
115
209
|
for (const m of data.meshes) {
|
|
116
210
|
if (viewer._subCache[m.name]) { viewer._subCache[m.name].userData.edges?.dispose(); viewer._subCache[m.name].dispose(); }
|
|
117
211
|
viewer.setSubGeometry(m.name, m);
|
|
118
|
-
|
|
212
|
+
cacheHash[m.name] = hashFor(m.name);
|
|
119
213
|
}
|
|
120
214
|
hideBusy();
|
|
121
215
|
refreshView();
|
|
122
216
|
if (data.ms && missingParts().length === 0) {
|
|
123
217
|
setStatus(`${statusEl.textContent} · ${(data.ms / 1000).toFixed(1)} s`);
|
|
124
218
|
}
|
|
219
|
+
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
|
|
125
220
|
maybeGenerate(); // active view may still need parts (tab switched during build)
|
|
126
221
|
break;
|
|
127
222
|
}
|
|
@@ -135,6 +230,11 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
135
230
|
triggerDownload(data.data, data.filename, data.mime);
|
|
136
231
|
setStatus(`${data.filename} downloaded`);
|
|
137
232
|
break;
|
|
233
|
+
case "needs-occt":
|
|
234
|
+
forcedBackend = "occt"; // probe missed; this part needs OCCT — stick to it
|
|
235
|
+
generating = false;
|
|
236
|
+
maybeGenerate();
|
|
237
|
+
break;
|
|
138
238
|
case "error":
|
|
139
239
|
generating = false;
|
|
140
240
|
hideBusy();
|
|
@@ -144,13 +244,16 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
144
244
|
}
|
|
145
245
|
}
|
|
146
246
|
|
|
147
|
-
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage
|
|
247
|
+
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
|
|
148
248
|
|
|
149
|
-
buildControls(controls, part.parameters, params, onParamChange);
|
|
249
|
+
const panel = buildControls(controls, part.parameters, params, onParamChange);
|
|
250
|
+
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view, params));
|
|
251
|
+
updateRelevance(); // initial view
|
|
150
252
|
|
|
151
253
|
function onParamChange() {
|
|
152
254
|
paramsVersion++; // every edit invalidates the caches (by version)
|
|
153
255
|
refreshView(); // keep showing the now-stale mesh (no flicker); disable export
|
|
256
|
+
updateRelevance();
|
|
154
257
|
scheduleGenerate();
|
|
155
258
|
}
|
|
156
259
|
|
|
@@ -163,26 +266,38 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
163
266
|
// Build whatever the active view is missing — automatic, no Generate button.
|
|
164
267
|
function maybeGenerate() {
|
|
165
268
|
if (!kernelReady || generating) return; // retried when the current build finishes
|
|
166
|
-
const
|
|
269
|
+
const needed = viewSubParts(part, view, params);
|
|
270
|
+
const missing = needed.filter((n) => !isCurrent(n));
|
|
167
271
|
if (missing.length === 0) return;
|
|
168
272
|
generating = true;
|
|
169
273
|
genVersion = paramsVersion;
|
|
274
|
+
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
|
|
170
275
|
showBusy("generating");
|
|
171
|
-
service.generate({ type: "generate", subparts: missing, view, params });
|
|
276
|
+
service.generate({ type: "generate", subparts: missing, view, params, cache: cachingOn }, backendFor());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Re-run the active view under the current caching setting, so toggling the
|
|
280
|
+
// ?debug switch updates the readout for the same design without a param change.
|
|
281
|
+
function forceRegen() {
|
|
282
|
+
for (const n of viewSubParts(part, view, params)) delete cacheHash[n];
|
|
283
|
+
refreshView();
|
|
284
|
+
maybeGenerate();
|
|
172
285
|
}
|
|
173
286
|
|
|
174
287
|
partSeg.addEventListener("click", (e) => {
|
|
175
288
|
const btn = e.target.closest("button[data-part]");
|
|
176
289
|
if (!btn) return;
|
|
177
290
|
view = btn.dataset.part;
|
|
291
|
+
saveView(view);
|
|
178
292
|
for (const b of partSeg.children) b.classList.toggle("on", b === btn);
|
|
179
293
|
refreshView(); // instant if the view's parts are cached + current
|
|
294
|
+
updateRelevance();
|
|
180
295
|
maybeGenerate(); // else auto-build the missing pieces
|
|
181
296
|
});
|
|
182
297
|
|
|
183
298
|
dlBtn.addEventListener("click", () => {
|
|
184
299
|
showBusy("exporting STL");
|
|
185
|
-
service.exportStl({ type: "export-stl", view, params });
|
|
300
|
+
service.exportStl({ type: "export-stl", view, params }, backendFor());
|
|
186
301
|
});
|
|
187
302
|
|
|
188
303
|
dlStepBtn.addEventListener("click", () => {
|
|
@@ -192,7 +307,7 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
192
307
|
|
|
193
308
|
dl3mfBtn?.addEventListener("click", () => {
|
|
194
309
|
showBusy("exporting 3MF");
|
|
195
|
-
service.export3mf({ type: "export-3mf", view, params });
|
|
310
|
+
service.export3mf({ type: "export-3mf", view, params }, backendFor());
|
|
196
311
|
});
|
|
197
312
|
|
|
198
313
|
// --- viewer controls (optional host-page buttons: #pause / #reframe / #theme) --
|
|
@@ -214,14 +329,25 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
214
329
|
themeBtn?.addEventListener("click", () => applyTheme(theme === "light" ? "dark" : "light"));
|
|
215
330
|
|
|
216
331
|
// Pause/resume the idle auto-rotation.
|
|
217
|
-
let rotating =
|
|
332
|
+
let rotating = loadRotating();
|
|
333
|
+
viewer.setAutoRotate(rotating);
|
|
334
|
+
if (pauseBtn) {
|
|
335
|
+
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
336
|
+
pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
|
|
337
|
+
}
|
|
218
338
|
pauseBtn?.addEventListener("click", () => {
|
|
219
339
|
rotating = !rotating;
|
|
220
340
|
viewer.setAutoRotate(rotating);
|
|
221
341
|
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
222
342
|
pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
|
|
343
|
+
saveRotating(rotating);
|
|
223
344
|
});
|
|
224
345
|
|
|
225
346
|
// Re-fit the camera to the current view.
|
|
226
347
|
reframeBtn?.addEventListener("click", () => viewer.frame());
|
|
348
|
+
|
|
349
|
+
// Persist the camera when the user finishes an orbit/zoom, and right before a
|
|
350
|
+
// reload (captures the latest pose, including auto-rotation drift).
|
|
351
|
+
viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
|
|
352
|
+
window.addEventListener("pagehide", () => saveCamera(viewer.getCameraState()));
|
|
227
353
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Which raw parameters affect the parts on screen in the active view. A removable
|
|
2
|
+
// relevance layer: the panel uses this to dim controls / hide sections that don't
|
|
3
|
+
// affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
|
|
4
|
+
// probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
|
|
5
|
+
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
|
+
import { viewSubParts } from "./jobs.js";
|
|
7
|
+
|
|
8
|
+
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
9
|
+
|
|
10
|
+
// A read-recording Proxy over a shallow clone of `obj`: records each top-level
|
|
11
|
+
// property key read into `seen`, returns the real value so the build's conditionals
|
|
12
|
+
// evaluate correctly, and never mutates the original `obj` (writes hit the clone).
|
|
13
|
+
function recorder(obj, seen) {
|
|
14
|
+
return new Proxy({ ...obj }, {
|
|
15
|
+
get(target, key) {
|
|
16
|
+
if (typeof key === "string") seen.add(key);
|
|
17
|
+
return Reflect.get(target, key);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function relevantParamKeys(part, view, params) {
|
|
23
|
+
try {
|
|
24
|
+
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.
|
|
31
|
+
for (const name of Object.keys(part.parts)) {
|
|
32
|
+
const sp = part.parts[name];
|
|
33
|
+
if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
|
|
34
|
+
}
|
|
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
|
+
return relevant;
|
|
47
|
+
} catch {
|
|
48
|
+
return RELEVANT_ALL; // couldn't analyze — treat everything as relevant
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Per-sub-part version of relevantParamKeys: which raw params each ON-SCREEN
|
|
53
|
+
// sub-part of the active view reads. Used by Layer 1 (mount.js) to skip
|
|
54
|
+
// regenerating sub-parts whose inputs are unchanged. Errs to RELEVANT_ALL on any
|
|
55
|
+
// analysis failure (caller then treats every param as relevant — safe, just slower).
|
|
56
|
+
export function subPartReadKeys(part, view, params) {
|
|
57
|
+
try {
|
|
58
|
+
const deriveInputs = new Set();
|
|
59
|
+
const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
|
|
60
|
+
const { kernel } = createProbeKernel();
|
|
61
|
+
const map = new Map();
|
|
62
|
+
for (const name of viewSubParts(part, view, params)) {
|
|
63
|
+
const sp = part.parts[name];
|
|
64
|
+
const reads = new Set();
|
|
65
|
+
const dSeen = new Set();
|
|
66
|
+
if (sp.enabled) sp.enabled(recorder(params, reads)); // gate params change presence too
|
|
67
|
+
sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
|
|
68
|
+
if (dSeen.size > 0) for (const k of deriveInputs) reads.add(k);
|
|
69
|
+
map.set(name, reads);
|
|
70
|
+
}
|
|
71
|
+
return map;
|
|
72
|
+
} catch {
|
|
73
|
+
return RELEVANT_ALL;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Stable string of the given param keys' current values — the cache-validity key
|
|
78
|
+
// for one sub-part. Sorted so key order never affects the result.
|
|
79
|
+
export function relevanceHash(keys, params) {
|
|
80
|
+
return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]));
|
|
81
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pure serializer. No three.js, no DOM. Three styles for the same Selection:
|
|
2
|
+
// token — compact clipboard/CLI line
|
|
3
|
+
// json — the structured object (embedded tool-call transport)
|
|
4
|
+
// prompt — one natural-language sentence an LLM ingests well
|
|
5
|
+
const AXIS_LABEL = { "1,0,0": "+X", "-1,0,0": "-X", "0,1,0": "+Y", "0,-1,0": "-Y", "0,0,1": "+Z", "0,0,-1": "-Z" };
|
|
6
|
+
|
|
7
|
+
const fmtNormal = (n) => AXIS_LABEL[n.join(",")] ?? n.join(",");
|
|
8
|
+
const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(",");
|
|
9
|
+
|
|
10
|
+
function tokenStyle(s) {
|
|
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
|
+
: "";
|
|
17
|
+
return `${head}${feat} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function promptStyle(s) {
|
|
21
|
+
const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
22
|
+
const feat = s.feature ? ` a ${s.feature.kind} face,` : "";
|
|
23
|
+
return `On sub-part **${s.subPart}**, the user pointed at${feat} local point (${s.point.join(", ")}), `
|
|
24
|
+
+ `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatSelection(selection, { style = "token" } = {}) {
|
|
28
|
+
if (style === "json") return selection;
|
|
29
|
+
if (style === "prompt") return promptStyle(selection);
|
|
30
|
+
return tokenStyle(selection);
|
|
31
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Public surface for the click-to-select module. The future agent harness depends
|
|
2
|
+
// only on attachPicker's onPick callback + the Selection contract — nothing else.
|
|
3
|
+
export { resolveSelection, quantizePoint, snapNormal } from "./resolve.js";
|
|
4
|
+
export { formatSelection } from "./format.js";
|
|
5
|
+
export { attachPicker, worldToSubPartLocal } from "./pick.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Viewer adapter — the ONLY three.js/DOM-aware file in the selection module.
|
|
2
|
+
// Raycasts a click against the visible sub-meshes, converts the hit to the
|
|
3
|
+
// sub-part's local CAD frame, and hands a resolved Selection to onPick.
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { resolveSelection } from "./resolve.js";
|
|
6
|
+
|
|
7
|
+
// Invert the mesh's world transform (pivot rotation + per-view recentring) to recover
|
|
8
|
+
// shared-frame CAD coords — the same frame build() models in.
|
|
9
|
+
export function worldToSubPartLocal(mesh, world) {
|
|
10
|
+
const v = Array.isArray(world) ? new THREE.Vector3(world[0], world[1], world[2]) : world.clone();
|
|
11
|
+
mesh.worldToLocal(v);
|
|
12
|
+
return [v.x, v.y, v.z];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function attachPicker(viewer, { part, getContext, onPick }) {
|
|
16
|
+
const raycaster = new THREE.Raycaster();
|
|
17
|
+
const ndc = new THREE.Vector2();
|
|
18
|
+
let active = false;
|
|
19
|
+
|
|
20
|
+
function onClick(ev) {
|
|
21
|
+
if (!active) return;
|
|
22
|
+
const rect = viewer.domElement.getBoundingClientRect();
|
|
23
|
+
ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
|
24
|
+
ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
|
25
|
+
raycaster.setFromCamera(ndc, viewer.camera);
|
|
26
|
+
|
|
27
|
+
const meshes = Object.values(viewer._subMeshes).filter((m) => m.visible);
|
|
28
|
+
const hit = raycaster.intersectObjects(meshes, false)[0];
|
|
29
|
+
if (!hit) return;
|
|
30
|
+
|
|
31
|
+
const selection = resolveSelection(part, getContext(), {
|
|
32
|
+
subPart: hit.object.name,
|
|
33
|
+
pointLocal: worldToSubPartLocal(hit.object, hit.point),
|
|
34
|
+
// face.normal is in the geometry's local frame, which equals the CAD frame here
|
|
35
|
+
// (the mesh carries no local transform; only its parents rotate/recentre).
|
|
36
|
+
normalLocal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : [0, 0, 0],
|
|
37
|
+
// hit.face metadata (kind/axis/radius) is the L1 increment — not populated yet.
|
|
38
|
+
});
|
|
39
|
+
viewer.flashPoint([hit.point.x, hit.point.y, hit.point.z]);
|
|
40
|
+
onPick(selection);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
viewer.domElement.addEventListener("click", onClick);
|
|
44
|
+
return {
|
|
45
|
+
setActive: (on) => { active = !!on; },
|
|
46
|
+
detach: () => viewer.domElement.removeEventListener("click", onClick),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure core: turn a backend-agnostic raycast hit into a semantic Selection.
|
|
2
|
+
// No three.js, no DOM, no kernel — only the param-deps read-key analysis.
|
|
3
|
+
import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
|
|
4
|
+
|
|
5
|
+
const COS_3DEG = 0.99863; // a normal within 3° of an axis snaps to that axis
|
|
6
|
+
const q2 = (x) => { const r = Math.round(x * 100) / 100; return r === 0 ? 0 : r; }; // 0.01mm, kill -0
|
|
7
|
+
|
|
8
|
+
export function quantizePoint(p) {
|
|
9
|
+
return [q2(p[0]), q2(p[1]), q2(p[2])];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function snapNormal(n) {
|
|
13
|
+
const len = Math.hypot(n[0], n[1], n[2]) || 1;
|
|
14
|
+
const u = [n[0] / len, n[1] / len, n[2] / len];
|
|
15
|
+
let ai = 0; // index of the dominant axis
|
|
16
|
+
if (Math.abs(u[1]) > Math.abs(u[ai])) ai = 1;
|
|
17
|
+
if (Math.abs(u[2]) > Math.abs(u[ai])) ai = 2;
|
|
18
|
+
if (Math.abs(u[ai]) >= COS_3DEG) {
|
|
19
|
+
const axis = [0, 0, 0];
|
|
20
|
+
axis[ai] = u[ai] > 0 ? 1 : -1;
|
|
21
|
+
return axis;
|
|
22
|
+
}
|
|
23
|
+
return [q2(u[0]), q2(u[1]), q2(u[2])];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Only the params the clicked sub-part actually reads — "this geometry, at these inputs".
|
|
27
|
+
function scopeParams(part, view, params, subPart) {
|
|
28
|
+
const reads = subPartReadKeys(part, view, params);
|
|
29
|
+
const keys = reads === RELEVANT_ALL
|
|
30
|
+
? Object.keys(params)
|
|
31
|
+
: [...(reads.get(subPart) ?? Object.keys(params))];
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const k of keys) out[k] = params[k];
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveSelection(part, ctx, hit) {
|
|
38
|
+
const point = quantizePoint(hit.pointLocal);
|
|
39
|
+
const selection = {
|
|
40
|
+
subPart: hit.subPart,
|
|
41
|
+
point,
|
|
42
|
+
normal: snapNormal(hit.normalLocal),
|
|
43
|
+
params: scopeParams(part, ctx.view, ctx.params, hit.subPart),
|
|
44
|
+
};
|
|
45
|
+
if (hit.face) {
|
|
46
|
+
// L1 — feature.selector is the author's own { dir, inPlane, at, near } vocabulary,
|
|
47
|
+
// so the LLM can drop it straight into a faces(...)/edges(...) call.
|
|
48
|
+
const feature = { kind: hit.face.kind, selector: { near: point } };
|
|
49
|
+
if (hit.face.axis != null) { feature.axis = hit.face.axis; feature.selector.dir = hit.face.axis; }
|
|
50
|
+
if (hit.face.radius != null) feature.radius = hit.face.radius;
|
|
51
|
+
selection.feature = feature;
|
|
52
|
+
}
|
|
53
|
+
return selection;
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Persist a little viewer UI state across browser reloads (notably Vite dev
|
|
2
|
+
// auto-refresh) in localStorage. All keys are global. Reads/writes are guarded:
|
|
3
|
+
// if localStorage is unavailable (private mode, disabled) or a value is corrupt,
|
|
4
|
+
// reads return the documented default and writes are no-ops — persistence never
|
|
5
|
+
// throws. Theme is persisted separately (in mount.js) and is not handled here.
|
|
6
|
+
|
|
7
|
+
const KEY = {
|
|
8
|
+
rotating: "partforge:rotating",
|
|
9
|
+
camera: "partforge:camera",
|
|
10
|
+
view: "partforge:view",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function read(key) {
|
|
14
|
+
try { return localStorage.getItem(key); } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
function write(key, value) {
|
|
17
|
+
try { localStorage.setItem(key, value); } catch { /* storage unavailable — no-op */ }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isVec3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n));
|
|
21
|
+
|
|
22
|
+
export function loadRotating() {
|
|
23
|
+
const raw = read(KEY.rotating);
|
|
24
|
+
if (raw === "false") return false;
|
|
25
|
+
if (raw === "true") return true;
|
|
26
|
+
return true; // default: auto-rotate on (matches the viewer's default)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveRotating(on) {
|
|
30
|
+
write(KEY.rotating, on ? "true" : "false");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadCamera() {
|
|
34
|
+
const raw = read(KEY.camera);
|
|
35
|
+
if (!raw) return null;
|
|
36
|
+
let parsed;
|
|
37
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
38
|
+
if (parsed && isVec3(parsed.pos) && isVec3(parsed.target)) {
|
|
39
|
+
return { pos: parsed.pos, target: parsed.target };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveCamera(state) {
|
|
45
|
+
if (!state || !isVec3(state.pos) || !isVec3(state.target)) return;
|
|
46
|
+
write(KEY.camera, JSON.stringify({ pos: state.pos, target: state.target }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadView() {
|
|
50
|
+
return read(KEY.view); // raw string or null; caller validates against available tabs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function saveView(name) {
|
|
54
|
+
if (typeof name === "string" && name) write(KEY.view, name);
|
|
55
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -61,10 +61,23 @@ export function createViewer(container, part) {
|
|
|
61
61
|
const partsGroup = new THREE.Group();
|
|
62
62
|
pivot.add(partsGroup);
|
|
63
63
|
|
|
64
|
+
// Per-sub-part material: parts share the default material unless they declare
|
|
65
|
+
// `display: { color?, opacity? }` (e.g. a reference/ghost part shown in a
|
|
66
|
+
// distinct colour and/or semi-transparent so it reads as "not a printed part").
|
|
67
|
+
function materialFor(name) {
|
|
68
|
+
const disp = part.parts[name].display;
|
|
69
|
+
if (!disp || (disp.color == null && disp.opacity == null)) return material;
|
|
70
|
+
const m = material.clone();
|
|
71
|
+
if (disp.color != null) m.color = new THREE.Color(disp.color);
|
|
72
|
+
if (disp.opacity != null && disp.opacity < 1) { m.transparent = true; m.opacity = disp.opacity; m.depthWrite = false; }
|
|
73
|
+
return m;
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
const subMesh = Object.fromEntries(
|
|
65
|
-
names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(),
|
|
77
|
+
names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(), materialFor(n))])
|
|
66
78
|
);
|
|
67
|
-
for (const m of Object.
|
|
79
|
+
for (const [n, m] of Object.entries(subMesh)) {
|
|
80
|
+
m.name = n;
|
|
68
81
|
m.visible = false;
|
|
69
82
|
partsGroup.add(m);
|
|
70
83
|
}
|
|
@@ -193,6 +206,20 @@ export function createViewer(container, part) {
|
|
|
193
206
|
renderer.render(scene, camera);
|
|
194
207
|
});
|
|
195
208
|
|
|
209
|
+
// --- camera state (read/write for persistence; mount.js owns storage) -------
|
|
210
|
+
function getCameraState() {
|
|
211
|
+
return {
|
|
212
|
+
pos: [camera.position.x, camera.position.y, camera.position.z],
|
|
213
|
+
target: [controls.target.x, controls.target.y, controls.target.z],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function setCameraState({ pos, target }) {
|
|
217
|
+
camera.position.set(pos[0], pos[1], pos[2]);
|
|
218
|
+
controls.target.set(target[0], target[1], target[2]);
|
|
219
|
+
controls.update();
|
|
220
|
+
}
|
|
221
|
+
function onCameraEnd(cb) { controls.addEventListener("end", cb); }
|
|
222
|
+
|
|
196
223
|
// --- dispose --------------------------------------------------------------
|
|
197
224
|
function dispose() {
|
|
198
225
|
renderer.setAnimationLoop(null);
|
|
@@ -200,5 +227,17 @@ export function createViewer(container, part) {
|
|
|
200
227
|
container.removeChild(renderer.domElement);
|
|
201
228
|
}
|
|
202
229
|
|
|
203
|
-
|
|
230
|
+
// Transient marker at a world-space point — visual confirmation of a pick.
|
|
231
|
+
function flashPoint(world) {
|
|
232
|
+
const dot = new THREE.Mesh(
|
|
233
|
+
new THREE.SphereGeometry(1.2, 16, 12),
|
|
234
|
+
new THREE.MeshBasicMaterial({ color: 0xffcc33, depthTest: false })
|
|
235
|
+
);
|
|
236
|
+
dot.renderOrder = 999;
|
|
237
|
+
dot.position.set(world[0], world[1], world[2]);
|
|
238
|
+
scene.add(dot);
|
|
239
|
+
setTimeout(() => { scene.remove(dot); dot.geometry.dispose(); dot.material.dispose(); }, 1200);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, _subCache: subCache, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint };
|
|
204
243
|
}
|