diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 73c42203..45124dc4 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1697,8 +1697,12 @@ fn region_samples(r: &Region2) -> Vec { /// 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) -> 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> = - 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 { + 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::>(); + 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::>(); 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 diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index d1d5b954..9041841f 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -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}"); +} diff --git a/examples/regressions/cut_whole_face_after_bottom_chamfer.json b/examples/regressions/cut_whole_face_after_bottom_chamfer.json new file mode 100644 index 00000000..ce744efa --- /dev/null +++ b/examples/regressions/cut_whole_face_after_bottom_chamfer.json @@ -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"}} + ] +}