partforge 0.64.1 → 0.65.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/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
|
@@ -10,17 +10,22 @@
|
|
|
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
19
|
// Known limits (documented, not bugs): no spherical corner patches yet — two
|
|
17
20
|
// chains meeting at a vertex leave a mitred junction where their blend surfaces
|
|
18
|
-
// intersect
|
|
21
|
+
// intersect, and a planar chain split at a sharp corner mitres the same way;
|
|
22
|
+
// radius feasibility is the caller's job (clamp like filleted-box.js
|
|
19
23
|
// does — an oversized radius self-intersects the cutters).
|
|
20
24
|
//
|
|
21
25
|
// Selector object mirrors edge-selector.js semantics ({dir, inPlane, at, near});
|
|
22
26
|
// `dir` only ever matches straight chains, like replicad's inDirection.
|
|
23
27
|
// Pure module: no DOM, no node:, no three — safe anywhere in the worker graph.
|
|
28
|
+
import { sweepSeedFrame } from "./sweep.js";
|
|
24
29
|
|
|
25
30
|
const TOL = 1e-4; // selector / coplanarity tolerance (mm)
|
|
26
31
|
const WELD = 1e6; // vertex weld quantization (1/WELD mm grid)
|
|
@@ -193,12 +198,123 @@ export function chainEdges(edges) {
|
|
|
193
198
|
}
|
|
194
199
|
if (run) runs.push(run);
|
|
195
200
|
// a run's type is the joint type joining its members; single-member runs are lines
|
|
201
|
+
const runChains = [];
|
|
196
202
|
for (const r of runs) {
|
|
197
203
|
const type = r.ks.length === 1 || r.type === "coll" || r.type === null ? "line" : "arc";
|
|
198
|
-
|
|
204
|
+
runChains.push(buildChain(edges, path, r.ks, type, closedUniform && runs.length === 1));
|
|
205
|
+
}
|
|
206
|
+
// Planar rescue is per-PATH, not per-run, and replaces the WHOLE path's chains: a rim
|
|
207
|
+
// that mixes straight, curvy, and short runs must become ONE swept tool, because
|
|
208
|
+
// per-run tools along the same rim continue each other nearly collinearly — their
|
|
209
|
+
// overshoots then overlap surface-on-surface (not the clean perpendicular crossing of
|
|
210
|
+
// a box corner) and the boolean leaves degenerate seams where identical blend
|
|
211
|
+
// surfaces coincide. A path whose runs are ALL line/arc keeps its exact per-run tools
|
|
212
|
+
// exactly as before — promotion only fires where the path would otherwise reroute.
|
|
213
|
+
if (runChains.some((c) => c.kind === "unsupported")) {
|
|
214
|
+
const rescue = buildPlanarPath(edges, path);
|
|
215
|
+
if (rescue) { chains.push(rescue); continue; }
|
|
216
|
+
}
|
|
217
|
+
chains.push(...runChains);
|
|
218
|
+
}
|
|
219
|
+
return stitchPlanarChains(chains);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Join open planar chains that continue each other across a path junction. The edge walk
|
|
223
|
+
// ends a path at any degree≠2 vertex — and a real rim grows one wherever its outline
|
|
224
|
+
// turns past sharpDeg, because that corner puts a sharp VERTICAL edge up the wall. The
|
|
225
|
+
// rim's halves then arrive as separate open planar chains whose swept tools would cross
|
|
226
|
+
// at the junction's own shallow angle — a near-parallel surface overlap that leaves
|
|
227
|
+
// degenerate seams (the same disease the whole-path promotion cures within one path).
|
|
228
|
+
// Stitched into one chain, the junction becomes an interior vertex: the sweep miters it
|
|
229
|
+
// when gentle, and planarTool's fold guard splits it (with a clean, wide-angle mitre
|
|
230
|
+
// crossing) when sharp. Chains stitch only when they share an endpoint, the same face
|
|
231
|
+
// plane, and the same convexity — the same-plane test is what keeps a top rim from ever
|
|
232
|
+
// stitching to a bottom rim.
|
|
233
|
+
function stitchPlanarChains(chains) {
|
|
234
|
+
const open = [], out = [];
|
|
235
|
+
for (const c of chains) (c.kind === "planar" && !c.closed ? open : out).push(c);
|
|
236
|
+
if (open.length < 2) return chains;
|
|
237
|
+
const key = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
238
|
+
const compatible = (a, b) => a.convex === b.convex && dot(a.faceN, b.faceN) > FLANK_COS &&
|
|
239
|
+
Math.abs(dot(a.points[0], a.faceN) - dot(b.points[0], a.faceN)) <= TOL;
|
|
240
|
+
const rev = (c) => ({ ...c, points: [...c.points].reverse(), wallNs: [...c.wallNs].reverse() });
|
|
241
|
+
let progress = true;
|
|
242
|
+
while (progress) {
|
|
243
|
+
progress = false;
|
|
244
|
+
outer: for (let i = 0; i < open.length; i++) {
|
|
245
|
+
const a = open[i], aEnd = key(a.points[a.points.length - 1]);
|
|
246
|
+
for (let j = 0; j < open.length; j++) {
|
|
247
|
+
if (i === j || !compatible(a, open[j])) continue;
|
|
248
|
+
let b = open[j];
|
|
249
|
+
if (key(b.points[b.points.length - 1]) === aEnd) b = rev(b);
|
|
250
|
+
if (key(b.points[0]) !== aEnd) continue;
|
|
251
|
+
const points = [...a.points, ...b.points.slice(1)];
|
|
252
|
+
const closed = key(points[0]) === key(points[points.length - 1]);
|
|
253
|
+
const joined = { ...a, points, wallNs: [...a.wallNs, ...b.wallNs], closed };
|
|
254
|
+
open.splice(Math.max(i, j), 1);
|
|
255
|
+
open.splice(Math.min(i, j), 1);
|
|
256
|
+
(closed ? out : open).push(joined);
|
|
257
|
+
progress = true;
|
|
258
|
+
break outer;
|
|
259
|
+
}
|
|
199
260
|
}
|
|
200
261
|
}
|
|
201
|
-
return
|
|
262
|
+
return [...out, ...open];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Whole-path planar rescue, called by chainEdges when any of a path's runs classified
|
|
266
|
+
// unsupported: rebuild the ENTIRE path (every member, in walk order) as one candidate
|
|
267
|
+
// planar chain. See the promotion comment at the call site for why the whole path — and
|
|
268
|
+
// tryPlanarChain below for what qualifies.
|
|
269
|
+
function buildPlanarPath(edges, path) {
|
|
270
|
+
const members = path.members.map((i) => edges[i]);
|
|
271
|
+
const points = [vertPos(members[0], path.verts[0])];
|
|
272
|
+
members.forEach((m, i) => points.push(vertPos(m, otherVid(m, path.verts[i]))));
|
|
273
|
+
return tryPlanarChain(members, points, members[0].convex, path.loop);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Rescue an unsupported path as a PLANAR chain: every point of the path lies in one plane,
|
|
277
|
+
// one flank IS that plane's face (a world-constant normal — the top of an extrusion, the
|
|
278
|
+
// plate around a boss), and the other flank — the wall — turns with the path at a constant
|
|
279
|
+
// dihedral. Top and bottom rims of extruded profiles whose outlines are neither straight
|
|
280
|
+
// nor circular (text, offset outlines, splines) are exactly this shape, and they used to
|
|
281
|
+
// be this module's most common NEEDS_OCCT reroute. The blend tool for a planar chain is a
|
|
282
|
+
// sweep of the same 2-D cross-section the prism and revolve tools use (planarTool below):
|
|
283
|
+
// in the sweep's transported frame both flanks have constant coordinates along the whole
|
|
284
|
+
// run — the same rotating-frame argument fitArcChain makes about surfaces of revolution —
|
|
285
|
+
// so one fixed profile blends the entire path.
|
|
286
|
+
//
|
|
287
|
+
// Flank pairing keys on the CANDIDATE face normal itself (each of member 0's two flanks
|
|
288
|
+
// in turn): every member contributes whichever of its flanks lies closer to the candidate.
|
|
289
|
+
// Neighbor-pairing — the trick classifyChain's line branch uses — is deliberately NOT
|
|
290
|
+
// reused here: over a long turning run the wall normal rotates far enough that it pairs
|
|
291
|
+
// against the face and scrambles both columns (measured on a 37-edge run of the wavy-rim
|
|
292
|
+
// fixture). Keying on the candidate is stable however far the wall turns, because the
|
|
293
|
+
// true face flank stays within FLANK_COS of it while the wall sits a whole dihedral away.
|
|
294
|
+
// A run where neither candidate yields a constant column (a helix, a saddle) returns null
|
|
295
|
+
// and stays unsupported — the rescue never guesses.
|
|
296
|
+
const FLANK_COS = 0.9986; // ~3°, the same constancy bar the line classifier uses
|
|
297
|
+
function tryPlanarChain(members, points, convex, closed) {
|
|
298
|
+
if (points.length < 3) return null; // a 2-point run is a line chain's job
|
|
299
|
+
for (const cand of [members[0].n1, members[0].n2]) {
|
|
300
|
+
const face = [], wall = [];
|
|
301
|
+
for (const m of members) {
|
|
302
|
+
const [f, wl] = dot(m.n1, cand) >= dot(m.n2, cand) ? [m.n1, m.n2] : [m.n2, m.n1];
|
|
303
|
+
face.push(f);
|
|
304
|
+
wall.push(wl);
|
|
305
|
+
}
|
|
306
|
+
const meanRaw = face.reduce((s, f) => add(s, f), [0, 0, 0]);
|
|
307
|
+
if (len(meanRaw) < 1e-9) continue;
|
|
308
|
+
const w = norm(meanRaw);
|
|
309
|
+
if (!face.every((f) => dot(f, w) > FLANK_COS)) continue; // not world-constant
|
|
310
|
+
const d0 = dot(points[0], w);
|
|
311
|
+
if (!points.every((p) => Math.abs(dot(p, w) - d0) <= TOL)) continue; // run not in the face plane
|
|
312
|
+
const dots = wall.map((n) => dot(n, w));
|
|
313
|
+
const meanDot = dots.reduce((s, x) => s + x, 0) / dots.length;
|
|
314
|
+
if (!dots.every((x) => Math.abs(x - meanDot) <= 0.05)) continue; // dihedral drifts (~3°)
|
|
315
|
+
return { kind: "planar", points, closed, convex, w, faceN: w, wallNs: wall };
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
202
318
|
}
|
|
203
319
|
|
|
204
320
|
function buildChain(edges, path, ks, type, closed) {
|
|
@@ -494,6 +610,101 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
494
610
|
return tool.translate(O);
|
|
495
611
|
}
|
|
496
612
|
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// Planar-chain blend tool: sweep the shared 2-D cross-section (profile2D) along the
|
|
615
|
+
// chain's own polyline with k.sweep — a prism IS the one-segment case of this sweep,
|
|
616
|
+
// generalized to a path that turns. In the sweep's transported frame the face and wall
|
|
617
|
+
// flanks keep constant coordinates along a planar constant-dihedral path, so ONE profile
|
|
618
|
+
// polygon serves every station; sweepSeedFrame gives the exact frame the sweep will seed,
|
|
619
|
+
// so the profile is authored in it rather than re-deriving (and drifting from) the pick.
|
|
620
|
+
//
|
|
621
|
+
// The sweep miters gently-turning joints on its own. A vertex whose miter would fold —
|
|
622
|
+
// a sharp corner, a reversal cusp, a segment shorter than the profile's reach — SPLITS
|
|
623
|
+
// the chain there instead, and each open stretch overshoots its ends the way prism
|
|
624
|
+
// cutters do, so adjacent stretches mitre into each other across the split. Concave
|
|
625
|
+
// fillers stay flush at their ends — overshoot would bulge outside the part when unioned
|
|
626
|
+
// (prismTool's own rule) — which can leave a hairline notch in a bead at a split; that is
|
|
627
|
+
// the mitred-junction limit from the module header, not a leak (the boolean stays
|
|
628
|
+
// watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
|
|
629
|
+
// converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
|
|
630
|
+
// the build. Returns an ARRAY of tools — one per stretch.
|
|
631
|
+
function planarTool(k, chain, magnitude, mode, segs) {
|
|
632
|
+
const { points, closed, convex, faceN, wallNs } = chain;
|
|
633
|
+
const pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
|
|
634
|
+
const m = pts.length;
|
|
635
|
+
const at = (i) => pts[((i % m) + m) % m];
|
|
636
|
+
const nSeg = closed ? m : m - 1;
|
|
637
|
+
const segDir = [], segLen = [];
|
|
638
|
+
for (let i = 0; i < nSeg; i++) {
|
|
639
|
+
const d = sub(at(i + 1), at(i)), l = len(d);
|
|
640
|
+
segDir.push(scl(d, 1 / (l || 1)));
|
|
641
|
+
segLen.push(l);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Fold guard, mirrored from resolveSweepStations' miter check with a stricter factor
|
|
645
|
+
// (0.45 vs 0.5) so the split fires before the sweep would throw. `reach` is a cheap
|
|
646
|
+
// rigid upper bound on the profile's half-width — exact reach needs the profile, the
|
|
647
|
+
// profile needs the stretch, and conservatism here only costs an extra mitred split.
|
|
648
|
+
const reach = magnitude * 1.5;
|
|
649
|
+
const isBreak = (i) => { // vertex i, with a segment on both sides
|
|
650
|
+
const dIn = segDir[(i - 1 + nSeg) % nSeg], dOut = segDir[i];
|
|
651
|
+
const c = clamp1(dot(dIn, dOut));
|
|
652
|
+
if (c < -1 + 1e-6) return true; // reversal cusp
|
|
653
|
+
return reach * Math.tan(Math.acos(c) / 2) > 0.45 * Math.min(segLen[(i - 1 + nSeg) % nSeg], segLen[i]);
|
|
654
|
+
};
|
|
655
|
+
const breaks = [];
|
|
656
|
+
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) if (isBreak(i)) breaks.push(i);
|
|
657
|
+
|
|
658
|
+
const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
|
|
659
|
+
const overshoot = (path) => {
|
|
660
|
+
if (!(over > 0) || path.length < 2) return path;
|
|
661
|
+
const a = path[0], b = path[1], y = path[path.length - 1], x = path[path.length - 2];
|
|
662
|
+
return [add(a, scl(norm(sub(a, b)), over)), ...path, add(y, scl(norm(sub(y, x)), over))];
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// One tool per stretch. The profile's wall normal is the SEED member's — the segment
|
|
666
|
+
// whose tangent the sweep frame is seeded ⟂ to: the closing segment for a closed loop,
|
|
667
|
+
// the first segment for an open stretch (overshoot extends along that same tangent, so
|
|
668
|
+
// it never changes the seed).
|
|
669
|
+
// ext stays 0 for every planar sweep, cutters and fillers alike — measured both ways
|
|
670
|
+
// on the fixtures. Stations sit ON the path vertices, so the profile's tangent lines
|
|
671
|
+
// ride the flank facets exactly: plane-on-plane contact the kernel resolves cleanly.
|
|
672
|
+
// An arc-tail extension (revolveTool's recipe for curved-vs-curved phase noise) turns
|
|
673
|
+
// that exact contact into a ~2° grazing CROSSING — and a grazing crossing's float
|
|
674
|
+
// wiggle carves sliver seams whether the tool is subtracted or unioned, because the
|
|
675
|
+
// crossing curve is exactly where the boolean's boundary hands over, always exposed.
|
|
676
|
+
const toolFor = (path3D, isClosed, wallN) => {
|
|
677
|
+
const { N, B } = sweepSeedFrame(path3D, isClosed);
|
|
678
|
+
const p2 = (v) => {
|
|
679
|
+
const q = [dot(v, N), dot(v, B)], l = Math.hypot(q[0], q[1]) || 1;
|
|
680
|
+
return [q[0] / l, q[1] / l];
|
|
681
|
+
};
|
|
682
|
+
const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs });
|
|
683
|
+
return k.sweep(poly, path3D, { closed: isClosed });
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
if (closed && breaks.length === 0) {
|
|
688
|
+
return [toolFor(pts.map((p) => [p[0], p[1], p[2]]), true, wallNs[nSeg - 1])];
|
|
689
|
+
}
|
|
690
|
+
// Open stretches between breaks. An open chain's endpoints are implicit breaks; a
|
|
691
|
+
// closed chain's stretches wrap from each break to the next.
|
|
692
|
+
const bounds = closed
|
|
693
|
+
? breaks.map((b, j) => [b, breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)])
|
|
694
|
+
: (breaks.length ? [[0, breaks[0]], ...breaks.map((b, j) => [b, j + 1 < breaks.length ? breaks[j + 1] : m - 1])] : [[0, m - 1]]);
|
|
695
|
+
const tools = [];
|
|
696
|
+
for (const [s, e] of bounds) {
|
|
697
|
+
if (e <= s) continue;
|
|
698
|
+
const path = [];
|
|
699
|
+
for (let i = s; i <= e; i++) path.push(at(i));
|
|
700
|
+
tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
|
|
701
|
+
}
|
|
702
|
+
return tools;
|
|
703
|
+
} catch (e) {
|
|
704
|
+
throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
497
708
|
// ---------------------------------------------------------------------------
|
|
498
709
|
// Spherical corner patches. Where exactly three selected straight convex chains
|
|
499
710
|
// meet at a vertex with mutually orthogonal directions (a box-like corner), the
|
|
@@ -565,9 +776,12 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
565
776
|
if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
|
|
566
777
|
const unsupported = selected.find((ch) => ch.kind === "unsupported");
|
|
567
778
|
if (unsupported) throw new UnsupportedEdgeError(`${mode}: ${unsupported.reason}`);
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
779
|
+
const toolsFor = (ch) =>
|
|
780
|
+
ch.kind === "planar"
|
|
781
|
+
? planarTool(k, ch, magnitude, mode, segs)
|
|
782
|
+
: [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs)];
|
|
783
|
+
const cutters = selected.filter((ch) => ch.convex).flatMap(toolsFor);
|
|
784
|
+
const fillers = selected.filter((ch) => !ch.convex).flatMap(toolsFor);
|
|
571
785
|
if (mode === "fillet") cutters.push(...cornerPatches(k, selected, magnitude, segs));
|
|
572
786
|
let out = solid;
|
|
573
787
|
if (cutters.length) out = out.cutAll(cutters);
|
|
@@ -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
|
|