partforge 0.61.0 → 0.62.1

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,576 @@
1
+ // Mesh fillet/chamfer for the Manifold backend — the tangent-wedge CSG technique
2
+ // (independently reimplemented from the approach discussed in elalish/manifold
3
+ // #1411): for each selected sharp edge chain, build a solid whose curved wall IS
4
+ // the rolling-ball blend surface, then boolean it against the part. Convex chains
5
+ // subtract a cutter; concave chains union a filler. Because the boolean runs in
6
+ // Manifold, output is watertight by construction and the blend radius is exact to
7
+ // tessellation.
8
+ //
9
+ // Edge classes supported:
10
+ // - straight chains with planar flanks → lofted prism cutter
11
+ // - circular-arc chains with revolved flanks → revolved cutter (bore rims,
12
+ // cylinder rims, the arcs where fillets meet a face), full circles included
13
+ // Anything else (helical edges, varying dihedral, branching curves) raises
14
+ // UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
15
+ //
16
+ // Known limits (documented, not bugs): no spherical corner patches yet — two
17
+ // chains meeting at a vertex leave a mitred junction where their blend surfaces
18
+ // intersect; radius feasibility is the caller's job (clamp like filleted-box.js
19
+ // does — an oversized radius self-intersects the cutters).
20
+ //
21
+ // Selector object mirrors edge-selector.js semantics ({dir, inPlane, at, near});
22
+ // `dir` only ever matches straight chains, like replicad's inDirection.
23
+ // Pure module: no DOM, no node:, no three — safe anywhere in the worker graph.
24
+
25
+ const TOL = 1e-4; // selector / coplanarity tolerance (mm)
26
+ const WELD = 1e6; // vertex weld quantization (1/WELD mm grid)
27
+ const COLLINEAR_DEG = 0.1; // joints straighter than this extend a line run
28
+ const SMOOTH_MAX_DEG = 30; // joints turning more than this are corners (chain ends)
29
+ const DEFAULT_SEGS = 116; // full-circle tessellation density (preview quality)
30
+
31
+ export class UnsupportedEdgeError extends Error {
32
+ constructor(message) { super(message); this.name = "UnsupportedEdgeError"; }
33
+ }
34
+
35
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
36
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
37
+ const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
38
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
39
+ 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]];
40
+ const len = (a) => Math.hypot(a[0], a[1], a[2]);
41
+ const norm = (a) => { const l = len(a); return l > 0 ? scl(a, 1 / l) : [0, 0, 0]; };
42
+ const clamp1 = (x) => Math.max(-1, Math.min(1, x));
43
+ const rotVec = (p, k, th) => { // Rodrigues rotation about unit axis k
44
+ const c = Math.cos(th), s = Math.sin(th);
45
+ return add(add(scl(p, c), scl(cross(k, p), s)), scl(k, dot(k, p) * (1 - c)));
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Sharp-edge extraction: weld vertices, keep edges whose two incident triangles
50
+ // meet at a dihedral sharper than sharpDeg, tag convexity and flank normals.
51
+ export function detectSharpEdges({ positions, indices }, { sharpDeg = 20 } = {}) {
52
+ const nVert = positions.length / 3;
53
+ const weld = new Map(), wid = new Int32Array(nVert), pts = [];
54
+ for (let i = 0; i < nVert; i++) {
55
+ const x = positions[i * 3], y = positions[i * 3 + 1], z = positions[i * 3 + 2];
56
+ const key = `${Math.round(x * WELD)},${Math.round(y * WELD)},${Math.round(z * WELD)}`;
57
+ let id = weld.get(key);
58
+ if (id === undefined) { id = pts.length; weld.set(key, id); pts.push([x, y, z]); }
59
+ wid[i] = id;
60
+ }
61
+ const edges = new Map(); // "lo:hi" -> { u, v, faces: [{ n, w }] }
62
+ for (let t = 0; t < indices.length; t += 3) {
63
+ const ids = [wid[indices[t]], wid[indices[t + 1]], wid[indices[t + 2]]];
64
+ const [a, b, c] = ids.map((i) => pts[i]);
65
+ const n = norm(cross(sub(b, a), sub(c, a)));
66
+ if (len(n) === 0) continue; // degenerate sliver
67
+ for (let e = 0; e < 3; e++) {
68
+ const u = ids[e], v = ids[(e + 1) % 3], w = pts[ids[(e + 2) % 3]];
69
+ if (u === v) continue;
70
+ const key = u < v ? `${u}:${v}` : `${v}:${u}`;
71
+ let rec = edges.get(key);
72
+ if (!rec) { rec = { u, v, faces: [] }; edges.set(key, rec); }
73
+ rec.faces.push({ n, w });
74
+ }
75
+ }
76
+ const cosSharp = Math.cos((sharpDeg * Math.PI) / 180);
77
+ const out = [];
78
+ for (const { u, v, faces } of edges.values()) {
79
+ if (faces.length !== 2) continue;
80
+ const [f1, f2] = faces;
81
+ if (dot(f1.n, f2.n) > cosSharp) continue; // smooth or coplanar
82
+ const convex = dot(sub(f2.w, pts[u]), f1.n) < -1e-9;
83
+ out.push({ ua: u, ub: v, a: pts[u], b: pts[v], n1: f1.n, n2: f2.n, convex });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Chain sharp edges into straight and circular-arc runs.
90
+ //
91
+ // Walk maximal paths through the sharp-edge graph (vertices of degree ≠ 2 and
92
+ // convexity flips end a path), classify each joint by turn angle, then split
93
+ // paths into runs: collinear-joined stretches are line chains; stretches joined
94
+ // by "circle-consistent" joints (small turn AND similar edge lengths — a long
95
+ // straight edge next to tiny arc facets fails the length test and stays its own
96
+ // line chain) are arc candidates, validated by a circumcircle fit. A loop with
97
+ // no run boundary at all is a full circle.
98
+ export function chainEdges(edges) {
99
+ const COS_COLL = Math.cos((COLLINEAR_DEG * Math.PI) / 180);
100
+ const COS_SMOOTH = Math.cos((SMOOTH_MAX_DEG * Math.PI) / 180);
101
+ const adj = new Map(); // welded vid -> [edge index...]
102
+ edges.forEach((e, i) => {
103
+ for (const v of [e.ua, e.ub]) (adj.get(v) ?? adj.set(v, []).get(v)).push(i);
104
+ });
105
+ const deg = (v) => adj.get(v).length;
106
+ const other = (e, v) => (e.ua === v ? e.ub : e.ua);
107
+ const used = new Array(edges.length).fill(false);
108
+
109
+ // orient edge e so it leaves vertex v: returns [from, to, dir]
110
+ const oriented = (e, v) => {
111
+ const from = v === e.ua ? e.a : e.b, to = v === e.ua ? e.b : e.a;
112
+ return { from, to, dir: norm(sub(to, from)) };
113
+ };
114
+
115
+ const paths = []; // { members: [edgeIdx...], verts: [vid...], loop }
116
+ const walk = (start, firstEdge) => {
117
+ const members = [firstEdge], verts = [start];
118
+ let v = start, e = firstEdge;
119
+ for (;;) {
120
+ used[e] = true;
121
+ const nv = other(edges[e], v);
122
+ verts.push(nv);
123
+ if (deg(nv) !== 2) break;
124
+ const next = adj.get(nv).find((i) => !used[i]);
125
+ if (next === undefined) break;
126
+ if (edges[next].convex !== edges[e].convex) break; // convexity flip ends the path
127
+ // turn angle gate: corners end paths
128
+ const d1 = oriented(edges[e], v).dir, d2 = oriented(edges[next], nv).dir;
129
+ if (dot(d1, d2) < COS_SMOOTH) break;
130
+ members.push(next);
131
+ v = nv; e = next;
132
+ }
133
+ return { members, verts, loop: verts[0] === verts[verts.length - 1] };
134
+ };
135
+ // open paths first (seeded at non-degree-2 vertices), then leftovers: loops,
136
+ // or open runs whose gates (turn/convexity) broke the walk mid-graph — those
137
+ // are walked in both directions from the seed and stitched
138
+ for (const [v, list] of adj) {
139
+ if (deg(v) === 2) continue;
140
+ for (const i of list) if (!used[i]) paths.push(walk(v, i));
141
+ }
142
+ edges.forEach((_, i) => {
143
+ if (used[i]) return;
144
+ const fwd = walk(edges[i].ua, i);
145
+ if (fwd.loop) { paths.push(fwd); return; }
146
+ // extend backward from the seed vertex if a compatible unused edge remains
147
+ const backSeed = adj.get(edges[i].ua).find((j) => !used[j]);
148
+ if (backSeed === undefined) { paths.push(fwd); return; }
149
+ const back = walk(other(edges[backSeed], edges[i].ua), backSeed);
150
+ paths.push({
151
+ members: [...back.members.slice().reverse(), ...fwd.members],
152
+ verts: [...back.verts.slice().reverse(), ...fwd.verts.slice(1)],
153
+ loop: false,
154
+ });
155
+ });
156
+
157
+ // split a path's member list into runs of "line" (collinear joints) and "arc"
158
+ // (circle-consistent joints) edges
159
+ const chains = [];
160
+ for (const path of paths) {
161
+ const { members, verts, loop } = path;
162
+ const dirs = members.map((i, k) => oriented(edges[i], verts[k]).dir);
163
+ const lens = members.map((i) => len(sub(edges[i].b, edges[i].a)));
164
+ const nJoint = loop ? members.length : members.length - 1; // joint j is between member j and j+1 (mod)
165
+ const jointType = []; // "coll" | "circ" | "cut"
166
+ for (let j = 0; j < nJoint; j++) {
167
+ const k2 = (j + 1) % members.length;
168
+ const c = dot(dirs[j], dirs[k2]);
169
+ const ratio = lens[j] / lens[k2];
170
+ if (c >= COS_COLL) jointType.push("coll");
171
+ else if (c >= COS_SMOOTH && ratio > 1 / 3 && ratio < 3) jointType.push("circ");
172
+ else jointType.push("cut");
173
+ }
174
+ // rotate loops so index 0 starts right after a run boundary; a loop with no
175
+ // boundary at all is a closed uniform curve (full circle candidate)
176
+ let order = members.map((_, k) => k);
177
+ let closedUniform = false;
178
+ if (loop) {
179
+ const boundary = jointType.findIndex((t, j) => t !== jointType[(j - 1 + nJoint) % nJoint] || t === "cut");
180
+ const cut = jointType.indexOf("cut");
181
+ const startAfter = cut !== -1 ? cut : boundary !== -1 ? boundary : -1;
182
+ if (startAfter === -1 && jointType.every((t) => t === jointType[0])) closedUniform = true;
183
+ else if (startAfter !== -1) order = order.map((k) => (startAfter + 1 + k) % members.length);
184
+ }
185
+ const runs = [];
186
+ let run = null;
187
+ for (let idx = 0; idx < order.length; idx++) {
188
+ const k = order[idx];
189
+ if (!run) { run = { type: null, ks: [k] }; continue; }
190
+ const t = jointType[order[idx - 1]]; // joint j joins member j and its successor
191
+ if (t === "cut" || (run.type !== null && run.type !== t)) { runs.push(run); run = { type: null, ks: [k] }; }
192
+ else { if (run.type === null) run.type = t; run.ks.push(k); }
193
+ }
194
+ if (run) runs.push(run);
195
+ // a run's type is the joint type joining its members; single-member runs are lines
196
+ for (const r of runs) {
197
+ const type = r.ks.length === 1 || r.type === "coll" || r.type === null ? "line" : "arc";
198
+ chains.push(buildChain(edges, path, r.ks, type, closedUniform && runs.length === 1));
199
+ }
200
+ }
201
+ return chains;
202
+ }
203
+
204
+ function buildChain(edges, path, ks, type, closed) {
205
+ // ks are positions along the path; path.members maps them to global edge indices
206
+ const members = ks.map((k) => edges[path.members[k]]);
207
+ const convex = members[0].convex;
208
+ // ordered polyline: the member at path position k runs path.verts[k] -> path.verts[k+1]
209
+ const points = [vertPos(members[0], path.verts[ks[0]])];
210
+ ks.forEach((k, i) => points.push(vertPos(members[i], otherVid(members[i], path.verts[k]))));
211
+ if (type !== "arc") {
212
+ // pair flank normals consistently against the first member (world frame is
213
+ // fine here — straight chains have near-constant flank normals)
214
+ const ref = members[0];
215
+ const flanks = members.map((m) => (dot(m.n1, ref.n1) >= dot(m.n2, ref.n1) ? [m.n1, m.n2] : [m.n2, m.n1]));
216
+ const a = points[0], b = points[points.length - 1];
217
+ const dir = norm(sub(b, a));
218
+ const n1 = norm(flanks.reduce((s, f) => add(s, f[0]), [0, 0, 0]));
219
+ const n2 = norm(flanks.reduce((s, f) => add(s, f[1]), [0, 0, 0]));
220
+ // planar-flank sanity: every member within ~3° of the mean
221
+ const planar = flanks.every((f) => dot(f[0], n1) > 0.9986 && dot(f[1], n2) > 0.9986);
222
+ if (!planar) return { kind: "unsupported", reason: "straight edge with non-planar flanks", points, convex };
223
+ return { kind: "line", points, a, b, dir, length: len(sub(b, a)), n1, n2, convex };
224
+ }
225
+ return fitArcChain(members, points, convex, closed);
226
+ }
227
+ const vertPos = (e, vid) => (vid === e.ua ? e.a : e.b);
228
+ const otherVid = (e, vid) => (vid === e.ua ? e.ub : e.ua);
229
+
230
+ // Circumcircle fit + rotating-frame flank extraction for an arc run.
231
+ function fitArcChain(members, points, convex, closed) {
232
+ const bad = (reason) => ({ kind: "unsupported", reason, points, convex });
233
+ const n = points.length;
234
+ if (n < 3) return bad("arc run too short to fit");
235
+ const p0 = points[0], pm = points[Math.floor(n / 2)], pn = closed ? points[Math.floor((2 * n) / 3)] : points[n - 1];
236
+ // circumcircle of three points
237
+ const e1 = sub(pm, p0), e2 = sub(pn, p0);
238
+ const w0 = cross(e1, e2);
239
+ if (len(w0) < 1e-12) return bad("arc points are collinear");
240
+ const w = norm(w0);
241
+ const l1 = dot(e1, e1), l2 = dot(e2, e2), c12 = dot(e1, e2);
242
+ const det = 2 * (l1 * l2 - c12 * c12);
243
+ const alpha = (l2 * (l1 - c12)) / det, beta = (l1 * (l2 - c12)) / det;
244
+ const O = add(p0, add(scl(e1, alpha), scl(e2, beta)));
245
+ const R = len(sub(p0, O));
246
+ const rtol = Math.max(1e-3, 1e-3 * R);
247
+ for (const p of points) {
248
+ if (Math.abs(len(sub(p, O)) - R) > rtol) return bad("edge curve is not circular");
249
+ if (Math.abs(dot(sub(p, O), w)) > rtol) return bad("edge curve is not planar");
250
+ }
251
+ // frame: azimuth 0 at the first point; flip w so azimuths increase along the run
252
+ const u0 = norm(sub(points[0], O));
253
+ let v0 = cross(w, u0);
254
+ const az = (p) => Math.atan2(dot(sub(p, O), v0), dot(sub(p, O), u0));
255
+ let wS = w, v0S = v0;
256
+ if (!closed && az(points[1]) < 0) { wS = scl(w, -1); v0S = cross(wS, u0); }
257
+ else if (closed && az(points[1]) < 0) { wS = scl(w, -1); v0S = cross(wS, u0); }
258
+ const azS = (p) => { const a = Math.atan2(dot(sub(p, O), v0S), dot(sub(p, O), u0)); return a < -1e-9 ? a + 2 * Math.PI : a; };
259
+ let span = 2 * Math.PI;
260
+ if (!closed) {
261
+ let prev = 0;
262
+ for (let i = 1; i < n; i++) {
263
+ const a = azS(points[i]);
264
+ if (a < prev - 1e-9) return bad("arc azimuths not monotonic");
265
+ prev = a;
266
+ }
267
+ span = azS(points[n - 1]);
268
+ }
269
+ // rotating-frame flanks: constant (ρ, ζ) components, negligible azimuthal part.
270
+ // Pairing happens HERE, in the rotating frame — a revolved wall's world-space
271
+ // normal flips sign across the circle, so world-frame pairing would swap flanks
272
+ // on the far side.
273
+ const rfRaw = members.map((m) => {
274
+ const mid = scl(add(vertPos(m, m.ua), vertPos(m, m.ub)), 0.5);
275
+ const th = azS(mid);
276
+ const rho = add(scl(u0, Math.cos(th)), scl(v0S, Math.sin(th)));
277
+ const azv = cross(wS, rho);
278
+ return [m.n1, m.n2].map((f) => {
279
+ if (Math.abs(dot(f, azv)) > 0.2) return null; // not a surface of revolution about this axis
280
+ const v2 = [dot(f, rho), dot(f, wS)];
281
+ const l = Math.hypot(v2[0], v2[1]);
282
+ return [v2[0] / l, v2[1] / l];
283
+ });
284
+ });
285
+ if (rfRaw.some((pair) => pair.some((f) => f === null))) return bad("flank is not a surface of revolution about the edge axis");
286
+ const refRf = rfRaw[0];
287
+ const rf = rfRaw.map(([f1, f2]) =>
288
+ f1[0] * refRf[0][0] + f1[1] * refRf[0][1] >= f2[0] * refRf[0][0] + f2[1] * refRf[0][1] ? [f1, f2] : [f2, f1]);
289
+ const mean = (idx) => {
290
+ const s = rf.reduce((acc, pair) => [acc[0] + pair[idx][0], acc[1] + pair[idx][1]], [0, 0]);
291
+ const l = Math.hypot(s[0], s[1]); return [s[0] / l, s[1] / l];
292
+ };
293
+ const n1 = mean(0), n2 = mean(1);
294
+ const ok = rf.every((pair) => pair[0][0] * n1[0] + pair[0][1] * n1[1] > 0.9986 && pair[1][0] * n2[0] + pair[1][1] * n2[1] > 0.9986);
295
+ if (!ok) return bad("flank angle varies along the arc");
296
+ return { kind: "arc", points, O, w: wS, u0, v0: v0S, R, span, closed, n1, n2, convex };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Selector: the edge-selector.js object form, evaluated against a chain.
301
+ const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
302
+ const PLANE_AXIS = { XY: 2, XZ: 1, YZ: 0 };
303
+ export function matchesSelector(chain, sel) {
304
+ if (sel == null) return true;
305
+ if (typeof sel === "function")
306
+ throw new UnsupportedEdgeError("function selectors are OCCT-specific — use the {dir, inPlane, at, near} object form");
307
+ const { dir, inPlane, at, near } = sel;
308
+ if (dir !== undefined) {
309
+ if (chain.kind !== "line") return false; // like replicad inDirection: straight edges only
310
+ const d = Array.isArray(dir) ? norm(dir) : AXIS[dir];
311
+ if (!d) throw new Error(`mesh fillet: unknown dir ${JSON.stringify(dir)}`);
312
+ if (Math.abs(dot(chain.dir, d)) < Math.cos((1 * Math.PI) / 180)) return false;
313
+ }
314
+ if (inPlane !== undefined) {
315
+ const ax = PLANE_AXIS[inPlane];
316
+ if (ax === undefined) throw new Error(`mesh fillet: unknown inPlane ${JSON.stringify(inPlane)}`);
317
+ const c = at ?? 0;
318
+ if (!chain.points.every((p) => Math.abs(p[ax] - c) <= TOL)) return false;
319
+ }
320
+ if (near !== undefined) {
321
+ if (chain.kind === "arc") {
322
+ // Select against the fitted circle, not its tessellated chords. An exact
323
+ // design-space point between two mesh vertices sits one facet sagitta away
324
+ // from the chord and must not spuriously miss (and reroute to OCCT).
325
+ const q = sub(near, chain.O);
326
+ const axial = dot(q, chain.w);
327
+ const radial = sub(q, scl(chain.w, axial));
328
+ if (Math.abs(axial) > TOL || Math.abs(len(radial) - chain.R) > TOL) return false;
329
+ if (!chain.closed) {
330
+ let az = Math.atan2(dot(radial, chain.v0), dot(radial, chain.u0));
331
+ if (az < 0) az += 2 * Math.PI;
332
+ const angularTol = TOL / Math.max(chain.R, TOL);
333
+ if (az > chain.span + angularTol && 2 * Math.PI - az > angularTol) return false;
334
+ }
335
+ } else {
336
+ let best = Infinity;
337
+ for (let i = 0; i + 1 < chain.points.length; i++) {
338
+ const a = chain.points[i], b = chain.points[i + 1];
339
+ const ab = sub(b, a), t = Math.max(0, Math.min(1, dot(sub(near, a), ab) / (dot(ab, ab) || 1)));
340
+ best = Math.min(best, len(sub(near, add(a, scl(ab, t)))));
341
+ }
342
+ if (best > TOL) return false;
343
+ }
344
+ }
345
+ return true;
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Shared 2D cross-section profile. P is the edge point, n1/n2 the unit flank
350
+ // normals, all in the cross-section plane. Fillet connects the tangent points
351
+ // with the rolling-ball arc; chamfer with a straight chord. Convex profiles
352
+ // hang off the corner (oversized past it by delta so no cutter wall is exactly
353
+ // coplanar with a flank); concave profiles tuck the corner point into the
354
+ // material so the filler welds on.
355
+ const rot2 = ([x, y], th) => [x * Math.cos(th) - y * Math.sin(th), x * Math.sin(th) + y * Math.cos(th)];
356
+ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
357
+ const c = clamp1(n1[0] * n2[0] + n1[1] * n2[1]);
358
+ if (1 + c < 1e-6) throw new UnsupportedEdgeError("~180° knife edge");
359
+ const bl = Math.hypot(n1[0] + n2[0], n1[1] + n2[1]);
360
+ const bis = [(n1[0] + n2[0]) / bl, (n1[1] + n2[1]) / bl];
361
+ const delta = 0.02 * magnitude;
362
+ const sgn = convex ? 1 : -1; // +bis is outside the material at a convex corner, inside at a concave one
363
+ const corner = [P[0] + sgn * delta * bis[0], P[1] + sgn * delta * bis[1]];
364
+ if (mode === "chamfer") {
365
+ // setback along each flank surface, away from the edge: perpendicular to the
366
+ // normal, on the material side of the bisector (which points out of the
367
+ // material at a convex corner and into the air pocket at a concave one)
368
+ const inFace = (nv) => {
369
+ let f = [-nv[1], nv[0]];
370
+ if (sgn * (f[0] * bis[0] + f[1] * bis[1]) > 0) f = [-f[0], -f[1]];
371
+ return f;
372
+ };
373
+ const f1 = inFace(n1), f2 = inFace(n2);
374
+ const T1 = [P[0] + magnitude * f1[0], P[1] + magnitude * f1[1]];
375
+ const T2 = [P[0] + magnitude * f2[0], P[1] + magnitude * f2[1]];
376
+ if (ext > 0) {
377
+ // revolve tools: extend the chord past both flanks so the tool's closing
378
+ // walls clear the flank tessellation instead of hugging it (same float
379
+ // phase-noise issue the fillet's arc extension solves); the extra polygon
380
+ // area lies outside the material for cutters and inside it for fillers
381
+ const ux = T1[0] - T2[0], uy = T1[1] - T2[1], ul = Math.hypot(ux, uy);
382
+ const e = 0.02 * magnitude;
383
+ T1[0] += (e * ux) / ul; T1[1] += (e * uy) / ul;
384
+ T2[0] -= (e * ux) / ul; T2[1] -= (e * uy) / ul;
385
+ }
386
+ return [corner, T1, T2];
387
+ }
388
+ const r = magnitude;
389
+ const C = [P[0] + sgn * (-r / (1 + c)) * (n1[0] + n2[0]), P[1] + sgn * (-r / (1 + c)) * (n1[1] + n2[1])];
390
+ const phi = Math.atan2(n1[0] * n2[1] - n1[1] * n2[0], c); // signed angle n1 → n2
391
+ // `ext` (radians) continues the arc a hair past both tangent points — used by
392
+ // revolve cutters only. At the tangent the blend surface touches the flank
393
+ // without crossing it; for two curved tessellations (posed revolve vs flank
394
+ // facets) float phase noise turns that contact into a wiggle of degenerate
395
+ // sliver triangles. Overshooting makes the cutter cross the flank decisively,
396
+ // penetrating the material by only r·(1−cos ext) ≈ r·5e-5 mm, far below
397
+ // visibility, and the extension curves into the material for cutters and
398
+ // fillers alike. Prism cutters keep ext = 0: their tangent contact is
399
+ // plane-on-plane, which the kernel resolves exactly.
400
+ const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
401
+ const nArc = Math.max(2, Math.ceil((span / (2 * Math.PI)) * segs));
402
+ const pts = [corner];
403
+ for (let i = 0; i <= nArc; i++) {
404
+ const nv = rot2(n1, s2 * (-ext + ((span + 2 * ext) * i) / nArc));
405
+ pts.push([C[0] + sgn * r * nv[0], C[1] + sgn * r * nv[1]]);
406
+ }
407
+ return pts;
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Cutter/filler solids.
412
+ function prismTool(k, chain, magnitude, mode, segs) {
413
+ const { a, dir: e, length, n1, n2, convex } = chain;
414
+ // pose rotation Z → e; the 2D basis is the image of X,Y under the SAME rotation
415
+ const axisRaw = cross([0, 0, 1], e);
416
+ const s = len(axisRaw);
417
+ let axis = null, theta = 0;
418
+ if (s > 1e-9) { axis = scl(axisRaw, 1 / s); theta = Math.atan2(s, e[2]); }
419
+ else if (e[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
420
+ const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
421
+ const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
422
+ const p2 = (w) => [dot(w, u), dot(w, v)];
423
+ const poly = profile2D({ P: [0, 0], n1: p2(n1), n2: p2(n2), magnitude, mode, convex, segs });
424
+ // convex cutters overshoot the edge ends (sticking outside the solid is
425
+ // harmless when subtracting); concave fillers must end flush — any overshoot
426
+ // would bulge outside the part when unioned
427
+ const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
428
+ let tool = k.loft(
429
+ [{ polygon: poly, z: -over }, { polygon: poly, z: length + over }],
430
+ { shading: "smooth" },
431
+ );
432
+ if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
433
+ return tool.translate(a);
434
+ }
435
+
436
+ function revolveTool(k, chain, magnitude, mode, segs) {
437
+ const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
438
+ // Seam-grazing guard. The edge circle passes through the flank tessellation's
439
+ // VERTICES (circumradius) while its facets sit at the apothem, so a revolved
440
+ // tool built exactly at R grazes every facet seam tangentially — Manifold
441
+ // keeps the resulting epsilon-degenerate needle triangles, and simplify()
442
+ // cannot always collapse them. `sag` is that facet sagitta plus a roundoff pad
443
+ // bounded relative to the requested feature, so tiny blends never inherit a
444
+ // fixed allowance larger than their own cross-section.
445
+ const sag = (R + magnitude) * (1 - Math.cos(Math.PI / segs)) + Math.min(2e-4, 0.02 * magnitude);
446
+ // Fillet: size the arc-tail extension to cross the facet planes, but cap it at
447
+ // 0.4 rad. Below the mesh's own facet scale a larger tail wraps around the tiny
448
+ // profile and creates one tunnel per facet; the cap bounds penetration to 8%
449
+ // of the requested radius while the cutter's outside corner still opens into
450
+ // free space.
451
+ const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / magnitude))));
452
+ let poly = profile2D({ P: [R, 0], n1, n2, magnitude, mode, convex, segs, ext });
453
+ if (mode === "chamfer") {
454
+ // Chamfer: the cone itself is the cutting surface — no tail to extend, so
455
+ // bury the whole profile by `sag` along the material-side bisector instead.
456
+ // The chamfer lands microns deep; dimensionally invisible.
457
+ const bl2 = Math.hypot(n1[0] + n2[0], n1[1] + n2[1]);
458
+ const bis2 = [(n1[0] + n2[0]) / bl2, (n1[1] + n2[1]) / bl2];
459
+ // A convex cutter must leave its outside closure corner unburied: moving the
460
+ // whole profile inward can close a micro-tunnel per flank facet when sag is
461
+ // larger than a tiny chamfer. A concave filler needs every point buried so
462
+ // it overlaps the source solid instead of leaving disconnected components.
463
+ poly = poly.map(([x, y], i) => i === 0 && convex ? [x, y] : [x - sag * bis2[0], y - sag * bis2[1]]);
464
+ }
465
+ if (poly.some(([x]) => x <= 0)) throw new UnsupportedEdgeError("fillet crosses the revolve axis (radius too large for this bore)");
466
+ // enforce CCW winding for the revolve
467
+ let area = 0;
468
+ for (let i = 0; i < poly.length; i++) {
469
+ const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
470
+ area += x1 * y2 - x2 * y1;
471
+ }
472
+ if (area < 0) poly = poly.slice().reverse();
473
+ const ovAng = closed || !convex ? 0 : Math.min(0.15, Math.max(1e-3, (0.05 * magnitude) / R));
474
+ const degrees = closed ? 360 : ((span + 2 * ovAng) * 180) / Math.PI;
475
+ let tool = k.revolve(poly, { degrees });
476
+ // pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
477
+ // chain's start direction (backed off by the angular overshoot)
478
+ const startDir = closed ? u0 : add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
479
+ const axisRaw = cross([0, 0, 1], w);
480
+ const s = len(axisRaw);
481
+ let axis = null, theta = 0;
482
+ if (s > 1e-9) { axis = scl(axisRaw, 1 / s); theta = Math.atan2(s, w[2]); }
483
+ else if (w[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
484
+ if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
485
+ const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
486
+ // Closed revolves get an extra half-facet twist: at 360° the tool's facet
487
+ // pitch exactly matches the flank's, and phase-aligned seams graze vertex-on-
488
+ // vertex at every step (the degenerate-needle generator). Half a step lands
489
+ // every crossing mid-facet. Partial arcs have a slightly different pitch
490
+ // (degrees don't divide evenly) and never align in the first place.
491
+ const dephase = closed ? Math.PI / segs : 0;
492
+ const twist = Math.atan2(dot(w, cross(xImage, startDir)), dot(xImage, startDir)) + dephase;
493
+ if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: w, deg: (twist * 180) / Math.PI });
494
+ return tool.translate(O);
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Spherical corner patches. Where exactly three selected straight convex chains
499
+ // meet at a vertex with mutually orthogonal directions (a box-like corner), the
500
+ // three edge blends are capped with a rolling-ball sphere octant instead of the
501
+ // default mitre: cutter = corner cube − sphere(C, r), the classic corner-mask
502
+ // construction, with C = V + r·(ê1+ê2+ê3) (distance r inside each face). The
503
+ // cube spans exactly [V, V + r·ê_j] — the sphere-cylinder tangent planes —
504
+ // because inside that cube the true rolling-ball surface is PURE sphere (the
505
+ // edge cylinders end at the tangent planes; extending protection cylinders in
506
+ // here would preserve their proud Steinmetz-intersection ridges instead of the
507
+ // sphere patch, and extending the cube out would gouge the edge fillets). The
508
+ // cube's outer walls land inside the material the edge cutters already remove,
509
+ // so the only new surface is the octant. Non-orthogonal corners keep the mitre
510
+ // — the safe, documented default.
511
+ function cornerPatches(k, selected, r, segs) {
512
+ const lines = selected.filter((ch) => ch.kind === "line" && ch.convex);
513
+ const byVertex = new Map();
514
+ for (const ch of lines) {
515
+ for (const [pt, dirOut] of [[ch.a, ch.dir], [ch.b, scl(ch.dir, -1)]]) {
516
+ const key = pt.map((v) => Math.round(v * 1e4)).join(",");
517
+ (byVertex.get(key) ?? byVertex.set(key, []).get(key)).push({ pt, dirOut });
518
+ }
519
+ }
520
+ const patches = [];
521
+ for (const ends of byVertex.values()) {
522
+ if (ends.length !== 3) continue;
523
+ let [e1, e2, e3] = ends.map((e) => e.dirOut);
524
+ const ortho = Math.abs(dot(e1, e2)) < 1e-3 && Math.abs(dot(e1, e3)) < 1e-3 && Math.abs(dot(e2, e3)) < 1e-3;
525
+ if (!ortho) continue; // non-orthogonal trihedral: leave the mitre
526
+ if (dot(cross(e1, e2), e3) < 0) [e2, e3] = [e3, e2]; // right-handed frame
527
+ const V = ends[0].pt;
528
+ const C = add(V, scl(add(add(e1, e2), e3), r));
529
+ const dOut = 0.02 * r;
530
+ // Bury the sphere a hair into the material (past its own facet sagitta): it
531
+ // is tangent to each flat face at a point and meets the edge-fillet
532
+ // cylinders tangentially at the cube walls, and tessellated tangency
533
+ // produces the same grazing-noise creases the edge tools guard against.
534
+ const bury = r * (1 - Math.cos(Math.PI / segs)) + 1e-3;
535
+ const inward = norm(add(add(e1, e2), e3));
536
+ // corner block: cube spanned by the edge frame, oversized only outward
537
+ let block = k.box({ min: [-dOut, -dOut, -dOut], max: [r, r, r] });
538
+ // pose standard axes onto (e2, e3, e1): Z → e1, then twist X-image onto e2
539
+ const axisRaw = cross([0, 0, 1], e1);
540
+ const s = len(axisRaw);
541
+ let axis = null, theta = 0;
542
+ if (s > 1e-9) { axis = scl(axisRaw, 1 / s); theta = Math.atan2(s, e1[2]); }
543
+ else if (e1[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
544
+ if (axis) block = block.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
545
+ const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
546
+ const twist = Math.atan2(dot(e1, cross(xImage, e2)), dot(xImage, e2));
547
+ if (Math.abs(twist) > 1e-9) block = block.rotateAbout({ axis: e1, deg: (twist * 180) / Math.PI });
548
+ block = block.translate(V);
549
+ patches.push(block.cut(k.sphere({ r }).at(add(C, scl(inward, bury)))));
550
+ }
551
+ return patches;
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // Entry points.
556
+ // meshFillet(k, solid, { r, edges?, segs?, sharpDeg? }) → Solid
557
+ // meshChamfer(k, solid, { d, edges?, segs?, sharpDeg? }) → Solid
558
+ export function meshFillet(k, solid, opts) { return apply(k, solid, "fillet", opts?.r, opts); }
559
+ export function meshChamfer(k, solid, opts) { return apply(k, solid, "chamfer", opts?.d, opts); }
560
+
561
+ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg = 20 } = {}) {
562
+ if (!(magnitude > 0)) throw new Error(`mesh ${mode}: magnitude must be > 0`);
563
+ const chains = chainEdges(detectSharpEdges(solid.toIndexedMesh(), { sharpDeg }));
564
+ const selected = chains.filter((ch) => matchesSelector(ch, edges));
565
+ if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
566
+ const unsupported = selected.find((ch) => ch.kind === "unsupported");
567
+ if (unsupported) throw new UnsupportedEdgeError(`${mode}: ${unsupported.reason}`);
568
+ const tool = (ch) => (ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs);
569
+ const cutters = selected.filter((ch) => ch.convex).map(tool);
570
+ const fillers = selected.filter((ch) => !ch.convex).map(tool);
571
+ if (mode === "fillet") cutters.push(...cornerPatches(k, selected, magnitude, segs));
572
+ let out = solid;
573
+ if (cutters.length) out = out.cutAll(cutters);
574
+ if (fillers.length) out = k.union([out, ...fillers]);
575
+ return out;
576
+ }
@@ -1,7 +1,7 @@
1
1
  // Geometry-free build execution. Two consumers share one Proxy implementation:
2
2
  //
3
3
  // • createProbeKernel() — records op NAMES so ../backend-select.js's
4
- // detectBackend() can route a part to OCCT when it uses fillet/chamfer/shell.
4
+ // detectBackend() can route a part to OCCT when it uses Solid.shell.
5
5
  // • createValidatingProbe() — additionally checks op names against the kernel
6
6
  // contract's op lists and routes options-form calls through the same op-options
7
7
  // normalizers the real backends use, so partforge/lint can catch a bad call in
@@ -15,17 +15,17 @@
15
15
  // lists, which test/kernel-contract.test.js pins to both backend implementations.
16
16
  import {
17
17
  KERNEL_OPS, KERNEL_OPTIONAL_OPS,
18
- SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS,
18
+ SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, ROUTED_CAD_OPS,
19
19
  } from "./kernel.js";
20
20
  import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
21
21
 
22
22
  // The probe hands out TWO chainable handles: k.shape2d()/k.text2d() yield a Shape2D
23
23
  // handle (whose extrude/revolve yield the Solid handle), everything else yields the
24
- // Solid handle. The split exists for one reason backend routing: `Shape2D.fillet`
25
- // is the shared pure-JS implementation and must NOT route a part to OCCT, while
26
- // `Solid.fillet` must. Handle-level VALIDATION stays deliberately permissive (one
27
- // union allowlist for both handle kinds), so the split can never false-positive on
28
- // an error-severity rule.
24
+ // Solid handle. The split lets lint and routing distinguish handle-specific ops:
25
+ // `Shape2D.fillet` is shared pure JS, `Solid.fillet` starts mesh-native and may
26
+ // request a runtime OCCT fallback, and `Solid.shell` probe-routes up front.
27
+ // Handle-level VALIDATION stays deliberately permissive (one union allowlist for
28
+ // both handle kinds), so the split can never false-positive on an error rule.
29
29
  const KERNEL_ALLOWED = new Set([...KERNEL_OPS, ...KERNEL_OPTIONAL_OPS]);
30
30
  const SOLID_ALLOWED = new Set([...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS]);
31
31
 
@@ -92,14 +92,13 @@ function makeProbe(onCall) {
92
92
  return { kernel, proxy, shape2d };
93
93
  }
94
94
 
95
- const CAD_OPS = new Set(OCCT_ONLY_OPS);
95
+ const CAD_OPS = new Set(ROUTED_CAD_OPS);
96
96
 
97
97
  export function createProbeKernel() {
98
98
  const used = new Set(); // every op name, any handle kind
99
99
  const solidUsed = new Set(); // ops recorded on kernel/Solid handles only — the
100
100
  // routing set: Shape2D.fillet must not look like Solid.fillet
101
- const cadCalls = []; // Solid-handle fillet/chamfer/shell calls WITH args
102
- // routing needs the magnitude (fillet(0) is identity, stays on Manifold)
101
+ const cadCalls = []; // Probe-routed Solid ops WITH args (currently shell).
103
102
  const { kernel } = makeProbe((scope, key, args) => {
104
103
  used.add(key);
105
104
  if (scope !== "shape2d") {
@@ -2,10 +2,12 @@
2
2
  // the validating probe with no geometry kernel. Every error in this group already
3
3
  // throws at runtime; the value is reaching it in microseconds, before a WASM boot.
4
4
  import { err, warn } from "./finding.js";
5
- import { OCCT_ONLY_OPS } from "../geometry/kernel.js";
5
+ import { ROUTED_CAD_OPS } from "../geometry/kernel.js";
6
6
  import { MAX_PROBE_OPS } from "../geometry/probe.js";
7
7
 
8
- const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
8
+ // fillet/chamfer are implemented on the mesh backend now (mesh-fillet.js), so a
9
+ // pinned-Manifold part may use them; only the still-unimplemented ops error here.
10
+ const OCCT_ONLY = new Set(ROUTED_CAD_OPS);
9
11
  const unique = (xs) => [...new Set(xs)];
10
12
 
11
13
  export const BUILD_RULES = [
@@ -52,9 +54,8 @@ export const BUILD_RULES = [
52
54
  id: "manifold-backend-uses-occt-op",
53
55
  run: ({ part, probe }) => {
54
56
  if (part?.meta?.backend !== "manifold") return [];
55
- // solidUsed, not used: `Shape2D.fillet`/`.chamfer` are backend-identical pure
56
- // JS and are fine under a pinned Manifold backend; only Solid-handle uses of
57
- // these names are CAD-only.
57
+ // solidUsed, not used: a Shape2D method with the same name as a routed Solid
58
+ // op is still backend-identical pure JS and is fine under pinned Manifold.
58
59
  return [...probe().solidUsed].filter((op) => OCCT_ONLY.has(op))
59
60
  .map((op) => err("manifold-backend-uses-occt-op",
60
61
  `\`meta.backend\` pins Manifold, but the build calls \`${op}\`, which only OCCT implements`,