kernel: the largest fitting fillet shrinks only on a refusal as too large

fillet_max bisected the radius down on any error of a candidate fillet,
not only KernelError::TooLarge, and fillet_max_each fell back to one
radius for all on any error of a group or of the combined fillet. A
kernel failure at the radius asked for (or at any candidate) came back
as a smaller radius reported as "the largest that fits".

fillet_max and fillet_max_each now return Result<Option<..>>: only
TooLarge narrows the search or falls back to one radius, and any other
error is passed on. Ok(None) is still "no radius fits". The doc's
FilletFit::Reduce passes the error on. The test of a point with no edge
near it now expects the fillet's own error, not "no radius".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-25 21:27:27 -04:00
parent 0bea4631be
commit 0e6681a2dd
3 changed files with 35 additions and 26 deletions
+1 -1
View File
@@ -697,7 +697,7 @@ fn fillet_fitted(
fit: crate::FilletFit,
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
match kernel::fillet(body, edges, r) {
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r) {
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r)? {
// Built at the radius asked for after all (the groups apart): nothing reduced.
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
None => Err(kernel::KernelError::TooLarge(why)),
+23 -17
View File
@@ -230,15 +230,17 @@ fn radius_step(radius: f64) -> f64 {
}
/// The largest radius below `radius` (to three significant digits) at which the fillet of
/// these edges works, with its result; `None` when none does. Each candidate is a real fillet
/// these edges works, with its result; `Ok(None)` when none does. Each candidate is a real fillet
/// of the edges, so the radius returned is one that was built; the search runs up to 16 of
/// them, so it is for an explicit request, not every refusal.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Option<(f64, Body)> {
/// them, so it is for an explicit request, not every refusal. Only a refusal as too large
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
/// search's error, since a smaller radius would not be the largest that fits.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(f64, Body)>> {
if !(radius.is_finite() && radius > 1e-6) {
return None;
return Ok(None);
}
let q = radius_step(radius);
// Radii n·q for n in [0, hi): hi fails (it is the request, or above it), lo works.
// Radii n·q for n in [0, hi): hi is too large (it is the request, or above it), lo works.
let mut hi = (radius / q).ceil() as u64;
let mut lo = 0u64;
let mut best: Option<Body> = None;
@@ -253,22 +255,24 @@ pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Option<(f64, Body
lo = mid;
best = Some(b);
}
Err(_) => hi = mid,
Err(KernelError::TooLarge(_)) => hi = mid,
Err(e) => return Err(e),
}
}
best.map(|b| (lo as f64 * q, b))
Ok(best.map(|b| (lo as f64 * q, b)))
}
/// [`fillet_max`] for each group of the edges that may limit each other's radius (sharing a
/// face or a vertex, directly or through other edges in the group), so a round that fits isn't
/// reduced for a tight edge elsewhere. The radius per edge (in `edges`' order) and the result;
/// one radius for all (as [`fillet_max`]) when a group is refused for another reason than its
/// size, the groups don't build together, or there are too many to search (each search is up
/// to 16 fillets, and this runs in the live preview).
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Option<(Vec<f64>, Body)> {
/// reduced for a tight edge elsewhere. The radius per edge (in `edges`' order) and the result,
/// `Ok(None)` when no radius fits. One radius for all (as [`fillet_max`]) when the groups,
/// each reduced to fit, are too large together, or there are too many to search (each search is
/// up to 16 fillets, and this runs in the live preview). Any failure other than a refusal as too
/// large is the error.
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(Vec<f64>, Body)>> {
const MAX_GROUPS: usize = 8;
const MAX_SEARCHES: usize = 2;
let one = || fillet_max(body, edges, radius).map(|(r, b)| (vec![r; edges.len()], b));
let one = || -> Result<Option<(Vec<f64>, Body)>> { Ok(fillet_max(body, edges, radius)?.map(|(r, b)| (vec![r; edges.len()], b))) };
let Some(groups) = interacting_groups(body, edges).filter(|g| g.len() > 1 && g.len() <= MAX_GROUPS) else { return one() };
let mut radii = vec![radius; edges.len()];
let mut searches = 0;
@@ -278,12 +282,13 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Option<(Vec<
Ok(_) => continue,
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
searches += 1;
match fillet_max(body, &pts, radius) {
match fillet_max(body, &pts, radius)? {
Some((r, _)) => r,
None => return one(),
}
}
Err(_) => return one(),
Err(KernelError::TooLarge(_)) => return one(),
Err(e) => return Err(e),
};
for i in g {
if let Some(x) = radii.get_mut(*i) {
@@ -292,8 +297,9 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Option<(Vec<
}
}
match fillet_each(body, edges, &radii) {
Ok(b) => Some((radii, b)),
Err(_) => one(),
Ok(b) => Ok(Some((radii, b))),
Err(KernelError::TooLarge(_)) => one(),
Err(e) => Err(e),
}
}
+11 -8
View File
@@ -357,14 +357,14 @@ fn fillet_max_finds_the_largest_radius_that_works() {
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
assert!(too_large(&b, &mids, 3.5));
let (r, body) = fillet_max(&b, &mids, 3.5).unwrap();
let (r, body) = fillet_max(&b, &mids, 3.5).unwrap().unwrap();
assert_eq!(r, 3.0);
assert!(rel(measure(&body).unwrap().volume, 840.0 - (4.0 - PI) * 90.0) < 2e-4);
// A 2 mm disc's top edge: up to 2 (the wall used up).
let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap();
let top = [Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)];
assert!(too_large(&disc, &top, 2.5));
assert_eq!(fillet_max(&disc, &top, 2.5).map(|x| x.0), Some(2.0));
assert_eq!(fillet_max(&disc, &top, 2.5).unwrap().map(|x| x.0), Some(2.0));
// Walls of different heights: at the shortest one's height only that wall would be used up,
// which is refused, so the largest radius is just below it (to the search's 3 digits).
let notched = boolean(
@@ -376,10 +376,13 @@ fn fillet_max_finds_the_largest_radius_that_works() {
.unwrap();
let outline = [Vec3::new(20.0, 0.0, 10.0), Vec3::new(40.0, 15.0, 10.0), Vec3::new(20.0, 30.0, 10.0), Vec3::new(0.0, 15.0, 10.0)];
assert!(too_large(&notched, &outline, 8.0));
let (r, _) = fillet_max(&notched, &outline, 8.0).unwrap();
let (r, _) = fillet_max(&notched, &outline, 8.0).unwrap().unwrap();
assert!((r - 6.999).abs() < 1e-9, "{r}");
// No edge there: no radius.
assert!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_none());
// No edge there: the fillet's own error, not a smaller radius (only a refusal as too large
// narrows the search).
let e = fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err();
assert!(!matches!(e, KernelError::TooLarge(_)), "{e}");
assert_eq!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err().to_string(), e.to_string());
}
/// Edges that share no face get their own largest radius: a 2 mm fin's top edge takes at most
@@ -401,7 +404,7 @@ fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
// at its edge less the quarter disc about (17, 26), inside the fin (x >= 19): 2 x 4 less the
// disc's part, in closed form; the corner's round (1 − π/4)·16 along 20.
let both = fillet(&body, &edges, 4.0).unwrap();
let (radii, b) = fillet_max_each(&body, &edges, 4.0).unwrap();
let (radii, b) = fillet_max_each(&body, &edges, 4.0).unwrap().unwrap();
assert_eq!(radii, vec![4.0, 4.0], "{radii:?}");
assert!(b.validity().is_empty(), "{:?}", b.validity());
let disc = |a: f64| 0.5 * (a * (16.0 - a * a).max(0.0).sqrt() + 16.0 * (a / 4.0).asin());
@@ -412,7 +415,7 @@ fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
// The post's four edges limit each other: one radius, as `fillet_max`.
let post = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
assert_eq!(fillet_max_each(&post, &mids, 3.5).map(|x| x.0), Some(vec![3.0; 4]));
assert_eq!(fillet_max_each(&post, &mids, 3.5).unwrap().map(|x| x.0), Some(vec![3.0; 4]));
}
/// A corner's rounds too large for its faces are refused as too large, so a fit can reduce
@@ -426,7 +429,7 @@ fn corner_rounds_larger_than_their_faces_are_too_large() {
// blend can't find it; the corner build is what says the radius is too large.
let e = fillet(&b, &corner, l).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let (r, body) = fillet_max(&b, &corner, l).unwrap();
let (r, body) = fillet_max(&b, &corner, l).unwrap().unwrap();
assert!((r - 19.99).abs() < eps, "{r}");
assert!(body.validity().is_empty(), "{:?}", body.validity());
// Three rounds along the edges past the corner cube, and the cube less its sphere octant.