partforge 0.64.0 → 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 +4 -2
- package/package.json +1 -1
- package/src/framework/geometry/contour-offset.js +18 -6
- package/src/framework/geometry/contour-ops.js +28 -1
- package/src/framework/geometry/mesh-fillet.js +220 -6
- package/src/framework/geometry/profile.js +45 -3
- package/src/framework/geometry/sweep.js +17 -5
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
|
@@ -826,14 +826,26 @@ function sourceBackedPositiveRegions(source, out, delta) {
|
|
|
826
826
|
|
|
827
827
|
// Crossing clustering can snap both ends of a tiny curve run onto the same pool vertex.
|
|
828
828
|
// The run then survives as a closed loop attached at one point (or as a whole one-segment
|
|
829
|
-
// contour). It encloses only geometry inside the resolver's own
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
829
|
+
// contour). It encloses only geometry inside the resolver's own splice uncertainty, but a
|
|
830
|
+
// downstream cap triangulator may bridge that loop across the face and expose the bridge
|
|
831
|
+
// as a bogus feature edge — and a surviving zero-chord loop also breaks the NEXT offset or
|
|
832
|
+
// boolean over this output (its crossing chaining reads the loop as an unclosable
|
|
833
|
+
// arrangement: the "could not chain offset boundary" class of failure). Positive dilation
|
|
834
|
+
// may discard these sub-resolution counter loops: they are collapsed negative boundaries,
|
|
835
|
+
// never new material. Keep erosion unchanged; its tiny surviving islands have no
|
|
836
|
+
// equivalent source-domain proof.
|
|
837
|
+
//
|
|
838
|
+
// The radius is 10*CLUSTER_TOL, not the 2*CLUSTER_TOL cluster-diameter bound this pass
|
|
839
|
+
// shipped with: the CROSSINGS that got merged sit within one cluster, but the curve run
|
|
840
|
+
// BETWEEN them wanders further before returning — measured on offset(6, round) of
|
|
841
|
+
// "Scotty" size 24 (test/contour-cleanup.test.js), the surviving splice loops reached
|
|
842
|
+
// 0.028 mm of control extent, 5.6x the old radius, and every one of them slipped this
|
|
843
|
+
// pass. 10x (0.05 mm) covers them with margin while staying far below any printable
|
|
844
|
+
// feature; a genuine pinch-off lobe bigger than that survives as its own region ring and
|
|
845
|
+
// never presents as a single zero-chord segment in the first place.
|
|
834
846
|
function dropSubresolutionPositiveLoops(out, delta) {
|
|
835
847
|
if (delta <= 0) return out;
|
|
836
|
-
const radius =
|
|
848
|
+
const radius = 10 * CLUSTER_TOL;
|
|
837
849
|
const clean = (contour) => {
|
|
838
850
|
const segments = [];
|
|
839
851
|
let from = contour.start;
|
|
@@ -656,7 +656,34 @@ function simplifyRun(scope, from, segs, tolerance) {
|
|
|
656
656
|
return refitRunViaPaper(scope, from, segs, tolerance);
|
|
657
657
|
}
|
|
658
658
|
|
|
659
|
-
|
|
659
|
+
// Sweep segments whose ENTIRE extent (endpoint and every control point) stays within the
|
|
660
|
+
// caller's tolerance of their own start — degenerate loop-backs and dust segments. These
|
|
661
|
+
// are exactly the artifacts an upstream offset/boolean can leave behind (the winding
|
|
662
|
+
// resolver's splice debris: zero-chord cubics with controls ~0.01-0.03 mm out), and
|
|
663
|
+
// without this pass they DEFEAT simplify: contourCorners reads the loop's joints as real
|
|
664
|
+
// corners, and the corner-preserving contract then pins the debris bit-exact, so
|
|
665
|
+
// `.simplify(0.03)` used to return a 0.02 mm loop untouched. Dropping a segment moves the
|
|
666
|
+
// path by at most its chord (≤ tolerance) — precisely the change the caller authorized.
|
|
667
|
+
// Everything larger than the tolerance is geometry, not debris, and passes through.
|
|
668
|
+
function sweepDegenerateSegments(contour, tolerance) {
|
|
669
|
+
const segments = [];
|
|
670
|
+
let from = contour.start;
|
|
671
|
+
for (const seg of contour.segments) {
|
|
672
|
+
const controls = [seg.via, seg.c1, seg.c2].filter(Boolean);
|
|
673
|
+
const extent = Math.max(
|
|
674
|
+
Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]),
|
|
675
|
+
...controls.map((p) => Math.hypot(p[0] - from[0], p[1] - from[1])),
|
|
676
|
+
);
|
|
677
|
+
if (extent <= tolerance) continue; // debris: path continues from `from`
|
|
678
|
+
segments.push(seg);
|
|
679
|
+
from = seg.to;
|
|
680
|
+
}
|
|
681
|
+
if (segments.length < 2) return contour; // a ring of dust is not ours to delete
|
|
682
|
+
return closeContourGap({ start: [contour.start[0], contour.start[1]], segments });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function simplifyContour(scope, rawContour, tolerance) {
|
|
686
|
+
const contour = sweepDegenerateSegments(rawContour, tolerance);
|
|
660
687
|
const corners = contourCorners(contour);
|
|
661
688
|
if (corners.length === 0) {
|
|
662
689
|
// Cornerless (smooth closed loop): simplify as ONE closed path — toPaperPath's default
|
|
@@ -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);
|
|
@@ -118,14 +118,36 @@ export function sampleBezier(p0, c1, c2, p1, segs) {
|
|
|
118
118
|
recurse(p0, c1, c2, p1, 0);
|
|
119
119
|
if (out.length === 0) out.push([p1[0], p1[1]]);
|
|
120
120
|
out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
|
|
121
|
-
|
|
121
|
+
// Emit-if-moved: near a cusp (a degenerate loop-back cubic — e.g. the winding
|
|
122
|
+
// resolver's splice debris, start === end with controls ~0.01 mm out) the curve's
|
|
123
|
+
// SPEED collapses, so depth-capped parameter-uniform splits cluster spatially and
|
|
124
|
+
// the raw list carries runs of samples nanometers apart (measured min gap 2e-9 mm).
|
|
125
|
+
// Those land in tessellated rings as coincident points and poison every consumer
|
|
126
|
+
// (sliver wall facets, mesh edge chains, ring dedup). Keep a sample only once it has
|
|
127
|
+
// moved SAMPLE_EPS from the last kept one; the exact endpoint stays pinned — it
|
|
128
|
+
// replaces a final sample that stopped short of it, so the one pair flanking the pin
|
|
129
|
+
// may be tighter than SAMPLE_EPS, and that is the only pair allowed to be. 1e-6 mm is
|
|
130
|
+
// a nanometer: far below any legitimate facet spacing (a 1 µm-radius arc at segs 96
|
|
131
|
+
// still spaces ~6e-5), so no real curve loses a sample.
|
|
132
|
+
const SAMPLE_EPS = 1e-6;
|
|
133
|
+
const kept = [];
|
|
134
|
+
let last = p0;
|
|
135
|
+
for (const p of out) {
|
|
136
|
+
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < SAMPLE_EPS) continue;
|
|
137
|
+
kept.push(p);
|
|
138
|
+
last = p;
|
|
139
|
+
}
|
|
140
|
+
if (kept.length && Math.hypot(kept[kept.length - 1][0] - p1[0], kept[kept.length - 1][1] - p1[1]) < SAMPLE_EPS)
|
|
141
|
+
kept[kept.length - 1] = [p1[0], p1[1]];
|
|
142
|
+
else kept.push([p1[0], p1[1]]);
|
|
143
|
+
return kept;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
// Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
|
|
125
147
|
// (identical to the former path); a path contour is walked start→segment→segment, lines
|
|
126
148
|
// pushing their `to`, arcs and cubics pushing their sampled points (sampleArc/sampleBezier).
|
|
127
149
|
export function tessellateContour(contour, segs) {
|
|
128
|
-
if (Array.isArray(contour)) return contour;
|
|
150
|
+
if (Array.isArray(contour)) return contour; // legacy point list: caller's data, bit-exact
|
|
129
151
|
const ring = [[contour.start[0], contour.start[1]]];
|
|
130
152
|
let prev = contour.start;
|
|
131
153
|
for (const seg of contour.segments) {
|
|
@@ -134,7 +156,27 @@ export function tessellateContour(contour, segs) {
|
|
|
134
156
|
else ring.push([seg.to[0], seg.to[1]]);
|
|
135
157
|
prev = seg.to;
|
|
136
158
|
}
|
|
137
|
-
|
|
159
|
+
// Coincident consecutive points never survive tessellation: a zero-length line segment
|
|
160
|
+
// (or a sampler edge case) would otherwise land verbatim in the ring and hand every
|
|
161
|
+
// consumer (extrude walls, mesh edge chains, silhouette masks) a degenerate edge. The
|
|
162
|
+
// very last point is kept unconditionally — it replaces a duplicate predecessor rather
|
|
163
|
+
// than being dropped — so the explicit-closure convention (final point lands exactly on
|
|
164
|
+
// `start`) survives the sweep.
|
|
165
|
+
const TESS_EPS = 1e-9;
|
|
166
|
+
const out = [ring[0]];
|
|
167
|
+
for (let i = 1; i < ring.length; i++) {
|
|
168
|
+
const p = ring[i], last = out[out.length - 1];
|
|
169
|
+
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < TESS_EPS) {
|
|
170
|
+
if (i === ring.length - 1) out[out.length - 1] = p; // keep the exact closure point
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
out.push(p);
|
|
174
|
+
}
|
|
175
|
+
// A path contour always tessellates to ≥2 points (start + arrival), even when the whole
|
|
176
|
+
// contour is degenerate — consumers walk poly EDGES (contour-winding's pieceSamples) and
|
|
177
|
+
// a single-point poly has none to walk.
|
|
178
|
+
if (out.length < 2) return [ring[0], ring[ring.length - 1]];
|
|
179
|
+
return out;
|
|
138
180
|
}
|
|
139
181
|
|
|
140
182
|
// Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
|
|
@@ -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
|
|