diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 15eb005d..5368ef75 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -997,13 +997,16 @@ pub enum FilletStyle { } /// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default), -/// or reduced to the largest that works for all of them (`Reduce`), with a warning. +/// reduced to the largest that works for all of them (`Reduce`), or tapered along each edge it +/// doesn't fit to the room there, down to zero where a face closes to a point (`Taper`); a +/// warning says what was done. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FilletFit { #[default] Exact, Reduce, + Taper, } impl FilletFit { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e8280901..268d27fa 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -265,7 +265,8 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result Ok((b, used.iter().any(|u| *u < r).then_some(used))), None => Err(kernel::KernelError::TooLarge(why)), }, + Err(kernel::KernelError::TooLarge(_)) if fit == crate::FilletFit::Taper => kernel::fillet_tapered(body, edges, r).map(|(b, _)| (b, None)), other => other.map(|b| (b, None)), } } @@ -296,6 +298,15 @@ fn reduced_note(used: &[f64], asked: f64) -> String { } } +/// The warning of a fillet that tapered on some of its edges (a taper per edge, None where the +/// radius is constant), naming the smallest radius a taper ends at. +fn tapered_note(tapers: &[Option]) -> Option { + let ends: Vec = tapers.iter().flatten().flat_map(|t| [t.spline.radii.first(), t.spline.radii.last()]).flatten().copied().collect(); + let low = ends.into_iter().reduce(f64::min)?; + let n = tapers.iter().flatten().count(); + Some(format!("the radius tapers to the room on {n} of {} edges, down to {} mm", tapers.len(), show_mm(low))) +} + /// Add `note` to a feature's warning. fn append_warning(warning: &mut Option, note: String) { *warning = Some(match warning.take() { @@ -3883,6 +3894,13 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: None => return Err(DocError::Invalid("no edges to fillet".into())), } } + FeatureKind::Fillet { fit: crate::FilletFit::Taper, .. } => { + let (b, tapers) = kernel::fillet_tapered(&mb.body, &edges, r)?; + if let Some(note) = tapered_note(&tapers) { + append_warning(warning, note); + } + (b, input) + } FeatureKind::Fillet { fit, .. } => { let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?; if let Some(used) = used { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 7715ac5a..27f15e45 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -85,6 +85,8 @@ //! edges of a solid or shelled body that become bends). A conversion without `bends` is the //! plate conversion it always was and saves as before; 0.8.0 would drop the bend edges and //! refuse the body as no plate. +//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't +//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit. use serde_json::Value; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index d626e758..27e914a6 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("fillet") .key("F") - .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning)"), + .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"), CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) @@ -808,10 +808,11 @@ fn fillet(s: &mut Session, p: &Value) -> Result { let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() { None | Some("exact") => solidcraft_doc::FilletFit::Exact, Some("reduce") => solidcraft_doc::FilletFit::Reduce, - Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact or reduce)"))), + Some("taper") => solidcraft_doc::FilletFit::Taper, + Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact, reduce or taper)"))), }; - if fit == solidcraft_doc::FilletFit::Reduce && !style.is_constant() { - return Err(bad(cmd, "`fit: reduce` takes a constant radius")); + if !fit.is_exact() && !style.is_constant() { + return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius")); } let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?; // The radii the fillet used, when `fit: reduce` reduced it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 250191d6..f759db2e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2926,9 +2926,49 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() { // Saved only when set. let json = serde_json::to_string(&s.doc.features).unwrap(); assert_eq!(json.matches("\"fit\":\"reduce\"").count(), 1, "{json}"); - // An unknown mode is refused, and so is reduce with a radius that is not constant. - assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper"})).is_err()); + // An unknown mode is refused, and so are reduce and taper with a radius that is not constant. + assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "stretch"})).is_err()); assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper", "type": "chord"})).is_err()); +} + +#[test] +fn fillet_fit_taper_follows_the_room_along_the_edge() { + // #293: the top front edge of a plate whose top narrows from 20 to `tip` mm along 40 mm. + // At a 2 mm tip it can't take 3 mm; taper keeps 3 where it fits and comes down to 0.9 of the + // 2 mm there. With an 8 mm tip 3 fits and stays constant; a plate closing to a point + // tapers to 0. The warning says what was done. + let (k, edge) = (1.0 - PI / 4.0, json!([[20, 0, 15]])); + let plate = |tip: f64| { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let pts = if tip > 0.0 { json!([[0, 0], [40, 0], [40, tip], [0, 20]]) } else { json!([[0, 0], [40, 0], [0, 20]]) }; + run(&mut s, "sketch.line", json!({"points": pts, "closed": true})); + run(&mut s, "solid.extrude", json!({"distance": 15})); + s + }; + let warning = |s: &Session, out: &Value| out["feature"].as_u64().and_then(|id| s.model.result(id)).and_then(|r| r.warning.clone()); + + let mut s = plate(2.0); + assert!(s.execute("solid.fillet", &json!({"edges": edge, "radius": 3})).is_err()); + let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"})); + let w = warning(&s, &out).unwrap_or_default(); + assert!(w.contains("tapers to the room on 1 of 1 edges, down to 1.8 mm"), "{w}"); + // Between the material a constant 1.8 and a constant 3 would remove. + let (v, full) = (volume(&mut s), 40.0 * 22.0 / 2.0 * 15.0); + assert!(v < full - k * 1.8 * 1.8 * 40.0 && v > full - k * 9.0 * 40.0, "{v}"); + let json = serde_json::to_string(&s.doc.features).unwrap(); + assert_eq!(json.matches("\"fit\":\"taper\"").count(), 1, "{json}"); + + let mut s = plate(8.0); + let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"})); + assert_eq!(warning(&s, &out), None); + assert!(rel(volume(&mut s), 40.0 * 28.0 / 2.0 * 15.0 - k * 9.0 * 40.0) < 2e-4); + + let mut s = plate(0.0); + let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 16, "fit": "taper"})); + let w = warning(&s, &out).unwrap_or_default(); + assert!(w.contains("on 1 of 1 edges, down to 0 mm"), "{w}"); } #[test] diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index ae5874ad..e9478f25 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use solidcraft_geom::Vec3; use truck_modeling::{self as mt, builder}; -use crate::blend::{plane_normal, rebuild_face, unsupported, vtx}; +use crate::blend::{plane_normal, rebuild_face, too_large, unsupported, vtx}; use crate::body::{Body, Solid, p3}; use crate::{KernelError, Result, guard}; @@ -60,23 +60,25 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64 fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false) } -/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front): -/// degree, knots and control radii. -struct RadiusLaw { - deg: usize, - knots: Vec, - radii: Vec, +/// A fillet radius along an edge: a clamped B-spline of the position along it (0 at one end, 1 +/// at the other), given by its degree, knots and control radii. The radius is a smooth curve +/// shaped by its control radii, each pulling it toward itself near its Greville position. +#[derive(Clone, Debug, PartialEq)] +pub struct RadiusSpline { + pub degree: usize, + pub knots: Vec, + pub radii: Vec, } -impl RadiusLaw { +impl RadiusSpline { /// Through (position, radius) points sorted by position from 0 to 1: straight between them, /// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point). - fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw { + pub fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusSpline { if !smooth || pts.len() < 3 { let mut knots = vec![0.0]; knots.extend(pts.iter().map(|p| p.0)); knots.push(1.0); - return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() }; + return RadiusSpline { degree: 1, knots, radii: pts.iter().map(|p| p.1).collect() }; } let n = pts.len(); let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0)); @@ -92,37 +94,65 @@ impl RadiusLaw { radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]); knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] }); } - RadiusLaw { deg: 3, knots, radii } + RadiusSpline { degree: 3, knots, radii } } /// Greville abscissae: where each control radius sits along the edge. - fn greville(&self) -> Vec { - (0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::() / self.deg as f64).collect() + pub fn greville(&self) -> Vec { + (0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.degree).sum::() / self.degree as f64).collect() } - /// The radius at `t` (sampled for checks). - fn eval(&self, t: f64) -> f64 { + /// The radius at position `t`. + pub fn eval(&self, t: f64) -> f64 { let kv = mt::KnotVec::from(self.knots.clone()); - let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default(); + let b = kv.try_bspline_basis_functions(self.degree, t.clamp(0.0, 1.0)).unwrap_or_default(); b.iter().zip(&self.radii).map(|(x, r)| x * r).sum() } + + /// The same radius with its positions taken from the other end. + pub fn reversed(&self) -> RadiusSpline { + RadiusSpline { + degree: self.degree, + knots: self.knots.iter().rev().map(|k| 1.0 - k).collect(), + radii: self.radii.iter().rev().copied().collect(), + } + } + + /// Refused unless it is a clamped B-spline from 0 to 1 of degree 1 to 3 with 2 to 100 control + /// radii, positive along the edge and zero at most at one end. + fn check(&self) -> Result<()> { + let (n, deg) = (self.radii.len(), self.degree); + if !(1..=3).contains(°) || !(2..=100).contains(&n) || n <= deg || self.knots.len() != n + deg + 1 { + return Err(KernelError::Invalid("a radius spline has degree 1…3, 2…100 control radii and as many knots as both and one more".into())); + } + let clamped = self.knots.iter().take(deg + 1).all(|k| *k == 0.0) && self.knots.iter().skip(n).all(|k| *k == 1.0); + if !clamped || !self.knots.iter().all(|k| k.is_finite()) || self.knots.windows(2).any(|w| w[1] < w[0]) { + return Err(KernelError::Invalid("radius spline knots rise from 0 to 1, repeated degree + 1 times at each end".into())); + } + if self.radii.iter().any(|r| !(r.is_finite() && (*r == 0.0 || *r > 1e-6) && *r < 1e6)) { + return Err(KernelError::Invalid("fillet radii must be positive (zero only at an end)".into())); + } + let zeros: Vec = (0..n).filter(|i| self.radii.get(*i) == Some(&0.0)).collect(); + if !matches!(zeros[..], [] | [0]) && zeros[..] != [n - 1] { + return Err(KernelError::Invalid("the radius may be zero at one end of the edge only".into())); + } + // Positive inside the edge (a zero end is given exactly, as a control radius). + if (1..200).any(|k| self.eval(k as f64 / 200.0) <= 1e-6) { + return Err(KernelError::Invalid("the radius must stay positive along the edge".into())); + } + Ok(()) + } } /// Fillet one straight edge with a radius that varies along it: (position, radius) points with /// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius /// straight between them or `smooth`. One end's radius may be zero. pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result { - body.require_brep("fillet")?; if points.len() < 2 || points.len() > 100 { return Err(KernelError::Invalid("a variable radius needs 2…100 points".into())); } - for (t, r) in points { - if !(r.is_finite() && (*r == 0.0 || *r > 1e-6) && *r < 1e6) { - return Err(KernelError::Invalid("fillet radii must be positive (zero only at an end)".into())); - } - if !(t.is_finite() && (0.0..=1.0).contains(t)) { - return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into())); - } + if points.iter().any(|(t, _)| !(t.is_finite() && (0.0..=1.0).contains(t))) { + return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into())); } let mut pts = points.to_vec(); pts.sort_by(|a, b| a.0.total_cmp(&b.0)); @@ -130,187 +160,375 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 { return Err(KernelError::Invalid("give the radius at both ends of the edge".into())); } - let zeros: Vec = pts.iter().enumerate().filter(|(_, p)| p.1 == 0.0).map(|(i, _)| i).collect(); - if !matches!(zeros[..], [] | [0]) && zeros[..] != [pts.len() - 1] { - return Err(KernelError::Invalid("the radius may be zero at one end of the edge only".into())); - } + fillet_variable_spline(body, edge_at, start, &RadiusSpline::through(&pts, smooth)) +} + +/// Fillet one straight edge with the radius `spline` along it, its positions from the end +/// nearest `start`. One end's radius may be zero. +pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { + body.require_brep("fillet")?; + spline.check()?; let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; - let size = body.size(); guard("variable fillet", || { - let solid: &Solid = &body.solid; + let s = EdgeSetup::new(body, &edge)?; + // 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() }; + build(body, &s, &edge, &law) + }) +} + +/// A straight edge between two planar faces as the variable blend sees it: the shell it is in +/// and that shell's faces, the frame at the edge, and its ends (front first). +struct EdgeSetup<'a> { + si: usize, + faces: Vec<&'a mt::Face>, + fr: EdgeFrame, + edge: mt::EdgeID, + v0: mt::Vertex, + v1: mt::Vertex, + p0: Vec3, + p1: Vec3, + d: Vec3, + tol: f64, +} + +impl<'a> EdgeSetup<'a> { + fn new(body: &'a Body, edge: &mt::Edge) -> Result> { if !matches!(edge.curve(), mt::Curve::Line(_)) { return Err(unsupported("only straight edges take a variable radius")); } - let shells = solid.boundaries(); - let (si, shell) = shells + let (si, shell) = body + .solid + .boundaries() .iter() .enumerate() .find(|(_, sh)| sh.edge_iter().any(|e| e.id() == edge.id())) .ok_or_else(|| KernelError::Failed("edge not found".into()))?; let faces: Vec<&mt::Face> = shell.face_iter().collect(); - let fr = edge_frame(&faces, &edge)?; + let fr = edge_frame(&faces, edge)?; let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone()); - // Positions from the end nearest `start` (the edge keeps its own direction). - if vtx(&v1).dist(start) < vtx(&v0).dist(start) { - pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect(); - } - let law = RadiusLaw::through(&pts, smooth); - // The end radii are the first and last control radii (exact: a zero end closes the blend). - let (ra, rb) = (law.radii.first().copied().unwrap_or(0.0), law.radii.last().copied().unwrap_or(0.0)); - // The largest radius along the edge decides whether the blend fits. - let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max); - // Positive inside the edge (a zero end is given exactly, as a control radius). - if (1..200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) { - return Err(KernelError::Invalid("the radius must stay positive along the edge".into())); - } let (p0, p1) = (vtx(&v0), vtx(&v1)); let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?; - let tol = (size * 1e-7).max(1e-9); - let back = |r: f64| r / (fr.phi / 2.0).tan(); - let arc_mid = |p: Vec3, s: f64, r: f64| { - let c = p + fr.t1 * s + fr.n1 * (r * fr.side); - c + (p - c).normalized().unwrap_or(-fr.n1) * r - }; - // Each end: the end face, its edges along the two faces, the blend's corners on them and - // the arc between. At a zero radius the blend closes to the end vertex (no arc), which - // stays with its edges. - struct End { - v: mt::Vertex, - g: usize, - e1: mt::Edge, - e2: mt::Edge, - a: mt::Vertex, - b: mt::Vertex, - arc: Option, + Ok(EdgeSetup { si, faces, fr, edge: edge.id(), v0, v1, p0, p1, d, tol: (body.size() * 1e-7).max(1e-9) }) + } + + /// How far a blend of radius `r` reaches into each face from the edge. + fn back(&self, r: f64) -> f64 { + r / (self.fr.phi / 2.0).tan() + } + + /// Whether the band a blend of radius `r` takes from the faces at position `u` (strictly + /// between the ends) stays inside both, clear of their other edges and holes. + fn band_fits(&self, u: f64, r: f64) -> bool { + let p = self.p0 + (self.p1 - self.p0) * u; + let s = self.back(r); + [(self.fr.i1, self.fr.t1), (self.fr.i2, self.fr.t2)] + .into_iter() + .all(|(i, t)| self.faces.get(i).is_some_and(|f| crate::blend::extension_fits(f, p, p + t * s, self.tol))) + } + + /// The largest radius up to `cap` whose band fits at position `u`. + fn room(&self, u: f64, cap: f64) -> f64 { + if self.band_fits(u, cap) { + return cap; } - let mut ends = Vec::new(); - for (v, p, r) in [(&v0, p0, ra), (&v1, p1, rb)] { - let at_v: Vec = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect(); - let others: Vec = at_v.iter().copied().filter(|i| *i != fr.i1 && *i != fr.i2).collect(); - let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) }; - let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?; - let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?; - if ng.dot(d).abs() < 1.0 - 1e-9 { + let (mut lo, mut hi) = (0.0, cap); + while hi - lo > self.tol { + let mid = (lo + hi) / 2.0; + if self.band_fits(u, mid) { lo = mid } else { hi = mid } + } + lo + } + + /// The room at the end `v` (`out` pointing away from the edge there), as a radius: zero + /// where a face closes to a point (its next side runs back along the edge), the reach of the + /// sides along the faces where they are square to the edge, None where a side leans outward. + fn end_room(&self, v: &mt::Vertex, out: Vec3) -> Option { + let mut room = f64::INFINITY; + for (i, t) in [(self.fr.i1, self.fr.t1), (self.fr.i2, self.fr.t2)] { + let side = self.faces.get(i)?.edge_iter().find(|e| e.id() != self.edge && (e.front() == v || e.back() == v))?; + let dir = leaving(&side, v, self.tol)?; + let lean = dir.dot(out); + if lean < -1e-9 { + return Some(0.0); + } + if lean > 1e-9 || !matches!(side.curve(), mt::Curve::Line(_)) { + return None; + } + let other = if side.front() == v { side.back() } else { side.front() }; + room = room.min((vtx(other) - vtx(v)).dot(t) * (self.fr.phi / 2.0).tan()); + } + Some(room) + } +} + +/// The direction `edge` leaves its end `v` in (along its first piece within `tol`). +fn leaving(edge: &mt::Edge, v: &mt::Vertex, tol: f64) -> Option { + use mt::{BoundedCurve, ParameterDivision1D}; + let curve = edge.curve(); + let pts = curve.parameter_division(curve.range_tuple(), tol.max(1e-6)).1; + let p = vtx(v); + let near = if crate::body::from_p3(*pts.first()?).dist(p) <= crate::body::from_p3(*pts.last()?).dist(p) { + pts.get(1)? + } else { + pts.get(pts.len().checked_sub(2)?)? + }; + (crate::body::from_p3(*near) - p).normalized() +} + +/// Build the blend of the edge of `s` with the radius `law` (its positions from the edge's front). +fn build(body: &Body, s: &EdgeSetup, edge: &mt::Edge, law: &RadiusSpline) -> Result { + let (faces, fr, tol) = (&s.faces, &s.fr, s.tol); + let (v0, v1, p0, p1, d) = (&s.v0, &s.v1, s.p0, s.p1, s.d); + // The end radii are the first and last control radii (exact: a zero end closes the blend). + let (ra, rb) = (law.radii.first().copied().unwrap_or(0.0), law.radii.last().copied().unwrap_or(0.0)); + let back = |r: f64| s.back(r); + let arc_mid = |p: Vec3, sb: f64, r: f64| { + let c = p + fr.t1 * sb + fr.n1 * (r * fr.side); + c + (p - c).normalized().unwrap_or(-fr.n1) * r + }; + // Each end: the blend's corners on the two faces and, with a radius, the arc between them, + // the end face and its edges along the two faces. At a zero radius the blend closes to the + // end vertex, and the faces around it stay as they are. + struct Arc { + v: mt::Vertex, + g: usize, + e1: mt::Edge, + e2: mt::Edge, + edge: mt::Edge, + } + struct End { + a: mt::Vertex, + b: mt::Vertex, + arc: Option, + } + let mut ends = Vec::new(); + for (v, p, r) in [(v0, p0, ra), (v1, p1, rb)] { + if r == 0.0 { + ends.push(End { a: v.clone(), b: v.clone(), arc: None }); + continue; + } + let at_v: Vec = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect(); + let others: Vec = at_v.iter().copied().filter(|i| *i != fr.i1 && *i != fr.i2).collect(); + let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) }; + let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?; + let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?; + if ng.dot(d).abs() < 1.0 - 1e-9 { + return Err(unsupported("the faces at the edge ends must be perpendicular to the edge")); + } + let shared = |fi: usize| -> Result { + let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; + f.edge_iter() + .find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id())) + .ok_or_else(|| unsupported("unexpected corner topology")) + }; + let (e1, e2) = (shared(fr.i1)?, shared(fr.i2)?); + for (e, t) in [(&e1, fr.t1), (&e2, fr.t2)] { + if !matches!(e.curve(), mt::Curve::Line(_)) { + return Err(unsupported("the edges at the corners must be straight")); + } + let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) }; + let along = (other - p).dot(t); + if (other - p - t * along).len() > tol * 100.0 + 1e-7 { return Err(unsupported("the faces at the edge ends must be perpendicular to the edge")); } - let shared = |fi: usize| -> Result { - let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; - f.edge_iter() - .find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id())) - .ok_or_else(|| unsupported("unexpected corner topology")) - }; - let (e1, e2) = (shared(fr.i1)?, shared(fr.i2)?); - for (e, t) in [(&e1, fr.t1), (&e2, fr.t2)] { - if !matches!(e.curve(), mt::Curve::Line(_)) { - return Err(unsupported("the edges at the corners must be straight")); - } - let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) }; - let along = (other - p).dot(t); - if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(rmax) + tol { - return Err(unsupported("the blend is larger than the neighbouring faces")); - } - } - let end = if r == 0.0 { - End { v: v.clone(), g, e1, e2, a: v.clone(), b: v.clone(), arc: None } - } else { - let s = back(r); - let (a, b) = (builder::vertex(p3(p + fr.t1 * s)), builder::vertex(p3(p + fr.t2 * s))); - let arc = Some(builder::circle_arc(&a, &b, p3(arc_mid(p, s, r)))); - End { v: v.clone(), g, e1, e2, a, b, arc } - }; - ends.push(end); - } - let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) }; - if end0.g == end1.g { - return Err(unsupported("both ends touch the same face")); - } - // Contact curves: the edge moved along each face by the setback of the radius there. - let g = law.greville(); - let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result { - if law.deg == 1 && law.radii.len() == 2 { - return Ok(builder::line(a, b)); - } - let ctrl: Vec = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect(); - let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?; - mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}"))) - }; - let la = contact(fr.t1, &end0.a, &end1.a)?; - let lb = contact(fr.t2, &end0.b, &end1.b)?; - let mut subst: HashMap = HashMap::new(); - for end in [end0, end1].into_iter().filter(|end| end.arc.is_some()) { - for (e, nv) in [(&end.e1, &end.a), (&end.e2, &end.b)] { - let (f, bk) = (e.absolute_front(), e.absolute_back()); - let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) }; - subst.insert(e.id(), ne); + if along <= back(r) + tol { + return Err(too_large("the blend is larger than the neighbouring faces")); } } - // The side edges at the ends are pieces of themselves; the contacts and end arcs are new. - let mut rec = crate::record::Rebuild::default(); - rec.pieces(&subst); - let mut new_faces: Vec = Vec::with_capacity(faces.len() + 1); - for (i, f) in faces.iter().enumerate() { - let nf = if i == fr.i1 { - let mut m = subst.clone(); - m.insert(edge.id(), la.clone()); - rebuild_face(f, &m, &[])? - } else if i == fr.i2 { - let mut m = subst.clone(); - m.insert(edge.id(), lb.clone()); - rebuild_face(f, &m, &[])? - } else if i == end0.g && end0.arc.is_some() { - rebuild_face(f, &subst, end0.arc.as_slice())? - } else if i == end1.g && end1.arc.is_some() { - rebuild_face(f, &subst, end1.arc.as_slice())? - } else { - (*f).clone() - }; - rec.face(&nf, f); - new_faces.push(nf); + let sb = back(r); + let (a, b) = (builder::vertex(p3(p + fr.t1 * sb)), builder::vertex(p3(p + fr.t2 * sb))); + let arc = builder::circle_arc(&a, &b, p3(arc_mid(p, sb, r))); + ends.push(End { a, b, arc: Some(Arc { v: v.clone(), g, e1, e2, edge: arc }) }); + } + let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) }; + if let (Some(x), Some(y)) = (&end0.arc, &end1.arc) + && x.g == y.g + { + return Err(unsupported("both ends touch the same face")); + } + // Along the edge the band the blend takes from each face stays inside it. + const STATIONS: usize = 256; + if (1..STATIONS).any(|k| !s.band_fits(k as f64 / STATIONS as f64, law.eval(k as f64 / STATIONS as f64))) { + return Err(too_large("the blend is larger than the neighbouring faces")); + } + // Contact curves: the edge moved along each face by the setback of the radius there. + let g = law.greville(); + let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result { + if law.degree == 1 && law.radii.len() == 2 { + return Ok(builder::line(a, b)); } - // The blend surface: rational quadratic arcs (tangent points and the edge point, middle - // weight sin(φ/2)) at each end, joined linearly. - let w = (fr.phi / 2.0).sin(); - let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt); - let row = |t: Vec3, wt: f64, off: bool| -> Vec { - g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect() - }; - let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)]; - let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl); - let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp)); - // Outward: away from the arc centre on a convex edge, toward it on a concave one (at an - // end with a radius). - let (p, r) = if end0.arc.is_none() { (p1, rb) } else { (p0, ra) }; - let (c, mid) = (p + fr.t1 * back(r) + fr.n1 * (r * fr.side), arc_mid(p, back(r), r)); - let outward = (mid - c) * (-fr.side); - { - use mt::{ParametricSurface3D, SearchParameter}; - if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) { - let nn = surf.normal(u, v); - if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 { - surf = mt::Invertible::inverse(&surf); - } - } + let ctrl: Vec = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect(); + let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?; + mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}"))) + }; + let la = contact(fr.t1, &end0.a, &end1.a)?; + let lb = contact(fr.t2, &end0.b, &end1.b)?; + let arcs = || [end0, end1].into_iter().filter_map(|end| end.arc.as_ref().map(|arc| (end, arc))); + let mut subst: HashMap = HashMap::new(); + for (end, arc) in arcs() { + for (e, nv) in [(&arc.e1, &end.a), (&arc.e2, &end.b)] { + let (f, bk) = (e.absolute_front(), e.absolute_back()); + let ne = if f == &arc.v { builder::line(nv, bk) } else { builder::line(f, nv) }; + subst.insert(e.id(), ne); } - // The blend face uses each new edge opposite to its neighbour; a closed end has no arc. - let f1_forward = - faces.get(fr.i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0))); - let wire: mt::Wire = if f1_forward { - [end0.arc.clone(), Some(lb.clone()), end1.arc.as_ref().map(mt::Edge::inverse), Some(la.inverse())].into_iter().flatten().collect() + } + // The side edges at the ends are pieces of themselves; the contacts and end arcs are new. + let mut rec = crate::record::Rebuild::default(); + rec.pieces(&subst); + let mut new_faces: Vec = Vec::with_capacity(faces.len() + 1); + for (i, f) in faces.iter().enumerate() { + let nf = if i == fr.i1 { + let mut m = subst.clone(); + m.insert(edge.id(), la.clone()); + rebuild_face(f, &m, &[])? + } else if i == fr.i2 { + let mut m = subst.clone(); + m.insert(edge.id(), lb.clone()); + rebuild_face(f, &m, &[])? + } else if let Some((_, arc)) = arcs().find(|(_, arc)| arc.g == i) { + rebuild_face(f, &subst, std::slice::from_ref(&arc.edge))? } else { - [Some(la.clone()), end1.arc.clone(), Some(lb.inverse()), end0.arc.as_ref().map(mt::Edge::inverse)].into_iter().flatten().collect() + (*f).clone() }; - let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?; - rec.blend(&blend, [edge.id()]); - new_faces.push(blend); - let mut shells = solid.boundaries().clone(); - if let Some(sh) = shells.get_mut(si) { - *sh = new_faces.into(); + rec.face(&nf, f); + new_faces.push(nf); + } + // The blend surface: rational quadratic arcs (tangent points and the edge point, middle + // weight sin(φ/2)) at each end, joined along the radius spline. + let w = (fr.phi / 2.0).sin(); + let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt); + let row = |t: Vec3, wt: f64, off: bool| -> Vec { + g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect() + }; + let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)]; + let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl); + let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp)); + // Outward: away from the arc centre on a convex edge, toward it on a concave one (at an + // end with a radius). + let (p, r) = if end0.arc.is_none() { (p1, rb) } else { (p0, ra) }; + let (c, mid) = (p + fr.t1 * back(r) + fr.n1 * (r * fr.side), arc_mid(p, back(r), r)); + let outward = (mid - c) * (-fr.side); + { + use mt::{ParametricSurface3D, SearchParameter}; + if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) { + let nn = surf.normal(u, v); + if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 { + surf = mt::Invertible::inverse(&surf); + } } - let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?; - crate::record::rebuilt(out, &[solid], &rec) + } + // The blend face uses each new edge opposite to its neighbour; a closed end has no arc. + let arc_of = |end: &End| end.arc.as_ref().map(|a| a.edge.clone()); + let f1_forward = faces.get(fr.i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == v0))); + let wire: mt::Wire = if f1_forward { + [arc_of(end0), Some(lb.clone()), arc_of(end1).map(|a| a.inverse()), Some(la.inverse())].into_iter().flatten().collect() + } else { + [Some(la.clone()), arc_of(end1), Some(lb.inverse()), arc_of(end0).map(|a| a.inverse())].into_iter().flatten().collect() + }; + let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?; + rec.blend(&blend, [edge.id()]); + new_faces.push(blend); + let solid: &Solid = &body.solid; + let mut shells = solid.boundaries().clone(); + if let Some(sh) = shells.get_mut(s.si) { + *sh = new_faces.into(); + } + let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?; + crate::record::rebuilt(out, &[solid], &rec) +} + +/// How much of the room along an edge a taper takes: its band stays within this fraction of each +/// face's width, so the faces keep a strip beside the round, narrowing to nothing only where a +/// face closes to a point. +const TAPER_SHARE: f64 = 0.9; +/// Spans of the cubic radius spline a taper builds. +const TAPER_SPANS: usize = 8; + +/// The radius a taper gives one edge: its positions from `start`. +#[derive(Clone, Debug, PartialEq)] +pub struct Taper { + pub start: Vec3, + pub spline: RadiusSpline, +} + +/// The radius spline that tapers `radius` on the straight edge at `edge_at` to the room along +/// it: `radius` where that fits, following the room down where it doesn't, to zero at an end +/// where a face closes to a point. Its control radii are the target min(radius, share × room) +/// at their Greville positions (Schoenberg's variation-diminishing spline), which stays under the +/// target wherever the room is concave along the edge (a convex face); the blend checks the rest. +pub fn taper(body: &Body, edge_at: Vec3, radius: f64) -> Result { + body.require_brep("fillet")?; + if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) { + return Err(KernelError::Invalid("fillet radii must be positive".into())); + } + let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; + guard("taper", || { + let s = EdgeSetup::new(body, &edge)?; + let leans = || unsupported("a taper needs each end of the edge square to it or closing to a point"); + let room0 = s.end_room(&s.v0, -s.d).ok_or_else(leans)?; + let room1 = s.end_room(&s.v1, s.d).ok_or_else(leans)?; + if room0 == 0.0 && room1 == 0.0 { + return Err(too_large("the faces close to a point at both ends of the edge")); + } + let mut knots = vec![0.0; 4]; + knots.extend((1..TAPER_SPANS).map(|k| k as f64 / TAPER_SPANS as f64)); + knots.extend([1.0; 4]); + let mut law = RadiusSpline { degree: 3, knots, radii: vec![0.0; TAPER_SPANS + 3] }; + let g = law.greville(); + let last = g.len() - 1; + law.radii = g + .iter() + .enumerate() + .map(|(j, u)| match j { + 0 => (TAPER_SHARE * room0).min(radius), + j if j == last => (TAPER_SHARE * room1).min(radius), + _ => TAPER_SHARE * s.room(*u, radius / TAPER_SHARE), + }) + .collect(); + if law.radii.iter().take(last).skip(1).any(|r| *r <= 1e-6) { + return Err(too_large("the room for the blend runs out inside the edge")); + } + law.check()?; + Ok(Taper { start: s.p0, spline: law }) }) } +/// A constant fillet of `edges` at `radius`; where that is too large, each edge it doesn't fit +/// on alone gets a [`taper`], and the rest the constant radius. The result and the taper of each +/// edge (in `edges`' order; None where the radius is constant). +pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec>)> { + let why = match crate::fillet(body, edges, radius) { + Err(KernelError::TooLarge(why)) => why, + other => return other.map(|b| (b, vec![None; edges.len()])), + }; + let mut tapers = vec![None; edges.len()]; + let mut constant = Vec::new(); + for (p, slot) in edges.iter().zip(tapers.iter_mut()) { + match crate::fillet(body, &[*p], radius) { + Ok(_) => constant.push(*p), + Err(KernelError::TooLarge(_)) => *slot = Some(taper(body, *p, radius)?), + Err(e) => return Err(e), + } + } + // Each edge fits alone but not together: nothing to taper. + if tapers.iter().all(Option::is_none) { + return Err(KernelError::TooLarge(why)); + } + // Each step's result carries its history over `[body]`. + let counts = crate::record::counts(&[body]); + let mut cur = crate::boolean_record::operand(body, 0, &counts); + for (p, t) in edges.iter().zip(&tapers) { + if let Some(t) = t { + cur = crate::record::after(fillet_variable_spline(&cur, *p, t.start, &t.spline)?, &cur, &counts); + } + } + if !constant.is_empty() { + cur = crate::record::after(crate::fillet(&cur, &constant, radius)?, &cur, &counts); + } + Ok((cur, tapers)) +} + /// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend /// is `chord` wide across; edges with the same face angle share a radius. pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { @@ -388,7 +606,7 @@ mod tests { assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed); assert_eq!(f.unmeshed_faces(), 0); let s = fillet_variable_points(&b, at, start, &pts, true).unwrap(); - let law = RadiusLaw::through(&pts, true); + let law = RadiusSpline::through(&pts, true); let n = 20000; let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::() / n as f64 * 20.0; assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12); @@ -426,7 +644,7 @@ mod tests { // Through a middle point, smooth, closing at the far end. let pts = [(0.0, 3.0), (0.5, 4.0), (1.0, 0.0)]; let s = fillet_variable_points(&b, at, start, &pts, true).unwrap(); - let law = RadiusLaw::through(&pts, true); + let law = RadiusSpline::through(&pts, true); let n = 20000; let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::() / n as f64 * 20.0; let v = measure(&s).unwrap().volume; @@ -436,11 +654,93 @@ mod tests { assert!(fillet_variable(&b, at, start, 0.0, 0.0).is_err()); assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 0.0), (1.0, 2.0)], false).is_err()); let dip = [(0.0, 0.0), (0.2, 0.1), (0.4, 6.0), (1.0, 6.0)]; - assert!((1..200).any(|i| RadiusLaw::through(&dip, true).eval(i as f64 / 200.0) <= 1e-6)); + assert!((1..200).any(|i| RadiusSpline::through(&dip, true).eval(i as f64 / 200.0) <= 1e-6)); assert!(fillet_variable_points(&b, at, start, &dip, true).is_err()); assert!(fillet_variable(&b, at, start, 4.0, 1e-9).is_err()); } + /// A prism of the polygon `pts` (in xy) from z = 0 to `h`. + fn prism(pts: &[(f64, f64)], h: f64) -> Body { + use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; + let outer = Loop2::polygon(&pts.iter().map(|(x, y)| Vec2::new(*x, *y)).collect::>()); + crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, h).unwrap().pop().unwrap() + } + + /// The material a right-angled blend with the radius `law` removes along an edge of length L: + /// ∫ (1 − π/4) r² over the edge. + fn removed(law: &RadiusSpline, len: f64) -> f64 { + let n = 20000; + (1.0 - std::f64::consts::FRAC_PI_4) * (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::() / n as f64 * len + } + + /// The volume of a fine tessellation: at a blend closed to a point `measure` takes a + /// coarser mesh of the blend, a few mm³ off on these parts. + fn fine_volume(b: &Body) -> f64 { + b.tessellate_with(5e-4, true).unwrap().measure().volume + } + + /// A taper on the top front edge of a wedge, whose top face narrows to a point, and of a + /// trapezoid, whose top face narrows to 2 mm: the radius asked for where it fits, below + /// 0.9 of the room everywhere, down to zero at the wedge's tip and to 1.8 mm at the + /// trapezoid's narrow end; the volume integrates the spline. + #[test] + fn taper_follows_the_room_along_the_edge() { + let (at, start) = (Vec3::new(20.0, 0.0, 15.0), Vec3::new(0.0, 0.0, 15.0)); + let wedge = prism(&[(0.0, 0.0), (40.0, 0.0), (0.0, 20.0)], 15.0); + assert!(matches!(crate::fillet(&wedge, &[at], 16.0), Err(KernelError::TooLarge(_)))); + let t = taper(&wedge, at, 16.0).unwrap(); + let law = if t.start.dist(start) < 1e-9 { t.spline.clone() } else { t.spline.reversed() }; + // The room: the top face is 20 (1 − u) wide, the front 15 high. + for i in 0..=100 { + let u = i as f64 / 100.0; + let r = law.eval(u); + assert!(r <= 0.9 * (20.0 * (1.0 - u)).min(15.0) + 1e-9 && r <= 16.0, "{u}: {r}"); + } + assert_eq!(law.eval(1.0), 0.0); + assert!((law.eval(0.0) - 13.5).abs() < 1e-9); + let (f, tapers) = fillet_tapered(&wedge, &[at], 16.0).unwrap(); + assert_eq!(tapers, vec![Some(t)]); + let v = fine_volume(&f); + assert!(rel(v, 6000.0 - removed(&law, 40.0)) < 1e-4, "{v} vs {}", 6000.0 - removed(&law, 40.0)); + assert_eq!(f.unmeshed_faces(), 0); + let faces: Vec<&mt::Face> = f.solid.boundaries().iter().flat_map(|sh| sh.face_iter()).collect(); + assert!(faces.iter().any(|fc| fc.edge_iter().count() == 3)); + + let trap = prism(&[(0.0, 0.0), (40.0, 0.0), (40.0, 2.0), (0.0, 20.0)], 15.0); + assert!(matches!(crate::fillet(&trap, &[at], 3.0), Err(KernelError::TooLarge(_)))); + let (f, tapers) = fillet_tapered(&trap, &[at], 3.0).unwrap(); + let t = tapers.into_iter().flatten().next().unwrap(); + let law = if t.start.dist(start) < 1e-9 { t.spline } else { t.spline.reversed() }; + assert!((law.eval(0.0) - 3.0).abs() < 1e-9 && (law.eval(1.0) - 1.8).abs() < 1e-9); + let v = fine_volume(&f); + assert!(rel(v, 6600.0 - removed(&law, 40.0)) < 1e-4, "{v} vs {}", 6600.0 - removed(&law, 40.0)); + // A radius that fits stays constant. + let (g, tapers) = fillet_tapered(&trap, &[at], 1.0).unwrap(); + assert_eq!(tapers, vec![None]); + assert!(rel(measure(&g).unwrap().volume, measure(&crate::fillet(&trap, &[at], 1.0).unwrap()).unwrap().volume) < 1e-12); + } + + /// A radius spline given directly: a cubic with its control radii pulling the radius up in + /// the middle; the same from the other end; refused when it isn't clamped or dips to zero. + #[test] + fn variable_radius_from_a_spline() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0)); + let law = RadiusSpline { degree: 3, knots: vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], radii: vec![2.0, 2.0, 6.0, 3.0, 0.0] }; + let f = fillet_variable_spline(&b, at, start, &law).unwrap(); + let v = fine_volume(&f); + assert!(rel(v, 6000.0 - removed(&law, 20.0)) < 1e-4, "{v} vs {}", 6000.0 - removed(&law, 20.0)); + let r = fillet_variable_spline(&b, at, Vec3::new(10.0, 20.0, 30.0), &law.reversed()).unwrap(); + assert!(rel(fine_volume(&r), v) < 1e-9); + let unclamped = RadiusSpline { knots: vec![0.0, 0.0, 0.0, 0.1, 0.5, 1.0, 1.0, 1.0, 1.0], ..law.clone() }; + assert!(fillet_variable_spline(&b, at, start, &unclamped).is_err()); + let dips = RadiusSpline { radii: vec![2.0, 0.0, 0.0, 3.0, 2.0], ..law.clone() }; + assert!(fillet_variable_spline(&b, at, start, &dips).is_err()); + // A middle radius beyond the faces: too large. + let big = RadiusSpline { radii: vec![2.0, 2.0, 40.0, 3.0, 2.0], ..law }; + assert!(matches!(fillet_variable_spline(&b, at, start, &big), Err(KernelError::TooLarge(_)))); + } + #[test] fn chord_length_on_a_right_angle() { let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 30b7e198..f6aa3043 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -79,7 +79,7 @@ mod untouched_faces_tests; 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 blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics}; pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 009dc830..5a866961 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1715,11 +1715,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ); enter |= field(ui, radius); ui.end_row(); - // A constant radius too large for the edges: refused, or reduced to the - // largest that fits (the preview shows what was used). + // A constant radius too large for the edges: refused, reduced to the + // largest that fits (the preview shows what was used), or tapered along + // the edges to the room there. if !*chamfer && rtype == 0 { row_label(ui, "Radius Fit"); - let fit = extra_choice(ui, &mut d.extra, "fi_fit", "fit", &[("Exact", "exact"), ("Reduce to Fit", "reduce")]); + let fit = extra_choice( + ui, + &mut d.extra, + "fi_fit", + "fit", + &[("Exact", "exact"), ("Reduce to Fit", "reduce"), ("Taper to Fit", "taper")], + ); ui.end_row(); if fit == 1 && let Some((lo, hi)) = app.preview.applied @@ -3552,9 +3559,17 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option None, + FilletFit::Reduce => Some("reduce"), + FilletFit::Taper => Some("taper"), + }; + if let Some(mode) = mode { + d.extra.insert("fit".into(), json!(mode)); + } } // A chord-length or variable fillet. if let FeatureKind::Fillet { style, .. } = &f.kind {