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.
@@ -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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[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";