partforge 0.137.0 → 0.138.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.
@@ -13,7 +13,10 @@
13
13
  // - planar contour chains at constant dihedral → swept cutter/filler along the
14
14
  // chain's own polyline (top/bottom rims of extruded text, offset outlines,
15
15
  // splines — see tryPlanarChain/planarTool)
16
- // Anything else (helical edges, varying dihedral, branching curves) raises
16
+ // - general chains between two curved faces → per-vertex cross-section ring
17
+ // (saddles, cross-hole rims, slanted cuts) stack (buildGeneralPath /
18
+ // generalTool), tried last
19
+ // Anything a general chain also refuses (mixed convexity, knife edges, a bend tighter than the section) raises
17
20
  // UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
18
21
  //
19
22
  // Corner treatment, by how sharp the corner is. A SHARP salient corner (turn past
@@ -264,6 +267,12 @@ export function chainEdges(edges) {
264
267
  if (runChains.some((c) => c.kind === "unsupported")) {
265
268
  const rescue = buildPlanarPath(edges, path);
266
269
  if (rescue) { chains.push(rescue); continue; }
270
+ // Last resort before a reroute: the whole path as ONE general chain, whose tool
271
+ // builds its cross-section per vertex from that vertex's own flank normals
272
+ // (generalTool). Runs only where every fixed-section tool has already refused,
273
+ // so no chain any existing tool accepts can ever land here.
274
+ const general = buildGeneralPath(edges, path);
275
+ if (general) { chains.push(general); continue; }
267
276
  }
268
277
  chains.push(...runChains);
269
278
  }
@@ -361,6 +370,91 @@ function buildPlanarPath(edges, path) {
361
370
  return tryPlanarChain(members, points, members[0].convex, path.loop);
362
371
  }
363
372
 
373
+ // General-chain rescue (spec 2026-10-02): an edge between two curved faces — a pipe
374
+ // tee's saddle, a cross hole's rim, an ellipse where a plane cuts a tube — fails every
375
+ // constancy test the fixed-section tools need, yet the mesh still records BOTH exact
376
+ // flank normals per member. Accept the whole path if it keeps one convexity and has
377
+ // no knife edge; generalTool builds a cross-section per vertex from those normals.
378
+ // Flanks are paired against the PREVIOUS member's pairing (continuity), never a world
379
+ // frame: over a saddle the flank normals swing far enough that world pairing swaps
380
+ // them partway round (fitArcChain's rotating-frame argument, made local).
381
+ function buildGeneralPath(edges, path) {
382
+ const members = path.members.map((i) => edges[i]);
383
+ if (members.length < 2) return null;
384
+ const convex = members[0].convex;
385
+ if (!members.every((m) => m.convex === convex)) return null;
386
+ const points = [vertPos(members[0], path.verts[0])];
387
+ members.forEach((m, i) => points.push(vertPos(m, otherVid(m, path.verts[i]))));
388
+ const closed = !!path.loop;
389
+ if (closed) points.pop(); // loop: last vertex is the first; keep one copy
390
+ const flanks = [];
391
+ let prev = [members[0].n1, members[0].n2];
392
+ for (const m of members) {
393
+ const keep = dot(m.n1, prev[0]) + dot(m.n2, prev[1]) >= dot(m.n2, prev[0]) + dot(m.n1, prev[1]);
394
+ const pair = keep ? [m.n1, m.n2] : [m.n2, m.n1];
395
+ if (dot(pair[0], pair[1]) < -1 + 1e-6) return null; // knife edge: no wedge to blend
396
+ flanks.push(pair);
397
+ prev = pair;
398
+ }
399
+ if (closed) {
400
+ const f0 = flanks[0], fl = flanks[flanks.length - 1];
401
+ if (dot(f0[0], fl[0]) + dot(f0[1], fl[1]) < dot(f0[0], fl[1]) + dot(f0[1], fl[0])) return null; // pairing cannot close
402
+ }
403
+ return { kind: "general", points, closed, convex, flanks };
404
+ }
405
+
406
+ // One station per path vertex, plus interior stations on members much longer than the
407
+ // median (a long facet between short ones would otherwise step the section) — at most
408
+ // MAX_MEMBER_STATIONS per member. The median is by COUNT, so a path that is mostly
409
+ // slivers has a tiny median and, uncapped, one long member between them asked for
410
+ // ceil(L / 2·median) − 1 stations: 500 003 for a sliver(1e-5)–10 mm–sliver path, which
411
+ // smoothing cannot merge and every ring of which went through ofMesh (and a zero
412
+ // median — duplicate points — asked for infinitely many). The cap sits above every
413
+ // count the fixtures and the fillet census ask for (largest: 329, on a knob; 71
414
+ // public forges + 56 eval parts, preview and print), so no real part's stations
415
+ // change; it only bounds the pathological case, which MAX_GENERAL_STATIONS then
416
+ // bounds per chain. A
417
+ // vertex's tangent bisects its two members; its flank normals average the incident
418
+ // members' (paired) normals, projected perpendicular to the tangent. `tilt` is how far
419
+ // that average sits from the incident facets — generalTool's grazing allowance.
420
+ export const MAX_MEMBER_STATIONS = 512;
421
+ export function generalStations(chain) {
422
+ const { points, closed, flanks } = chain;
423
+ const n = points.length;
424
+ const nMem = closed ? n : n - 1;
425
+ const memDir = (i) => norm(sub(points[(i + 1) % n], points[i]));
426
+ const memLen = (i) => len(sub(points[(i + 1) % n], points[i]));
427
+ const perp = (v, t) => norm(sub(v, scl(t, dot(v, t))));
428
+ const angle = (a, b) => Math.acos(clamp1(dot(a, b)));
429
+ const lens = Array.from({ length: nMem }, (_, i) => memLen(i)).sort((a, b) => a - b);
430
+ const median = lens[lens.length >> 1];
431
+ const vertexStation = (v) => {
432
+ const inc = [];
433
+ if (closed || v > 0) inc.push((v - 1 + nMem) % nMem);
434
+ if (closed || v < n - 1) inc.push(v % nMem);
435
+ const t = norm(inc.reduce((acc, i) => add(acc, memDir(i)), [0, 0, 0]));
436
+ const raw1 = norm(inc.reduce((acc, i) => add(acc, flanks[i][0]), [0, 0, 0]));
437
+ const raw2 = norm(inc.reduce((acc, i) => add(acc, flanks[i][1]), [0, 0, 0]));
438
+ let tilt = 0;
439
+ for (const i of inc) tilt = Math.max(tilt, angle(raw1, flanks[i][0]), angle(raw2, flanks[i][1]));
440
+ return { p: points[v], t, n1: perp(raw1, t), n2: perp(raw2, t), tilt };
441
+ };
442
+ const out = [];
443
+ for (let v = 0; v < n; v++) {
444
+ out.push(vertexStation(v));
445
+ if (!closed && v === n - 1) break;
446
+ const L = memLen(v);
447
+ const extra = Math.min(MAX_MEMBER_STATIONS, Math.ceil(L / (2 * median)) - 1);
448
+ const t = memDir(v), [f1, f2] = flanks[v];
449
+ for (let j = 1; j <= extra; j++) {
450
+ const s = j / (extra + 1);
451
+ out.push({ p: add(points[v], scl(sub(points[(v + 1) % n], points[v]), s)), t,
452
+ n1: perp(f1, t), n2: perp(f2, t), tilt: 0 });
453
+ }
454
+ }
455
+ return out;
456
+ }
457
+
364
458
  // Rescue an unsupported path as a PLANAR chain: every point of the path lies in one plane,
365
459
  // one flank IS that plane's face (a world-constant normal — the top of an extrusion, the
366
460
  // plate around a boss), and the other flank — the wall — turns with the path at a constant
@@ -566,8 +660,11 @@ export function matchesSelector(chain, sel) {
566
660
  }
567
661
  } else {
568
662
  let best = Infinity;
569
- for (let i = 0; i + 1 < chain.points.length; i++) {
570
- const a = chain.points[i], b = chain.points[i + 1];
663
+ // A closed general chain keeps no duplicated closing point, so its last
664
+ // member wraps points[n-1] -> points[0]; other kinds keep the original loop.
665
+ const pn = chain.points.length, wrap = chain.kind === "general" && chain.closed;
666
+ for (let i = 0; wrap ? i < pn : i + 1 < pn; i++) {
667
+ const a = chain.points[i], b = chain.points[(i + 1) % pn];
571
668
  const ab = sub(b, a), t = Math.max(0, Math.min(1, dot(sub(near, a), ab) / (dot(ab, ab) || 1)));
572
669
  best = Math.min(best, len(sub(near, add(a, scl(ab, t)))));
573
670
  }
@@ -585,7 +682,7 @@ export function matchesSelector(chain, sel) {
585
682
  // coplanar with a flank); concave profiles tuck the corner point into the
586
683
  // material so the filler welds on.
587
684
  const rot2 = ([x, y], th) => [x * Math.cos(th) - y * Math.sin(th), x * Math.sin(th) + y * Math.cos(th)];
588
- function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
685
+ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0, nArc: nArcFixed }) {
589
686
  const c = clamp1(n1[0] * n2[0] + n1[1] * n2[1]);
590
687
  // `knifeEdge` marks the refusal as the anti-parallel-flank degeneracy, so the
591
688
  // planar rim machinery can SKIP a noise stretch (a sliver facet's flipped
@@ -633,7 +730,8 @@ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
633
730
  // fillers alike. Prism cutters keep ext = 0: their tangent contact is
634
731
  // plane-on-plane, which the kernel resolves exactly.
635
732
  const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
636
- const nArc = Math.max(2, Math.ceil((span / (2 * Math.PI)) * segs));
733
+ // general chains pass a fixed count so every station's ring has the same size
734
+ const nArc = nArcFixed ?? Math.max(2, Math.ceil((span / (2 * Math.PI)) * segs));
637
735
  // Area-exact tessellation: an inscribed chord polygon under-sweeps the ball arc by a
638
736
  // first-order-in-facet-angle area deficit whose RELATIVE size is radius-independent
639
737
  // (~0.23/n² of the blend cross-section) — at the sagitta-bounded density above it
@@ -682,6 +780,656 @@ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
682
780
  return mode === "fillet" ? markBlend(k, tool, { kind: "line", p: add(a, ballOffset(n1, n2, magnitude, convex)), d: e }) : tool;
683
781
  }
684
782
 
783
+ // Centre of the circle through a, b, c (null when collinear).
784
+ function circumcentre(a, b, c) {
785
+ const e1 = sub(b, a), e2 = sub(c, a), w = cross(e1, e2), ww = dot(w, w);
786
+ if (ww < 1e-18) return null;
787
+ const t = add(scl(cross(w, e1), dot(e2, e2)), scl(cross(e2, w), dot(e1, e1)));
788
+ return add(a, scl(t, 1 / (2 * ww)));
789
+ }
790
+
791
+ // Station smoothing. generalStations places a station at every path vertex, and on a
792
+ // mesh-boolean intersection those vertices are tessellation, not geometry: a facet
793
+ // ridge crossing kinks the polyline, and two ridges a hair apart leave a sliver
794
+ // segment whose three-point circumradius reads a 3 mm bend as 0.2 mm. Smooth before
795
+ // building rings: stations closer than MERGE_FRAC × the median spacing merge into a
796
+ // neighbour; each tangent becomes the chord between the points ~magnitude/2 of arc
797
+ // length either side (clamped at open ends), with the flank normals re-projected
798
+ // perpendicular to it; and the fold guard reads the circumcircle over that same span.
799
+ // Ring planes then turn as smoothly as the geometry, so a kink cannot fold the stack.
800
+ const MERGE_FRAC = 0.2;
801
+ function smoothStations(st0, closed, magnitude) {
802
+ const m0 = st0.length;
803
+ const gaps = [];
804
+ for (let i = 0; i < (closed ? m0 : m0 - 1); i++) gaps.push(len(sub(st0[(i + 1) % m0].p, st0[i].p)));
805
+ const median = [...gaps].sort((a, b) => a - b)[gaps.length >> 1] || 0;
806
+ const minGap = MERGE_FRAC * median;
807
+ const st = [st0[0]];
808
+ for (let i = 1; i < m0; i++) {
809
+ if (len(sub(st0[i].p, st[st.length - 1].p)) >= minGap) st.push(st0[i]);
810
+ else if (!closed && i === m0 - 1 && st.length > 1) st[st.length - 1] = st0[i]; // open ends stay put
811
+ }
812
+ if (closed && st.length > 3 && len(sub(st[0].p, st[st.length - 1].p)) < minGap) st.pop();
813
+ const m = st.length;
814
+ const cum = [0];
815
+ for (let i = 1; i < m; i++) cum.push(cum[i - 1] + len(sub(st[i].p, st[i - 1].p)));
816
+ const L = closed ? cum[m - 1] + len(sub(st[0].p, st[m - 1].p)) : cum[m - 1];
817
+ const pointAt = (s) => {
818
+ if (closed) s = ((s % L) + L) % L;
819
+ else s = Math.max(0, Math.min(L, s));
820
+ let lo = 0, hi = m - 1;
821
+ if (s >= cum[m - 1]) { lo = m - 1; } else { while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (cum[mid] <= s) lo = mid; else hi = mid; } }
822
+ const a = st[lo].p, b = st[(lo + 1) % m].p;
823
+ const segL = (lo === m - 1 ? L : cum[lo + 1]) - cum[lo];
824
+ return segL > 0 ? add(a, scl(sub(b, a), (s - cum[lo]) / segL)) : a;
825
+ };
826
+ const h = magnitude / 2;
827
+ const perp = (v, t) => norm(sub(v, scl(t, dot(v, t))));
828
+ return st.map((s, i) => {
829
+ const prev = pointAt(cum[i] - h), next = pointAt(cum[i] + h);
830
+ const chord = sub(next, prev);
831
+ const t = len(chord) > 1e-12 ? norm(chord) : s.t;
832
+ const interior = closed || (cum[i] - h >= 0 && cum[i] + h <= L);
833
+ return { p: s.p, t, n1: perp(s.n1, t), n2: perp(s.n2, t), tilt: s.tilt, prev, next, interior };
834
+ });
835
+ }
836
+
837
+ // Osculating curvature of one flank in the section plane, signed so that κ < 0 is a
838
+ // flank falling away from its tangent line into the material (a convex tube seen
839
+ // from outside) and κ > 0 one rising into the air. Probe: step `s` along the flank's
840
+ // in-face direction f from the edge point p, cast both ways along the flank normal n
841
+ // (range 2s) and take the nearest hit on a triangle facing roughly along n (the
842
+ // OTHER flank, and back-facing or far geometry, are skipped); the circle tangent to
843
+ // the flank at p through that hit has κ = 2h/(s² + h²). A miss, or a bend too tight
844
+ // to trust (|κ|·s ≥ 0.9), reads as a straight flank.
845
+ const FLANK_FACING_COS = 0.82; // ~35°
846
+ function flankCurvature(rays, p, n, f, s) {
847
+ if (!rays) return 0;
848
+ const S = add(p, scl(f, s));
849
+ let best = null;
850
+ for (const sign of [1, -1]) {
851
+ const hit = rays.nearest(S, scl(n, sign), 2 * s, (fn) => dot(fn, n) >= FLANK_FACING_COS);
852
+ if (hit && (!best || hit.t < best.t)) best = { t: hit.t, h: sign * hit.t };
853
+ }
854
+ if (!best) return 0;
855
+ const kappa = (2 * best.h) / (s * s + best.h * best.h);
856
+ return Math.abs(kappa) * s >= 0.9 ? 0 : kappa;
857
+ }
858
+
859
+ // Short-range ray casts against the part's own mesh, for flankCurvature. Local on
860
+ // purpose: only the triangles whose bounds come within the padded box of a general
861
+ // chain are kept. Deliberately NOT oracle/bvh.js — the cut & print kit loads this
862
+ // module and must never load the oracle (test/kit-layering.test.js). Every probe is
863
+ // short (range 2 × magnitude), so the kept triangles go into a uniform grid of
864
+ // `cell`-sized cubes (cell = the probe range: a probe's box is at most one cell
865
+ // wide plus a hair of padding, so it touches at most 3 cells per axis — usually 2)
866
+ // and a probe tests only the triangles sharing a cell with its segment's box. That set contains every triangle the segment can hit, so the nearest hit is
867
+ // exactly the brute-force one — the brute force over every kept triangle measured
868
+ // 2.6 × 10⁹ triangle tests and 65 s on a chain-mail sheet (Task 4b report). A
869
+ // triangle spanning more than GRID_BIG_CELLS cells (a long flat facet) is tested by
870
+ // every probe instead of being copied into all of them.
871
+ const GRID_BIG_CELLS = 512;
872
+ function localRays({ positions: P, indices: I }, boxes, cell) {
873
+ const keep = [];
874
+ for (let t = 0; t < I.length; t += 3) {
875
+ let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
876
+ for (let j = 0; j < 3; j++) {
877
+ const o = I[t + j] * 3, x = P[o], y = P[o + 1], z = P[o + 2];
878
+ if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; if (z < z0) z0 = z; if (z > z1) z1 = z;
879
+ }
880
+ if (boxes.some((b) => x1 >= b[0] && x0 <= b[3] && y1 >= b[1] && y0 <= b[4] && z1 >= b[2] && z0 <= b[5])) keep.push(t);
881
+ }
882
+ const nT = keep.length;
883
+ const V = new Float64Array(nT * 9);
884
+ keep.forEach((t, i) => { for (let j = 0; j < 3; j++) { const o = I[t + j] * 3; V[i * 9 + j * 3] = P[o]; V[i * 9 + j * 3 + 1] = P[o + 1]; V[i * 9 + j * 3 + 2] = P[o + 2]; } });
885
+ // grid over the kept triangles' bounds; a cell key is ix + nx·(iy + ny·iz), exact
886
+ // in a double for any grid this module can meet
887
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
888
+ for (let b = 0; b < V.length; b += 3) for (let a = 0; a < 3; a++) { lo[a] = Math.min(lo[a], V[b + a]); hi[a] = Math.max(hi[a], V[b + a]); }
889
+ const pad = 1e-6 * cell;
890
+ const nx = Math.max(1, Math.floor((hi[0] - lo[0]) / cell) + 1), ny = Math.max(1, Math.floor((hi[1] - lo[1]) / cell) + 1);
891
+ const ci = (x, a) => Math.floor((x - lo[a]) / cell);
892
+ const cells = new Map(), big = [];
893
+ for (let i = 0; i < nT; i++) {
894
+ const b = i * 9;
895
+ const r0 = [0, 0, 0], r1 = [0, 0, 0];
896
+ for (let a = 0; a < 3; a++) {
897
+ const v0 = V[b + a], v1 = V[b + 3 + a], v2 = V[b + 6 + a];
898
+ r0[a] = ci(Math.min(v0, v1, v2) - pad, a); r1[a] = ci(Math.max(v0, v1, v2) + pad, a);
899
+ }
900
+ if ((r1[0] - r0[0] + 1) * (r1[1] - r0[1] + 1) * (r1[2] - r0[2] + 1) > GRID_BIG_CELLS) { big.push(i); continue; }
901
+ for (let z = r0[2]; z <= r1[2]; z++) for (let y = r0[1]; y <= r1[1]; y++) for (let x = r0[0]; x <= r1[0]; x++) {
902
+ const key = x + nx * (y + ny * z);
903
+ const list = cells.get(key);
904
+ if (list) list.push(i); else cells.set(key, [i]);
905
+ }
906
+ }
907
+ const stamp = new Int32Array(nT);
908
+ let probe = 0;
909
+ // nearest hit within (0, tMax] on a triangle whose unit normal passes `accept`.
910
+ // Möller–Trumbore with scalar temporaries, in the same operation order as the
911
+ // vector helpers (so a hit's t is bit-identical to the brute-force form).
912
+ const test = (i, o, d, tMax, accept, best) => {
913
+ const b = i * 9, ax = V[b], ay = V[b + 1], az = V[b + 2];
914
+ const e1x = V[b + 3] - ax, e1y = V[b + 4] - ay, e1z = V[b + 5] - az;
915
+ const e2x = V[b + 6] - ax, e2y = V[b + 7] - ay, e2z = V[b + 8] - az;
916
+ const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x;
917
+ const det = e1x * px + e1y * py + e1z * pz;
918
+ if (Math.abs(det) < 1e-12) return best;
919
+ const tx = o[0] - ax, ty = o[1] - ay, tz = o[2] - az;
920
+ const u = (tx * px + ty * py + tz * pz) / det;
921
+ if (u < 0 || u > 1) return best;
922
+ const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
923
+ const v = (d[0] * qx + d[1] * qy + d[2] * qz) / det;
924
+ if (v < 0 || u + v > 1) return best;
925
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
926
+ if (!(t > 1e-9 && t <= tMax) || (best && t >= best.t)) return best;
927
+ const fn = norm(cross([e1x, e1y, e1z], [e2x, e2y, e2z]));
928
+ if (!accept(fn)) return best;
929
+ return { t, n: fn };
930
+ };
931
+ const nearest = (o, d, tMax, accept) => {
932
+ let best = null;
933
+ probe++;
934
+ const r0 = [0, 0, 0], r1 = [0, 0, 0];
935
+ for (let a = 0; a < 3; a++) {
936
+ const e = o[a] + d[a] * tMax;
937
+ r0[a] = Math.max(0, ci(Math.min(o[a], e) - pad, a)); r1[a] = ci(Math.max(o[a], e) + pad, a);
938
+ }
939
+ for (let z = r0[2]; z <= r1[2]; z++) for (let y = r0[1]; y <= r1[1]; y++) for (let x = r0[0]; x <= r1[0]; x++) {
940
+ if (x >= nx || y >= ny) continue;
941
+ const list = cells.get(x + nx * (y + ny * z));
942
+ if (!list) continue;
943
+ for (const i of list) {
944
+ if (stamp[i] === probe) continue;
945
+ stamp[i] = probe;
946
+ best = test(i, o, d, tMax, accept, best);
947
+ }
948
+ }
949
+ for (const i of big) best = test(i, o, d, tMax, accept, best);
950
+ return best;
951
+ };
952
+ return { nearest };
953
+ }
954
+
955
+ // Rolling-ball section with curved flanks — pure 2-D, edge point at the origin. n1/n2
956
+ // are the unit flank normals, k1/k2 their section-plane curvatures (flankCurvature's
957
+ // sign convention; 0 = straight). Each flank is the circle tangent to its tangent line
958
+ // at the edge with that curvature, or the line itself. The ball centre C sits at
959
+ // distance r from both curves on the material side (convex: cutter) or the air side
960
+ // (concave: filler) — profile2D's tangent-line centre is the Newton start, and the
961
+ // solve falls back to it (straight flanks) when it does not converge nearby. Returns
962
+ // C, the contacts T1/T2, the arc's start direction m1, signed sweep phi, and the
963
+ // flank descriptions sectionPolygon needs.
964
+ function curvedBall({ n1, n2, k1, k2, magnitude: r, convex }) {
965
+ const c = clamp1(n1[0] * n2[0] + n1[1] * n2[1]);
966
+ if (1 + c < 1e-6) throw Object.assign(new UnsupportedEdgeError("~180° knife edge"), { knifeEdge: true });
967
+ const sgn = convex ? 1 : -1;
968
+ const bl = Math.hypot(n1[0] + n2[0], n1[1] + n2[1]);
969
+ const bis = [(n1[0] + n2[0]) / bl, (n1[1] + n2[1]) / bl];
970
+ const inFace = (nv) => { let f = [-nv[1], nv[0]]; if (sgn * (f[0] * bis[0] + f[1] * bis[1]) > 0) f = [-f[0], -f[1]]; return f; };
971
+ const flank = (n, kap) => ({ n, f: inFace(n), kap, O: kap ? [n[0] / kap, n[1] / kap] : null, R: kap ? 1 / Math.abs(kap) : Infinity });
972
+ const solve = (F1, F2) => {
973
+ // signed distance to a flank, positive on its air side, and its gradient
974
+ const dist = (F, X) => {
975
+ if (!F.O) return { d: X[0] * F.n[0] + X[1] * F.n[1], g: F.n };
976
+ const dx = X[0] - F.O[0], dy = X[1] - F.O[1], D = Math.hypot(dx, dy) || 1e-12, sk = Math.sign(F.kap);
977
+ return { d: sk * (F.R - D), g: [(-sk * dx) / D, (-sk * dy) / D] };
978
+ };
979
+ const foot = (F, X) => {
980
+ if (!F.O) { const d = X[0] * F.n[0] + X[1] * F.n[1]; return [X[0] - d * F.n[0], X[1] - d * F.n[1]]; }
981
+ const dx = X[0] - F.O[0], dy = X[1] - F.O[1], D = Math.hypot(dx, dy);
982
+ return [F.O[0] + (F.R * dx) / D, F.O[1] + (F.R * dy) / D];
983
+ };
984
+ const C0 = [(-sgn * r * (n1[0] + n2[0])) / (1 + c), (-sgn * r * (n1[1] + n2[1])) / (1 + c)];
985
+ let C = C0, ok = false;
986
+ for (let it = 0; it < 40; it++) {
987
+ const a = dist(F1, C), b = dist(F2, C);
988
+ const e1 = a.d + sgn * r, e2 = b.d + sgn * r;
989
+ if (Math.abs(e1) < 1e-12 && Math.abs(e2) < 1e-12) { ok = true; break; }
990
+ const det = a.g[0] * b.g[1] - a.g[1] * b.g[0];
991
+ if (Math.abs(det) < 1e-12) break;
992
+ C = [C[0] - (e1 * b.g[1] - e2 * a.g[1]) / det, C[1] - (a.g[0] * e2 - b.g[0] * e1) / det];
993
+ }
994
+ if (!ok || Math.hypot(C[0] - C0[0], C[1] - C0[1]) > 2 * r) return null;
995
+ const T1 = foot(F1, C), T2 = foot(F2, C);
996
+ const a1 = T1[0] * F1.f[0] + T1[1] * F1.f[1], a2 = T2[0] * F2.f[0] + T2[1] * F2.f[1];
997
+ if (!(a1 > 0 && a2 > 0)) return null; // contact behind the edge: not this corner's ball
998
+ const m1 = norm2([sgn * (T1[0] - C[0]), sgn * (T1[1] - C[1])]);
999
+ const m2 = norm2([sgn * (T2[0] - C[0]), sgn * (T2[1] - C[1])]);
1000
+ const phi = Math.atan2(m1[0] * m2[1] - m1[1] * m2[0], clamp1(m1[0] * m2[0] + m1[1] * m2[1]));
1001
+ return { C, T1, T2, a1, a2, m1, phi, F1, F2, bis, sgn };
1002
+ };
1003
+ return solve(flank(n1, k1), flank(n2, k2)) ?? solve(flank(n1, 0), flank(n2, 0));
1004
+ }
1005
+ const norm2 = ([x, y]) => { const l = Math.hypot(x, y) || 1; return [x / l, y / l]; };
1006
+
1007
+ // Measuring the wall AT the contact (refineBall). flankCurvature reads each flank at
1008
+ // the EDGE, from the station's averaged facet normals; on a faceted cylinder those
1009
+ // step by a facet's turn (~3° at preview), and the ball solved from them jumped by up
1010
+ // to r·Δθ/2 per facet — 25 µm of spine jitter on the cross hole, lumpy bands and up
1011
+ // to 7° of shading error. The ball only cares about the wall where it touches it, so
1012
+ // each station re-measures both flanks there: a five-hit patch (wallPatch) around the
1013
+ // current contact gives the wall's secant normal through a point ON the facets, the
1014
+ // ball is re-solved tangent to the two local tangent lines, and the contacts move.
1015
+ // Measured on the cross hole and tee (r = 1, 1.5, 2): a second round changes nothing
1016
+ // a first one left (one round leaves the tee at r = 1 at 2.95°, two at 1.35°, three
1017
+ // the same as two), and the patch half-width CONTACT_D × r is flat from 0.15 to 0.4
1018
+ // in shading (1.4°) and line placement (≤ 17 µm); 0.3 keeps a probe's reach well
1019
+ // inside its flank on small features while spanning several preview facets (the
1020
+ // facet sag error in the normal is sag / d). A patch that cannot be measured (a
1021
+ // probe off the flank, a sliver shadowing every retry) keeps the edge-measured
1022
+ // solution for that station.
1023
+ const CONTACT_ROUNDS = 2;
1024
+ const CONTACT_D = 0.3; // × r — patch half-width (and ray reach)
1025
+ // One accepted hit on the wall below/above X along unit n: cast from X + rho·n back
1026
+ // along −n over 2·rho. Returns the wall point and the hit facet's normal, or null.
1027
+ function wallHit(rays, X, n, rho) {
1028
+ const hit = rays.nearest(add(X, scl(n, rho)), scl(n, -1), 2 * rho, (fn) => dot(fn, n) >= FLANK_FACING_COS);
1029
+ return hit ? { p: add(X, scl(n, rho - hit.t)), fn: hit.n } : null;
1030
+ }
1031
+ // wallHit at a contact point that must not miss: a boolean sliver lying exactly
1032
+ // under X (rejected by the facing test) shadows the facet behind it, so retry a hair
1033
+ // to either side along the edge tangent t and carry the hit facet's plane back to
1034
+ // the ray through X.
1035
+ function contactHit(rays, X, n, t, rho) {
1036
+ const h = wallHit(rays, X, n, rho);
1037
+ if (h) return h;
1038
+ for (const s of [1, -1]) {
1039
+ const o = wallHit(rays, add(X, scl(t, s * 0.02 * rho)), n, rho);
1040
+ const dn = o ? dot(n, o.fn) : 0;
1041
+ if (dn > 0.5) return { p: add(X, scl(n, dot(sub(o.p, X), o.fn) / dn)), fn: o.fn };
1042
+ }
1043
+ return null;
1044
+ }
1045
+ // The smooth wall at a contact: the snapped centre hit plus four hits ±df along the
1046
+ // section's in-face direction f and ±dt along the edge tangent t. The two chords'
1047
+ // cross product is the secant normal — exact at the centre for a surface of
1048
+ // revolution to second order, and off by at most sag/d on facets (vs up to half a
1049
+ // facet's turn for the hit facet's own normal).
1050
+ function wallPatch(rays, P, n, f, t, df, dt, rho) {
1051
+ const c = contactHit(rays, P, n, t, rho);
1052
+ if (!c) return null;
1053
+ const fp = wallHit(rays, add(P, scl(f, df)), n, rho), fm = wallHit(rays, add(P, scl(f, -df)), n, rho);
1054
+ const tp = wallHit(rays, add(P, scl(t, dt)), n, rho), tm = wallHit(rays, add(P, scl(t, -dt)), n, rho);
1055
+ // a missed sample (a boolean sliver can shadow the facet behind it) falls back to
1056
+ // the one-sided chord through the centre; both of a pair missing gives up
1057
+ const chord = (a, b) => (a || b ? sub((a ?? c).p, (b ?? c).p) : null);
1058
+ const ct = chord(tp, tm), cf = chord(fp, fm);
1059
+ if (!ct || !cf) return { p: c.p, fn: c.fn, n: null };
1060
+ let nn = norm(cross(ct, cf));
1061
+ if (dot(nn, n) < 0) nn = scl(nn, -1);
1062
+ return { p: c.p, fn: c.fn, n: nn };
1063
+ }
1064
+ // the unit contact normals of a ball solution, pointing into the air from each wall
1065
+ const contactNormals = (sol, sgn) =>
1066
+ [sol.T1, sol.T2].map((Tk) => norm2([sgn * (Tk[0] - sol.C[0]), sgn * (Tk[1] - sol.C[1])]));
1067
+ function refineBall(rays, sol, frame, t3, r, convex) {
1068
+ const { p2, to3 } = frame, sgn = convex ? 1 : -1;
1069
+ if (!rays) return { ...sol, N: contactNormals(sol, sgn) };
1070
+ const vec3 = ([x, y]) => sub(to3([x, y]), to3([0, 0]));
1071
+ let { C } = sol, T = [sol.T1, sol.T2];
1072
+ let N = contactNormals(sol, sgn);
1073
+ const F = [sol.F1, sol.F2];
1074
+ let ok = false;
1075
+ for (let round = 0; round < CONTACT_ROUNDS; round++) {
1076
+ const flanks = [];
1077
+ for (let kk = 0; kk < 2; kk++) {
1078
+ const n3 = norm(vec3(N[kk])), f3 = norm(vec3([-N[kk][1], N[kk][0]]));
1079
+ const aT = T[kk][0] * F[kk].f[0] + T[kk][1] * F[kk].f[1];
1080
+ const df = Math.min(CONTACT_D * r, 0.5 * aT), rho = CONTACT_D * r;
1081
+ const w = df > 0 ? wallPatch(rays, to3(T[kk]), n3, f3, t3, df, CONTACT_D * r, rho) : null;
1082
+ if (!w || !w.n) { flanks.push(null); continue; }
1083
+ flanks.push({ Q: p2(sub(w.p, to3([0, 0]))), n: norm2(p2(w.n)) });
1084
+ }
1085
+ if (!flanks[0] || !flanks[1]) break;
1086
+ // ball tangent to both local tangent lines: n_k·(C − Q_k) = −sgn·r
1087
+ const [A, B] = flanks, det = A.n[0] * B.n[1] - A.n[1] * B.n[0];
1088
+ if (Math.abs(det) < 1e-9) break;
1089
+ const ra = dot2(A.n, A.Q) - sgn * r, rb = dot2(B.n, B.Q) - sgn * r;
1090
+ const Cn = [(ra * B.n[1] - rb * A.n[1]) / det, (A.n[0] * rb - B.n[0] * ra) / det];
1091
+ if (Math.hypot(Cn[0] - C[0], Cn[1] - C[1]) > 0.5 * r) break;
1092
+ const Tn = [A, B].map((Fk) => [Cn[0] + sgn * r * Fk.n[0], Cn[1] + sgn * r * Fk.n[1]]);
1093
+ if (!Tn.every((Tk, kk) => Tk[0] * F[kk].f[0] + Tk[1] * F[kk].f[1] > 0)) break;
1094
+ C = Cn; T = Tn; N = [A.n, B.n]; ok = true;
1095
+ }
1096
+ if (!ok) return { ...sol, N };
1097
+ const m1 = norm2([sgn * (T[0][0] - C[0]), sgn * (T[0][1] - C[1])]);
1098
+ const m2 = norm2([sgn * (T[1][0] - C[0]), sgn * (T[1][1] - C[1])]);
1099
+ const phi = Math.atan2(m1[0] * m2[1] - m1[1] * m2[0], clamp1(m1[0] * m2[0] + m1[1] * m2[1]));
1100
+ const a1 = T[0][0] * F[0].f[0] + T[0][1] * F[0].f[1], a2 = T[1][0] * F[1].f[0] + T[1][1] * F[1].f[1];
1101
+ return { ...sol, C, T1: T[0], T2: T[1], a1, a2, m1, phi, N };
1102
+ }
1103
+ const dot2 = (a, b) => a[0] * b[0] + a[1] * b[1];
1104
+
1105
+ // Chamfer setbacks measured ON the wall (chamferSection). profile2D's chamfer puts
1106
+ // each setback on the flank's tangent line at the edge; on a curved wall that point
1107
+ // stands off the facets by ~d²κ/2, and a filler's side wall along that line adds a
1108
+ // lens of material over the wall from where the line leaves the facets out to the
1109
+ // setback — the overlay drew every edge of it (boss-on-dome, d = 1.5: 44 mm of
1110
+ // boss-side line against a 19.75 mm setback curve, 137 segments across the junction;
1111
+ // the tee's tube side +74%). A cutter's tangent line stands off into the air and
1112
+ // costs only placement (the cross hole's tube-side line sat up to 83 µm off its
1113
+ // setback). The setback is the wall point at chord distance d from the edge in the
1114
+ // section plane: walk the wall from the edge in CHAMFER_STEPS steps, each re-probed
1115
+ // onto the facets along the last hit facet's in-plane normal (a step's reach d/steps
1116
+ // covers the wall's drop below a step's tangent, d²κ/(2·steps²), for any bend the
1117
+ // facing test accepts), then slide along the wall until the chord is d. The section's
1118
+ // flank is then the circle tangent to the flank at the edge through that setback —
1119
+ // contactPolygon's flank model, so the chamfer section is laid out, snapped and
1120
+ // margined exactly as a fillet's, with a straight chord in place of the ball arc. A
1121
+ // walk that misses keeps the tangent-line setback for that station.
1122
+ const CHAMFER_STEPS = 4;
1123
+ function wallSetback(rays, P, t3, n3, f3, d) {
1124
+ const inPlane = (v) => norm(sub(v, scl(t3, dot(v, t3))));
1125
+ let H = P, nl = n3, dir = f3;
1126
+ const step = (X, reach) => {
1127
+ const h = wallHit(rays, X, nl, reach);
1128
+ if (!h) return false;
1129
+ H = h.p;
1130
+ nl = inPlane(h.fn);
1131
+ if (dot(nl, n3) < 0) nl = scl(nl, -1);
1132
+ let dn = inPlane(cross(t3, nl));
1133
+ if (dot(dn, dir) < 0) dn = scl(dn, -1);
1134
+ dir = dn;
1135
+ return true;
1136
+ };
1137
+ for (let j = 0; j < CHAMFER_STEPS; j++) if (!step(add(H, scl(dir, d / CHAMFER_STEPS)), d / CHAMFER_STEPS)) return null;
1138
+ for (let it = 0; it < 3; it++) {
1139
+ const gap = d - len(sub(H, P));
1140
+ if (Math.abs(gap) < 1e-6 * d) break;
1141
+ if (!step(add(H, scl(dir, gap)), Math.max(2 * Math.abs(gap), 0.05 * d))) return null;
1142
+ }
1143
+ return H;
1144
+ }
1145
+ function chamferSection(rays, flat, frame, s, d) {
1146
+ const { p2 } = frame, { sgn } = flat;
1147
+ const vec3 = ([x, y]) => add(scl(frame.u, x), scl(frame.v, y));
1148
+ const flanks = [flat.F1, flat.F2].map((F) => {
1149
+ const H = rays ? wallSetback(rays, s.p, s.t, norm(vec3(F.n)), norm(vec3(F.f)), d) : null;
1150
+ const T = H ? p2(sub(H, s.p)) : [d * F.f[0], d * F.f[1]];
1151
+ const a = dot2(T, F.f), h = dot2(T, F.n);
1152
+ // the circle tangent to the flank at the edge through T (straight if T is on the line)
1153
+ const kap = H && a > 0 && Math.abs(h) > 1e-9 * d ? (2 * h) / (a * a + h * h) : 0;
1154
+ if (!(a > 0)) return { F: { ...F, kap: 0 }, T: [d * F.f[0], d * F.f[1]], a: d, N: F.n };
1155
+ // the circle's normal at T, pointing into the air like F.n
1156
+ const N = kap ? norm2([(T[0] - F.n[0] / kap) * Math.sign(-kap), (T[1] - F.n[1] / kap) * Math.sign(-kap)]) : F.n;
1157
+ return { F: { ...F, kap }, T, a, N: dot2(N, F.n) < 0 ? [-N[0], -N[1]] : N };
1158
+ });
1159
+ const [A, B] = flanks;
1160
+ // The step at each contact (CONTACT_MU) is a face of its own between the wall and
1161
+ // the chamfer face. Leaving the contact along the wall normal, it meets the wall at
1162
+ // 90° but the chamfer face at only 90° − α, where α is the chord's angle to the wall
1163
+ // at that contact: past α ≈ 55° that is inside the 35° crease angle, and the step
1164
+ // (a few µm tall wherever the fold allowance lifts the contact) shaded the chamfer
1165
+ // face's contact vertices up to ~21° off. Leaning the step toward the edge by α/2
1166
+ // puts it 90° − α/2 ≥ 45° from BOTH faces, whatever its height. S is that lean, in
1167
+ // contactPolygon's convention (it scales by sgn like N).
1168
+ const S = [A, B].map((Fk, kk) => {
1169
+ const T = Fk.T, To = kk === 0 ? B.T : A.T, N = Fk.N;
1170
+ let w = [N[1], -N[0]];
1171
+ if (dot2(w, Fk.F.f) < 0) w = [-w[0], -w[1]]; // along the wall, away from the edge
1172
+ const c = norm2([To[0] - T[0], To[1] - T[1]]);
1173
+ const alpha = Math.acos(clamp1(-dot2(c, w))), beta = alpha / 2;
1174
+ return norm2([Math.cos(beta) * N[0] - sgn * Math.sin(beta) * w[0], Math.cos(beta) * N[1] - sgn * Math.sin(beta) * w[1]]);
1175
+ });
1176
+ // no ball: C, m1 and phi only feed the arc's interior, which a chord does not have
1177
+ return { ...flat, C: [0, 0], T1: A.T, T2: B.T, a1: A.a, a2: B.a, F1: A.F, F2: B.F, m1: A.N, phi: 0, N: [A.N, B.N], S, sgn };
1178
+ }
1179
+
1180
+ // The fillet (or chamfer) polygon ending ON the wall. Layout: corner point (delta past the edge
1181
+ // along the bisector, outside the material for a cutter, inside it for a filler),
1182
+ // FLANK_SAMPLES points along flank 1 toward its contact, the point straight above
1183
+ // contact 1 (along the contact normal, on the harmless side), contact 1 itself, the
1184
+ // ball arc's interior (fixed nArc, area-exact radius; none for a chamfer's chord, nArc =
1185
+ // 1), contact 2, the point above it,
1186
+ // and flank 2's samples back toward the corner. Every call with the same nArc returns
1187
+ // the same point count, so the stack stitches.
1188
+ // The flank points follow each flank's circle — a straight chord would bite a lens of
1189
+ // material out of a curved flank for a cutter, or add one over it for a filler — lifted
1190
+ // toward the harmless side (air for a cutter, material for a filler) by delta plus
1191
+ // a·sin(tilt): the station's normals are an average of the incident facets, so a real
1192
+ // facet leaves the modelled flank by up to a·sin(tilt) at distance a from the edge, and
1193
+ // a flank edge inside that band grazes the facets. Measured on the general fixtures
1194
+ // rebuilt from 32–100-segment tubes and spheres (test/mesh-fillet-general.test.js):
1195
+ // a constant lift strands shells (10 of 120 cases); a·sin(tilt) × 0.5, 1 or 1.5 leaves
1196
+ // every case at its genus.
1197
+ // The arc ENDS at the contacts, and the closing edge leaves each one at right angles
1198
+ // to the wall. The earlier layout continued the arc past the contacts and closed the
1199
+ // polygon beyond them; that arc ran within the facets' sag (~1 µm) of the wall for
1200
+ // ±~0.05 mm around each contact, so every facet kept a sub-µm lens of uncut (or
1201
+ // unfilled) wall there, and the overlay — which draws every seam between a blend and
1202
+ // its wall — traced both edges of every lens: a staircase of two lines ~0.09 mm apart
1203
+ // with a rung at each facet seam, 37 mm of line against the cross hole's 19.7 mm
1204
+ // contact curve. generalTool snaps `ends` onto the faceted wall and keeps the points
1205
+ // next to them strictly on the tool side, so the boolean cuts the wall along the
1206
+ // contact polyline, crossing it steeply, and the band ends on one clean line.
1207
+ const FLANK_SAMPLES = 4;
1208
+ // The wall-side margins generalTool gives a fillet section's contacts and the arc
1209
+ // points next to them (× r): CONTACT_MU is the floor — enough that a contact never
1210
+ // sits exactly ON a facet, where the boolean would meet a coplanar touch instead of
1211
+ // a crossing — and CONTACT_STOP the arc depth past which the wall no longer
1212
+ // threatens the arc, ending the probe walk inward.
1213
+ // SHADING DEPENDS ON THIS STEP. The margin leaves a sub-µm step between the wall and
1214
+ // the section at every contact (the closing edge's run from the wall to the contact).
1215
+ // For a chamfer that step is what keeps the wall's contact vertices off the chamfer
1216
+ // face — at a face-to-wall angle under the wall's 35° crease (down to 27° on the
1217
+ // boss-on-dome junction) they would otherwise smooth into it and streak the wall
1218
+ // (14% of the boss wall before) — so it must stay well over the shading weld
1219
+ // (creased-normals.js SHADE_WELD, 5e-5 mm; 2e-4·r passes it for r ≥ 0.25 mm). The
1220
+ // step is a face of its own, up to the fold allowance (CONTACT_MU_MAX·r) tall, so it
1221
+ // can be well over creased-normals' SHADE_SLIVER: a chamfer's step leans toward the
1222
+ // edge (chamferSection's S) to meet both the wall and the chamfer face at ≥ 45°, past
1223
+ // the crease angle, or it tilts the face's contact vertices up to ~21° (41% of the
1224
+ // cross hole's chamfer faces). Re-run test/mesh-fillet-general-shading.test.js after
1225
+ // changing either.
1226
+ const CONTACT_MU = 2e-4;
1227
+ const CONTACT_STOP = 3e-3;
1228
+ // The fold allowance is a worst-case bound (the ridge midway between the two
1229
+ // points) and a fold can reach ~1.2 rad under FLANK_FACING_COS (35° each side), so on a coarse
1230
+ // 32-gon with 1–2 mm station spacing it asked for up to 0.39·r (slantCut, r = 0.5)
1231
+ // — a step in the band, and a deeper cut than the fillet's. Fine meshes never ask
1232
+ // for more than 0.03·r (every fixture, r = 0.5–2). Capped at 0.05·r the coarse
1233
+ // fixtures keep their genus and move TOWARD OCCT (coarse slantCut r = 1: 9.3 →
1234
+ // 6.0%; r = 0.5: 5.7%), the coarse cross hole's lines stay single, and no fine
1235
+ // number moves.
1236
+ const CONTACT_MU_MAX = 0.05;
1237
+ function contactPolygon(sol, magnitude, nArc, tilt, ends) {
1238
+ const { C, a1, a2, m1, phi, F1, F2, bis, sgn, N } = sol, S = sol.S ?? N;
1239
+ const r = magnitude, delta = 0.02 * magnitude;
1240
+ const onFlank = (F, a, lift) => {
1241
+ let b = 0;
1242
+ if (F.kap) { const R = 1 / F.kap; b = R - Math.sign(R) * Math.sqrt(Math.max(0, R * R - a * a)); }
1243
+ return [a * F.f[0] + (b + sgn * lift) * F.n[0], a * F.f[1] + (b + sgn * lift) * F.n[1]];
1244
+ };
1245
+ const lifted = (F, a) => onFlank(F, a, delta + a * Math.sin(tilt));
1246
+ const above = (H, Nk, a) => [H[0] + sgn * (delta + a * Math.sin(tilt)) * Nk[0], H[1] + sgn * (delta + a * Math.sin(tilt)) * Nk[1]];
1247
+ const side = (F, aT) => Array.from({ length: FLANK_SAMPLES }, (_, j) => lifted(F, (aT * (j + 1)) / (FLANK_SAMPLES + 1)));
1248
+ const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
1249
+ const th = span / nArc, rEq = nArc > 1 ? r * Math.sqrt(th / Math.sin(th)) : r;
1250
+ const arc = [];
1251
+ for (let i = 1; i < nArc; i++) {
1252
+ const nv = rot2(m1, (s2 * span * i) / nArc);
1253
+ arc.push([C[0] + sgn * rEq * nv[0], C[1] + sgn * rEq * nv[1]]);
1254
+ }
1255
+ return { pts: [[sgn * delta * bis[0], sgn * delta * bis[1]], ...side(F1, a1), above(ends[0], S[0], a1), ends[0],
1256
+ ...arc, ends[1], above(ends[1], S[1], a2), ...side(F2, a2).reverse()], arcStart: FLANK_SAMPLES + 2 };
1257
+ }
1258
+
1259
+ // General-chain tool: one ring per (smoothed) station, each the section in the plane
1260
+ // perpendicular to the edge, meshed as a ring stack. Fillet sections solve the
1261
+ // rolling ball against each flank's measured section-plane curvature (curvedBall —
1262
+ // the tangent-line profile2D section mis-sizes the spandrel by up to 1.7× where a
1263
+ // flank is a tube seen side-on), then re-solve it against the wall measured at the
1264
+ // contacts (refineBall) and end on the wall there (contactPolygon). Chamfer sections
1265
+ // take their setbacks on the wall (chamferSection) and are laid out the same way, the
1266
+ // chord standing in for the arc (nArc = 1). Every ring carries the same arc count —
1267
+ // the widest station's — so the stack stitches. A bend tighter than the section's
1268
+ // reach toward its own centre would fold the stack: refuse it, which reroutes (the
1269
+ // spec's policy) rather than emitting a self-intersecting tool.
1270
+ function generalTool(k, chain, magnitude, mode, pSegs, rays = null, stations = null) {
1271
+ if (!k._ringStackSolid) throw new UnsupportedEdgeError("general chain: this kernel has no ring-stack builder");
1272
+ const { convex, closed } = chain;
1273
+ const st = stations ?? smoothStations(generalStations(chain), closed, magnitude);
1274
+ const frames = st.map((s) => {
1275
+ const u = s.n1, v = cross(s.t, u);
1276
+ return { u, v, p2: (w) => [dot(w, u), dot(w, v)], to3: ([x, y]) => add(s.p, add(scl(u, x), scl(v, y))) };
1277
+ });
1278
+ const sols = st.map((s, i) => {
1279
+ const { p2 } = frames[i], n1 = p2(s.n1), n2 = p2(s.n2);
1280
+ const flat = curvedBall({ n1, n2, k1: 0, k2: 0, magnitude, convex }); // for the in-face directions
1281
+ if (!flat) throw new UnsupportedEdgeError(`general chain: no rolling-ball section for ${mode} ${magnitude}`);
1282
+ if (mode === "chamfer") return chamferSection(rays, flat, frames[i], s, magnitude);
1283
+ const f3 = (f) => add(scl(frames[i].u, f[0]), scl(frames[i].v, f[1]));
1284
+ const k1 = flankCurvature(rays, s.p, s.n1, f3(flat.F1.f), magnitude);
1285
+ const k2 = flankCurvature(rays, s.p, s.n2, f3(flat.F2.f), magnitude);
1286
+ const sol = k1 || k2 ? curvedBall({ n1, n2, k1, k2, magnitude, convex }) : flat;
1287
+ return refineBall(rays, sol, frames[i], s.t, magnitude, convex);
1288
+ });
1289
+ let maxSpan = 0;
1290
+ for (let i = 0; i < st.length; i++) maxSpan = Math.max(maxSpan, Math.abs(sols[i].phi));
1291
+ // a chamfer's "arc" is its straight chord: contact to contact, no interior points
1292
+ const nArc = mode === "chamfer" ? 1 : Math.max(2, Math.ceil((maxSpan / (2 * Math.PI)) * pSegs));
1293
+ const m = st.length;
1294
+ // snap every contact onto the faceted wall and keep its facet normal
1295
+ const snaps = sols.map((sol, i) => {
1296
+ const { to3 } = frames[i], { N } = sol;
1297
+ return [sol.T1, sol.T2].map((Tk, kk) => {
1298
+ const n3 = norm(sub(to3(N[kk]), to3([0, 0])));
1299
+ const h = rays ? contactHit(rays, to3(Tk), n3, st[i].t, CONTACT_D * magnitude) : null;
1300
+ return { T: h ? frames[i].p2(sub(h.p, st[i].p)) : Tk, H: h ? h.p : to3(Tk), fn: h ? h.fn : n3, n3, hit: !!h };
1301
+ });
1302
+ });
1303
+ // Sections end ON the wall (contactPolygon): pass 1 lays out every station's
1304
+ // polygon and probes the wall under its contact (snapped, depth 0) and under the arc
1305
+ // points next to it, out to where the arc has left the wall by CONTACT_STOP; pass 2
1306
+ // gives each probed point its tool-side margin — the floor CONTACT_MU, plus L·fold/4
1307
+ // for the chord to the same point of each neighbouring station and to its neighbours
1308
+ // in the section, which leaves the wall by up to that much when the two sit on facets
1309
+ // turned `fold` apart. Only a fold TOWARD the tool side (a concave wall under a
1310
+ // cutter, a convex one under a filler) lifts a chord out of it; the other way the
1311
+ // chord sinks deeper on its own, and a margin there only opens a visible step
1312
+ // (measured on the coarse cross hole's tube: 12% of doubled line, and coarse slantCut
1313
+ // 3 points further from OCCT). The margin is capped at CONTACT_MU_MAX, each point is
1314
+ // pushed along the contact normal until it is that deep, and when the last probed
1315
+ // point still needed a push the walk continues inward at its margin.
1316
+ const sgn = convex ? 1 : -1;
1317
+ const lay = sols.map((sol, i) => {
1318
+ const { to3 } = frames[i];
1319
+ const { pts, arcStart } = contactPolygon(sol, magnitude, nArc, st[i].tilt, snaps[i].map((sn) => sn.T));
1320
+ const probes = [0, 1].map((kk) => {
1321
+ const { n3, H, fn, hit } = snaps[i][kk];
1322
+ const list = [{ idx: kk === 0 ? arcStart : arcStart + nArc, H, fn, depth: 0 }];
1323
+ if (!rays || !hit) return list;
1324
+ for (let j = 1; j < nArc / 2; j++) {
1325
+ const idx = kk === 0 ? arcStart + j : arcStart + nArc - j;
1326
+ const X = to3(pts[idx]), h = wallHit(rays, X, n3, 0.05 * magnitude);
1327
+ if (!h) break;
1328
+ const depth = -sgn * dot(sub(X, h.p), n3);
1329
+ list.push({ idx, H: h.p, fn: h.fn, depth });
1330
+ if (depth >= CONTACT_STOP * magnitude) break;
1331
+ }
1332
+ return list;
1333
+ });
1334
+ return { pts, probes };
1335
+ });
1336
+ const rings = [], centres = [];
1337
+ for (let i = 0; i < st.length; i++) {
1338
+ const { to3 } = frames[i], sol = sols[i], { pts, probes } = lay[i];
1339
+ for (let kk = 0; kk < 2; kk++) {
1340
+ const Nk = sol.N[kk], { n3 } = snaps[i][kk];
1341
+ let need = 0;
1342
+ probes[kk].forEach((q, j) => {
1343
+ let mu = CONTACT_MU * magnitude;
1344
+ const rise = (o) => {
1345
+ // only a wall folding TOWARD the tool side lifts the chord out of it
1346
+ if (!o || sgn * (dot(q.fn, sub(o.H, q.H)) + dot(o.fn, sub(q.H, o.H))) <= 0) return;
1347
+ mu = Math.max(mu, CONTACT_MU * magnitude + (len(sub(q.H, o.H)) * Math.acos(clamp1(dot(q.fn, o.fn)))) / 4);
1348
+ };
1349
+ for (const nb of [i - 1, i + 1]) if (closed || (nb >= 0 && nb < m)) rise(lay[(nb + m) % m].probes[kk][j]);
1350
+ rise(probes[kk][j - 1]); rise(probes[kk][j + 1]);
1351
+ mu = Math.min(mu, CONTACT_MU_MAX * magnitude);
1352
+ need = mu;
1353
+ if (q.depth < mu) { const X = pts[q.idx], dd = mu - q.depth; pts[q.idx] = [X[0] - sgn * dd * Nk[0], X[1] - sgn * dd * Nk[1]]; }
1354
+ });
1355
+ // the walk stopped at CONTACT_STOP, but the last point's margin can ask for
1356
+ // more (coarse facets): keep walking inward at that margin until the arc is
1357
+ // deep enough on its own
1358
+ const last = probes[kk][probes[kk].length - 1];
1359
+ if (rays && snaps[i][kk].hit && last.depth < need) {
1360
+ for (let j = probes[kk].length; j < nArc / 2; j++) {
1361
+ const idx = kk === 0 ? FLANK_SAMPLES + 2 + j : FLANK_SAMPLES + 2 + nArc - j;
1362
+ const X = to3(pts[idx]), h = wallHit(rays, X, n3, 0.05 * magnitude);
1363
+ if (!h) break;
1364
+ const depth = -sgn * dot(sub(X, h.p), n3);
1365
+ if (depth >= need) break;
1366
+ const dd = need - depth;
1367
+ pts[idx] = [pts[idx][0] - sgn * dd * Nk[0], pts[idx][1] - sgn * dd * Nk[1]];
1368
+ }
1369
+ }
1370
+ }
1371
+ if (mode === "fillet") centres.push(to3(sol.C));
1372
+ rings.push(pts.map(to3));
1373
+ }
1374
+ for (let i = 0; i < st.length; i++) {
1375
+ const s = st[i];
1376
+ if (!s.interior) continue;
1377
+ const O = circumcentre(s.prev, s.p, s.next);
1378
+ if (!O) continue;
1379
+ const rho = len(sub(O, s.p)), beta = norm(sub(O, s.p));
1380
+ let reach = 0;
1381
+ for (const q of rings[i]) reach = Math.max(reach, dot(sub(q, s.p), beta));
1382
+ if (reach > 0.9 * rho)
1383
+ throw new UnsupportedEdgeError(`general chain: bend too tight for ${mode} ${magnitude} (local radius ${rho.toFixed(2)} mm)`);
1384
+ }
1385
+ if (!closed && convex) {
1386
+ // convex cutters overshoot open ends (prismTool's rule); concave fillers end flush
1387
+ const over = Math.max(1e-3, 0.05 * magnitude);
1388
+ const t0 = st[0].t, tN = st[m - 1].t;
1389
+ rings.unshift(rings[0].map((q) => sub(q, scl(t0, over))));
1390
+ rings.push(rings[rings.length - 1].map((q) => add(q, scl(tN, over))));
1391
+ }
1392
+ const tool = k._ringStackSolid(rings, { closed });
1393
+ return mode === "fillet" ? markBlend(k, tool, { kind: "spine", pts: refineSpine(centres, closed), closed, r: magnitude }) : tool;
1394
+ }
1395
+
1396
+ // The shading spine is nearest-point-on-polyline, so a coarse polyline tilts the
1397
+ // normal by up to half a turn angle on the concave side of a bend (2.4° on a tee at
1398
+ // ~3.5° per station). One interpolating 4-point subdivision pass (Dyn) quarters that;
1399
+ // a vertex turning past SPINE_SMOOTH_COS, or a missing neighbour (open ends), keeps
1400
+ // the plain midpoint so a real corner is never rounded or overshot.
1401
+ const SPINE_SMOOTH_COS = Math.cos((25 * Math.PI) / 180);
1402
+ function refineSpine(pts, closed) {
1403
+ const n = pts.length;
1404
+ if (n < 3) return pts;
1405
+ const at = (i) => (closed ? pts[((i % n) + n) % n] : pts[i]);
1406
+ const turnOk = (i) => {
1407
+ const a = at(i - 1), b = at(i), c = at(i + 1);
1408
+ if (!a || !b || !c) return false;
1409
+ return dot(norm(sub(b, a)), norm(sub(c, b))) > SPINE_SMOOTH_COS;
1410
+ };
1411
+ const out = [];
1412
+ const nSeg = closed ? n : n - 1;
1413
+ for (let i = 0; i < nSeg; i++) {
1414
+ out.push(pts[i]);
1415
+ const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
1416
+ let mid = null;
1417
+ if (p0 && p3 && turnOk(i) && turnOk(i + 1)) {
1418
+ // the cubic through the four points at their chord-length parameters, at the
1419
+ // middle of p1→p2 — Dyn's (−1, 9, 9, −1)/16 when the spacing is even, and no
1420
+ // overshoot when a station pair sits much closer than its neighbours
1421
+ const t = [-len(sub(p1, p0)), 0, len(sub(p2, p1))];
1422
+ t.push(t[2] + len(sub(p3, p2)));
1423
+ const x = t[2] / 2, P = [p0, p1, p2, p3];
1424
+ const w = t.map((tj, j) => t.reduce((acc, tm, q) => (q === j ? acc : (acc * (x - tm)) / (tj - tm)), 1));
1425
+ if (w.every(Number.isFinite)) mid = [0, 1, 2].map((a) => P.reduce((acc, pj, j) => acc + w[j] * pj[a], 0));
1426
+ }
1427
+ out.push(mid ?? [0, 1, 2].map((a) => (p1[a] + p2[a]) / 2));
1428
+ }
1429
+ if (!closed) out.push(pts[n - 1]);
1430
+ return out;
1431
+ }
1432
+
685
1433
  // `segs` is the KERNEL quality — it sizes the flank-facet guards (sag/ext) and the
686
1434
  // closed-revolve dephase, which are about matching the neighboring tessellation and
687
1435
  // must not follow the blend cap. `pSegs` is the sagitta-bounded density for the blend
@@ -941,6 +1689,19 @@ function planarSpine(path, closed, faceN, wallN, magnitude, convex) {
941
1689
  return { kind: "path", pts: path, closed, dirs, f: faceN, kf: K * (1 + cf), kd: K * len(wIn) };
942
1690
  }
943
1691
 
1692
+ // planarTool's fold guard at vertex i (between segments iIn and i), shared with
1693
+ // planarizeArc so a rim is only handed to the sweep when the sweep can follow it in one
1694
+ // piece. See the reach discussion inside planarTool for the two bounds.
1695
+ function sweepFoldsAt(segDir, segLen, wallNs, i, iIn, nSeg, magnitude) {
1696
+ const c = clamp1(dot(segDir[iIn], segDir[i]));
1697
+ const turn = Math.acos(c);
1698
+ // inside-of-bend direction ≈ change of travel; past the wall ⇒ reflex bend
1699
+ const bendIn = norm(sub(segDir[i], segDir[iIn]));
1700
+ const reflexBend = dot(bendIn, wallNs[iIn]) + dot(bendIn, wallNs[i % nSeg]) > 0;
1701
+ const r = reflexBend ? 0.1 * magnitude : 1.1 * magnitude;
1702
+ return c < -1 + 1e-6 || r * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
1703
+ }
1704
+
944
1705
  function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null, flankAt = () => segs) {
945
1706
  const { points, closed, convex, faceN } = chain;
946
1707
  let { wallNs } = chain;
@@ -997,19 +1758,11 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
997
1758
  // past the wall, where the profile reaches only the corner delta — the
998
1759
  // symmetric bound there shattered concave arcs of the same radii (the
999
1760
  // roundAll fast path's reflex arcs exactly).
1000
- const reach = magnitude * 1.1;
1001
- const reachWall = 0.1 * magnitude;
1002
1761
  const breaks = [];
1003
1762
  for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
1004
1763
  const iIn = (i - 1 + nSeg) % nSeg;
1005
- const c = clamp1(dot(segDir[iIn], segDir[i]));
1006
- const turn = Math.acos(c);
1007
- // inside-of-bend direction ≈ change of travel; past the wall ⇒ reflex bend
1008
- const bendIn = norm(sub(segDir[i], segDir[iIn]));
1009
- const reflexBend = dot(bendIn, wallNs[iIn]) + dot(bendIn, wallNs[i % nSeg]) > 0;
1010
- const r = reflexBend ? reachWall : reach;
1011
- const fold = c < -1 + 1e-6 || r * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
1012
- const sharp = turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
1764
+ const fold = sweepFoldsAt(segDir, segLen, wallNs, i, iIn, nSeg, magnitude);
1765
+ const sharp = Math.acos(clamp1(dot(segDir[iIn], segDir[i]))) > (SMOOTH_MAX_DEG * Math.PI) / 180;
1013
1766
  if (fold || sharp) breaks.push(i);
1014
1767
  }
1015
1768
  // Corner arcs per break vertex, with each side's setback budget measured along the
@@ -1433,7 +2186,7 @@ function cornerBlendBetween(E1, E2, magnitude) {
1433
2186
  // each other, which roundSalientCorners never handled because it skips arc chains —
1434
2187
  // stops existing as a category. Selection still runs on the ARC form (near-selectors
1435
2188
  // match the fitted circle, not its chords); conversion happens after, in apply().
1436
- function planarizeArc(ch) {
2189
+ function planarizeArc(ch, magnitude) {
1437
2190
  if (ch.kind !== "arc") return null;
1438
2191
  // face flank = the rotating-frame flank that is axial (±w, world-constant); ~3° bar
1439
2192
  const pick = Math.abs(ch.n1[0]) <= 0.05 ? 0 : Math.abs(ch.n2[0]) <= 0.05 ? 1 : -1;
@@ -1447,6 +2200,29 @@ function planarizeArc(ch) {
1447
2200
  const rho = norm(sub(q, scl(ch.w, dot(q, ch.w))));
1448
2201
  wallNs.push(norm(add(scl(rho, wall[0]), scl(ch.w, wall[1]))));
1449
2202
  }
2203
+ // A full-circle rim the sweep would have to split (its fold guard firing — a fillet
2204
+ // radius approaching the rim's own, r ≳ 0.82·R on a kernel circle) stays an arc:
2205
+ // split at every vertex, the per-facet tools' coincident mitre caps leave a
2206
+ // zero-thickness sheet per facet inside the band (a dashed ring in the line overlay,
2207
+ // carried into exports), and near r ≈ R the result is no longer a valid solid. The
2208
+ // revolve follows the circle in one piece, and refuses honestly once the profile
2209
+ // reaches the axis. One folding vertex is enough: the arc classifier accepts chords
2210
+ // only ~10% uneven (a 64-gon of R = 10 with one vertex moved 0.1 step stays an arc,
2211
+ // 0.2 does not), so a single fold means r is within ~10% of folding everywhere, and
2212
+ // there the revolve is the better tool — that 64-gon at r = 7.5 folds at one vertex:
2213
+ // the revolve leaves a clean band 0.5% under Pappus, the sweep split there drew a
2214
+ // 19 mm seam across the band and cut 1.7% over (test/mesh-fillet.test.js). OPEN arcs
2215
+ // keep the sweep regardless: a small corner round inside an outline is meant to
2216
+ // collapse into a virtual corner there (collapseTightCorners).
2217
+ if (magnitude != null && ch.closed) {
2218
+ const ring = pts.slice(0, -1), n = ring.length, segDir = [], segLen = [];
2219
+ for (let i = 0; i < n; i++) {
2220
+ const d = sub(ring[(i + 1) % n], ring[i]), l = len(d);
2221
+ segDir.push(scl(d, 1 / (l || 1)));
2222
+ segLen.push(l);
2223
+ }
2224
+ for (let i = 0; i < n; i++) if (sweepFoldsAt(segDir, segLen, wallNs, i, (i - 1 + n) % n, n, magnitude)) return null;
2225
+ }
1450
2226
  return { kind: "planar", points: pts.map((p) => [p[0], p[1], p[2]]), closed: ch.closed,
1451
2227
  convex: ch.convex, w: faceN, faceN, wallNs };
1452
2228
  }
@@ -1601,6 +2377,64 @@ function cornerPatches(k, selected, r, segs, flankAt = () => segs) {
1601
2377
  return patches;
1602
2378
  }
1603
2379
 
2380
+ // Curved-wall rims. A closed planar chain's sweep (planarTool) models the wall as
2381
+ // STRAIGHT in the section plane — exact for an extrusion wall, but a sphere or any
2382
+ // curved revolve/loft profile falls away from that tangent line, and the band then
2383
+ // grazes the faceted wall near its contact exactly as the first general tool did:
2384
+ // the dome's base rim at r = 1 drew 142.7 mm of sphere-side line against a 125.5 mm
2385
+ // ring (88 segments across it), shaded up to 15.7° off, and left 25 shells at r = 2.
2386
+ // Such a rim is handed to the general path, whose sections measure the wall at the
2387
+ // contact and cut it cleanly. The test: at each member, probe the wall at
2388
+ // CURVED_WALL_PROBES × r from the edge along the section's in-face direction (a hit
2389
+ // counts only within 10% of the probe depth, so a short wall's next face or another
2390
+ // feature is ignored) and take the largest turn between the first hit facet and a
2391
+ // later one; the rim is curved when the median over members reaches CURVED_WALL_TURN.
2392
+ // Measured over the fillet census (217 sub-parts, r = 0.5) and the test suite:
2393
+ // straight walls (extrusions, cylinders, cones, offset text) read ≤ 1.4e-3°, curved
2394
+ // ones ≥ 0.126° (a nut), the dome 0.74° at r = 1; 0.02° sits ~14× above the first and
2395
+ // ~6× below the second. A wall that is curved but faceted coarser than the probe
2396
+ // reach (a 32-row sphere: one flat facet row taller than 1.5·r) reads 0 and keeps the
2397
+ // sweep, which is exact on that facet. The budget charged this rim as the planar run
2398
+ // it was (blendWork ran first), and a rim the general tool refuses (its fold guard,
2399
+ // on rims the sweep handles: two census parts) is swept as before, so no selection
2400
+ // reroutes that did not.
2401
+ const CURVED_WALL_TURN = (0.02 * Math.PI) / 180;
2402
+ const CURVED_WALL_PROBES = [0.1, 0.5, 1, 1.5];
2403
+ function curvedWallRim(rays, ch, magnitude) {
2404
+ if (!rays) return null;
2405
+ const pts = ch.points, n = pts.length;
2406
+ const ring = len(sub(pts[n - 1], pts[0])) < 1e-9 ? pts.slice(0, -1) : pts.slice();
2407
+ if (ring.length < 3 || ch.wallNs.length !== ring.length) return null;
2408
+ const perp = (v, t) => norm(sub(v, scl(t, dot(v, t))));
2409
+ const turns = [];
2410
+ const flanks = [];
2411
+ for (let i = 0; i < ring.length; i++) {
2412
+ const a = ring[i], b = ring[(i + 1) % ring.length], t = norm(sub(b, a)), p = scl(add(a, b), 0.5);
2413
+ const n1 = perp(ch.faceN, t), n2 = perp(ch.wallNs[i], t), v = cross(t, n1);
2414
+ flanks.push([ch.faceN, ch.wallNs[i]]);
2415
+ // a knife-edge member (a sliver's flipped facet — the planar path skips those)
2416
+ // has no wedge to measure: such a rim stays with the sweep
2417
+ if (dot(n1, n2) < -1 + 1e-6) return null;
2418
+ const flat = curvedBall({ n1: [1, 0], n2: [dot(n2, n1), dot(n2, v)], k1: 0, k2: 0, magnitude, convex: ch.convex });
2419
+ if (!flat) return null;
2420
+ const f3 = add(scl(n1, flat.F2.f[0]), scl(v, flat.F2.f[1]));
2421
+ let worst = 0, base = null;
2422
+ for (const sf of CURVED_WALL_PROBES) {
2423
+ const S = add(p, scl(f3, sf * magnitude)), rho = sf * magnitude;
2424
+ const h = rays.nearest(add(S, scl(n2, rho)), scl(n2, -1), 2 * rho, (fn) => dot(fn, n2) >= FLANK_FACING_COS);
2425
+ if (!h || Math.abs(h.t - rho) > 0.1 * rho) continue;
2426
+ if (!base) base = h.n; else worst = Math.max(worst, Math.acos(clamp1(dot(h.n, base))));
2427
+ }
2428
+ turns.push(worst);
2429
+ }
2430
+ const med = [...turns].sort((x, y) => x - y)[turns.length >> 1];
2431
+ if (!(med >= CURVED_WALL_TURN)) return null;
2432
+ const chain = { kind: "general", points: ring, closed: true, convex: ch.convex, flanks, rim: ch };
2433
+ const stations = smoothStations(generalStations(chain), true, magnitude);
2434
+ if (stations.length > MAX_GENERAL_STATIONS) return null;
2435
+ return { chain, stations };
2436
+ }
2437
+
1604
2438
  // ---------------------------------------------------------------------------
1605
2439
  // Entry points.
1606
2440
  // meshFillet(k, solid, { r, edges?, segs?, sharpDeg? }) → Solid
@@ -1608,18 +2442,11 @@ function cornerPatches(k, selected, r, segs, flankAt = () => segs) {
1608
2442
  export function meshFillet(k, solid, opts) { return apply(k, solid, "fillet", opts?.r, opts); }
1609
2443
  export function meshChamfer(k, solid, opts) { return apply(k, solid, "chamfer", opts?.d, opts); }
1610
2444
 
1611
- // `segs` is the kernel's per-circle CAP: it bounds the blend densities (blendSegs)
1612
- // and is what every circle was built at on a flat tier. `segsAt(r)` is what a circle
1613
- // of radius r was ACTUALLY built at — the print tier sizes circles by chord tolerance
1614
- // (circle-segs.js), so a flank's facet pitch is no longer the cap. The three places
1615
- // that reason about the neighbouring tessellation (revolveTool's seam-grazing sag and
1616
- // closed-revolve dephase, cornerHornTool's sphere burial) ask it; everything sized
1617
- // from the blend's own sagitta bound keeps the cap. Absent, it is the cap — the
1618
- // pre-print-rule behaviour, and byte-identical at preview either way.
1619
- function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg = 20, segsAt = null } = {}) {
1620
- if (!(magnitude > 0)) throw new Error(`mesh ${mode}: magnitude must be > 0`);
1621
- const flankAt = segsAt ?? (() => segs);
1622
- const chains = chainEdges(detectSharpEdges(solid.toIndexedMesh(), { sharpDeg }));
2445
+ // Chain classification and corner planning shared by apply() and meshFilletWork():
2446
+ // everything up to (not including) building a single tool.
2447
+ function planChains(solid, mode, magnitude, edges, sharpDeg) {
2448
+ const mesh = solid.toIndexedMesh();
2449
+ const chains = chainEdges(detectSharpEdges(mesh, { sharpDeg }));
1623
2450
  const selected = chains.filter((ch) => matchesSelector(ch, edges));
1624
2451
  if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
1625
2452
  const unsupported = selected.find((ch) => ch.kind === "unsupported");
@@ -1627,7 +2454,7 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1627
2454
  // Face-plane arc rims sweep their own polyline (see planarizeArc); re-stitch so a
1628
2455
  // converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
1629
2456
  // these chains were planar, so their junctions are still open here.
1630
- const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch), { absorbLines: true });
2457
+ const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch, magnitude) ?? ch), { absorbLines: true });
1631
2458
  // Ends of selected chains, keyed by vertex — planarTool steers a SHARP break
1632
2459
  // corner only when another selected chain leaves that vertex out of the face
1633
2460
  // plane (a vertical edge being blended too, the roundAll/trihedral case where
@@ -1657,13 +2484,127 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1657
2484
  : ch.n1 && ch.n2 && dot(ch.n1, ch.n2) < -1 + 1e-6;
1658
2485
  effective = effective.filter((ch) => !knife(ch));
1659
2486
  arcs = arcs.filter((ch) => !knife(ch));
2487
+ // every general chain's smoothed stations, computed once: blendWork charges them,
2488
+ // the station cap reads them, and generalTool builds its rings from them
2489
+ const stations = new Map();
2490
+ for (const ch of effective)
2491
+ if (ch.kind === "general") stations.set(ch, smoothStations(generalStations(ch), ch.closed, magnitude));
2492
+ return { mesh, endTins, effective, arcs, horns, pivots, stations };
2493
+ }
2494
+
2495
+ // Work budget (Task 4b, 2026-10-02). A general chain admits sub-parts that used to
2496
+ // reroute on it — and those are disproportionately thread, knurl and texture
2497
+ // geometry, with thousands of OTHER sharp edges behind the general ones. Measured
2498
+ // over the fillet census (71 public forges + 56 eval cases, r = 0.5, preview
2499
+ // quality, one process per sub-part), the time is the booleans of the whole tool
2500
+ // set, not the general tools: a chain-mail sheet spent 10 s, of which its 27
2501
+ // general tools were 1.8 s and its 99 arc tools ~7 s; the larger sheets ran
2502
+ // 60–130 s at 1.5–4 GB and four of them exhausted the WASM heap. So the estimate
2503
+ // counts every tool, weighted by its measured relative cost (least-squares fit of
2504
+ // the per-kind counts against fillet time, rounded: line 1, arc rim 5, planar run
2505
+ // 11, corner horn / pivot 4, corner arc 6, general 0.1 per chain vertex), times
2506
+ // √(mesh triangles) (each boolean's cost grows with the body it cuts). Its fit to
2507
+ // fillet time is ~1 work unit ≈ 0.035 ms.
2508
+ // GENERAL_WORK_BUDGET = 195 000: every census sub-part with a general chain that
2509
+ // filleted OK in ≤ 5 s sits at or below 189 858 (the highest: e106d859 dibber,
2510
+ // 4.9 s); the lowest one above sits at 202 231 (ffacb618 chain-mail cells,
2511
+ // 10.4 s). The general-chain fixtures sit far below (see the test file).
2512
+ // Only a selection CONTAINING a general chain is budgeted: before general chains
2513
+ // existed every such selection rerouted, so refusing one is never a regression,
2514
+ // while a selection of ordinary chains keeps its old behaviour whatever it costs.
2515
+ // The count is deterministic — never wall-clock — so a build's result, and the
2516
+ // solid cache keyed on it, cannot depend on how loaded the machine was. It does
2517
+ // scale with √(mesh triangles), and print tessellates finer than preview, so a part
2518
+ // just under the budget at preview can reroute at print: the same reroute class, with
2519
+ // OCCT's result correct, only slower.
2520
+ // A general chain is charged by its vertices, or by its smoothed station (ring)
2521
+ // count / STATION_ALLOWANCE when that is larger. The 0.1-per-vertex weight was fitted
2522
+ // on census chains carrying at most 11.4 stations per vertex (a knob; most carry 1–4),
2523
+ // so with the allowance at 16 the fitted charge already covers their rings and every
2524
+ // fixture and census work number is unchanged; a sliver-dominated path whose long
2525
+ // members take hundreds of interior stations each (generalStations) is charged for
2526
+ // its rings instead. Separately, a chain over MAX_GENERAL_STATIONS rings refuses
2527
+ // outright (`too many sections`): 16 384 is 4.5× the longest census chain (3 609
2528
+ // stations, a cap rim at print, which fillets OK), and bounds one chain's ring stack
2529
+ // at a few hundred thousand vertices.
2530
+ export const GENERAL_WORK_BUDGET = 195000;
2531
+ export const MAX_GENERAL_STATIONS = 16384;
2532
+ const STATION_ALLOWANCE = 16;
2533
+ const WORK_WEIGHT = { line: 1, arc: 5, planar: 11, corner: 4, cornerArc: 6, generalPoint: 0.1 };
2534
+ function blendWork({ mesh, effective, arcs, horns, pivots, stations }) {
2535
+ let units = WORK_WEIGHT.corner * (horns.length + pivots.length) + WORK_WEIGHT.cornerArc * arcs.length;
2536
+ let general = false, maxStations = 0;
2537
+ for (const ch of effective) {
2538
+ if (ch.kind === "general") {
2539
+ general = true;
2540
+ const m = stations.get(ch).length;
2541
+ maxStations = Math.max(maxStations, m);
2542
+ units += WORK_WEIGHT.generalPoint * Math.max(ch.points.length, m / STATION_ALLOWANCE);
2543
+ } else units += WORK_WEIGHT[ch.kind] ?? WORK_WEIGHT.line;
2544
+ }
2545
+ return { work: units * Math.sqrt(mesh.indices.length / 3), general, maxStations };
2546
+ }
2547
+
2548
+ // The budget estimate for a fillet/chamfer, without building anything: `work` is
2549
+ // what apply() compares against GENERAL_WORK_BUDGET when `general` is true. Throws
2550
+ // the same UnsupportedEdgeError apply would for an empty or unsupported selection.
2551
+ // meshFilletWork(solid, { mode?, magnitude, edges?, sharpDeg? })
2552
+ // → { work, general, budget, maxStations, stationCap }
2553
+ export function meshFilletWork(solid, { mode = "fillet", magnitude, edges, sharpDeg = 20 } = {}) {
2554
+ return { ...blendWork(planChains(solid, mode, magnitude, edges, sharpDeg)), budget: GENERAL_WORK_BUDGET,
2555
+ stationCap: MAX_GENERAL_STATIONS };
2556
+ }
2557
+
2558
+ // `segs` is the kernel's per-circle CAP: it bounds the blend densities (blendSegs)
2559
+ // and is what every circle was built at on a flat tier. `segsAt(r)` is what a circle
2560
+ // of radius r was ACTUALLY built at — the print tier sizes circles by chord tolerance
2561
+ // (circle-segs.js), so a flank's facet pitch is no longer the cap. The three places
2562
+ // that reason about the neighbouring tessellation (revolveTool's seam-grazing sag and
2563
+ // closed-revolve dephase, cornerHornTool's sphere burial) ask it; everything sized
2564
+ // from the blend's own sagitta bound keeps the cap. Absent, it is the cap — the
2565
+ // pre-print-rule behaviour, and byte-identical at preview either way.
2566
+ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg = 20, segsAt = null } = {}) {
2567
+ if (!(magnitude > 0)) throw new Error(`mesh ${mode}: magnitude must be > 0`);
2568
+ const flankAt = segsAt ?? (() => segs);
2569
+ const plan = planChains(solid, mode, magnitude, edges, sharpDeg);
2570
+ const { mesh, endTins, effective, arcs, horns, pivots, stations } = plan;
2571
+ const { work, general, maxStations } = blendWork(plan);
2572
+ if (maxStations > MAX_GENERAL_STATIONS)
2573
+ throw new UnsupportedEdgeError(`general chain: too many sections for the mesh ${mode} (${maxStations} stations > ${MAX_GENERAL_STATIONS})`);
2574
+ if (general && work > GENERAL_WORK_BUDGET)
2575
+ throw new UnsupportedEdgeError(`general chain: too complex for the mesh ${mode} (work ${Math.round(work)} > budget ${GENERAL_WORK_BUDGET})`);
1660
2576
  const pSegs = blendSegs(segs, magnitude);
2577
+ // general sections probe the walls on the mesh: a fillet's flank curvature and
2578
+ // contacts, a chamfer's setbacks
2579
+ const rims = effective.filter((ch) => ch.kind === "planar" && ch.closed);
2580
+ const generals = effective.filter((ch) => ch.kind === "general");
2581
+ const rays = generals.length || rims.length ? localRays(mesh, [...generals, ...rims].map((ch) => {
2582
+ const b = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity], pad = 3 * magnitude;
2583
+ for (const p of ch.points) for (let a = 0; a < 3; a++) { b[a] = Math.min(b[a], p[a] - pad); b[a + 3] = Math.max(b[a + 3], p[a] + pad); }
2584
+ return b;
2585
+ }), 2 * magnitude) : null;
2586
+ for (const ch of rims) {
2587
+ const g = curvedWallRim(rays, ch, magnitude);
2588
+ if (!g) continue;
2589
+ effective[effective.indexOf(ch)] = g.chain;
2590
+ stations.set(g.chain, g.stations);
2591
+ }
2592
+ // a curved-wall rim the general tool refuses is swept as before (curvedWallRim)
2593
+ const rimTools = (ch) => {
2594
+ try { return [generalTool(k, ch, magnitude, mode, pSegs, rays, stations.get(ch))]; }
2595
+ catch (e) {
2596
+ if (!(e instanceof UnsupportedEdgeError)) throw e;
2597
+ return planarTool(k, ch.rim, magnitude, mode, segs, pSegs, endTins, flankAt);
2598
+ }
2599
+ };
1661
2600
  const toolsFor = (ch) =>
1662
2601
  ch.kind === "planar"
1663
2602
  ? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins, flankAt)
1664
2603
  : ch.kind === "arc"
1665
2604
  ? [revolveTool(k, ch, magnitude, mode, segs, pSegs, flankAt)]
1666
- : [prismTool(k, ch, magnitude, mode, segs, pSegs)];
2605
+ : ch.kind === "general"
2606
+ ? (ch.rim ? rimTools(ch) : [generalTool(k, ch, magnitude, mode, pSegs, rays, stations.get(ch))])
2607
+ : [prismTool(k, ch, magnitude, mode, segs, pSegs)];
1667
2608
  const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
1668
2609
  cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs, flankAt)));
1669
2610
  cutters.push(...pivots.map((p) => reflexPivotTool(k, p, magnitude, mode, segs, pSegs)));