fix(kernel): fillet mixed arc and line loop corners (#604)

Thanks @onenewcode.
This commit is contained in:
onenewcode authored and GitHub committed 2026-10-17 07:06:26 -04:00
1 parent 78c6364b02
commit 87d4eb55e5
4 files changed
+460 -10

No files matched your search

+3 -1
View File
@@ -330,7 +330,9 @@ completed drawings publish atomically. The browser still exports synchronously.
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
checked within 0.1% of analytic offset slices), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
with the edge between them too, the corner becomes a sphere octant),
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
+14 -1
View File
@@ -1773,7 +1773,20 @@ fn loop_split(cur: &Body, pts: &[Vec3]) -> Result<Option<(Vec<Vec<Vec3>>, Vec<Ve
let Some(e) = near.iter().find(|e| planar_edge.contains(&e.id())).or(near.first()) else { return Ok(None) };
per.push((*p, e.id()));
}
let ids: Vec<mt::EdgeID> = per.iter().map(|x| x.1).collect();
// Fillets follow tangent chains in the original body. Include those edges before
// recognising a whole loop, too: two picks across a sharp arc/line joint can cover
// the entire rim once each pick's smooth chain is included.
let ids = guard("loop tangent chains", || {
let mut ids: std::collections::HashSet<mt::EdgeID> = per.iter().map(|x| x.1).collect();
for (_, id) in &per {
if let Some(edge) = all.iter().find(|e| e.id() == *id)
&& let Some(chain) = smooth_chain(&solid, edge)
{
ids.extend(chain);
}
}
Ok(ids)
})?;
let mut covered: Vec<mt::EdgeID> = Vec::new();
let mut groups = Vec::new();
for f in solid.face_iter() {
+121 -8
View File
@@ -1,9 +1,10 @@
//! Blending a whole boundary loop of a planar face whose neighbours are walls standing
//! perpendicular to it (planes, and cylinders with axes along the face normal): a pocket floor,
//! the top outline of an extruded plate. The loop must be smooth (lines and arcs meeting
//! tangentially). The face shrinks by the radius, the walls are trimmed by it, and each loop edge
//! the top outline of an extruded plate. Lines and arcs can meet tangentially or at mitred sharp
//! corners. The face shrinks by the radius, the walls are trimmed by it, and each loop edge
//! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along
//! an arc, all meeting along the profile arcs at the joints. The result is exact.
//! an arc, all meeting along the profile curves at the joints. Sharp joints at perpendicular
//! curved walls follow the intersections of the exact offsets, fitted within tolerance.
use std::collections::HashMap;
@@ -56,6 +57,74 @@ impl Seg {
}
}
/// Intersection of two inward offsets nearest their shared corner, in the face plane.
fn offset_joint(prev: &Seg, cur: &Seg, n: Vec3, d: f64) -> Option<Vec3> {
let p = cur.start();
let line_circle = |line: &Seg, arc: &Seg| {
let Seg::Arc { a, c, .. } = *arc else { return None };
let tangent = line.tangent(p)?;
let origin = p + n.cross(tangent) * d;
let radius = a.dist(c) + n.cross(arc.tangent(p)?).dot((p - c).normalized()?) * d;
let foot = origin + tangent * (c - origin).dot(tangent);
let height2 = radius * radius - foot.dist(c).powi(2);
if !(radius > 0.0 && height2 >= 0.0) {
return None;
}
let (a, b) = (foot + tangent * height2.sqrt(), foot - tangent * height2.sqrt());
Some(if a.dist(p) < b.dist(p) { a } else { b })
};
match (prev, cur) {
(Seg::Line { .. }, Seg::Arc { .. }) => line_circle(prev, cur),
(Seg::Arc { .. }, Seg::Line { .. }) => line_circle(cur, prev),
(Seg::Arc { a: a0, c: c0, .. }, Seg::Arc { a: a1, c: c1, .. }) => {
let delta = *c1 - *c0;
let distance = delta.len();
let axis = delta.normalized()?;
let r0 = a0.dist(*c0) + n.cross(prev.tangent(p)?).dot((p - *c0).normalized()?) * d;
let r1 = a1.dist(*c1) + n.cross(cur.tangent(p)?).dot((p - *c1).normalized()?) * d;
let along = (r0 * r0 - r1 * r1 + distance * distance) / (2.0 * distance);
let height2 = r0 * r0 - along * along;
if !(r0 > 0.0 && r1 > 0.0 && height2 >= 0.0) {
return None;
}
let foot = *c0 + axis * along;
let across = n.cross(axis) * height2.sqrt();
let (a, b) = (foot + across, foot - across);
Some(if a.dist(p) < b.dist(p) { a } else { b })
}
_ => None,
}
}
/// A curved-wall joint is generally not planar. Fit its exact offset slices rather than
/// projecting a circular section onto a tangent mitre and revolving that distorted section.
fn offset_profile(prev: &Seg, cur: &Seg, n: Vec3, side: f64, r: f64, round: bool, tol: f64) -> Result<mt::Curve> {
use mt::ParametricCurve;
let point = |u: f64| {
let (d, h) = if round {
let (sin, cos) = (u * std::f64::consts::FRAC_PI_2).sin_cos();
(r * (1.0 - sin), r * (1.0 - cos))
} else {
(r * (1.0 - u), r * u)
};
offset_joint(prev, cur, n, d).map(|p| p + n * (side * h))
};
for count in [8, 16, 32, 64, 128] {
let pts = (0..=count)
.map(|i| point(f64::from(i) / f64::from(count)))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| too_large("the blend's offset walls do not meet"))?;
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| unsupported("curved corner profile"))?;
if (0..count).all(|i| {
let u = (f64::from(i) + 0.5) / f64::from(count);
point(u).is_some_and(|p| p.dist(from_p3(curve.subs(u))) <= tol)
}) {
return Ok(mt::Curve::BSplineCurve(curve));
}
}
Err(unsupported("could not fit the curved corner within tolerance"))
}
/// The exact shape of an oriented loop edge (a line, or an arc found from samples).
fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
use mt::{BoundedCurve, ParametricCurve};
@@ -158,10 +227,19 @@ fn face_normal(f: &mt::Face, p: Vec3) -> Option<Vec3> {
Vec3::new(n.x, n.y, n.z).normalized()
}
/// Blend the loop of `edges` (by id) if it is a whole smooth boundary loop of a planar face with
/// Blend `edges` (by id) if they form a boundary loop or neighbouring run of a planar face with
/// perpendicular walls, with what it built from `solid` (each blend face in place of its loop
/// edge). `None` when the edges are not such a loop.
pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
match crate::guard("loop blend", || Ok(loop_blend_inner(solid, edge_ids, size, r, round))) {
Ok(result) => result,
Err(error) => Some(Err(error)),
}
}
fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
#[cfg(test)]
assert!(crate::guarded(), "loop selection must run inside the kernel guard");
let tol = (size * 1e-6).max(1e-7);
for (si, shell) in solid.boundaries().iter().enumerate() {
let faces: Vec<&mt::Face> = shell.face_iter().collect();
@@ -204,6 +282,8 @@ fn build(
round: bool,
run: Option<(usize, usize)>,
) -> Result<(Solid, Rebuild)> {
#[cfg(test)]
assert!(crate::guarded(), "loop construction must run inside the kernel guard");
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
let all_edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
@@ -226,7 +306,8 @@ fn build(
// Joints: k round a whole loop; k + 1 along a run (its two ends included).
let nj = if open { k + 1 } else { k };
let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k };
// Joints: smooth, or sharp between two lines (the blends then meet in a mitre).
// Joints: smooth, or sharp where the walls meet in one straight seam (the blends
// then meet in a mitre). A loop can therefore mix lines and arcs at its corners.
let mut mitre = vec![false; nj];
for i in 0..k {
if open && i == 0 {
@@ -236,7 +317,7 @@ fn build(
let p = cur.start();
let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) };
if ta.dot(tb) < 1.0 - 1e-6 {
if !(matches!(prev, Seg::Line { .. }) && matches!(cur, Seg::Line { .. })) || ta.dot(tb) < -1.0 + 1e-3 {
if ta.dot(tb) < -1.0 + 1e-3 {
return Err(unsupported("blending a loop with sharp corners next to arcs"));
}
if let Some(x) = mitre.get_mut(i) {
@@ -340,6 +421,7 @@ fn build(
let mut pa = Vec::with_capacity(nj);
let mut pb = Vec::with_capacity(nj);
let mut centres = Vec::with_capacity(nj);
let mut curved_joint = vec![false; nj];
// Joint i: the start of piece i (the end of the last piece for a run's far end).
let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) };
for i in 0..nj {
@@ -360,7 +442,18 @@ fn build(
};
let x = d0.cross(d1);
let lam = ((m * sb - m0 * sb0).cross(d1)).dot(x) / x.len2();
pa.push(p + m0 * sb0 + d0 * lam);
let exact = (matches!(segs.get(ip), Some(Seg::Arc { .. })) || matches!(segs.get(i), Some(Seg::Arc { .. })))
&& t.dot(n * side) > 1.0 - 1e-9
&& t0.dot(n * side) > 1.0 - 1e-9;
if exact {
let (Some(prev), Some(cur)) = (segs.get(ip), segs.get(i)) else { return Err(unsupported("corner segments")) };
pa.push(offset_joint(prev, cur, n, r).ok_or_else(|| too_large("the blend's offset walls do not meet"))?);
if let Some(slot) = curved_joint.get_mut(i) {
*slot = true;
}
} else {
pa.push(p + m0 * sb0 + d0 * lam);
}
let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?;
let (h0, h1) = (e.dot(t0), e.dot(t));
if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 {
@@ -453,6 +546,12 @@ fn build(
let mut profiles: Vec<mt::Edge> = Vec::with_capacity(nj);
for i in 0..nj {
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) };
if curved_joint.get(i).copied().unwrap_or(false) {
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { return Err(unsupported("corner segments")) };
let curve = offset_profile(prev, cur, n, side, r, round, (r * 1e-7).max(1e-9))?;
profiles.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("curved corner: {e}")))?);
continue;
}
if !round {
profiles.push(builder::line(a, b));
continue;
@@ -471,6 +570,8 @@ fn build(
let cc = centre(i) + d1 * lam;
let q = p + t * sb + d1 * lam;
let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r;
// q projects onto the wall rail at pb_i; fitting through pb_i before projection
// would distort the circular cross-section at mixed line/arc corners.
let arc = builder::circle_arc(&builder::vertex(p3(pa_i)), &builder::vertex(p3(q)), p3(mid));
let nb = (pa_i - p).cross(pb_i - p).normalized().ok_or_else(|| unsupported("mitre plane"))?;
let den = d1.dot(nb);
@@ -692,7 +793,19 @@ fn build(
let meridian = builder::circle_arc(&top, &bottom, eq);
builder::rsweep(&meridian, p3(c), v3(w), mt::Rad(std::f64::consts::TAU))
}
Seg::Arc { c, axis, angle, .. } => builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)),
Seg::Arc { c, axis, angle, .. }
if !curved_joint.get(i).copied().unwrap_or(false) && !curved_joint.get(nxt(i)).copied().unwrap_or(false) =>
{
builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle))
}
Seg::Arc { a, c, axis, angle, .. } => {
// Sweep a radial section, not the shared corner curve: the latter can be
// nonplanar and would change the round's radius all along this edge.
let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
let (fa, wa) = (builder::vertex(p3(a + into(s, a) * sb)), builder::vertex(p3(a + t * sb)));
let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i))) } else { builder::line(&fa, &wa) };
builder::rsweep(&base, p3(c), v3(axis), mt::Rad(angle))
}
};
let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?;
// Outward: toward the profile centre on a concave loop, away from it on a convex one.
+322
View File
@@ -1939,6 +1939,328 @@ fn fillet_an_open_curved_edge() {
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
fn mixed_arc_and_line_prism() -> Body {
use solidcraft_geom::Seg2;
let d = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: PI },
Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) },
],
};
extrude(&Plane::XY, &[Region2 { outer: d, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap()
}
#[test]
fn fillet_smooth_arc_and_line_loop_keeps_exact_removal() {
use solidcraft_geom::Seg2;
// A stadium: four tangent joints, none of which should use a sharp-corner mitre.
let outline = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(-10.0, -10.0), b: Vec2::new(10.0, -10.0) },
Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: -PI / 2.0, sweep: PI },
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(-10.0, 10.0) },
Seg2::Arc { center: Vec2::new(-10.0, 0.0), radius: 10.0, start: PI / 2.0, sweep: PI },
],
};
let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
let blended =
fillet(&body, &[Vec3::new(0.0, -10.0, 15.0), Vec3::new(20.0, 0.0, 15.0), Vec3::new(0.0, 10.0, 15.0), Vec3::new(-20.0, 0.0, 15.0)], 0.5)
.unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
let r = 0.5;
let area = r * r * (1.0 - PI / 4.0);
let centroid = r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
let want = area * (40.0 + 2.0 * PI * (10.0 - centroid));
let fine = |b: &Body| b.tessellate_with(1e-4, true).unwrap().measure().volume;
let removed = fine(&body) - fine(&blended);
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
#[test]
fn fillet_sharp_arc_and_arc_loop() {
use solidcraft_geom::Seg2;
// The intersection of two radius-10 disks whose centres are 10 mm apart.
let outline = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::new(-5.0, 0.0), radius: 10.0, start: -PI / 3.0, sweep: 2.0 * PI / 3.0 },
Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 10.0, start: 2.0 * PI / 3.0, sweep: 2.0 * PI / 3.0 },
],
};
let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
let blended = fillet(&body, &[Vec3::new(5.0, 0.0, 15.0), Vec3::new(-5.0, 0.0, 15.0)], 0.5).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
let mesh = blended.tessellate_with(1e-4, true).unwrap();
for p in &mesh.positions {
assert!((p.x - 5.0).hypot(p.y) <= 10.0 + 1e-3 && (p.x + 5.0).hypot(p.y) <= 10.0 + 1e-3, "outside the lens: {p:?}");
}
let area = |rho: f64| 2.0 * rho * rho * (5.0 / rho).acos() - 10.0 * (rho * rho - 25.0).sqrt();
let dt = (PI / 2.0) / 8192.0;
let want: f64 = (0..8192)
.map(|i| {
let theta = (f64::from(i) + 0.5) * dt;
(area(10.0) - area(10.0 - 0.5 * (1.0 - theta.cos()))) * 0.5 * theta.cos() * dt
})
.sum();
let removed = body.tessellate_with(1e-4, true).unwrap().measure().volume - mesh.measure().volume;
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
#[test]
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
use solidcraft_geom::Seg2;
// Reconstruct the outer outline from its seven public pick coordinates:
// R25 plate, x=+/-4 tab sides, R2 corners and a 4 mm straight top.
// The hole's dimensions come from the public reproduction, not an original design file.
let theta = (4.0_f64 / 25.0).acos();
let y = (625.0_f64 - 16.0).sqrt();
let corner_y = 280.0 / 9.0;
let hole_y = 262.0 / 9.0;
let outline = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 25.0, start: PI - theta, sweep: PI + 2.0 * theta },
Seg2::Line { a: Vec2::new(4.0, y), b: Vec2::new(4.0, corner_y) },
Seg2::Arc { center: Vec2::new(2.0, corner_y), radius: 2.0, start: 0.0, sweep: PI / 2.0 },
Seg2::Line { a: Vec2::new(2.0, corner_y + 2.0), b: Vec2::new(-2.0, corner_y + 2.0) },
Seg2::Arc { center: Vec2::new(-2.0, corner_y), radius: 2.0, start: PI / 2.0, sweep: PI / 2.0 },
Seg2::Line { a: Vec2::new(-4.0, corner_y), b: Vec2::new(-4.0, y) },
],
};
let body =
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![Loop2::circle(Vec2::new(0.0, hole_y), 1.5)] }], 0.0, 2.0).unwrap().pop().unwrap();
let inner = fillet(&body, &[Vec3::new(1.5, hole_y, 2.0)], 0.5).unwrap();
let edges = [
Vec3::new(-24.91935125502191, -2.0064727829797544, 2.0),
Vec3::new(24.919351255429795, -2.006472777913911, 2.0),
Vec3::new(3.999999997492012, 27.894518234675537, 2.0),
Vec3::new(3.4142135598750105, 32.525324672801695, 2.0),
Vec3::new(-2.480541994032137e-9, 33.11111111046627, 2.0),
Vec3::new(-3.4142135648720653, 32.52532467287703, 2.0),
Vec3::new(-4.0000000025087745, 27.89451823429679, 2.0),
];
// All 127 nonempty subsets: the whole rim, each individual edge, and every
// contiguous or disjoint partial selection of the issue's seven picks.
for mask in 1_u32..(1 << edges.len()) {
let picks: Vec<_> = edges.iter().enumerate().filter(|(i, _)| mask & (1 << i) != 0).map(|(_, p)| *p).collect();
let blended = fillet(&inner, &picks, 0.5).unwrap_or_else(|e| panic!("{picks:?}: {e}"));
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
let mesh = blended.display_mesh(0.01).unwrap();
for p in &mesh.positions {
let inside = p.x.hypot(p.y) <= 25.001
|| (p.x.abs() <= 4.001 && p.y >= y - 0.001 && p.y <= corner_y)
|| (p.x.abs() <= 2.001 && p.y >= corner_y && p.y <= corner_y + 2.001)
|| (p.x - 2.0).hypot(p.y - corner_y) <= 2.001
|| (p.x + 2.0).hypot(p.y - corner_y) <= 2.001;
assert!(inside && p.z >= -0.001 && p.z <= 2.001, "outside the plate: {p:?}");
}
}
}
fn partial_mixed_run_prism() -> Body {
use solidcraft_geom::Seg2;
let outline = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(40.0, 0.0) },
Seg2::Line { a: Vec2::new(40.0, 0.0), b: Vec2::new(40.0, 20.0) },
Seg2::Line { a: Vec2::new(40.0, 20.0), b: Vec2::new(30.0, 20.0) },
Seg2::Arc { center: Vec2::new(20.0, 20.0), radius: 10.0, start: 0.0, sweep: PI },
Seg2::Line { a: Vec2::new(10.0, 20.0), b: Vec2::new(0.0, 20.0) },
Seg2::Line { a: Vec2::new(0.0, 20.0), b: Vec2::new(0.0, 0.0) },
],
};
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap()
}
#[test]
fn mixed_loop_blend_guards_selection_and_construction() {
let body = mixed_arc_and_line_prism();
let solid = body.deep_copy();
let top = solid
.boundaries()
.iter()
.flat_map(|shell| shell.face_iter())
.find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9))
.unwrap();
let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect();
// The internal test-only assertions check the active guard during both selection and build.
assert!(!crate::guarded());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, true)));
assert!(matches!(result, Ok(Some(Ok(_)))), "{result:?}");
assert!(!crate::guarded());
// A degenerate profile still returns a clean error and restores the guard depth.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.0, true)));
assert!(matches!(result, Ok(Some(Err(_)))), "{result:?}");
assert!(!crate::guarded());
}
#[test]
fn fillet_mixed_arc_and_line_perimeter_edges() {
let body = mixed_arc_and_line_prism();
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], 0.5).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
let display_tol = (blended.size() * 1e-3).max(1e-3);
for mesh in [
blended.tessellate_with(1e-4, true).unwrap(),
blended.display_mesh_angular(display_tol, crate::DISPLAY_ANGLE).unwrap(),
blended.reference_display_mesh_angular(display_tol, crate::DISPLAY_ANGLE).unwrap(),
] {
let bounds = mesh.bounds();
let tol = 1e-3;
assert!(
bounds.min.x >= -10.0 - tol
&& bounds.max.x <= 10.0 + tol
&& bounds.min.y >= -tol
&& bounds.max.y <= 10.0 + tol
&& bounds.min.z >= -tol
&& bounds.max.z <= 15.0 + tol,
"{bounds:?}"
);
for p in &mesh.positions {
assert!(p.x.hypot(p.y) <= 10.0 + tol, "outside the source semicircle: {p:?}");
}
}
}
/// Integrate exact inward-offset D slices, including the arc/line corner overlaps.
/// At height H-r+r*sin(theta), the inset is d=r*(1-cos(theta)); the remaining
/// slice is the circular segment of radius R-d above y=d. No kernel geometry is used.
fn d_fillet_removed_reference(r: f64, steps: u32) -> f64 {
let dt = (PI / 2.0) / f64::from(steps);
(0..steps)
.map(|i| {
let theta = (f64::from(i) + 0.5) * dt;
let d = r * (1.0 - theta.cos());
let rho = 10.0 - d;
let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt();
(PI * 50.0 - remaining) * r * theta.cos() * dt
})
.sum()
}
#[test]
fn fillet_mixed_arc_and_line_removed_volume_converges() {
let body = mixed_arc_and_line_prism();
let source_exact = PI * 100.0 * 15.0 / 2.0;
let tolerances = [1e-4, 2.5e-5];
let source_volumes = tolerances.map(|tol| body.tessellate_with(tol, true).unwrap().measure().volume);
for r in [0.5, 0.25] {
let want = d_fillet_removed_reference(r, 8192);
assert!((want - d_fillet_removed_reference(r, 4096)).abs() < 1e-7, "reference quadrature must converge");
if r == 0.5 {
assert!((want - 2.715578447526581).abs() < 1e-7, "{want}");
}
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap();
let mut previous = None;
for (tol, source) in tolerances.into_iter().zip(source_volumes) {
let volume = blended.tessellate_with(tol, true).unwrap().measure().volume;
// Equal chord tolerances reduce (not necessarily cancel) the error from the
// unchanged walls. The convergence and exact-source checks below guard this.
let removed = source - volume;
assert!(rel(removed, want) < 1e-3, "r={r}, tol={tol}: removed {removed}, expected {want}");
if let Some(coarser) = previous {
assert!(rel(removed, coarser) < 1e-3, "r={r}: removal has not converged: {coarser} -> {removed}");
let exact_removed = source_exact - volume;
// The source's inscribed semicylinder loses at most pi*R*H*tol.
// Do not charge its chord error to the much smaller fillet volume.
let source_error = (source_exact - source).abs();
assert!(source_error < PI * 10.0 * 15.0 * tol, "source chord error {source_error}");
assert!((exact_removed - want).abs() < source_error + want * 1e-3, "r={r}: exact-source removal {exact_removed}, expected {want}");
}
previous = Some(removed);
}
}
}
#[test]
fn loop_chamfer_mixed_arc_and_line_uses_offset_intersections() {
let body = mixed_arc_and_line_prism();
// Exercise the shared loop builder; public chamfer prefers boolean cutters first.
let solid = body.deep_copy();
let top = solid
.face_iter()
.find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9))
.unwrap();
let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect();
let (result, history) = crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, false).unwrap().unwrap();
let blended = crate::record::rebuilt(result, &[&solid], &history).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
// A chamfer has a linear inset through its height instead of a circular section.
let step = 0.5 / 8192.0;
let want: f64 = (0..8192)
.map(|i| {
let d = (f64::from(i) + 0.5) * step;
let rho = 10.0 - d;
let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt();
(PI * 50.0 - remaining) * step
})
.sum();
// The cone's triangulation needs this chord tolerance for the small volume difference.
let fine = |b: &Body| b.tessellate_with(2.5e-5, true).unwrap().measure().volume;
let removed = fine(&body) - fine(&blended);
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
#[test]
fn fillet_mixed_arc_and_line_joint_preserves_both_section_radii() {
use truck_modeling::{BoundedCurve, ParametricCurve};
let r = 0.5;
let body = mixed_arc_and_line_prism();
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap();
crate::guard("inspect mixed fillet mitres", || {
let solid = blended.deep_copy();
let mut mitres = std::collections::HashSet::new();
for edge in solid.boundaries().iter().flat_map(|shell| shell.edge_iter()) {
let a = crate::body::from_p3(edge.front().point());
let b = crate::body::from_p3(edge.back().point());
// Only the two corner profiles join the top contact to the wall contact.
if (a.z - b.z).abs() < r - 1e-8 || a.z.min(b.z) < 15.0 - r - 1e-8 || a.x * b.x <= 0.0 {
continue;
}
if !mitres.insert(edge.id()) {
continue;
}
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
for i in 0..=257 {
let p = crate::body::from_p3(curve.subs(lo + (hi - lo) * f64::from(i) / 257.0));
// The joint lies on both the cylindrical line blend and the toroidal
// arc blend, not their tangent-plane approximation.
let radius2 = (p.y - r).powi(2) + (p.z - (15.0 - r)).powi(2);
assert!((radius2 - r * r).abs() < 1e-7, "distorted line section at {p:?}: radius squared {radius2}");
let radius2 = (p.x.hypot(p.y) - (10.0 - r)).powi(2) + (p.z - (15.0 - r)).powi(2);
assert!((radius2 - r * r).abs() < 1e-7, "distorted arc section at {p:?}: radius squared {radius2}");
}
}
assert_eq!(mitres.len(), 2, "both arc/line corners must be checked");
Ok(())
})
.unwrap();
}
#[test]
fn fillet_partial_mixed_arc_and_line_perimeter_run() {
let body = partial_mixed_run_prism();
let blended = fillet(&body, &[Vec3::new(35.0, 20.0, 15.0), Vec3::new(20.0, 30.0, 15.0), Vec3::new(5.0, 20.0, 15.0)], 0.5).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
let mesh = blended.tessellate_with(1e-4, true).unwrap();
let bounds = mesh.bounds();
let tol = 1e-3;
assert!(
bounds.min.x >= -tol
&& bounds.max.x <= 40.0 + tol
&& bounds.min.y >= -tol
&& bounds.max.y <= 30.0 + tol
&& bounds.min.z >= -tol
&& bounds.max.z <= 15.0 + tol,
"{bounds:?}"
);
for p in &mesh.positions {
if p.y > 20.0 + tol {
assert!((p.x - 20.0).hypot(p.y - 20.0) <= 10.0 + tol, "outside the source semicircle: {p:?}");
}
}
}
/// Boolean survey: random boxes, cylinders and spheres combined every way; prints what fails.
#[test]
#[ignore]