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
@@ -0,0 +1,128 @@
1
+ // Always-on hover inspection: a cursor-following tooltip naming the feature +
2
+ // sub-part under the pointer, and an overlay mesh highlighting the feature's
3
+ // surface. Feature names come from Solid.label() in the part's build, carried
4
+ // per-triangle in the mesh payload (geometry.userData.featureIds/features).
5
+ import * as THREE from "three";
6
+ import { raycastViewer } from "./raycast.js";
7
+
8
+ const HIGHLIGHT = 0x4da3ff;
9
+
10
+ // Extract the subset of a non-indexed geometry belonging to one feature id.
11
+ function featureSubset(geometry, featureId) {
12
+ const { featureIds } = geometry.userData;
13
+ const pos = geometry.getAttribute("position");
14
+ let count = 0;
15
+ for (let t = 0; t < featureIds.length; t++) if (featureIds[t] === featureId) count++;
16
+ const out = new Float32Array(count * 9);
17
+ let o = 0;
18
+ for (let t = 0; t < featureIds.length; t++) {
19
+ if (featureIds[t] !== featureId) continue;
20
+ for (let v = 0; v < 3; v++) {
21
+ out[o++] = pos.getX(t * 3 + v); out[o++] = pos.getY(t * 3 + v); out[o++] = pos.getZ(t * 3 + v);
22
+ }
23
+ }
24
+ const g = new THREE.BufferGeometry();
25
+ g.setAttribute("position", new THREE.BufferAttribute(out, 3));
26
+ return g;
27
+ }
28
+
29
+ export function attachHoverLabels(viewer, { part, schedule = (cb) => requestAnimationFrame(cb) }) {
30
+ // Hover is a mouse idiom — skip entirely on touch-only devices.
31
+ if (globalThis.matchMedia && !matchMedia("(hover: hover)").matches) return { detach: () => {} };
32
+
33
+ const tip = document.createElement("div");
34
+ tip.id = "pf-hover-tip";
35
+ const feat = document.createElement("b");
36
+ const sub = document.createElement("span");
37
+ sub.className = "pf-hover-sub";
38
+ tip.append(feat, sub);
39
+ document.body.appendChild(tip);
40
+
41
+ const material = new THREE.MeshBasicMaterial({
42
+ color: HIGHLIGHT, transparent: true, opacity: 0.35,
43
+ polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
44
+ });
45
+ const overlay = new THREE.Mesh(new THREE.BufferGeometry(), material);
46
+ overlay.visible = false;
47
+ overlay.renderOrder = 2;
48
+ let overlayParent = null;
49
+ // Subset cache per sub-part: rebuilt when the sub-part's geometry object changes
50
+ // (i.e. after a regenerate) — keyed on the geometry instance.
51
+ const subsets = new Map(); // subPart -> { geo, byId: Map(featureId -> BufferGeometry) }
52
+
53
+ const subLabel = (name) => part.parts[name]?.label ?? name;
54
+
55
+ function clearHighlight() {
56
+ overlay.visible = false;
57
+ }
58
+
59
+ function hide() {
60
+ tip.classList.remove("show");
61
+ clearHighlight();
62
+ }
63
+
64
+ function show(hit, x, y) {
65
+ if (hit.feature) {
66
+ feat.textContent = hit.feature.label;
67
+ sub.textContent = subLabel(hit.subPart);
68
+ const cached = subsets.get(hit.subPart);
69
+ let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
70
+ if (!byId) {
71
+ for (const g of cached?.byId.values() ?? []) g.dispose();
72
+ byId = new Map();
73
+ subsets.set(hit.subPart, { geo: hit.mesh.geometry, byId });
74
+ }
75
+ let g = byId.get(hit.feature.id);
76
+ if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
77
+ overlay.geometry = g;
78
+ if (overlayParent !== hit.mesh.parent) { hit.mesh.parent.add(overlay); overlayParent = hit.mesh.parent; }
79
+ overlay.visible = true;
80
+ } else {
81
+ feat.textContent = subLabel(hit.subPart);
82
+ sub.textContent = "";
83
+ clearHighlight();
84
+ }
85
+ tip.style.left = `${x + 14}px`;
86
+ tip.style.top = `${y + 14}px`;
87
+ tip.classList.add("show");
88
+ }
89
+
90
+ let pending = null; // latest pointer position; one raycast per scheduled frame
91
+ let down = false;
92
+
93
+ function onMove(ev) {
94
+ if (ev.pointerType === "touch") return;
95
+ if (down) return;
96
+ const had = pending;
97
+ pending = { x: ev.clientX, y: ev.clientY };
98
+ if (had) return; // a frame is already scheduled
99
+ schedule(() => {
100
+ const p = pending;
101
+ pending = null;
102
+ if (!p || down) return;
103
+ const hit = raycastViewer(viewer, p.x, p.y);
104
+ if (hit) show(hit, p.x, p.y); else hide();
105
+ });
106
+ }
107
+ const onDown = () => { down = true; hide(); };
108
+ const onUp = () => { down = false; };
109
+ const onLeave = () => hide();
110
+
111
+ viewer.domElement.addEventListener("pointermove", onMove);
112
+ viewer.domElement.addEventListener("pointerdown", onDown);
113
+ viewer.domElement.addEventListener("pointerup", onUp);
114
+ viewer.domElement.addEventListener("pointerleave", onLeave);
115
+
116
+ return {
117
+ detach: () => {
118
+ viewer.domElement.removeEventListener("pointermove", onMove);
119
+ viewer.domElement.removeEventListener("pointerdown", onDown);
120
+ viewer.domElement.removeEventListener("pointerup", onUp);
121
+ viewer.domElement.removeEventListener("pointerleave", onLeave);
122
+ tip.remove();
123
+ overlayParent?.remove(overlay);
124
+ for (const { byId } of subsets.values()) for (const g of byId.values()) g.dispose();
125
+ material.dispose();
126
+ },
127
+ };
128
+ }
@@ -3,3 +3,6 @@
3
3
  export { resolveSelection, quantizePoint, snapNormal } from "./resolve.js";
4
4
  export { formatSelection } from "./format.js";
5
5
  export { attachPicker, worldToSubPartLocal } from "./pick.js";
6
+ export { attachPickToggle } from "./pick-toggle.js";
7
+ export { raycastViewer, featureAt } from "./raycast.js";
8
+ export { attachHoverLabels } from "./hover.js";
@@ -0,0 +1,34 @@
1
+ import { attachPicker } from "./pick.js";
2
+ import { formatSelection } from "./format.js";
3
+
4
+ // The ?pick clipboard mode: a fixed toggle button + a transient toast. Clicking the
5
+ // button arms the picker; clicking geometry copies a selection token and flashes it.
6
+ // Styles live in app.css (#pf-pick / #pf-pick-toast). Self-contained — dropping the
7
+ // call in mount and this file reverts the feature exactly.
8
+ export function attachPickToggle(viewer, { part, getContext }) {
9
+ const btn = document.createElement("button");
10
+ btn.id = "pf-pick";
11
+ btn.textContent = "Pick";
12
+ btn.title = "Click a surface to copy a selection token";
13
+ document.body.appendChild(btn);
14
+
15
+ const toast = document.createElement("div");
16
+ toast.id = "pf-pick-toast";
17
+ document.body.appendChild(toast);
18
+
19
+ let hideTimer;
20
+ const picker = attachPicker(viewer, {
21
+ part,
22
+ getContext,
23
+ onPick: (selection) => {
24
+ const token = formatSelection(selection, { style: "token" });
25
+ navigator.clipboard?.writeText(token);
26
+ toast.textContent = `copied: ${token}`;
27
+ toast.classList.add("show");
28
+ clearTimeout(hideTimer);
29
+ hideTimer = setTimeout(() => toast.classList.remove("show"), 4000);
30
+ },
31
+ });
32
+
33
+ btn.addEventListener("click", () => picker.setActive(btn.classList.toggle("on")));
34
+ }
@@ -1,42 +1,19 @@
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";
1
+ // Viewer adapter for click-to-select: arms a click listener, raycasts via the shared
2
+ // selection raycast, and hands a resolved Selection to onPick.
3
+ import { raycastViewer, worldToSubPartLocal } from "./raycast.js";
5
4
  import { resolveSelection } from "./resolve.js";
6
5
 
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
- }
6
+ export { worldToSubPartLocal };
14
7
 
15
8
  export function attachPicker(viewer, { part, getContext, onPick }) {
16
- const raycaster = new THREE.Raycaster();
17
- const ndc = new THREE.Vector2();
18
9
  let active = false;
19
10
 
20
11
  function onClick(ev) {
21
12
  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];
13
+ const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
29
14
  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]);
15
+ const selection = resolveSelection(part, getContext(), hit);
16
+ viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
40
17
  onPick(selection);
41
18
  }
42
19
 
@@ -0,0 +1,43 @@
1
+ // Shared raycast for the selection modules: pointer position → the sub-part mesh,
2
+ // triangle, CAD-local point/normal, and (when the mesh carries attribution) the
3
+ // feature under the pointer. Used by both the click-picker and the hover-labeler.
4
+ import * as THREE from "three";
5
+
6
+ const raycaster = new THREE.Raycaster();
7
+ const ndc = new THREE.Vector2();
8
+
9
+ // Invert the mesh's world transform (pivot rotation + per-view recentring) to recover
10
+ // shared-frame CAD coords — the same frame build() models in.
11
+ export function worldToSubPartLocal(mesh, world) {
12
+ const v = Array.isArray(world) ? new THREE.Vector3(world[0], world[1], world[2]) : world.clone();
13
+ mesh.worldToLocal(v);
14
+ return [v.x, v.y, v.z];
15
+ }
16
+
17
+ // The feature carried by a mesh triangle, or null (unlabeled / no attribution data).
18
+ export function featureAt(mesh, triIndex) {
19
+ const { featureIds, features } = mesh.geometry.userData;
20
+ const id = featureIds?.[triIndex] ?? 0;
21
+ return id > 0 ? { id, label: features[id - 1] } : null;
22
+ }
23
+
24
+ export function raycastViewer(viewer, clientX, clientY) {
25
+ const rect = viewer.domElement.getBoundingClientRect();
26
+ ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
27
+ ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
28
+ raycaster.setFromCamera(ndc, viewer.camera);
29
+ const meshes = Object.values(viewer._subMeshes).filter((m) => m.visible);
30
+ const hit = raycaster.intersectObjects(meshes, false)[0];
31
+ if (!hit) return null;
32
+ return {
33
+ mesh: hit.object,
34
+ subPart: hit.object.name,
35
+ triIndex: hit.faceIndex,
36
+ pointWorld: hit.point,
37
+ pointLocal: worldToSubPartLocal(hit.object, hit.point),
38
+ // face.normal is in the geometry's local frame, which equals the CAD frame here
39
+ // (the mesh carries no local transform; only its parents rotate/recentre).
40
+ normalLocal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : [0, 0, 0],
41
+ feature: featureAt(hit.object, hit.faceIndex),
42
+ };
43
+ }
@@ -42,13 +42,8 @@ export function resolveSelection(part, ctx, hit) {
42
42
  normal: snapNormal(hit.normalLocal),
43
43
  params: scopeParams(part, ctx.view, ctx.params, hit.subPart),
44
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
- }
45
+ // Feature attribution from the mesh payload (Solid.label() in the part's build) —
46
+ // the same name the hover tooltip shows, so user, agent, and viewer share vocabulary.
47
+ if (hit.feature) selection.feature = { label: hit.feature.label };
53
48
  return selection;
54
49
  }
@@ -0,0 +1,18 @@
1
+ // The status line, busy overlay, and export-button enabling — mount's host-page
2
+ // chrome, as one small adapter. #status/#busy/#phase are required page elements;
3
+ // export buttons are looked up by id and any that are absent are simply skipped.
4
+ export function createStatusUi(doc = document) {
5
+ const statusEl = doc.getElementById("status");
6
+ const busyEl = doc.getElementById("busy");
7
+ const phaseEl = doc.getElementById("phase");
8
+ const exportBtns = ["download", "download-step", "download-3mf"]
9
+ .map((id) => doc.getElementById(id)).filter(Boolean);
10
+
11
+ return {
12
+ setStatus(msg, isErr = false) { statusEl.textContent = msg; statusEl.classList.toggle("err", isErr); },
13
+ showBusy(phase) { phaseEl.textContent = `${phase}…`; busyEl.classList.add("show"); },
14
+ hideBusy() { busyEl.classList.remove("show"); },
15
+ setExportEnabled(on) { exportBtns.forEach((b) => { b.disabled = !on; }); },
16
+ statusText: () => statusEl.textContent,
17
+ };
18
+ }
@@ -2,12 +2,13 @@
2
2
  // auto-refresh) in localStorage. All keys are global. Reads/writes are guarded:
3
3
  // if localStorage is unavailable (private mode, disabled) or a value is corrupt,
4
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.
5
+ // throws.
6
6
 
7
7
  const KEY = {
8
8
  rotating: "partforge:rotating",
9
9
  camera: "partforge:camera",
10
10
  view: "partforge:view",
11
+ theme: "partforge:theme",
11
12
  };
12
13
 
13
14
  function read(key) {
@@ -46,6 +47,15 @@ export function saveCamera(state) {
46
47
  write(KEY.camera, JSON.stringify({ pos: state.pos, target: state.target }));
47
48
  }
48
49
 
50
+ export function loadTheme() {
51
+ const raw = read(KEY.theme);
52
+ return raw === "light" ? "light" : "dark"; // default: dark (matches the viewer's default)
53
+ }
54
+
55
+ export function saveTheme(mode) {
56
+ if (mode === "light" || mode === "dark") write(KEY.theme, mode);
57
+ }
58
+
49
59
  export function loadView() {
50
60
  return read(KEY.view); // raw string or null; caller validates against available tabs
51
61
  }
@@ -0,0 +1,33 @@
1
+ import { loadView, saveView } from "./view-state.js";
2
+
3
+ // The view-tab segmented control. When the part declares `views`, the buttons are
4
+ // generated from it (part.views is the single source of truth — host pages leave
5
+ // the #part div empty); a part without `views` keeps whatever buttons the page
6
+ // hand-wrote. The active view persists across reloads via view-state.
7
+ export function createViewTabs(el, part, { onChange }) {
8
+ if (el && part.views) {
9
+ el.innerHTML = Object.entries(part.views)
10
+ .map(([key, v], i) => `<button data-part="${key}"${i === 0 ? ' class="on"' : ""}>${v?.label ?? key}</button>`)
11
+ .join("");
12
+ }
13
+
14
+ const setActive = (btn) => { for (const b of el.children) b.classList.toggle("on", b === btn); };
15
+
16
+ // Initial view: the saved one if it still matches a tab, else the active (first) tab.
17
+ const defaultView = el.querySelector("button.on")?.dataset.part ?? el.querySelector("button")?.dataset.part;
18
+ const saved = loadView();
19
+ const savedBtn = saved ? [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === saved) : null;
20
+ let view = savedBtn ? saved : defaultView;
21
+ if (savedBtn) setActive(savedBtn);
22
+
23
+ el.addEventListener("click", (e) => {
24
+ const btn = e.target.closest("button[data-part]");
25
+ if (!btn) return;
26
+ view = btn.dataset.part;
27
+ saveView(view);
28
+ setActive(btn);
29
+ onChange(view);
30
+ });
31
+
32
+ return { current: () => view };
33
+ }
@@ -0,0 +1,48 @@
1
+ import { loadRotating, saveRotating, saveCamera, loadTheme, saveTheme } from "./view-state.js";
2
+
3
+ // Wire the optional viewer-chrome buttons on the host page (#pause / #reframe /
4
+ // #theme) to the viewer, plus persist the camera pose. Each button is optional —
5
+ // omit it from the page and its behavior is simply absent. Self-contained: touches
6
+ // only the viewer and the DOM, none of the part/params/regenerate state.
7
+ export function attachViewerControls(viewer) {
8
+ const pauseBtn = document.getElementById("pause");
9
+ const reframeBtn = document.getElementById("reframe");
10
+ const themeBtn = document.getElementById("theme");
11
+
12
+ // Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
13
+ // scene together; remember the choice across reloads.
14
+ let theme = loadTheme();
15
+ function applyTheme(mode) {
16
+ theme = mode;
17
+ document.documentElement.dataset.theme = mode;
18
+ viewer.setTheme(mode);
19
+ themeBtn?.classList.toggle("on", mode === "light");
20
+ saveTheme(mode);
21
+ }
22
+ applyTheme(theme);
23
+ themeBtn?.addEventListener("click", () => applyTheme(theme === "light" ? "dark" : "light"));
24
+
25
+ // Pause/resume the idle auto-rotation.
26
+ let rotating = loadRotating();
27
+ viewer.setAutoRotate(rotating);
28
+ const syncPause = () => {
29
+ if (!pauseBtn) return;
30
+ pauseBtn.textContent = rotating ? "⏸" : "▶";
31
+ pauseBtn.title = rotating ? "Pause rotation" : "Resume rotation";
32
+ };
33
+ syncPause();
34
+ pauseBtn?.addEventListener("click", () => {
35
+ rotating = !rotating;
36
+ viewer.setAutoRotate(rotating);
37
+ syncPause();
38
+ saveRotating(rotating);
39
+ });
40
+
41
+ // Re-fit the camera to the current view.
42
+ reframeBtn?.addEventListener("click", () => viewer.frame());
43
+
44
+ // Persist the camera when the user finishes an orbit/zoom, and right before a
45
+ // reload (captures the latest pose, including auto-rotation drift).
46
+ viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
47
+ window.addEventListener("pagehide", () => saveCamera(viewer.getCameraState()));
48
+ }
@@ -105,7 +105,7 @@ export function createViewer(container, part) {
105
105
  // --- geometry builder -----------------------------------------------------
106
106
  // BufferGeometry from a worker mesh payload — kept in its shared-frame coords
107
107
  // (NOT recentred) so the pieces assemble in the right relative positions.
108
- function buildGeometry({ positions, normals, indices, triangles, edges }) {
108
+ function buildGeometry({ positions, normals, indices, triangles, edges, featureIds, features }) {
109
109
  const geo = new THREE.BufferGeometry();
110
110
  geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
111
111
  if (indices?.length) geo.setIndex(new THREE.BufferAttribute(indices, 1)); // Manifold is non-indexed
@@ -122,6 +122,7 @@ export function createViewer(container, part) {
122
122
  out.computeBoundingBox();
123
123
  }
124
124
  out.userData.triangles = triCount;
125
+ if (featureIds) { out.userData.featureIds = featureIds; out.userData.features = features; }
125
126
  // feature edge lines: Manifold supplies seam-aware segments; else derive by angle
126
127
  const lg = new LineSegmentsGeometry();
127
128
  if (edges?.length) lg.setPositions(edges);
@@ -134,9 +135,15 @@ export function createViewer(container, part) {
134
135
  const subCache = Object.fromEntries(names.map((n) => [n, null]));
135
136
 
136
137
  function setSubGeometry(name, payload) {
138
+ const prev = subCache[name]; // free the geometry this replaces (its own buffers + edge lines)
139
+ if (prev) { prev.userData.edges?.dispose(); prev.dispose(); }
137
140
  subCache[name] = buildGeometry(payload);
138
141
  }
139
142
 
143
+ // Cache queries for the app's regenerate loop (so it never reaches into subCache).
144
+ const hasSubMesh = (name) => !!subCache[name];
145
+ const subTriangles = (name) => subCache[name]?.userData.triangles ?? 0;
146
+
140
147
  // --- show / hide assembly -------------------------------------------------
141
148
  const _box = new THREE.Box3();
142
149
 
@@ -227,13 +234,6 @@ export function createViewer(container, part) {
227
234
  }
228
235
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
229
236
 
230
- // --- dispose --------------------------------------------------------------
231
- function dispose() {
232
- renderer.setAnimationLoop(null);
233
- renderer.dispose();
234
- container.removeChild(renderer.domElement);
235
- }
236
-
237
237
  // Transient marker at a world-space point — visual confirmation of a pick.
238
238
  function flashPoint(world) {
239
239
  const dot = new THREE.Mesh(
@@ -246,5 +246,5 @@ export function createViewer(container, part) {
246
246
  setTimeout(() => { scene.remove(dot); dot.geometry.dispose(); dot.material.dispose(); }, 1200);
247
247
  }
248
248
 
249
- return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, _subCache: subCache, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint };
249
+ return { showAssembly, hideAssembly, setSubGeometry, hasSubMesh, subTriangles, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint };
250
250
  }
@@ -29,23 +29,6 @@ async function occtKernel() {
29
29
  return createOcctKernel(replicad);
30
30
  }
31
31
 
32
- // Transfer the big binary buffers (zero-copy) instead of structured-cloning them.
33
- function transferOf(m) {
34
- if (m.type === "meshes") {
35
- const t = [];
36
- for (const x of m.meshes) {
37
- t.push(x.positions.buffer);
38
- if (x.normals?.buffer) t.push(x.normals.buffer);
39
- if (x.indices?.buffer) t.push(x.indices.buffer);
40
- if (x.edges?.buffer) t.push(x.edges.buffer);
41
- }
42
- return t;
43
- }
44
- if (m.type === "download-parts") return m.parts.map((p) => (ArrayBuffer.isView(p.data) ? p.data.buffer : p.data));
45
- if (m.type === "download") return [m.data];
46
- return [];
47
- }
48
-
49
32
  export function runWorker(part) {
50
33
  const backend = self.name === "occt" ? "occt" : "manifold";
51
34
  let manifold = null; // { preview, print }
@@ -55,14 +38,22 @@ export function runWorker(part) {
55
38
  // Manifold is cheap to boot — bring it up eagerly and signal readiness.
56
39
  if (backend === "manifold") {
57
40
  booting = manifoldKernels().then((m) => { manifold = m; postMessage({ type: "ready" }); });
41
+ } else {
42
+ // OCCT boots lazily (its ~11 MB WASM loads on the first job), but the worker can
43
+ // accept jobs as soon as its module graph is up — messages queue in the port.
44
+ // EVERY worker must post ready: mount gates the first generate on it, so if only
45
+ // the manifold worker signalled, boot would silently depend on the manifold
46
+ // worker always being spawned alongside this one.
47
+ postMessage({ type: "ready" });
58
48
  }
59
49
 
60
50
  self.onmessage = async (e) => {
61
51
  let kernel;
62
52
  if (backend === "manifold") {
63
53
  await booting;
64
- const printJob = e.data.type === "export-stl" || e.data.type === "export-3mf"; // high-res mesh exports
65
- kernel = printJob ? manifold.print : manifold.preview;
54
+ // The sender declares the job's mesh quality; the worker knows nothing about
55
+ // job-type semantics (mount marks STL/3MF exports quality:"print").
56
+ kernel = e.data.quality === "print" ? manifold.print : manifold.preview;
66
57
  } else {
67
58
  if (!occt) {
68
59
  postMessage({ type: "progress", phase: "loading exact kernel" }); // feedback during cold boot
@@ -71,6 +62,7 @@ export function runWorker(part) {
71
62
  }
72
63
  kernel = occt;
73
64
  }
74
- await handle(kernel, part, e.data, (m) => postMessage(m, transferOf(m)));
65
+ // handle() declares each message's transferables (the big binary buffers).
66
+ await handle(kernel, part, e.data, (m, transfer = []) => postMessage(m, transfer));
75
67
  };
76
68
  }
@@ -0,0 +1,75 @@
1
+ // Example PartDefinition — the motivating showcase for k.loft(). Silhouette rings are
2
+ // stacked up a smooth base→waist→rim curve; each ring is a regular n-gon rotated by a
3
+ // running twist plus an alternating half-facet offset, so the facets zig-zag into a
4
+ // woven look. A second, wall-inset loft is cut from the body to hollow it (Manifold
5
+ // backend, so it stays fast — no OCCT). See docs/AUTHORING-PARTS.md for the conventions.
6
+ import { regularPolygon } from "partforge/geometry";
7
+
8
+ const RINGS = 28; // silhouette resolution (ring count up the height)
9
+
10
+ // Body radius at height fraction t (0..1): a quadratic Bézier through base/waist/rim.
11
+ const silhouette = (t, p) => { const a = 1 - t; return a * a * p.baseR + 2 * a * t * p.waistR + t * t * p.rimR; };
12
+
13
+ // Ring list for a wall at radial `inner` inset (offset along the face normal so the
14
+ // perpendicular wall stays == p.wall on every facet). inner=false → outer surface.
15
+ const vaseRings = (p, inner) => {
16
+ const inset = inner ? p.wall / Math.cos(Math.PI / p.facets) : 0;
17
+ const out = [];
18
+ for (let i = 0; i <= RINGS; i++) {
19
+ const t = i / RINGS;
20
+ const radius = Math.max(silhouette(t, p) - inset, 0.5);
21
+ const rotate = p.twist * t + (i % 2) * (180 / p.facets); // running twist + alternating half-facet
22
+ out.push({ sides: p.facets, radius, z: p.height * t, rotate });
23
+ }
24
+ return out;
25
+ };
26
+
27
+ export default {
28
+ meta: { title: "Faceted Vase", units: "mm", background: 0x15181d },
29
+ parameters: [
30
+ {
31
+ id: "body",
32
+ title: "Body",
33
+ description: "A faceted, twisting vase built from stacked cross-sections (`k.loft`). " +
34
+ "Pick a preset, or open **Advanced** for exact dimensions. **Facets** and **Twist** are the styling; **Wall** decides whether it prints cleanly.",
35
+ presets: {
36
+ "Tulip vase": { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2 },
37
+ "Barrel pot": { height: 90, baseR: 40, waistR: 44, rimR: 38, facets: 8, twist: 0, wall: 2.4 },
38
+ "Twist column": { height: 180, baseR: 30, waistR: 30, rimR: 30, facets: 6, twist: 120, wall: 2 },
39
+ },
40
+ advanced: [
41
+ { key: "height", label: "Height", unit: "mm", min: 40, max: 220, step: 1, description: "Overall height along the axis." },
42
+ { key: "baseR", label: "Base radius", unit: "mm", min: 15, max: 70, step: 1, description: "Across-corners radius at the foot." },
43
+ { key: "waistR", label: "Waist radius", unit: "mm", min: 12, max: 80, step: 1, description: "Radius at mid-height — set below base+rim to pinch a waist, above to bulge a belly." },
44
+ { key: "rimR", label: "Rim radius", unit: "mm", min: 12, max: 80, step: 1, description: "Across-corners radius at the mouth." },
45
+ { key: "facets", label: "Facets", min: 3, max: 12, step: 1, description: "Sides of each cross-section. Low counts read crystalline; high counts approach smooth." },
46
+ { key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5, description: "Total rotation of the facets from foot to rim, for a spiral." },
47
+ { key: "wall", label: "Wall thickness", unit: "mm", min: 1, max: 5, step: 0.1, description: "Perpendicular wall thickness. The fdm-pla profile wants **≥ 1.2 mm**." },
48
+ { key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 8, step: 0.5, hidden: true, description: "Internal: solid base thickness; hidden but drives the geometry." },
49
+ ],
50
+ },
51
+ ],
52
+ defaults: { height: 150, baseR: 35, waistR: 26, rimR: 40, facets: 5, twist: 40, wall: 2, floor: 3 },
53
+ parts: {
54
+ vase: {
55
+ label: "Vase", views: ["vase"], export: { name: "vase" },
56
+ build: (k, p) => {
57
+ const body = k.loft(vaseRings(p, false)).label("Faceted wall");
58
+ // Hollow it: an inset loft clipped to z ≥ floor (so the base stays solid), cut from the body.
59
+ const cavity = k.loft(vaseRings(p, true))
60
+ .intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10])).label("Cavity");
61
+ return body.cut(cavity);
62
+ },
63
+ },
64
+ },
65
+ views: { vase: { label: "Vase" } },
66
+ // Self-verification: opt into the FDM-PLA profile (bed-fit gate + min-wall warning) and
67
+ // pin the intent — an open vessel (no through-holes), fits the bed, no interpenetration.
68
+ verify: {
69
+ process: "fdm-pla",
70
+ expect: {
71
+ vase: { holes: 0, bbox: "<=[220,220,230]" },
72
+ _view: { overlaps: 0 },
73
+ },
74
+ },
75
+ };
@@ -37,7 +37,7 @@ export default {
37
37
  // the shortest edge it touches (here the fillets' bottom arcs), so it stops at
38
38
  // its valid maximum instead of mangling the bottom face.
39
39
  if (p.chamfer > 0) s = s.chamfer(p.chamfer, { inPlane: "XY", at: 0 }); // base edges
40
- if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).at([p.w / 2, p.d / 2, -1]));
40
+ if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).at([p.w / 2, p.d / 2, -1]).label("Bore"));
41
41
  return s;
42
42
  },
43
43
  },
@@ -89,7 +89,7 @@ export default {
89
89
  views: ["planter"],
90
90
  export: { name: "planter" },
91
91
  build: (k, p, d) => {
92
- const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper, twist: p.twist });
92
+ const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper, twist: p.twist }).label("Faceted wall");
93
93
  // Hollow it. The cavity is built from z=0 sharing the body's exact twist RATE and
94
94
  // taper slope (f rescales the ~4 mm overshoot so the rates still match), so the
95
95
  // inner and outer facets stay radially aligned at every height — the wall can't
@@ -97,10 +97,11 @@ export default {
97
97
  const f = (p.height + 4) / p.height;
98
98
  const cavity = k
99
99
  .prism(d.innerPts, p.height + 4, { scaleTop: 1 + (d.innerTaper - 1) * f, twist: p.twist * f })
100
- .intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10]));
100
+ .intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10]))
101
+ .label("Cavity");
101
102
  let s = body.cut(cavity);
102
103
  // Optional drainage hole straight through the base.
103
- if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]));
104
+ if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
104
105
  return s;
105
106
  },
106
107
  },