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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/bin/cli.js +73 -0
  3. package/docs/AUTHORING-PARTS.md +200 -3
  4. package/package.json +15 -1
  5. package/src/app-filleted-box.js +7 -0
  6. package/src/filleted-box-worker.js +3 -0
  7. package/src/framework/app.css +26 -1
  8. package/src/framework/controls.js +153 -36
  9. package/src/framework/debug-overlay.js +40 -0
  10. package/src/framework/geometry/edge-selector.js +17 -0
  11. package/src/framework/geometry/errors.js +10 -0
  12. package/src/framework/geometry/face-selector.js +19 -0
  13. package/src/framework/geometry/kernel.js +9 -1
  14. package/src/framework/geometry/manifold-backend.js +76 -18
  15. package/src/framework/geometry/occt-backend.js +118 -4
  16. package/src/framework/geometry/polygon.js +117 -0
  17. package/src/framework/geometry/probe.js +52 -0
  18. package/src/framework/geometry/solid-cache.js +39 -0
  19. package/src/framework/geometry/solid-hash.js +21 -0
  20. package/src/framework/geometry-service.js +8 -10
  21. package/src/framework/jobs.js +24 -8
  22. package/src/framework/markdown.js +41 -0
  23. package/src/framework/mount.js +144 -18
  24. package/src/framework/param-deps.js +81 -0
  25. package/src/framework/selection/format.js +31 -0
  26. package/src/framework/selection/index.js +5 -0
  27. package/src/framework/selection/pick.js +48 -0
  28. package/src/framework/selection/resolve.js +54 -0
  29. package/src/framework/view-state.js +55 -0
  30. package/src/framework/viewer.js +42 -3
  31. package/src/parts/demo.js +29 -11
  32. package/src/parts/filleted-box.js +46 -0
  33. package/src/testing/build.js +17 -0
  34. package/src/testing/measure.js +53 -0
  35. package/src/testing/mesh.js +27 -0
  36. package/src/testing/render.js +159 -0
  37. package/src/testing.js +3 -0
@@ -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 routes preview generate through the OCCT worker (dev toggle).
20
- const occtPreview = new URLSearchParams(location.search).get("backend") === "occt";
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 host page owns the view-tab markup (#part buttons, data-part = view name);
40
- // `part.views` documents the available views and their labels for that page.
41
- // The initial view is whichever tab the page marks active (else the first tab).
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
- let view = partSeg.querySelector("button.on")?.dataset.part ?? partSeg.querySelector("button")?.dataset.part;
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 cacheVersion = Object.fromEntries(names.map((n) => [n, -1])); // params version each was built at
119
+ const cacheHash = {}; // n -> relevance hash each sub-part's cached mesh was built at
50
120
 
51
- // A cached sub-part is current only if it was built at the latest params version.
52
- const isCurrent = (n) => viewer._subCache[n] && cacheVersion[n] === paramsVersion;
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) framedView = view;
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
- cacheVersion[m.name] = genVersion;
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, occtPreview });
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 missing = missingParts();
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 = true;
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
+ }
@@ -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(), material)])
77
+ names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(), materialFor(n))])
66
78
  );
67
- for (const m of Object.values(subMesh)) {
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
- return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, _subCache: subCache };
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
  }