partforge 0.60.2 → 0.62.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 +4 -3
- package/bin/cli.js +17 -1
- package/docs/AUTHORING-PARTS.md +52 -32
- package/docs/ERROR-PATTERNS.md +16 -4
- package/docs/KERNEL-CONTRACT.md +65 -13
- package/package.json +1 -1
- package/src/app-mixed-smoke.js +9 -0
- package/src/framework/backend-select.js +57 -24
- package/src/framework/geometry/creased-normals.js +13 -1
- package/src/framework/geometry/kernel.js +14 -7
- package/src/framework/geometry/manifold-backend.js +37 -0
- package/src/framework/geometry/mesh-fillet.js +576 -0
- package/src/framework/geometry/probe.js +9 -10
- package/src/framework/jobs.js +3 -1
- package/src/framework/lint/rules-build.js +6 -5
- package/src/framework/mount.js +33 -11
- package/src/framework/regen-loop.js +13 -9
- package/src/mixed-smoke-worker.js +3 -0
- package/src/parts/filleted-box.js +3 -2
- package/src/parts/mixed-smoke.js +43 -0
- package/types/kernel.d.ts +9 -6
|
@@ -12,6 +12,8 @@ import { finishKernel } from "./kernel-front.js";
|
|
|
12
12
|
import { meshToStl } from "./mesh-stl.js";
|
|
13
13
|
import { creasedNormals } from "./creased-normals.js";
|
|
14
14
|
import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
|
|
15
|
+
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
16
|
+
import { KernelCapabilityError } from "./errors.js";
|
|
15
17
|
|
|
16
18
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
17
19
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -102,10 +104,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
102
104
|
return { positions, indices };
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
// Mesh fillet/chamfer (mesh-fillet.js): the tool solids are built through the
|
|
108
|
+
// kernel's own cached loft/revolve/boolean ops, so the whole op memoizes at
|
|
109
|
+
// this boundary — and the result is simplify()ed to drop the zero-area sliver
|
|
110
|
+
// triangles booleans legitimately leave behind (they are invisible but their
|
|
111
|
+
// degenerate normals poison the crease pass into drawing phantom edge lines).
|
|
112
|
+
// Unsupported edge classes (helical edges, varying dihedral, …) surface as
|
|
113
|
+
// KernelCapabilityError so the framework reroutes that sub-part to OCCT.
|
|
114
|
+
// simplify() will not collapse triangles across run (originalID) boundaries,
|
|
115
|
+
// and the boolean's sliver triangles sit exactly on them — so the result is
|
|
116
|
+
// re-originaled first. That folds every surface into one fresh original,
|
|
117
|
+
// which is also the documented B-rep semantic: fillet/chamfer produce new
|
|
118
|
+
// surfaces, so feature-label attribution downstream of the op uses the
|
|
119
|
+
// fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
|
|
120
|
+
const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
|
|
121
|
+
const meshCadOp = (op, run) => {
|
|
122
|
+
try {
|
|
123
|
+
return T(T(run()._m.asOriginal()).simplify(SIMPLIFY_EPS));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
105
130
|
const wrap = (m, hash) => addSugar({
|
|
106
131
|
_m: m,
|
|
107
132
|
_hash: hash,
|
|
108
133
|
cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
|
|
134
|
+
fillet: (r, selector) => {
|
|
135
|
+
if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
|
|
136
|
+
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
137
|
+
return cached(h("fillet", hash, r, selector ?? null, segs), () =>
|
|
138
|
+
meshCadOp("fillet", () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
139
|
+
},
|
|
140
|
+
chamfer: (d, selector) => {
|
|
141
|
+
if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
|
|
142
|
+
if (d === 0) return wrap(m, hash);
|
|
143
|
+
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
144
|
+
meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
145
|
+
},
|
|
109
146
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
110
147
|
() => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
111
148
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
@@ -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
|
+
}
|