partforge 0.137.1 → 0.138.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/README.md +2 -1
- package/docs/AUTHORING-PARTS.md +12 -3
- package/docs/ERROR-PATTERNS.md +2 -2
- package/docs/KERNEL-CONTRACT.md +16 -7
- package/package.json +1 -1
- package/src/framework/geometry/blend-surfaces.js +16 -1
- package/src/framework/geometry/creased-normals.js +23 -1
- package/src/framework/geometry/kernel.js +2 -2
- package/src/framework/geometry/manifold-backend.js +11 -7
- package/src/framework/geometry/mesh-build.js +32 -0
- package/src/framework/geometry/mesh-fillet.js +971 -30
- package/src/framework/lint/rules-schema.js +27 -0
- package/src/framework/pose-probe.js +26 -4
- package/src/parts/screw.js +3 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
|
1006
|
-
const
|
|
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
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
:
|
|
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)));
|