diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 96f53ba0..0e30dcaa 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2816,6 +2816,64 @@ fn a_pocket_around_a_cut_out_rebuilds_on_a_folded_sheet() { } } +/// Engraving a long channel leaves its corner rounds round. The channel (1200 long, 1 thick) has +/// 2 mm rounds where its legs end, and two 5.95 x 5 pockets 0.1 apart are cut 0.1 deep into a leg +/// halfway along. Their walls in one plane leave the second pocket to the exact planar fallback, +/// whose mesh (a thousandth of the body, 1.2 mm) is flat across a 2 mm round: taken for a plane, +/// the rounds came back flat chamfers beside a round hole, and without one the cut failed. +#[test] +fn engraving_a_long_channel_keeps_its_corner_rounds() { + for hole in [false, true] { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Thin", "active": true, "thickness": "1 mm", "bend_radius": "1 mm", "k_factor": "0.44"})); + // Along z, 20 wide, its legs 65 and 25 high from the web at y = 65. + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, -600], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Profile"})); + run(&mut s, "sketch.line", json!({"points": [[-10, 0], [-10, 65], [10, 65], [10, 40]], "ids": ["l1", "l2", "l3"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Profile", "curves": ["l1", "l2", "l3"], "distance": 1200})); + // Each leg's free edge rounded at both ends: the corner outside a quarter circle, cut through. + let legs = [(-10.0_f64, 0.0_f64), (10.0, 40.0)]; + for (k, (x, y)) in legs.iter().enumerate() { + let name = format!("Rounds{k}"); + run(&mut s, "sketch.create", json!({"plane": {"origin": [x, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": name})); + for end in [-600.0, 600.0_f64] { + let c = end - 2.0 * end.signum(); + let far = end + 2.0 * end.signum(); + run(&mut s, "sketch.line", json!({"points": [[c, y], [c, y - 2.0], [far, y - 2.0], [far, y + 2.0], [end, y + 2.0]]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [c, y + 2.0], "start": [end, y + 2.0], "sweep": -90.0 * end.signum()})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "distance": 4, "direction": "symmetric", "operation": "cut"})); + } + if hole { + run(&mut s, "sketch.create", json!({"plane": {"origin": [-10, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [360, 57], "diameter": 4})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 4, "direction": "symmetric", "operation": "cut"})); + } + let v0 = volume(&mut s); + // Into the outer face of the leg at x = -10 (its material from -10 to -9). + run(&mut s, "sketch.create", json!({"plane": {"origin": [-10, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": "Label"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-6, 30], "p1": [-0.05, 35]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.05, 30], "p1": [6, 35]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Label", "distance": 0.1, "direction": "negative", "operation": "cut"})); + // 2 x 5.95 x 5 x 0.1. + let removed = v0 - volume(&mut s); + assert!((removed - 5.95).abs() < 1e-3, "hole {hole}: removed {removed}"); + // A point on each round, halfway through the leg: 2 - 2 cos 45 deg in from both edges. + let d = 2.0 - std::f64::consts::SQRT_2; + let faces: Vec<[f64; 3]> = + legs.iter().flat_map(|(x, y)| [-600.0, 600.0_f64].map(|end| [x + 0.5 * -x.signum(), y + d, end - d * end.signum()])).collect(); + let radii = run(&mut s, "inspect.minimum_radius", json!({"faces": faces})); + let items = radii["items"].as_array().unwrap(); + assert_eq!(items.len(), 4); + for item in items { + assert!(item["min_radius"].as_f64().is_some_and(|r| (r - 2.0).abs() < 1e-6), "hole {hole}: {item}"); + } + } +} + /// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working: /// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263). #[test] diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 7d03a82d..951c3216 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -227,13 +227,36 @@ impl NotPlanar { } } +/// The kind of curved surface `face` lies on, or None when it lies on a plane. `flat`: whether +/// its mesh is flat. The meshes here are as coarse as a thousandth of the body, which spans a +/// round much smaller than that (a 2 mm corner of a 1.2 m channel) with one flat strip of +/// facets: the surface decides, and the mesh only for a surface none of these kinds fits. +fn curved_kind(face: &mt::Face, flat: bool, tol: f64) -> Option<&'static str> { + use crate::offset::{Surf, surf_of}; + if matches!(face.surface(), mt::Surface::Plane(_)) { + return None; + } + match surf_of(face, tol) { + Some(Surf::Plane { .. }) => None, + Some(Surf::Cylinder { .. }) => Some("cylinder"), + Some(Surf::Cone { .. }) => Some("cone"), + Some(Surf::Sphere { .. }) => Some("sphere"), + Some(Surf::Torus { .. }) => Some("torus"), + None => (!flat).then_some("freeform"), + } +} + +/// The tolerance [`curved_kind`] tells surfaces apart by: face_surface's, with the body's +/// bounds computed once. Walking to each indexed face separately made curved-body diagnostics +/// quadratic. +fn kind_tol(b: &Body) -> f64 { + (b.size() * 1e-7).max(1e-9) * 100.0 +} + /// The plane of each face (from its mesh, which must be flat), or what stands in the way: the /// curved faces by the kind of surface they lie on. fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result, NotPlanar> { - use crate::offset::{Surf, surf_of}; - // Match face_surface's classification tolerance, but compute the body bounds only - // once. Walking to each indexed face separately made curved-body diagnostics quadratic. - let surface_tol = (b.size() * 1e-7).max(1e-9) * 100.0; + let surface_tol = kind_tol(b); let mut surfaces = b.solid.face_iter(); let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new(); let mut out = Vec::with_capacity(faces.len()); @@ -247,19 +270,10 @@ fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result out.push(PlaneEq { n, w: n.dot(f.centroid) }), - None => { - let kind = match surf_of(surface, surface_tol) { - Some(Surf::Cylinder { .. }) => "cylinder", - Some(Surf::Cone { .. }) => "cone", - Some(Surf::Sphere { .. }) => "sphere", - Some(Surf::Torus { .. }) => "torus", - Some(Surf::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), - None => "freeform", - }; - *curved.entry(kind).or_insert(0) += 1; - } + match (f.plane_normal, curved_kind(surface, f.plane_normal.is_some(), surface_tol)) { + (Some(n), None) => out.push(PlaneEq { n, w: n.dot(f.centroid) }), + (None, None) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), + (_, Some(kind)) => *curved.entry(kind).or_insert(0) += 1, } } if surfaces.next().is_some() { @@ -437,9 +451,14 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option> = + a.solid.face_iter().zip(&infos).map(|(f, info)| info.plane_normal.filter(|_| curved_kind(f, true, surface_tol).is_none())).collect(); let mut curved: BTreeMap = BTreeMap::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { - if infos.get(*fi as usize)?.plane_normal.is_some() { + if planes.get(*fi as usize)?.is_some() { continue; } let bx = curved.entry(*fi).or_insert(solidcraft_geom::Aabb3::EMPTY); @@ -458,7 +477,7 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect(); + let flat: Vec<(Vec3, Vec3)> = planes.iter().zip(&infos).filter_map(|(n, f)| Some(((*n)?, f.centroid))).collect(); let gap = size * 1e-4; let place = |k: usize, near: f64, far: f64| -> Option { let axis = *axes.get(k)?; @@ -1223,3 +1242,59 @@ mod diagnostic_tests { assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into())); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod small_round_tests { + use std::f64::consts::FRAC_PI_2; + + use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + + use super::*; + + /// An `l` x `w` x 1 plate on XY with its four corners rounded (radius `r`), and a hole of + /// diameter 4 through it 100 from one end when `hole`. + fn rounded_plate(l: f64, w: f64, r: f64, hole: bool) -> Body { + let (x, y) = (l / 2.0, w / 2.0); + let corners = [(x - r, y - r), (r - x, y - r), (r - x, r - y), (x - r, r - y)]; + let mut segs = Vec::new(); + for (k, (cx, cy)) in corners.iter().enumerate() { + let start = k as f64 * FRAC_PI_2; + let at = |t: f64| Vec2::new(cx + r * t.cos(), cy + r * t.sin()); + segs.push(Seg2::Arc { center: Vec2::new(*cx, *cy), radius: r, start, sweep: FRAC_PI_2 }); + let (nx, ny) = corners[(k + 1) % 4]; + let next = start + FRAC_PI_2; + segs.push(Seg2::Line { a: at(next), b: Vec2::new(nx + r * next.cos(), ny + r * next.sin()) }); + } + let holes = if hole { vec![Loop2::circle(Vec2::new(x - 100.0, 0.0), 2.0).reversed()] } else { Vec::new() }; + crate::extrude_planar_regions(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes }], 0.0, 1.0).unwrap() + } + + /// Faces of `b` on cylinders of radius `r`. + fn cylinders(b: &Body, r: f64) -> usize { + (0..b.face_count()) + .filter(|&i| matches!(crate::face_surface(b, i), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-9)) + .count() + } + + /// A pocket cut into the middle of a long plate by the exact fallback leaves the plate's + /// small corner rounds round. Meshed at a thousandth of the 1200 mm plate (1.2 mm), each + /// 2 mm quarter round (its chord 0.59 mm inside the arc) is one flat strip of facets, which + /// was taken for a plane: the whole plate went to the polygon boolean (and, beside a round + /// hole, the rounds went into the planar slab around the pocket), and the rounds came back + /// flat chamfers, each taking r^2 (pi/4 - 1/2) = 1.14 mm^3 more than its round. + #[test] + fn a_long_plates_small_rounds_stay_round_beside_a_pocket() { + for hole in [false, true] { + let plate = rounded_plate(1200.0, 40.0, 2.0, hole); + assert_eq!(cylinders(&plate, 2.0), 4 + 2 * usize::from(hole)); + // 10 x 5, 0.1 deep from the top. + let tool = crate::box_solid(Vec3::new(-5.0, -2.5, 0.9), Vec3::new(5.0, 2.5, 1.0)).unwrap(); + let r = planar_boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap(); + assert_eq!(cylinders(&r, 2.0), 4 + 2 * usize::from(hole), "hole {hole}"); + // The pocket's 10 x 5 x 0.1 and nothing else (a flattened round takes 1.14 more). + let removed = crate::measure(&plate).unwrap().volume - crate::measure(&r).unwrap().volume; + assert!((removed - 5.0).abs() < 1e-3, "hole {hole}: removed {removed}"); + } + } +}