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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
c9ed3d154d
commit
c0e3e3d216
12 files changed
+373
-14
No files matched your search
@@ -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);
|
||||
|
||||
@@ -172,6 +172,10 @@ pub enum FeatureKind {
|
||||
radius: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
/// 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<Vec3>,
|
||||
@@ -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));
|
||||
|
||||
+10
-1
@@ -2101,7 +2101,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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<String, Value>, 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())),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Vec3> {
|
||||
pub(crate) fn plane_normal(f: &mt::Face) -> Option<Vec3> {
|
||||
match f.oriented_surface() {
|
||||
mt::Surface::Plane(p) => {
|
||||
let n = p.normal();
|
||||
@@ -42,7 +42,7 @@ fn plane_normal(f: &mt::Face) -> Option<Vec3> {
|
||||
|
||||
/// 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<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
|
||||
@@ -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<EdgeFrame> {
|
||||
let on_edge: Vec<usize> = (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<Vec3> {
|
||||
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> {
|
||||
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<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
|
||||
let others: Vec<usize> = 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<mt::Edge> {
|
||||
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<mt::EdgeID, mt::Edge> = 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<mt::Face> = 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> {
|
||||
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<Vec3>)> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn patch_region(plane: &Plane, region: &Region2) -> Result<Body> {
|
||||
}
|
||||
|
||||
/// The edge of a body nearest a point (by samples along each edge).
|
||||
fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
|
||||
pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
|
||||
let mut best: Option<(f64, mt::Edge)> = None;
|
||||
for e in body.solid.edge_iter() {
|
||||
let c = e.curve();
|
||||
|
||||
Reference in new issue
Block a user