Fillets and chamfers meeting at corners on curved faces: what #935 leaves (#854)

* Fillets and chamfers meeting at corners on curved faces, on top of #935

What #935 and #926 leave of #851 and the matrix:

- Corner build for blends meeting on faces of any surface (curvecorner.rs):
  the knife-edge tip's round with the arc's or the back edge's, edges
  picked in any order, a round ending in an earlier round of its radius
  (as a feature of its own), and chamfers meeting at the arc's corner.
  Its end curves are fitted by chord length: a uniform parameter
  through points clustered at the ends doubled back on itself there,
  and the display planner refused the face.
- A straight edge ending at a curved face (the back edge into the
  knife-edge tip): rolled, or cut by the face in the planar corner
  build (blend_corner.rs).
- A rolling ball turning about a rounded corner of its own radius
  (a pivot: a piece of sphere or cone).
- Seams that are an earlier blend's straight contact (fitted curves)
  count as straight.
- No spline across a joint where the strip's curvature jumps (a line
  into a small round): each strip ends on the joint's section.
- Sections at a chain's vertices on the faces of the edge from it
  alone.
- Every new fillet path passes #949's check for openings.

Tests measure volumes through the display plan, as #935's do.
Action matrix: 51 more fillets and chamfers pass (sweep, tangent arc
chain, sheet-metal hem, bracket holes), none fewer. Robustness seed 33
recorded (its last fillet builds differently; display and measured
volumes now agree).

Co-Authored-By: bherbruck <brennen.herbruck@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

* Corner blend tests: scale, seed 33's volumes, tighter engine bounds

From the review of #854:

- seed_33_display_plan_and_measure_agree: seed 33's bodies measure
  the same through the display plan and the measure (2e-5), which the
  golden's re-record rests on.
- the_arc_rounded_after_the_back_edge_matches_both_together: the order
  that still leaves 0.05 mm³ at the tip, as an ignored test.
- corner_blends_hold_at_any_scale: the cases this branch adds on the
  #851 part, at 0.01x and 100x, take off what they take at full size
  times k³ (within 5e-6, checked to 1e-4).
- The engine tests on the part as drawn measure through the display
  plan; bounds are about twice what each reads (2e-4 to 3e-3), and the
  front corner's sets are checked against the kernel's CSG grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 07:40:23 -04:00
1 parent 27374ca289
commit 32d3cf6877
13 files changed
+2739 -129

No files matched your search

+188 -47
View File
@@ -495,6 +495,18 @@ fn blend_geometry(
return Err(unsupported("G2 fillets require straight edges around each planar end face"));
}
let g = faces.get(ga).ok_or_else(|| KernelError::Failed("face".into()))?;
// An end face running on smoothly from a side, an earlier round of this radius (its
// end an arc of it): the blend ends in that round, which takes a corner of its own
// (`curveblend::corner_blend`), the same as rounding both together.
let normal = |f: &mt::Face| crate::curveblend::project(f, p, None).map(|x| x.1);
let of_radius = |e: &mt::Edge| crate::curveblend::circle_of(e, r * 1e-4).is_some_and(|c| (c.1 - r).abs() <= r * 1e-4);
if let Some(ng) = normal(g)
&& [i1, i2].iter().filter_map(|i| faces.get(*i)).filter_map(|f| normal(f)).any(|ns| ns.dot(ng) > 1.0 - 1e-6)
&& shape == Shape::Round
&& (of_radius(&e1) || of_radius(&e2))
{
return Err(unsupported("a blend ending in an earlier round"));
}
let surf = g.surface();
let surf_b = faces.get(gb).ok_or_else(|| KernelError::Failed("face".into()))?.surface();
let on_tol = (size * 1e-5).max(1e-6);
@@ -1134,8 +1146,14 @@ fn orders<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
/// boundary leaves its surface (a blend that ran on past the end of a round's surface, which a
/// round of a curved edge does not have) another order that has none is preferred.
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
blend_with(body, edges, r, shape, what, false)
}
/// [`blend`]; `curved_ends`: also roll a ball along a straight edge between planes that ends at
/// a curved face (the last resort, after the corner build for an earlier round there).
fn blend_with(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, curved_ends: bool) -> Result<Body> {
let sound = |b: &Body| crate::tess::boundaries_on_surfaces(&b.solid, (b.size() * 1e-4).max(1e-6));
let first = blend_in_order(body, edges, r, shape, what);
let first = blend_in_order(body, edges, r, shape, what, curved_ends);
let reorder = (2..=MAX_REORDERED).contains(&edges.len())
&& match &first {
Ok(b) => !sound(b),
@@ -1147,7 +1165,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
}
let mut fallback: Option<Body> = None;
for order in orders(edges).into_iter().skip(1) {
if let Ok(done) = blend_in_order(body, &order, r, shape, what) {
if let Ok(done) = blend_in_order(body, &order, r, shape, what, curved_ends) {
if sound(&done) {
return Ok(done);
}
@@ -1164,7 +1182,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
}
}
fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, curved_ends: bool) -> Result<Body> {
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
return Err(KernelError::Invalid(format!("{what} size must be positive")));
}
@@ -1178,6 +1196,16 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str)
let counts = record::counts(&[body]);
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = crate::boolean_record::healed(body)?;
// The last resort only for edges it treats otherwise: else it repeats the refusal.
if curved_ends {
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, edges)?;
let other =
Body::unique_edges(&solid).iter().any(|e| ids.contains(&e.id()) && !curved_case(&solid, e, false) && curved_case(&solid, e, true));
if !other {
return Err(unsupported("no edge ending at a curved face"));
}
}
// Every edge of a convex planar body: rounded as a whole (sphere corners).
if shape == Shape::Round && edges.len() >= 6 {
let solid = cur.deep_copy();
@@ -1207,8 +1235,8 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str)
{
let _ = groups;
let loop_pts: Vec<Vec3> = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect();
cur = record::after(blend_in_order(&cur, &rest, r, shape, what)?, &cur, &counts);
return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what)?, &cur, &counts));
cur = record::after(blend_in_order(&cur, &rest, r, shape, what, curved_ends)?, &cur, &counts);
return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what, curved_ends)?, &cur, &counts));
}
// A loop the loop blend refuses for its shape (seams that aren't straight, walls it can't
// trim) is blended edge by edge below instead: a ball rolls along its smooth chain.
@@ -1236,11 +1264,11 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str)
Err(_) => {}
}
}
blend_each(cur, edges, r, shape, what, &counts)
blend_each(cur, edges, r, shape, what, &counts, curved_ends)
}
/// The edges blended one at a time (see [`blend`]), `cur` being the healed body.
fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, counts: &[(usize, usize)]) -> Result<Body> {
fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, counts: &[(usize, usize)], curved_ends: bool) -> Result<Body> {
let counts = counts.to_vec();
// One at a time, in whatever order works: a run along a planar face's loop (bridging arcs
// that earlier blends left between selected edges, so corners round off), a single edge,
@@ -1251,6 +1279,30 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c
// refused away from every later blend would be refused the same way again: it is not tried
// again (a refusal costs a rolling-ball attempt, seconds each on a long curved edge).
let mut refused: Vec<(Vec3, (Vec3, Vec3), KernelError)> = Vec::new();
// A chain runs on along the edges smooth with a selected one in the body as given, not along
// a round this blend has made meanwhile (which would take in edges nobody selected, or not,
// by the order the edges come in).
let along: Vec<mt::Edge> = {
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
let tol = (cur.size() * 2e-3).max(1e-3);
let mut list: Vec<mt::Edge> = Vec::new();
for p in edges {
if let Some((idx, _)) = cur.nearest_edge(*p, tol)?
&& let Some(e) = all.get(idx)
{
let ids = smooth_chain(&solid, e).unwrap_or_else(|| vec![e.id()]);
for x in all.iter().filter(|x| ids.contains(&x.id())) {
if !list.iter().any(|y| y.id() == x.id()) {
list.push(x.clone());
}
}
}
}
list
};
let near_chain = (cur.size() * 1e-6).max(1e-7);
let on_chains = |e: &mt::Edge| lies_on(e, &along, near_chain);
for _ in 0..(2 * edges.len() + 4) {
if todo.is_empty() {
return Ok(cur);
@@ -1315,11 +1367,23 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c
};
// A straight edge running on smoothly into curved ones (a flange into its bend, a
// side into a rounded corner): the blend follows the whole smooth chain, as the
// rolling ball would.
let chain = smooth_chain(&solid, &edge);
let runs_on = !curved_case(&solid, &edge)
// rolling ball would. A fillet doesn't run on into an earlier round's arc of its
// radius: it ends in that round (as if both were rounded together).
let chain = smooth_chain_within(&solid, &edge, &on_chains);
let into_round = |ids: &[mt::EdgeID]| {
Body::unique_edges(&solid).iter().any(|x| {
ids.contains(&x.id())
&& x.id() != edge.id()
&& [edge.front(), edge.back()].iter().any(|v| x.front() == *v || x.back() == *v)
&& crate::curveblend::circle_of(x, size * 1e-6).is_some_and(|c| (c.1 - r).abs() <= r * 1e-4)
})
};
let own = edge.id();
let runs_on = !curved_case(&solid, &edge, false)
&& chain.as_ref().is_some_and(|ids| {
ids.len() > 1 && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
ids.len() > 1
&& Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
&& !(shape == Shape::Round && into_round(ids))
});
let local = || guard(what, || blend_one(&solid, &edge, size, r, shape));
let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) {
@@ -1333,16 +1397,24 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c
None => match local() {
Ok(o) => Ok((o, vec![j])),
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
Err(e) if !curved_case(&solid, &edge) => Err(e),
Err(e) if !curved_case(&solid, &edge, curved_ends) => Err(e),
Err(e) => match chain.as_deref() {
// The chain that cannot be rolled (it runs on into the edge an earlier round
// left where it met another face): the edge alone, ending on that round.
Some(ids) => roll(ids)
.or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) })
.map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}),
// left where it met another face): the edge alone, ending on that round. A
// straight edge into an earlier round of this radius: on its own.
Some(ids) => {
let ids = if shape == Shape::Round && !curved_case(&solid, &edge, false) && into_round(ids) {
std::slice::from_ref(&own)
} else {
ids
};
roll(ids).or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) }).map_err(|e2| {
match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}
})
}
None => Err(e),
},
},
@@ -1500,12 +1572,16 @@ fn run_on_loop(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result<Option<(Vec<mt
Ok(best)
}
/// Is the edge curved, or does it lie between faces that aren't both planar?
fn curved_case(solid: &Solid, edge: &mt::Edge) -> bool {
/// Is the edge curved, or does it lie between faces that aren't both planar (or, with
/// `curved_ends`, end at one)?
fn curved_case(solid: &Solid, edge: &mt::Edge, curved_ends: bool) -> bool {
if !matches!(edge.curve(), mt::Curve::Line(_)) {
return true;
}
solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(|f| !matches!(f.oriented_surface(), mt::Surface::Plane(_)))
// Between curved faces, or (when asked) ending at one: a straight edge running into a wall.
let curved = |f: &mt::Face| !matches!(f.oriented_surface(), mt::Surface::Plane(_));
solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(curved)
|| (curved_ends && solid.face_iter().filter(|f| f.vertex_iter().any(|v| v == *edge.front() || v == *edge.back())).any(curved))
}
/// Whether the two faces of `edge` meet tangentially along it (sampled at a quarter, the
@@ -1531,6 +1607,32 @@ fn tangent_faces(solid: &Solid, edge: &mt::Edge) -> bool {
/// The chain of edges continuing `edge` smoothly at its ends (its ids): closed, or open where
/// the next edge turns sharply.
fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
smooth_chain_within(solid, edge, &|_| true)
}
/// Whether edge `e` runs along one of `along` (is a piece of it): points through it lie on one.
fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
if along.iter().any(|a| a.id() == e.id()) {
return true;
}
let c = e.curve();
let (t0, t1) = c.range_tuple();
[0.25, 0.5, 0.75].iter().all(|f| {
let p = c.subs(t0 + (t1 - t0) * f);
along.iter().any(|a| {
let ac = a.curve();
let (a0, a1) = ac.range_tuple();
let d = |t: f64| crate::body::from_p3(ac.subs(t)).dist(crate::body::from_p3(p));
let Some(hint) = (0..=16).map(|i| a0 + (a1 - a0) * i as f64 / 16.0).min_by(|x, y| d(*x).total_cmp(&d(*y))) else { return false };
let t = ac.search_nearest_parameter(p, hint, 50).unwrap_or(hint).clamp(a0, a1);
d(t).min(d(hint)) <= tol
})
})
}
/// [`smooth_chain`], walking on only over edges `keep` allows.
fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option<Vec<mt::EdgeID>> {
use mt::{BoundedCurve, ParametricCurve};
let all = Body::unique_edges(solid);
// Along the edge as it runs from its front to its back (an edge may run against its curve).
@@ -1550,7 +1652,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
let next = all.iter().filter(|e| !ids.contains(&e.id()) && (*e.front() == v || *e.back() == v)).find_map(|e| {
let fwd = *e.front() == v;
let t = if fwd { dir(e, false)? } else { -dir(e, true)? };
(t.dot(d) > 1.0 - 1e-4 && !smooth_seam(solid, e)).then(|| (e.clone(), fwd))
(t.dot(d) > 1.0 - 1e-4 && !smooth_seam(solid, e) && keep(e)).then(|| (e.clone(), fwd))
});
let Some((e, fwd)) = next else { return Some(false) };
ids.push(e.id());
@@ -1713,21 +1815,52 @@ fn closed(b: Body) -> Result<Body> {
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
body.require_brep("fillet")?;
// Corners the local blends refuse (an earlier round at an edge's end): radius per edge.
blend(body, edges, radius, Shape::Round, "fillet").and_then(closed).or_else(|e| {
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
// The local blend's refusal, unless only the corner build found the radius too large.
crate::blend_corner::fillet_radii(body, &sel)
.and_then(closed)
.map_err(|e2| match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
})
// Three edges at a vertex of any faces end in a sphere.
.or_else(|e| {
if edges.len() >= 3 { crate::blend_vertex::fillet_ball_vertices(body, edges, radius).and_then(closed).map_err(|_| e) } else { Err(e) }
})
})
blend(body, edges, radius, Shape::Round, "fillet")
.and_then(closed)
.or_else(|e| {
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
// The local blend's refusal, unless only the corner build found the radius too large.
crate::blend_corner::fillet_radii(body, &sel)
.and_then(closed)
.map_err(|e2| keep_size(e, e2))
// Three edges at a vertex of any faces end in a sphere.
.or_else(|e| {
if edges.len() >= 3 {
crate::blend_vertex::fillet_ball_vertices(body, edges, radius).and_then(closed).map_err(|_| e)
} else {
Err(e)
}
})
})
// Straight edges ending at a curved face that isn't an earlier round's: a rolling ball
// trimmed by the face. Refused, the refusal above stands.
.or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(closed).map_err(|_| e))
// Blends meeting at corners on curved faces: all at once.
.or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(closed).map_err(|e2| keep_size(e, e2)))
}
/// The first refusal, unless only the second found the blend too large (a smaller one may fit).
fn keep_size(e: KernelError, e2: KernelError) -> KernelError {
match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
}
}
/// Blend the edges together, rebuilding the corners where they meet on faces of any surface
/// (see [`crate::curveblend::corner_blend`]).
fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
let counts = record::counts(&[body]);
let cur = crate::boolean_record::healed(body)?;
let size = cur.size();
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, edges)?;
if ids.len() != edges.len() {
return Err(KernelError::Invalid("no edge near a selected point".into()));
}
let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?;
Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts))
}
/// The step of the radius search: three significant digits of the requested radius.
@@ -1925,7 +2058,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
return Ok(result);
}
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = crate::boolean_record::healed(body)?;
let cur = crate::boolean_record::healed(body)?;
if let Some(groups) = loop_groups(&cur, edges)? {
let whole = || -> Result<Body> {
let mut lc = cur.clone();
@@ -1951,14 +2084,22 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
Err(_) => {}
}
}
for p in edges {
let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) {
Ok(Some(b)) => b,
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?,
};
cur = record::after(step, &cur, &counts);
}
Ok(cur)
let start = cur.clone();
let one_by_one = || -> Result<Body> {
let mut cur = start.clone();
for p in edges {
let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) {
Ok(Some(b)) => b,
// A chain running on into curved faces, or an edge ending at one: as for fillets.
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")
.or_else(|e| blend_with(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer", true).map_err(|_| e))?,
};
cur = record::after(step, &cur, &counts);
}
Ok(cur)
};
// Chamfers meeting at corners on curved faces: all at once.
one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2)))
}
/// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left).