partforge 0.64.1 → 0.65.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.
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2153,8 +2153,10 @@ the requirement that exposed the failure.
|
|
|
2153
2153
|
Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
|
|
2154
2154
|
**OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold — and since
|
|
2155
2155
|
contract v3 that **includes fillet and chamfer**: the mesh backend blends straight
|
|
2156
|
-
edges
|
|
2157
|
-
face)
|
|
2156
|
+
edges, circular-arc edges (bore rims, cylinder rims, the arcs where fillets meet a
|
|
2157
|
+
face), and **planar contour edges at constant dihedral** — the top/bottom rims of any
|
|
2158
|
+
extruded profile, however curvy its outline: `text2d` lettering, `Shape2D.offset`
|
|
2159
|
+
outlines, spline profiles all round natively now. Only `shell` still routes a
|
|
2158
2160
|
sub-part to OCCT up front; a fillet/chamfer on an edge class the mesh backend can't
|
|
2159
2161
|
blend (helical edges, varying dihedral) reroutes that sub-part to OCCT automatically
|
|
2160
2162
|
at runtime — no declaration needed either way:
|
package/package.json
CHANGED
|
@@ -46,7 +46,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
46
46
|
// (manifests as "Out of bounds memory access").
|
|
47
47
|
const tracked = [];
|
|
48
48
|
const T = (obj) => { tracked.push(obj); return obj; };
|
|
49
|
-
|
|
49
|
+
// n-ary union in ONE batch op. This must never be a pairwise reduce: a hundred-tool
|
|
50
|
+
// cutAll (a text rim's fillet) reduced sequentially runs a hundred booleans on an
|
|
51
|
+
// ever-growing intermediate — measured 12 s and a 4 GB peak on a lettering part —
|
|
52
|
+
// while Manifold's own batch operator evaluates the same union as a balanced tree.
|
|
53
|
+
// A one-solid "union" returns the operand's own Manifold untouched (see union below).
|
|
54
|
+
const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
|
|
50
55
|
|
|
51
56
|
const cache = createSolidCache();
|
|
52
57
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
@@ -125,9 +130,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
125
130
|
// surfaces, so feature-label attribution downstream of the op uses the
|
|
126
131
|
// fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
|
|
127
132
|
const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
|
|
133
|
+
// Debris sweep: where blend tools graze each other or a flank near-tangentially, the
|
|
134
|
+
// boolean can strand a CLOSED femto-component (measured ~1e-8 mm³, 4 triangles) that
|
|
135
|
+
// simplify() cannot remove — it collapses edges, never whole components — and that
|
|
136
|
+
// flips the result's genus/decompose count. Anything below DEBRIS_VOL is two orders
|
|
137
|
+
// under the smallest feature the tessellation itself can express, and three under
|
|
138
|
+
// anything printable, so dropping it can never erase real geometry.
|
|
139
|
+
const DEBRIS_VOL = 1e-6; // mm³
|
|
140
|
+
const dropDebris = (m) => {
|
|
141
|
+
const parts = m.decompose();
|
|
142
|
+
if (parts.length <= 1) { for (const p of parts) T(p); return m; }
|
|
143
|
+
const kept = [];
|
|
144
|
+
for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
|
|
145
|
+
return kept.length === parts.length ? m : T(Manifold.compose(kept));
|
|
146
|
+
};
|
|
128
147
|
const meshCadOp = (op, run) => {
|
|
129
148
|
try {
|
|
130
|
-
return T(T(run()._m.asOriginal()).simplify(SIMPLIFY_EPS));
|
|
149
|
+
return T(dropDebris(T(run()._m.asOriginal())).simplify(SIMPLIFY_EPS));
|
|
131
150
|
} catch (e) {
|
|
132
151
|
if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
|
|
133
152
|
throw e;
|
|
@@ -158,8 +177,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
158
177
|
// tessellation, and so the result, is tier-dependent.
|
|
159
178
|
return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
|
|
160
179
|
},
|
|
180
|
+
// batch difference: first minus the union of the rest, evaluated as one boolean
|
|
181
|
+
// tree — no materialized intermediate union (the unionRaw memory note applies)
|
|
161
182
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
162
|
-
() => T(
|
|
183
|
+
() => T(Manifold.difference([m, ...tools.map((t) => t._m)]))),
|
|
163
184
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
164
185
|
union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
|
|
165
186
|
clone: () => wrap(m, hash),
|
|
@@ -325,10 +346,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
325
346
|
// (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
|
|
326
347
|
sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
|
|
327
348
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
328
|
-
|
|
349
|
+
// opts.segs may only COARSEN below the kernel's quality (min), never exceed it:
|
|
350
|
+
// callers use it where a small feature's sagitta bound needs fewer facets than
|
|
351
|
+
// the per-circle quality would spend (mesh-fillet's free-standing corner arcs).
|
|
352
|
+
revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
|
|
353
|
+
const density = Math.min(segs, segsOverride ?? segs);
|
|
329
354
|
if (pts && pts._shape2d)
|
|
330
|
-
return cached(h("revolve", pts._hash, degrees,
|
|
331
|
-
return cached(h("revolve", pts, degrees,
|
|
355
|
+
return cached(h("revolve", pts._hash, degrees, density), () => T(csFor(pts).revolve(density, degrees)));
|
|
356
|
+
return cached(h("revolve", pts, degrees, density), () => T(Manifold.revolve([pts], density, degrees)));
|
|
332
357
|
},
|
|
333
358
|
// A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
|
|
334
359
|
// unionRaw's reduce returns the operand's own Manifold untouched, so caching it
|
|
@@ -10,17 +10,30 @@
|
|
|
10
10
|
// - straight chains with planar flanks → lofted prism cutter
|
|
11
11
|
// - circular-arc chains with revolved flanks → revolved cutter (bore rims,
|
|
12
12
|
// cylinder rims, the arcs where fillets meet a face), full circles included
|
|
13
|
+
// - planar contour chains at constant dihedral → swept cutter/filler along the
|
|
14
|
+
// chain's own polyline (top/bottom rims of extruded text, offset outlines,
|
|
15
|
+
// splines — see tryPlanarChain/planarTool)
|
|
13
16
|
// Anything else (helical edges, varying dihedral, branching curves) raises
|
|
14
17
|
// UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
|
|
15
18
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// Corner treatment: a salient two-chain corner in a common face plane is ROUNDED —
|
|
20
|
+
// a small arc chain (radius ~1.05-1.25× the magnitude) replaces the mitre, its horn
|
|
21
|
+
// block shaves the sharp corner down to band depth, and the band sweeps around with
|
|
22
|
+
// no crease (see roundSalientCorners). The silhouette inside the band rounds by about
|
|
23
|
+
// the blend radius at such corners; the flat shelf the horn leaves at the band's base
|
|
24
|
+
// is the deliberate residue. Corners keep their mitre — today's and OCCT's behavior,
|
|
25
|
+
// a real crease the feature-line overlay honestly draws — when they are REFLEX (the
|
|
26
|
+
// ball cannot reach in), or too tight to host the setback (glyph-scale features
|
|
27
|
+
// smaller than ~3× the magnitude), or when three or more chains meet (the spherical
|
|
28
|
+
// cornerPatches below own the orthogonal three-chain case).
|
|
29
|
+
//
|
|
30
|
+
// Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
|
|
31
|
+
// like filleted-box.js does — an oversized radius self-intersects the cutters).
|
|
20
32
|
//
|
|
21
33
|
// Selector object mirrors edge-selector.js semantics ({dir, inPlane, at, near});
|
|
22
34
|
// `dir` only ever matches straight chains, like replicad's inDirection.
|
|
23
35
|
// Pure module: no DOM, no node:, no three — safe anywhere in the worker graph.
|
|
36
|
+
import { sweepSeedFrame } from "./sweep.js";
|
|
24
37
|
|
|
25
38
|
const TOL = 1e-4; // selector / coplanarity tolerance (mm)
|
|
26
39
|
const WELD = 1e6; // vertex weld quantization (1/WELD mm grid)
|
|
@@ -32,6 +45,24 @@ export class UnsupportedEdgeError extends Error {
|
|
|
32
45
|
constructor(message) { super(message); this.name = "UnsupportedEdgeError"; }
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
// Blend-band tessellation density: enough facets to keep the chord sagitta invisible,
|
|
49
|
+
// never more. The kernel's `segs` is a per-circle quality knob sized for part-scale
|
|
50
|
+
// circles; spending it on a blend of radius r tessellates a 0.5 mm fillet to 0.2 µm
|
|
51
|
+
// sagitta at preview quality — and a text rim's hundred-tool boolean then carries ~4×
|
|
52
|
+
// the triangles it needs (measured 12 s / 4 GB on a lettering part before this cap).
|
|
53
|
+
// BLEND_SAG (2 µm) is finer than preview quality's own ~4 µm sagitta at part scale;
|
|
54
|
+
// the 0.02·r term keeps micro-blends sane, and the floor of 12 keeps every facet
|
|
55
|
+
// angle (≤30°) under the viewer's 35° same-surface crease threshold.
|
|
56
|
+
const BLEND_SAG = 2e-3; // mm — max chord sagitta of a blend cross-section
|
|
57
|
+
function blendSegs(segs, r) {
|
|
58
|
+
const s = Math.min(BLEND_SAG, 0.02 * r);
|
|
59
|
+
return Math.min(segs, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - s / r))));
|
|
60
|
+
}
|
|
61
|
+
// One derivation for a synthetic corner arc's angular density, shared by revolveTool
|
|
62
|
+
// (which sweeps at it) and cornerHornTool (whose apothem bound below depends on it) —
|
|
63
|
+
// the horn's containment proof only holds if both compute the same number.
|
|
64
|
+
const cornerArcSegs = (segs, R, magnitude) => blendSegs(segs, R + magnitude);
|
|
65
|
+
|
|
35
66
|
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
36
67
|
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
37
68
|
const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
@@ -193,12 +224,135 @@ export function chainEdges(edges) {
|
|
|
193
224
|
}
|
|
194
225
|
if (run) runs.push(run);
|
|
195
226
|
// a run's type is the joint type joining its members; single-member runs are lines
|
|
227
|
+
const runChains = [];
|
|
196
228
|
for (const r of runs) {
|
|
197
229
|
const type = r.ks.length === 1 || r.type === "coll" || r.type === null ? "line" : "arc";
|
|
198
|
-
|
|
230
|
+
runChains.push(buildChain(edges, path, r.ks, type, closedUniform && runs.length === 1));
|
|
231
|
+
}
|
|
232
|
+
// Planar rescue is per-PATH, not per-run, and replaces the WHOLE path's chains: a rim
|
|
233
|
+
// that mixes straight, curvy, and short runs must become ONE swept tool, because
|
|
234
|
+
// per-run tools along the same rim continue each other nearly collinearly — their
|
|
235
|
+
// overshoots then overlap surface-on-surface (not the clean perpendicular crossing of
|
|
236
|
+
// a box corner) and the boolean leaves degenerate seams where identical blend
|
|
237
|
+
// surfaces coincide. A path whose runs are ALL line/arc keeps its exact per-run tools
|
|
238
|
+
// exactly as before — promotion only fires where the path would otherwise reroute.
|
|
239
|
+
if (runChains.some((c) => c.kind === "unsupported")) {
|
|
240
|
+
const rescue = buildPlanarPath(edges, path);
|
|
241
|
+
if (rescue) { chains.push(rescue); continue; }
|
|
242
|
+
}
|
|
243
|
+
chains.push(...runChains);
|
|
244
|
+
}
|
|
245
|
+
return stitchPlanarChains(chains);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Join open planar chains that continue each other across a path junction. The edge walk
|
|
249
|
+
// ends a path at any degree≠2 vertex — and a real rim grows one wherever its outline
|
|
250
|
+
// turns past sharpDeg, because that corner puts a sharp VERTICAL edge up the wall. The
|
|
251
|
+
// rim's halves then arrive as separate open planar chains whose swept tools would cross
|
|
252
|
+
// at the junction's own shallow angle — a near-parallel surface overlap that leaves
|
|
253
|
+
// degenerate seams (the same disease the whole-path promotion cures within one path).
|
|
254
|
+
// Stitched into one chain, the junction becomes an interior vertex: the sweep miters it
|
|
255
|
+
// when gentle, and planarTool's fold guard splits it (with a clean, wide-angle mitre
|
|
256
|
+
// crossing) when sharp. Chains stitch only when they share an endpoint, the same face
|
|
257
|
+
// plane, and the same convexity — the same-plane test is what keeps a top rim from ever
|
|
258
|
+
// stitching to a bottom rim.
|
|
259
|
+
function stitchPlanarChains(chains) {
|
|
260
|
+
const open = [], out = [];
|
|
261
|
+
for (const c of chains) (c.kind === "planar" && !c.closed ? open : out).push(c);
|
|
262
|
+
if (open.length < 2) return chains;
|
|
263
|
+
const key = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
264
|
+
const compatible = (a, b) => a.convex === b.convex && dot(a.faceN, b.faceN) > FLANK_COS &&
|
|
265
|
+
Math.abs(dot(a.points[0], a.faceN) - dot(b.points[0], a.faceN)) <= TOL;
|
|
266
|
+
const rev = (c) => ({ ...c, points: [...c.points].reverse(), wallNs: [...c.wallNs].reverse() });
|
|
267
|
+
let progress = true;
|
|
268
|
+
while (progress) {
|
|
269
|
+
progress = false;
|
|
270
|
+
outer: for (let i = 0; i < open.length; i++) {
|
|
271
|
+
let a = open[i];
|
|
272
|
+
// Orientations: b forward/reversed against a's END covers end-start and end-end;
|
|
273
|
+
// the ordered (j,i) pass covers start-end. START-START needs a itself reversed —
|
|
274
|
+
// without this clause two chains seeded outward from the same junction vertex
|
|
275
|
+
// (the edge walk picks its seeds by graph order, not geometry) never stitch, and
|
|
276
|
+
// their tools overshoot tangentially into each other at that junction.
|
|
277
|
+
if (open.some((b, j) => j !== i && compatible(a, b) &&
|
|
278
|
+
(key(b.points[0]) === key(a.points[0]) || key(b.points[b.points.length - 1]) === key(a.points[0]))) &&
|
|
279
|
+
!open.some((b, j) => j !== i && compatible(a, b) &&
|
|
280
|
+
(key(b.points[0]) === key(a.points[a.points.length - 1]) || key(b.points[b.points.length - 1]) === key(a.points[a.points.length - 1])))) {
|
|
281
|
+
a = rev(a);
|
|
282
|
+
}
|
|
283
|
+
const aEnd = key(a.points[a.points.length - 1]);
|
|
284
|
+
for (let j = 0; j < open.length; j++) {
|
|
285
|
+
if (i === j || !compatible(a, open[j])) continue;
|
|
286
|
+
let b = open[j];
|
|
287
|
+
if (key(b.points[b.points.length - 1]) === aEnd) b = rev(b);
|
|
288
|
+
if (key(b.points[0]) !== aEnd) continue;
|
|
289
|
+
const points = [...a.points, ...b.points.slice(1)];
|
|
290
|
+
const closed = key(points[0]) === key(points[points.length - 1]);
|
|
291
|
+
const joined = { ...a, points, wallNs: [...a.wallNs, ...b.wallNs], closed };
|
|
292
|
+
open.splice(Math.max(i, j), 1);
|
|
293
|
+
open.splice(Math.min(i, j), 1);
|
|
294
|
+
(closed ? out : open).push(joined);
|
|
295
|
+
progress = true;
|
|
296
|
+
break outer;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return [...out, ...open];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Whole-path planar rescue, called by chainEdges when any of a path's runs classified
|
|
304
|
+
// unsupported: rebuild the ENTIRE path (every member, in walk order) as one candidate
|
|
305
|
+
// planar chain. See the promotion comment at the call site for why the whole path — and
|
|
306
|
+
// tryPlanarChain below for what qualifies.
|
|
307
|
+
function buildPlanarPath(edges, path) {
|
|
308
|
+
const members = path.members.map((i) => edges[i]);
|
|
309
|
+
const points = [vertPos(members[0], path.verts[0])];
|
|
310
|
+
members.forEach((m, i) => points.push(vertPos(m, otherVid(m, path.verts[i]))));
|
|
311
|
+
return tryPlanarChain(members, points, members[0].convex, path.loop);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Rescue an unsupported path as a PLANAR chain: every point of the path lies in one plane,
|
|
315
|
+
// one flank IS that plane's face (a world-constant normal — the top of an extrusion, the
|
|
316
|
+
// plate around a boss), and the other flank — the wall — turns with the path at a constant
|
|
317
|
+
// dihedral. Top and bottom rims of extruded profiles whose outlines are neither straight
|
|
318
|
+
// nor circular (text, offset outlines, splines) are exactly this shape, and they used to
|
|
319
|
+
// be this module's most common NEEDS_OCCT reroute. The blend tool for a planar chain is a
|
|
320
|
+
// sweep of the same 2-D cross-section the prism and revolve tools use (planarTool below):
|
|
321
|
+
// in the sweep's transported frame both flanks have constant coordinates along the whole
|
|
322
|
+
// run — the same rotating-frame argument fitArcChain makes about surfaces of revolution —
|
|
323
|
+
// so one fixed profile blends the entire path.
|
|
324
|
+
//
|
|
325
|
+
// Flank pairing keys on the CANDIDATE face normal itself (each of member 0's two flanks
|
|
326
|
+
// in turn): every member contributes whichever of its flanks lies closer to the candidate.
|
|
327
|
+
// Neighbor-pairing — the trick classifyChain's line branch uses — is deliberately NOT
|
|
328
|
+
// reused here: over a long turning run the wall normal rotates far enough that it pairs
|
|
329
|
+
// against the face and scrambles both columns (measured on a 37-edge run of the wavy-rim
|
|
330
|
+
// fixture). Keying on the candidate is stable however far the wall turns, because the
|
|
331
|
+
// true face flank stays within FLANK_COS of it while the wall sits a whole dihedral away.
|
|
332
|
+
// A run where neither candidate yields a constant column (a helix, a saddle) returns null
|
|
333
|
+
// and stays unsupported — the rescue never guesses.
|
|
334
|
+
const FLANK_COS = 0.9986; // ~3°, the same constancy bar the line classifier uses
|
|
335
|
+
function tryPlanarChain(members, points, convex, closed) {
|
|
336
|
+
if (points.length < 3) return null; // a 2-point run is a line chain's job
|
|
337
|
+
for (const cand of [members[0].n1, members[0].n2]) {
|
|
338
|
+
const face = [], wall = [];
|
|
339
|
+
for (const m of members) {
|
|
340
|
+
const [f, wl] = dot(m.n1, cand) >= dot(m.n2, cand) ? [m.n1, m.n2] : [m.n2, m.n1];
|
|
341
|
+
face.push(f);
|
|
342
|
+
wall.push(wl);
|
|
199
343
|
}
|
|
344
|
+
const meanRaw = face.reduce((s, f) => add(s, f), [0, 0, 0]);
|
|
345
|
+
if (len(meanRaw) < 1e-9) continue;
|
|
346
|
+
const w = norm(meanRaw);
|
|
347
|
+
if (!face.every((f) => dot(f, w) > FLANK_COS)) continue; // not world-constant
|
|
348
|
+
const d0 = dot(points[0], w);
|
|
349
|
+
if (!points.every((p) => Math.abs(dot(p, w) - d0) <= TOL)) continue; // run not in the face plane
|
|
350
|
+
const dots = wall.map((n) => dot(n, w));
|
|
351
|
+
const meanDot = dots.reduce((s, x) => s + x, 0) / dots.length;
|
|
352
|
+
if (!dots.every((x) => Math.abs(x - meanDot) <= 0.05)) continue; // dihedral drifts (~3°)
|
|
353
|
+
return { kind: "planar", points, closed, convex, w, faceN: w, wallNs: wall };
|
|
200
354
|
}
|
|
201
|
-
return
|
|
355
|
+
return null;
|
|
202
356
|
}
|
|
203
357
|
|
|
204
358
|
function buildChain(edges, path, ks, type, closed) {
|
|
@@ -243,11 +397,39 @@ function fitArcChain(members, points, convex, closed) {
|
|
|
243
397
|
const alpha = (l2 * (l1 - c12)) / det, beta = (l1 * (l2 - c12)) / det;
|
|
244
398
|
const O = add(p0, add(scl(e1, alpha), scl(e2, beta)));
|
|
245
399
|
const R = len(sub(p0, O));
|
|
246
|
-
|
|
400
|
+
// The fit tolerance is ABSOLUTE and tight (2 µm) on purpose, sandwiched from both
|
|
401
|
+
// sides. Below: it must ACCEPT this module's own blend rims — profile2D's area-exact
|
|
402
|
+
// bump parks interior arc vertices up to r·θ²/12 ≈ 1.3 µm off the true circle (the
|
|
403
|
+
// sagitta bound caps θ so that ceiling is density-independent), and a true revolved
|
|
404
|
+
// rim's float32 quantization is far under that. Above: it must REJECT an offset
|
|
405
|
+
// outline that merely APPROXIMATES a circle after simplify() — those deviate by
|
|
406
|
+
// several microns, and the revolve tool follows the FITTED circle, so accepting one
|
|
407
|
+
// turns every real deviation into tangent-seam jitter along the whole run (measured:
|
|
408
|
+
// a label backing drew ~450 band-edge lines from two accepted pseudo-arcs). Rejected
|
|
409
|
+
// rims fall through to the planar-path rescue, whose sweep follows the true polyline
|
|
410
|
+
// exactly. The old max(1e-3, 1e-3·R) relative term is what let the pseudo-arcs in.
|
|
411
|
+
const rtol = 2e-3;
|
|
247
412
|
for (const p of points) {
|
|
248
413
|
if (Math.abs(len(sub(p, O)) - R) > rtol) return bad("edge curve is not circular");
|
|
249
414
|
if (Math.abs(dot(sub(p, O), w)) > rtol) return bad("edge curve is not planar");
|
|
250
415
|
}
|
|
416
|
+
// Chord-dip gate: the wall facets hang on these same points, so the deepest chord
|
|
417
|
+
// midpoint below the fitted circle measures how coarse the flank tessellation
|
|
418
|
+
// really is. The revolve tool is the right instrument only for kernel-quality
|
|
419
|
+
// surfaces of revolution — its tangent extension chases facets a few microns deep.
|
|
420
|
+
// A rim whose facets dip an order deeper (a polygonal prism, a coarse offset
|
|
421
|
+
// outline) must blend along its own polyline instead: the planar rescue's sweep
|
|
422
|
+
// makes station-exact contact per facet, where a revolve's round tail can only
|
|
423
|
+
// graze a deep flat facet (measured: 24-gon rim, 188 band lines as an arc, zero as
|
|
424
|
+
// a planar chain). Bound: 3× the dip a DEFAULT_SEGS-quality wall would have, plus
|
|
425
|
+
// the fit tolerance both sides of the chord ride on.
|
|
426
|
+
let dip = 0;
|
|
427
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
428
|
+
const q = sub(scl(add(points[i], points[i + 1]), 0.5), O);
|
|
429
|
+
dip = Math.max(dip, R - len(sub(q, scl(w, dot(q, w)))));
|
|
430
|
+
}
|
|
431
|
+
if (dip > 3 * R * (1 - Math.cos(Math.PI / DEFAULT_SEGS)) + 2 * rtol)
|
|
432
|
+
return bad("edge polyline is coarser than a kernel-quality surface of revolution");
|
|
251
433
|
// frame: azimuth 0 at the first point; flip w so azimuths increase along the run
|
|
252
434
|
const u0 = norm(sub(points[0], O));
|
|
253
435
|
let v0 = cross(w, u0);
|
|
@@ -399,17 +581,28 @@ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
|
|
|
399
581
|
// plane-on-plane, which the kernel resolves exactly.
|
|
400
582
|
const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
|
|
401
583
|
const nArc = Math.max(2, Math.ceil((span / (2 * Math.PI)) * segs));
|
|
584
|
+
// Area-exact tessellation: an inscribed chord polygon under-sweeps the ball arc by a
|
|
585
|
+
// first-order-in-facet-angle area deficit whose RELATIVE size is radius-independent
|
|
586
|
+
// (~0.23/n² of the blend cross-section) — at the sagitta-bounded density above it
|
|
587
|
+
// would bias every blend's volume by ~0.2-0.3%. Interior vertices sit at
|
|
588
|
+
// r·√(θ/sinθ), the radius at which the chord polygon sweeps exactly the arc's area
|
|
589
|
+
// (a micron-scale outward nudge that is material-safe in both boolean directions:
|
|
590
|
+
// a cutter bites a hair deeper mid-chord, a filler overlaps a hair more). The two
|
|
591
|
+
// END vertices stay exactly on the ball — they are the seam with the flanks.
|
|
592
|
+
const th = (span + 2 * ext) / nArc;
|
|
593
|
+
const rEq = r * Math.sqrt(th / Math.sin(th));
|
|
402
594
|
const pts = [corner];
|
|
403
595
|
for (let i = 0; i <= nArc; i++) {
|
|
404
596
|
const nv = rot2(n1, s2 * (-ext + ((span + 2 * ext) * i) / nArc));
|
|
405
|
-
|
|
597
|
+
const ri = i === 0 || i === nArc ? r : rEq;
|
|
598
|
+
pts.push([C[0] + sgn * ri * nv[0], C[1] + sgn * ri * nv[1]]);
|
|
406
599
|
}
|
|
407
600
|
return pts;
|
|
408
601
|
}
|
|
409
602
|
|
|
410
603
|
// ---------------------------------------------------------------------------
|
|
411
604
|
// Cutter/filler solids.
|
|
412
|
-
function prismTool(k, chain, magnitude, mode, segs) {
|
|
605
|
+
function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
413
606
|
const { a, dir: e, length, n1, n2, convex } = chain;
|
|
414
607
|
// pose rotation Z → e; the 2D basis is the image of X,Y under the SAME rotation
|
|
415
608
|
const axisRaw = cross([0, 0, 1], e);
|
|
@@ -420,10 +613,12 @@ function prismTool(k, chain, magnitude, mode, segs) {
|
|
|
420
613
|
const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
|
|
421
614
|
const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
|
|
422
615
|
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 });
|
|
616
|
+
const poly = profile2D({ P: [0, 0], n1: p2(n1), n2: p2(n2), magnitude, mode, convex, segs: pSegs });
|
|
424
617
|
// convex cutters overshoot the edge ends (sticking outside the solid is
|
|
425
|
-
// harmless when subtracting
|
|
426
|
-
//
|
|
618
|
+
// harmless when subtracting, and at a rounded corner the overshoot continues
|
|
619
|
+
// tangentially into the arc tool, like a stadium rim's prisms always have);
|
|
620
|
+
// concave fillers must end flush — any overshoot would bulge outside the part
|
|
621
|
+
// when unioned
|
|
427
622
|
const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
|
|
428
623
|
let tool = k.loft(
|
|
429
624
|
[{ polygon: poly, z: -over }, { polygon: poly, z: length + over }],
|
|
@@ -433,7 +628,11 @@ function prismTool(k, chain, magnitude, mode, segs) {
|
|
|
433
628
|
return tool.translate(a);
|
|
434
629
|
}
|
|
435
630
|
|
|
436
|
-
|
|
631
|
+
// `segs` is the KERNEL quality — it sizes the flank-facet guards (sag/ext) and the
|
|
632
|
+
// closed-revolve dephase, which are about matching the neighboring tessellation and
|
|
633
|
+
// must not follow the blend cap. `pSegs` is the sagitta-bounded density for the blend
|
|
634
|
+
// cross-section itself (blendSegs above).
|
|
635
|
+
function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
437
636
|
const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
|
|
438
637
|
// Seam-grazing guard. The edge circle passes through the flank tessellation's
|
|
439
638
|
// VERTICES (circumradius) while its facets sit at the apothem, so a revolved
|
|
@@ -442,14 +641,33 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
442
641
|
// cannot always collapse them. `sag` is that facet sagitta plus a roundoff pad
|
|
443
642
|
// bounded relative to the requested feature, so tiny blends never inherit a
|
|
444
643
|
// fixed allowance larger than their own cross-section.
|
|
445
|
-
|
|
644
|
+
//
|
|
645
|
+
// The kernel-density term is an ASSUMPTION about the flank, and it is wrong
|
|
646
|
+
// whenever the wall's facets hang on a polyline coarser than kernel quality —
|
|
647
|
+
// an offset outline, a polygonal prism — or when the rim rides the fit tolerance
|
|
648
|
+
// off the fitted circle. The wall facets hang on the chain's own points, so the
|
|
649
|
+
// real depth is measurable: the deepest chord midpoint below the fitted circle.
|
|
650
|
+
// Where the assumed extension fell short of that, the crossing failed mid-facet
|
|
651
|
+
// and a radial knife-fin of wall survived both cutters, drawing a line along the
|
|
652
|
+
// band (the label-backing bug). A synthetic corner arc measures nothing — its two
|
|
653
|
+
// points span the whole corner, and its flanks are planes, not a tessellation.
|
|
654
|
+
const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI / segs));
|
|
655
|
+
let dip = 0;
|
|
656
|
+
if (!chain.synthetic) {
|
|
657
|
+
const pts = chain.points;
|
|
658
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
659
|
+
const q = sub(scl(add(pts[i], pts[i + 1]), 0.5), O);
|
|
660
|
+
dip = Math.max(dip, R - len(sub(q, scl(w, dot(q, w)))));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const sag = Math.max(kernelSag, dip) + Math.min(2e-4, 0.02 * magnitude);
|
|
446
664
|
// Fillet: size the arc-tail extension to cross the facet planes, but cap it at
|
|
447
665
|
// 0.4 rad. Below the mesh's own facet scale a larger tail wraps around the tiny
|
|
448
666
|
// profile and creates one tunnel per facet; the cap bounds penetration to 8%
|
|
449
667
|
// of the requested radius while the cutter's outside corner still opens into
|
|
450
668
|
// free space.
|
|
451
669
|
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 });
|
|
670
|
+
let poly = profile2D({ P: [R, 0], n1, n2, magnitude, mode, convex, segs: pSegs, ext });
|
|
453
671
|
if (mode === "chamfer") {
|
|
454
672
|
// Chamfer: the cone itself is the cutting surface — no tail to extend, so
|
|
455
673
|
// bury the whole profile by `sag` along the material-side bisector instead.
|
|
@@ -472,7 +690,12 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
472
690
|
if (area < 0) poly = poly.slice().reverse();
|
|
473
691
|
const ovAng = closed || !convex ? 0 : Math.min(0.15, Math.max(1e-3, (0.05 * magnitude) / R));
|
|
474
692
|
const degrees = closed ? 360 : ((span + 2 * ovAng) * 180) / Math.PI;
|
|
475
|
-
|
|
693
|
+
// A real edge-circle arc keeps the kernel's angular density — its facets interact
|
|
694
|
+
// with the flank's own tessellation of the same circle (the dephase note below).
|
|
695
|
+
// A SYNTHETIC corner arc (cornerArcAt) is free-standing between planes, so its
|
|
696
|
+
// angular density follows the same sagitta bound as the cross-section.
|
|
697
|
+
const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) : segs;
|
|
698
|
+
let tool = k.revolve(poly, { degrees, segs: aSegs });
|
|
476
699
|
// pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
|
|
477
700
|
// chain's start direction (backed off by the angular overshoot)
|
|
478
701
|
const startDir = closed ? u0 : add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
|
|
@@ -494,6 +717,413 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
494
717
|
return tool.translate(O);
|
|
495
718
|
}
|
|
496
719
|
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// Planar-chain blend tool: sweep the shared 2-D cross-section (profile2D) along the
|
|
722
|
+
// chain's own polyline with k.sweep — a prism IS the one-segment case of this sweep,
|
|
723
|
+
// generalized to a path that turns. In the sweep's transported frame the face and wall
|
|
724
|
+
// flanks keep constant coordinates along a planar constant-dihedral path, so ONE profile
|
|
725
|
+
// polygon serves every station; sweepSeedFrame gives the exact frame the sweep will seed,
|
|
726
|
+
// so the profile is authored in it rather than re-deriving (and drifting from) the pick.
|
|
727
|
+
//
|
|
728
|
+
// The sweep miters gently-turning joints on its own. A vertex whose miter would fold —
|
|
729
|
+
// a sharp corner, a reversal cusp, a segment shorter than the profile's reach — SPLITS
|
|
730
|
+
// the chain there instead, and each open stretch overshoots its ends the way prism
|
|
731
|
+
// cutters do, so adjacent stretches mitre into each other across the split. Concave
|
|
732
|
+
// fillers stay flush at their ends — overshoot would bulge outside the part when unioned
|
|
733
|
+
// (prismTool's own rule) — which can leave a hairline notch in a bead at a split; that is
|
|
734
|
+
// the mitred-junction limit from the module header, not a leak (the boolean stays
|
|
735
|
+
// watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
|
|
736
|
+
// converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
|
|
737
|
+
// the build. Returns an ARRAY of tools — one per stretch.
|
|
738
|
+
function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
739
|
+
const { points, closed, convex, faceN, wallNs } = chain;
|
|
740
|
+
const pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
|
|
741
|
+
const m = pts.length;
|
|
742
|
+
const at = (i) => pts[((i % m) + m) % m];
|
|
743
|
+
const nSeg = closed ? m : m - 1;
|
|
744
|
+
const segDir = [], segLen = [];
|
|
745
|
+
for (let i = 0; i < nSeg; i++) {
|
|
746
|
+
const d = sub(at(i + 1), at(i)), l = len(d);
|
|
747
|
+
segDir.push(scl(d, 1 / (l || 1)));
|
|
748
|
+
segLen.push(l);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Fold guard, mirrored from resolveSweepStations' miter check with a stricter factor
|
|
752
|
+
// (0.45 vs 0.5) so the split fires before the sweep would throw. `reach` is a cheap
|
|
753
|
+
// rigid upper bound on the profile's half-width — exact reach needs the profile, the
|
|
754
|
+
// profile needs the stretch, and conservatism here only costs an extra mitred split.
|
|
755
|
+
// Split at a vertex whose miter would fold (fold guard, stricter 0.45 factor so the
|
|
756
|
+
// split fires before the sweep would throw) — and also at any stitched-junction
|
|
757
|
+
// corner sharper than SMOOTH_MAX_DEG, whose miter crease would otherwise exceed the
|
|
758
|
+
// viewer's line threshold and draw across the band. A salient split corner with room
|
|
759
|
+
// for the setback gets a corner ARC (cornerArcAt — the same rounded-corner treatment
|
|
760
|
+
// apply() gives two-chain junctions), the adjoining stretches trimmed to its tangent
|
|
761
|
+
// points; reflex or too-tight splits keep the overshoot mitre.
|
|
762
|
+
const reach = magnitude * 1.5;
|
|
763
|
+
const breaks = [];
|
|
764
|
+
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
|
|
765
|
+
const iIn = (i - 1 + nSeg) % nSeg;
|
|
766
|
+
const c = clamp1(dot(segDir[iIn], segDir[i]));
|
|
767
|
+
const turn = Math.acos(c);
|
|
768
|
+
const fold = c < -1 + 1e-6 || reach * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
|
|
769
|
+
const sharp = turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
|
|
770
|
+
if (fold || sharp) breaks.push(i);
|
|
771
|
+
}
|
|
772
|
+
// Corner arcs per break vertex, with each side's setback budget measured along the
|
|
773
|
+
// polyline to the ADJACENT break (or chain end) — a single tessellation segment says
|
|
774
|
+
// nothing about the room a whole smooth stretch offers.
|
|
775
|
+
const cornerArcs = new Map(); // break vertex index → { arc, t }
|
|
776
|
+
if (convex && breaks.length) {
|
|
777
|
+
const segSum = (from, to) => {
|
|
778
|
+
let sum = 0;
|
|
779
|
+
for (let i = from; i < to; i++) sum += segLen[((i % nSeg) + nSeg) % nSeg];
|
|
780
|
+
return sum;
|
|
781
|
+
};
|
|
782
|
+
for (let j = 0; j < breaks.length; j++) {
|
|
783
|
+
const i = breaks[j], iIn = (i - 1 + nSeg) % nSeg;
|
|
784
|
+
const prevB = closed
|
|
785
|
+
? breaks[(j - 1 + breaks.length) % breaks.length] - (j === 0 ? m : 0)
|
|
786
|
+
: (j > 0 ? breaks[j - 1] : 0);
|
|
787
|
+
const nextB = closed
|
|
788
|
+
? breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)
|
|
789
|
+
: (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
|
|
790
|
+
const got = cornerArcAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i],
|
|
791
|
+
wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude);
|
|
792
|
+
if (got) cornerArcs.set(i, got);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
|
|
797
|
+
const overshoot = (path) => {
|
|
798
|
+
if (!(over > 0) || path.length < 2) return path;
|
|
799
|
+
const a = path[0], b = path[1], y = path[path.length - 1], x = path[path.length - 2];
|
|
800
|
+
return [add(a, scl(norm(sub(a, b)), over)), ...path, add(y, scl(norm(sub(y, x)), over))];
|
|
801
|
+
};
|
|
802
|
+
// pull a stretch endpoint back along the polyline by t, toward a corner arc's
|
|
803
|
+
// tangent point — consuming whole segments where the setback spans several
|
|
804
|
+
const pullBack = (path, t, fromEnd) => {
|
|
805
|
+
if (!(t > 0) || path.length < 2) return path;
|
|
806
|
+
let p = fromEnd ? path.slice().reverse() : path.slice();
|
|
807
|
+
let rem = t;
|
|
808
|
+
while (rem > 1e-12 && p.length >= 2) {
|
|
809
|
+
const seg = sub(p[1], p[0]), l = len(seg);
|
|
810
|
+
if (l > rem + 1e-9) { p[0] = add(p[0], scl(seg, rem / l)); break; }
|
|
811
|
+
rem -= l;
|
|
812
|
+
p.shift();
|
|
813
|
+
}
|
|
814
|
+
return fromEnd ? p.reverse() : p;
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// One tool per stretch. The profile's wall normal is the SEED member's — the segment
|
|
818
|
+
// whose tangent the sweep frame is seeded ⟂ to: the closing segment for a closed loop,
|
|
819
|
+
// the first segment for an open stretch (overshoot extends along that same tangent, so
|
|
820
|
+
// it never changes the seed).
|
|
821
|
+
// ext stays 0 for every planar sweep, cutters and fillers alike — measured both ways
|
|
822
|
+
// on the fixtures. Stations sit ON the path vertices, so the profile's tangent lines
|
|
823
|
+
// ride the flank facets exactly: plane-on-plane contact the kernel resolves cleanly.
|
|
824
|
+
// An arc-tail extension (revolveTool's recipe for curved-vs-curved phase noise) turns
|
|
825
|
+
// that exact contact into a ~2° grazing CROSSING — and a grazing crossing's float
|
|
826
|
+
// wiggle carves sliver seams whether the tool is subtracted or unioned, because the
|
|
827
|
+
// crossing curve is exactly where the boolean's boundary hands over, always exposed.
|
|
828
|
+
const toolFor = (path3D, isClosed, wallN) => {
|
|
829
|
+
const { N, B } = sweepSeedFrame(path3D, isClosed);
|
|
830
|
+
const p2 = (v) => {
|
|
831
|
+
const q = [dot(v, N), dot(v, B)], l = Math.hypot(q[0], q[1]) || 1;
|
|
832
|
+
return [q[0] / l, q[1] / l];
|
|
833
|
+
};
|
|
834
|
+
const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs: pSegs });
|
|
835
|
+
return k.sweep(poly, path3D, { closed: isClosed });
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
if (closed && breaks.length === 0) {
|
|
840
|
+
return [toolFor(pts.map((p) => [p[0], p[1], p[2]]), true, wallNs[nSeg - 1])];
|
|
841
|
+
}
|
|
842
|
+
// Open stretches between breaks. An open chain's endpoints are implicit breaks; a
|
|
843
|
+
// closed chain's stretches wrap from each break to the next.
|
|
844
|
+
const bounds = closed
|
|
845
|
+
? breaks.map((b, j) => [b, breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)])
|
|
846
|
+
: (breaks.length ? [[0, breaks[0]], ...breaks.map((b, j) => [b, j + 1 < breaks.length ? breaks[j + 1] : m - 1])] : [[0, m - 1]]);
|
|
847
|
+
const tools = [];
|
|
848
|
+
const arcAt = (i) => cornerArcs.get(((i % m) + m) % m);
|
|
849
|
+
for (const [s, e] of bounds) {
|
|
850
|
+
if (e <= s) continue;
|
|
851
|
+
let path = [];
|
|
852
|
+
for (let i = s; i <= e; i++) path.push(at(i));
|
|
853
|
+
const aS = arcAt(s), aE = arcAt(e);
|
|
854
|
+
if (aS) path = pullBack(path, aS.t, false);
|
|
855
|
+
if (aE) path = pullBack(path, aE.t, true);
|
|
856
|
+
tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
|
|
857
|
+
}
|
|
858
|
+
for (const got of cornerArcs.values()) {
|
|
859
|
+
tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
|
|
860
|
+
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
861
|
+
tools.push(cornerHornTool(k, got, magnitude, segs));
|
|
862
|
+
}
|
|
863
|
+
return tools;
|
|
864
|
+
} catch (e) {
|
|
865
|
+
throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
// Rounded corners. Where exactly TWO selected convex chains meet at a salient corner
|
|
871
|
+
// in a common face plane (a letter corner, a polygon corner on a rim), the blend used
|
|
872
|
+
// to continue straight through from both sides and the two tools crossed in a mitre.
|
|
873
|
+
// That groove is a REAL crease — 76-90° dihedral, measured — so the feature-line
|
|
874
|
+
// overlay faithfully drew a polyline ACROSS the blend band at every such corner, and
|
|
875
|
+
// OCCT's native fillet produces the same intersection-and-trim crease. There is no
|
|
876
|
+
// groove-free construction that keeps the silhouette sharp: the two straight blends
|
|
877
|
+
// must either intersect or the blend must steer around the corner. This steers: the
|
|
878
|
+
// corner is replaced by a small circular ARC chain (radius ~1.05-1.25× the blend
|
|
879
|
+
// magnitude, tangent to both neighbors at a setback), the neighbors are trimmed to the
|
|
880
|
+
// tangent points, and the existing revolveTool sweeps the arc — exactly the shape a
|
|
881
|
+
// rounded-rectangle rim already has, which renders line-free today. The cost, stated
|
|
882
|
+
// plainly: within the band the silhouette rounds by about the blend radius at corners
|
|
883
|
+
// sharper than CORNER_ROUND_MIN_TURN; gentler corners keep their exact silhouette (a
|
|
884
|
+
// mitre under the viewer\'s 35° line threshold draws nothing).
|
|
885
|
+
//
|
|
886
|
+
// REFLEX corners keep the mitre: the ball genuinely cannot reach into the corner, the
|
|
887
|
+
// crease there is real geometry, and rounding the path would ADD material. A corner
|
|
888
|
+
// whose neighbors are too short to host the setback (tight glyph features) falls back
|
|
889
|
+
// to the mitre too — the fallback is today\'s behavior, never a failure.
|
|
890
|
+
// Corners gentler than this keep their mitre: the two blends there differ by less than
|
|
891
|
+
// the mitre turn everywhere, far under the viewer's 35° line threshold, and the shallow
|
|
892
|
+
// overlap sliver stays too flat for simplify() to fold into visible creases. Measured:
|
|
893
|
+
// a 20.7° mitre still drew (its long shallow overlap wedge triangulates into >35°
|
|
894
|
+
// junk), an ~8° one does not. The silhouette cost of rounding a gentle corner is a
|
|
895
|
+
// sagitta of ρ·(1−cos(turn/2)) — sub-micron at these angles — so the gate is safe low.
|
|
896
|
+
const CORNER_ROUND_MIN_TURN = (8 * Math.PI) / 180;
|
|
897
|
+
const RHO_MIN = 1.05; // × magnitude — revolve floor: the profile reaches magnitude inward of the arc
|
|
898
|
+
const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over the floor for margin
|
|
899
|
+
|
|
900
|
+
// Corner-arc descriptor at one vertex. tin1/tin2 point from the vertex INTO each side;
|
|
901
|
+
// wall1/wall2 are the sides\' outward wall normals at the vertex; len1/len2 bound the
|
|
902
|
+
// setback. Returns { arc, t } (a synthetic kind:"arc" chain for revolveTool, plus the
|
|
903
|
+
// setback to trim each side by) or null when the corner keeps its mitre.
|
|
904
|
+
function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude) {
|
|
905
|
+
const tIn = scl(tin1, -1), tOut = tin2; // travel: arrive along side 1, depart into 2
|
|
906
|
+
const turn = Math.acos(clamp1(dot(tIn, tOut)));
|
|
907
|
+
if (turn < CORNER_ROUND_MIN_TURN) return null;
|
|
908
|
+
const turnS = dot(cross(tIn, tOut), f);
|
|
909
|
+
const matLeft = dot(wall1, cross(tIn, f)) > 0;
|
|
910
|
+
if ((turnS > 0) !== matLeft) return null; // reflex: the crease is real — keep the mitre
|
|
911
|
+
const tanH = Math.tan(turn / 2);
|
|
912
|
+
if (!(tanH > 1e-6) || !Number.isFinite(tanH)) return null;
|
|
913
|
+
const t = Math.min(RHO_PREF * magnitude * tanH, 0.45 * len1, 0.45 * len2);
|
|
914
|
+
const rho = t / tanH;
|
|
915
|
+
if (rho < RHO_MIN * magnitude) return null; // no room: mitre fallback
|
|
916
|
+
// inward bisector from the walls; O sits at distance rho from both edge lines
|
|
917
|
+
const proj = (wl) => { const p = sub(scl(wl, -1), scl(f, -dot(wl, f))); const l = len(p) || 1; return scl(p, 1 / l); };
|
|
918
|
+
const uA = proj(wall1), uB = proj(wall2);
|
|
919
|
+
const bisRaw = add(uA, uB);
|
|
920
|
+
if (len(bisRaw) < 1e-9) return null;
|
|
921
|
+
const O = add(vertex, scl(norm(bisRaw), rho / Math.cos(turn / 2)));
|
|
922
|
+
const pA = add(vertex, scl(tin1, t)), pB = add(vertex, scl(tin2, t));
|
|
923
|
+
const u0raw = sub(pA, O), uEraw = sub(pB, O);
|
|
924
|
+
const u0 = norm(u0raw), uE = norm(uEraw);
|
|
925
|
+
const span = Math.acos(clamp1(dot(u0, uE)));
|
|
926
|
+
if (!(span > 1e-4)) return null;
|
|
927
|
+
const s = dot(cross(u0, uE), f) >= 0 ? 1 : -1; // orient w so azimuth increases pA → pB
|
|
928
|
+
const w = scl(f, s);
|
|
929
|
+
// rotating-frame flanks, fitArcChain\'s convention ([ρ-component, w-component]):
|
|
930
|
+
// the face flank is pure ±w, the wall is pure outward radial
|
|
931
|
+
return {
|
|
932
|
+
t,
|
|
933
|
+
vertex,
|
|
934
|
+
f,
|
|
935
|
+
arc: { kind: "arc", points: [pA, pB], O, w, u0, v0: cross(w, u0), R: rho, span,
|
|
936
|
+
closed: false, n1: [0, s], n2: [1, 0], convex: true, synthetic: true },
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// The horn cutter that completes a rounded corner. The arc tool blends the band around
|
|
941
|
+
// the corner's arc cylinder, but the SOLID still has its sharp corner: the column of
|
|
942
|
+
// material between that cylinder and the original vertex would poke up through the band
|
|
943
|
+
// untouched. This block removes it — footprint bounded by the two walls and an arc-side
|
|
944
|
+
// polyline held strictly INSIDE the arc tool's own cut region — from just above the
|
|
945
|
+
// face down to exactly band depth. What remains below is a small flat shelf at the
|
|
946
|
+
// corner base; its rim is a boundary line BELOW the band, the deliberate trade for a
|
|
947
|
+
// band with no lines across it.
|
|
948
|
+
//
|
|
949
|
+
// The arc-side vertices sit at the arc TOOL's guaranteed apothem, R·cos(π/aSegs), less
|
|
950
|
+
// a micron margin — not on the circle itself. Vertices on the circle only bow inside
|
|
951
|
+
// the SMOOTH cylinder; the tool is a polygonal revolve whose facets sit at ITS apothem,
|
|
952
|
+
// and whenever the horn's chords landed shallower than a tool facet (a short-span arc
|
|
953
|
+
// at reduced angular density), the wall between them survived both cutters as a lens
|
|
954
|
+
// filament — an island or a handle, decided by facet phase (measured: the arrow's
|
|
955
|
+
// 20.7° corner flipped genus at some densities and not others). The apothem bound makes
|
|
956
|
+
// containment a proof instead of a phase lottery: tool pitch ≤ 2π/aSegs by definition
|
|
957
|
+
// of its step count, so its apothem ≥ R·cos(π/aSegs) > every horn vertex radius. The
|
|
958
|
+
// cost is a micron-deep extra bite at the corner base, covered near the tangent lines
|
|
959
|
+
// by the neighbors' own overshoot.
|
|
960
|
+
function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
|
|
961
|
+
const { O, w, u0, R, span } = arc;
|
|
962
|
+
const delta = 0.02 * magnitude;
|
|
963
|
+
const rH = R * Math.cos(Math.PI / cornerArcSegs(segs, R, magnitude)) - Math.min(1e-3, 0.02 * magnitude);
|
|
964
|
+
// Pose and depth run along the FACE normal f (material below the face), never the
|
|
965
|
+
// arc's w — w flips sign with the arc's travel direction, and a block lofted along a
|
|
966
|
+
// downward w would stand above the face and cut the top instead of the horn.
|
|
967
|
+
const aRaw = cross([0, 0, 1], f);
|
|
968
|
+
const s = len(aRaw);
|
|
969
|
+
let axis = null, theta = 0;
|
|
970
|
+
if (s > 1e-9) { axis = scl(aRaw, 1 / s); theta = Math.atan2(s, f[2]); }
|
|
971
|
+
else if (f[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
|
|
972
|
+
const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
|
|
973
|
+
const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
|
|
974
|
+
const p2 = (p) => { const q = sub(p, O); return [dot(q, u), dot(q, v)]; };
|
|
975
|
+
const poly = [];
|
|
976
|
+
poly.push(p2(add(vertex, scl(norm(sub(vertex, O)), delta)))); // vertex, nudged outward
|
|
977
|
+
poly.push(p2(add(O, scl(u0, R + delta)))); // tangent A, nudged past its wall
|
|
978
|
+
const steps = 8;
|
|
979
|
+
for (let i = 0; i <= steps; i++) poly.push(p2(add(O, scl(rotVec(u0, w, (span * i) / steps), rH))));
|
|
980
|
+
poly.push(p2(add(O, scl(rotVec(u0, w, span), R + delta)))); // tangent B, nudged past its wall
|
|
981
|
+
let area = 0;
|
|
982
|
+
for (let i = 0; i < poly.length; i++) {
|
|
983
|
+
const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
|
|
984
|
+
area += x1 * y2 - x2 * y1;
|
|
985
|
+
}
|
|
986
|
+
const ring = area < 0 ? poly.slice().reverse() : poly;
|
|
987
|
+
let tool = k.loft([{ polygon: ring, z: -magnitude }, { polygon: ring, z: delta }], { shading: "smooth" });
|
|
988
|
+
if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
|
|
989
|
+
return tool.translate(O);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function chainEndInfo(ch, end) {
|
|
993
|
+
if (ch.kind === "line") {
|
|
994
|
+
return end === "start"
|
|
995
|
+
? { v: ch.a, tin: ch.dir, flanks: [ch.n1, ch.n2], len: ch.length }
|
|
996
|
+
: { v: ch.b, tin: scl(ch.dir, -1), flanks: [ch.n1, ch.n2], len: ch.length };
|
|
997
|
+
}
|
|
998
|
+
const pts = ch.points, m = pts.length;
|
|
999
|
+
let plen = 0;
|
|
1000
|
+
for (let i = 0; i + 1 < m; i++) plen += len(sub(pts[i + 1], pts[i]));
|
|
1001
|
+
return end === "start"
|
|
1002
|
+
? { v: pts[0], tin: norm(sub(pts[1], pts[0])), flanks: [ch.faceN, ch.wallNs[0]], len: plen }
|
|
1003
|
+
: { v: pts[m - 1], tin: norm(sub(pts[m - 2], pts[m - 1])), flanks: [ch.faceN, ch.wallNs[ch.wallNs.length - 1]], len: plen };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Corner arc for two chain ends meeting at one vertex, or null (no common face plane,
|
|
1007
|
+
// gentle turn, reflex corner, or no room for the setback).
|
|
1008
|
+
function cornerArcBetween(E1, E2, magnitude) {
|
|
1009
|
+
let f = null, wall1 = null, wall2 = null;
|
|
1010
|
+
for (const c1 of E1.flanks) {
|
|
1011
|
+
for (const c2 of E2.flanks) {
|
|
1012
|
+
if (dot(c1, c2) <= FLANK_COS) continue;
|
|
1013
|
+
// the shared face is ⟂ BOTH tangents; each wall is ⟂ only its own chain\'s
|
|
1014
|
+
if (Math.abs(dot(c1, E1.tin)) > 0.05 || Math.abs(dot(c1, E2.tin)) > 0.05) continue;
|
|
1015
|
+
f = norm(add(c1, c2));
|
|
1016
|
+
wall1 = E1.flanks[0] === c1 ? E1.flanks[1] : E1.flanks[0];
|
|
1017
|
+
wall2 = E2.flanks[0] === c2 ? E2.flanks[1] : E2.flanks[0];
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (!f) return null;
|
|
1021
|
+
return cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Convert a FACE-PLANE arc chain to the equivalent planar chain, or return null when
|
|
1025
|
+
// the arc has no world-constant flank (a rim on a curved face — revolveTool's
|
|
1026
|
+
// irreplaceable case). In-plane rims blend by sweeping their own polyline instead of
|
|
1027
|
+
// revolving a fitted circle, for two reasons measured on a label backing. The sweep's
|
|
1028
|
+
// stations sit ON the rim vertices, so its flank contact is plane-exact per wall facet,
|
|
1029
|
+
// where the revolve's contact is a three-way micron contest (its own angular chords,
|
|
1030
|
+
// the wall's facets, and the circle fit's offset) that strands radial knife-fins along
|
|
1031
|
+
// the band whenever the margins interfere. And a planar chain STITCHES to its planar
|
|
1032
|
+
// neighbors, so the arc↔planar junction — two tools overshooting tangentially into
|
|
1033
|
+
// each other, which roundSalientCorners never handled because it skips arc chains —
|
|
1034
|
+
// stops existing as a category. Selection still runs on the ARC form (near-selectors
|
|
1035
|
+
// match the fitted circle, not its chords); conversion happens after, in apply().
|
|
1036
|
+
function planarizeArc(ch) {
|
|
1037
|
+
if (ch.kind !== "arc") return null;
|
|
1038
|
+
// face flank = the rotating-frame flank that is axial (±w, world-constant); ~3° bar
|
|
1039
|
+
const pick = Math.abs(ch.n1[0]) <= 0.05 ? 0 : Math.abs(ch.n2[0]) <= 0.05 ? 1 : -1;
|
|
1040
|
+
if (pick === -1) return null;
|
|
1041
|
+
const [face, wall] = pick === 0 ? [ch.n1, ch.n2] : [ch.n2, ch.n1];
|
|
1042
|
+
const faceN = scl(ch.w, Math.sign(face[1]));
|
|
1043
|
+
const pts = ch.points;
|
|
1044
|
+
const wallNs = [];
|
|
1045
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1046
|
+
const q = sub(scl(add(pts[i], pts[i + 1]), 0.5), ch.O);
|
|
1047
|
+
const rho = norm(sub(q, scl(ch.w, dot(q, ch.w))));
|
|
1048
|
+
wallNs.push(norm(add(scl(rho, wall[0]), scl(ch.w, wall[1]))));
|
|
1049
|
+
}
|
|
1050
|
+
return { kind: "planar", points: pts.map((p) => [p[0], p[1], p[2]]), closed: ch.closed,
|
|
1051
|
+
convex: ch.convex, w: faceN, faceN, wallNs };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Trim a chain back by tStart/tEnd (0 = untouched) toward the corner arcs that replace
|
|
1055
|
+
// its mitred ends. Line chains shift their endpoints; planar chains walk the polyline in
|
|
1056
|
+
// from each end, dropping consumed vertices (and their members\' wall normals) and
|
|
1057
|
+
// planting the new endpoint mid-segment. Returns the trimmed copy, or null when nothing
|
|
1058
|
+
// usable remains (guarded against by cornerArcAt\'s 0.45·length setback cap).
|
|
1059
|
+
function trimChain(ch, tStart, tEnd) {
|
|
1060
|
+
if (!(tStart > 0) && !(tEnd > 0)) return ch;
|
|
1061
|
+
if (ch.kind === "line") {
|
|
1062
|
+
const length = ch.length - tStart - tEnd;
|
|
1063
|
+
if (!(length > 1e-9)) return null;
|
|
1064
|
+
return { ...ch, a: add(ch.a, scl(ch.dir, tStart)), b: sub(ch.b, scl(ch.dir, tEnd)), length };
|
|
1065
|
+
}
|
|
1066
|
+
let pts = ch.points.map((p) => [p[0], p[1], p[2]]);
|
|
1067
|
+
let walls = ch.wallNs.slice();
|
|
1068
|
+
const eat = (t) => { // consume t from the FRONT of pts/walls
|
|
1069
|
+
while (t > 1e-12 && pts.length >= 2) {
|
|
1070
|
+
const seg = sub(pts[1], pts[0]), l = len(seg);
|
|
1071
|
+
if (l > t + 1e-12) { pts[0] = add(pts[0], scl(seg, t / l)); return true; }
|
|
1072
|
+
t -= l;
|
|
1073
|
+
pts.shift();
|
|
1074
|
+
walls.shift();
|
|
1075
|
+
}
|
|
1076
|
+
return pts.length >= 2;
|
|
1077
|
+
};
|
|
1078
|
+
const flip = () => { pts.reverse(); walls.reverse(); };
|
|
1079
|
+
if (tStart > 0 && !eat(tStart)) return null;
|
|
1080
|
+
if (tEnd > 0) { flip(); if (!eat(tEnd)) return null; flip(); }
|
|
1081
|
+
if (pts.length < 2) return null;
|
|
1082
|
+
return { ...ch, points: pts, wallNs: walls, closed: false };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Round the salient two-chain corners of a selection: returns the effective chain list
|
|
1086
|
+
// (trimmed neighbors substituted in place) plus the synthetic corner-arc chains.
|
|
1087
|
+
function roundSalientCorners(selected, magnitude) {
|
|
1088
|
+
const keyOf = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
1089
|
+
const ends = new Map();
|
|
1090
|
+
for (const ch of selected) {
|
|
1091
|
+
if (ch.closed || ch.convex !== true) continue;
|
|
1092
|
+
if (ch.kind !== "line" && ch.kind !== "planar") continue;
|
|
1093
|
+
for (const end of ["start", "end"]) {
|
|
1094
|
+
const info = chainEndInfo(ch, end);
|
|
1095
|
+
const kk = keyOf(info.v);
|
|
1096
|
+
(ends.get(kk) ?? ends.set(kk, []).get(kk)).push({ ch, end, info });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const arcs = [], horns = [], trims = new Map();
|
|
1100
|
+
const addTrim = (ch, end, t) => {
|
|
1101
|
+
const cur = trims.get(ch) ?? { start: 0, end: 0 };
|
|
1102
|
+
cur[end] = t;
|
|
1103
|
+
trims.set(ch, cur);
|
|
1104
|
+
};
|
|
1105
|
+
for (const list of ends.values()) {
|
|
1106
|
+
if (list.length !== 2 || (list[0].ch === list[1].ch && list[0].end === list[1].end)) continue;
|
|
1107
|
+
const got = cornerArcBetween(list[0].info, list[1].info, magnitude);
|
|
1108
|
+
if (!got) continue;
|
|
1109
|
+
arcs.push(got.arc);
|
|
1110
|
+
// a gentle corner's horn is a sliver — depth ρ·(1/cos(turn/2) − 1), microns at
|
|
1111
|
+
// small turns — not worth a cutter (and thin cutters are their own noise source)
|
|
1112
|
+
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
1113
|
+
horns.push({ vertex: got.vertex, f: got.f, arc: got.arc });
|
|
1114
|
+
addTrim(list[0].ch, list[0].end, got.t);
|
|
1115
|
+
addTrim(list[1].ch, list[1].end, got.t);
|
|
1116
|
+
}
|
|
1117
|
+
if (!arcs.length) return { chains: selected, arcs, horns };
|
|
1118
|
+
const chains = [];
|
|
1119
|
+
for (const ch of selected) {
|
|
1120
|
+
const tr = trims.get(ch);
|
|
1121
|
+
const eff = tr ? trimChain(ch, tr.start, tr.end) : ch;
|
|
1122
|
+
if (eff) chains.push(eff);
|
|
1123
|
+
}
|
|
1124
|
+
return { chains, arcs, horns };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
497
1127
|
// ---------------------------------------------------------------------------
|
|
498
1128
|
// Spherical corner patches. Where exactly three selected straight convex chains
|
|
499
1129
|
// meet at a vertex with mutually orthogonal directions (a box-like corner), the
|
|
@@ -565,10 +1195,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
565
1195
|
if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
|
|
566
1196
|
const unsupported = selected.find((ch) => ch.kind === "unsupported");
|
|
567
1197
|
if (unsupported) throw new UnsupportedEdgeError(`${mode}: ${unsupported.reason}`);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
1198
|
+
// Face-plane arc rims sweep their own polyline (see planarizeArc); re-stitch so a
|
|
1199
|
+
// converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
|
|
1200
|
+
// these chains were planar, so their junctions are still open here.
|
|
1201
|
+
const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
|
|
1202
|
+
const { chains: effective, arcs, horns } = roundSalientCorners(planarized, magnitude);
|
|
1203
|
+
const pSegs = blendSegs(segs, magnitude);
|
|
1204
|
+
const toolsFor = (ch) =>
|
|
1205
|
+
ch.kind === "planar"
|
|
1206
|
+
? planarTool(k, ch, magnitude, mode, segs, pSegs)
|
|
1207
|
+
: [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
|
|
1208
|
+
const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
|
|
1209
|
+
cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
|
|
1210
|
+
const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
|
|
1211
|
+
if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
|
|
572
1212
|
let out = solid;
|
|
573
1213
|
if (cutters.length) out = out.cutAll(cutters);
|
|
574
1214
|
if (fillers.length) out = k.union([out, ...fillers]);
|
|
@@ -43,6 +43,20 @@ const rodrigues = (v, k, ang) => {
|
|
|
43
43
|
const placeRing = (profile2D, center, N, B) =>
|
|
44
44
|
profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
|
|
45
45
|
|
|
46
|
+
// The seed frame a sweep of `path3D` will start from — exported so a caller authoring a
|
|
47
|
+
// profile FOR a sweep (mesh-fillet's planar-chain tool) can express it in exactly the
|
|
48
|
+
// frame the sweep will use, instead of replicating the reference-vector pick and drifting
|
|
49
|
+
// when it changes. Matches resolveSweepStations: the frame is ⟂ the tangent coming INTO
|
|
50
|
+
// the first processed station (the closing segment for a closed loop, the first segment
|
|
51
|
+
// for an open path).
|
|
52
|
+
export function sweepSeedFrame(path3D, closed = false) {
|
|
53
|
+
const m = path3D.length;
|
|
54
|
+
const T = closed ? norm(sub(path3D[0], path3D[m - 1])) : norm(sub(path3D[1], path3D[0]));
|
|
55
|
+
const ref = Math.abs(dot(T, Z)) < 0.9 ? Z : X;
|
|
56
|
+
const N = norm(sub(ref, scl(T, dot(ref, T))));
|
|
57
|
+
return { T, N, B: cross(T, N) };
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
export function resolveSweepStations(profile2D, path3D, { closed = false, cornerRadius = 0 } = {}) {
|
|
47
61
|
if (!Array.isArray(profile2D) || profile2D.length < 3)
|
|
48
62
|
throw new Error("sweep: profile2D must be an array of ≥3 [x,y] points");
|
|
@@ -66,11 +80,9 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
|
|
|
66
80
|
for (const [x, y] of profile2D) maxReach = Math.max(maxReach, Math.hypot(x, y));
|
|
67
81
|
|
|
68
82
|
// Seed the frame ⟂ the tangent coming INTO the first processed station (reference-vector
|
|
69
|
-
// method; the ref pick avoids N collapsing when the path starts along Z).
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let N = norm(sub(ref, scl(seedT, dot(ref, seedT))));
|
|
73
|
-
let B = cross(seedT, N);
|
|
83
|
+
// method; the ref pick avoids N collapsing when the path starts along Z). Shared with
|
|
84
|
+
// sweepSeedFrame above so profile authors can target the exact same frame.
|
|
85
|
+
let { N, B } = sweepSeedFrame(P, closed);
|
|
74
86
|
|
|
75
87
|
const stations = [];
|
|
76
88
|
|