diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 516bd5b9ec..1a286f31e2 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -1,12 +1,11 @@ -//! Edge fillets and chamfers. SMLib blends the picked edges, each with its tangent chain, in one -//! call ([`crate::blend_smlib`]): the blend faces, the corners where blends meet (a sphere, a -//! patch, a bevel or a mitre, as its fillet corners choose) and the end faces rebuilt round -//! them. Before SMLib runs, a blend that would run past the faces beside its edge into other -//! geometry or into another blend is refused as too large, with the largest size that fits -//! ([`crate::blend_fit`]); after, a result that changed more than the blends can is refused too. +//! Edge fillets and chamfers. SMLib blends exactly the picked edges in one call +//! ([`crate::blend_smlib`]): the blend faces, the corners where blends meet (a sphere, a patch, a +//! bevel or a mitre, as its fillet corners choose) and the end faces rebuilt round them. A blend +//! whose contact lines would leave the faces it rolls on is SMLib's refusal, reported as +//! [`KernelError::TooLarge`]. A tangent chain is the caller's to select. //! -//! The helpers here read a body's schema 4 record for those checks: planar faces' outward -//! normals, the direction into a face across an edge, an edge's tangent at its ends. +//! The helpers here read a body's schema 4 record: planar faces' outward normals, the direction +//! into a face across an edge, an edge's tangent at its ends. use std::collections::BTreeSet; @@ -89,6 +88,7 @@ impl Faces { } /// The outward unit normal of face `f` at `p` (a point within `accept` of its surface). + #[cfg(test)] pub(crate) fn normal_at(&self, f: usize, p: Vec3, accept: f64) -> Option { let s = SurfaceEval::new(&self.rec.faces.get(f)?.surface).ok()?; let uv = s.project(p, None, accept)?; @@ -96,11 +96,6 @@ impl Faces { Some(su.cross(sv).normalized()? * self.side(f)?) } - /// The unit natural normal of face `f`'s surface at `p` (a point within `accept` of it). - pub(crate) fn natural_normal_at(&self, f: usize, p: Vec3, accept: f64) -> Option { - Some(self.normal_at(f, p, accept)? * self.side(f)?) - } - /// Every edge use in the face's loops. pub(crate) fn uses(&self, f: usize) -> impl Iterator { self.rec.faces.get(f).into_iter().flat_map(|face| face.loops.iter()).flat_map(|l| match l { @@ -215,9 +210,7 @@ fn closed(input: &Body, b: Body) -> Result { /// largest that works). pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { body.require_brep("fillet")?; - crate::blend_fit::fit(body, edges, crate::blend_fit::Size::Round(radius), || { - crate::blend_smlib::fillet(body, edges, radius).and_then(|b| closed(body, b)) - }) + crate::blend_smlib::fillet(body, edges, radius).and_then(|b| closed(body, b)) } /// [`fillet`] whose corners where three blended edges meet are G2 patches. SMLib's fillet @@ -306,7 +299,7 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Option<(Vec< } /// The fillet of each edge at its own radius (`radii` in `edges`' order), all in one SMLib -/// blend. Each round must fit beside its own edge (#1080). +/// blend. pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result { if radii.len() != edges.len() { return Err(KernelError::Invalid("one radius per edge".into())); @@ -315,9 +308,6 @@ pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result { return fillet(body, edges, *r); } body.require_brep("fillet")?; - for (p, r) in edges.iter().zip(radii) { - crate::blend_fit::check(body, std::slice::from_ref(p), crate::blend_fit::Size::Round(*r))?; - } crate::blend_smlib::fillet_each(body, edges, radii).and_then(|b| closed(body, b)) } @@ -369,7 +359,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } - crate::blend_fit::fit(body, edges, crate::blend_fit::Size::Chamfer(distance), || crate::blend_smlib::chamfer(body, edges, distance)) + crate::blend_smlib::chamfer(body, edges, distance) } /// The second side of an unequal chamfer: a distance along the other face, or the angle the @@ -396,9 +386,7 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } - crate::blend_fit::fit(body, edges, crate::blend_fit::Size::Sides(d1, side, flip), || { - crate::blend_smlib::chamfer_sides(body, edges, d1, side, flip) - }) + crate::blend_smlib::chamfer_sides(body, edges, d1, side, flip) } #[cfg(test)] diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs deleted file mode 100644 index 7c346b3e00..0000000000 --- a/crates/kernel/src/blend_fit.rs +++ /dev/null @@ -1,645 +0,0 @@ -//! 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 crate::blend::{ChamferSide, Faces}; -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: [u32; 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; - } - 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 + 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()?; - 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 * u.cross(w).len(); - Some(Wedge { planes, faces, volume: area * self.p0.dist(self.p1) }) - } -} - -/// 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; - 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 next; - } - cur = next; - } - 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 -/// 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), -} - -/// The selection on `body` as SMLib blends it: its record's faces and edges, in their order (the -/// mesh's edges and faces are in the same order). -fn select(body: &Body, pts: &[Vec3]) -> Result { - let size = body.size(); - let chord = (size * 1e-3).max(1e-4); - let mesh = body.mesh(&crate::MeshTolerance::chord(chord))?; - let rec = Faces::of(body)?; - let planar: Vec = (0..rec.rec.faces.len()).map(|f| rec.plane_normal(f).is_some()).collect(); - 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 count once. - let frame = match best.filter(|(i, dist)| *i < rec.rec.edges.len() && *dist <= size * 0.05 + 1e-3) { - Some((e, _)) if taken.contains(&e) => continue, - Some((e, _)) => { - taken.push(e); - frame(&rec, 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(rec: &Faces, e: usize) -> Option { - if !rec.straight(e) { - return None; - } - let ends = rec.rec.edges.get(e)?.vertices; - let (p0, p1) = rec.ends(e)?; - let d = (p1 - p0).normalized()?; - // Each face along the edge: its outward normal and the direction into it (the face lies to - // the left of its loop seen from its surface's natural normal). - let mut beside = Vec::new(); - let mut allowed = Vec::new(); - let mut chained = false; - for i in 0..rec.rec.faces.len() { - let at_end = rec.uses(i).any(|x| rec.rec.edges.get(x.edge as usize).is_some_and(|y| y.vertices.iter().any(|v| ends.contains(v)))); - if at_end { - allowed.push(i); - } - let mut uses: Vec<_> = rec.uses(i).filter(|x| x.edge as usize == e).collect(); - if uses.is_empty() { - continue; - } - let n = rec.plane_normal(i)?; - let u = uses.pop()?; - if !uses.is_empty() { - return None; - } - beside.push((n, rec.direction_into_face(i, u)?)); - chained |= rec.uses(i).filter(|x| x.edge as usize != e).any(|x| { - let Some(other) = rec.rec.edges.get(x.edge as usize) else { return false }; - let [a, b] = other.vertices; - let x = x.edge as usize; - (ends.contains(&a) && rec.tangent(x, false).is_some_and(|t| t.dot(d).abs() > 1.0 - 1e-6)) - || (ends.contains(&b) && rec.tangent(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 }) -} - -/// What a blend that doesn't fit runs into. -enum Clash { - Face, - Blend, -} - -/// 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(); - // What each blend changes: for a round only the fan between its edge and its arc, a part of - // the wedge. A face inside the wedge but beyond the arc (the inside round of a wall, when the - // outside one is a concentric round) is on the ball's side, left as it is. - let changes: Vec>> = sel.frames.iter().map(|f| f.as_ref().and_then(|f| f.pieces(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 = 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), changed)) in sel.frames.iter().zip(&wedges).zip(&changes).enumerate() { - let (Some(f), Some(w)) = (f, w) else { continue }; - // Without its pieces (or for a chamfer, whose one piece is the wedge) the wedge counts. - let changed = changed.as_deref().unwrap_or_default(); - 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 }; - // 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(); - // The pieces near the arc's ends are thin: a curved face only goes clear of them - // when its mesh stays a chord outside, or a sliver into the round is missed (#324). - let fan = if sel.planar.get(*face).copied().unwrap_or(false) { flat } else { -sel.chord }; - reaches_inside_beyond(t, &w.planes, eps, &trimmed, out) - && (changed.len() <= 1 || changed.iter().any(|p| reaches_inside_beyond(t, &p.planes, fan, &trimmed, out))) - }); - 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 && blends_cross(fi, wi, fj, wj, size, eps) { - return Some(Clash::Blend); - } - } - } - 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) -} - -/// Why a blend at `size` doesn't fit the selection 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_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, 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); - 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), true).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)))) -} - -/// The blend `build` makes of `edges` at `size` on `input`: refused before it is built when it -/// would not fit ([`check`]), and after when it changes more than the blends can -/// ([`check_volume`]) or goes the wrong way. -pub(crate) fn fit(input: &Body, edges: &[Vec3], size: Size, build: impl FnOnce() -> Result) -> Result { - if input.is_mesh() || edges.is_empty() { - return build(); - } - let sel = select(input, edges)?; - check_in(&sel, size)?; - let out = build()?; - if out.is_mesh() { - return Ok(out); - } - let out = volume_in(&sel, input, out, size)?; - right_way(input, out, edges, size) -} - -/// Whether the edge of `body` nearest `p` is convex (`Some(true)`: a chamfer takes material -/// away) or concave (`Some(false)`: it fills), from the two faces' outward normals and the -/// directions into each face across the edge at `p`. `None` unless that is clear-cut: faces -/// meeting at a few degrees of tangent or of a knife edge, an edge not between two faces. -fn convex_at(body: &Body, p: Vec3) -> Option { - let tol = (body.size() * 1e-4).max(1e-4); - let (i, _) = body.nearest_edge(p, tol).ok()??; - let rec = Faces::of(body).ok()?; - let edge = rec.rec.edges.get(i)?; - let t = nearest_parameter(&edge.curve, edge.interval, p)?; - let along = crate::blend::curve_tangent(&edge.curve, t)?; - let on = solidcraft_geom::brep::eval::CurveEval::new(&edge.curve).ok()?.point(t)?; - let accept = (body.size() * 1e-3).max(1e-3); - let mut sides = Vec::new(); - for f in rec.faces_of(i) { - let f = f as usize; - for u in rec.uses(f).filter(|u| u.edge as usize == i) { - let dir = if u.forward { along } else { along * -1.0 }; - // The face lies to the left of its loop seen from its surface's natural normal. - let (n, natural) = (rec.normal_at(f, on, accept)?, rec.natural_normal_at(f, on, accept)?); - sides.push((n, natural.cross(dir).normalized()?)); - } - } - let [(n1, t1), (n2, t2)] = sides[..] else { return None }; - // About 3°: the faces neither nearly tangent nor folded onto each other. - const CLEAR: f64 = 0.05; - if t1.dot(t2) > 1.0 - CLEAR || t1.dot(t2) < -1.0 + CLEAR { - return None; - } - let (a, b) = (n1.dot(t2), n2.dot(t1)); - if a < -CLEAR && b < -CLEAR { - Some(true) - } else if a > CLEAR && b > CLEAR { - Some(false) - } else { - None - } -} - -/// A chamfer of convex edges takes material away and one of concave edges fills it: a result -/// that went the other way (a chamfer on a curved edge running off a face it skims, which the -/// wedges can't bound) is refused rather than kept. Rounds are left to the wedges: a round -/// rolled along a smooth chain may run on round convex and concave stretches alike. -fn right_way(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result { - if matches!(size, Size::Round(_)) { - return Ok(out); - } - let ways: Option> = edges.iter().map(|p| convex_at(input, *p)).collect(); - let Some(ways) = ways else { return Ok(out) }; - let (convex, concave) = (ways.iter().all(|w| *w), ways.iter().all(|w| !*w)); - if !(convex || concave) { - return Ok(out); - } - let (Ok(a), Ok(b)) = (crate::measure(input), crate::measure(&out)) else { return Ok(out) }; - let slack = a.volume.abs() * 1e-5 + 1e-6; - let grew = b.volume - a.volume; - if (convex && grew > slack) || (concave && grew < -slack) { - return Err(KernelError::TooLarge(format!( - "{} mm is too large for this {}: on {} edge it would {} the part by {} mm³ instead (it runs off a face next to the edge)", - show(size.value()), - size.what(), - if convex { "a convex" } else { "a concave" }, - if convex { "grow" } else { "shrink" }, - // A fraction of a mm³ is typical here: to the hundredth. - show((grew.abs() * 100.0).round() / 100.0) - ))); - } - Ok(out) -} - -/// The parameter of the point of `c` within `interval` nearest `p`: the nearest of 33 evenly -/// spaced starts, refined by Gauss-Newton steps on the distance. -fn nearest_parameter(c: &solidcraft_geom::brep::Curve, interval: [f64; 2], p: Vec3) -> Option { - let e = solidcraft_geom::brep::eval::CurveEval::new(c).ok()?; - let [lo, hi] = interval; - if !(lo.is_finite() && hi.is_finite() && lo < hi) { - return None; - } - let at = |k: usize| lo + (hi - lo) * k as f64 / 32.0; - let mut t = (0..=32).map(at).filter_map(|t| Some((t, e.point(t)?.dist(p)))).min_by(|a, b| a.1.total_cmp(&b.1))?.0; - for _ in 0..32 { - let (q, d) = (e.point(t)?, crate::blend::curve_derivative(c, t)?); - if !(d.len2() > 0.0) { - break; - } - let step = (p - q).dot(d) / d.len2(); - t = (t + step).clamp(lo, hi); - if step.abs() <= (hi - lo) * 1e-12 { - break; - } - } - Some(t) -} - -/// 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.mesh(&crate::MeshTolerance::chord(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}") -} diff --git a/crates/kernel/src/blend_smlib.rs b/crates/kernel/src/blend_smlib.rs index ae2a1011c9..be9577771d 100644 --- a/crates/kernel/src/blend_smlib.rs +++ b/crates/kernel/src/blend_smlib.rs @@ -199,6 +199,7 @@ mod imp { let r = op(&tagged).map_err(|e| match e { SmError::Unsupported => super::unsupported(&format!("this {what}, which SMLib cannot build exactly")), SmError::NoEffect => KernelError::Failed(format!("SMLib made no change: the {what} reported success but built no face")), + SmError::NoRoom => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")), e => failed(what, "operation", e), })?; let check = r.check(CheckLevel::All).map_err(|e| failed(what, "check", e))?; diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index d7544ced04..a8c087ba7b 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -198,24 +198,14 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & } /// Fillet one edge with the radius `spline` along it, its positions from the end nearest -/// `start`. One end's radius may be zero. On a straight edge between planar faces a law whose -/// band runs past the faces anywhere along the edge is refused as too large. +/// `start`. One end's radius may be zero. A law whose round would leave the faces beside the +/// edge is SMLib's refusal ([`KernelError::TooLarge`]). pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { body.require_brep("fillet")?; spline.check()?; if !(edge_at.is_finite() && start.is_finite()) { return Err(KernelError::Invalid("the edge pick and the start point must be finite".into())); } - if let Ok(s) = EdgeSetup::new(body, edge_at) { - // Positions from the end nearest `start` (the edge keeps its own direction). - let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() }; - const STATIONS: usize = 256; - let fits = - guard("variable fillet", || Ok((1..STATIONS).all(|k| s.band_fits(k as f64 / STATIONS as f64, law.eval(k as f64 / STATIONS as f64)))))?; - if !fits { - return Err(too_large("the blend is larger than the neighbouring faces")); - } - } crate::blend_smlib::fillet_variable(body, edge_at, start, spline) } diff --git a/crates/kernel/src/issue_1080_tests.rs b/crates/kernel/src/issue_1080_tests.rs index e7ac18c551..09e8c3497f 100644 --- a/crates/kernel/src/issue_1080_tests.rs +++ b/crates/kernel/src/issue_1080_tests.rs @@ -80,21 +80,6 @@ fn chamfers_meeting_at_a_corner_still_build() { assert!(crate::fillet(&b, &edges, 5.0).is_ok()); } -/// A result that changes more of the part than the blends can is refused (#871's shelled box -/// lost 240 mm³ more than any round of its edge could take). -#[test] -fn a_result_that_removes_more_than_the_blend_is_refused() { - let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 20.0, 20.0)).unwrap(); - let e = [Vec3::new(10.0, 0.0, 20.0)]; - let size = crate::blend_fit::Size::Chamfer(2.0); - let good = crate::chamfer(&b, &e, 2.0).unwrap(); - assert!(crate::blend_fit::check_volume(&b, good, &e, size).is_ok()); - // A chamfer and a 1 mm slab off the far side as well. - let worse = cut(&crate::chamfer(&b, &e, 2.0).unwrap(), [0.0, 19.0, 0.0], [20.0, 20.0, 20.0]); - 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 leaves /// the sharp inside corner 2 mm away on its ball's side, 0.76 mm inside the round; it reaches /// the corner only past 2√2 / (√2 - 1) ≈ 6.83 mm. With the inside edge rounded in the same diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 75bf796638..8826a52463 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -15,7 +15,6 @@ mod assemble; mod blend; mod blend_corner; -mod blend_fit; mod blend_smlib; mod blend_variable; #[cfg(test)] diff --git a/crates/kernel/src/plane_fit.rs b/crates/kernel/src/plane_fit.rs index 5ba2985c15..7b3a828348 100644 --- a/crates/kernel/src/plane_fit.rs +++ b/crates/kernel/src/plane_fit.rs @@ -12,4 +12,3 @@ pub(crate) fn loop_area(points: &[Vec3]) -> Vec3 { } n * 0.5 } - diff --git a/crates/smlib/src/ffi.rs b/crates/smlib/src/ffi.rs index 90b261312b..6f4f2554be 100644 --- a/crates/smlib/src/ffi.rs +++ b/crates/smlib/src/ffi.rs @@ -191,8 +191,8 @@ pub mod flat; pub mod history; pub mod measure; pub mod ops; -pub mod split; pub mod sheets; +pub mod split; pub mod ssi; pub mod sweeps; pub mod trim; diff --git a/crates/smlib/src/lib.rs b/crates/smlib/src/lib.rs index 8b683d17f4..059129e5e0 100644 --- a/crates/smlib/src/lib.rs +++ b/crates/smlib/src/lib.rs @@ -48,7 +48,8 @@ pub enum Error { NoMatch { point: usize }, /// Selection point `point` is equally near to two edges (faces). Ambiguous { point: usize }, - /// An inward offset (a shell's wall, faces moved in) leaves no room inside the body. + /// No room for the operation: an inward offset (a shell's wall, faces moved in) leaves none + /// inside the body, or a blend is larger than the faces it rolls on allow. NoRoom, /// SMLib cannot build this exactly yet (a variable radius whose law has a corner). Unsupported, @@ -80,7 +81,7 @@ impl std::fmt::Display for Error { Error::NotSolid => write!(f, "SMLib's result is not a closed solid"), Error::NoMatch { point } => write!(f, "selection point {} is not near any edge or face", point + 1), Error::Ambiguous { point } => write!(f, "selection point {} is equally near to two edges or faces", point + 1), - Error::NoRoom => write!(f, "the offset leaves no room inside the body"), + Error::NoRoom => write!(f, "there is no room for it: an offset leaves none inside the body, or a blend is larger than its faces allow"), Error::Unsupported => write!(f, "not supported yet: SMLib cannot build this exactly"), Error::NoEffect => write!(f, "SMLib made no change: it reported success, but the operation did nothing"), Error::NotExact => write!(f, "SMLib cannot build this exactly in the input's geometry"), diff --git a/crates/smlib/src/ops.rs b/crates/smlib/src/ops.rs index 0e18c4d719..b0debecd90 100644 --- a/crates/smlib/src/ops.rs +++ b/crates/smlib/src/ops.rs @@ -121,21 +121,21 @@ impl Brep { ffi::ops::solid_count(&self.0).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error) } - /// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, rounded + /// The edges nearest to `edges` (each within `tolerance`), exactly those, rounded /// at `radius`. pub fn fillet(&self, edges: &[[f64; 3]], tolerance: f64, radius: f64) -> Result { self.pick_op(ffi::ops::Pick::Fillet, edges, tolerance, radius, false) } - /// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, cut by + /// The edges nearest to `edges` (each within `tolerance`), exactly those, cut by /// an equal-distance chamfer `distance` back along each face (measured at each edge's /// midpoint), with bevelled corners. pub fn chamfer(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64) -> Result { self.pick_op(ffi::ops::Pick::Chamfer, edges, tolerance, distance, false) } - /// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, blended - /// as `kind`, edge i (and its chain) at `sizes[i]`. + /// The edges nearest to `edges` (each within `tolerance`), exactly those, blended + /// as `kind`, edge i at `sizes[i]`. pub fn blend(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result { init()?; if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() { @@ -150,7 +150,7 @@ impl Brep { ffi::ops::blend(kind, &self.0, edges, sizes, tolerance).map(Brep).map_err(pick_error) } - /// The one edge nearest to `edge` (within `tolerance`, no tangent chain) rounded with a radius + /// The one edge nearest to `edge` (within `tolerance`) rounded with a radius /// that varies along it: a clamped B-spline of `degree` (1 to 3) with `knots` and control /// `radii` over the position along the edge, 0 at its end nearest `start` and 1 at the other /// (the edge curve's parameter scaled to 0..1). @@ -164,7 +164,7 @@ impl Brep { }) } - /// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, cut by + /// The edges nearest to `edges` (each within `tolerance`), exactly those, cut by /// a chamfer `distance` back along the face whose outward normal leans most along `first`, /// and by `second` on the other, with bevelled corners (setbacks measured at each edge's /// midpoint). diff --git a/crates/smlib/src/ops_tests.rs b/crates/smlib/src/ops_tests.rs index a74b898da7..c29a63850c 100644 --- a/crates/smlib/src/ops_tests.rs +++ b/crates/smlib/src/ops_tests.rs @@ -163,7 +163,7 @@ fn shell_a_box_open_at_the_top() { } #[test] -fn fillet_follows_a_tangent_chain() { +fn fillet_takes_exactly_the_picked_edges() { // Round the vertical edge x = y = 20 first (r = 5): the top rim is then line, arc, line, // meeting tangentially. let c = cube(20.0); @@ -171,15 +171,16 @@ fn fillet_follows_a_tangent_chain() { shot(&round, "fillet_one_edge"); let pi = std::f64::consts::PI; assert_close(volume(&round), 8000.0 - (1.0 - pi / 4.0) * 25.0 * 20.0, 1e-6, "one rounded edge"); - // One point on the straight part of the rim rounds the whole chain. - let chain = round.fillet(&[[7.5, 20.0, 20.0]], 0.1, 1.0).unwrap(); - shot(&chain, "fillet_tangent_chain"); + // One point on the straight part of the rim rounds that edge only; the chain is rounded when + // all of its edges are picked. + let one = round.fillet(&[[7.5, 20.0, 20.0]], 0.1, 1.0).unwrap(); + shot(&one, "fillet_one_of_a_chain"); let arc = [15.0 + 5.0 * (pi / 4.0).cos(), 15.0 + 5.0 * (pi / 4.0).sin(), 20.0]; let all = round.fillet(&[[7.5, 20.0, 20.0], arc, [20.0, 7.5, 20.0]], 0.1, 1.0).unwrap(); shot(&all, "fillet_chain_picked"); - assert_eq!(chain.counts().unwrap(), all.counts().unwrap(), "one pick = the whole chain"); - assert_close(volume(&chain), volume(&all), 1e-9, "one pick = the whole chain"); - assert_eq!(chain.counts().unwrap()[0], round.counts().unwrap()[0] + 3, "three rounds on the chain"); + assert_eq!(one.counts().unwrap()[0], round.counts().unwrap()[0] + 1, "one round on the picked edge"); + assert_eq!(all.counts().unwrap()[0], round.counts().unwrap()[0] + 3, "three rounds on the chain"); + assert!(volume(&all) < volume(&one), "the chain takes more than its one edge"); } #[test]