157 lines
8.8 KiB
Rust
157 lines
8.8 KiB
Rust
//! Booleans of bodies with faces in a common plane: a face is pushed out of the plane only where
|
|
//! that leaves the result as it is. Every expected volume is what the two bodies define.
|
|
#![allow(clippy::unwrap_used, clippy::panic)]
|
|
use crate::*;
|
|
use solidcraft_geom::Vec3;
|
|
|
|
/// An l x w x h block with two bottom edges chamfered by 1 mm: no prism along any axis, so
|
|
/// its booleans are not done on sections.
|
|
fn block(l: f64, w: f64, h: f64) -> Body {
|
|
let b = box_solid(Vec3::ZERO, Vec3::new(l, w, h)).unwrap();
|
|
let b = chamfer(&b, &[Vec3::new(l / 2.0, w, 0.0)], 1.0).unwrap();
|
|
chamfer(&b, &[Vec3::new(l, w / 2.0, 0.0)], 1.0).unwrap()
|
|
}
|
|
|
|
/// A 200 x 100 x 20 block with a rib across it: 20 wide over the block's 100, from the top
|
|
/// face z = 20 up to z = 30.
|
|
fn ribbed() -> Body {
|
|
let rib = box_solid(Vec3::new(90.0, 0.0, 20.0), Vec3::new(110.0, 100.0, 30.0)).unwrap();
|
|
boolean(&block(200.0, 100.0, 20.0), &rib, BoolOp::Union).unwrap().unwrap()
|
|
}
|
|
|
|
/// A channel along that block, longer than its 200: y 30 to 70 and z 10 to 20, so its top
|
|
/// lies in the plane of the face the rib stands on.
|
|
fn channel() -> Body {
|
|
box_solid(Vec3::new(-20.0, 30.0, 10.0), Vec3::new(220.0, 70.0, 20.0)).unwrap()
|
|
}
|
|
|
|
/// What the boolean of `a` and `b` adds to a's volume, the result's lumps, and how it was made.
|
|
fn change(a: &Body, b: &Body, op: BoolOp) -> (f64, usize, BooleanDiagnostics) {
|
|
let (r, how) = capture_boolean_diagnostics(|| boolean(a, b, op));
|
|
let r = r.unwrap().unwrap();
|
|
(measure(&r).unwrap().volume - measure(a).unwrap().volume, r.lumps().unwrap().len(), how)
|
|
}
|
|
|
|
fn used(how: &BooleanDiagnostics, strategy: BooleanStrategy) -> bool {
|
|
how.events.iter().any(|e| e.strategy == strategy)
|
|
}
|
|
|
|
/// A channel cut along a block, up to the face a rib stands on. The channel's top lies in
|
|
/// that face's plane and the rib bridges it: pushed up out of the plane (2.29 % of the
|
|
/// body's size, 5.6 mm), the top would cut a notch 20 x 40 into the rib.
|
|
#[test]
|
|
fn a_cut_up_to_a_face_leaves_what_stands_on_the_face() {
|
|
let (added, lumps, how) = change(&ribbed(), &channel(), BoolOp::Cut);
|
|
// It takes 200 x 40 x 10 of the block and nothing of the rib.
|
|
assert!((added + 80_000.0).abs() < 1e-6 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
|
|
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
|
|
}
|
|
|
|
/// The same with a round hole through the block beside the channel (r 4 at 30, 15: y 11 to
|
|
/// 19, the channel from 30 on). The body no longer has planar faces only, but the hole lies
|
|
/// clear of the tool, and the cut stays what the channel is.
|
|
#[test]
|
|
fn a_round_hole_beside_the_cut_changes_nothing() {
|
|
let hole = cylinder(Vec3::new(30.0, 15.0, -1.0), Vec3::Z, 4.0, 22.0).unwrap();
|
|
let drilled = boolean(&ribbed(), &hole, BoolOp::Cut).unwrap().unwrap();
|
|
let (added, lumps, how) = change(&drilled, &channel(), BoolOp::Cut);
|
|
// 200 x 40 x 10 again (the hole is measured on its mesh, before and after).
|
|
assert!((added + 80_000.0).abs() < 1e-3 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
|
|
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
|
|
}
|
|
|
|
/// With nothing in their way the faces are pushed as before: a pocket's top out of the plane
|
|
/// of the block's top face, a pad's foot into the block. (This holds without the check too:
|
|
/// it keeps the check from refusing what is harmless.)
|
|
#[test]
|
|
fn faces_with_room_are_pushed_apart() {
|
|
let block = block(200.0, 100.0, 20.0);
|
|
// A pocket 50 x 60 x 5 from the top face: 15 000 less.
|
|
let pocket = box_solid(Vec3::new(75.0, 20.0, 15.0), Vec3::new(125.0, 80.0, 20.0)).unwrap();
|
|
let (added, _, how) = change(&block, &pocket, BoolOp::Cut);
|
|
assert!((added + 15_000.0).abs() < 1e-6 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
|
|
// A pad 50 x 60 x 10 on the top face: 30 000 more.
|
|
let pad = box_solid(Vec3::new(75.0, 20.0, 20.0), Vec3::new(125.0, 80.0, 30.0)).unwrap();
|
|
let (added, lumps, how) = change(&block, &pad, BoolOp::Union);
|
|
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
|
|
}
|
|
|
|
/// A disc joined into a round hole it fills (issue: "boolean Union: no result" for a 1 mm
|
|
/// floor extruded into a through hole of a part that isn't a prism). The disc's wall lies on
|
|
/// the hole's, and at either end its face lies in the block's bottom or top face: it adds its
|
|
/// own volume, the block stays one piece, and the hole is closed where the disc is.
|
|
#[test]
|
|
fn a_disc_joined_into_the_hole_it_fills() {
|
|
let hole = cylinder(Vec3::new(150.0, 50.0, -1.0), Vec3::Z, 20.0, 22.0).unwrap();
|
|
let drilled = boolean(&block(200.0, 100.0, 20.0), &hole, BoolOp::Cut).unwrap().unwrap();
|
|
for (z0, z1) in [(0.0, 1.0), (5.0, 8.0), (19.0, 20.0)] {
|
|
let disc = cylinder(Vec3::new(150.0, 50.0, z0), Vec3::Z, 20.0, z1 - z0).unwrap();
|
|
let want = measure(&disc).unwrap().volume;
|
|
let (r, how) = capture_boolean_diagnostics(|| boolean(&drilled, &disc, BoolOp::Union));
|
|
let r = r.unwrap().unwrap();
|
|
let added = measure(&r).unwrap().volume - measure(&drilled).unwrap().volume;
|
|
assert!((added - want).abs() < want * 1e-3, "{z0}..{z1}: {added} vs {want}, {how:?}");
|
|
assert!(r.lumps().unwrap().len() == 1 && r.validity().is_empty(), "{z0}..{z1}: {:?}", r.validity());
|
|
// Exact: nothing of the tool is moved.
|
|
assert!(used(&how, BooleanStrategy::GrownBoss) && !used(&how, BooleanStrategy::ToolNudge), "{z0}..{z1}: {how:?}");
|
|
}
|
|
}
|
|
|
|
/// A hemisphere of radius `r` standing on the plane z = 0.
|
|
fn hemisphere(r: f64) -> Body {
|
|
let ball = sphere(Vec3::ZERO, r).unwrap();
|
|
let below = box_solid(Vec3::new(-2.0 * r, -2.0 * r, -2.0 * r), Vec3::new(2.0 * r, 2.0 * r, 0.0)).unwrap();
|
|
boolean(&ball, &below, BoolOp::Cut).unwrap().unwrap()
|
|
}
|
|
|
|
/// A hemisphere (r 20) and a block (x 5 to 35, y -30 to 30, z 0 to 25) that both stand on z = 0, the block's foot over most of the
|
|
/// hemisphere's flat face. The neighbour of that face is the sphere, which cannot be stretched, so its face is grown instead (a
|
|
/// strip along its rim) and what the join leaves below the plane is cut off by editing faces. The block takes the part of the dome
|
|
/// beyond x = 5, half of a cap 15 high: the union is the dome and the block less that part.
|
|
#[test]
|
|
fn a_block_flush_with_the_flat_face_of_a_hemisphere_joins_exactly() {
|
|
let (r, x0) = (20.0, 5.0);
|
|
let block = box_solid(Vec3::new(x0, -30.0, 0.0), Vec3::new(35.0, 30.0, 25.0)).unwrap();
|
|
let dome = hemisphere(r);
|
|
let h = r - x0;
|
|
let expected = 2.0 / 3.0 * std::f64::consts::PI * r.powi(3) - std::f64::consts::PI * h * h * (3.0 * r - h) / 6.0 + 30.0 * 60.0 * 25.0;
|
|
for (a, b) in [(&dome, &block), (&block, &dome)] {
|
|
let joined = crate::coplanar::boolean_grown(a, b, BoolOp::Union).expect("the route applies").unwrap().unwrap();
|
|
let v = measure(&joined).unwrap().volume;
|
|
assert!((v - expected).abs() < expected * 1e-4, "{v} against {expected}");
|
|
assert_eq!(joined.lumps().unwrap().len(), 1);
|
|
assert!(joined.validity().is_empty(), "{:?}", joined.validity());
|
|
// The flat faces are one plane again: nothing of the join stays below z = 0.
|
|
let mesh = joined.tessellate(0.05).unwrap();
|
|
let lowest = mesh.positions.iter().map(|p| p.z).fold(f64::MAX, f64::min);
|
|
assert!(lowest > -1e-6, "the join reaches down to z = {lowest}");
|
|
}
|
|
}
|
|
|
|
/// Growing the flat face of a dome past its plane and cutting what that adds off again gives the dome back: the same volume, the same
|
|
/// faces, nothing below the plane.
|
|
#[test]
|
|
fn what_growing_a_face_adds_is_cut_off_again_exactly() {
|
|
let dome = hemisphere(20.0);
|
|
let measured = measure(&dome).unwrap().volume;
|
|
let grown = crate::coplanar::grow_faces_exact(&dome, -Vec3::Z, 0.0, 3.0).unwrap();
|
|
assert!(measure(&grown).unwrap().volume > measured + 3.0 * 300.0, "the stub adds volume");
|
|
let back = crate::coplanar::trim_exact(&grown, -Vec3::Z, 0.0).unwrap();
|
|
let v = measure(&back).unwrap().volume;
|
|
assert!((v - measured).abs() < measured * 1e-6, "{v} against {measured}");
|
|
assert_eq!(back.face_count(), dome.face_count());
|
|
assert!(back.validity().is_empty(), "{:?}", back.validity());
|
|
let lowest = back.tessellate(0.05).unwrap().positions.iter().map(|p| p.z).fold(f64::MAX, f64::min);
|
|
assert!(lowest > -1e-6, "the body reaches down to z = {lowest}");
|
|
}
|
|
|
|
/// A plane the body stands on has nothing beyond it, and one clear of the body is refused (nothing is cut off).
|
|
#[test]
|
|
fn trimming_at_a_plane_with_nothing_beyond_it() {
|
|
let dome = hemisphere(20.0);
|
|
let measured = measure(&dome).unwrap().volume;
|
|
let same = crate::coplanar::trim_exact(&dome, -Vec3::Z, 0.0).unwrap();
|
|
assert!((measure(&same).unwrap().volume - measured).abs() < measured * 1e-9);
|
|
assert!(crate::coplanar::trim_exact(&dome, -Vec3::Z, 5.0).is_err());
|
|
}
|