partforge 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -14
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +48 -23
- package/package.json +1 -1
- package/src/app-planter.js +10 -0
- package/src/framework/app.css +94 -29
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/kernel-front.js +31 -0
- package/src/framework/geometry/kernel.js +53 -10
- package/src/framework/geometry/manifold-backend.js +48 -25
- package/src/framework/geometry/occt-backend.js +47 -96
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +120 -0
- package/src/planter-worker.js +3 -0
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
|
@@ -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
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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,
|
|
249
|
+
return { showAssembly, hideAssembly, setSubGeometry, hasSubMesh, subTriangles, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint };
|
|
250
250
|
}
|
package/src/framework/worker.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
},
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Example PartDefinition — a faceted planter / cup / vase. A second worked example
|
|
2
|
+
// alongside parts/demo.js (the Spacer): it shows a prism-based body (Manifold backend,
|
|
3
|
+
// so it stays fast — no OCCT), per-control descriptions, presets, an optional feature
|
|
4
|
+
// (drainage), a hidden internal constant (floor), and a derive() that turns raw inputs
|
|
5
|
+
// into the n-gon point lists and dependent dimensions the build consumes.
|
|
6
|
+
//
|
|
7
|
+
// Why it's a good demo: every control has an obvious reason to touch it before
|
|
8
|
+
// printing. Facets/twist are pure fun, height/diameter/taper fit it to your plant or
|
|
9
|
+
// pens, the drainage hole is a real functional choice (planter vs. cup), and dropping
|
|
10
|
+
// Wall below the fdm-pla 1.2 mm minimum trips partforge's min-wall warning.
|
|
11
|
+
|
|
12
|
+
// A regular n-gon of circumradius R, in the XY plane, as [[x,y],…] for k.prism.
|
|
13
|
+
// A small rotation seats a flat edge toward the viewer so even-sided shapes read right.
|
|
14
|
+
const ngon = (R, n) => {
|
|
15
|
+
const pts = [];
|
|
16
|
+
const offset = Math.PI / n - Math.PI / 2; // flat side facing -Y
|
|
17
|
+
for (let i = 0; i < n; i++) {
|
|
18
|
+
const a = (2 * Math.PI * i) / n + offset;
|
|
19
|
+
pts.push([R * Math.cos(a), R * Math.sin(a)]);
|
|
20
|
+
}
|
|
21
|
+
return pts;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
meta: { title: "Faceted Planter", units: "mm", background: 0x15181d },
|
|
26
|
+
parameters: [
|
|
27
|
+
{
|
|
28
|
+
id: "body",
|
|
29
|
+
title: "Body",
|
|
30
|
+
description:
|
|
31
|
+
"The faceted vessel. Pick a preset to start, or open **Advanced** for exact dimensions. " +
|
|
32
|
+
"**Facets** and **Twist** are pure styling; **Wall** is the one that decides whether it prints cleanly.",
|
|
33
|
+
presets: {
|
|
34
|
+
"Pen cup": { facets: 6, dia: 80, height: 100, taper: 1.0, twist: 0, drain: 0 },
|
|
35
|
+
Planter: { facets: 8, dia: 90, height: 80, taper: 0.9, twist: 0, drain: 8 },
|
|
36
|
+
Vase: { facets: 5, dia: 70, height: 150, taper: 1.12, twist: 40, drain: 0 },
|
|
37
|
+
},
|
|
38
|
+
advanced: [
|
|
39
|
+
{ key: "facets", label: "Facets", min: 3, max: 12, step: 1,
|
|
40
|
+
description: "Number of flat sides around the body. Low counts read as crystalline; high counts approach a smooth cylinder." },
|
|
41
|
+
{ key: "dia", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
|
|
42
|
+
description: "Across-corners diameter at the base. Size it to the plant, pens, or shelf it has to fit." },
|
|
43
|
+
{ key: "height", label: "Height", unit: "mm", min: 20, max: 200, step: 1,
|
|
44
|
+
description: "Overall height along the axis." },
|
|
45
|
+
{ key: "taper", label: "Top taper", min: 0.6, max: 1.4, step: 0.02,
|
|
46
|
+
description: "Rim size relative to the base: below 1 tapers inward (planter), 1 is straight (cup), above 1 flares out (vase)." },
|
|
47
|
+
{ key: "wall", label: "Wall thickness", unit: "mm", min: 0.8, max: 4, step: 0.1,
|
|
48
|
+
description: "Side-wall thickness. The fdm-pla profile wants **≥ 1.2 mm** — go thinner and partforge flags a min-wall warning." },
|
|
49
|
+
{ key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5,
|
|
50
|
+
description: "Rotates the facets from base to rim for a spiral look. 0 keeps the facets vertical." },
|
|
51
|
+
{ key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 6, step: 0.5, hidden: true,
|
|
52
|
+
description: "Internal: solid base thickness, fixed by the design. Hidden from the end user but still drives the geometry." },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "drainage",
|
|
57
|
+
title: "Drainage",
|
|
58
|
+
description: "Optional drainage hole through the base — turn it on for a planter, off to hold water like a cup or vase.",
|
|
59
|
+
features: [
|
|
60
|
+
{ label: "Drainage hole", key: "drain", on: 8,
|
|
61
|
+
description: "Drills a centered hole of this diameter through the floor.",
|
|
62
|
+
sliders: [{ key: "drain", label: "Hole diameter", unit: "mm", min: 3, max: 30, step: 1,
|
|
63
|
+
description: "Diameter of the centered drainage hole." }] },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
defaults: { facets: 6, dia: 70, height: 90, taper: 1.2, wall: 1.6, twist: 30, drain: 8, floor: 3 },
|
|
68
|
+
// derive(): turn raw inputs into the n-gon point lists and dependent dimensions the
|
|
69
|
+
// build needs, sized so the wall stays even (see build()).
|
|
70
|
+
derive: (p) => {
|
|
71
|
+
const Rout = p.dia / 2;
|
|
72
|
+
// Offset the inner polygon inward by `wall` along the FACE normals, not the radius:
|
|
73
|
+
// for a regular n-gon an edge offset of `wall` shrinks the circumradius by
|
|
74
|
+
// wall / cos(π/n). This keeps the perpendicular wall = `wall` on every flat.
|
|
75
|
+
// clamp only matters if wall is set past the slider bounds via the API
|
|
76
|
+
const Rin = Math.max(Rout - p.wall / Math.cos(Math.PI / p.facets), 1);
|
|
77
|
+
return {
|
|
78
|
+
outerPts: ngon(Rout, p.facets),
|
|
79
|
+
innerPts: ngon(Rin, p.facets),
|
|
80
|
+
// Inner taper that holds the wall constant top-to-bottom even as the body flares:
|
|
81
|
+
// pick it so inner_radius(top) = outer_radius(top) − wall.
|
|
82
|
+
innerTaper: 1 + (Rout * (p.taper - 1)) / Rin,
|
|
83
|
+
drainR: (p.drain + 0.2) / 2, // nominal hole + 0.2 mm print clearance, as a radius
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
parts: {
|
|
87
|
+
planter: {
|
|
88
|
+
label: "Planter",
|
|
89
|
+
views: ["planter"],
|
|
90
|
+
export: { name: "planter" },
|
|
91
|
+
build: (k, p, d) => {
|
|
92
|
+
const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper, twist: p.twist }).label("Faceted wall");
|
|
93
|
+
// Hollow it. The cavity is built from z=0 sharing the body's exact twist RATE and
|
|
94
|
+
// taper slope (f rescales the ~4 mm overshoot so the rates still match), so the
|
|
95
|
+
// inner and outer facets stay radially aligned at every height — the wall can't
|
|
96
|
+
// pinch when twisted. Then clip the cavity to z ≥ floor so the base stays solid.
|
|
97
|
+
const f = (p.height + 4) / p.height;
|
|
98
|
+
const cavity = k
|
|
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]))
|
|
101
|
+
.label("Cavity");
|
|
102
|
+
let s = body.cut(cavity);
|
|
103
|
+
// Optional drainage hole straight through the base.
|
|
104
|
+
if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
|
|
105
|
+
return s;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
views: { planter: { label: "Planter" } },
|
|
110
|
+
// Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
|
|
111
|
+
// FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
|
|
112
|
+
// — one drainage hole through the base, fits the bed, no interpenetration.
|
|
113
|
+
verify: {
|
|
114
|
+
process: "fdm-pla",
|
|
115
|
+
expect: {
|
|
116
|
+
planter: { holes: 1 /* drain=8 at defaults → 1 hole; adjust if running verify with a non-default drain */, bbox: "<=[220,220,250]" },
|
|
117
|
+
_view: { overlaps: 0 } /* _view = whole-model composite (not a named part) */,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|