//! Does a fillet or chamfer fit where it is asked for (#1080)? A blend of a straight edge //! between two planar faces takes away (or, on a concave edge, fills) at most the wedge between //! the edge and its two setback lines. Any other face of the body inside that wedge means the //! blend runs past a neighbouring face's boundary into other geometry (a hole, a pocket, an //! earlier round), and two such wedges overlapping mean two blends cross. Either way the result //! the operations build is not a blend of those edges, so it is refused with the largest size //! that does fit. After the build, the volume taken away is checked against the wedges too. use solidcraft_geom::Vec3; use truck_modeling as mt; use crate::blend::{ChamferSide, plane_normal, vtx}; use crate::body::Body; use crate::{KernelError, Result}; /// What is asked for on every edge. #[derive(Clone, Copy, Debug)] pub(crate) enum Size { /// A round of this radius. Round(f64), /// An equal-distance chamfer. Chamfer(f64), /// A chamfer with a second distance or an angle (see [`crate::chamfer_sides`]). Sides(f64, ChamferSide, bool), } impl Size { fn scaled(self, k: f64) -> Size { match self { Size::Round(r) => Size::Round(r * k), Size::Chamfer(s) => Size::Chamfer(s * k), Size::Sides(d, ChamferSide::Distance(d2), f) => Size::Sides(d * k, ChamferSide::Distance(d2 * k), f), Size::Sides(d, a, f) => Size::Sides(d * k, a, f), } } fn value(self) -> f64 { match self { Size::Round(v) | Size::Chamfer(v) | Size::Sides(v, _, _) => v, } } fn what(self) -> &'static str { match self { Size::Round(_) => "fillet", _ => "chamfer", } } } /// A straight edge between two planar faces: its ends, the unit directions into each face /// (perpendicular to the edge) and the faces' outward normals. struct Frame { p0: Vec3, p1: Vec3, /// Into face a and face b, from the edge. ta: Vec3, tb: Vec3, na: Vec3, nb: Vec3, /// The faces (indices in the body's face order) the blend may meet: the two beside the /// edge and every face at its ends. allowed: Vec, ends: [mt::VertexID; 2], /// Another edge of the two faces runs on from an end tangentially: the blend runs on along /// that chain past this edge. chained: bool, } /// One edge's region: the triangle edge, setback on face a, setback on face b, swept along the /// edge, as half-spaces `n·x <= c` and as its five boundary polygons. struct Wedge { planes: [(Vec3, f64); 5], faces: Vec>, /// Cross-section area times length: the most a blend of the edge takes away or fills. volume: f64, } impl Frame { /// The setbacks along face a and face b for `size`; `None` where the faces are (nearly) /// tangent or the size doesn't make a blend. fn setbacks(&self, size: Size) -> Option<(f64, f64)> { let cos = self.ta.dot(self.tb).clamp(-1.0, 1.0); let phi = cos.acos(); if !(1e-3..std::f64::consts::PI - 1e-3).contains(&phi) { return None; } match size { // The round touches each face at r / tan(φ/2) from the edge. Size::Round(r) => { let t = r / (phi * 0.5).tan(); Some((t, t)) } Size::Chamfer(s) => Some((s, s)), Size::Sides(d1, side, flip) => { // As `chamfer_tool_sides`: side 1 is the face facing up most, unless flipped. let up = Vec3::new(1e-6, 1e-3, 1.0); let a_is_first = (self.na.dot(up) >= self.nb.dot(up)) != flip; let d2 = match side { ChamferSide::Distance(d) => d, ChamferSide::Angle(a) => { let den = (phi + a).sin(); if den <= 1e-9 { return None; } d1 * a.sin() / den } }; Some(if a_is_first { (d1, d2) } else { (d2, d1) }) } } } fn wedge(&self, size: Size) -> Option { let (sa, sb) = self.setbacks(size)?; if !(sa.is_finite() && sb.is_finite() && sa > 0.0 && sb > 0.0) { return None; } 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); // 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()?; if n.dot(opposite - p) > 0.0 { n = -n; } Some((n, n.dot(p))) }; 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(); 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 { let mut cur: Vec = poly.to_vec(); for (n, c) in planes { let c = c - eps; let mut next = Vec::with_capacity(cur.len() + 2); for (i, p) in cur.iter().enumerate() { let Some(q) = cur.get((i + 1) % cur.len()) else { continue }; let (dp, dq) = (n.dot(*p) - c, n.dot(*q) - c); if dp <= 0.0 { next.push(*p); } if (dp < 0.0 && dq > 0.0) || (dp > 0.0 && dq < 0.0) { next.push(*p + (*q - *p) * (dp / (dp - dq))); } } if next.is_empty() { return false; } cur = next; } true } /// The body's straight edges between planar faces nearest the points (each once; `None` for a /// point whose edge is something else, which the check leaves alone). struct Selection { frames: Vec>, /// The body's triangles, with their face and whether that face is planar. tris: Vec<([Vec3; 3], usize)>, planar: Vec, size: f64, chord: f64, /// The body's volume and area from that mesh. coarse: (f64, f64), } fn select(body: &Body, pts: &[Vec3]) -> Result { // As the blends take it: coplanar neighbours as one face, collinear pieces as one edge. let healed = crate::boolean_record::healed(body)?; crate::guard("blend fit", || select_in(&healed, pts)) } fn select_in(body: &Body, pts: &[Vec3]) -> Result { let size = body.size(); let chord = (size * 1e-3).max(1e-4); let mesh = body.tessellate(chord)?; let solid = &*body.solid; let faces: Vec<&mt::Face> = solid.face_iter().collect(); let planar: Vec = faces.iter().map(|f| plane_normal(f).is_some()).collect(); let all = Body::unique_edges(solid); let mut taken: Vec = Vec::new(); let mut frames = Vec::new(); for p in pts { // The edge nearest the point, as the blends pick it. let best = mesh .edges .iter() .enumerate() .map(|(i, poly)| (i, poly.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min))) .min_by(|a, b| a.1.total_cmp(&b.1)); // Points on the same edge (pieces healed into one) count once. let frame = match best.and_then(|(i, dist)| all.get(i).filter(|_| dist <= size * 0.05 + 1e-3)) { Some(e) if taken.contains(&e.id()) => continue, Some(e) => { taken.push(e.id()); frame(&faces, e) } None => None, }; frames.push(frame); } let m = mesh.measure(); let coarse = (m.volume, m.area); let tris = mesh.triangles.iter().zip(&mesh.tri_face).filter_map(|(t, f)| Some((mesh.tri(t)?, *f as usize))).collect(); Ok(Selection { frames, tris, planar, size, chord, coarse }) } fn frame(faces: &[&mt::Face], e: &mt::Edge) -> Option { if !matches!(e.curve(), mt::Curve::Line(_)) { return None; } let id = e.id(); let ends = [e.front().id(), e.back().id()]; let (p0, p1) = (vtx(e.absolute_front()), vtx(e.absolute_back())); let d = (p1 - p0).normalized()?; // Each face along the edge: its outward normal and the direction into it (its boundary runs // counter-clockwise about the normal, so the face lies to the left of the edge's use). let mut beside = Vec::new(); let mut allowed = Vec::new(); let mut chained = false; for (i, f) in faces.iter().enumerate() { let mut uses: Vec = f.boundaries().iter().flat_map(|w| w.edge_iter()).filter(|x| x.id() == id).cloned().collect(); let at_end = f.boundaries().iter().flat_map(|w| w.edge_iter()).any(|x| ends.contains(&x.front().id()) || ends.contains(&x.back().id())); if at_end { allowed.push(i); } if uses.is_empty() { continue; } let n = plane_normal(f)?; let u = uses.pop()?; if !uses.is_empty() { return None; } let dir = (vtx(u.back()) - vtx(u.front())).normalized()?; beside.push((n, n.cross(dir).normalized()?)); chained |= f.boundaries().iter().flat_map(|w| w.edge_iter()).filter(|x| x.id() != id).any(|x| { ends.iter().any(|v| *v == x.front().id() && tangent_at(x, false).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6)) || ends.iter().any(|v| *v == x.back().id() && tangent_at(x, true).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6)) }); } let [(na, ta), (nb, tb)] = beside[..] else { return None }; // Both directions perpendicular to the edge, within their faces. if ta.dot(d).abs() > 1e-6 || tb.dot(d).abs() > 1e-6 { return None; } Some(Frame { p0, p1, ta, tb, na, nb, allowed, ends, chained }) } /// The unit tangent of an edge (as used) at its start or end. fn tangent_at(e: &mt::Edge, at_end: bool) -> Option { use mt::{BoundedCurve, ParametricCurve}; let c = e.oriented_curve(); let (t0, t1) = c.range_tuple(); let d = c.der(if at_end { t1 } else { t0 }); Vec3::new(d.x, d.y, d.z).normalized() } /// What a blend that doesn't fit runs into. enum Clash { Face, Blend, } fn first_clash(sel: &Selection, size: Size) -> 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; for (f, w) in sel.frames.iter().zip(&wedges) { 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) }); if hit { return Some(Clash::Face); } } // Two blends whose edges don't meet must not overlap (those meeting at a vertex mitre). let eps = (sel.size * 1e-7).max(1e-9); for (i, (fi, wi)) in sel.frames.iter().zip(&wedges).enumerate() { let (Some(fi), Some(wi)) = (fi, wi) else { continue }; for (fj, wj) in sel.frames.iter().zip(&wedges).skip(i + 1) { let (Some(fj), Some(wj)) = (fj, wj) else { continue }; if fi.ends.iter().any(|v| fj.ends.contains(v)) { 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 { return Some(Clash::Blend); } } } None } /// The step the largest fitting size is given to: three significant digits. fn step(v: f64) -> f64 { 10f64.powf(v.log10().floor() - 3.0) } /// Why a blend of `edges` at `size` doesn't fit `body` and the largest size that does (to /// three significant digits; `None` when not even the smallest step fits), or `None` when it /// fits or the check can't tell (edges other than straight ones between planar faces). fn misfit(body: &Body, edges: &[Vec3], size: Size) -> Result)>> { if body.is_mesh() || edges.is_empty() || !size.value().is_finite() || size.value() <= 0.0 { return Ok(None); } misfit_in(&select(body, edges)?, size) } /// [`misfit`] on the selection. 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 asked = size.value(); let q = step(asked); let (mut lo, mut hi) = (0u64, (asked / q).ceil() as u64); for _ in 0..20 { if hi.saturating_sub(lo) <= 1 { break; } let mid = lo + (hi - lo) / 2; if first_clash(sel, size.scaled(mid as f64 * q / asked)).is_none() { lo = mid; } else { hi = mid; } } Ok(Some((clash, (lo > 0).then_some(lo as f64 * q)))) } /// "; the largest radius that fits is 5 mm", or nothing. fn fits_note(size: Size, largest: Option) -> String { let unit = if matches!(size, Size::Round(_)) { "radius" } else { "size" }; largest.map(|v| format!("; the largest {unit} that fits is {} mm", show(v))).unwrap_or_default() } /// Refuse a blend of `edges` at `size` that runs into other faces of `body` or into another of /// the blends, with [`KernelError::TooLarge`] naming the largest size that fits. pub(crate) fn check(body: &Body, edges: &[Vec3], size: Size) -> Result<()> { if body.is_mesh() || edges.is_empty() { return Ok(()); } check_in(&select(body, edges)?, size) } /// [`check`] on the selection. fn check_in(sel: &Selection, size: Size) -> Result<()> { let Some((clash, largest)) = misfit_in(sel, size)? else { return Ok(()) }; let what = size.what(); let into = match clash { Clash::Face => "it runs past the faces beside the edge into other geometry".to_string(), Clash::Blend => format!("the {what}s on these edges would cross each other"), }; Err(KernelError::TooLarge(format!("{} mm is too large for this {what}: {into}{}", show(size.value()), fits_note(size, largest)))) } /// A blend's refusal `e`, with the largest size that fits added when the size is why. pub(crate) fn explain(body: &Body, edges: &[Vec3], size: Size, e: KernelError) -> KernelError { let note = match misfit(body, edges, size) { Ok(Some((_, largest @ Some(_)))) => fits_note(size, largest), _ => return e, }; match e { KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m}{note}")), KernelError::Failed(m) => KernelError::Failed(format!("{m}{note}")), other => other, } } /// A blend of `edges` at `size` that turned `input` into `out`, if it fits ([`check`]) and /// changes no more than it can ([`check_volume`]). pub(crate) fn accept(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result { if input.is_mesh() || out.is_mesh() || edges.is_empty() { return Ok(out); } let sel = select(input, edges)?; check_in(&sel, size)?; volume_in(&sel, input, out, size) } /// After a blend of `edges` at `size` turned `input` into `out`: refused when the volume taken /// away (or added) is more than the blends' wedges hold, which only a result that ran into /// other geometry does (#871's shelled box lost 240 mm³ more than any round could take). #[cfg(test)] pub(crate) fn check_volume(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result { if input.is_mesh() || out.is_mesh() || edges.is_empty() { return Ok(out); } let Ok(sel) = select(input, edges) else { return Ok(out) }; volume_in(&sel, input, out, size) } /// [`check_volume`] on the selection. fn volume_in(sel: &Selection, input: &Body, out: Body, size: Size) -> Result { if sel.frames.is_empty() { return Ok(out); } let mut bound = 0.0; for f in &sel.frames { // An edge the wedges can't describe: no bound. let Some(w) = f.as_ref().and_then(|f| f.wedge(size)) else { return Ok(out) }; let Some(f) = f.as_ref().filter(|f| !f.chained) else { return Ok(out) }; // Past an edge's ends a chamfer may still cut under a sloping end face. let (sa, sb) = f.setbacks(size).unwrap_or((0.0, 0.0)); let len = f.p0.dist(f.p1); bound += w.volume * (len + 2.0 * sa.max(sb)) / len.max(1e-12); } // Well within the bound on the meshes the check made anyway: accepted. Otherwise measured // finely, as a coarse mesh of a cone or a blend is off by more than a small blend. if let Ok(m) = out.tessellate(sel.chord).map(|m| m.measure()) { let (va, aa) = sel.coarse; // A chord's sag over every face bounds what the meshes miss. let slack = (aa + m.area) * sel.chord; if (va - m.volume).abs() + slack <= bound * 1.02 { return Ok(out); } } let (Ok(ma), Ok(mb)) = (crate::measure(input), crate::measure(&out)) else { return Ok(out) }; let slack = ma.volume.abs() * 1e-4 + (ma.area + mb.area) * sel.size * 1e-5; let changed = (ma.volume - mb.volume).abs(); if changed > bound * 1.02 + slack { return Err(KernelError::TooLarge(format!( "{} mm is too large for this {}: the result changes {} mm³ of the part, more than the {} can (at most {} mm³)", show(size.value()), size.what(), show_rounded(changed), size.what(), show_rounded(bound) ))); } Ok(out) } /// A length in mm, without trailing zeros. fn show(v: f64) -> String { let s = format!("{v:.4}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } fn show_rounded(v: f64) -> String { format!("{v:.0}") }