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.
@@ -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 and circular-arc edges (bore rims, cylinder rims, the arcs where fillets meet a
2157
- face) natively, at exact radius to within tessellation. Only `shell` still routes a
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 2*CLUSTER_TOL cluster-
830
- // diameter uncertainty, but a downstream cap triangulator may bridge that loop across the
831
- // face and expose the bridge as a bogus feature edge. Positive dilation may discard these
832
- // sub-resolution counter loops: they are collapsed negative boundaries, never new material.
833
- // Keep erosion unchanged; its tiny surviving islands have no equivalent source-domain proof.
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 = 2 * CLUSTER_TOL;
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
- function simplifyContour(scope, contour, tolerance) {
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; radius feasibility is the caller's job (clamp like filleted-box.js
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
- chains.push(buildChain(edges, path, r.ks, type, closedUniform && runs.length === 1));
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 chains;
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 tool = (ch) => (ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs);
569
- const cutters = selected.filter((ch) => ch.convex).map(tool);
570
- const fillers = selected.filter((ch) => !ch.convex).map(tool);
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
- return out;
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
- return ring;
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
- const seedT = closed ? dir[segCount - 1] : dir[0];
71
- const ref = Math.abs(dot(seedT, Z)) < 0.9 ? Z : X;
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