partforge 0.65.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.65.0",
3
+ "version": "0.65.1",
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",
@@ -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
- const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
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(m.subtract(unionRaw(tools.map((t) => t._m))))),
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
- revolve: (pts, { degrees = 360 } = {}) => {
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, segs), () => T(csFor(pts).revolve(segs, degrees)));
331
- return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, 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
@@ -16,11 +16,19 @@
16
16
  // Anything else (helical edges, varying dihedral, branching curves) raises
17
17
  // UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
18
18
  //
19
- // Known limits (documented, not bugs): no spherical corner patches yet two
20
- // chains meeting at a vertex leave a mitred junction where their blend surfaces
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
23
- // does an oversized radius self-intersects the cutters).
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).
24
32
  //
25
33
  // Selector object mirrors edge-selector.js semantics ({dir, inPlane, at, near});
26
34
  // `dir` only ever matches straight chains, like replicad's inDirection.
@@ -37,6 +45,24 @@ export class UnsupportedEdgeError extends Error {
37
45
  constructor(message) { super(message); this.name = "UnsupportedEdgeError"; }
38
46
  }
39
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
+
40
66
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
41
67
  const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
42
68
  const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
@@ -242,7 +268,19 @@ function stitchPlanarChains(chains) {
242
268
  while (progress) {
243
269
  progress = false;
244
270
  outer: for (let i = 0; i < open.length; i++) {
245
- const a = open[i], aEnd = key(a.points[a.points.length - 1]);
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]);
246
284
  for (let j = 0; j < open.length; j++) {
247
285
  if (i === j || !compatible(a, open[j])) continue;
248
286
  let b = open[j];
@@ -359,11 +397,39 @@ function fitArcChain(members, points, convex, closed) {
359
397
  const alpha = (l2 * (l1 - c12)) / det, beta = (l1 * (l2 - c12)) / det;
360
398
  const O = add(p0, add(scl(e1, alpha), scl(e2, beta)));
361
399
  const R = len(sub(p0, O));
362
- const rtol = Math.max(1e-3, 1e-3 * R);
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;
363
412
  for (const p of points) {
364
413
  if (Math.abs(len(sub(p, O)) - R) > rtol) return bad("edge curve is not circular");
365
414
  if (Math.abs(dot(sub(p, O), w)) > rtol) return bad("edge curve is not planar");
366
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");
367
433
  // frame: azimuth 0 at the first point; flip w so azimuths increase along the run
368
434
  const u0 = norm(sub(points[0], O));
369
435
  let v0 = cross(w, u0);
@@ -515,17 +581,28 @@ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
515
581
  // plane-on-plane, which the kernel resolves exactly.
516
582
  const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
517
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));
518
594
  const pts = [corner];
519
595
  for (let i = 0; i <= nArc; i++) {
520
596
  const nv = rot2(n1, s2 * (-ext + ((span + 2 * ext) * i) / nArc));
521
- pts.push([C[0] + sgn * r * nv[0], C[1] + sgn * r * nv[1]]);
597
+ const ri = i === 0 || i === nArc ? r : rEq;
598
+ pts.push([C[0] + sgn * ri * nv[0], C[1] + sgn * ri * nv[1]]);
522
599
  }
523
600
  return pts;
524
601
  }
525
602
 
526
603
  // ---------------------------------------------------------------------------
527
604
  // Cutter/filler solids.
528
- function prismTool(k, chain, magnitude, mode, segs) {
605
+ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
529
606
  const { a, dir: e, length, n1, n2, convex } = chain;
530
607
  // pose rotation Z → e; the 2D basis is the image of X,Y under the SAME rotation
531
608
  const axisRaw = cross([0, 0, 1], e);
@@ -536,10 +613,12 @@ function prismTool(k, chain, magnitude, mode, segs) {
536
613
  const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
537
614
  const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
538
615
  const p2 = (w) => [dot(w, u), dot(w, v)];
539
- 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 });
540
617
  // convex cutters overshoot the edge ends (sticking outside the solid is
541
- // harmless when subtracting); concave fillers must end flush any overshoot
542
- // would bulge outside the part when unioned
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
543
622
  const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
544
623
  let tool = k.loft(
545
624
  [{ polygon: poly, z: -over }, { polygon: poly, z: length + over }],
@@ -549,7 +628,11 @@ function prismTool(k, chain, magnitude, mode, segs) {
549
628
  return tool.translate(a);
550
629
  }
551
630
 
552
- function revolveTool(k, chain, magnitude, mode, segs) {
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) {
553
636
  const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
554
637
  // Seam-grazing guard. The edge circle passes through the flank tessellation's
555
638
  // VERTICES (circumradius) while its facets sit at the apothem, so a revolved
@@ -558,14 +641,33 @@ function revolveTool(k, chain, magnitude, mode, segs) {
558
641
  // cannot always collapse them. `sag` is that facet sagitta plus a roundoff pad
559
642
  // bounded relative to the requested feature, so tiny blends never inherit a
560
643
  // fixed allowance larger than their own cross-section.
561
- const sag = (R + magnitude) * (1 - Math.cos(Math.PI / segs)) + Math.min(2e-4, 0.02 * magnitude);
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);
562
664
  // Fillet: size the arc-tail extension to cross the facet planes, but cap it at
563
665
  // 0.4 rad. Below the mesh's own facet scale a larger tail wraps around the tiny
564
666
  // profile and creates one tunnel per facet; the cap bounds penetration to 8%
565
667
  // of the requested radius while the cutter's outside corner still opens into
566
668
  // free space.
567
669
  const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / magnitude))));
568
- 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 });
569
671
  if (mode === "chamfer") {
570
672
  // Chamfer: the cone itself is the cutting surface — no tail to extend, so
571
673
  // bury the whole profile by `sag` along the material-side bisector instead.
@@ -588,7 +690,12 @@ function revolveTool(k, chain, magnitude, mode, segs) {
588
690
  if (area < 0) poly = poly.slice().reverse();
589
691
  const ovAng = closed || !convex ? 0 : Math.min(0.15, Math.max(1e-3, (0.05 * magnitude) / R));
590
692
  const degrees = closed ? 360 : ((span + 2 * ovAng) * 180) / Math.PI;
591
- let tool = k.revolve(poly, { degrees });
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 });
592
699
  // pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
593
700
  // chain's start direction (backed off by the angular overshoot)
594
701
  const startDir = closed ? u0 : add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
@@ -628,7 +735,7 @@ function revolveTool(k, chain, magnitude, mode, segs) {
628
735
  // watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
629
736
  // converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
630
737
  // the build. Returns an ARRAY of tools — one per stretch.
631
- function planarTool(k, chain, magnitude, mode, segs) {
738
+ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
632
739
  const { points, closed, convex, faceN, wallNs } = chain;
633
740
  const pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
634
741
  const m = pts.length;
@@ -645,15 +752,46 @@ function planarTool(k, chain, magnitude, mode, segs) {
645
752
  // (0.45 vs 0.5) so the split fires before the sweep would throw. `reach` is a cheap
646
753
  // rigid upper bound on the profile's half-width — exact reach needs the profile, the
647
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.
648
762
  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
763
  const breaks = [];
656
- for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) if (isBreak(i)) breaks.push(i);
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
+ }
657
795
 
658
796
  const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
659
797
  const overshoot = (path) => {
@@ -661,6 +799,20 @@ function planarTool(k, chain, magnitude, mode, segs) {
661
799
  const a = path[0], b = path[1], y = path[path.length - 1], x = path[path.length - 2];
662
800
  return [add(a, scl(norm(sub(a, b)), over)), ...path, add(y, scl(norm(sub(y, x)), over))];
663
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
+ };
664
816
 
665
817
  // One tool per stretch. The profile's wall normal is the SEED member's — the segment
666
818
  // whose tangent the sweep frame is seeded ⟂ to: the closing segment for a closed loop,
@@ -679,7 +831,7 @@ function planarTool(k, chain, magnitude, mode, segs) {
679
831
  const q = [dot(v, N), dot(v, B)], l = Math.hypot(q[0], q[1]) || 1;
680
832
  return [q[0] / l, q[1] / l];
681
833
  };
682
- const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs });
834
+ const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs: pSegs });
683
835
  return k.sweep(poly, path3D, { closed: isClosed });
684
836
  };
685
837
 
@@ -693,18 +845,285 @@ function planarTool(k, chain, magnitude, mode, segs) {
693
845
  ? breaks.map((b, j) => [b, breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)])
694
846
  : (breaks.length ? [[0, breaks[0]], ...breaks.map((b, j) => [b, j + 1 < breaks.length ? breaks[j + 1] : m - 1])] : [[0, m - 1]]);
695
847
  const tools = [];
848
+ const arcAt = (i) => cornerArcs.get(((i % m) + m) % m);
696
849
  for (const [s, e] of bounds) {
697
850
  if (e <= s) continue;
698
- const path = [];
851
+ let path = [];
699
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);
700
856
  tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
701
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
+ }
702
863
  return tools;
703
864
  } catch (e) {
704
865
  throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
705
866
  }
706
867
  }
707
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
+
708
1127
  // ---------------------------------------------------------------------------
709
1128
  // Spherical corner patches. Where exactly three selected straight convex chains
710
1129
  // meet at a vertex with mutually orthogonal directions (a box-like corner), the
@@ -776,13 +1195,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
776
1195
  if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
777
1196
  const unsupported = selected.find((ch) => ch.kind === "unsupported");
778
1197
  if (unsupported) throw new UnsupportedEdgeError(`${mode}: ${unsupported.reason}`);
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);
779
1204
  const toolsFor = (ch) =>
780
1205
  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);
785
- if (mode === "fillet") cutters.push(...cornerPatches(k, selected, magnitude, segs));
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));
786
1212
  let out = solid;
787
1213
  if (cutters.length) out = out.cutAll(cutters);
788
1214
  if (fillers.length) out = k.union([out, ...fillers]);