fix: a blind cut on a long body keeps its small rounds (#1593)
Thanks @NOWN-DEV.
This commit is contained in:
1 parent
39758b0cc4
commit
3fe6bacea0
2 files changed
+152
-19
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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<Vec<PlaneEq>, 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<Vec<P
|
||||
if f.area <= 0.0 {
|
||||
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
|
||||
}
|
||||
match f.plane_normal {
|
||||
Some(n) => 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<Re
|
||||
let tol = (a.size() * 1e-3).max(1e-3);
|
||||
let infos = a.faces(tol).ok()?;
|
||||
let mesh = a.tessellate(tol).ok()?;
|
||||
// Each face's plane normal where it is planar (see [`curved_kind`]): a small round meshed
|
||||
// flat is no plane, and taken for one the middle piece would rebuild it as a chamfer.
|
||||
let surface_tol = kind_tol(a);
|
||||
let planes: Vec<Option<Vec3>> =
|
||||
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<u32, solidcraft_geom::Aabb3> = 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<Re
|
||||
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
|
||||
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
|
||||
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
|
||||
let flat: Vec<(Vec3, Vec3)> = 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<f64> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user