Extrude Cut of a whole face beside round corners: grow the arcs too (#1275) (#1401)

This commit is contained in:
bherbruck authored and GitHub committed 2026-10-15 10:27:22 -04:00
1 parent 073acd295b
commit 762b6fe934
3 files changed
+105 -30

No files matched your search

+73 -30
View File
@@ -1697,8 +1697,12 @@ fn region_samples(r: &Region2) -> Vec<Vec2> {
/// Grow profile edges that lie on a face of a target body away from the profile, by a small
/// amount, when the space they grow into is already material (join) or empty (cut). `probe`
/// maps a sketch point to the world points where the tool would be (along the extrude, around
/// the revolve). Only loops of straight edges are adjusted.
/// the revolve). Loops of lines and arcs are adjusted: a line moves out, an arc keeps its centre
/// and changes its radius (a rounded corner of a face cut whole, #1275). Arcs move only where
/// both of their ends meet tangent neighbours that move with them; otherwise the loop is left
/// as it is.
fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec<Vec3>) -> Region2 {
use solidcraft_geom::Seg2;
if !matches!(op, Operation::Cut | Operation::Join) {
return r.clone();
}
@@ -1710,55 +1714,94 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
let delta = (size * 0.005).max(1e-3);
let lp = r.outer.ccw();
let lines: Option<Vec<(Vec2, Vec2)>> =
lp.segs.iter().map(|s| if let solidcraft_geom::Seg2::Line { a, b } = *s { Some((a, b)) } else { None }).collect();
let Some(lines) = lines else { return r.clone() };
let segs = &lp.segs;
if segs.is_empty() || !segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return r.clone();
}
// The outward normal (away from the profile) at parameter `t` of a segment of the
// counterclockwise loop: the tangent turned clockwise.
let out_at = |s: &Seg2, t: f64| -> Option<Vec2> {
match *s {
Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)),
Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()),
_ => None,
}
};
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let mut grow = vec![0.0; lines.len()];
let mut grow = vec![0.0; segs.len()];
let mut any = false;
for (i, (a, b)) in lines.iter().enumerate() {
let Some(dir) = (*b - *a).normalized() else { continue };
let out = Vec2::new(dir.y, -dir.x);
for (i, seg) in segs.iter().enumerate() {
if let Seg2::Arc { radius, sweep, .. } = *seg
&& sweep < 0.0
&& radius <= 2.0 * delta
{
// A concave arc too small to shrink by the step.
continue;
}
// Is the edge on a body face? Material on exactly one side of it.
let mids = [a.lerp(*b, 0.25), a.lerp(*b, 0.5), a.lerp(*b, 0.75)];
let side = |k: f64| mids.iter().flat_map(|m| probe(*m + out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), out_at(seg, *t)?))).collect();
if mids.len() < 3 {
continue;
}
let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let (outer, inner) = (side(0.5), side(-0.5));
let want_outer = op == Operation::Join;
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
let room = side(1.0).iter().all(|x| *x == want_outer);
if on_face && room {
if let Some(g) = grow.get_mut(i) {
*g = delta;
}
if on_face
&& room
&& let Some(g) = grow.get_mut(i)
{
*g = delta;
any = true;
}
}
if !any {
return r.clone();
}
// Offset the chosen lines and re-intersect neighbours.
let n = lines.len();
let shifted: Vec<(Vec2, Vec2)> = lines
.iter()
.zip(&grow)
.map(|((a, b), g)| {
let dir = (*b - *a).normalized().unwrap_or(Vec2::X);
let out = Vec2::new(dir.y, -dir.x);
(*a + out * *g, dir)
})
.collect();
let mut pts = Vec::with_capacity(n);
let n = segs.len();
let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0;
// Where segment `i` starts once grown: the corner of it and the one before.
let mut starts = Vec::with_capacity(n);
for i in 0..n {
let (Some(&(p0, d0)), Some(&(p1, d1))) = (shifted.get((i + n - 1) % n), shifted.get(i)) else { return r.clone() };
let (Some(prev), Some(cur), Some(&g0), Some(&g1)) = (segs.get((i + n - 1) % n), segs.get(i), grow.get((i + n - 1) % n), grow.get(i)) else {
return r.clone();
};
let corner = cur.start();
if g0 == 0.0 && g1 == 0.0 {
starts.push(corner);
continue;
}
if tangent(prev, cur) {
// Moved together, a tangent joint moves along the common normal and stays tangent.
if g0 != g1 {
return r.clone();
}
let Some(out) = out_at(cur, 0.0) else { return r.clone() };
starts.push(corner + out * g1);
continue;
}
let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return r.clone() };
let (Some(d0), Some(d1)) = ((b0 - a0).normalized(), (b1 - a1).normalized()) else { return r.clone() };
let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * g0, a1 + Vec2::new(d1.y, -d1.x) * g1);
let den = d0.cross(d1);
if den.abs() < 1e-12 {
pts.push(p1);
starts.push(p1);
continue;
}
let t = (p1 - p0).cross(d1) / den;
pts.push(p0 + d0 * t);
starts.push(p0 + d0 * t);
}
Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: r.holes.clone() }
let mut out = Vec::with_capacity(n);
for (i, seg) in segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(&g)) = (starts.get(i), starts.get((i + 1) % n), grow.get(i)) else { return r.clone() };
out.push(match *seg {
Seg2::Line { .. } => Seg2::Line { a, b },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius: radius + g * sweep.signum(), start, sweep },
_ => return r.clone(),
});
}
Region2 { outer: solidcraft_geom::Loop2 { segs: out }, holes: r.holes.clone() }
}
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
@@ -267,3 +267,24 @@ fn lip_swept_flush_round_a_corner_joins() {
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
}
/// #1275: an Extrude Cut of a whole face of a rounded-rectangle block with a bottom chamfer. The
/// tool's corner cylinders lay on the block's; they are grown off them now, as straight edges
/// were, and the cut takes 5 mm off the top.
#[test]
fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
let s = replay(include_str!("../../../examples/regressions/cut_whole_face_after_bottom_chamfer.json"));
let bodies = &s.model.state().bodies;
assert_eq!(bodies.len(), 1);
let b = &bodies[0];
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
let pi = std::f64::consts::PI;
let block = (40.0 * 40.0 - (4.0 - pi) * 8.0 * 8.0) * 15.0;
// The chamfer's 2 × 2 triangle swept along the loop (Pappus: its centroid is 2/3 mm in).
let chamfer = 2.0 * (4.0 * 24.0 + 2.0 * pi * (8.0 - 2.0 / 3.0));
let mesh = b.body.tessellate(1e-4).unwrap();
let volume = mesh.measure().volume;
assert!((volume - (block - chamfer)).abs() < 0.05, "{volume} expected {}", block - chamfer);
let top = mesh.bounds().max.z;
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
}
@@ -0,0 +1,11 @@
{
"description": "A rounded-rectangle block (40 x 40, R8 corners, 20 high) with a 2 mm chamfer round its bottom loop, then an Extrude Cut of its whole top face 5 mm down. The tool's walls, its corner cylinders included, lay on the block's walls; only straight profile edges were grown off the walls, so the cut failed with 'no result' (#1275). Result: the block 15 high, less the chamfer.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.rounded", "params": {"p0": [0, 0], "p1": [40, 40], "radius": 8}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "operation": "new"}},
{"command": "solid.chamfer", "params": {"distance": 2, "edges": [[20, 0, 0], [40, 20, 0], [20, 40, 0], [0, 20, 0], [2.343, 2.343, 0], [37.657, 2.343, 0], [37.657, 37.657, 0], [2.343, 37.657, 0]]}},
{"command": "solid.extrude", "params": {"face": [20, 20, 20], "distance": -5, "operation": "cut"}}
]
}