fix(kernel): fillet that uses up a side face makes a full round (#317)

A constant-radius fillet whose setback equals the width of a neighbouring
face was refused ("the blend is larger than the neighbouring faces"), so
r = 3 on the vertical edges of a 6 mm wide box failed while 2.999 worked.
The corner edge is now consumed: the rail ends at its far vertex and a side
face used up at both ends is removed, its remaining edge becoming the rail.
A face used up at one end only, and radii past the limit, are still refused.

Refs #199


Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 11:44:32 -04:00
1 parent a3ff816176
commit a9e4830ded
3 files changed
+109 -17

No files matched your search

+11
View File
@@ -22,6 +22,17 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn fillet_as_wide_as_a_face_rounds_it_off() {
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}));
run(&mut s, "solid.fillet", json!({"edges": [[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]], "radius": "3"}));
let expect = 840.0 - (4.0 - PI) * 9.0 * 10.0;
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
assert!(s.execute("solid.fillet", &json!({"edges": [[21, 0, 10]], "radius": 3.5})).is_err());
}
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
+65 -17
View File
@@ -43,10 +43,15 @@ pub(crate) 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.
pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
rebuild_face_dropping(f, subst, &[], connectors)
}
/// [`rebuild_face`], also leaving out the `dropped` edges (consumed by a blend).
fn rebuild_face_dropping(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, dropped: &[mt::EdgeID], 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();
for e in w.edge_iter() {
for e in w.edge_iter().filter(|e| !dropped.contains(&e.id())) {
let ne = match subst.get(&e.id()) {
Some(n) => {
// `n` was built in the orientation of the stored edge; match this use.
@@ -201,7 +206,8 @@ fn blend_geometry(
) -> Result<Solid> {
let s = setback(r, phi, shape);
let tol = (size * 1e-7).max(1e-9);
// End faces and the side edges at each end.
// End faces and the side edges at each end. A side edge that the fillet's setback uses up
// exactly is consumed: the rail ends at its far vertex (`ca`, `cb`).
struct End {
v: mt::Vertex,
g: usize,
@@ -209,6 +215,8 @@ fn blend_geometry(
e2: mt::Edge,
a: Vec3,
b: Vec3,
ca: Option<mt::Vertex>,
cb: Option<mt::Vertex>,
}
let mut ends: Vec<End> = Vec::new();
for (v, p) in [(&v0, p0), (&v1, p1)] {
@@ -238,19 +246,27 @@ fn blend_geometry(
.ok_or_else(|| unsupported("unexpected corner topology"))
};
let (e1, e2) = (shared(i1)?, shared(i2)?);
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
let mut consumed: [Option<mt::Vertex>; 2] = [None, None];
for (k, (e, q, t, fi)) in [(&e1, a, t1, i1), (&e2, b, t2, i2)].into_iter().enumerate() {
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 far = if e.front() == v { e.back().clone() } else { e.front().clone() };
let other = vtx(&far);
let delta = other - p;
if shape == Shape::Round {
// Fillets retain the square-end check: the original corner edge
// must run in the setback direction and have enough length left.
// must run in the setback direction and have enough length left,
// or exactly the setback (the edge is consumed).
let along = delta.dot(t);
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along < s - tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
if along <= s + tol
&& let Some(slot) = consumed.get_mut(k)
{
*slot = Some(far);
}
continue;
}
let len = delta.len();
@@ -265,7 +281,8 @@ fn blend_geometry(
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
ends.push(End { v: v.clone(), g, e1, e2, a, b });
let [ca, cb] = consumed;
ends.push(End { v: v.clone(), g, e1, e2, a, b, ca, cb });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g {
@@ -274,10 +291,32 @@ fn blend_geometry(
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
let a0 = builder::vertex(p3(end0.a));
let b0 = builder::vertex(p3(end0.b));
let a1 = builder::vertex(p3(end1.a));
let b1 = builder::vertex(p3(end1.b));
// A side face whose corner edges are consumed at both ends is used up: its remaining edge,
// parallel to the old edge at the setback, becomes the rail and the face is removed.
let used_up = |fi: usize, c0: &Option<mt::Vertex>, c1: &Option<mt::Vertex>| -> Result<Option<mt::Edge>> {
let (Some(x0), Some(x1)) = (c0, c1) else {
return match (c0, c1) {
(None, None) => Ok(None),
_ => Err(unsupported("the blend uses up a neighbouring face at one end only")),
};
};
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
return Err(unsupported("the blend uses up a neighbouring face with more edges"));
}
let rail = f
.edge_iter()
.find(|e| matches!(e.curve(), mt::Curve::Line(_)) && ((e.front() == x0 && e.back() == x1) || (e.front() == x1 && e.back() == x0)))
.ok_or_else(|| unsupported("the blend uses up a neighbouring face with more edges"))?;
Ok(Some(if rail.front() == x0 { rail.clone() } else { rail.inverse() }))
};
let rail_a = used_up(i1, &end0.ca, &end1.ca)?;
let rail_b = used_up(i2, &end0.cb, &end1.cb)?;
let vertex = |c: &Option<mt::Vertex>, p: Vec3| c.clone().unwrap_or_else(|| builder::vertex(p3(p)));
let a0 = vertex(&end0.ca, end0.a);
let b0 = vertex(&end0.cb, end0.b);
let a1 = vertex(&end1.ca, end1.a);
let b1 = vertex(&end1.cb, end1.b);
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
// middle is the point nearest the old edge.
@@ -294,13 +333,19 @@ fn blend_geometry(
};
let c0 = corner(&a0, &b0, p0);
let c1 = corner(&a1, &b1, p1);
let la = builder::line(&a0, &a1);
let lb = builder::line(&b0, &b1);
let la = rail_a.clone().unwrap_or_else(|| builder::line(&a0, &a1));
let lb = rail_b.clone().unwrap_or_else(|| builder::line(&b0, &b1));
// Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B.
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped); the
// blended edge moves to A or B.
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut dropped: Vec<mt::EdgeID> = Vec::new();
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
if used.is_some() {
dropped.push(e.id());
continue;
}
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);
@@ -308,6 +353,9 @@ fn blend_geometry(
}
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
for (i, f) in faces.iter().enumerate() {
if (i == i1 && rail_a.is_some()) || (i == i2 && rail_b.is_some()) {
continue;
}
let nf = if i == i1 {
let mut m = subst.clone();
m.insert(edge.id(), la.clone());
@@ -317,9 +365,9 @@ fn blend_geometry(
m.insert(edge.id(), lb.clone());
rebuild_face(f, &m, &[])?
} else if i == end0.g {
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c0))?
} else if i == end1.g {
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c1))?
} else {
(*f).clone()
};
+33
View File
@@ -181,6 +181,39 @@ fn fillets_on_several_edges_and_angles() {
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
}
#[test]
fn fillet_consuming_a_side_face_makes_a_full_round() {
// 14 x 6 x 10: r = 3 on the four vertical edges uses up both 6 mm end faces (#199).
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
let full = 840.0 - (4.0 - PI) * 9.0 * 10.0;
for order in [mids.to_vec(), mids.iter().rev().copied().collect()] {
let f = fillet(&b, &order, 3.0).unwrap();
let m = measure(&f).unwrap();
assert!(rel(m.volume, full) < 2e-4, "{} vs {full}", m.volume);
// Top, bottom, the two long sides and four quarter rounds; the end faces are gone.
assert_eq!(m.faces, 8, "{m:?}");
assert_eq!(f.lumps().unwrap().len(), 1);
assert!(f.validity().is_empty(), "{:?}", f.validity());
}
// One end only: two rounds meeting on one end face.
let f = fillet(&b, &mids[1..4].iter().step_by(2).copied().collect::<Vec<_>>(), 3.0).unwrap();
assert!(rel(measure(&f).unwrap().volume, 840.0 - (4.0 - PI) * 9.0 * 5.0) < 2e-4);
assert!(f.validity().is_empty());
// A 6 x 6 post rounded on all four edges with r = 3 is a cylinder.
let post = box_solid(Vec3::ZERO, Vec3::new(6.0, 6.0, 10.0)).unwrap();
let corners = [Vec3::new(0.0, 0.0, 5.0), Vec3::new(6.0, 0.0, 5.0), Vec3::new(6.0, 6.0, 5.0), Vec3::new(0.0, 6.0, 5.0)];
let c = fillet(&post, &corners, 3.0).unwrap();
assert!(rel(measure(&c).unwrap().volume, PI * 9.0 * 10.0) < 2e-4, "{}", measure(&c).unwrap().volume);
assert!(c.validity().is_empty());
// Past the limit there is no face left to consume: still refused, the body unchanged.
for r in [3.0 + 1e-3, 3.5] {
let e = fillet(&b, &mids, r).unwrap_err();
assert!(e.to_string().contains("larger than the neighbouring faces"), "{r}: {e}");
}
assert_eq!(measure(&b).unwrap().volume, 840.0);
}
#[test]
fn tapered_extrudes() {
// 40 x 40 square, 30 up, 10° outward: a frustum.