From 5835bb10d453baf35710a458436e3a7c63d364a5 Mon Sep 17 00:00:00 2001
From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com>
Date: Sun, 11 Oct 2026 01:26:27 +0200
Subject: [PATCH] Join chamfers across unequal sloped rim walls
---
ROADMAP.md | 1 +
crates/kernel/src/blend.rs | 8 +++
crates/kernel/src/blend_corner.rs | 102 ++++++++++++++++++++++++++----
crates/kernel/src/loopblend.rs | 15 ++++-
4 files changed, 113 insertions(+), 13 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 9456c346..6b09c647 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -69,6 +69,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
+ connected equal-distance chamfers also trim each other across unequal sloped rim walls;
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), runs of
diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs
index 075dde67..d65ee441 100644
--- a/crates/kernel/src/blend.rs
+++ b/crates/kernel/src/blend.rs
@@ -785,6 +785,14 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result
{
return Ok(r);
}
}
+ // At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild
+ // the corners locally, allowing adjacent chamfers to reach different depths
+ // on their common wall seam.
+ if edges.len() > 1
+ && let Ok(result) = crate::blend_corner::chamfer(body, edges, distance)
+ {
+ return Ok(result);
+ }
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
if let Some(groups) = loop_groups(&cur, edges)? {
diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs
index bb114a81..92d0ade6 100644
--- a/crates/kernel/src/blend_corner.rs
+++ b/crates/kernel/src/blend_corner.rs
@@ -38,7 +38,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result {
let mut first: Option = None;
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
for _ in 0..4 {
- let err = match build(&cur, &sel) {
+ let err = match build(&cur, &sel, true) {
Ok(b) => return Ok(b),
Err(e) => e,
};
@@ -54,6 +54,14 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result {
Err(first.unwrap_or_else(|| unsupported("these fillets")))
}
+/// Local equal-distance chamfers, including pocket rims where adjacent walls have
+/// different slopes. The planar strips trim each other at each selected corner.
+pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result {
+ let cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
+ let selected: Vec<_> = edges.iter().map(|p| (*p, distance)).collect();
+ build(&cur, &selected, false)
+}
+
/// A face of the shell being rebuilt, or a new face.
#[derive(Clone, Copy, PartialEq)]
enum Owner {
@@ -174,7 +182,7 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec)],
Err(KernelError::Failed("fillet corner: a boundary does not close".into()))
}
-fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result {
+fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let solid = cur.deep_copy();
@@ -252,16 +260,20 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result {
return Err(unsupported("the faces at the edge are tangent"));
}
let r = radius.get(id).copied().unwrap_or(1.0);
- let s = r / (alpha / 2.0).tan();
+ let s = if round { r / (alpha / 2.0).tan() } else { r };
let w = (alpha / 2.0).sin();
// The cylinder, well past both ends (sections on oblique end faces reach beyond them).
let m = 4.0 * (s + r);
let (q0, q1) = (p0 - d * m, p1 + d * m);
let ctrl = vec![vec![h(q0 + ta * s, 1.0), h(q1 + ta * s, 1.0)], vec![h(q0, w), h(q1, w)], vec![h(q0 + tb * s, 1.0), h(q1 + tb * s, 1.0)]];
- let surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
- (mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
- ctrl,
- )));
+ let surf = if round {
+ mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
+ (mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
+ ctrl,
+ )))
+ } else {
+ mt::Surface::Plane(mt::Plane::new(p3(q0 + ta * s), p3(q1 + ta * s), p3(q0 + tb * s)))
+ };
let mid = (p0 + p1) * 0.5;
let axis = mid + ta * s - na * r;
let out = ((ta + tb) * -1.0).normalized().unwrap_or(na);
@@ -337,7 +349,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result {
endpt.insert((*e1, *vid, Some(f12)), a.clone());
endpt.insert((*e3, *vid, None), b.clone());
endpt.insert((*e1, *vid, Some(f31)), b.clone());
- let c = edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))?;
+ let c = if round { edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))? } else { builder::line(&a, &b) };
list.push((c, vec![Owner::Face(f23), Owner::Cyl(*e1)]));
}
([(ea, ua), (eb, ub)], [(e3, u3)]) => {
@@ -355,7 +367,9 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result {
endpt.insert((*ea, *vid, Some(fab)), qv.clone());
endpt.insert((*eb, *vid, Some(fab)), qv.clone());
let (la, lb) = ((xa - v).len(), (xb - v).len());
- let crease_to = |end: Vec3| -> Result { crease(sa, sb, q, end, size) };
+ let crease_to = |end: Vec3| -> Result {
+ if round { crease(sa, sb, q, end, size) } else { Ok(mt::Curve::Line(mt::Line(p3(q), p3(end)))) }
+ };
if (la - lb).abs() <= size * 1e-9 {
let xv = builder::vertex(p3(xb));
endpt.insert((*e3, *vid, None), xv.clone());
@@ -376,10 +390,16 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result {
let c0 = (x - v).dot(tt) - small.s;
let c1 = big.w * (0.0 - small.s);
let c2 = (z - v).dot(tt) - small.s;
- let t = bernstein_root(c0, c1, c2).ok_or_else(|| unsupported("fillets this different in size at one corner"))?;
- let y = {
+ // A chamfer section is a line, not the cylinder's conic.
+ // Where it meets the other strip's contact line is also
+ // where the crease ends; the remainder trims that wall.
+ let t = if round { bernstein_root(c0, c1, c2) } else { Some(c0 / (c0 - c2)).filter(|t| *t > 0.0 && *t < 1.0) }
+ .ok_or_else(|| unsupported("blends this different in size at one corner"))?;
+ let y = if round {
use mt::ParametricCurve;
crate::body::from_p3(sec.subs(t))
+ } else {
+ x + (z - x) * t
};
{
use mt::Cut;
@@ -390,13 +410,16 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result {
endpt.insert((*e3, *vid, None), xv.clone());
endpt.insert((eb_, *vid, Some(fbig3)), xv.clone());
endpt.insert((es, *vid, Some(fsmall3)), yv.clone());
- let piece = edge(&xv, &yv, mt::Curve::NurbsCurve(sec))?;
+ let piece = if round { edge(&xv, &yv, mt::Curve::NurbsCurve(sec))? } else { builder::line(&xv, &yv) };
list.push((piece, vec![Owner::Face(fsmall3), Owner::Cyl(eb_)]));
let c = edge(&qv, &yv, crease_to(y)?)?;
list.push((c, vec![Owner::Cyl(*ea), Owner::Cyl(*eb)]));
}
}
([(e1, u1), (e2, u2), (e3, u3)], []) => {
+ if !round {
+ return Err(unsupported("three chamfers at one corner in the local blend"));
+ }
let (s1, s2, s3) = (sel(*e1)?, sel(*e2)?, sel(*e3)?);
let (f12, f23, f31) = (face_with(*e1, *e2)?, face_with(*e2, *e3)?, face_with(*e3, *e1)?);
let q12 = meet(v, (*u1, s1.t(f12), s1.s), (*u2, s2.t(f12), s2.s))?;
@@ -1150,4 +1173,59 @@ mod tests {
}
}
}
+
+ #[test]
+ fn chamfers_meet_on_unequal_sloped_rim_walls() {
+ use solvecraft_geom::{Loop2, Plane, Vec2};
+ for margin in [1.0, 4.0] {
+ let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
+ let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
+ let pocket = crate::loft(&[
+ (Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)),
+ (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0)),
+ ])
+ .unwrap();
+ let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
+ let picks = [Vec3::new(50.0, margin, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
+ for count in [2, 3, 4] {
+ if margin == 1.0 {
+ assert!(crate::chamfer(&basin, &picks[..count], 2.0).is_err());
+ assert!(crate::chamfer(&basin, &picks[..count], 1.0).is_err());
+ }
+ let distance = if margin == 1.0 { 0.5 } else { 2.0 };
+ let result = crate::chamfer(&basin, &picks[..count], distance).unwrap();
+ assert!(result.validity().is_empty());
+ assert_eq!(result.unmeshed_faces(), 0);
+ assert!(vol(&result) < vol(&basin));
+ assert!(vol(&basin) - vol(&result) < distance.powi(2) * (60.0 + 50.0 - margin));
+ let reverse: Vec<_> = picks[..count].iter().copied().rev().collect();
+ let reversed = crate::chamfer(&basin, &reverse, distance).unwrap();
+ assert!((vol(&result) - vol(&reversed)).abs() < 1e-6);
+ // These are exact planes, with every boundary on its owning face.
+ for f in result.solid.face_iter() {
+ let Some(n) = plane_normal(f) else { continue };
+ let origin = vtx(&f.vertex_iter().next().unwrap());
+ for v in f.vertex_iter() {
+ assert!((vtx(&v) - origin).dot(n).abs() < 1e-7);
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn equal_slope_rim_loops_cannot_escape_the_face() {
+ use solvecraft_geom::{Loop2, Plane, Vec2};
+ let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
+ let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
+ let pocket =
+ crate::loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 1.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 11.0, 70.0, 40.0))])
+ .unwrap();
+ let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
+ let picks = [Vec3::new(50.0, 1.0, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
+ assert!(crate::chamfer(&basin, &picks, 2.0).is_err());
+ assert!(fillet(&basin, &picks, 4.0).is_err());
+ assert!(crate::chamfer(&basin, &picks, 0.5).is_ok());
+ assert!(fillet(&basin, &picks, 0.5).is_ok());
+ }
}
diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs
index 713d1478..98d40abe 100644
--- a/crates/kernel/src/loopblend.rs
+++ b/crates/kernel/src/loopblend.rs
@@ -270,7 +270,7 @@ fn build(
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) {
return None;
}
- let sb = r / (phi / 2.0).tan();
+ let sb = if round { r / (phi / 2.0).tan() } else { r };
Some((m, t, sb, r / (phi / 2.0).sin()))
};
// The straight seam between the walls at a sharp joint (direction away from the face).
@@ -328,6 +328,19 @@ fn build(
}
pb.push(p + e * (sb / h1));
}
+ // Sharp joints between straight rim edges can extend beyond the old corner.
+ // Equal wall slopes reach this path instead of the corner builder, and must
+ // also reject an extension through the outer wall or another hole.
+ for (i, q) in pa.iter().enumerate() {
+ // Smooth arc transitions and open-run ends retain their existing checks.
+ if !mitre.get(i).copied().unwrap_or(false) {
+ continue;
+ }
+ let p = joint_point(i).ok_or_else(|| unsupported("degenerate loop joint"))?;
+ if !crate::blend::extension_fits(f, p, *q, size * 1e-7) {
+ return Err(unsupported("the blend is larger than the neighbouring faces"));
+ }
+ }
// 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.max(nj)];