From 4bacd0cae051b5c95a082ce5c05a54cd5cffae08 Mon Sep 17 00:00:00 2001 From: Erikas <5955795+erkexzcx@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:26:24 +0300 Subject: [PATCH] Fillet fit check: rounds on both sides of a wall in one feature fit as they do one after the other (#1668) (#1720) Thanks @erkexzcx. --- crates/kernel/src/blend_fit.rs | 130 ++++++++++++++++++++++++-- crates/kernel/src/issue_1080_tests.rs | 25 +++++ 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs index f2e55301..8deb9063 100644 --- a/crates/kernel/src/blend_fit.rs +++ b/crates/kernel/src/blend_fit.rs @@ -116,9 +116,52 @@ impl Frame { if !(sa.is_finite() && sb.is_finite() && sa > 0.0 && sb > 0.0) { return None; } + self.prism(self.ta * sa, self.tb * sb) + } + + /// For a round, the arc of its cross-section from the setback on face a to the one on face + /// b, in [`ARC_PIECES`] pieces, as offsets from the edge; `None` for a chamfer. + fn arc(&self, size: Size) -> Option> { + let Size::Round(r) = size else { return None }; + let (sa, sb) = self.setbacks(size)?; + let half = 0.5 * self.ta.dot(self.tb).clamp(-1.0, 1.0).acos(); + // The ball's centre, on the bisector, `r` from both faces. + let centre = (self.ta + self.tb).normalized()? * (r / half.sin()); + let (a, b) = (self.ta * sa - centre, self.tb * sb - centre); + (0..=ARC_PIECES).map(|k| Some(centre + (a + (b - a) * (k as f64 / ARC_PIECES as f64)).normalized()? * r)).collect() + } + + /// What a blend of the edge at `size` changes, within the wedge: for a round, the fan of + /// prisms from the edge to the pieces of its arc (between the edge and the arc lies all it + /// takes away or fills; the ball's side of the arc is left as it is), for a chamfer the + /// wedge itself. + fn pieces(&self, size: Size) -> Option> { + match self.arc(size) { + Some(arc) => arc.windows(2).map(|w| self.prism(*w.first()?, *w.get(1)?)).collect(), + None => Some(vec![self.wedge(size)?]), + } + } + + /// The blend's own surface at `size`, as polygons: the strips between its arc's pieces, or + /// the chamfer's face. + fn surface(&self, size: Size) -> Option>> { + match self.arc(size) { + Some(arc) => arc + .windows(2) + .map(|w| { + let (u, v) = (*w.first()?, *w.get(1)?); + Some(vec![self.p0 + u, self.p0 + v, self.p1 + v, self.p1 + u]) + }) + .collect(), + None => Some(vec![self.wedge(size)?.faces.get(2)?.clone()]), + } + } + + /// The triangle between the edge and the offsets `u` and `w` across it, swept along the edge. + fn prism(&self, u: Vec3, w: Vec3) -> Option { let d = (self.p1 - self.p0).normalized()?; - let (a0, b0) = (self.p0 + self.ta * sa, self.p0 + self.tb * sb); - let (a1, b1) = (self.p1 + self.ta * sa, self.p1 + self.tb * sb); + let (a0, b0) = (self.p0 + u, self.p0 + w); + let (a1, b1) = (self.p1 + u, self.p1 + w); // Each side's plane, facing away from the triangle's third corner. let side = |p: Vec3, q: Vec3, opposite: Vec3| -> Option<(Vec3, f64)> { let mut n = d.cross(q - p).normalized()?; @@ -130,13 +173,18 @@ impl Frame { let planes = [side(self.p0, a0, b0)?, side(self.p0, b0, a0)?, side(a0, b0, self.p0)?, (-d, -d.dot(self.p0)), (d, d.dot(self.p1))]; let faces = vec![vec![self.p0, a0, a1, self.p1], vec![self.p0, b0, b1, self.p1], vec![a0, b0, b1, a1], vec![self.p0, a0, b0], vec![self.p1, a1, b1]]; - let area = 0.5 * (self.ta * sa).cross(self.tb * sb).len(); + let area = 0.5 * u.cross(w).len(); Some(Wedge { planes, faces, volume: area * self.p0.dist(self.p1) }) } } -/// `poly` clipped to the half-spaces, each moved `eps` inwards: is anything left? -fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool { +/// Pieces a round's arc is divided into for [`Frame::pieces`] and [`Frame::surface`]: each +/// chord sags under 0.5 % of the radius. +const ARC_PIECES: usize = 8; + +/// `poly` clipped to the half-spaces `n·x <= c`, each moved `eps` inwards (empty when nothing +/// is left). +fn clip(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> Vec { let mut cur: Vec = poly.to_vec(); for (n, c) in planes { let c = c - eps; @@ -152,11 +200,53 @@ fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool { } } if next.is_empty() { - return false; + return next; } cur = next; } - true + cur +} + +/// `poly` clipped to the half-spaces, each moved `eps` inwards: is anything left? +fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool { + !clip(poly, planes, eps).is_empty() +} + +/// Most polygons [`reaches_inside_beyond`] clips before it gives up and answers yes. +const MAX_CLIPS: usize = 4096; + +/// Whether the part of `poly` outside every one of the wedges `trimmed` (by more than `out`) +/// reaches inside `planes` (by `eps`). A face beside or at the end of another edge the same +/// feature blends is trimmed back to that blend, so its part inside that edge's wedge is gone +/// (#1668). +fn reaches_inside_beyond(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64, trimmed: &[&Wedge], out: f64) -> bool { + let mut parts = vec![poly.to_vec()]; + for w in trimmed { + // Outside a convex wedge is outside one of its planes (pieces may overlap: only + // whether anything is left counts). + let mut next = Vec::new(); + for part in &parts { + for (n, c) in &w.planes { + let piece = clip(part, &[(-*n, -*c)], out); + if !piece.is_empty() { + next.push(piece); + } + } + if next.len() > MAX_CLIPS { + return true; + } + } + parts = next; + } + parts.iter().any(|p| reaches_inside(p, planes, eps)) +} + +/// Whether two sets of convex prisms overlap (by more than `eps`): a face of one reaches +/// inside the other. +fn overlap(a: &[Wedge], b: &[Wedge], eps: f64) -> bool { + a.iter().any(|x| { + b.iter().any(|y| y.faces.iter().any(|p| reaches_inside(p, &x.planes, eps)) || x.faces.iter().any(|p| reaches_inside(p, &y.planes, eps))) + }) } /// The body's straight edges between planar faces nearest the points (each once; `None` for a @@ -275,11 +365,22 @@ fn first_clash(sel: &Selection, size: Size) -> Option { // 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; - for (f, w) in sel.frames.iter().zip(&wedges) { + // 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() { let (Some(f), Some(w)) = (f, w) else { continue }; let hit = sel.tris.iter().filter(|(_, face)| !f.allowed.contains(face)).any(|(t, face)| { let eps = if sel.planar.get(*face).copied().unwrap_or(false) { flat } else { curved }; - reaches_inside(t, &w.planes, eps) + // The other blended edges this face runs along or ends them trim it back. + let trimmed: Vec<&Wedge> = sel + .frames + .iter() + .zip(&wedges) + .enumerate() + .filter(|(j, (fj, _))| *j != i && fj.as_ref().is_some_and(|fj| fj.allowed.contains(face))) + .filter_map(|(_, (_, wj))| wj.as_ref()) + .collect(); + reaches_inside_beyond(t, &w.planes, eps, &trimmed, out) }); if hit { return Some(Clash::Face); @@ -295,7 +396,7 @@ fn first_clash(sel: &Selection, size: Size) -> Option { continue; } let meet = wj.faces.iter().any(|p| reaches_inside(p, &wi.planes, eps)) || wi.faces.iter().any(|p| reaches_inside(p, &wj.planes, eps)); - if meet { + if meet && blends_cross(fi, wi, fj, wj, size, eps) { return Some(Clash::Blend); } } @@ -303,6 +404,15 @@ fn first_clash(sel: &Selection, size: Size) -> Option { None } +/// Two blends whose wedges overlap: do they cross? Rounds on both sides of a wall (the outside +/// round's wedge reaches past the inside corner, which the inside round fills) need not: they +/// cross only where what one changes (between its edge and its arc) overlaps what the other +/// does, or where either's surface runs into the other's wedge (#1668). +fn blends_cross(fi: &Frame, wi: &Wedge, fj: &Frame, wj: &Wedge, size: Size, eps: f64) -> bool { + let (Some(pi), Some(pj), Some(si), Some(sj)) = (fi.pieces(size), fj.pieces(size), fi.surface(size), fj.surface(size)) else { return true }; + overlap(&pi, &pj, eps) || sj.iter().any(|p| reaches_inside(p, &wi.planes, eps)) || si.iter().any(|p| reaches_inside(p, &wj.planes, eps)) +} + /// The step the largest fitting size is given to: three significant digits. fn step(v: f64) -> f64 { 10f64.powf(v.log10().floor() - 3.0) diff --git a/crates/kernel/src/issue_1080_tests.rs b/crates/kernel/src/issue_1080_tests.rs index 914ffa3f..4d3f6648 100644 --- a/crates/kernel/src/issue_1080_tests.rs +++ b/crates/kernel/src/issue_1080_tests.rs @@ -94,3 +94,28 @@ fn a_result_that_removes_more_than_the_blend_is_refused() { let m = too_large(crate::blend_fit::check_volume(&b, worse, &e, size)); assert!(m.contains("more than the chamfer can"), "{m}"); } + +/// #1668: a box shelled outward 2 mm, open at the bottom. R5 on its top outer edge alone would +/// reach past the sharp inside corner 2 mm away; with the inside edge rounded in the same +/// fillet, that corner is filled, and the two build as one feature as they do one after the +/// other: the outer round takes (1 - π/4) · 25 mm² along 104 mm, the inner one adds it along +/// 100 mm. +#[test] +fn rounds_on_both_sides_of_a_wall_build_as_one_fillet() { + let outer = box_solid(Vec3::new(-52.0, -32.0, 0.0), Vec3::new(52.0, 32.0, 42.0)).unwrap(); + let shelled = cut(&outer, [-50.0, -30.0, -1.0], [50.0, 30.0, 40.0]); + let (out_edge, in_edge) = (Vec3::new(0.0, 32.0, 42.0), Vec3::new(0.0, 30.0, 40.0)); + let m = too_large(crate::fillet(&shelled, &[out_edge], 5.0)); + assert!(m.contains("runs past the faces beside the edge"), "{m}"); + let both = crate::fillet(&shelled, &[out_edge, in_edge], 5.0).unwrap(); + assert!(both.validity().is_empty(), "{:?}", both.validity()); + let section = (1.0 - std::f64::consts::FRAC_PI_4) * 25.0; + let want = shelled.volume_at(0.01).unwrap() - section * 104.0 + section * 100.0; + let v = crate::measure(&both).unwrap().volume; + assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); + let stepped = crate::fillet(&crate::fillet(&shelled, &[in_edge], 5.0).unwrap(), &[out_edge], 5.0).unwrap(); + assert!((crate::measure(&stepped).unwrap().volume - v).abs() < 1e-4 * want); + // Past the wall's own room they still cross: R7's outer round reaches the inner one's arc. + let m = too_large(crate::fillet(&shelled, &[out_edge, in_edge], 7.0)); + assert!(m.contains("the largest radius that fits"), "{m}"); +}