feat: fit reduce lowers the radius only where it doesn't fit (#496)

Fillet fit: reduce lowers the radius only where it doesn't fit: picked edges are grouped where they can limit each other (sharing a face or vertex), and each group gets the requested radius where it fits, otherwise the largest that does. Refs #293.

Co-authored-by: Iraklis Kourtis <jyviko@gmail.com>
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 05:27:03 -04:00
1 parent 6d5df4b9b3
commit 0f0bb09858
9 files changed
+252 -35

No files matched your search

+123 -7
View File
@@ -828,22 +828,28 @@ fn smooth_loop(w: &mt::Wire) -> bool {
/// The B-rep edges nearest the points (each once).
fn edge_ids(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result<Vec<mt::EdgeID>> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let all = Body::unique_edges(solid);
let mut ids = Vec::new();
for p in pts {
if let Some((idx, dist)) = cur.nearest_edge(*p, tol)?
&& dist <= size * 0.05 + 1e-3
&& let Some(e) = all.get(idx)
&& !ids.contains(&e.id())
if let Some(id) = nearest_edge_id(cur, &all, *p)?
&& !ids.contains(&id)
{
ids.push(e.id());
ids.push(id);
}
}
Ok(ids)
}
/// The edge of `all` (`cur`'s edges, in their order) a point picks: the nearest, if near enough.
fn nearest_edge_id(cur: &Body, all: &[mt::Edge], p: Vec3) -> Result<Option<mt::EdgeID>> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
Ok(match cur.nearest_edge(p, tol)? {
Some((idx, dist)) if dist <= size * 0.05 + 1e-3 => all.get(idx).map(|e| e.id()),
_ => None,
})
}
/// When the edges make up whole boundary loops of planar faces: the edge points per loop.
fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result<Option<Vec<Vec<Vec3>>>> {
Ok(loop_split(cur, pts)?.and_then(|(g, rest)| rest.is_empty().then_some(g)))
@@ -955,6 +961,116 @@ pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Option<(f64, Body
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)> {
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 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;
for g in &groups {
let pts: Vec<Vec3> = g.iter().filter_map(|i| edges.get(*i).copied()).collect();
let r = match fillet(body, &pts, radius) {
Ok(_) => continue,
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
searches += 1;
match fillet_max(body, &pts, radius) {
Some((r, _)) => r,
None => return one(),
}
}
Err(_) => return one(),
};
for i in g {
if let Some(x) = radii.get_mut(*i) {
*x = r;
}
}
}
match fillet_each(body, edges, &radii) {
Ok(b) => Some((radii, b)),
Err(_) => one(),
}
}
/// The fillet of each edge at its own radius (`radii` in `edges`' order): built together where
/// the corner build takes it, else the edges of each radius together, the largest first.
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
if radii.len() != edges.len() {
return Err(KernelError::Invalid("one radius per edge".into()));
}
if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) {
return fillet(body, edges, *r);
}
let sel: Vec<(Vec3, f64)> = edges.iter().copied().zip(radii.iter().copied()).collect();
if let Ok(b) = crate::blend_corner::fillet_radii(body, &sel) {
return Ok(b);
}
let mut by_radius: Vec<(f64, Vec<Vec3>)> = Vec::new();
for (p, r) in sel {
match by_radius.iter_mut().find(|(x, _)| x.to_bits() == r.to_bits()) {
Some((_, pts)) => pts.push(p),
None => by_radius.push((r, vec![p])),
}
}
by_radius.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut cur = body.clone();
for (r, pts) in &by_radius {
cur = fillet(&cur, pts, *r)?;
}
Ok(cur)
}
/// The points' edges (found as [`edge_ids`] finds them, on the body as the blends see it) grouped
/// where they share a face or a vertex, directly or through other points' edges: indices into
/// `pts`. `None` when a point has no edge near it.
fn interacting_groups(body: &Body, pts: &[Vec3]) -> Option<Vec<Vec<usize>>> {
let cur = crate::boolean_record::healed(body).ok()?;
let all = Body::unique_edges(&cur.solid);
// What each point's edge touches: its faces and its ends.
let mut groups: Vec<(Vec<mt::FaceID>, Vec<mt::VertexID>, Vec<usize>)> = Vec::new();
for (i, p) in pts.iter().enumerate() {
let id = nearest_edge_id(&cur, &all, *p).ok()??;
let edge = all.iter().find(|e| e.id() == id)?;
let mut faces: Vec<mt::FaceID> = cur.solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == id)).map(|f| f.id()).collect();
let mut ends = vec![edge.front().id(), edge.back().id()];
let mut members = vec![i];
// Join every group touching one of these faces or ends.
let (meet, rest): (Vec<_>, Vec<_>) =
groups.into_iter().partition(|(fs, vs, _)| fs.iter().any(|f| faces.contains(f)) || vs.iter().any(|v| ends.contains(v)));
for (fs, vs, ms) in meet {
for f in fs {
if !faces.contains(&f) {
faces.push(f);
}
}
for v in vs {
if !ends.contains(&v) {
ends.push(v);
}
}
members.extend(ms);
}
groups = rest;
groups.push((faces, ends, members));
}
Some(
groups
.into_iter()
.map(|(_, _, mut ms)| {
ms.sort_unstable();
ms
})
.collect(),
)
}
/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
/// a mitre); the local operation is the fallback.
+1 -1
View File
@@ -70,7 +70,7 @@ mod thread_tests;
mod topo;
mod tspline;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_max};
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
+30
View File
@@ -382,6 +382,36 @@ fn fillet_max_finds_the_largest_radius_that_works() {
assert!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_none());
}
/// Edges that share no face get their own largest radius: a 2 mm fin's top edge takes at most
/// its thickness, while a corner of the block it stands on, far from it, keeps the 4 asked for.
/// Edges that limit each other (the post's four, sharing faces) still share one.
#[test]
fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
let (block, fin) = (40.0 * 30.0 * 20.0, 2.0 * 30.0 * 10.0);
let body = boolean(
&box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(),
&box_solid(Vec3::new(19.0, 0.0, 20.0), Vec3::new(21.0, 30.0, 30.0)).unwrap(),
BoolOp::Union,
)
.unwrap()
.unwrap();
let edges = [Vec3::new(21.0, 15.0, 30.0), Vec3::new(40.0, 0.0, 10.0)];
assert!(matches!(fillet(&body, &edges, 4.0), Err(KernelError::TooLarge(_))));
let (one, _) = fillet_max(&body, &edges, 4.0).unwrap();
let (radii, b) = fillet_max_each(&body, &edges, 4.0).unwrap();
assert_eq!(radii.get(1), Some(&4.0), "{radii:?}");
let fin_r = radii.first().copied().unwrap();
assert!(fin_r < 4.0 && (fin_r - one).abs() < 1e-9, "{radii:?}, one radius {one}");
assert!(b.validity().is_empty(), "{:?}", b.validity());
// Each round takes (1 − π/4)·r² along its edge: the fin's 30 long, the corner's 20.
let removed = (1.0 - PI / 4.0) * (fin_r * fin_r * 30.0 + 16.0 * 20.0);
assert!(rel(measure(&b).unwrap().volume, block + fin - removed) < 2e-4);
// 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]));
}
/// A corner's rounds too large for its faces are refused as too large, so a fit can reduce
/// them, also where only the corner build finds it (the edge-by-edge blend failing otherwise).
#[test]