From c0e3e3d2163b763b38a2d35afe70592dac4a57e5 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 19:22:53 +0000 Subject: [PATCH] Variable-radius and chord-length fillets New kernel file blend_variable.rs. The hooks into blend.rs are visibility only: plane_normal, vtx, rebuild_face and unsupported are now pub(crate). - fillet_variable(body, edge, start, r0, r1) works on the same edge configuration as the constant fillet (straight edge, planar faces, end faces square to it). The radius runs linearly from the end nearest `start`, and every section square to the edge is a circular arc. The blend is an exact degree (2, 1) rational B-spline: arc control points move linearly. - fillet_chord(body, edges, chord): the radius per edge comes from its face angle (chord = 2 r cos(phi/2)). Fillet feature: style constant | chord | variable {radius2, start}; FusionFilletEdgesCommand takes type, radius2 and start. Tests: - Variable 2 -> 5 on a box edge removes (1 - pi/4) L (r0^2 + r0 r1 + r1^2)/3. Starting from the other end gives the same solid; equal radii give the constant fillet; too large is refused. - Chord 4 on a right angle equals radius 4/sqrt(2). - Engine: both types by command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 8 +- crates/doc/src/document.rs | 29 ++- crates/doc/src/eval.rs | 11 +- crates/doc/src/params.rs | 9 +- crates/doc/src/tests.rs | 6 +- crates/engine/src/cmd/features.rs | 16 +- crates/engine/src/tests.rs | 19 ++ crates/io/src/lib.rs | 6 +- crates/kernel/src/blend.rs | 8 +- crates/kernel/src/blend_variable.rs | 271 ++++++++++++++++++++++++++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/surfaces.rs | 2 +- 12 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 crates/kernel/src/blend_variable.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index d18a9e4d..cee64fcb 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -166,7 +166,13 @@ impl FeatureKind { pt(origin); dir(d); } - FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt), + FeatureKind::Fillet { edges, style, .. } => { + edges.iter_mut().for_each(pt); + if let crate::FilletStyle::Variable { start, .. } = style { + pt(start); + } + } + FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } => faces.iter_mut().for_each(pt), FeatureKind::Draft { faces, neutral, pull, .. } => { faces.iter_mut().for_each(pt); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 8363c4dc..184b2079 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -172,6 +172,10 @@ pub enum FeatureKind { radius: String, #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, + /// Constant radius (the default), chord length (`radius` is the width across), or a + /// radius varying along each edge. + #[serde(default, skip_serializing_if = "FilletStyle::is_constant")] + style: FilletStyle, }, Chamfer { edges: Vec, @@ -677,6 +681,24 @@ pub enum FeatureKind { }, } +/// How a fillet's size is given. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FilletStyle { + #[default] + Constant, + /// `radius` is the chord: the width across the blend. + Chord, + /// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between. + Variable { radius2: String, start: Vec3 }, +} + +impl FilletStyle { + pub fn is_constant(&self) -> bool { + *self == FilletStyle::Constant + } +} + /// A face picked by a point on it, on a named body. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FaceAt { @@ -995,7 +1017,12 @@ impl FeatureKind { } } FeatureKind::Revolve { angle, .. } => v.push(angle), - FeatureKind::Fillet { radius, .. } => v.push(radius), + FeatureKind::Fillet { radius, style, .. } => { + v.push(radius); + if let FilletStyle::Variable { radius2, .. } = style { + v.push(radius2); + } + } FeatureKind::Chamfer { distance, distance2, angle, .. } => { v.push(distance); v.extend(distance2.iter().map(String::as_str)); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index eaa1a0db..70dac4de 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2101,7 +2101,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } - FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => { + FeatureKind::Fillet { edges, radius, body, .. } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => { let r = val(vals, radius, Kind::Length)?; let ti = blend_target(st, body, edges)?; let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) }; @@ -2144,6 +2144,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: resolve_edges(&mb.body, edges, &f.edge_refs, warning)? }; let nb = match &f.kind { + FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?, + FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start }, .. } => { + let r2 = val(vals, radius2, Kind::Length)?; + let mut b = mb.body.clone(); + for p in &edges { + b = kernel::fillet_variable(&b, *p, *start, r, r2)?; + } + b + } FeatureKind::Fillet { .. } => kernel::fillet(&mb.body, &edges, r)?, FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?, _ => return Err(DocError::Invalid("blend".into())), diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index ef1513f7..e8c68681 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -48,7 +48,14 @@ impl FeatureKind { } } FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)), - FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)), + FeatureKind::Fillet { radius, style, .. } => match style { + crate::FilletStyle::Constant => v.push(("Radius", radius, L)), + crate::FilletStyle::Chord => v.push(("Chord length", radius, L)), + crate::FilletStyle::Variable { radius2, .. } => { + v.push(("Start radius", radius, L)); + v.push(("End radius", radius2, L)); + } + }, FeatureKind::Chamfer { distance, distance2, angle, .. } => { v.push(("Distance", distance, L)); if let Some(d) = distance2 { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 83f77c5c..2238b2c9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -81,7 +81,8 @@ fn parametric_plate_reevaluates() { #[test] fn box_fillet_cut_timeline() { let (mut doc, _) = plate_doc(); - doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap(); + doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None, style: Default::default() }, None) + .unwrap(); let mut sk = Sketch::new(); sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap(); let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap(); @@ -132,7 +133,8 @@ fn errors_are_reported_not_fatal() { None, ) .unwrap(); - doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap(); + doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None) + .unwrap(); doc.add_feature( FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody }, None, diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 0584f086..084fe62c 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?"), + .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); radius2?; start?: [x,y,z]"), CommandSpec::new("FusionChamferCommand", "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("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"), CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect) @@ -515,7 +515,19 @@ fn fillet(s: &mut Session, p: &Value) -> Result { let edges = edge_points(s, p, cmd)?; let radius = req_expr(cmd, p, "radius")?; check_expr(s, &radius, Kind::Length, cmd, "radius")?; - add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string) }) + let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() { + None | Some("constant") | Some("rolling_ball") => solvecraft_doc::FilletStyle::Constant, + Some("chord") | Some("chord_length") => solvecraft_doc::FilletStyle::Chord, + Some("variable") => { + let radius2 = req_expr(cmd, p, "radius2")?; + check_expr(s, &radius2, Kind::Length, cmd, "radius2")?; + let start = + p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?; + solvecraft_doc::FilletStyle::Variable { radius2, start } + } + Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))), + }; + add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style }) } fn chamfer(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 067d4994..723e513a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1058,6 +1058,25 @@ fn split_body_by_a_face() { assert!(s.execute("FusionSplitBodyCommand", &json!({"body": "Block", "tool": {"body": "Tool", "point": [100, 0, 0]}})).is_err()); } +/// Fillet types: chord length (radius from the face angle) and a radius varying along the +/// edge; volumes from the removed material. +#[test] +fn chord_and_variable_fillets() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"})); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[10, 10, 30]], "radius": 4, "type": "chord"})); + let r = 4.0 / 2f64.sqrt(); + let v = solvecraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume; + assert!(rel(v, 6000.0 - (1.0 - PI / 4.0) * r * r * 20.0) < 1e-4, "{v}"); + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"})); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 5, "start": [10, 0, 30]})); + let v = solvecraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume; + let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0; + assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}"); + assert!(s.execute("FusionFilletEdgesCommand", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err()); +} + /// Components export as a STEP assembly: products, occurrences, placements. #[test] fn components_export_as_step_assembly() { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index f1155f1c..7ed5dcb0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -562,7 +562,11 @@ mod tests { None, ) .unwrap(); - doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 5.0)], radius: "4".into(), body: None }, None).unwrap(); + doc.add_feature( + FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 5.0)], radius: "4".into(), body: None, style: Default::default() }, + None, + ) + .unwrap(); doc.add_feature( FeatureKind::Cylinder { base: Vec3::new(80.0, 0.0, 0.0), diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index e8d75de4..79c21209 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -21,16 +21,16 @@ enum Shape { Flat, } -fn unsupported(msg: &str) -> KernelError { +pub(crate) fn unsupported(msg: &str) -> KernelError { KernelError::Failed(format!("not supported yet: {msg}")) } -fn vtx(v: &mt::Vertex) -> Vec3 { +pub(crate) fn vtx(v: &mt::Vertex) -> Vec3 { from_p3(v.point()) } /// Outward unit normal of a planar face (from its oriented surface), if planar. -fn plane_normal(f: &mt::Face) -> Option { +pub(crate) fn plane_normal(f: &mt::Face) -> Option { match f.oriented_surface() { mt::Surface::Plane(p) => { let n = p.normal(); @@ -42,7 +42,7 @@ fn plane_normal(f: &mt::Face) -> Option { /// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra /// edges where consecutive edges no longer connect. -fn rebuild_face(f: &mt::Face, subst: &HashMap, connectors: &[mt::Edge]) -> Result { +pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap, connectors: &[mt::Edge]) -> Result { let mut wires = Vec::new(); for w in f.absolute_boundaries() { let mut edges: Vec = Vec::new(); diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs new file mode 100644 index 00000000..00ca7707 --- /dev/null +++ b/crates/kernel/src/blend_variable.rs @@ -0,0 +1,271 @@ +//! Fillets beyond a constant radius: a radius that varies along the edge (from one end to the +//! other) and chord-length fillets (the width across the blend given instead of the radius). +//! +//! Variable radius: a straight, convex or concave edge between two planar faces whose ends each +//! touch one more planar face square to the edge (the configuration of `blend`). Every section +//! square to the edge is a circular arc tangent to both faces, its radius running linearly from +//! one end to the other; the arcs' control points move linearly, so the blend is exactly a +//! rational B-spline of degree 2 across and 1 along. + +use std::collections::HashMap; + +use solvecraft_geom::Vec3; +use truck_modeling::{self as mt, builder}; + +use crate::blend::{plane_normal, rebuild_face, unsupported, vtx}; +use crate::body::{Body, Solid, p3}; +use crate::{KernelError, Result, guard}; + +/// The radius of a chord-length fillet between faces whose in-face directions (away from the +/// edge) make the angle `phi`: the arc spans π − φ, so the chord is 2 r cos(φ/2). +fn chord_radius(chord: f64, phi: f64) -> f64 { + chord / (2.0 * (phi / 2.0).cos()) +} + +/// The two planar faces at an edge, the in-face directions away from it (`t1`, `t2`), the +/// first face's normal, the angle between the directions and the side of the material. +struct EdgeFrame { + i1: usize, + i2: usize, + n1: Vec3, + t1: Vec3, + t2: Vec3, + phi: f64, + side: f64, +} + +fn edge_frame(faces: &[&mt::Face], edge: &mt::Edge) -> Result { + let on_edge: Vec = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.edge_iter().any(|e| e.id() == edge.id()))).collect(); + let [i1, i2] = on_edge[..] else { return Err(unsupported("the edge is not between two faces")) }; + let (Some(f1), Some(f2)) = (faces.get(i1), faces.get(i2)) else { return Err(KernelError::Failed("faces".into())) }; + let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) }; + let into = |f: &mt::Face, n: Vec3| -> Option { + let e = f.boundary_iters().into_iter().flatten().find(|e| e.id() == edge.id())?; + let dir = (vtx(e.back()) - vtx(e.front())).normalized()?; + n.cross(dir).normalized() + }; + let (Some(t1), Some(t2)) = (into(f1, n1), into(f2, n2)) else { return Err(KernelError::Failed("blend: edge direction".into())) }; + let phi = t1.dot(t2).clamp(-1.0, 1.0).acos(); + let convex = n1.dot(t2) < -1e-9 && n2.dot(t1) < -1e-9; + let concave = n1.dot(t2) > 1e-9 && n2.dot(t1) > 1e-9; + if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) || !(convex || concave) { + return Err(unsupported("the faces at the edge are tangent or the edge is ambiguous")); + } + Ok(EdgeFrame { i1, i2, n1, t1, t2, phi, side: if convex { -1.0 } else { 1.0 } }) +} + +/// Fillet one straight edge with radius `r0` at its end nearest `start` and `r1` at the other. +pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64) -> Result { + body.require_brep("fillet")?; + for r in [r0, r1] { + if !(r.is_finite() && r > 1e-6 && r < 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:?}")))?; + let size = body.size(); + guard("variable fillet", || { + let solid: &Solid = &body.solid; + 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 + .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 (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone()); + // r0 at the end nearest `start` (the edge keeps its own direction). + let (ra, rb) = if vtx(&v1).dist(start) < vtx(&v0).dist(start) { (r1, r0) } else { (r0, r1) }; + 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(); + // Each end: the end face and its edges along the two faces. + struct End { + v: mt::Vertex, + g: usize, + e1: mt::Edge, + e2: mt::Edge, + } + 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 { + 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(r) + tol { + return Err(unsupported("the blend is larger than the neighbouring faces")); + } + } + ends.push(End { v: v.clone(), g, e1, e2 }); + } + 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")); + } + let (s0, s1) = (back(ra), back(rb)); + let (pa0, pb0, pa1, pb1) = (p0 + fr.t1 * s0, p0 + fr.t2 * s0, p1 + fr.t1 * s1, p1 + fr.t2 * s1); + let (a0, b0, a1, b1) = (builder::vertex(p3(pa0)), builder::vertex(p3(pb0)), builder::vertex(p3(pa1)), builder::vertex(p3(pb1))); + 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 + }; + let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra))); + let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb))); + let la = builder::line(&a0, &a1); + let lb = builder::line(&b0, &b1); + let mut subst: HashMap = HashMap::new(); + for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] { + for (e, nv) in [(&end.e1, a), (&end.e2, 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); + } + } + 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 { + rebuild_face(f, &subst, std::slice::from_ref(&c0))? + } else if i == end1.g { + rebuild_face(f, &subst, std::slice::from_ref(&c1))? + } else { + (*f).clone() + }; + new_faces.push(nf); + } + // 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 ctrl = vec![vec![h(pa0, 1.0), h(pa1, 1.0)], vec![h(p0, w), h(p1, w)], vec![h(pb0, 1.0), h(pb1, 1.0)]]; + let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), 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. + let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra)); + 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); + } + } + } + // The blend face uses each new edge opposite to its neighbour. + 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 { + vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into() + } else { + vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into() + }; + let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?; + new_faces.push(blend); + let mut shells = solid.boundaries().clone(); + if let Some(sh) = shells.get_mut(si) { + *sh = new_faces.into(); + } + Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?) + }) +} + +/// 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 { + body.require_brep("fillet")?; + if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) { + return Err(KernelError::Invalid("the chord length must be positive".into())); + } + if edges.is_empty() || edges.len() > 1000 { + return Err(KernelError::Invalid("select 1…1000 edges".into())); + } + // Radius per edge from its face angle. + let mut groups: Vec<(f64, Vec)> = Vec::new(); + for p in edges { + let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; + let shell = + body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id())).ok_or_else(|| KernelError::Failed("edge".into()))?; + let faces: Vec<&mt::Face> = shell.face_iter().collect(); + let fr = edge_frame(&faces, &e)?; + let r = chord_radius(chord, fr.phi); + match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { + Some((_, v)) => v.push(*p), + None => groups.push((r, vec![*p])), + } + } + let mut cur = body.clone(); + for (r, pts) in groups { + cur = crate::fillet(&cur, &pts, r)?; + } + Ok(cur) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{box_solid, measure}; + + fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-9) + } + + /// The material a variable fillet removes from a right-angled edge of length L, radius + /// running r0 → r1: ∫ (1 − π/4) r(t)² dt = (1 − π/4) L (r0² + r0 r1 + r1²) / 3. + #[test] + fn variable_radius_on_a_box_edge() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + // The edge along y at x = 10, z = 30; radius 2 at y = 0, 5 at y = 20. + let f = fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 5.0).unwrap(); + let removed = (1.0 - std::f64::consts::FRAC_PI_4) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0; + let v = measure(&f).unwrap().volume; + assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed); + // Started from the other end: the same solid. + let h = fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 20.0, 30.0), 5.0, 2.0).unwrap(); + assert!(rel(measure(&h).unwrap().volume, v) < 1e-9); + // Equal radii give the constant fillet. + let g = fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 3.0, 3.0).unwrap(); + let k = crate::fillet(&b, &[Vec3::new(10.0, 10.0, 30.0)], 3.0).unwrap(); + assert!(rel(measure(&g).unwrap().volume, measure(&k).unwrap().volume) < 1e-6); + // Too big for the faces: refused. + assert!(fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).is_err()); + } + + #[test] + fn chord_length_on_a_right_angle() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + // A right angle: chord c gives radius c / √2. + let f = fillet_chord(&b, &[Vec3::new(10.0, 10.0, 30.0)], 4.0).unwrap(); + let k = crate::fillet(&b, &[Vec3::new(10.0, 10.0, 30.0)], 4.0 / 2f64.sqrt()).unwrap(); + assert!(rel(measure(&f).unwrap().volume, measure(&k).unwrap().volume) < 1e-9); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index aa28a611..269201f5 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -13,6 +13,7 @@ #![forbid(unsafe_code)] mod blend; +mod blend_variable; mod body; mod build; mod coplanar; @@ -42,6 +43,7 @@ mod sweep_path; mod topo; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet}; +pub use blend_variable::{fillet_chord, fillet_variable}; pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use delete_face::delete_faces; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 127a97b2..1c08443d 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -42,7 +42,7 @@ pub fn patch_region(plane: &Plane, region: &Region2) -> Result { } /// The edge of a body nearest a point (by samples along each edge). -fn edge_near(body: &Body, p: Vec3) -> Option { +pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option { let mut best: Option<(f64, mt::Edge)> = None; for e in body.solid.edge_iter() { let c = e.curve();