partforge 0.1.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 +53 -0
- package/docs/AUTHORING-PARTS.md +272 -0
- package/package.json +41 -0
- package/src/app-demo.js +10 -0
- package/src/demo-worker.js +3 -0
- package/src/framework/app.css +141 -0
- package/src/framework/assembly.js +29 -0
- package/src/framework/controls.js +180 -0
- package/src/framework/geometry/fuzzy-cut.js +32 -0
- package/src/framework/geometry/helix-tube.js +50 -0
- package/src/framework/geometry/kernel.js +25 -0
- package/src/framework/geometry/manifold-backend.js +194 -0
- package/src/framework/geometry/occt-backend.js +59 -0
- package/src/framework/geometry/polygon.js +23 -0
- package/src/framework/geometry/threemf.js +52 -0
- package/src/framework/geometry-service.js +20 -0
- package/src/framework/index.js +1 -0
- package/src/framework/jobs.js +73 -0
- package/src/framework/mount.js +227 -0
- package/src/framework/viewer.js +204 -0
- package/src/framework/worker.js +76 -0
- package/src/index.js +6 -0
- package/src/parts/demo.js +40 -0
- package/src/testing/mesh.js +21 -0
- package/src/testing/occt.js +18 -0
- package/src/testing.js +8 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Builds the sectioned control panel from a part's `parameters` schema.
|
|
2
|
+
//
|
|
3
|
+
// Most sections show a preset picker (below the title) plus an expandable
|
|
4
|
+
// "Advanced" block of sliders. The "features" section instead puts, under
|
|
5
|
+
// Advanced, a checkbox per feature followed by its own controls — ticking one
|
|
6
|
+
// enables it and reveals those controls right below it.
|
|
7
|
+
// All controls mutate the shared `params` object and call onDirty() on change.
|
|
8
|
+
|
|
9
|
+
// Short numeric string without float noise (4 dp max) for the value box.
|
|
10
|
+
const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
|
|
11
|
+
|
|
12
|
+
// Parse a typed value → clamped to [min, max], or null if not a finite number.
|
|
13
|
+
export function clampToRange(raw, min, max) {
|
|
14
|
+
const v = parseFloat(raw);
|
|
15
|
+
if (!Number.isFinite(v)) return null;
|
|
16
|
+
return Math.min(max, Math.max(min, v));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function el(tag, className, text) {
|
|
20
|
+
const node = document.createElement(tag);
|
|
21
|
+
if (className) node.className = className;
|
|
22
|
+
if (text != null) node.textContent = text;
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One parameter control bound to params[def.key]. `def.control`:
|
|
27
|
+
// "slider" (default) — range slider + an editable number box (drag OR type)
|
|
28
|
+
// "number" — number box only (no slider)
|
|
29
|
+
// The box accepts exact values (finer than `step`); typed values clamp to
|
|
30
|
+
// [min, max] on commit (blur/Enter). Returns { wrap, sync }.
|
|
31
|
+
function makeSlider(def, params, onChange) {
|
|
32
|
+
const numeric = def.control === "number";
|
|
33
|
+
const wrap = el("div", "slider");
|
|
34
|
+
const row = el("div", "row");
|
|
35
|
+
row.append(el("label", "", def.label));
|
|
36
|
+
|
|
37
|
+
// editable value box (+ optional unit suffix)
|
|
38
|
+
const val = el("div", "val");
|
|
39
|
+
const box = document.createElement("input");
|
|
40
|
+
box.type = "number";
|
|
41
|
+
box.className = "num";
|
|
42
|
+
box.min = def.min; box.max = def.max; box.step = def.step;
|
|
43
|
+
box.value = numStr(params[def.key]);
|
|
44
|
+
val.append(box);
|
|
45
|
+
if (def.unit) val.append(el("span", "unit", def.unit));
|
|
46
|
+
row.append(val);
|
|
47
|
+
wrap.append(row);
|
|
48
|
+
|
|
49
|
+
let slider = null;
|
|
50
|
+
if (!numeric) {
|
|
51
|
+
slider = document.createElement("input");
|
|
52
|
+
slider.type = "range";
|
|
53
|
+
slider.min = def.min; slider.max = def.max; slider.step = def.step;
|
|
54
|
+
slider.value = params[def.key];
|
|
55
|
+
slider.addEventListener("input", () => {
|
|
56
|
+
params[def.key] = +slider.value;
|
|
57
|
+
box.value = numStr(+slider.value);
|
|
58
|
+
onChange?.();
|
|
59
|
+
});
|
|
60
|
+
wrap.append(slider);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// live preview while typing (unclamped); clamp + reformat on commit (blur/Enter)
|
|
64
|
+
box.addEventListener("input", () => {
|
|
65
|
+
const v = parseFloat(box.value);
|
|
66
|
+
if (!Number.isFinite(v)) return;
|
|
67
|
+
params[def.key] = v;
|
|
68
|
+
if (slider) slider.value = v;
|
|
69
|
+
onChange?.();
|
|
70
|
+
});
|
|
71
|
+
box.addEventListener("change", () => {
|
|
72
|
+
const v = clampToRange(box.value, def.min, def.max);
|
|
73
|
+
if (v == null) { box.value = numStr(params[def.key]); return; } // revert invalid input
|
|
74
|
+
params[def.key] = v;
|
|
75
|
+
box.value = numStr(v);
|
|
76
|
+
if (slider) slider.value = v;
|
|
77
|
+
onChange?.();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const sync = () => {
|
|
81
|
+
box.value = numStr(params[def.key]);
|
|
82
|
+
if (slider) slider.value = params[def.key];
|
|
83
|
+
};
|
|
84
|
+
return { wrap, sync };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A collapsible "Advanced ▾" block. Returns { adv, toggle }.
|
|
88
|
+
function advancedBlock() {
|
|
89
|
+
const adv = el("div", "adv hidden");
|
|
90
|
+
const toggle = el("button", "adv-toggle", "Advanced ▾");
|
|
91
|
+
toggle.addEventListener("click", () => {
|
|
92
|
+
const hidden = adv.classList.toggle("hidden");
|
|
93
|
+
toggle.textContent = hidden ? "Advanced ▾" : "Advanced ▴";
|
|
94
|
+
});
|
|
95
|
+
return { adv, toggle };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildControls(root, parameters, params, onDirty) {
|
|
99
|
+
for (const sec of parameters) {
|
|
100
|
+
const section = el("div", "section");
|
|
101
|
+
section.append(el("div", "sec-title", sec.title));
|
|
102
|
+
if (sec.features) buildFeatureSection(section, sec, params, onDirty);
|
|
103
|
+
else buildPresetSection(section, sec, params, onDirty);
|
|
104
|
+
root.append(section);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildPresetSection(section, sec, params, onDirty) {
|
|
109
|
+
// preset picker, below the title, full width
|
|
110
|
+
const preset = document.createElement("select");
|
|
111
|
+
preset.className = "preset";
|
|
112
|
+
const presetNames = Object.keys(sec.presets);
|
|
113
|
+
for (const name of [...presetNames, "Custom"]) {
|
|
114
|
+
const o = document.createElement("option");
|
|
115
|
+
o.value = name;
|
|
116
|
+
o.textContent = name;
|
|
117
|
+
preset.append(o);
|
|
118
|
+
}
|
|
119
|
+
preset.value = presetNames[0];
|
|
120
|
+
section.append(preset);
|
|
121
|
+
|
|
122
|
+
const { adv, toggle } = advancedBlock();
|
|
123
|
+
const syncs = {};
|
|
124
|
+
for (const def of sec.advanced) {
|
|
125
|
+
const s = makeSlider(def, params, () => {
|
|
126
|
+
preset.value = "Custom";
|
|
127
|
+
onDirty?.();
|
|
128
|
+
});
|
|
129
|
+
adv.append(s.wrap);
|
|
130
|
+
syncs[def.key] = s.sync;
|
|
131
|
+
}
|
|
132
|
+
section.append(toggle, adv);
|
|
133
|
+
|
|
134
|
+
// applying a preset overwrites its keys and refreshes this section's sliders
|
|
135
|
+
preset.addEventListener("change", () => {
|
|
136
|
+
const bundle = sec.presets[preset.value];
|
|
137
|
+
if (!bundle) return; // "Custom"
|
|
138
|
+
Object.assign(params, bundle);
|
|
139
|
+
for (const key in syncs) if (key in params) syncs[key]();
|
|
140
|
+
onDirty?.();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildFeatureSection(section, sec, params, onDirty) {
|
|
145
|
+
// Everything lives under Advanced: each feature is a checkbox followed by its
|
|
146
|
+
// own controls, which appear directly below it when the box is checked.
|
|
147
|
+
const { adv, toggle } = advancedBlock();
|
|
148
|
+
section.append(toggle, adv);
|
|
149
|
+
|
|
150
|
+
for (const feat of sec.features) {
|
|
151
|
+
const checkRow = el("label", "feat");
|
|
152
|
+
const box = document.createElement("input");
|
|
153
|
+
box.type = "checkbox";
|
|
154
|
+
box.checked = params[feat.key] > 0;
|
|
155
|
+
checkRow.append(box, el("span", "", feat.label));
|
|
156
|
+
|
|
157
|
+
const group = el("div", "feat-group");
|
|
158
|
+
const syncs = [];
|
|
159
|
+
for (const def of feat.sliders) {
|
|
160
|
+
const s = makeSlider(def, params, onDirty);
|
|
161
|
+
group.append(s.wrap);
|
|
162
|
+
syncs.push(s.sync);
|
|
163
|
+
}
|
|
164
|
+
group.classList.toggle("hidden", !box.checked);
|
|
165
|
+
|
|
166
|
+
box.addEventListener("change", () => {
|
|
167
|
+
if (box.checked) {
|
|
168
|
+
if (!(params[feat.key] > 0)) params[feat.key] = feat.on; // enable
|
|
169
|
+
syncs.forEach((s) => s());
|
|
170
|
+
group.classList.remove("hidden");
|
|
171
|
+
} else {
|
|
172
|
+
params[feat.key] = 0; // disable
|
|
173
|
+
group.classList.add("hidden");
|
|
174
|
+
}
|
|
175
|
+
onDirty?.();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
adv.append(checkRow, group); // checkbox, then its controls right below
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Fuzzy boolean cut via the raw OpenCASCADE kernel.
|
|
2
|
+
//
|
|
3
|
+
// replicad's high-level Shape3D.cut() runs BRepAlgoAPI_Cut with the default
|
|
4
|
+
// (zero) fuzzy tolerance, which returns an EMPTY result for large near-tangent
|
|
5
|
+
// helical groove tools (fine up to a few turns, empty by ~10). OCCT's fuzzy
|
|
6
|
+
// boolean snaps near-coincident geometry within a tolerance and makes the
|
|
7
|
+
// operation robust — this is how the full multi-turn drum gets cut.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors replicad's own cut() (BRepAlgoAPI_Cut_3 + Build + SimplifyResult +
|
|
10
|
+
// cast) and just adds SetFuzzyValue before Build.
|
|
11
|
+
|
|
12
|
+
import { getOC, cast } from "replicad";
|
|
13
|
+
|
|
14
|
+
export function fuzzyCut(base, tool, { fuzz = 1e-3, simplify = false } = {}) {
|
|
15
|
+
const oc = getOC();
|
|
16
|
+
const progress = new oc.Message_ProgressRange_1();
|
|
17
|
+
const cutter = new oc.BRepAlgoAPI_Cut_3(base.wrapped, tool.wrapped, progress);
|
|
18
|
+
cutter.SetFuzzyValue(fuzz);
|
|
19
|
+
cutter.Build(progress);
|
|
20
|
+
if (cutter.HasErrors && cutter.HasErrors()) {
|
|
21
|
+
cutter.delete();
|
|
22
|
+
progress.delete();
|
|
23
|
+
throw new Error(`fuzzy cut failed (OCCT BOP error, fuzz=${fuzz})`);
|
|
24
|
+
}
|
|
25
|
+
// SimplifyResult merges coplanar faces but is very slow on helical results —
|
|
26
|
+
// off by default; the mesh/STEP are fine without it.
|
|
27
|
+
if (simplify) cutter.SimplifyResult(true, true, 1e-3);
|
|
28
|
+
const result = cast(cutter.Shape());
|
|
29
|
+
cutter.delete();
|
|
30
|
+
progress.delete();
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Builds a watertight triangle mesh of a circular profile swept along a helix in
|
|
2
|
+
// its frenet frame, then imports it as a Manifold solid. The profile stays
|
|
3
|
+
// perpendicular to the helix tangent (unlike twist-extrude), so it matches an
|
|
4
|
+
// exact frenet sweep. Winding is consistent-outward; getting it wrong makes
|
|
5
|
+
// Manifold.ofMesh throw or import an inverted solid.
|
|
6
|
+
const norm = (v) => { const m = Math.hypot(...v); return [v[0] / m, v[1] / m, v[2] / m]; };
|
|
7
|
+
const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
|
|
8
|
+
|
|
9
|
+
export function helixTube(wasm, opts) {
|
|
10
|
+
const { pathR, profileR, pitch, turns, z0 = 0, lefthand = false,
|
|
11
|
+
stationsPerTurn = 24, ringSegs = 16 } = opts;
|
|
12
|
+
const sign = lefthand ? -1 : 1;
|
|
13
|
+
const c = pitch / (2 * Math.PI); // z-rise per radian
|
|
14
|
+
const phiMax = 2 * Math.PI * turns;
|
|
15
|
+
const n = Math.max(2, Math.ceil(turns * stationsPerTurn)) + 1;
|
|
16
|
+
const V = [], Tr = [];
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < n; i++) {
|
|
19
|
+
const phi = (phiMax * i) / (n - 1);
|
|
20
|
+
const ctr = [pathR * Math.cos(sign * phi), pathR * Math.sin(sign * phi), z0 + c * phi];
|
|
21
|
+
const T = norm([-sign * pathR * Math.sin(sign * phi), sign * pathR * Math.cos(sign * phi), c]);
|
|
22
|
+
const N = [Math.cos(sign * phi), Math.sin(sign * phi), 0]; // radial, ⟂ T
|
|
23
|
+
const B = norm(cross(T, N));
|
|
24
|
+
for (let j = 0; j < ringSegs; j++) {
|
|
25
|
+
const a = (2 * Math.PI * j) / ringSegs;
|
|
26
|
+
V.push(ctr[0] + profileR * (Math.cos(a) * N[0] + Math.sin(a) * B[0]),
|
|
27
|
+
ctr[1] + profileR * (Math.cos(a) * N[1] + Math.sin(a) * B[1]),
|
|
28
|
+
ctr[2] + profileR * (Math.cos(a) * N[2] + Math.sin(a) * B[2]));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// side faces (outward winding)
|
|
32
|
+
for (let i = 0; i < n - 1; i++) for (let j = 0; j < ringSegs; j++) {
|
|
33
|
+
const a = i*ringSegs + j, b = i*ringSegs + (j+1)%ringSegs;
|
|
34
|
+
const cc = (i+1)*ringSegs + j, dd = (i+1)*ringSegs + (j+1)%ringSegs;
|
|
35
|
+
Tr.push(a, dd, cc, a, b, dd);
|
|
36
|
+
}
|
|
37
|
+
// end caps
|
|
38
|
+
const c0 = V.length / 3;
|
|
39
|
+
V.push(pathR * Math.cos(0), pathR * Math.sin(0), z0);
|
|
40
|
+
for (let j = 0; j < ringSegs; j++) Tr.push(c0, (j+1)%ringSegs, j);
|
|
41
|
+
const base = (n - 1) * ringSegs, cz = V.length / 3;
|
|
42
|
+
V.push(pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax);
|
|
43
|
+
for (let j = 0; j < ringSegs; j++) Tr.push(cz, base + j, base + (j+1)%ringSegs);
|
|
44
|
+
|
|
45
|
+
const mesh = new wasm.Mesh({ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr) });
|
|
46
|
+
mesh.merge();
|
|
47
|
+
const out = wasm.Manifold.ofMesh(mesh);
|
|
48
|
+
mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The GeometryKernel contract (documentation). Backends implement the @typedef
|
|
2
|
+
// below. (2-D polygon helpers live in ./polygon.js.)
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} Solid An opaque handle to a backend solid.
|
|
6
|
+
* @property {(tool: Solid) => Solid} cut
|
|
7
|
+
* @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
|
|
8
|
+
* @property {(other: Solid) => Solid} intersect boolean intersection (Manifold)
|
|
9
|
+
* @property {(v: number[]) => Solid} translate
|
|
10
|
+
* @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
|
|
11
|
+
* @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
|
|
12
|
+
* @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
|
|
13
|
+
* @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices:Uint32Array, triangles:number}} toMesh
|
|
14
|
+
* @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
|
|
15
|
+
* @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF (Manifold)
|
|
16
|
+
*
|
|
17
|
+
* @typedef {Object} GeometryKernel
|
|
18
|
+
* @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
|
|
19
|
+
* @property {(min:number[], max:number[]) => Solid} box
|
|
20
|
+
* @property {(points2D:number[][], h:number) => Solid} prism extrude polygon from z=0
|
|
21
|
+
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
22
|
+
* @property {(solids:Solid[]) => Solid} union
|
|
23
|
+
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only
|
|
24
|
+
* @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
|
|
25
|
+
*/
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { helixTube } from "./helix-tube.js";
|
|
2
|
+
|
|
3
|
+
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
4
|
+
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
5
|
+
// by the export path — Manifold meshing is cheap, so we tessellate generously).
|
|
6
|
+
const SEGS = { preview: 116, print: 480 }; // circular segments
|
|
7
|
+
const TUBE = { preview: { stationsPerTurn: 38, ringSegs: 24 }, print: { stationsPerTurn: 160, ringSegs: 40 } };
|
|
8
|
+
const SHARP_ANGLE = 35; // deg — same-surface edges sharper than this shade hard (cut seams are always hard)
|
|
9
|
+
const COPLANAR_COS = Math.cos((5 * Math.PI) / 180); // edge lines: skip cut seams that bend less than 5° (coplanar)
|
|
10
|
+
const MIN_EDGE2 = 0.01 * 0.01; // edge lines: drop sub-0.01mm segments (degenerate boolean slivers, not real features)
|
|
11
|
+
|
|
12
|
+
export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
13
|
+
const { Manifold, CrossSection } = wasm;
|
|
14
|
+
const segs = SEGS[quality], tube = TUBE[quality];
|
|
15
|
+
|
|
16
|
+
// Manifold/CrossSection are WASM objects with no garbage collection — every
|
|
17
|
+
// primitive and boolean op allocates a new one. Track them all and free them
|
|
18
|
+
// per job via cleanup(); otherwise repeated generates exhaust the WASM heap
|
|
19
|
+
// (manifests as "Out of bounds memory access").
|
|
20
|
+
const tracked = [];
|
|
21
|
+
const T = (obj) => { tracked.push(obj); return obj; };
|
|
22
|
+
const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
|
|
23
|
+
|
|
24
|
+
// Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
|
|
25
|
+
// transient mesh handle.
|
|
26
|
+
function meshOut(m, asStl) {
|
|
27
|
+
const g = m.getMesh();
|
|
28
|
+
const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180));
|
|
29
|
+
g.delete?.();
|
|
30
|
+
return r;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Raw indexed mesh (positions x,y,z per vertex + triangle indices) for 3MF.
|
|
34
|
+
function indexedMeshOut(m) {
|
|
35
|
+
const g = m.getMesh();
|
|
36
|
+
const np = g.numProp, vp = g.vertProperties;
|
|
37
|
+
const nVert = (vp.length / np) | 0;
|
|
38
|
+
let positions;
|
|
39
|
+
if (np === 3) {
|
|
40
|
+
positions = Float32Array.from(vp);
|
|
41
|
+
} else {
|
|
42
|
+
positions = new Float32Array(nVert * 3);
|
|
43
|
+
for (let i = 0; i < nVert; i++) { positions[i * 3] = vp[i * np]; positions[i * 3 + 1] = vp[i * np + 1]; positions[i * 3 + 2] = vp[i * np + 2]; }
|
|
44
|
+
}
|
|
45
|
+
const indices = Uint32Array.from(g.triVerts);
|
|
46
|
+
g.delete?.();
|
|
47
|
+
return { positions, indices };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const wrap = (m) => ({
|
|
51
|
+
_m: m,
|
|
52
|
+
cut: (t) => wrap(T(m.subtract(t._m))),
|
|
53
|
+
cutAll: (tools) => wrap(T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
54
|
+
intersect: (t) => wrap(T(m.intersect(t._m))),
|
|
55
|
+
volume: () => m.volume(),
|
|
56
|
+
translate: (v) => wrap(T(m.translate(v))),
|
|
57
|
+
rotate: (deg, center, axis) => {
|
|
58
|
+
const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
|
|
59
|
+
const a = T(m.translate([-center[0], -center[1], -center[2]]));
|
|
60
|
+
const b = T(a.rotate(euler));
|
|
61
|
+
return wrap(T(b.translate(center)));
|
|
62
|
+
},
|
|
63
|
+
mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane]))),
|
|
64
|
+
toMesh: () => meshOut(m, false),
|
|
65
|
+
toSTL: () => Promise.resolve(meshOut(m, true)),
|
|
66
|
+
toIndexedMesh: () => indexedMeshOut(m),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
cylinder: (rb, rt, h, { center = false } = {}) => wrap(T(Manifold.cylinder(h, rb, rt, segs, center))),
|
|
71
|
+
box: (min, max) => {
|
|
72
|
+
const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
|
|
73
|
+
return wrap(T(cube.translate(min)));
|
|
74
|
+
},
|
|
75
|
+
prism: (pts, h) => {
|
|
76
|
+
const cs = T(CrossSection.ofPolygons([pts]));
|
|
77
|
+
return wrap(T(cs.extrude(h)));
|
|
78
|
+
},
|
|
79
|
+
helixSweptTube: (o) => wrap(T(helixTube(wasm, { ...o, ...tube }))),
|
|
80
|
+
union: (solids) => wrap(unionRaw(solids.map((s) => s._m))), // unionRaw already tracks its result
|
|
81
|
+
toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
|
|
82
|
+
// Free every WASM object created since the last cleanup. Call after each job
|
|
83
|
+
// once its meshes/buffers have been copied out (meshOut already did).
|
|
84
|
+
cleanup: () => { for (const o of tracked) o.delete?.(); tracked.length = 0; },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Build a non-indexed mesh with normals that are smooth within a single original
|
|
89
|
+
// surface but HARD across boolean-cut seams. Manifold's runOriginalID tells us
|
|
90
|
+
// which input solid each triangle came from; we average a corner's face normals
|
|
91
|
+
// only over incident triangles of the SAME original surface that also meet within
|
|
92
|
+
// `sharpCos` — so cut seams stay crisp at any angle (even near-tangent), and a
|
|
93
|
+
// surface's own sharp edges (e.g. a face meeting a side) stay crisp too.
|
|
94
|
+
function creasedNormals(g, sharpCos) {
|
|
95
|
+
const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
|
|
96
|
+
const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
|
|
97
|
+
|
|
98
|
+
// unify any coincident vertices Manifold kept separate, for adjacency
|
|
99
|
+
const remap = new Uint32Array(nVert);
|
|
100
|
+
for (let i = 0; i < nVert; i++) remap[i] = i;
|
|
101
|
+
const mf = g.mergeFromVert, mt = g.mergeToVert;
|
|
102
|
+
if (mf && mt) for (let i = 0; i < mf.length; i++) remap[mf[i]] = mt[i];
|
|
103
|
+
|
|
104
|
+
// per-triangle original-surface id, from the run table
|
|
105
|
+
const triOID = new Uint32Array(nTri);
|
|
106
|
+
const ri = g.runIndex, roid = g.runOriginalID;
|
|
107
|
+
for (let r = 0; r < roid.length; r++)
|
|
108
|
+
for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
|
|
109
|
+
|
|
110
|
+
// per-triangle face normals
|
|
111
|
+
const fn = new Float32Array(nTri * 3);
|
|
112
|
+
for (let t = 0; t < nTri; t++) {
|
|
113
|
+
const a = tris[t * 3] * np, b = tris[t * 3 + 1] * np, c = tris[t * 3 + 2] * np;
|
|
114
|
+
const ux = vp[b] - vp[a], uy = vp[b + 1] - vp[a + 1], uz = vp[b + 2] - vp[a + 2];
|
|
115
|
+
const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
|
|
116
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
117
|
+
const L = Math.hypot(nx, ny, nz) || 1;
|
|
118
|
+
fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// canonical vertex → incident triangles
|
|
122
|
+
const incident = new Map();
|
|
123
|
+
for (let t = 0; t < nTri; t++)
|
|
124
|
+
for (let k = 0; k < 3; k++) {
|
|
125
|
+
const cv = remap[tris[t * 3 + k]];
|
|
126
|
+
const arr = incident.get(cv);
|
|
127
|
+
if (arr) arr.push(t); else incident.set(cv, [t]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const positions = new Float32Array(nTri * 9);
|
|
131
|
+
const normals = new Float32Array(nTri * 9);
|
|
132
|
+
for (let t = 0; t < nTri; t++) {
|
|
133
|
+
const fx = fn[t * 3], fy = fn[t * 3 + 1], fz = fn[t * 3 + 2], oid = triOID[t];
|
|
134
|
+
for (let k = 0; k < 3; k++) {
|
|
135
|
+
const v = tris[t * 3 + k];
|
|
136
|
+
let nx = 0, ny = 0, nz = 0;
|
|
137
|
+
for (const t2 of incident.get(remap[v])) {
|
|
138
|
+
if (triOID[t2] !== oid) continue; // different cut surface → hard
|
|
139
|
+
if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
|
|
140
|
+
nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
|
|
141
|
+
}
|
|
142
|
+
const L = Math.hypot(nx, ny, nz) || 1;
|
|
143
|
+
const o = (t * 3 + k) * 3, vv = v * np;
|
|
144
|
+
positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
|
|
145
|
+
normals[o] = nx / L; normals[o + 1] = ny / L; normals[o + 2] = nz / L;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Feature edge segments for CAD-style edge lines: draw a line where the surface
|
|
150
|
+
// actually BENDS — a sharp same-surface edge (dihedral past sharpCos), or a cut
|
|
151
|
+
// seam (different original surface) that bends more than COPLANAR_COS. Coplanar
|
|
152
|
+
// faces — even across a cut seam — get no line, and curved-surface facets are skipped.
|
|
153
|
+
const edges = [];
|
|
154
|
+
const seenEdge = new Map(); // edge key → first incident triangle
|
|
155
|
+
for (let t = 0; t < nTri; t++)
|
|
156
|
+
for (let e = 0; e < 3; e++) {
|
|
157
|
+
const i = remap[tris[t * 3 + e]], j = remap[tris[t * 3 + ((e + 1) % 3)]];
|
|
158
|
+
if (i === j) continue;
|
|
159
|
+
const key = i < j ? i * nVert + j : j * nVert + i;
|
|
160
|
+
const prev = seenEdge.get(key);
|
|
161
|
+
if (prev === undefined) { seenEdge.set(key, t); continue; }
|
|
162
|
+
seenEdge.delete(key);
|
|
163
|
+
const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
|
|
164
|
+
const hard = dot < sharpCos || (triOID[prev] !== triOID[t] && dot < COPLANAR_COS);
|
|
165
|
+
if (hard) {
|
|
166
|
+
const ai = i * np, bj = j * np;
|
|
167
|
+
const dx = vp[ai] - vp[bj], dy = vp[ai + 1] - vp[bj + 1], dz = vp[ai + 2] - vp[bj + 2];
|
|
168
|
+
if (dx * dx + dy * dy + dz * dz >= MIN_EDGE2) // skip degenerate sliver segments (noise)
|
|
169
|
+
edges.push(vp[ai], vp[ai + 1], vp[ai + 2], vp[bj], vp[bj + 1], vp[bj + 2]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stlFromMesh(g) {
|
|
177
|
+
const tris = g.triVerts, vp = g.vertProperties, np = g.numProp, n = tris.length / 3;
|
|
178
|
+
const ab = new ArrayBuffer(84 + n * 50); const dv = new DataView(ab); dv.setUint32(80, n, true);
|
|
179
|
+
let o = 84; const P = (i) => [vp[i*np], vp[i*np+1], vp[i*np+2]];
|
|
180
|
+
for (let i = 0; i < n; i++) {
|
|
181
|
+
const a = P(tris[i*3]), b = P(tris[i*3+1]), c = P(tris[i*3+2]);
|
|
182
|
+
// Per-facet flat normal from the winding (Manifold is CCW → outward). Slicers
|
|
183
|
+
// recompute this, but viewers that light from the stored normal (macOS
|
|
184
|
+
// Preview/Quick Look) render the mesh unlit if it's left as zero.
|
|
185
|
+
const ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
|
|
186
|
+
const vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
|
|
187
|
+
const nx = uy*vz - uz*vy, ny = uz*vx - ux*vz, nz = ux*vy - uy*vx;
|
|
188
|
+
const L = Math.hypot(nx, ny, nz) || 1;
|
|
189
|
+
dv.setFloat32(o, nx/L, true); dv.setFloat32(o+4, ny/L, true); dv.setFloat32(o+8, nz/L, true); o += 12;
|
|
190
|
+
for (const p of [a, b, c]) for (const x of p) { dv.setFloat32(o, x, true); o += 4; }
|
|
191
|
+
dv.setUint16(o, 0, true); o += 2;
|
|
192
|
+
}
|
|
193
|
+
return ab;
|
|
194
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// OCCT backend via replicad. Same GeometryKernel shape as the Manifold backend,
|
|
2
|
+
// and the only backend with toSTEP(). This is where today's drum.js kernel calls
|
|
3
|
+
// (makeCylinder, makeHelix+genericSweep, draw/extrude, cut/fuse) now live.
|
|
4
|
+
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
5
|
+
|
|
6
|
+
export function createOcctKernel(replicad) {
|
|
7
|
+
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
8
|
+
makeCompound, loft, draw, exportSTEP } = replicad;
|
|
9
|
+
|
|
10
|
+
const wrap = (shape) => ({
|
|
11
|
+
_s: shape,
|
|
12
|
+
cut: (t) => wrap(shape.cut(t._s)),
|
|
13
|
+
cutAll: (tools) => wrap(shape.cut(makeCompound(tools.map((t) => t._s)))),
|
|
14
|
+
translate: (v) => wrap(shape.translate(v)),
|
|
15
|
+
rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
|
|
16
|
+
mirror: (plane) => wrap(shape.mirror(plane)),
|
|
17
|
+
toMesh: ({ quality = "preview" } = {}) => {
|
|
18
|
+
const m = shape.mesh(MESH[quality]);
|
|
19
|
+
return {
|
|
20
|
+
positions: Float32Array.from(m.vertices),
|
|
21
|
+
normals: new Float32Array(0), // let the main thread crease (matches prior look)
|
|
22
|
+
indices: Uint32Array.from(m.triangles),
|
|
23
|
+
triangles: m.triangles.length / 3,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// cylinder OR frustum (loft of two circles) when rb !== rt
|
|
30
|
+
const cylinder = (rb, rt, h, { center = false } = {}) => {
|
|
31
|
+
const z0 = center ? -h / 2 : 0;
|
|
32
|
+
if (Math.abs(rb - rt) < 1e-9) return wrap(makeCylinder(rb, h, [0, 0, z0]));
|
|
33
|
+
const w1 = assembleWire([makeCircle(rb, [0, 0, z0])]);
|
|
34
|
+
const w2 = assembleWire([makeCircle(rt, [0, 0, z0 + h])]);
|
|
35
|
+
return wrap(loft([w1, w2]));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// extrude a 2-D polygon from z=0
|
|
39
|
+
const prism = (pts, h) => {
|
|
40
|
+
let pen = draw(pts[0]);
|
|
41
|
+
for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
|
|
42
|
+
return wrap(pen.close().sketchOnPlane("XY").extrude(h));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// circle profile swept along a helix (frenet)
|
|
46
|
+
const helixSweptTube = ({ pathR, profileR, pitch, turns, z0, lefthand }) => {
|
|
47
|
+
const spine = makeHelix(pitch, pitch * turns, pathR, [0, 0, z0], [0, 0, 1], lefthand);
|
|
48
|
+
const dir = lefthand ? -1 : 1;
|
|
49
|
+
const tangent = [0, dir * pathR, pitch / (2 * Math.PI)];
|
|
50
|
+
const profile = assembleWire([makeCircle(profileR, [pathR, 0, z0], tangent)]);
|
|
51
|
+
return wrap(genericSweep(profile, spine, { frenet: true }));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
cylinder, box: (min, max) => wrap(makeBox(min, max)), prism, helixSweptTube,
|
|
56
|
+
union: (solids) => wrap(solids.map((s) => s._s).reduce((a, b) => a.fuse(b))),
|
|
57
|
+
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 2-D polygon helpers shared by parts that call kernel.prism().
|
|
2
|
+
|
|
3
|
+
// CCW polygon points for a circular-sector "pie" from the origin, radius tipR.
|
|
4
|
+
export function piePolygon(tipR, arcDeg, segs = 32) {
|
|
5
|
+
const a = (arcDeg * Math.PI) / 180;
|
|
6
|
+
const pts = [[0, 0]];
|
|
7
|
+
const steps = Math.max(2, Math.ceil((segs * arcDeg) / 360));
|
|
8
|
+
for (let i = 0; i <= steps; i++) {
|
|
9
|
+
const t = (a * i) / steps;
|
|
10
|
+
pts.push([tipR * Math.cos(t), tipR * Math.sin(t)]);
|
|
11
|
+
}
|
|
12
|
+
return pts;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Vertex-up regular hexagon, circumradius r (flats facing ±X).
|
|
16
|
+
export function hexPolygon(r) {
|
|
17
|
+
const pts = [];
|
|
18
|
+
for (let i = 0; i < 6; i++) {
|
|
19
|
+
const a = Math.PI / 2 + (i * Math.PI) / 3;
|
|
20
|
+
pts.push([r * Math.cos(a), r * Math.sin(a)]);
|
|
21
|
+
}
|
|
22
|
+
return pts;
|
|
23
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Minimal 3MF writer. 3MF is an OPC package (a zip) holding an XML model; it's a
|
|
2
|
+
// mesh format like STL but supports units and multiple named objects in one file,
|
|
3
|
+
// so a multi-part view exports as a single .3mf. Built from indexed meshes.
|
|
4
|
+
import { zipSync, strToU8 } from "fflate";
|
|
5
|
+
|
|
6
|
+
const CONTENT_TYPES =
|
|
7
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
8
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
9
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
10
|
+
'<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>' +
|
|
11
|
+
"</Types>";
|
|
12
|
+
|
|
13
|
+
const RELS =
|
|
14
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
15
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
16
|
+
'<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>' +
|
|
17
|
+
"</Relationships>";
|
|
18
|
+
|
|
19
|
+
const xmlEsc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ }[c]));
|
|
20
|
+
const r = (x) => +x.toFixed(4); // 0.1 µm precision — finer than any printer, much smaller XML
|
|
21
|
+
|
|
22
|
+
// parts: [{ name, positions: Float32Array (x,y,z per vertex), indices: Uint32Array (3 per triangle) }]
|
|
23
|
+
// → ArrayBuffer of the .3mf zip (millimetre units; one <object> + <build> item per part).
|
|
24
|
+
export function meshTo3MF(parts) {
|
|
25
|
+
const out = [
|
|
26
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
27
|
+
'<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">',
|
|
28
|
+
"<resources>",
|
|
29
|
+
];
|
|
30
|
+
parts.forEach((p, i) => {
|
|
31
|
+
out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"><mesh><vertices>`);
|
|
32
|
+
const v = p.positions;
|
|
33
|
+
for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${r(v[k])}" y="${r(v[k + 1])}" z="${r(v[k + 2])}"/>`);
|
|
34
|
+
out.push("</vertices><triangles>");
|
|
35
|
+
const t = p.indices;
|
|
36
|
+
for (let k = 0; k < t.length; k += 3) out.push(`<triangle v1="${t[k]}" v2="${t[k + 1]}" v3="${t[k + 2]}"/>`);
|
|
37
|
+
out.push("</triangles></mesh></object>");
|
|
38
|
+
});
|
|
39
|
+
out.push("</resources><build>");
|
|
40
|
+
parts.forEach((p, i) => out.push(`<item objectid="${i + 1}"/>`));
|
|
41
|
+
out.push("</build></model>");
|
|
42
|
+
|
|
43
|
+
const zip = zipSync(
|
|
44
|
+
{
|
|
45
|
+
"[Content_Types].xml": strToU8(CONTENT_TYPES),
|
|
46
|
+
"_rels/.rels": strToU8(RELS),
|
|
47
|
+
"3D/3dmodel.model": strToU8(out.join("")),
|
|
48
|
+
},
|
|
49
|
+
{ level: 6 } // 3MF XML is highly repetitive — compresses ~10×
|
|
50
|
+
);
|
|
51
|
+
return zip.buffer;
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Main-thread side of the two geometry workers. Spawns both (manifold/occt),
|
|
2
|
+
// funnels their messages to one handler, and routes outbound jobs to the right one.
|
|
3
|
+
//
|
|
4
|
+
// `createWorker(name)` must be supplied by the app and build the worker with the
|
|
5
|
+
// `new Worker(new URL("./part-worker.js", import.meta.url), { type:"module", name })`
|
|
6
|
+
// pattern INLINE — Vite only bundles a worker (and its backend chunks) when it sees
|
|
7
|
+
// that literal call, so the framework can't construct it from a passed-in URL.
|
|
8
|
+
export function createGeometryService({ createWorker, onMessage, occtPreview = false }) {
|
|
9
|
+
const preview = createWorker("manifold"); // preview meshes + STL
|
|
10
|
+
const exporter = createWorker("occt"); // STEP (and preview when occtPreview)
|
|
11
|
+
preview.onmessage = onMessage;
|
|
12
|
+
exporter.onmessage = onMessage;
|
|
13
|
+
const genWorker = occtPreview ? exporter : preview;
|
|
14
|
+
return {
|
|
15
|
+
generate: (msg) => genWorker.postMessage(msg),
|
|
16
|
+
exportStl: (msg) => preview.postMessage(msg),
|
|
17
|
+
export3mf: (msg) => preview.postMessage(msg), // mesh export → Manifold worker
|
|
18
|
+
exportStep: (msg) => exporter.postMessage(msg),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { mount } from "./mount.js";
|