From 3eae8c34f66085f16d35f72f46a60e866853593d Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:39 +0200 Subject: [PATCH] Fillet beside a hole: the suggested largest radius fits (#324) (#1885) Thanks @SychPL. --- crates/kernel/src/blend_fit.rs | 15 +++++++---- crates/kernel/src/issue_324_tests.rs | 37 ++++++++++++++++++++++++++++ crates/kernel/src/lib.rs | 2 ++ 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 crates/kernel/src/issue_324_tests.rs diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs index 8deb9063..e0c7a351 100644 --- a/crates/kernel/src/blend_fit.rs +++ b/crates/kernel/src/blend_fit.rs @@ -360,11 +360,14 @@ enum Clash { Blend, } -fn first_clash(sel: &Selection, size: Size) -> Option { +/// What a blend at `size` runs into first, if anything. Curved faces are meshed to within the +/// chord: they count when their mesh reaches into a wedge by more than twice the chord, or, when +/// `strict`, when it comes within the chord of one (the surface may then be inside it). +fn first_clash(sel: &Selection, size: Size, strict: bool) -> Option { let wedges: Vec> = sel.frames.iter().map(|f| f.as_ref().and_then(|f| f.wedge(size))).collect(); // Planar faces are exact in the mesh; curved ones are within the chord. let flat = (sel.size * 1e-7).max(1e-9); - let curved = sel.chord * 2.0; + let curved = if strict { -sel.chord } else { sel.chord * 2.0 }; // Planar faces beside an edge lie in its wedge's side planes: outside it means further. let out = (sel.size * 1e-6).max(1e-8); for (i, (f, w)) in sel.frames.iter().zip(&wedges).enumerate() { @@ -433,8 +436,10 @@ fn misfit_in(sel: &Selection, size: Size) -> Result)> if sel.frames.iter().all(Option::is_none) || !size.value().is_finite() || size.value() <= 0.0 { return Ok(None); } - let Some(clash) = first_clash(sel, size) else { return Ok(None) }; - // Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0). + let Some(clash) = first_clash(sel, size, false) else { return Ok(None) }; + // Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0). Strictly: a size + // offered as fitting must fit, though a curved face that only touches the wedge at it would + // not be refused (a hole whose rim the round's band just reaches, #324). let asked = size.value(); let q = step(asked); let (mut lo, mut hi) = (0u64, (asked / q).ceil() as u64); @@ -443,7 +448,7 @@ fn misfit_in(sel: &Selection, size: Size) -> Result)> break; } let mid = lo + (hi - lo) / 2; - if first_clash(sel, size.scaled(mid as f64 * q / asked)).is_none() { + if first_clash(sel, size.scaled(mid as f64 * q / asked), true).is_none() { lo = mid; } else { hi = mid; diff --git a/crates/kernel/src/issue_324_tests.rs b/crates/kernel/src/issue_324_tests.rs new file mode 100644 index 00000000..57cb86a5 --- /dev/null +++ b/crates/kernel/src/issue_324_tests.rs @@ -0,0 +1,37 @@ +//! #324: a round on a block's top edge that would overlap a through hole is refused, and the +//! largest radius the refusal offers fits. + +use solidcraft_geom::Vec3; + +use crate::{Body, BoolOp, build::box_solid, build::cylinder, ops::boolean}; + +/// A 40 x 20 x 10 block with a through hole of radius 3 at (20, `y`). +fn block_with_hole(y: f64) -> Body { + let block = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 20.0, 10.0)).unwrap(); + let hole = cylinder(Vec3::new(20.0, y, -1.0), Vec3::Z, 3.0, 12.0).unwrap(); + boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap() +} + +/// The round on the top edge at y = 20 covers the top face from y = 20 - r, and the hole's +/// wall reaches y = 17.5: a radius over 2.5 runs into the hole. The refusal of 4 mm offered +/// 2.721 mm, which was refused the same way: the curved wall's mesh, with its tolerance, was +/// first seen inside the round's region 0.22 mm past where the wall really enters it. +#[test] +fn the_radius_offered_beside_a_hole_fits() { + let b = block_with_hole(14.5); + let edge = Vec3::new(10.0, 20.0, 10.0); + let m = crate::fillet(&b, &[edge], 4.0).unwrap_err().to_string(); + let offered: f64 = m + .split("the largest radius that fits is ") + .nth(1) + .and_then(|s| s.split(' ').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("no radius offered: {m}")); + assert!(offered > 2.0 && offered <= 2.5, "{m}"); + let r = crate::fillet(&b, &[edge], offered).unwrap(); + assert!(r.validity().is_empty(), "{:?}", r.validity()); + // The block, the hole and the round's corner: the round stays clear of the hole. + let want = 8000.0 - std::f64::consts::PI * 9.0 * 10.0 - 40.0 * offered * offered * (1.0 - std::f64::consts::FRAC_PI_4); + let v = r.volume_at(0.005).unwrap(); + assert!((v - want).abs() < 0.05, "{v} against {want}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b443effa..b2be9b2f 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -56,6 +56,8 @@ mod issue_1038_tests; #[cfg(test)] mod issue_1080_tests; #[cfg(test)] +mod issue_324_tests; +#[cfg(test)] mod issue_616_tests; #[cfg(test)] mod issue_674_tests;