Fillets and chamfers on runs of neighbouring edges along a planar face's loop (two or three top edges of a box): the blends meet in mitres at the sharp corners between them and end square where unblended edges go on (those edges and the walls' seams stop at the blend)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:31:27 +00:00
1 parent bee763959c
commit 01b349842c
4 files changed
+156 -34

No files matched your search

+2 -1
View File
@@ -58,7 +58,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres),
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square),
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
+13
View File
@@ -318,6 +318,19 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
cur = blend(&cur, &rest, r, shape, what)?;
return blend(&cur, &loop_pts, r, shape, what);
}
// Neighbouring edges along a planar face's loop (sharp corners between them: the blends
// meet in mitres), ending where unblended edges go on.
if edges.len() >= 2 {
let size = cur.size();
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, edges)?;
if ids.len() == edges.len()
&& let Some(Ok(res)) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
&& let Ok(b) = guard(what, || Ok(res)).and_then(Body::new)
{
return Ok(b);
}
}
if let Some(groups) = loop_groups(&cur, edges)? {
for g in groups {
let size = cur.size();
+119 -31
View File
@@ -39,6 +39,12 @@ impl Seg {
Seg::Line { a, .. } | Seg::Arc { a, .. } => a,
}
}
fn end(&self) -> Vec3 {
match *self {
Seg::Line { b, .. } => b,
Seg::Arc { a, c, axis, angle, .. } => turn(a, c, axis, angle),
}
}
/// Unit tangent (direction of travel) at a point of the segment.
fn tangent(&self, p: Vec3) -> Option<Vec3> {
match *self {
@@ -144,10 +150,21 @@ pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f
let Some(n) = plane_normal(f) else { continue };
for (wi, w) in f.boundaries().iter().enumerate() {
let ids: Vec<mt::EdgeID> = w.edge_iter().map(|e| e.id()).collect();
if ids.len() != edge_ids.len() || !edge_ids.iter().all(|e| ids.contains(e)) {
if !edge_ids.iter().all(|e| ids.contains(e)) {
continue;
}
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round));
if ids.len() == edge_ids.len() {
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, None));
}
// A run of neighbouring edges of the loop (its ends meet unblended edges).
let m = ids.len();
let inside = |i: usize| ids.get(i % m).is_some_and(|x| edge_ids.contains(x));
let starts: Vec<usize> = (0..m).filter(|i| inside(*i) && !inside(*i + m - 1)).collect();
if let [s0] = starts[..]
&& (0..edge_ids.len()).all(|j| inside(s0 + j))
{
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, Some((s0, edge_ids.len()))));
}
}
}
}
@@ -155,10 +172,28 @@ pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f
}
#[allow(clippy::too_many_arguments)]
fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: Vec3, size: f64, tol: f64, r: f64, round: bool) -> Result<Solid> {
fn build(
solid: &Solid,
si: usize,
faces: &[&mt::Face],
fi: usize,
wi: usize,
n: Vec3,
size: f64,
tol: f64,
r: f64,
round: bool,
run: Option<(usize, usize)>,
) -> Result<Solid> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
let edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
let all_edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
// The blended edges in loop order: the whole loop, or a run of it (open).
let open = run.is_some();
let edges: Vec<mt::Edge> = match run {
None => all_edges,
Some((s0, len)) => (0..len).filter_map(|j| all_edges.get((s0 + j) % all_edges.len().max(1)).cloned()).collect(),
};
let edge_segs: Vec<Seg> = edges
.iter()
.map(|e| seg_of(e, (size * 1e-5).max(1e-6)))
@@ -169,9 +204,15 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
let owner: Vec<usize> = split.iter().map(|x| x.0).collect();
let segs: Vec<Seg> = split.iter().map(|x| x.1).collect();
let k = segs.len();
// Joints: k round a whole loop; k + 1 along a run (its two ends included).
let nj = if open { k + 1 } else { k };
let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k };
// Joints: smooth, or sharp between two lines (the blends then meet in a mitre).
let mut mitre = vec![false; k];
let mut mitre = vec![false; nj];
for i in 0..k {
if open && i == 0 {
continue;
}
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { continue };
let p = cur.start();
let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) };
@@ -256,12 +297,14 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
_ => None,
}
};
let mut pa = Vec::with_capacity(k);
let mut pb = Vec::with_capacity(k);
let mut centres = Vec::with_capacity(k);
for i in 0..k {
let p = segs.get(i).map(|s| s.start()).unwrap_or_default();
let (m, t, sb, dc) = frame(i, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
let mut pa = Vec::with_capacity(nj);
let mut pb = Vec::with_capacity(nj);
let mut centres = Vec::with_capacity(nj);
// Joint i: the start of piece i (the end of the last piece for a run's far end).
let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) };
for i in 0..nj {
let p = joint_point(i).unwrap_or_default();
let (m, t, sb, dc) = frame(i.min(k - 1), p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
centres.push(p + (m + t).normalized().unwrap_or(m) * dc);
if !mitre.get(i).copied().unwrap_or(false) {
pa.push(p + m * sb);
@@ -287,14 +330,11 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
}
// The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks
// to a point (a rounded corner rounded again: the blend there is a sphere).
let mut collapsed = vec![false; k];
let mut collapsed = vec![false; k.max(nj)];
for (i, s) in segs.iter().enumerate() {
if let Seg::Arc { a, c, .. } = *s {
let toward = into(s, a).dot(c - a) > 0.0;
if toward
&& (a.dist(c) - r).abs() <= tol * 10.0
&& !mitre.get(i).copied().unwrap_or(true)
&& !mitre.get((i + 1) % k).copied().unwrap_or(true)
if toward && (a.dist(c) - r).abs() <= tol * 10.0 && !mitre.get(i).copied().unwrap_or(true) && !mitre.get(nxt(i)).copied().unwrap_or(true)
{
if let Some(x) = collapsed.get_mut(i) {
*x = true;
@@ -312,7 +352,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
for i in 0..k {
if collapsed.get(i).copied().unwrap_or(false)
&& let Some(v) = va.get(i).cloned()
&& let Some(slot) = va.get_mut((i + 1) % k)
&& let Some(slot) = va.get_mut(nxt(i))
{
*slot = v;
}
@@ -320,12 +360,12 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
let vb: Vec<mt::Vertex> = pb.iter().map(|p| builder::vertex(p3(*p))).collect();
let centre = |i: usize| centres.get(i).copied().unwrap_or_default();
let profile_mid = |i: usize| {
let (Some(s), c) = (segs.get(i), centre(i)) else { return Vec3::ZERO };
c + (s.start() - c).normalized().unwrap_or(n) * r
let (Some(p), c) = (joint_point(i), centre(i)) else { return Vec3::ZERO };
c + (p - c).normalized().unwrap_or(n) * r
};
// Profiles at the joints (face side → wall side).
let mut profiles: Vec<mt::Edge> = Vec::with_capacity(k);
for i in 0..k {
let mut profiles: Vec<mt::Edge> = Vec::with_capacity(nj);
for i in 0..nj {
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) };
if !round {
profiles.push(builder::line(a, b));
@@ -368,7 +408,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
if std::ptr::eq(vs, va.as_slice()) && collapsed.get(i).copied().unwrap_or(false) {
return None;
}
let (a, b) = (vs.get(i)?, vs.get((i + 1) % k)?);
let (a, b) = (vs.get(i)?, vs.get(nxt(i))?);
Some(match *s {
Seg::Line { .. } => builder::line(a, b),
Seg::Arc { mid, .. } => builder::circle_arc(a, b, p3(offset(mid, s))),
@@ -391,16 +431,28 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?;
// Seams between neighbouring walls at the joints get shorter.
// Model vertices at the joints (the second half of a circle starts at none).
let joint_vertex: Vec<Option<mt::Vertex>> = (0..k)
let mut joint_vertex: Vec<Option<mt::Vertex>> = (0..k)
.map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None })
.collect();
if open {
joint_vertex.push(edges.last().map(|e| e.back().clone()));
}
// At a run's ends the face's next loop edges stop at the face-side point.
let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id());
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut seam_ends: HashMap<mt::EdgeID, (mt::Edge, Option<mt::Vertex>, Option<mt::Vertex>)> = HashMap::new();
let loop_ids: Vec<mt::EdgeID> = edges.iter().map(|e| e.id()).collect();
for (i, v) in joint_vertex.iter().enumerate() {
let Some(v) = v else { continue };
for g in faces {
for e in g.edge_iter() {
if loop_ids.contains(&e.id()) || seam_subst.contains_key(&e.id()) || !(e.front() == v || e.back() == v) {
if loop_ids.contains(&e.id()) || !(e.front() == v || e.back() == v) {
continue;
}
// Once per edge and joint (an edge between both ends of a run is met twice).
if seam_ends.get(&e.id()).is_some_and(|x: &(mt::Edge, Option<mt::Vertex>, Option<mt::Vertex>)| {
(x.0.absolute_front() == v && x.1.is_some()) || (x.0.absolute_back() == v && x.2.is_some())
}) {
continue;
}
let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone());
@@ -408,17 +460,31 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the wall seams must be straight"));
}
let along = (from_p3(other.point()) - from_p3(v.point())).dot(pb.get(i).copied().unwrap_or_default() - from_p3(v.point()));
let reach = (pb.get(i).copied().unwrap_or_default() - from_p3(v.point())).len2();
let face_side = on_face(&e);
let target = if face_side { pa.get(i) } else { pb.get(i) }.copied().unwrap_or_default();
let along = (from_p3(other.point()) - from_p3(v.point())).dot(target - from_p3(v.point()));
let reach = (target - from_p3(v.point())).len2();
if along <= reach + tol {
return Err(unsupported("the blend is taller than the walls"));
return Err(unsupported(if face_side { "the blend is longer than the next edge" } else { "the blend is taller than the walls" }));
}
let nb = if face_side { va.get(i) } else { vb.get(i) }.ok_or_else(|| KernelError::Failed("joint".into()))?;
let slot = seam_ends.entry(e.id()).or_insert_with(|| (e.absolute_clone(), None, None));
if &fa == v {
slot.1 = Some(nb.clone());
} else {
slot.2 = Some(nb.clone());
}
let nb = vb.get(i).ok_or_else(|| KernelError::Failed("joint".into()))?;
let ne = if &fa == v { builder::line(nb, &fb) } else { builder::line(&fa, nb) };
seam_subst.insert(e.id(), ne);
}
}
}
for (id, (e, a, b)) in seam_ends {
let a = a.unwrap_or_else(|| e.absolute_front().clone());
let b = b.unwrap_or_else(|| e.absolute_back().clone());
if from_p3(a.point()).dist(from_p3(b.point())) < tol * 10.0 {
return Err(unsupported("the blend is longer than the next edge"));
}
seam_subst.insert(id, builder::line(&a, &b));
}
// Rebuild faces.
let idx_of = |id: mt::EdgeID| loop_ids.iter().position(|x| *x == id);
let rebuild = |g: &mt::Face, rails: &[Option<mt::Edge>]| -> Result<mt::Face> {
@@ -444,6 +510,28 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
};
es.push(ne);
}
// A run's end: the face's edge and the wall's seam now stop at the two ends of the
// end profile, which closes the gap between them.
if open {
let mut fixed: Vec<mt::Edge> = Vec::with_capacity(es.len() + 2);
let m = es.len();
for j in 0..m {
let (Some(cur), Some(next)) = (es.get(j), es.get((j + 1) % m)) else { continue };
fixed.push(cur.clone());
if cur.back() == next.front() {
continue;
}
for ji in [0, nj - 1] {
let (Some(a), Some(b), Some(pr)) = (va.get(ji), vb.get(ji), profiles.get(ji)) else { continue };
if cur.back() == a && next.front() == b {
fixed.push(pr.clone());
} else if cur.back() == b && next.front() == a {
fixed.push(pr.inverse());
}
}
}
es = fixed;
}
wires.push(mt::Wire::from(es));
}
crate::heal::absolute_face(g, wires).ok_or_else(|| KernelError::Failed("loop blend: a face could not be rebuilt".into()))
@@ -465,7 +553,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n:
use mt::{ParametricSurface3D, SearchNearestParameter};
for i in 0..k {
let (Some(s), Some(pr), Some(pr_next), Some(fr), Some(wr)) =
(segs.get(i), profiles.get(i), profiles.get((i + 1) % k), face_rails.get(i), wall_rails.get(i))
(segs.get(i), profiles.get(i), profiles.get(nxt(i)), face_rails.get(i), wall_rails.get(i))
else {
continue;
};
+22 -2
View File
@@ -138,8 +138,11 @@ fn fillets_on_several_edges_and_angles() {
let removed = r * r / (phi / 2.0).tan() - r * r * (PI - phi) / 2.0;
let v0 = measure(&p).unwrap().volume;
assert!(rel(measure(&fp).unwrap().volume, v0 - removed * 10.0) < 2e-4, "{} vs {}", measure(&fp).unwrap().volume, v0 - removed * 10.0);
// Adjacent edges at a blended corner are not supported yet (an error, not a crash).
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).is_err());
// Adjacent edges meeting at a corner: their blends meet in a mitre.
let two = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).unwrap();
let cut = 24000.0 - measure(&two).unwrap().volume;
let a = 4.0 * (1.0 - PI / 4.0);
assert!(cut > a * (20.0 + 30.0 - 2.0) && cut < a * 50.0, "{cut}");
// Concave edge: not supported.
let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap();
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
@@ -1175,3 +1178,20 @@ fn booleans_found_by_the_survey() {
let vu = measure(&u).unwrap().volume;
assert!(vu > PI * 64.0 * 20.0 && vu < PI * 64.0 * 20.0 + 1620.0, "{vu}");
}
#[test]
fn fillet_two_edges_meeting_at_a_corner() {
// Two top edges of a box meeting at a corner (the vertical edge there stays sharp): the
// fillets meet in a mitre and stop square at the far walls.
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let f = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0)], 3.0).unwrap();
let removed = 24000.0 - measure(&f).unwrap().volume;
// Each edge loses r²(1 − π/4) along its length; at the mitred corner the two cuts overlap
// in a region of volume r³(1 − π/4)·… — bounded between the sum less one corner and the sum.
let a = 9.0 * (1.0 - PI / 4.0);
let (lo, hi) = (a * (40.0 + 30.0) - a * 3.0, a * (40.0 + 30.0));
assert!(removed > lo && removed < hi, "removed {removed} in ({lo}, {hi})");
// Three edges: front, left and back.
let f3 = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)], 3.0).unwrap();
assert!(measure(&f3).unwrap().volume < measure(&f).unwrap().volume);
}