diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 66551f4063..da9e666322 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -1119,6 +1119,9 @@ pub fn fillet_g2(body: &Body, edges: &[Vec3], radius: f64) -> Result { if edges.is_empty() || edges.len() > 1000 || edges.iter().any(|p| ![p.x, p.y, p.z].into_iter().all(f64::is_finite)) { return Err(KernelError::Invalid("G2 fillet needs 1 to 1000 finite edge points".into())); } + if crate::blend_smlib::selected()? { + return crate::blend_smlib::fillet_g2(body, edges, radius); + } let counts = record::counts(&[body]); let mut cur = crate::boolean_record::healed(body)?; for p in edges { @@ -1847,6 +1850,9 @@ fn closed(input: &Body, b: Body) -> Result { /// largest that works). pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { body.require_brep("fillet")?; + if crate::blend_smlib::selected()? { + return crate::blend_smlib::fillet(body, edges, radius); + } // A round that would run past its faces into other geometry or another round (#1080). // Checked once built, so a refusal of the build itself stands as it is. let size = crate::blend_fit::Size::Round(radius); @@ -1919,6 +1925,9 @@ fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, g2: bo /// unchanged. pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64) -> Result { body.require_brep("fillet")?; + if crate::blend_smlib::selected()? { + return Err(crate::blend_smlib::unsupported("a fillet with G2 corner patches")); + } if !(radius.is_finite() && radius > 0.0) { return Err(KernelError::Invalid("the blend radius must be finite and positive".into())); } @@ -2004,6 +2013,9 @@ 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())); } + if crate::blend_smlib::selected()? { + return crate::blend_smlib::fillet_each(body, edges, radii); + } if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) { return fillet(body, edges, *r); } @@ -2089,6 +2101,9 @@ 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())); } + if crate::blend_smlib::selected()? { + return crate::blend_smlib::chamfer(body, edges, distance); + } // A chamfer that would run past its faces into other geometry or another chamfer, or whose // result changes more than the chamfers can (#1080). let size = crate::blend_fit::Size::Chamfer(distance); @@ -2327,6 +2342,13 @@ 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())); } + if crate::blend_smlib::selected()? { + // SMLib has no solver for unequal setbacks: equal ones are its equal-distance chamfer. + return match side { + ChamferSide::Distance(d2) if d2 == d1 => crate::blend_smlib::chamfer(body, edges, d1), + _ => Err(crate::blend_smlib::unsupported("a chamfer with two different setbacks (two distances, or a distance and an angle)")), + }; + } let size = crate::blend_fit::Size::Sides(d1, side, flip); let counts = record::counts(&[body]); let cut = || -> Result { diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index 3def4625b1..54effa4780 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -33,6 +33,10 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result { if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) { return Err(KernelError::Invalid("fillet size must be positive".into())); } + if crate::blend_smlib::selected()? { + let (points, radii): (Vec, Vec) = edges.iter().copied().unzip(); + return crate::blend_smlib::fillet_each(body, &points, &radii); + } // Each step's result carries its history over `[body]`. let counts = crate::record::counts(&[body]); let mut cur = crate::boolean_record::healed(body)?; diff --git a/crates/kernel/src/blend_smlib.rs b/crates/kernel/src/blend_smlib.rs new file mode 100644 index 0000000000..2e208dc00d --- /dev/null +++ b/crates/kernel/src/blend_smlib.rs @@ -0,0 +1,329 @@ +//! Fillets and chamfers through SMLib, when [`crate::backend`] selects it. +//! +//! Each kernel entry point ([`crate::fillet`], [`crate::chamfer`], …) asks [`selected`] first and, +//! when SMLib is the kernel, hands its arguments here unchanged: the body goes into SMLib as its +//! schema 4 record (healed where SMLib's check refuses it as given), tagged as operand 0; each +//! point picks the body's nearest edge in its own edge order, as truck's blends do, and SMLib is +//! given that edge's midpoint, which no other edge is as near to (a point at a vertex between two +//! edges would leave SMLib to choose); SMLib blends those edges with their tangent chains, its check must pass, and the result comes back decoded with its +//! geometry checked. An option SMLib has no construction for is refused as "not supported yet", +//! never built by truck instead, so a comparison never compares truck with itself. +//! +//! The history is SMLib's tags, renumbered into the body's orders; a blend face, which SMLib +//! leaves untagged, comes from the edge it replaces ([`solidcraft_smlib::history::blends`]). + +use solidcraft_geom::Vec3; + +use crate::blend_variable::RadiusSpline; +use crate::body::Body; +use crate::{KernelError, Result}; + +/// Whether SMLib runs the blends ([`crate::backend`]). A setting naming SMLib in a build without +/// it, or naming no kernel, is the error it is for every operation. +pub(crate) fn selected() -> Result { + Ok(crate::backend()? == crate::Backend::Smlib) +} + +/// What SMLib cannot build yet: refused rather than built by truck. +pub(crate) fn unsupported(what: &str) -> KernelError { + KernelError::Invalid(format!("not supported yet: {what} with the SMLib kernel")) +} + +/// A constant-radius round of the edges nearest `edges` ([`crate::fillet`]). +pub(crate) fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { + imp::blend(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()]) +} + +/// Each edge rounded at its own radius ([`crate::fillet_each`]). +pub(crate) fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result { + if radii.len() != edges.len() { + return Err(KernelError::Invalid("one radius per edge".into())); + } + imp::blend(body, "fillet", Kind::Round, edges, radii) +} + +/// Curvature-continuous rounds with the setback of a round of `radius` ([`crate::fillet_g2`]). +pub(crate) fn fillet_g2(body: &Body, edges: &[Vec3], radius: f64) -> Result { + imp::blend(body, "G2 fillet", Kind::Curvature, edges, &vec![radius; edges.len()]) +} + +/// Rounds `chord` wide from contact to contact ([`crate::fillet_chord`]). +pub(crate) fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { + imp::blend(body, "chord fillet", Kind::Chord, edges, &vec![chord; edges.len()]) +} + +/// An equal-distance chamfer ([`crate::chamfer`]). +pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { + imp::blend(body, "chamfer", Kind::Chamfer, edges, &vec![distance; edges.len()]) +} + +/// One edge rounded with the radius `spline` (checked by the caller) along it from the end +/// nearest `start` ([`crate::fillet_variable_spline`]). +pub(crate) fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { + imp::variable(body, edge_at, start, spline) +} + +#[derive(Clone, Copy)] +enum Kind { + Round, + Curvature, + Chord, + Chamfer, +} + +#[cfg(feature = "smlib")] +mod imp { + use std::collections::BTreeSet; + + use solidcraft_geom::Vec3; + use solidcraft_smlib::{Blend, Brep, CheckLevel}; + + use super::Kind; + use crate::blend_variable::RadiusSpline; + use crate::body::Body; + use crate::history::{BodyHistory, EdgeOrigin, History}; + use crate::native::{body_from_smlib_ordered, smlib_body_ordered}; + use crate::{KernelError, Result, guard, record}; + + fn failed(what: &str, step: &str, e: impl std::fmt::Display) -> KernelError { + KernelError::Failed(format!("SMLib {what}: {step}: {e}")) + } + + /// How far a pick may lie from its edge: as far as truck's blends accept one. + fn pick_tolerance(body: &Body) -> f64 { + body.size() * 0.05 + 1e-3 + } + + /// The edges `picks` select: each one's index in the body's edge order and the point halfway + /// along it, for SMLib to pick. + fn resolve(body: &Body, picks: &[Vec3]) -> Result { + if picks.iter().any(|p| !p.is_finite()) { + return Err(KernelError::Invalid("edge picks must be finite points".into())); + } + let edges = body.edges((body.size() * 2e-3).max(1e-3))?; + let reach = pick_tolerance(body); + let mut out = Picked { edges: Vec::with_capacity(picks.len()), points: Vec::with_capacity(picks.len()) }; + for p in picks { + let mut best: Option<(f64, &crate::body::EdgeInfo)> = None; + for e in &edges { + for w in e.points.windows(2) { + if let [a, b] = w { + let d = p.dist_to_segment(*a, *b); + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, e)); + } + } + } + } + let Some((_, e)) = best.filter(|(d, _)| *d <= reach) else { + return Err(KernelError::Invalid(format!("no edge near the pick at ({:.3}, {:.3}, {:.3})", p.x, p.y, p.z))); + }; + out.edges.push(e.index); + out.points.push([e.mid.x, e.mid.y, e.mid.z]); + } + Ok(out) + } + + struct Picked { + /// Each pick's edge in the body's edge order. + edges: Vec, + /// A point halfway along each. + points: Vec<[f64; 3]>, + } + + fn sizes_ok(what: &str, sizes: &[f64]) -> Result<()> { + if sizes.is_empty() || sizes.len() > solidcraft_smlib::MAX_POINTS { + return Err(KernelError::Invalid(format!("{what}: select 1…{} edges", solidcraft_smlib::MAX_POINTS))); + } + if sizes.iter().any(|s| !(s.is_finite() && *s > 1e-6 && *s < 1e6)) { + return Err(KernelError::Invalid(format!("{what}: the size must be positive and finite"))); + } + Ok(()) + } + + pub(super) fn blend(body: &Body, what: &str, kind: Kind, edges: &[Vec3], sizes: &[f64]) -> Result { + body.require_brep(what)?; + sizes_ok(what, sizes)?; + let picked = resolve(body, edges)?; + let kind = match kind { + Kind::Round => Blend::Round, + Kind::Curvature => Blend::Curvature, + Kind::Chord => Blend::Chord, + Kind::Chamfer => Blend::Chamfer, + }; + let tol = pick_tolerance(body); + run(body, what, &picked.edges, |b| b.blend(kind, &picked.points, sizes, tol)) + } + + pub(super) fn variable(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { + body.require_brep("fillet")?; + if !start.is_finite() { + return Err(KernelError::Invalid("the start point must be finite".into())); + } + let picked = resolve(body, &[edge_at])?; + let [at] = picked.points[..] else { return Err(KernelError::Invalid("one edge".into())) }; + let tol = pick_tolerance(body); + run(body, "variable fillet", &picked.edges, |b| { + b.fillet_variable(at, tol, [start.x, start.y, start.z], spline.degree, &spline.knots, &spline.radii) + }) + } + + /// `op` on `body` in SMLib, tagged as operand 0, checked, decoded, with its history. + fn run(body: &Body, what: &str, edges: &[usize], op: impl FnOnce(&Brep) -> solidcraft_smlib::Result) -> Result { + guard(what, || { + let (sb, order) = smlib_body_ordered(body)?; + let tagged = sb.tagged(0).map_err(|e| failed(what, "tag", e))?; + let r = op(&tagged).map_err(|e| failed(what, "operation", e))?; + let check = r.check(CheckLevel::All).map_err(|e| failed(what, "check", e))?; + if !check.is_valid() { + let input_fails = sb.check(CheckLevel::All).is_ok_and(|c| !c.is_valid()); + let step = + if input_fails { "SMLib's check refuses the result (the input already fails it)" } else { "SMLib's check refuses the result" }; + return Err(failed(what, step, check)); + } + let (out, result_order) = body_from_smlib_ordered(&r)?; + let history = match (order, result_order) { + (Some(o), Some(ro)) => match r.history().map_err(|e| failed(what, "history", e))?.history(&[o], &ro) { + Ok(mut h) => { + let operand = History::identity(body).edges; + let replaced = blended(body, edges, &h); + solidcraft_smlib::history::blends(&mut h, &operand, &replaced); + Some(h) + } + Err(e) => { + log::debug!(target: "smlib", "{what} history: {e}"); + None + } + }, + _ => None, + }; + Ok(out.with_checked_history(history, &record::counts(&[body])).inherit_regularization_protection(body)) + }) + } + + /// The operand's edges the blend replaced: each picked edge, and every edge no result edge + /// comes from (the rest of the tangent chains), in the operand's edge order. + fn blended(body: &Body, picked: &[usize], h: &History) -> Vec { + let mut out: BTreeSet = picked.iter().copied().collect(); + let kept: BTreeSet = h + .edges + .iter() + .flat_map(|e| e.origins.iter()) + .filter_map(|o| match o { + EdgeOrigin::Edge { operand: 0, edge } => Some(*edge), + _ => None, + }) + .collect(); + out.extend((0..body.edge_count()).filter(|e| !kept.contains(e))); + out.into_iter().collect() + } +} + +#[cfg(not(feature = "smlib"))] +mod imp { + use solidcraft_geom::Vec3; + + use super::Kind; + use crate::blend_variable::RadiusSpline; + use crate::body::Body; + use crate::{KernelError, Result}; + + fn not_built() -> KernelError { + KernelError::Invalid("not supported yet: this build has no SMLib".into()) + } + + pub(super) fn blend(_: &Body, _: &str, _: Kind, _: &[Vec3], _: &[f64]) -> Result { + Err(not_built()) + } + + pub(super) fn variable(_: &Body, _: Vec3, _: Vec3, _: &RadiusSpline) -> Result { + Err(not_built()) + } +} + +#[cfg(all(test, feature = "smlib"))] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use solidcraft_geom::Vec3; + + use super::*; + use crate::history::{Change, FaceOrigin}; + use crate::{box_solid, measure}; + + const QUARTER: f64 = 1.0 - std::f64::consts::FRAC_PI_4; + + fn cube() -> Body { + box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 20.0, 20.0)).unwrap() + } + + fn volume(b: &Body) -> f64 { + measure(b).unwrap().volume + } + + /// Within the kernel measure's accuracy on a curved face (a few 1e-6 of the volume; SMLib's + /// own exact volumes match to 1e-3 mm³ in the C API's tests). + fn near(got: f64, want: f64) -> bool { + (got - want).abs() <= 5e-6 * want.abs() + } + + /// The edge of `b` nearest `p`, in its edge order. + fn edge_at(b: &Body, p: Vec3) -> usize { + b.nearest_edge(p, 1e-3).unwrap().unwrap().0 + } + + #[test] + fn a_round_loses_its_corner_and_its_face_comes_from_the_edge() { + let b = cube(); + let at = Vec3::new(10.0, 20.0, 20.0); + let r = imp::blend(&b, "fillet", Kind::Round, &[at], &[2.0]).unwrap(); + assert!(near(volume(&r), 8000.0 - QUARTER * 4.0 * 20.0), "{}", volume(&r)); + assert_eq!(r.face_count(), 7); + let h = r.history().expect("a history"); + let blends: Vec<_> = h.faces.iter().filter(|f| f.change == Change::Generated).collect(); + assert_eq!(blends.len(), 1); + assert_eq!(blends[0].origins, vec![FaceOrigin::Edge { operand: 0, edge: edge_at(&b, at) }]); + } + + #[test] + fn a_pick_at_a_vertex_takes_the_nearest_edge_by_its_midpoint() { + // (20, 20, 10) is on the vertical edge only; (20, 20, 20) is a corner of three edges, which + // the body's own order resolves as truck's blends do, never left to SMLib. + let b = cube(); + assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(20.0, 20.0, 20.0)], &[1.0]).is_ok()); + assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(60.0, 60.0, 60.0)], &[1.0]).is_err()); + } + + #[test] + fn each_edge_takes_its_own_size() { + let b = cube(); + let picks = [Vec3::new(10.0, 20.0, 20.0), Vec3::new(10.0, 0.0, 20.0)]; + let r = imp::blend(&b, "fillet", Kind::Round, &picks, &[2.0, 3.0]).unwrap(); + assert!(near(volume(&r), 8000.0 - QUARTER * 13.0 * 20.0), "{}", volume(&r)); + let c = imp::blend(&b, "chamfer", Kind::Chamfer, &picks, &[2.0, 3.0]).unwrap(); + assert!(near(volume(&c), 8000.0 - 0.5 * 13.0 * 20.0), "{}", volume(&c)); + assert_eq!(c.history().map(|h| h.faces.iter().filter(|f| matches!(f.origins[..], [FaceOrigin::Edge { .. }])).count()), Some(2)); + } + + #[test] + fn a_linear_radius_runs_from_the_start_end() { + // 3 at x = 0 to 1 at x = 20: (1 - pi/4) L (r0^2 + r0 r1 + r1^2) / 3 taken away, from + // either end given as the start with the law reversed. + let b = cube(); + let at = Vec3::new(10.0, 20.0, 20.0); + let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![3.0, 1.0] }; + let r = imp::variable(&b, at, Vec3::new(0.0, 20.0, 20.0), &law).unwrap(); + let want = 8000.0 - QUARTER * 20.0 * 13.0 / 3.0; + assert!((volume(&r) - want).abs() < 0.05, "{}", volume(&r)); + let back = imp::variable(&b, at, Vec3::new(20.0, 20.0, 20.0), &law.reversed()).unwrap(); + assert!((volume(&back) - volume(&r)).abs() < 1e-6 * 8000.0); + } + + #[test] + fn options_smlib_cannot_build_are_refused() { + assert!(unsupported("x").to_string().contains("not supported yet")); + let b = cube(); + assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(10.0, 20.0, 20.0)], &[f64::NAN]).is_err()); + assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(f64::NAN, 20.0, 20.0)], &[1.0]).is_err()); + assert!(imp::blend(&b, "fillet", Kind::Round, &[], &[]).is_err()); + } +} diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index 337408aa56..8dd8e4d91b 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -210,6 +210,9 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { body.require_brep("fillet")?; spline.check()?; + if crate::blend_smlib::selected()? { + return crate::blend_smlib::fillet_variable(body, edge_at, start, spline); + } let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; guard("variable fillet", || { let Ok(s) = EdgeSetup::new(body, &edge) else { @@ -599,6 +602,9 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } + if crate::blend_smlib::selected()? { + return crate::blend_smlib::fillet_chord(body, edges, chord); + } let found: Vec = edges .iter() .map(|p| crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))) diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index c97d7d51c1..6de9533299 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -16,6 +16,7 @@ mod backend; mod blend; mod blend_corner; mod blend_fit; +mod blend_smlib; mod blend_variable; mod blend_vertex; #[cfg(test)]