Files
OneCAD/crates/kernel/src/tests.rs
T
phanoob beb3ea01e7 Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept) (#1204)
Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept).
2026-10-14 17:55:58 -04:00

2533 lines
130 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::f64::consts::PI;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::*;
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
fn rect(w: f64, h: f64) -> Region2 {
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]), holes: vec![] }
}
#[test]
fn box_extrude_measures() {
let b = extrude(&Plane::XY, &[rect(40.0, 30.0)], 0.0, 20.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 24000.0) < 1e-9, "{}", m.volume);
assert!(rel(m.area, 5200.0) < 1e-9);
assert_eq!((m.faces, m.edges, m.vertices), (6, 12, 8));
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (6, 12, 8));
assert!(m.centroid.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-6);
}
#[test]
fn cylinder_measures_match_analytic() {
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
let v = PI * 225.0 * 50.0;
let a = 2.0 * PI * 225.0 + 2.0 * PI * 15.0 * 50.0;
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
assert!(rel(m.area, a) < 5e-4, "{} vs {a}", m.area);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (3, 2, 2), "{:?}", m.merged);
assert_eq!(m.merged.face_types.get("cylinder"), Some(&1));
}
#[test]
fn extrude_on_other_planes_and_offsets() {
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 250.0) < 1e-9);
// XZ normal is +Y: the body spans y in [-2, 3].
assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox);
}
#[test]
fn region_with_hole() {
let r = Region2 { outer: rect(40.0, 30.0).outer, holes: vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.0).reversed()] };
let b = extrude(&Plane::XY, &[r], 0.0, 10.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
}
#[test]
fn through_cut_preserves_a_larger_blind_pocket() {
let plate = box_solid(Vec3::new(-30.0, -28.0, 0.0), Vec3::new(60.0, 22.0, 8.0)).unwrap();
let pocket = cylinder(Vec3::ZERO, Vec3::Z, 11.0, 6.0).unwrap();
let through = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 9.5, 10.0).unwrap();
let expected = 90.0 * 50.0 * 8.0 - PI * 11.0_f64.powi(2) * 6.0 - PI * 9.5_f64.powi(2) * 2.0;
for reverse in [false, true] {
let (first, second) = if reverse { (&through, &pocket) } else { (&pocket, &through) };
let cut = boolean(&plate, first, BoolOp::Cut).unwrap().unwrap();
let result = boolean(&cut, second, BoolOp::Cut).unwrap().unwrap();
let m = measure(&result).unwrap();
assert!(rel(m.volume, expected) < 5e-4, "reverse={reverse}: {} vs {expected}", m.volume);
assert_eq!(m.merged.faces, 9);
assert!(!result.tessellate(0.01).unwrap().contains(Vec3::new(10.0, 0.0, 3.0)));
}
}
#[test]
fn join_a_cylinder_on_its_chamfered_top() {
for seam in [0.0, crate::build::SEAM_ANGLE] {
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] };
let base = extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let base = chamfer(&base, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
let region = Region2 { outer: Loop2::circle_from(Vec2::new(0.0, -3.774758283725534e-15), 8.0, seam), holes: vec![] };
let plane = Plane { origin: Vec3::new(0.0, 0.0, 20.000000000000007), ..Plane::XY };
let tool = extrude(&plane, &[region], 0.0, 5.0).unwrap().pop().unwrap();
let expected = measure(&base).unwrap().volume + PI * 64.0 * 5.0;
let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&joined).unwrap().volume, expected) < 5e-4);
assert_eq!(joined.lumps().unwrap().len(), 1);
}
}
#[test]
fn box_fillet_cut_volume() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0)], 3.0).unwrap();
let mf = measure(&f).unwrap();
let expect_f = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
assert!(rel(mf.volume, expect_f) < 1e-4, "{} vs {expect_f}", mf.volume);
assert_eq!(mf.merged.faces, 7);
let hole = cylinder(Vec3::new(20.0, 15.0, -5.0), Vec3::Z, 5.0, 30.0).unwrap();
let cut = boolean(&f, &hole, BoolOp::Cut).unwrap().unwrap();
let mc = measure(&cut).unwrap();
let expect = expect_f - PI * 25.0 * 20.0;
assert!(rel(mc.volume, expect) < 1e-3, "{} vs {expect}", mc.volume);
}
#[test]
fn union_and_intersect() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap();
let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&u).unwrap().volume, 2000.0 - 125.0) < 1e-6);
let i = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap();
assert!(rel(measure(&i).unwrap().volume, 125.0) < 1e-6);
}
#[test]
fn revolve_washer_and_sphere() {
let r =
Region2 { outer: Loop2::polygon(&[Vec2::new(10.0, 0.0), Vec2::new(15.0, 0.0), Vec2::new(15.0, 5.0), Vec2::new(10.0, 5.0)]), holes: vec![] };
let b = revolve(&Plane::XZ, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
let v = PI * (225.0 - 100.0) * 5.0;
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
let half = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, PI).unwrap().pop().unwrap();
assert!(rel(measure(&half).unwrap().volume, v / 2.0) < 5e-4);
let s = sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap();
let ms = measure(&s).unwrap();
assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume);
}
#[test]
fn revolve_adjacent_profiles_on_the_axis() {
// Two rectangles on the axis, revolved together: their merged outline keeps a vertex
// where the shared edge met the axis. The swept axis pieces used to be zero-area faces,
// and cutting the body's flat end out of another failed.
let r = Region2 {
outer: Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(30.0, 0.0),
Vec2::new(60.0, 0.0),
Vec2::new(60.0, 20.0),
Vec2::new(30.0, 20.0),
Vec2::new(0.0, 20.0),
]),
holes: vec![],
};
let b = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::X, 2.0 * PI).unwrap().pop().unwrap();
assert!(rel(measure(&b).unwrap().volume, PI * 400.0 * 60.0) < 5e-4);
let faces = b.faces(1e-3).unwrap();
assert!(faces.iter().all(|f| f.area > 1e-6), "{:?}", faces.iter().map(|f| f.area).collect::<Vec<_>>());
let disc = cylinder(Vec3::new(-1.0, 0.0, 0.0), Vec3::X, 40.0, 3.0).unwrap();
let cut = boolean(&disc, &b, BoolOp::Cut).unwrap().unwrap();
let want = PI * 1600.0 * 3.0 - PI * 400.0 * 2.0;
assert!(rel(measure(&cut).unwrap().volume, want) < 1e-3, "{} vs {want}", measure(&cut).unwrap().volume);
}
#[test]
fn chamfer_and_step() {
let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let c = chamfer(&b, &[Vec3::new(20.0, 10.0, 20.0)], 2.0).unwrap();
assert!(rel(measure(&c).unwrap().volume, 8000.0 - 2.0 * 20.0) < 1e-4);
let s = step_export(&[&c], "SolidCraft").unwrap();
assert!(s.starts_with("ISO-10303-21;"));
assert!(s.contains("MANIFOLD_SOLID_BREP") || s.contains("CLOSED_SHELL"));
}
#[test]
fn hostile_inputs_are_errors() {
assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, 0.0).is_err());
assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, f64::NAN).is_err());
assert!(extrude(&Plane::XY, &[], 0.0, 1.0).is_err());
assert!(extrude(&Plane::XY, &[rect(0.0, 1.0)], 0.0, 1.0).is_err());
assert!(box_solid(Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0)).is_err());
assert!(cylinder(Vec3::ZERO, Vec3::ZERO, 1.0, 1.0).is_err());
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], -1.0).is_err());
assert!(fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_err());
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], 100.0).is_err());
let r = Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]), holes: vec![] };
assert!(revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, 1.0).is_err(), "crosses axis");
assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err());
}
#[test]
fn fillets_on_several_edges_and_angles() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
// Two opposite vertical edges, then a horizontal top edge between them is not touching either.
let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 10.0)], 4.0).unwrap();
let corner = 16.0 - PI * 4.0;
assert!(rel(measure(&f).unwrap().volume, 24000.0 - 2.0 * corner * 20.0) < 2e-4);
let top = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0)], 2.0).unwrap();
assert!(rel(measure(&top).unwrap().volume, 24000.0 - (4.0 - PI) * 40.0) < 2e-4);
// Triangular prism: 60° edges.
let tri = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(15.0, 25.980762113533)]), holes: vec![] };
let p = extrude(&Plane::XY, &[tri], 0.0, 10.0).unwrap().pop().unwrap();
let r = 2.0;
let fp = fillet(&p, &[Vec3::new(0.0, 0.0, 5.0)], r).unwrap();
// Removed area for interior angle φ: r²(cot(φ/2)) − r²(π − φ)/2.
let phi = PI / 3.0;
let removed = r * r / (phi / 2.0).tan() - r * r * (PI - phi) / 2.0;
let v0 = measure(&p).unwrap().volume;
assert!(rel(measure(&fp).unwrap().volume, v0 - removed * 10.0) < 2e-4, "{} vs {}", measure(&fp).unwrap().volume, v0 - removed * 10.0);
// Adjacent edges meeting at a corner: their blends meet in a mitre.
let two = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).unwrap();
let cut = 24000.0 - measure(&two).unwrap().volume;
let a = 4.0 * (1.0 - PI / 4.0);
assert!(cut > a * (20.0 + 30.0 - 2.0) && cut < a * 50.0, "{cut}");
// Concave edge where a post stands on the plate: the round adds material, its end caps
// extending the post's end faces down onto the plate.
let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap();
let f = fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).unwrap();
let added = measure(&f).unwrap().volume - measure(&u).unwrap().volume;
assert!(rel(added, (1.0 - PI / 4.0) * 10.0) < 1e-3, "{added}");
assert!(f.validity().is_empty());
assert_eq!(f.lumps().unwrap().len(), 1);
}
#[test]
fn fillet_ends_in_an_end_face_at_an_angle() {
// #215: the round ends in the end face's plane along an ellipse (its section) when that face
// is not square to the edge. A prism on a trapezoid (0,0) (40,0) (30,20) (0,20), 10 high: the
// top edge along y = 0 ends at x = 0 (square) and in the face x = 40 - y/2 (at an angle).
let r: f64 = 2.0;
let (area, c) = ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let trapezoid = |far: f64| {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(far, 20.0), Vec2::new(0.0, 20.0)];
extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap()
};
// The removed section's length at depth y into the top face is 40 + (far - 40) y / 20; its
// mean over the section is taken at the section's centroid (c from the edge).
for far in [30.0, 50.0] {
let b = trapezoid(far);
let v0 = measure(&b).unwrap().volume;
let f = fillet(&b, &[Vec3::new(20.0, 0.0, 10.0)], r).unwrap();
let want = v0 - area * (40.0 + (far - 40.0) * c / 20.0);
let m = measure(&f).unwrap();
assert!(rel(m.volume, want) < 2e-4, "far {far}: {} vs {want}", m.volume);
assert_eq!(f.lumps().unwrap().len(), 1);
assert!(f.validity().is_empty(), "far {far}: {:?}", f.validity());
}
}
#[test]
fn fillet_ending_across_two_faces() {
// #215: a plate's outline chamfered, then the mitre where two chamfers meet at a corner
// rounded. The mitre runs from the top face (ending in it at an angle) down to the corner
// where two side faces meet; the round ends across both, cutting back the vertical edge.
let (r, k) = (2.0, 5.0);
let b = box_solid(Vec3::ZERO, Vec3::new(100.0, 50.0, 20.0)).unwrap();
let outline = [Vec3::new(50.0, 0.0, 20.0), Vec3::new(100.0, 25.0, 20.0), Vec3::new(50.0, 50.0, 20.0), Vec3::new(0.0, 25.0, 20.0)];
let c = chamfer(&b, &outline, k).unwrap();
let f = fillet(&c, &[Vec3::new(k / 2.0, k / 2.0, 20.0 - k / 2.0)], r).unwrap();
assert!(f.validity().is_empty(), "{:?}", f.validity());
assert_eq!(f.lumps().unwrap().len(), 1);
// The removed material, by definition: inside the plate, between the two chamfers and the
// round, i.e. outside the round's cylinder and on the corner's side of both lines where it
// touches the chamfers (each at the setback s = r cot(φ/2) from the mitre, φ = 120° between
// the chamfers). Sampled at pseudo-random points (a regular grid aliases with the region's
// thin slivers along the chamfers): about 1.89 mm³, standard error about 0.02.
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
let (n1, n2) = (Vec3::new(0.0, -1.0, 1.0).normalized().unwrap(), Vec3::new(-1.0, 0.0, 1.0).normalized().unwrap());
// In each chamfer, square to the mitre and away from the other chamfer.
let into = |n: Vec3, other: Vec3| {
let t = n.cross(d).normalized().unwrap();
if t.dot(other) < 0.0 { t } else { -t }
};
let (t1, t2) = (into(n1, n2), into(n2, n1));
let phi = (-n1.dot(n2)).acos();
let s = r / (phi / 2.0).tan();
let corner = Vec3::new(0.0, 0.0, 20.0 - k);
let axis_at = corner + t1 * s - n1 * r;
let removed_at = |q: Vec3| {
let u = q - axis_at;
q.z - q.x <= 15.0 && q.z - q.y <= 15.0 && (q - corner).dot(t1) < s && (q - corner).dot(t2) < s && (u - d * u.dot(d)).len() > r
};
let (lo, span) = (Vec3::new(0.0, 0.0, 20.0 - k - 2.0 * r), k + 2.0 * r);
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
let mut unit = || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 11) as f64 / (1u64 << 53) as f64
};
let samples = 4_000_000;
let hits = (0..samples).filter(|_| removed_at(lo + Vec3::new(unit(), unit(), unit()) * span)).count();
let removed = span.powi(3) * hits as f64 / samples as f64;
let lost = measure(&c).unwrap().volume - measure(&f).unwrap().volume;
assert!((lost - removed).abs() < 0.07, "{lost} vs {removed}");
}
#[test]
fn loop_fillet_as_tall_as_its_wall_consumes_it() {
// The removed cross-section (a square of side r less a quarter disc) and its centroid's
// distance from the wall: r(10 − 3π) / (3(4 − π)).
let spandrel = |r: f64| ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
// #190: a disc 13 in radius and 2 high, its top circle rounded by 2. Pappus for the ring.
let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap();
let f = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], 2.0).unwrap();
let (a, c) = spandrel(2.0);
let want = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c);
let m = measure(&f).unwrap();
assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume);
// Top, round and bottom: the wall is gone.
assert_eq!(m.merged.faces, 3, "{:?}", m.merged);
assert!(f.validity().is_empty(), "{:?}", f.validity());
// A 40 x 30 x 5 plate, its whole top outline rounded by 5 (mitred at the corners).
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 5.0)).unwrap();
let top = [Vec3::new(20.0, 0.0, 5.0), Vec3::new(40.0, 15.0, 5.0), Vec3::new(20.0, 30.0, 5.0), Vec3::new(0.0, 15.0, 5.0)];
let f = fillet(&plate, &top, 5.0).unwrap();
let (a, c) = spandrel(5.0);
let want = 6000.0 - a * 2.0 * ((40.0 - 2.0 * c) + (30.0 - 2.0 * c));
let m = measure(&f).unwrap();
assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume);
assert!(f.validity().is_empty(), "{:?}", f.validity());
// Taller than the wall: still refused, the body unchanged.
for r in [2.0 + 1e-3, 2.5] {
let e = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], r).unwrap_err();
assert!(e.to_string().contains("taller than the walls"), "{r}: {e}");
}
}
#[test]
fn fillet_consuming_a_side_face_makes_a_full_round() {
// 14 x 6 x 10: r = 3 on the four vertical edges uses up both 6 mm end faces (#199).
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
let full = 840.0 - (4.0 - PI) * 9.0 * 10.0;
for order in [mids.to_vec(), mids.iter().rev().copied().collect()] {
let f = fillet(&b, &order, 3.0).unwrap();
let m = measure(&f).unwrap();
assert!(rel(m.volume, full) < 2e-4, "{} vs {full}", m.volume);
// Top, bottom, the two long sides and four quarter rounds; the end faces are gone.
assert_eq!(m.faces, 8, "{m:?}");
assert_eq!(f.lumps().unwrap().len(), 1);
assert!(f.validity().is_empty(), "{:?}", f.validity());
}
// One end only: two rounds meeting on one end face.
let f = fillet(&b, &mids[1..4].iter().step_by(2).copied().collect::<Vec<_>>(), 3.0).unwrap();
assert!(rel(measure(&f).unwrap().volume, 840.0 - (4.0 - PI) * 9.0 * 5.0) < 2e-4);
assert!(f.validity().is_empty());
// A 6 x 6 post rounded on all four edges with r = 3 is a cylinder.
let post = box_solid(Vec3::ZERO, Vec3::new(6.0, 6.0, 10.0)).unwrap();
let corners = [Vec3::new(0.0, 0.0, 5.0), Vec3::new(6.0, 0.0, 5.0), Vec3::new(6.0, 6.0, 5.0), Vec3::new(0.0, 6.0, 5.0)];
let c = fillet(&post, &corners, 3.0).unwrap();
assert!(rel(measure(&c).unwrap().volume, PI * 9.0 * 10.0) < 2e-4, "{}", measure(&c).unwrap().volume);
assert!(c.validity().is_empty());
// Past the limit there is no face left to consume: still refused, the body unchanged.
for r in [3.0 + 1e-3, 3.5] {
let e = fillet(&b, &mids, r).unwrap_err();
assert!(e.to_string().contains("larger than the neighbouring faces"), "{r}: {e}");
}
assert_eq!(measure(&b).unwrap().volume, 840.0);
}
#[test]
fn fillet_max_finds_the_largest_radius_that_works() {
let too_large = |b: &Body, edges: &[Vec3], r: f64| matches!(fillet(b, edges, r), Err(KernelError::TooLarge(_)));
// Two rounds on a 6 mm face: up to 3 (a full round), although after the first round at
// 3.5 the next edge alone would take only 2.5.
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
assert!(too_large(&b, &mids, 3.5));
let (r, body) = fillet_max(&b, &mids, 3.5).unwrap();
assert_eq!(r, 3.0);
assert!(rel(measure(&body).unwrap().volume, 840.0 - (4.0 - PI) * 90.0) < 2e-4);
// A 2 mm disc's top edge: up to 2 (the wall used up).
let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap();
let top = [Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)];
assert!(too_large(&disc, &top, 2.5));
assert_eq!(fillet_max(&disc, &top, 2.5).map(|x| x.0), Some(2.0));
// Walls of different heights: at the shortest one's height only that wall would be used up,
// which is refused, so the largest radius is just below it (to the search's 3 digits).
let notched = boolean(
&box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(),
&box_solid(Vec3::new(35.0, -1.0, -1.0), Vec3::new(41.0, 5.0, 3.0)).unwrap(),
BoolOp::Cut,
)
.unwrap()
.unwrap();
let outline = [Vec3::new(20.0, 0.0, 10.0), Vec3::new(40.0, 15.0, 10.0), Vec3::new(20.0, 30.0, 10.0), Vec3::new(0.0, 15.0, 10.0)];
assert!(too_large(&notched, &outline, 8.0));
let (r, _) = fillet_max(&notched, &outline, 8.0).unwrap();
assert!((r - 6.999).abs() < 1e-9, "{r}");
// No edge there: no radius.
assert!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_none());
}
/// Edges that share no face get their own largest radius: a 2 mm fin's top edge takes at most
/// its thickness, while a corner of the block it stands on, far from it, keeps the 4 asked for.
/// Edges that limit each other (the post's four, sharing faces) still share one.
#[test]
fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
let (block, fin) = (40.0 * 30.0 * 20.0, 2.0 * 30.0 * 10.0);
let body = boolean(
&box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(),
&box_solid(Vec3::new(19.0, 0.0, 20.0), Vec3::new(21.0, 30.0, 30.0)).unwrap(),
BoolOp::Union,
)
.unwrap()
.unwrap();
let edges = [Vec3::new(21.0, 15.0, 30.0), Vec3::new(40.0, 0.0, 10.0)];
assert!(matches!(fillet(&body, &edges, 4.0), Err(KernelError::TooLarge(_))));
let (one, _) = fillet_max(&body, &edges, 4.0).unwrap();
let (radii, b) = fillet_max_each(&body, &edges, 4.0).unwrap();
assert_eq!(radii.get(1), Some(&4.0), "{radii:?}");
let fin_r = radii.first().copied().unwrap();
assert!(fin_r < 4.0 && (fin_r - one).abs() < 1e-9, "{radii:?}, one radius {one}");
assert!(b.validity().is_empty(), "{:?}", b.validity());
// Each round takes (1 − π/4)·r² along its edge: the fin's 30 long, the corner's 20.
let removed = (1.0 - PI / 4.0) * (fin_r * fin_r * 30.0 + 16.0 * 20.0);
assert!(rel(measure(&b).unwrap().volume, block + fin - removed) < 2e-4);
// The post's four edges limit each other: one radius, as `fillet_max`.
let post = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
assert_eq!(fillet_max_each(&post, &mids, 3.5).map(|x| x.0), Some(vec![3.0; 4]));
}
/// A corner's rounds too large for its faces are refused as too large, so a fit can reduce
/// them, also where only the corner build finds it (the edge-by-edge blend failing otherwise).
#[test]
fn corner_rounds_larger_than_their_faces_are_too_large() {
let (l, eps) = (20.0, 1e-9);
let b = box_solid(Vec3::ZERO, Vec3::new(l, l, l)).unwrap();
let corner = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)];
// As wide as the faces: the first edge's round uses up its neighbour, then the edge-by-edge
// blend can't find it; the corner build is what says the radius is too large.
let e = fillet(&b, &corner, l).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let (r, body) = fillet_max(&b, &corner, l).unwrap();
assert!((r - 19.99).abs() < eps, "{r}");
assert!(body.validity().is_empty(), "{:?}", body.validity());
// Three rounds along the edges past the corner cube, and the cube less its sphere octant.
let removed = 3.0 * (1.0 - PI / 4.0) * r * r * (l - r) + r * r * r * (1.0 - PI / 6.0);
assert!(rel(measure(&body).unwrap().volume, l * l * l - removed) < 2e-4);
// Different radii per edge, one wider than its faces.
let sel: Vec<(Vec3, f64)> = corner.iter().copied().zip([21.0, 5.0, 5.0]).collect();
let e = crate::blend_corner::fillet_radii(&b, &sel).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
}
#[test]
fn tapered_extrudes() {
// 40 x 40 square, 30 up, 10° outward: a frustum.
let sq = Region2 {
outer: Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]),
holes: vec![],
};
let b = extrude_tapered(&Plane::XY, &sq, 30.0, 1.0, 10f64.to_radians()).unwrap();
let top = 40.0 + 2.0 * 30.0 * 10f64.to_radians().tan();
let v = 30.0 / 3.0 * (1600.0 + top * top + 40.0 * top);
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
assert_eq!(m.merged.faces, 6);
// Inward taper on a circle (a cone frustum), downward.
let c = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };
let b = extrude_tapered(&Plane::XY, &c, 10.0, -1.0, -20f64.to_radians()).unwrap();
let r2 = 10.0 - 10.0 * 20f64.to_radians().tan();
let v = PI * 10.0 / 3.0 * (100.0 + r2 * r2 + 10.0 * r2);
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
assert!(m.bbox.max.z < 1e-6 && m.bbox.min.z > -10.0 - 1e-6, "{:?}", m.bbox);
assert!(extrude_tapered(&Plane::XY, &c, 10.0, 1.0, -60f64.to_radians()).is_err(), "closes the profile");
}
/// A slot (two lines and two half-circles, tangent) tapered inward: every edge moves in by
/// d = z · tan 8°, so A(d) = A₀ − P·d + π·d² (#546). An arc meeting a line at a corner is refused.
#[test]
fn tapered_extrude_of_lines_and_arcs() {
let (r, l, h, a) = (5.0, 20.0, 12.0, 8f64.to_radians());
let slot = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(0.0, -r), b: Vec2::new(l, -r) },
Seg2::Arc { center: Vec2::new(l, 0.0), radius: r, start: -PI / 2.0, sweep: PI },
Seg2::Line { a: Vec2::new(l, r), b: Vec2::new(0.0, r) },
Seg2::Arc { center: Vec2::ZERO, radius: r, start: PI / 2.0, sweep: PI },
],
};
let b = extrude_tapered(&Plane::XY, &Region2 { outer: slot, holes: vec![] }, h, 1.0, -a).unwrap();
let (a0, p, t) = (2.0 * r * l + PI * r * r, 2.0 * l + 2.0 * PI * r, a.tan());
let v = a0 * h - p * t * h * h / 2.0 + PI * t * t * h.powi(3) / 3.0;
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
assert_eq!(m.merged.faces, 6);
// A quarter disc: the arc meets both lines at right angles.
let quarter = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(r, 0.0) },
Seg2::Arc { center: Vec2::ZERO, radius: r, start: 0.0, sweep: PI / 2.0 },
Seg2::Line { a: Vec2::new(0.0, r), b: Vec2::ZERO },
],
};
let e = extrude_tapered(&Plane::XY, &Region2 { outer: quarter, holes: vec![] }, h, 1.0, a).unwrap_err();
assert!(e.to_string().contains("arc meets another edge at a corner"), "{e}");
}
/// A circle drawn as four conics (the way free curves come, as in text) tapered inward: each
/// conic's control polygon moves in by d, which for a circle is the exact smaller circle, so
/// the solid is a cone frustum.
#[test]
fn tapered_extrude_of_conics() {
let (r, h, a) = (10.0, 12.0, 8f64.to_radians());
let w = std::f64::consts::FRAC_1_SQRT_2;
let p = |x: f64, y: f64| Vec2::new(x, y);
let disc = Loop2 {
segs: vec![
Seg2::Conic { a: p(r, 0.0), apex: p(r, r), b: p(0.0, r), w },
Seg2::Conic { a: p(0.0, r), apex: p(-r, r), b: p(-r, 0.0), w },
Seg2::Conic { a: p(-r, 0.0), apex: p(-r, -r), b: p(0.0, -r), w },
Seg2::Conic { a: p(0.0, -r), apex: p(r, -r), b: p(r, 0.0), w },
],
};
let b = extrude_tapered(&Plane::XY, &Region2 { outer: disc, holes: vec![] }, h, 1.0, -a).unwrap();
let r1 = r - h * a.tan();
let v = PI * h / 3.0 * (r * r + r1 * r1 + r * r1);
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-3, "{} vs {v}", m.volume);
assert!((m.bbox.max.x - r).abs() < 1e-6 && (m.bbox.max.z - h).abs() < 1e-6, "{:?}", m.bbox);
}
/// A circle drawn as four cubics (as splines and text come) tapered inward. A cubic's control
/// polygon moved in is only close to the cubic's true offset, so the solid is checked against
/// the offset's volume within a tolerance: the section at depth d of a convex smooth loop has
/// area A₀ − P·d + π·d² (Steiner), so the volume is A₀·h − P·t·h²/2 + π·t²·h³/3.
#[test]
fn tapered_extrude_of_cubics_is_close_to_the_true_offset() {
let (r, h, a) = (10.0, 12.0, 8f64.to_radians());
let k = r * 0.552_284_749_830_793_4;
let p = |x: f64, y: f64| Vec2::new(x, y);
let disc = Loop2 {
segs: vec![
Seg2::Cubic { p0: p(r, 0.0), p1: p(r, k), p2: p(k, r), p3: p(0.0, r) },
Seg2::Cubic { p0: p(0.0, r), p1: p(-k, r), p2: p(-r, k), p3: p(-r, 0.0) },
Seg2::Cubic { p0: p(-r, 0.0), p1: p(-r, -k), p2: p(-k, -r), p3: p(0.0, -r) },
Seg2::Cubic { p0: p(0.0, -r), p1: p(k, -r), p2: p(r, -k), p3: p(r, 0.0) },
],
};
let (area, perimeter) = (disc.signed_area().abs(), disc.segs.iter().map(|s| s.length()).sum::<f64>());
let b = extrude_tapered(&Plane::XY, &Region2 { outer: disc, holes: vec![] }, h, 1.0, -a).unwrap();
let t = a.tan();
let v = area * h - perimeter * t * h * h / 2.0 + PI * t * t * h.powi(3) / 3.0;
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
}
/// An S-shaped cubic (one curve turning both ways) has no moved cubic close to its offset: it is
/// cut into pieces until each piece's moved curve stays within 2 % of the offset of the true
/// one (or 10 µm), both ways.
#[test]
fn a_tapered_s_curve_is_cut_into_pieces_that_follow_its_offset() {
let s_curve = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(5.0, 10.0), p2: Vec2::new(10.0, -10.0), p3: Vec2::new(15.0, 0.0) };
let d = 0.5;
assert!(crate::build::offset_error(&s_curve, d).unwrap() > 0.05 * d, "one piece is far off");
let lp = Loop2 {
segs: vec![
s_curve,
Seg2::Line { a: Vec2::new(15.0, 0.0), b: Vec2::new(15.0, -20.0) },
Seg2::Line { a: Vec2::new(15.0, -20.0), b: Vec2::new(0.0, -20.0) },
Seg2::Line { a: Vec2::new(0.0, -20.0), b: Vec2::new(0.0, 0.0) },
],
};
let (cut, from) = crate::build::offset_splits(&lp, d);
let pieces: Vec<&Seg2> = cut.segs.iter().zip(&from).filter(|(_, i)| **i == 0).map(|(s, _)| s).collect();
assert!(pieces.len() > 1 && from.iter().filter(|i| **i > 0).count() == 3, "{} pieces", pieces.len());
for p in pieces {
for d in [d, -d] {
let e = crate::build::offset_error(p, d).unwrap();
assert!(e <= crate::build::offset_tolerance(d) + 1e-9, "{e}");
}
}
}
/// A wedge whose tip is a flat line far shorter than the taper moves its sides in: the tip line
/// goes, the two long sides meet, and its side is the flat triangle up to there. The solid is a
/// prismatoid, its volume h/6 · (A₀ + 4·A_mid + A₁).
#[test]
fn tapered_extrude_swallows_a_short_tip() {
let (h, a) = (2.0, 5f64.to_radians());
let d = h * a.tan();
let pts = [Vec2::new(0.0, -2.0), Vec2::new(20.0, -0.01), Vec2::new(20.0, 0.01), Vec2::new(0.0, 2.0)];
let b = extrude_tapered(&Plane::XY, &Region2 { outer: Loop2::polygon(&pts), holes: vec![] }, h, 1.0, -a).unwrap();
// The top: the three long lines moved in by d (to their left, the loop running
// counter-clockwise), meeting each other.
let moved = |p: Vec2, q: Vec2| {
let t = (q - p).normalized().unwrap();
(p + t.perp() * d, t)
};
let meet = |(p0, t0): (Vec2, Vec2), (p1, t1): (Vec2, Vec2)| p0 + t0 * ((p1 - p0).cross(t1) / t0.cross(t1));
let (low, high, left) = (moved(pts[0], pts[1]), moved(pts[2], pts[3]), moved(pts[3], pts[0]));
let top = [meet(left, low), meet(low, high), meet(high, left)];
let area = |q: &[Vec2]| 0.5 * q.iter().zip(q.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>();
// Halfway up, each side edge's midpoint (the two tip corners both run to the top's tip).
let mid = [pts[0].lerp(top[0], 0.5), pts[1].lerp(top[1], 0.5), pts[2].lerp(top[1], 0.5), pts[3].lerp(top[2], 0.5)];
let v = h / 6.0 * (area(&pts) + 4.0 * area(&mid) + area(&top));
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
assert_eq!(m.merged.faces, 6, "bottom, top, three sides and the tip's triangle");
}
/// A taper from below the profile, with a foot: the sides stand straight for the foot's height,
/// then slope on to the top, and the section at the profile's plane is the profile.
#[test]
fn tapered_extrude_between_with_a_foot() {
let a = 10f64.to_radians();
let sq = Region2 {
outer: Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]),
holes: vec![],
};
let (from, to, foot) = (-2.0, 10.0, 0.5);
let b = extrude_tapered_between(&Plane::XY, &sq, from, to, foot, 1.0, a).unwrap();
let side = |z: f64| 40.0 + 2.0 * z * a.tan();
let (s0, s1, rise) = (side(from), side(to), to - from - foot);
let v = foot * s0 * s0 + rise / 3.0 * (s0 * s0 + s1 * s1 + s0 * s1);
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
assert!((m.bbox.min.z - from).abs() < 1e-6 && (m.bbox.max.z - to).abs() < 1e-6, "{:?}", m.bbox);
assert_eq!(m.merged.faces, 10);
assert!(extrude_tapered_between(&Plane::XY, &sq, from, to, 7.0, 1.0, a).is_err(), "a foot taller than half the solid");
}
/// A taper steeper than a profile's details is refused, and says why: a slot narrower than the
/// taper closes; a ring's hole smaller than it closes.
#[test]
fn tapered_extrude_refuses_a_taper_steeper_than_the_details() {
let slot = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(10.0, 0.0),
Vec2::new(10.0, 10.0),
Vec2::new(5.1, 10.0),
Vec2::new(5.1, 2.0),
Vec2::new(4.9, 2.0),
Vec2::new(4.9, 10.0),
Vec2::new(0.0, 10.0),
]);
let e = extrude_tapered(&Plane::XY, &Region2 { outer: slot, holes: vec![] }, 2.0, 1.0, 10f64.to_radians()).unwrap_err();
assert!(e.to_string().contains("the taper closes the profile"), "{e}");
let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.0), holes: vec![Loop2::circle(Vec2::ZERO, 0.3).reversed()] };
let e = extrude_tapered(&Plane::XY, &ring, 2.0, 1.0, 10f64.to_radians()).unwrap_err();
assert!(e.to_string().contains("the taper closes the profile"), "{e}");
}
#[test]
fn polyhedra_shell_and_draft() {
let b = box_solid(Vec3::ZERO, Vec3::new(50.0, 40.0, 30.0)).unwrap();
let s = shell(&b, &[Vec3::new(25.0, 20.0, 30.0)], 2.0).unwrap();
let m = measure(&s).unwrap();
assert!(rel(m.volume, 60000.0 - 46.0 * 36.0 * 28.0) < 1e-6, "{}", m.volume);
assert_eq!(m.merged.faces, 11);
// Draft two side faces 5° in, about the bottom, pulling up.
let d = draft(&b, &[Vec3::new(0.0, 20.0, 15.0), Vec3::new(50.0, 20.0, 15.0)], &Plane::XY, Vec3::Z, 5f64.to_radians()).unwrap();
let t = 30.0 * 5f64.to_radians().tan();
assert!(rel(measure(&d).unwrap().volume, 40.0 * 30.0 * (50.0 + 50.0 - 2.0 * t) / 2.0) < 1e-6, "{}", measure(&d).unwrap().volume);
let tet = convex_polyhedron(&[
HalfSpace { n: Vec3::new(0.0, 0.0, -1.0), d: 0.0 },
HalfSpace { n: Vec3::new(-1.0, 0.0, 0.0), d: 0.0 },
HalfSpace { n: Vec3::new(0.0, -1.0, 0.0), d: 0.0 },
HalfSpace { n: Vec3::new(1.0, 1.0, 1.0).normalized().unwrap(), d: 6.0 / 3f64.sqrt() },
])
.unwrap();
assert!(rel(measure(&tet).unwrap().volume, 36.0) < 1e-9);
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 5.0).unwrap();
let cup = shell(&cyl, &[Vec3::new(0.0, 0.0, 5.0)], 1.0).unwrap();
assert!(rel(measure(&cup).unwrap().volume, PI * (25.0 * 5.0 - 16.0 * 4.0)) < 1e-4, "cup");
}
#[test]
fn loft_and_sweep() {
let sq = Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]);
let top = Plane::XY.offset(40.0);
let l = loft(&[(Plane::XY, sq), (top, Loop2::circle(Vec2::ZERO, 10.0))]).unwrap();
let m = measure(&l).unwrap();
println!("loft volume {} faces {:?}", m.volume, m.merged);
assert!(m.volume > 314.0 * 40.0 && m.volume < 1600.0 * 40.0);
// A tube: up 30, quarter bend of radius 20, across 30.
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 3.0), holes: vec![] };
let path = [
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 30.0) },
PathSeg::Arc {
a: Vec3::new(0.0, 0.0, 30.0),
center: Vec3::new(20.0, 0.0, 30.0),
axis: Vec3::new(0.0, 1.0, 0.0),
angle: std::f64::consts::FRAC_PI_2,
},
PathSeg::Line { a: Vec3::new(20.0, 0.0, 50.0), b: Vec3::new(50.0, 0.0, 50.0) },
];
let t = sweep(&Plane::XY, &disc, &path).unwrap();
for tol in [0.1, 0.01, 0.003, 0.001] {
println!("tube tess {tol}: {}", t.tessellate(tol).unwrap().measure().volume);
}
let area = PI * 9.0;
let expect = area * (30.0 + 30.0 + 20.0 * std::f64::consts::FRAC_PI_2);
assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume);
}
#[test]
fn sweep_round_a_sharp_corner_is_mitred() {
// A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile
// along the second leg within its own plane (an empty, inside-out body). Mitred, the
// volume is the area times the path length.
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] };
let path = [
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) },
PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) },
];
let b = sweep(&Plane::XY, &sq, &path).unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume);
}
#[test]
fn moving_a_body_with_an_intersection_curve_edge_ends() {
// A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving
// such a body scales the curve's tolerance by the matrix norm, whose root refinement cycled
// forever for about one translation in eight (this one included): the app hung.
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 40.0).unwrap();
let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap();
let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap();
let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap();
assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_))));
let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334;
let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap();
let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume);
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
/// Drain pads joined flush under a floor with a drain hole. The floor (100 × 80, top sloping
/// 1°, so not a prism) has a Ø20 hole through it at (50, 40).
/// - A ring whose bore is the hole's wall (coincident cylinders): used to fail.
/// - The same ring 2 mm into the floor: used to fail too.
/// - A disc across the hole: worked before, and must stay exact.
#[test]
fn drain_pads_flush_under_a_floor_with_a_hole() {
let t = 1f64.to_radians().tan();
let hs = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let slab = convex_polyhedron(&[
hs(Vec3::X, Vec3::new(100.0, 0.0, 0.0)),
hs(-Vec3::X, Vec3::ZERO),
hs(Vec3::Y, Vec3::new(0.0, 80.0, 0.0)),
hs(-Vec3::Y, Vec3::ZERO),
hs(-Vec3::Z, Vec3::ZERO),
hs(Vec3::new(-t, 0.0, 1.0), Vec3::new(0.0, 0.0, 10.0)),
])
.unwrap();
let hole = cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 10.0, 30.0).unwrap();
let floor = boolean(&slab, &hole, BoolOp::Cut).unwrap().unwrap();
// Top z = 10 + t x: the slab's volume is its mean height (at x = 50) times its area, the
// hole's likewise (its top is planar, centred at x = 50).
let v_floor = (10.0 + t * 50.0) * (8000.0 - PI * 100.0);
assert!(rel(measure(&floor).unwrap().volume, v_floor) < 2e-5, "{} vs {v_floor}", measure(&floor).unwrap().volume);
let ring = |z0: f64, z1: f64| {
let r = Region2 { outer: Loop2::circle(Vec2::new(50.0, 40.0), 20.0), holes: vec![Loop2::circle(Vec2::new(50.0, 40.0), 10.0).reversed()] };
extrude(&Plane::XY, &[r], z0, z1).unwrap().pop().unwrap()
};
// The ring's part inside the floor adds nothing: 5 mm of ring below it either way.
let v_ring = PI * (400.0 - 100.0) * 5.0;
for (z0, z1) in [(-5.0, 0.0), (-5.0, 2.0)] {
let (result, diagnostics) = capture_boolean_diagnostics(|| boolean(&floor, &ring(z0, z1), BoolOp::Union));
let u = result.unwrap().unwrap();
assert!(diagnostics.events.iter().any(|event| event.strategy == BooleanStrategy::CommonBore), "{diagnostics:?}");
assert!(diagnostics.events.iter().all(|event| event.translation_mm.is_finite() && event.face_shift_mm.is_finite()));
let v = measure(&u).unwrap().volume;
assert!(rel(v, v_floor + v_ring) < 2e-5, "ring {z0}..{z1}: {v} vs {}", v_floor + v_ring);
assert!(u.validity().is_empty(), "{:?}", u.validity());
}
let disc = cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 20.0, 5.0).unwrap();
let u = boolean(&floor, &disc, BoolOp::Union).unwrap().unwrap();
let v = measure(&u).unwrap().volume;
let expect = v_floor + PI * 400.0 * 5.0;
assert!(rel(v, expect) < 2e-5, "disc across the hole: {v} vs {expect}");
// A boss ring standing on the sloping top around the hole, its foot just under the top
// everywhere: it adds its annulus times its height above the top at the centre.
let foot = 10.0 + t * 30.0 - 0.2;
let u = boolean(&floor, &ring(foot, 14.0), BoolOp::Union).unwrap().unwrap();
let v = measure(&u).unwrap().volume;
let expect = v_floor + PI * 300.0 * (14.0 - (10.0 + t * 50.0));
assert!(rel(v, expect) < 2e-5, "boss ring on the slope: {v} vs {expect}");
assert!(u.validity().is_empty(), "{:?}", u.validity());
}
/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads
/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed
/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain
/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing
/// material, rejected the result, and the planar fallback refused the round pad.
#[test]
fn round_pads_crossing_a_shallow_fall() {
let hs = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let walls = |floor: &[HalfSpace]| {
let mut h = vec![
hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)),
hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)),
hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)),
hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)),
hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)),
];
h.extend_from_slice(floor);
convex_polyhedron(&h).unwrap()
};
let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap();
// One fall along x at 1°: floor z = 8 + t (x − 10).
let t = 1f64.to_radians().tan();
let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap();
let v0 = measure(&fall).unwrap().volume;
for x in [60.0, 100.0] {
// A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above).
let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap();
let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap();
// The part of the pad above the floor: the floor under the disc is planar, so its mean
// height is the height at the centre (where the floor is below z = 9 everywhere).
let floor = |x: f64| 8.0 + t * (x - 10.0);
let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN };
let v = measure(&u).unwrap().volume;
if above.is_finite() {
assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above);
} else {
assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}");
}
}
// Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall.
let t2 = 2f64.to_radians().tan();
let d = Vec3::new(240.0, 110.0, 8.0);
let hopper = walls(&[
hs(Vec3::new(t2, 0.0, -1.0), d),
hs(Vec3::new(-t2, 0.0, -1.0), d),
hs(Vec3::new(0.0, t2, -1.0), d),
hs(Vec3::new(0.0, -t2, -1.0), d),
]);
let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap();
let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap();
let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap();
// Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor.
let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume;
assert!((gain - 6517.0).abs() < 10.0, "{gain}");
}
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
let base = box_solid(Vec3::ZERO, Vec3::new(80.0, 50.0, 8.0)).unwrap();
let wall = box_solid(Vec3::new(0.0, 42.0, 8.0), Vec3::new(80.0, 50.0, 50.0)).unwrap();
let u = planar_boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
let m = measure(&u).unwrap();
assert!(rel(m.volume, 80.0 * 50.0 * 8.0 + 80.0 * 8.0 * 42.0) < 1e-9, "{}", m.volume);
assert_eq!(m.merged.faces, 8, "{:?}", m.merged);
// Through the public boolean (B-rep first, polygon fallback).
let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&u2).unwrap().volume, m.volume) < 1e-9);
assert_eq!(u2.face_count(), 8, "coplanar wall and base faces must merge in the B-rep");
assert!(u2.validity().is_empty());
// Two boxes sharing a face, and a cut flush with a side.
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(10.0, 0.0, 0.0), Vec3::new(20.0, 10.0, 10.0)).unwrap();
let ab = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&ab).unwrap().volume, 2000.0) < 1e-9);
assert_eq!(measure(&ab).unwrap().merged.faces, 6);
let notch = box_solid(Vec3::new(0.0, 0.0, 5.0), Vec3::new(5.0, 10.0, 10.0)).unwrap();
let c = boolean(&a, &notch, BoolOp::Cut).unwrap().unwrap();
assert!(rel(measure(&c).unwrap().volume, 1000.0 - 250.0) < 1e-9);
let i = boolean(&a, &notch, BoolOp::Intersect).unwrap().unwrap();
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
}
/// The planar fallback runs after any failed B-rep boolean, not only on coincident faces; when
/// it can't run it says which body has which curved faces (it used to blame "coincident faces
/// on curved bodies" whatever the cause).
#[test]
fn planar_fallback_names_the_curved_faces() {
let slab = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let pad = cylinder(Vec3::new(20.0, 15.0, 5.0), Vec3::Z, 8.0, 10.0).unwrap();
let e = planar_boolean(&slab, &pad, BoolOp::Union).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
assert!(e.contains("the tool has 2 curved faces (cylinder)"), "{e}");
assert!(!e.contains("target"), "{e}");
assert!(!e.contains("coincident faces on curved bodies"), "{e}");
let ball = sphere(Vec3::new(20.0, 15.0, 10.0), 5.0).unwrap();
let e = planar_boolean(&ball, &pad, BoolOp::Cut).unwrap_err().to_string();
assert!(e.contains("the target has 6 curved faces (sphere) and the tool has 2 curved faces (cylinder)"), "{e}");
// All-planar bodies, a face of one at a shallow angle (1°): the fallback takes them.
let slope = Vec3::new(-1f64.to_radians().tan(), 0.0, 1.0).normalized().unwrap();
let wedge = convex_polyhedron(&[
HalfSpace { n: Vec3::X, d: 30.0 },
HalfSpace { n: -Vec3::X, d: -10.0 },
HalfSpace { n: Vec3::Y, d: 20.0 },
HalfSpace { n: -Vec3::Y, d: -10.0 },
HalfSpace { n: -Vec3::Z, d: -5.0 },
HalfSpace { n: slope, d: slope.dot(Vec3::new(10.0, 0.0, 11.0)) },
])
.unwrap();
let u = planar_boolean(&slab, &wedge, BoolOp::Union).unwrap().unwrap();
let above = box_solid(Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 20.0)).unwrap();
let expect = 40.0 * 30.0 * 10.0 + measure(&boolean(&wedge, &above, BoolOp::Intersect).unwrap().unwrap()).unwrap().volume;
assert!(rel(measure(&u).unwrap().volume, expect) < 1e-6, "{} vs {expect}", measure(&u).unwrap().volume);
}
#[test]
fn connected_logo_join_preserves_exact_faces_at_edge_contact() {
let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap();
for (i, (lo, hi)) in
[(Vec3::new(0.0, 0.0, 10.0), Vec3::new(10.0, 10.0, 20.0)), (Vec3::new(-10.0, 0.0, 10.0), Vec3::new(0.0, 10.0, 20.0))].into_iter().enumerate()
{
let tool = box_solid(lo, hi).unwrap();
body = boolean(&body, &tool, BoolOp::Union).unwrap().unwrap();
assert!(body.validity().is_empty());
let m = measure(&body).unwrap();
assert!((m.volume - (3000.0 + i as f64 * 1000.0)).abs() < 1e-6, "join {i}: {}", m.volume);
assert!((m.bbox.max.z - 20.0).abs() < 1e-9, "join {i}: {}", m.bbox.max.z);
let tops =
body.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 20.0).abs() < 1e-9).count();
assert_eq!(tops, 1, "join {i}: the top must be a single selectable face");
assert!(m.bbox.min.dist(Vec3::new(if i == 0 { 0.0 } else { -10.0 }, 0.0, 0.0)) < 1e-9);
assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9);
}
let tool = box_solid(Vec3::new(-10.0, 10.0, 10.0), Vec3::new(0.0, 20.0, 20.0)).unwrap();
let logo = boolean(&body, &tool, BoolOp::Union).unwrap().unwrap();
assert!(logo.validity().is_empty());
let m = measure(&logo).unwrap();
assert!((m.volume - 5000.0).abs() < 1e-6);
assert!(m.bbox.min.dist(Vec3::new(-10.0, 0.0, 0.0)) < 1e-9);
assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9);
let tops =
logo.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 20.0).abs() < 1e-9).count();
assert_eq!(tops, 1);
let text = step_export(&[&logo], "SolidCraft").unwrap();
let imported = step_import(&text).unwrap();
assert_eq!(imported.bodies.len(), 1);
let roundtrip = &imported.bodies.first().unwrap().body;
assert!(roundtrip.validity().is_empty());
assert!((measure(roundtrip).unwrap().volume - 5000.0).abs() < 1e-6);
// An intentional overlap removes the pinch without any automatic displacement.
let overlap = box_solid(Vec3::new(-10.0, 10.0, 10.0), Vec3::new(0.1, 20.0, 20.0)).unwrap();
let joined = boolean(&body, &overlap, BoolOp::Union).unwrap().unwrap();
assert!(joined.validity().is_empty());
let m = measure(&joined).unwrap();
assert!((m.volume - 5010.0).abs() < 1e-6);
assert!(m.bbox.min.dist(Vec3::new(-10.0, 0.0, 0.0)) < 1e-9);
assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9);
}
#[test]
fn corner_boss_on_stepped_case_has_one_exact_top_face() {
let outer = box_solid(Vec3::new(-50.0, -25.0, 0.0), Vec3::new(50.0, 25.0, 25.0)).unwrap();
let cavity = box_solid(Vec3::new(-48.0, -23.0, 2.0), Vec3::new(48.0, 23.0, 26.0)).unwrap();
let case = boolean(&outer, &cavity, BoolOp::Cut).unwrap().unwrap();
let boss = cylinder(Vec3::new(-45.0, 20.0, 2.0), Vec3::Z, 5.0, 23.0).unwrap();
let joined = boolean(&case, &boss, BoolOp::Union).unwrap().unwrap();
let common = boolean(&case, &boss, BoolOp::Intersect).unwrap().unwrap();
let cut = boolean(&case, &boss, BoolOp::Cut).unwrap().unwrap();
let (a, b, u, i, c) =
(measure(&case).unwrap(), measure(&boss).unwrap(), measure(&joined).unwrap(), measure(&common).unwrap(), measure(&cut).unwrap());
assert!(rel(u.volume + i.volume, a.volume + b.volume) < 1e-4);
assert!(rel(c.volume + i.volume, a.volume) < 1e-4);
for body in [&joined, &common, &cut] {
assert!(body.validity().is_empty());
}
assert!((u.bbox.max.z - 25.0).abs() < 1e-9, "the boss must not be nudged above the rim");
let top_faces = |body: &Body| {
body.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 25.0).abs() < 1e-9).count()
};
assert_eq!(top_faces(&joined), 1, "the boss and both walls share one top face");
let rounded = fillet(&joined, &[Vec3::new(-50.0, 25.0, 12.5)], 5.0).unwrap();
assert!(rounded.validity().is_empty());
assert_eq!(top_faces(&rounded), 1, "rounding the outside corner must retain the merged top");
}
#[test]
fn sphere_booleans() {
let s = sphere(Vec3::ZERO, 15.0).unwrap();
let m = measure(&s).unwrap();
assert!(rel(m.volume, 4.0 / 3.0 * PI * 3375.0) < 1e-3, "{}", m.volume);
assert_eq!((m.merged.faces, m.merged.edges), (1, 0));
// A ball cut out of the top face of a slab (oracle 14).
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
let c = boolean(&b, &s, BoolOp::Cut).unwrap().unwrap();
let m = measure(&c).unwrap();
assert!(rel(m.volume, 50.0 * 50.0 * 20.0 - 2.0 / 3.0 * PI * 3375.0) < 1e-3, "{}", m.volume);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (7, 13, 9));
}
#[test]
fn similar_spheres_meet() {
// bherbruck/solidcraft#232: the contact test compared every pair of nearby triangles of the
// two fine sphere meshes and gave up past its work limit, so their join was refused.
let a = sphere(Vec3::ZERO, 10.0).unwrap();
for (x, meet) in [(12.0, true), (4.0, true), (20.0, true), (20.5, false)] {
let b = sphere(Vec3::new(x, 0.0, 0.0), 10.0).unwrap();
assert_eq!(a.meets(&b).unwrap(), meet, "centres {x} mm apart");
}
}
#[test]
#[ignore]
fn debug_convergence() {
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
for t in [0.1, 0.05, 0.02, 0.01, 0.005, 0.002] {
let m = b.tessellate(t).unwrap();
let mm = m.measure();
println!("tol {t}: tris {} vol {} err {:.3e}", m.triangles.len(), mm.volume, mm.volume / (PI * 225.0 * 50.0) - 1.0);
}
let m = b.tessellate(0.05).unwrap();
for f in 0..4u32 {
let ns: Vec<Vec3> =
m.triangles.iter().zip(&m.tri_face).filter(|(_, ff)| **ff == f).flat_map(|(t, _)| t.iter().map(|k| m.normals[*k as usize])).collect();
println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max));
}
}
#[test]
#[ignore]
fn debug_coplanar() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
for (z0, z1) in [(0.0, 20.0), (-5.0, 20.0), (0.0, 25.0), (-5.0, 25.0), (5.0, 20.0), (5.0, 25.0)] {
let c = cylinder(Vec3::new(20.0, 15.0, z0), Vec3::Z, 5.0, z1 - z0).unwrap();
let r = boolean(&b, &c, BoolOp::Cut);
println!("cut z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume)));
let r = boolean(&b, &c, BoolOp::Union);
println!("union z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume)));
}
let r =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] };
for ang in [PI / 2.0, PI, 1.5 * PI, 2.0 * PI] {
let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap();
let m = measure(&q).unwrap();
println!("revolve {ang}: vol {} expect {} faces {}", m.volume, ang / 2.0 * 1600.0 * 30.0, m.faces);
}
}
#[test]
#[ignore]
fn debug_blind() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 25.0)).unwrap();
println!("box pocket: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 15.0)).unwrap();
println!("box void: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
for r in [5.0, 4.3] {
for z in [5.0, 7.3, 13.1] {
let c = cylinder(Vec3::new(20.3, 15.1, z), Vec3::Z, r, 30.0).unwrap();
println!("cyl r{r} z{z}: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
}
}
let c = cylinder(Vec3::new(20.0, 15.0, 25.0), -Vec3::Z, 5.0, 20.0).unwrap();
println!("cyl down: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
let s = sphere(Vec3::new(20.0, 15.0, 20.0), 5.0).unwrap();
println!("sphere: {:?}", boolean(&b, &s, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
}
#[test]
#[ignore]
fn debug_revolve_doc_case() {
let r =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] };
for ang in [PI / 2.0, -PI / 2.0] {
let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap();
let m = measure(&q).unwrap();
println!("ang {ang}: vol {} bbox {:?}", m.volume, m.bbox);
}
let r2 = Region2 { outer: r.outer.reversed(), holes: vec![] };
let q = revolve(&Plane::XY, std::slice::from_ref(&r2), Vec2::ZERO, Vec2::Y, PI / 2.0).unwrap().pop().unwrap();
println!("reversed: {}", measure(&q).unwrap().volume);
}
#[test]
#[ignore]
fn debug_coplanar_union_rate() {
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::new(40.0, 20.0), 12.0), holes: vec![] }], 0.0, 40.0).unwrap().pop().unwrap();
let mut ok = 0;
for i in 0..20 {
let sh = Vec3::new(0.0137 * i as f64, 0.0291 * i as f64, -0.0173 * i as f64);
let (a2, b2) = (transform(&a, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap(), transform(&b, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap());
for tol in [1e-3, 1e-2, 5e-2] {
let r = guard("t", || {
let (sa, sb) = (a2.deep_copy(), b2.deep_copy());
Ok(truck_shapeops::or(&sa, &sb, tol))
});
let good = matches!(r, Ok(Some(_)));
if good {
ok += 1;
}
println!("shift {i} tol {tol}: {}", if good { "ok" } else { "fail" });
}
}
println!("ok {ok}/60");
}
#[test]
fn chamfer_top_loop_mitres() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let edges = [Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)];
let c = chamfer(&b, &edges, 2.0).unwrap();
let m = measure(&c).unwrap();
assert!(rel(m.volume, 23730.666667) < 1e-6, "{}", m.volume);
assert_eq!(m.merged.faces, 10);
}
#[test]
fn chamfer_sloped_basin_rim() {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let pocket =
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))]).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let result = chamfer(&basin, &[Vec3::new(50.0, 4.0, 30.0)], 2.0).unwrap();
// The removed triangular section has area s² sin(phi)/2. Its length varies
// linearly between the sloped end walls, so its centroid gives the mean length.
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
let drop = 2.0 / (1.0 + wall_slope.powi(2)).sqrt();
let removed = drop * (60.0 - drop / 3.0);
let before = measure(&basin).unwrap();
let after = measure(&result).unwrap();
assert!(rel(after.volume, before.volume - removed) < 1e-8, "{} vs {}", after.volume, before.volume - removed);
assert_eq!(after.merged.faces, before.merged.faces + 1);
assert_eq!(result.lumps().unwrap().len(), 1);
assert!(result.validity().is_empty());
let mesh = result.tessellate(0.01).unwrap();
assert!(!mesh.contains(Vec3::new(50.0, 3.5, 29.9)));
assert!(mesh.contains(Vec3::new(50.0, 3.5, 28.0)));
}
#[test]
fn chamfer_wider_than_sloped_basin_rim_is_refused() {
for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (1.0, 1.0)] {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let pocket =
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))])
.unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let before = measure(&basin).unwrap();
let error = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_err();
assert!(error.to_string().contains("larger than the neighbouring faces"), "{margin}, {distance}: {error}");
assert_eq!(measure(&basin).unwrap().volume, before.volume);
// A smaller chamfer on the same thin rim is still supported.
let smaller = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap();
assert!(smaller.validity().is_empty());
assert!(measure(&smaller).unwrap().volume < before.volume);
}
}
#[test]
#[ignore]
fn debug_chamfer_cut() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
// Prism along x: triangle in the YZ plane beyond the top-front edge.
let plane = Plane { origin: Vec3::new(-5.0, 0.0, 0.0), x: Vec3::Y, y: Vec3::Z };
let tri = Loop2::polygon(&[Vec2::new(2.3, 20.3), Vec2::new(-0.3, 17.7), Vec2::new(10.0, 30.0)]);
let tri = if tri.signed_area() < 0.0 { tri.reversed() } else { tri };
let tool = extrude(&plane, &[Region2 { outer: tri, holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
println!("tool vol {}", measure(&tool).unwrap().volume);
let r = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap();
let m = measure(&r).unwrap();
println!("cut: {} faces {} {:?}", m.volume, m.faces, m.merged);
for f in r.faces(0.01).unwrap() {
println!("face {} area {:.3} c {:?} n {:?}", f.index, f.area, f.centroid, f.plane_normal);
}
for tol in [0.1, 0.01, 0.001] {
println!("tess {tol}: {}", r.tessellate(tol).unwrap().measure().volume);
}
}
#[test]
#[ignore]
fn debug_revolved_hole_tool() {
let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap();
// Drill point profile in the XZ plane through x = 50: revolve about the vertical line x = 50.
let pl = Plane { origin: Vec3::new(50.0, 20.0, 0.0), x: Vec3::X, y: Vec3::Z };
let r = 3.0;
let tip = r / 59f64.to_radians().tan();
let prof =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 16.0), Vec2::new(0.0, 5.0 - tip), Vec2::new(r, 5.0), Vec2::new(r, 16.0)]), holes: vec![] };
let tool = revolve(&pl, &[prof], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
println!("tool vol {}", measure(&tool).unwrap().volume);
let res = boolean(&b, &tool, BoolOp::Cut);
println!("cut: {:?}", res.map(|o| o.map(|x| measure(&x).unwrap().volume)));
let expect = 36000.0 - std::f64::consts::PI * 9.0 * 10.0 - std::f64::consts::PI * 9.0 * tip / 3.0;
println!("expect {expect}");
}
#[test]
#[ignore]
fn debug_sphere() {
let s = sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap();
let m = measure(&s).unwrap();
println!("vol {} area {} faces {} bbox {:?}", m.volume, m.area, m.faces, m.bbox);
for f in s.faces(0.05).unwrap() {
println!("face {} area {:.3} c {:?}", f.index, f.area, f.centroid);
}
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for (name, c) in [("origin", Vec3::ZERO), ("up", Vec3::new(0.0, 0.0, 3.0)), ("off", Vec3::new(1.3, 2.1, 0.7))] {
let sp = sphere(c, 15.0).unwrap();
println!("{name} cut {:?}", boolean(&b, &sp, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume)));
println!("{name} and {:?}", boolean(&b, &sp, BoolOp::Intersect).map(|o| o.map(|x| measure(&x).unwrap().volume)));
}
}
#[test]
#[ignore]
fn debug_countersink() {
let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap();
for (name, pts) in [
("csink", vec![Vec2::new(0.0, 0.6), Vec2::new(6.6, 0.6), Vec2::new(3.0, -3.0), Vec2::new(3.0, -31.0), Vec2::new(0.0, -31.0)]),
("csink-thru-top", vec![Vec2::new(0.0, 0.6), Vec2::new(6.6, 0.6), Vec2::new(3.0, -3.0), Vec2::new(3.0, -16.3), Vec2::new(0.0, -16.3)]),
(
"cbore",
vec![Vec2::new(0.0, 0.6), Vec2::new(7.0, 0.6), Vec2::new(7.0, -5.0), Vec2::new(4.0, -5.0), Vec2::new(4.0, -31.0), Vec2::new(0.0, -31.0)],
),
] {
for (pn, pl) in [
("xz@top", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::X, Vec3::Z).unwrap()),
("yz@top", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::Y, Vec3::Z).unwrap()),
("diag", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::new(0.6, 0.8, 0.0), Vec3::Z).unwrap()),
] {
let tool = revolve(&pl, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)
.unwrap()
.pop()
.unwrap();
println!(
"{name} {pn}: tool {} cut {:?}",
measure(&tool).unwrap().volume,
boolean(&b, &tool, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume))
);
}
}
}
#[test]
#[ignore]
fn debug_cbore_seq() {
let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap();
let pl = Plane::from_normal(Vec3::new(15.0, 20.0, 15.0), Vec3::Z).unwrap();
for (s1, s2, r2top) in [(0.0, 0.0, 0.6), (0.613, 1.226, 0.6), (0.3, 0.9, 2.37)] {
let t1 = extrude(&pl, &[Region2 { outer: Loop2::circle_from(Vec2::ZERO, 4.0, s1), holes: vec![] }], -31.0, 0.6).unwrap().pop().unwrap();
let c1 = boolean(&b, &t1, BoolOp::Cut).unwrap().unwrap();
let t2 = extrude(&pl, &[Region2 { outer: Loop2::circle_from(Vec2::ZERO, 7.0, s2), holes: vec![] }], -5.0, r2top).unwrap().pop().unwrap();
println!(
"seams {s1} {s2}: first {} second {:?}",
measure(&c1).unwrap().volume,
boolean(&c1, &t2, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume))
);
let c2 = boolean(&b, &t2, BoolOp::Cut).unwrap().unwrap();
println!(" swapped: {:?}", boolean(&c2, &t1, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume)));
}
}
#[test]
#[ignore]
fn debug_truck_sphere() {
use truck_modeling::{self as mt, builder};
let mk = |c: Vec3, r: f64, axis: Vec3| -> Body {
let ax = axis.normalized().unwrap();
let side = ax.any_perp();
let v0 = builder::vertex(crate::body::p3(c + ax * r));
let arc = builder::rsweep(&v0, crate::body::p3(c), crate::body::v3(side), mt::Rad(PI));
let shell = builder::rsweep(&arc, crate::body::p3(c), crate::body::v3(ax), mt::Rad(2.0 * PI));
Body::new(mt::Solid::new(vec![shell])).unwrap()
};
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for (name, axis) in [("z", Vec3::Z), ("x", Vec3::X), ("diag", Vec3::new(0.3, 0.5, 0.8))] {
let sp = mk(Vec3::ZERO, 15.0, axis);
println!("{name}: faces {} vol {}", sp.face_count(), measure(&sp).unwrap().volume);
for op in [BoolOp::Cut, BoolOp::Intersect, BoolOp::Union] {
let r = guard("t", || {
let (sa, mut sb) = (b.deep_copy(), sp.deep_copy());
let res = match op {
BoolOp::Cut => {
sb.not();
truck_shapeops::and(&sa, &sb, 0.01)
}
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, 0.01),
BoolOp::Union => truck_shapeops::or(&sa, &sb, 0.01),
};
Ok(res.map(|s| s.face_iter().count()))
});
println!(" {op:?}: {r:?}");
}
}
}
#[test]
#[ignore]
fn debug_sphere_patches() {
let sp = crate::build::sphere_patches(Vec3::ZERO, 15.0).unwrap();
let m = measure(&sp).unwrap();
println!("vol {} want {} area {} merged {:?}", m.volume, 4.0 / 3.0 * PI * 3375.0, m.area, m.merged);
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for op in [BoolOp::Cut, BoolOp::Intersect, BoolOp::Union] {
let r = boolean(&b, &sp, op).map(|o| {
o.map(|x| {
let m = measure(&x).unwrap();
(m.volume, m.merged)
})
});
println!("{op:?}: {r:?}");
}
let c = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
let a = 0.3f64;
let (ca, sa) = (a.cos(), a.sin());
let b2 = 0.5f64;
let (cb, sb) = (b2.cos(), b2.sin());
// Rz(a) * Rx(b)
let rot = [Vec3::new(ca, sa, 0.0), Vec3::new(-sa * cb, ca * cb, sb), Vec3::new(sa * sb, -ca * sb, cb)];
for (name, s2) in [
("plain", crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap()),
("turned", crate::build::sphere_patches_rotated(Vec3::ZERO, 25.0, rot).unwrap()),
] {
println!("{name} vol {}", measure(&s2).unwrap().volume);
println!(
"{name} cube and sphere: {:?}",
boolean(&c, &s2, BoolOp::Intersect).map(|o| o.map(|x| {
let m = measure(&x).unwrap();
(m.volume, m.merged)
}))
);
}
}
#[test]
#[ignore]
fn debug_sphere_cases() {
let s = crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap();
let cases = [
("one plane", Vec3::new(-100.0, -100.0, -100.0), Vec3::new(100.0, 100.0, 20.0)),
("two planes", Vec3::new(-100.0, -100.0, -20.0), Vec3::new(100.0, 100.0, 20.0)),
("three", Vec3::new(-100.0, -100.0, -20.0), Vec3::new(20.0, 100.0, 20.0)),
("four", Vec3::new(-100.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)),
("five", Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 100.0)),
("cube", Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)),
("cube big sphere-ish", Vec3::new(-24.0, -24.0, -24.0), Vec3::new(24.0, 24.0, 24.0)),
("cube off", Vec3::new(-20.3, -19.7, -20.1), Vec3::new(19.9, 20.2, 19.8)),
];
for (name, lo, hi) in cases {
let b = box_solid(lo, hi).unwrap();
for op in [BoolOp::Intersect, BoolOp::Cut] {
let r = boolean(&b, &s, op).map(|o| o.map(|x| measure(&x).unwrap().volume));
println!("{name} {op:?}: {:?}", r.map_err(|e| e.to_string().chars().take(60).collect::<String>()));
}
}
}
#[test]
#[ignore]
fn debug_sphere_rotations() {
let b = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
for k in 0..8 {
let (a, c) = (0.37 * k as f64 + 0.1, 0.61 * k as f64 + 0.2);
let (ca, sa, cc, sc) = (a.cos(), a.sin(), c.cos(), c.sin());
let rot = [Vec3::new(ca, sa, 0.0), Vec3::new(-sa * cc, ca * cc, sc), Vec3::new(sa * sc, -ca * sc, cc)];
let s = crate::build::sphere_patches_rotated(Vec3::ZERO, 25.0, rot).unwrap();
let r = guard("t", || {
let res = truck_shapeops::and(&b.deep_copy(), &s.deep_copy(), 0.01);
Ok(res.map(|x| x.face_iter().count()))
});
println!("rot {k}: {r:?}");
}
}
#[test]
#[ignore]
fn debug_cube_sphere_measure() {
let b = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
let s = crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap();
let r = guard("t", || Ok(truck_shapeops::and(&b.deep_copy(), &s.deep_copy(), 0.05))).unwrap().unwrap();
let body = Body::new(r).unwrap();
for tol in [0.5, 0.1, 0.02] {
let m = body.tessellate(tol).unwrap();
let mm = m.measure();
println!("tol {tol}: vol {} area {} tris {}", mm.volume, mm.area, m.triangles.len());
}
for f in body.faces(0.05).unwrap() {
println!("face {} area {:.2} c {:?} planar {:?}", f.index, f.area, f.centroid, f.plane_normal.is_some());
}
}
#[test]
fn mesh_bodies_move_measure_and_refuse_solid_ops() {
// Unit cube as 12 triangles with repeated corners (welded on construction).
let c = |i: u32| Vec3::new((i & 1) as f64 * 10.0, ((i >> 1) & 1) as f64 * 10.0, ((i >> 2) & 1) as f64 * 10.0);
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut pos = Vec::new();
let mut tris = Vec::new();
for q in quads {
for t in [[q[0], q[2], q[1]], [q[0], q[3], q[2]]] {
let n = pos.len() as u32;
pos.extend(t.iter().map(|i| c(*i)));
tris.push([n, n + 1, n + 2]);
}
}
let b = crate::mesh_body(&pos, &tris).unwrap();
assert!(b.is_mesh() && b.is_closed_mesh());
let m = measure(&b).unwrap();
assert!((m.volume - 1000.0).abs() < 1e-9 && (m.area - 600.0).abs() < 1e-9, "{m:?}");
assert_eq!((m.faces, m.edges, m.vertices), (12, 18, 8));
let moved = crate::transform(&b, Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.7).unwrap();
assert!((measure(&moved).unwrap().volume - 1000.0).abs() < 1e-9);
let mirrored = crate::transform_matrix(&b, [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]).unwrap();
assert!((measure(&mirrored).unwrap().volume - 1000.0).abs() < 1e-9);
let solid = box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap();
assert!(fillet(&b, &[Vec3::new(5.0, 0.0, 0.0)], 1.0).unwrap_err().to_string().contains("mesh"));
assert!(boolean(&solid, &b, BoolOp::Union).is_err());
assert!(crate::step_export(&[&b], "t").is_err());
assert!(crate::mesh_body(&pos, &[[0, 1, 99]]).is_err());
assert!(crate::mesh_body(&[Vec3::new(f64::NAN, 0.0, 0.0)], &[[0, 0, 0]]).is_err());
assert!(crate::mesh_body(&pos, &[[0, 0, 1]]).is_err());
}
#[test]
fn concave_fillet() {
// An L made of a base and a wall (one solid), fillet the inside corner.
let base = box_solid(Vec3::ZERO, Vec3::new(80.0, 50.0, 8.0)).unwrap();
let wall = box_solid(Vec3::new(0.0, 42.0, 0.0), Vec3::new(80.0, 50.0, 50.0)).unwrap();
let l = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
let v0 = measure(&l).unwrap().volume;
let f = fillet(&l, &[Vec3::new(40.0, 42.0, 8.0)], 5.0).unwrap();
let m = measure(&f).unwrap();
assert!(rel(m.volume, v0 + (25.0 - 25.0 * PI / 4.0) * 80.0) < 1e-4, "{} vs {}", m.volume, v0 + (25.0 - 25.0 * PI / 4.0) * 80.0);
assert_eq!(m.merged.face_types.get("cylinder"), Some(&1));
}
#[test]
#[ignore]
fn debug_pocket_corners() {
let b = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 20.0)).unwrap();
let p = box_solid(Vec3::new(15.0, 15.0, 12.0), Vec3::new(65.0, 45.0, 25.0)).unwrap();
let mut cur = boolean(&b, &p, BoolOp::Cut).unwrap().unwrap();
for e in [Vec3::new(65.0, 15.0, 16.0), Vec3::new(15.0, 15.0, 16.0), Vec3::new(65.0, 45.0, 16.0), Vec3::new(15.0, 45.0, 16.0)] {
match fillet(&cur, &[e], 5.0) {
Ok(n) => {
println!("ok {e:?} vol {}", measure(&n).unwrap().volume);
cur = n;
}
Err(err) => {
println!("fail {e:?}: {err}");
break;
}
}
}
}
#[test]
fn pocket_floor_loop_fillet() {
let b = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 20.0)).unwrap();
let p = box_solid(Vec3::new(15.0, 15.0, 12.0), Vec3::new(65.0, 45.0, 25.0)).unwrap();
let mut cur = boolean(&b, &p, BoolOp::Cut).unwrap().unwrap();
cur = fillet(&cur, &[Vec3::new(65.0, 15.0, 16.0), Vec3::new(15.0, 15.0, 16.0), Vec3::new(65.0, 45.0, 16.0), Vec3::new(15.0, 45.0, 16.0)], 5.0)
.unwrap();
let v1 = measure(&cur).unwrap().volume;
let floor: Vec<Vec3> = [(40.0, 15.0), (65.0, 30.0), (40.0, 45.0), (15.0, 30.0)].iter().map(|(x, y)| Vec3::new(*x, *y, 12.0)).collect();
let mut pts = floor.clone();
let d = 5.0 - 5.0 / 2f64.sqrt();
pts.extend([(15.0 + d, 15.0 + d), (65.0 - d, 15.0 + d), (65.0 - d, 45.0 - d), (15.0 + d, 45.0 - d)].iter().map(|(x, y)| Vec3::new(*x, *y, 12.0)));
let f = fillet(&cur, &pts, 2.0).unwrap();
let m = measure(&f).unwrap();
// Added: the fillet profile (r²(1 − π/4)) swept along the floor outline (Pappus for the
// corners: the profile's centroid sits 2 − 4·2/(3(4 − π))·… inward; check loosely).
let straight = 2.0 * (40.0 + 20.0);
let added_lines = (4.0 - PI) * straight;
assert!(m.volume > v1 + added_lines && m.volume < v1 + added_lines + 4.0 * 2.0 * PI * 5.0, "{} {}", m.volume, v1);
println!("pocket: {:?} vol {}", m.merged, m.volume);
}
#[test]
fn round_every_edge_of_a_box() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let mut pts = Vec::new();
for e in b.edges(0.1).unwrap() {
pts.push(e.mid);
}
let f = fillet(&b, &pts, 3.0).unwrap();
let m = measure(&f).unwrap();
let r = 3.0f64;
let (a, bb, c) = (40.0 - 2.0 * r, 30.0 - 2.0 * r, 20.0 - 2.0 * r);
// Inner box grown by a ball (Steiner): V = abc + 2r(ab+bc+ca) + πr²(a+b+c) + 4/3πr³.
let want = a * bb * c + 2.0 * r * (a * bb + bb * c + c * a) + PI * r * r * (a + bb + c) + 4.0 / 3.0 * PI * r.powi(3);
assert!(rel(m.volume, want) < 1e-3, "{} vs {want}", m.volume);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (26, 48, 24), "{:?}", m.merged);
}
#[test]
#[ignore]
fn debug_heal_sphere_cut() {
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
let s = sphere(Vec3::ZERO, 15.0).unwrap();
let r = guard("t", || {
let mut sb = s.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.05))
})
.unwrap()
.unwrap();
let raw = Body::new(r.clone()).unwrap();
let healed = Body::new(crate::heal::heal(r, 60.0)).unwrap();
for (name, x) in [("raw", raw), ("healed", healed)] {
println!("{name}: vol {} faces {}", measure(&x).unwrap().volume, x.face_count());
for f in x.faces(0.05).unwrap() {
println!(" face {} area {:.2} c {:?}", f.index, f.area, f.centroid);
}
}
}
#[test]
#[ignore]
fn debug_coplanar_cylinder_union() {
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let c = cylinder(Vec3::new(40.0, 20.0, 0.0), Vec3::Z, 12.0, 40.0).unwrap();
for op in [BoolOp::Union, BoolOp::Cut, BoolOp::Intersect] {
let r = guard("t", || {
let (sa, mut sb) = (a.deep_copy(), c.deep_copy());
Ok(match op {
BoolOp::Union => truck_shapeops::or(&sa, &sb, 0.05),
BoolOp::Cut => {
sb.not();
truck_shapeops::and(&sa, &sb, 0.05)
}
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, 0.05),
}
.map(|s| s.face_iter().count()))
});
println!("{op:?}: {r:?}");
}
}
#[test]
fn coplanar_curved_booleans() {
// A box and a cylinder standing on the same plane, the cylinder's axis on the box's side.
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let c = cylinder(Vec3::new(40.0, 20.0, 0.0), Vec3::Z, 12.0, 40.0).unwrap();
let half = PI * 144.0 / 2.0;
let u = boolean(&a, &c, BoolOp::Union).unwrap().unwrap();
let m = measure(&u).unwrap();
assert!(rel(m.volume, 32000.0 + half * 40.0 + half * 20.0) < 1e-3, "{}", m.volume);
let cut = boolean(&a, &c, BoolOp::Cut).unwrap().unwrap();
assert!(rel(measure(&cut).unwrap().volume, 32000.0 - half * 20.0) < 1e-3);
let i = boolean(&a, &c, BoolOp::Intersect).unwrap().unwrap();
assert!(rel(measure(&i).unwrap().volume, half * 20.0) < 1e-3);
println!("union {:?}", m.merged);
}
#[test]
fn shell_non_convex() {
// An L-shaped block opened at the top of its tall part.
let base = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 10.0)).unwrap();
let tower = box_solid(Vec3::ZERO, Vec3::new(20.0, 40.0, 40.0)).unwrap();
let l = boolean(&base, &tower, BoolOp::Union).unwrap().unwrap();
let s = shell(&l, &[Vec3::new(10.0, 20.0, 40.0)], 2.0).unwrap();
// Outer volume minus the cavity (the L shrunk by 2, open upward).
let outer = 60.0 * 40.0 * 10.0 + 20.0 * 40.0 * 30.0;
let cavity = 56.0 * 36.0 * 6.0 + 16.0 * 36.0 * 32.0;
let v = measure(&s).unwrap().volume;
assert!(rel(v, outer - cavity) < 1e-6, "{v} vs {}", outer - cavity);
}
/// A basin: a slab with a tapered pocket and a 4 mm front rim, shelled from below. A wall
/// thicker than the rim (with the pocket's slope) would turn the top face inside out: refused,
/// not a body with wrong mass properties.
#[test]
fn shell_thicker_than_a_rim_is_refused() {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
let pocket = loft(&[(at(30.0), rect(20.0, 4.0, 80.0, 50.0)), (at(10.0), rect(30.0, 20.0, 70.0, 40.0))]).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let open = [Vec3::new(50.0, 30.0, 0.0)];
let e = shell(&basin, &open, 6.0).unwrap_err().to_string();
assert!(e.contains("rim or ledge"), "{e}");
let (whole, thin) = (measure(&basin).unwrap(), measure(&shell(&basin, &open, 1.0).unwrap()).unwrap());
assert!(thin.volume > 0.0 && thin.volume < whole.volume, "{} vs {}", thin.volume, whole.volume);
let (lo, hi) = (thin.bbox.min, thin.bbox.max);
let c = thin.centroid;
assert!(c.x > lo.x && c.x < hi.x && c.y > lo.y && c.y < hi.y && c.z > lo.z && c.z < hi.z, "{c:?}");
assert!((c.x - 50.0).abs() < 1e-3, "{c:?}");
}
#[test]
#[ignore]
fn debug_drilled_hole() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 15.0)).unwrap();
let r = 3.4;
let half = (118f64 / 2.0).to_radians();
let tip = r / half.tan();
let (top, bottom) = (0.6, -12.0);
let a = std::env::var("ANG").ok().and_then(|x| x.parse::<f64>().ok()).unwrap_or(0.0);
let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::new(a.cos(), a.sin(), 0.0), Vec3::Z).unwrap();
let tipv = if std::env::var_os("NOTIP").is_some() { 0.0 } else { tip };
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tipv)];
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
println!("tool faces {} vol {}", tool.face_count(), measure(&tool).unwrap().volume);
let r = guard("t", || {
let mut sb = tool.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.02).map(|s| s.face_iter().count()))
});
println!("cut: {r:?}");
}
#[test]
#[ignore]
fn debug_face_param_areas() {
use truck_modeling::{BoundedCurve, ParametricCurve, SearchNearestParameter};
let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::X, Vec3::Z).unwrap();
let pts = [Vec2::new(0.0, 0.6), Vec2::new(3.4, 0.6), Vec2::new(3.4, -12.0), Vec2::new(0.0, -12.0)];
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
let ex = cylinder(Vec3::new(20.0, 15.0, 3.0), Vec3::Z, 3.4, 12.6).unwrap();
for (name, b) in [("revolve", &tool), ("extrude", &ex)] {
for f in b.solid.face_iter() {
let s = f.surface();
let mut poly: Vec<(f64, f64)> = Vec::new();
for w in f.absolute_boundaries() {
for e in w.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
for k in 0..8 {
let p = c.subs(t0 + (t1 - t0) * k as f64 / 8.0);
if let Some(uv) = s.search_nearest_parameter(p, poly.last().copied(), 100) {
poly.push(uv);
}
}
}
}
let n = poly.len();
let area: f64 = (0..n)
.map(|i| {
let (a, b) = (poly[i], poly[(i + 1) % n]);
a.0 * b.1 - b.0 * a.1
})
.sum::<f64>()
/ 2.0;
println!("{name}: orientation {} param area {area:.4} wires {}", f.orientation(), f.absolute_boundaries().len());
}
}
}
#[test]
#[ignore]
fn debug_revolved_tube_cut() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 15.0)).unwrap();
let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::X, Vec3::Z).unwrap();
for (name, inner) in [("tube", 1.0), ("solid", 0.0)] {
let pts = [Vec2::new(inner, 0.6), Vec2::new(3.4, 0.6), Vec2::new(3.4, -12.0), Vec2::new(inner, -12.0)];
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
let r = guard("t", || {
let mut sb = tool.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.02).map(|s| s.face_iter().count()))
});
println!("{name}: faces {} cut {r:?}", tool.face_count());
}
}
#[test]
fn circle_loop_fillets_and_chamfers() {
// A boss on a plate: concave fillet where it meets the plate, convex at its top.
let plate = box_solid(Vec3::new(-30.0, -30.0, 0.0), Vec3::new(30.0, 30.0, 8.0)).unwrap();
let boss = cylinder(Vec3::new(0.0, 0.0, 8.0), Vec3::Z, 10.0, 15.0).unwrap();
let b = boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
let v0 = measure(&b).unwrap().volume;
let f = fillet(&b, &[Vec3::new(-10.0, 0.0, 8.0)], 3.0).unwrap();
let v1 = measure(&f).unwrap().volume;
// Concave: the profile area r²(1 − π/4) swept round a circle of radius ≈ 10 + r/3.
let added = 9.0 * (1.0 - PI / 4.0) * 2.0 * PI * (10.0 + 3.0 * (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI));
assert!((v1 - v0 - added).abs() < 0.02 * added, "{} {}", v1 - v0, added);
let c = chamfer(&f, &[Vec3::new(-10.0, 0.0, 23.0)], 1.0).unwrap();
let v2 = measure(&c).unwrap().volume;
// Convex chamfer: a 1×1 triangle round a circle of radius ≈ 10 − 1/3.
let removed = 0.5 * 2.0 * PI * (10.0 - 1.0 / 3.0);
assert!((v1 - v2 - removed).abs() < 0.02 * removed, "{} {}", v1 - v2, removed);
}
#[test]
fn trimmed_revolved_faces_measure_exactly() {
use solidcraft_geom::Seg2;
use std::f64::consts::FRAC_PI_2;
// A slot with concave ends sketched on the plane x = 12.5, cut 4 deep into a revolved shaft
// of radius 12.5 (the cut faces' triangulation spans wide angles unless refined).
let segs = vec![
Seg2::Arc { center: Vec2::new(-45.0, 0.0), radius: 4.0, start: FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(-45.0, -4.0), b: Vec2::new(-75.0, -4.0) },
Seg2::Arc { center: Vec2::new(-75.0, 0.0), radius: 4.0, start: -FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(-75.0, 4.0), b: Vec2::new(-45.0, 4.0) },
];
let pl = Plane::new(Vec3::new(12.5, 0.0, 0.0), Vec3::new(0.0, 0.0, -1.0), Vec3::Y).unwrap();
let tool = extrude(&pl, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], -4.0, 1.0).unwrap().pop().unwrap();
let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, -30.0), (12.5, -30.0), (12.5, -90.0), (10.0, -90.0), (10.0, -120.0), (0.0, -120.0)];
let segs = (0..8).map(|i| Seg2::Line { a: Vec2::new(pts[i].0, pts[i].1), b: Vec2::new(pts[(i + 1) % 8].0, pts[(i + 1) % 8].1) }).collect();
let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap();
let shaft = revolve(&xz, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap();
let c = boolean(&shaft, &tool, BoolOp::Cut).unwrap().unwrap();
// Removed: ∫∫ over the slot of (√(12.5² − y²) − 8.5), by a fine midpoint rule.
let (n, mut want) = (800, 0.0);
for i in 0..n {
let z = 30.0 + 60.0 * (i as f64 + 0.5) / n as f64;
for j in 0..n {
let y = -4.0 + 8.0 * (j as f64 + 0.5) / n as f64;
if (45.0..=75.0).contains(&z) && (z - 45.0).powi(2) + y * y >= 16.0 && (z - 75.0).powi(2) + y * y >= 16.0 {
want += ((156.25 - y * y).sqrt() - 8.5) * (60.0 / n as f64) * (8.0 / n as f64);
}
}
}
let got = measure(&shaft).unwrap().volume - measure(&c).unwrap().volume;
assert!((got - want).abs() < 0.01 * want, "removed {got}, want {want}");
}
#[test]
fn shell_with_cylindrical_faces() {
// A box with its four vertical edges rounded, opened at the top.
let b = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let edges: Vec<Vec3> = [(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (0.0, 60.0)].iter().map(|(x, y)| Vec3::new(*x, *y, 15.0)).collect();
let r = fillet(&b, &edges, 6.0).unwrap();
let s = shell(&r, &[Vec3::new(50.0, 30.0, 30.0)], 2.0).unwrap();
let outer = (100.0 * 60.0 - (4.0 - PI) * 36.0) * 30.0;
let cavity = (96.0 * 56.0 - (4.0 - PI) * 16.0) * 28.0;
let v = measure(&s).unwrap().volume;
assert!(rel(v, outer - cavity) < 1e-4, "{v} vs {}", outer - cavity);
// A plate with a round boss, opened underneath: the cavity follows the boss.
let plate = box_solid(Vec3::new(-30.0, -30.0, 0.0), Vec3::new(30.0, 30.0, 10.0)).unwrap();
let boss = cylinder(Vec3::new(0.0, 0.0, 5.0), Vec3::Z, 12.0, 25.0).unwrap();
let pb = boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
let s = shell(&pb, &[Vec3::new(0.0, 0.0, 0.0)], 2.0).unwrap();
let outer = 3600.0 * 10.0 + PI * 144.0 * 20.0;
let cavity = 56.0 * 56.0 * 8.0 + PI * 100.0 * 20.0;
let v = measure(&s).unwrap().volume;
assert!(rel(v, outer - cavity) < 1e-4, "{v} vs {}", outer - cavity);
}
#[test]
fn draft_of_a_round_ended_pocket() {
use solidcraft_geom::Seg2;
use std::f64::consts::FRAC_PI_2;
let block = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 25.0)).unwrap();
let segs = vec![
Seg2::Line { a: Vec2::new(25.0, 20.0), b: Vec2::new(55.0, 20.0) },
Seg2::Arc { center: Vec2::new(55.0, 30.0), radius: 10.0, start: -FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(55.0, 40.0), b: Vec2::new(25.0, 40.0) },
Seg2::Arc { center: Vec2::new(25.0, 30.0), radius: 10.0, start: FRAC_PI_2, sweep: PI },
];
let tool = extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 15.0, 30.0).unwrap().pop().unwrap();
let b = boolean(&block, &tool, BoolOp::Cut).unwrap().unwrap();
let walls = [Vec3::new(15.0, 30.0, 20.0), Vec3::new(40.0, 20.0, 20.0), Vec3::new(65.0, 30.0, 20.0), Vec3::new(40.0, 40.0, 20.0)];
let a = 5f64.to_radians();
let d = draft(&b, &walls, &Plane::XY.offset(25.0), Vec3::Z, a).unwrap();
// The pocket narrows going down: at depth t its walls stand t·tan(a) further in.
let (n, mut pocket) = (2000, 0.0);
for k in 0..n {
let e = (k as f64 + 0.5) / n as f64 * 10.0 * a.tan();
pocket += (30.0 * (20.0 - 2.0 * e) + PI * (10.0 - e).powi(2)) * 10.0 / n as f64;
}
let v = measure(&d).unwrap().volume;
assert!(rel(v, 80.0 * 60.0 * 25.0 - pocket) < 1e-4, "{v} vs {}", 80.0 * 60.0 * 25.0 - pocket);
}
/// A round wall is stored in pieces (a cylinder's two halves); one pick drafts all of it (#547).
/// A hole drafted from the top narrows going down: a frustum from R 10 to 10 − 25 · tan 5°.
#[test]
fn draft_of_a_round_hole_from_one_pick() {
let block = box_solid(Vec3::ZERO, Vec3::new(60.0, 60.0, 25.0)).unwrap();
let hole = cylinder(Vec3::new(30.0, 30.0, -1.0), Vec3::Z, 10.0, 27.0).unwrap();
let b = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
let a = 5f64.to_radians();
let d = draft(&b, &[Vec3::new(40.0, 30.0, 12.5)], &Plane::XY.offset(25.0), Vec3::Z, a).unwrap();
let (big, small) = (10.0, 10.0 - 25.0 * a.tan());
let want = 60.0 * 60.0 * 25.0 - PI * 25.0 / 3.0 * (big * big + big * small + small * small);
let m = measure(&d).unwrap();
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
}
#[test]
fn rounded_corners_rounded_again_make_spheres() {
// Vertical edges first, then the top and bottom loops with the same radius: the corners
// become sphere octants, as if every edge were rounded at once.
let (a, b, c, r) = (40.0, 30.0, 20.0, 2.0);
let bx = box_solid(Vec3::ZERO, Vec3::new(a, b, c)).unwrap();
let vert: Vec<Vec3> = [(0.0, 0.0), (a, 0.0), (a, b), (0.0, b)].iter().map(|(x, y)| Vec3::new(*x, *y, c / 2.0)).collect();
let f = fillet(&bx, &vert, r).unwrap();
let f = fillet(&f, &[Vec3::new(a / 2.0, 0.0, c)], r).unwrap();
let f = fillet(&f, &[Vec3::new(a / 2.0, 0.0, 0.0)], r).unwrap();
let (ai, bi, ci) = (a - 2.0 * r, b - 2.0 * r, c - 2.0 * r);
let area = 2.0 * (ai * bi + bi * ci + ai * ci) + PI * r * (ai + bi + ci) * 2.0 + 4.0 * PI * r * r;
let vol = ai * bi * ci + 2.0 * r * (ai * bi + bi * ci + ai * ci) + PI * r * r * (ai + bi + ci) + 4.0 / 3.0 * PI * r * r * r;
let m = measure(&f).unwrap();
println!("area {} want {area}; volume {} want {vol}; faces {:?}", m.area, m.volume, m.merged);
assert!(rel(m.volume, vol) < 1e-4, "volume {} vs {vol}", m.volume);
assert!(rel(m.area, area) < 1e-4, "area {} vs {area}", m.area);
}
#[test]
fn every_edge_of_an_l_block() {
// An L (60 × 40, legs 8 wide) 20 thick with all 18 edges rounded by 2: the inner vertical
// edge is concave (its corners become tori), the others convex (sphere corners).
let pts = [(0.0, 0.0), (60.0, 0.0), (60.0, 8.0), (8.0, 8.0), (8.0, 40.0), (0.0, 40.0)];
let outer = Loop2::polygon(&pts.iter().map(|(x, y)| Vec2::new(*x, *y)).collect::<Vec<_>>());
let l = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
let edges: Vec<Vec3> = l.edges(0.1).unwrap().iter().map(|e| e.mid).collect();
let f = fillet(&l, &edges, 2.0).unwrap();
let m = measure(&f).unwrap();
// Area by pieces: walls (lengths less the roundings, 16 high), top and bottom (the L shrunk
// by 2 with a radius-4 inner corner), quarter cylinders along every straight edge, ten
// sphere octants, and two torus quarters (Pappus: arc π, centroid radius 4 − 4/π, angle π/2).
let walls = 16.0 * (56.0 + 4.0 + 48.0 + 28.0 + 4.0 + 36.0);
let caps = 2.0 * (56.0 * 4.0 + 4.0 * 32.0 + 16.0 - 4.0 * PI);
let vertical = 6.0 * PI * 16.0;
let horizontal = 2.0 * PI * (56.0 + 4.0 + 48.0 + 28.0 + 4.0 + 36.0);
let spheres = 10.0 * 2.0 * PI;
let tori = 2.0 * PI * (4.0 - 4.0 / PI) * PI / 2.0;
let area = walls + caps + vertical + horizontal + spheres + tori;
assert!(rel(m.area, area) < 1e-4, "area {} vs {area}", m.area);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (38, 74, 38));
}
#[test]
fn overlapping_holes_and_mirror_joins() {
// Overlapping through holes in a plate: done on the plate's section, exactly.
let plate = box_solid(Vec3::new(-50.0, -50.0, 0.0), Vec3::new(50.0, 50.0, 5.0)).unwrap();
let hole = |x: f64, y: f64| {
let c = Loop2::circle(Vec2::new(x, y), 5.0);
extrude(&Plane::XY, &[Region2 { outer: c, holes: vec![] }], -1.0, 6.0).unwrap().pop().unwrap()
};
let mut cur = plate;
for (x, y) in [(30.0, 0.0), (25.0, 0.0)] {
cur = boolean(&cur, &hole(x, y), BoolOp::Cut).unwrap().unwrap();
}
// Two discs of radius 5, centres 5 apart: their union's area is 2·25π − (50π/3 − 25√3/2).
let lens = 50.0 * PI / 3.0 - 25.0 * 3f64.sqrt() / 2.0;
let want = 50000.0 - (50.0 * PI - lens) * 5.0;
assert!(rel(measure(&cur).unwrap().volume, want) < 1e-5, "{} vs {want}", measure(&cur).unwrap().volume);
// A bracket with rounded corners joined with its mirror image across the face they share.
let half = box_solid(Vec3::ZERO, Vec3::new(30.0, 40.0, 10.0)).unwrap();
let half = fillet(&half, &[Vec3::new(30.0, 0.0, 5.0), Vec3::new(30.0, 40.0, 5.0)], 5.0).unwrap();
let m = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
let other = transform_matrix(&half, m).unwrap();
let both = boolean(&half, &other, BoolOp::Union).unwrap().unwrap();
let v = measure(&half).unwrap().volume;
let mb = measure(&both).unwrap();
assert!(rel(mb.volume, 2.0 * v) < 1e-9, "{} vs {}", mb.volume, 2.0 * v);
// One body: the shared face is gone and the faces either side of it merged.
assert_eq!(mb.merged.faces, 10, "{:?}", mb.merged);
// Two equal cylinders on one axis, overlapping: one longer cylinder.
let c1 = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 30.0).unwrap();
let c2 = cylinder(Vec3::new(0.0, 0.0, 20.0), Vec3::Z, 10.0, 30.0).unwrap();
let u = measure(&boolean(&c1, &c2, BoolOp::Union).unwrap().unwrap()).unwrap();
let v1 = measure(&c1).unwrap().volume;
assert!(rel(u.volume, v1 * 5.0 / 3.0) < 2e-4, "{} (one: {v1})", u.volume);
assert_eq!(u.merged.faces, 3, "{:?}", u.merged);
}
#[test]
fn torus_cut_through_its_middle_plane() {
// A torus whose tube centre lies in a box's top face: the box's top cuts the torus where
// the tube is vertical. (The revolve profile's seam points used to lie in that plane.)
let bx = box_solid(Vec3::new(-30.0, -30.0, -10.0), Vec3::new(30.0, 30.0, 0.0)).unwrap();
let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap();
let ring = Region2 { outer: Loop2::circle(Vec2::new(22.0, 0.0), 4.0), holes: vec![] };
let torus = revolve(&xz, &[ring], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap();
let c = boolean(&bx, &torus, BoolOp::Cut).unwrap().unwrap();
// Half the torus volume (2π²Rr²) is removed.
let want = 36000.0 - PI * PI * 22.0 * 16.0;
let v = measure(&c).unwrap().volume;
assert!(rel(v, want) < 1e-4, "{v} vs {want}");
// A revolved tube cut across: both walls cross the box's top face (nested loops on it).
let tube =
Region2 { outer: Loop2::polygon(&[Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, -30.0), Vec2::new(5.0, -30.0)]), holes: vec![] };
let t = revolve(&xz, &[tube], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap();
let bx = box_solid(Vec3::new(-40.0, -40.0, -40.0), Vec3::new(40.0, 40.0, 10.0)).unwrap();
let i = boolean(&t, &bx, BoolOp::Intersect).unwrap().unwrap();
let v = measure(&i).unwrap().volume;
assert!(rel(v, PI * 75.0 * 10.0) < 1e-4, "{v}");
}
#[test]
fn fillet_where_a_branch_pipe_meets_a_main_pipe() {
// A branch (radius 10) on a main pipe (radius 15): the saddle curve between the two
// cylinders, blended by rolling a ball of radius 3 along it.
let main = cylinder(Vec3::new(-40.0, 0.0, 0.0), Vec3::X, 15.0, 80.0).unwrap();
let branch = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 35.0).unwrap();
let tee = boolean(&main, &branch, BoolOp::Union).unwrap().unwrap();
let v0 = measure(&tee).unwrap().volume;
let f = fillet(&tee, &[Vec3::new(0.0, 10.0, 125f64.sqrt())], 3.0).unwrap();
let m = measure(&f).unwrap();
// The added material: less than r²(1 − π/4) (a right-angled corner's) swept round the
// ≈ 65 mm saddle, as the pipes meet at obtuse angles, but well over a third of it.
let bare = 9.0 * (1.0 - PI / 4.0) * 65.0;
let added = m.volume - v0;
assert!(added > 0.35 * bare && added < bare, "added {added} (bare {bare})");
// Main cylinder, branch cylinder, the blend, the branch's top and the main's two ends.
assert_eq!(m.merged.faces, 6, "{:?}", m.merged);
}
#[test]
fn fillet_an_open_curved_edge() {
use solidcraft_geom::Seg2;
// A D-shaped block: the round top edge only, ending at the flat wall's sharp corners.
let d = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: PI },
Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) },
],
};
let b = extrude(&Plane::XY, &[Region2 { outer: d, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
let v0 = measure(&b).unwrap().volume;
let f = fillet(&b, &[Vec3::new(0.0, 10.0, 15.0)], 2.0).unwrap();
// (A fine tessellation: the difference of two measured volumes magnifies their error.)
let _ = v0;
let fine = |x: &Body| x.tessellate_with(0.002, true).unwrap().measure().volume;
let removed = fine(&b) - fine(&f);
// Pappus: the corner's cross-section r²(1 − π/4), its centroid 0.2234 r in from the wall.
let r = 2.0;
let want = r * r * (1.0 - PI / 4.0) * (10.0 - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))) * PI;
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
/// Boolean survey: random boxes, cylinders and spheres combined every way; prints what fails.
#[test]
#[ignore]
fn boolean_survey() {
let mut seed: u64 = 0x2545_f491_4f6c_dd1d;
let mut rnd = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1_000_000) as f64 / 1_000_000.0
};
let mut shapes: Vec<(String, Body)> = Vec::new();
for i in 0..40 {
let kind = i % 4;
let (x, y, z) = ((rnd() - 0.5) * 20.0, (rnd() - 0.5) * 20.0, (rnd() - 0.5) * 20.0);
// Snap some coordinates to a 5 mm grid: coincident faces and tangencies.
let snap = |v: f64, s: bool| if s { (v / 5.0).round() * 5.0 } else { v };
let s = rnd() < 0.5;
let (x, y, z) = (snap(x, s), snap(y, s), snap(z, s));
let a = 5.0 + snap(rnd() * 20.0, s);
let ax = (rnd() * 3.0) as usize % 3;
let b = match kind {
0 => box_solid(Vec3::new(x, y, z), Vec3::new(x + a, y + a * 0.8, z + a * 0.6)),
1 => cylinder(Vec3::new(x, y, z), [Vec3::X, Vec3::Y, Vec3::Z][ax], a * 0.4, a),
2 => sphere(Vec3::new(x, y, z), a * 0.5),
_ => box_solid(Vec3::new(x, y, z), Vec3::new(x + a * 0.5, y + a, z + a * 0.3)),
};
if let Ok(b) = b {
shapes.push((format!("{}{i}@({x:.3},{y:.3},{z:.3}) a={a:.3} ax={ax}", ["box", "cyl", "sph", "slab"][kind]), b));
}
}
let (mut ok, mut empty, mut fail) = (0, 0, Vec::new());
for i in 0..shapes.len() {
for j in i + 1..(i + 4).min(shapes.len()) {
for op in [BoolOp::Union, BoolOp::Cut, BoolOp::Intersect] {
let (na, a) = &shapes[i];
let (nb, b) = &shapes[j];
match boolean(a, b, op) {
Ok(Some(_)) => ok += 1,
Ok(None) => empty += 1,
Err(e) => fail.push(format!("{na} {op:?} {nb}: {}", e.to_string().chars().take(140).collect::<String>())),
}
}
}
}
println!("ok {ok} empty {empty} failed {}", fail.len());
for f in &fail {
println!(" {f}");
}
}
#[test]
fn booleans_found_by_the_survey() {
// A tool wholly inside: a void (the solid keeps an inner shell).
let sp = sphere(Vec3::new(-2.894, 7.008, 8.081), 8.869).unwrap();
let slab = box_solid(Vec3::new(-5.0, 0.0, 5.0), Vec3::new(-2.5, 5.0, 6.5)).unwrap();
let c = boolean(&sp, &slab, BoolOp::Cut).unwrap().unwrap();
let want = 4.0 / 3.0 * PI * 8.869f64.powi(3) - 18.75;
assert!(rel(measure(&c).unwrap().volume, want) < 1e-3, "{}", measure(&c).unwrap().volume);
// A sphere clipping a box's corner: the result comes out outward-facing.
let sp = sphere(Vec3::new(-8.226, 9.539, -1.251), 11.8435).unwrap();
let bx = box_solid(Vec3::new(0.0, -5.0, -5.0), Vec3::new(15.0, 7.0, 4.0)).unwrap();
let c = boolean(&bx, &sp, BoolOp::Cut).unwrap().unwrap();
let v = measure(&c).unwrap().volume;
assert!(v > 1400.0 && v < 1620.0, "{v}");
// A cylinder and a box sharing an end plane, the box's side near-tangent to the cylinder.
let cy = cylinder(Vec3::new(-5.0, 0.0, 0.0), Vec3::X, 8.0, 20.0).unwrap();
let bx = box_solid(Vec3::new(0.0, -5.0, -5.0), Vec3::new(15.0, 7.0, 4.0)).unwrap();
let u = boolean(&cy, &bx, BoolOp::Union).unwrap().unwrap();
let vu = measure(&u).unwrap().volume;
assert!(vu > PI * 64.0 * 20.0 && vu < PI * 64.0 * 20.0 + 1620.0, "{vu}");
}
#[test]
fn fillet_two_edges_meeting_at_a_corner() {
// Two top edges of a box meeting at a corner (the vertical edge there stays sharp): the
// fillets meet in a mitre and stop square at the far walls.
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let f = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0)], 3.0).unwrap();
let removed = 24000.0 - measure(&f).unwrap().volume;
// Each edge loses r²(1 − π/4) along its length; at the mitred corner the two cuts overlap
// in a region of volume r³(1 − π/4)·… — bounded between the sum less one corner and the sum.
let a = 9.0 * (1.0 - PI / 4.0);
let (lo, hi) = (a * (40.0 + 30.0) - a * 3.0, a * (40.0 + 30.0));
assert!(removed > lo && removed < hi, "removed {removed} in ({lo}, {hi})");
// Three edges: front, left and back.
let f3 = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)], 3.0).unwrap();
assert!(measure(&f3).unwrap().volume < measure(&f).unwrap().volume);
}
#[test]
fn fillet_three_edges_at_a_corner() {
// The three edges meeting at one corner of a box: the corner becomes a sphere octant.
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let r = 3.0;
let f = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0), Vec3::new(0.0, 0.0, 10.0)], r).unwrap();
let removed = 24000.0 - measure(&f).unwrap().volume;
// Three edges lose r²(1 − π/4) each along their length; the corner, a cube r³ less an
// octant of the ball, was counted three times over and is removed once (r³ − πr³/6).
let a = r * r * (1.0 - PI / 4.0);
let want = a * (40.0 + 30.0 + 20.0) - 3.0 * a * r + (r * r * r - PI * r * r * r / 6.0);
assert!(rel(removed, want) < 2e-3, "removed {removed} vs {want}");
let m = measure(&f).unwrap().merged;
assert_eq!(m.face_types.get("sphere"), Some(&1), "{m:?}");
}
#[test]
fn shell_with_spherical_and_conical_faces() {
// A cylinder capped by a hemisphere (a dome), revolved, opened at the bottom.
use solidcraft_geom::Seg2;
let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap();
let segs = vec![
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) },
Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, -20.0) },
Seg2::Arc { center: Vec2::new(0.0, -20.0), radius: 10.0, start: 0.0, sweep: -PI / 2.0 },
Seg2::Line { a: Vec2::new(0.0, -30.0), b: Vec2::new(0.0, 0.0) },
];
let b = revolve(&xz, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap();
let s = shell(&b, &[Vec3::new(0.0, 0.0, 0.0)], 1.0).unwrap();
let outer = PI * 100.0 * 20.0 + 2.0 / 3.0 * PI * 1000.0;
let inner = PI * 81.0 * 20.0 + 2.0 / 3.0 * PI * 729.0;
let v = measure(&s).unwrap().volume;
assert!(rel(v, outer - inner) < 2e-3, "{v} vs {}", outer - inner);
// A cone frustum (a revolved trapezoid), opened at its wide base.
let prof =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(5.0, -10.0), Vec2::new(0.0, -10.0)]), holes: vec![] };
let fr = revolve(&xz, &[prof], Vec2::ZERO, Vec2::new(0.0, -1.0), 2.0 * PI).unwrap().pop().unwrap();
let v0 = measure(&fr).unwrap().volume;
let s = shell(&fr, &[Vec3::new(0.0, 0.0, 0.0)], 1.0).unwrap();
let v = measure(&s).unwrap().volume;
assert!(v > 0.1 * v0 && v < 0.6 * v0, "{v} of {v0}");
}
#[test]
fn smooth_loft_through_three_sections() {
// Squares 40 → 20 → 40 wide, 0, 20 and 40 up: the middle is pinched smoothly (no edge there).
let sq = |s: f64| Loop2::polygon(&[Vec2::new(-s, -s), Vec2::new(s, -s), Vec2::new(s, s), Vec2::new(-s, s)]);
let l = loft(&[(Plane::XY, sq(20.0)), (Plane::XY.offset(20.0), sq(10.0)), (Plane::XY.offset(40.0), sq(20.0))]).unwrap();
let m = measure(&l).unwrap();
// Smooth: between the ruled (two frustums, 2 · 20 · (1600 + 400 + 800)/3) and the box.
let ruled = 2.0 * 20.0 * (1600.0 + 400.0 + 800.0) / 3.0;
assert!(m.volume < ruled && m.volume > 40.0 * 400.0, "{}", m.volume);
// Four sides and two caps; the middle section leaves no edges.
assert_eq!(m.merged.faces, 6, "{:?}", m.merged);
assert_eq!(m.merged.edges, 12, "{:?}", m.merged);
// A circle, a square and a circle: valid and between the inner and outer bounds.
let c = loft(&[
(Plane::XY, Loop2::circle(Vec2::ZERO, 10.0)),
(Plane::XY.offset(15.0), sq(12.0)),
(Plane::XY.offset(30.0), Loop2::circle(Vec2::ZERO, 8.0)),
])
.unwrap();
let v = measure(&c).unwrap().volume;
assert!(v > PI * 64.0 * 30.0 && v < 576.0 * 30.0, "{v}");
}
/// A loft from the basin's whole outline (10 up) down to a drain pad in a corner (1 up), cut
/// from a 3 mm shell: its top edges lie on the outer walls. One order of the sections failed
/// the cut ("shell not oriented and closed", or a panic inside the intersection), the other
/// worked; both orders now build the same body, valid and outward.
#[test]
fn loft_section_order_gives_the_same_body() {
let r = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
let big = (at(10.0), r(0.0, 0.0, 100.0, 60.0));
let small = (at(1.0), r(80.0, 40.0, 90.0, 50.0));
let basin = shell(&box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(), &[Vec3::new(50.0, 30.0, 30.0)], 3.0).unwrap();
// The material inside the loft: the floor from 1 to 3 up, the walls from 3 to 10.
let v0 = measure(&basin).unwrap().volume;
let removed = v0 - 41582.028;
let mut volumes = Vec::new();
for secs in [vec![big.clone(), small.clone()], vec![small.clone(), big.clone()]] {
let l = loft(&secs).unwrap();
let m = measure(&l).unwrap();
assert!(m.volume > 0.0, "{}", m.volume);
volumes.push(m.volume);
let cut = boolean(&basin, &l, BoolOp::Cut).unwrap().unwrap();
let v = measure(&cut).unwrap().volume;
assert!((v0 - v - removed).abs() < 0.01 * removed, "{v} vs {}", v0 - removed);
let joined = boolean(&basin, &l, BoolOp::Union).unwrap().unwrap();
assert!(measure(&joined).unwrap().volume > v0);
}
assert!(rel(volumes[0], volumes[1]) < 1e-12, "{volumes:?}");
// Reversed in z too (large below, small above, and sketched on planes facing down).
let down = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::Y, Vec3::X).unwrap();
for secs in [
vec![(at(0.0), r(0.0, 0.0, 100.0, 60.0)), (at(20.0), r(40.0, 20.0, 60.0, 40.0))],
vec![(down(20.0), r(20.0, 40.0, 40.0, 60.0)), (at(0.0), r(0.0, 0.0, 100.0, 60.0))],
] {
// Prismoid: h/6 · (A0 + 4 Amid + A1).
let want = 20.0 / 6.0 * (6000.0 + 4.0 * 60.0 * 40.0 + 400.0);
let back: Vec<(Plane, Loop2)> = secs.iter().rev().cloned().collect();
for b in [loft(&secs).unwrap(), loft(&back).unwrap()] {
let v = measure(&b).unwrap().volume;
assert!(rel(v, want) < 1e-6, "{v} vs {want}");
}
}
}
#[test]
fn tangent_holes_pinch_alike() {
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. A tangent line
// contact does not split a face (#307, as the reference kernel counts it): the ±25 wall is one
// face on both sides, so 6 planes and 6 hole walls.
let mut b = box_solid(Vec3::new(-50.0, -20.0, 0.0), Vec3::new(50.0, 20.0, 5.0)).unwrap();
for x in [15.0, 25.0, 30.0, -15.0, -25.0, -30.0] {
let plane = Plane::XY.offset(-1.0);
let disc = Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 5.0), holes: vec![] };
let t = extrude(&plane, &[disc], 0.0, 7.0).unwrap().pop().unwrap();
b = boolean(&b, &t, BoolOp::Cut).unwrap().unwrap();
}
let m = measure(&b).unwrap();
assert_eq!(m.merged.faces, 12, "{:?}", m.merged);
}
/// A d10 tube bent at R5: the inside of the bend is a horn torus pinched to a point. One torus
/// face, with a degenerate loop at the pinch (counted as Fusion does: 5 faces, 6 edges, 4 vertices).
#[test]
fn tight_bend_counts() {
use crate::build::PathSeg;
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.0), holes: vec![] };
let path = [
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 20.0) },
PathSeg::Arc {
a: Vec3::new(0.0, 0.0, 20.0),
center: Vec3::new(5.0, 0.0, 20.0),
axis: Vec3::new(0.0, 1.0, 0.0),
angle: std::f64::consts::FRAC_PI_2,
},
PathSeg::Line { a: Vec3::new(5.0, 0.0, 25.0), b: Vec3::new(25.0, 0.0, 25.0) },
];
let b = sweep(&Plane::XY, &region, &path).unwrap();
let m = measure(&b).unwrap().merged;
assert_eq!((m.faces, m.edges, m.vertices), (5, 6, 4), "{m:?}");
}
#[test]
#[ignore]
fn boolean_identity_survey() {
let n: u64 = std::env::var("N").ok().and_then(|x| x.parse().ok()).unwrap_or(100);
let seeds: Vec<u64> = match std::env::var("SEEDS") {
Ok(s) => s.split(',').filter_map(|x| x.parse().ok()).collect(),
Err(_) => (0..n).collect(),
};
let mut bad = 0;
for seed in seeds {
let c = crate::fuzz::boolean_case(seed);
if c.outcome != "ok" {
bad += 1;
println!("{seed}: {} {} | {:?} | {:?}", c.outcome, c.detail, c.a, c.b);
}
}
println!("{bad}/{n} failed");
}
/// A quarter turn of a rectangle with an edge on the axis: no zero-area face along the axis, so
/// a cylinder can be cut through the corner (it failed in every boolean attempt before).
#[test]
fn partial_revolve_on_the_axis() {
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // This fixture's rectangle is above z = 0.
let pie = revolve(&plane, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
let v = measure(&pie).unwrap().volume;
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
assert_eq!(measure(&pie).unwrap().merged.faces, 5);
let cyl = cylinder(Vec3::new(2.5, 0.0, 5.0), Vec3::Z, 3.75, 22.5).unwrap();
let cut = boolean(&pie, &cyl, BoolOp::Cut).unwrap().unwrap();
assert!(cut.validity().is_empty());
let vc = measure(&cut).unwrap().volume;
assert!(vc < v - 1.0 && vc > v - PI * 3.75 * 3.75 * 2.5, "{vc}");
}
/// Boolean identities on primitives the robustness runs found failing: cylinders tangent to box
/// faces from inside and out, crossing cylinders touching along a line (their seams used to lie
/// exactly where the other body cut them).
#[test]
fn boolean_identity_on_tangent_primitives() {
// Independent seeds, one thread each.
let cases: Vec<_> = std::thread::scope(|scope| {
let workers = [1, 32, 169].map(|seed| scope.spawn(move || (seed, crate::fuzz::boolean_case(seed))));
workers.into_iter().map(|w| w.join().unwrap()).collect()
});
for (seed, c) in cases {
assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b);
}
}
/// A body in two pieces: booleans go a piece at a time.
#[test]
fn boolean_of_a_body_in_pieces() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
let two = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert_eq!(two.shell_count(), 2);
let tool = cylinder(Vec3::new(-5.0, 5.0, 5.0), Vec3::X, 2.0, 40.0).unwrap();
let cut = boolean(&two, &tool, BoolOp::Cut).unwrap().unwrap();
assert_eq!(cut.shell_count(), 2);
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
let v = measure(&cut).unwrap().volume;
assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}");
}
/// Measuring a body of 5000 faces (a modelled thread is like that) counts right (it took 13 s,
/// a per-face recomputation of the body's size; now under a second).
#[test]
fn measure_of_a_body_with_many_faces() {
let n = 5000;
let pts: Vec<Vec2> = (0..n)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / n as f64;
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
})
.collect();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (n + 2, 3 * n, 2 * n), "{:?}", m.merged);
}
/// The time it takes (run alone, the suite's other tests share the machine):
/// `cargo test --release -p solidcraft-kernel perf_measure_5000_faces -- --ignored`.
#[test]
#[ignore]
fn perf_measure_5000_faces() {
let pts: Vec<Vec2> = (0..5000)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / 5000.0;
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
})
.collect();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
let t = std::time::Instant::now();
measure(&b).unwrap();
assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed());
}
/// A lip on a rounded, shelled rim (#29): the band along the rim's inner (or outer) edge raised,
/// its walls continuing the walls below. Exactly the band's area times its height is added.
#[test]
fn raise_band_on_a_rounded_rim() {
let b = box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap();
let f =
fillet(&b, &[Vec3::new(40.0, 30.0, 15.0), Vec3::new(-40.0, 30.0, 15.0), Vec3::new(-40.0, -30.0, 15.0), Vec3::new(40.0, -30.0, 15.0)], 6.0)
.unwrap();
let sh = shell(&f, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap();
let v0 = measure(&sh).unwrap().volume;
// A rounded rectangle's area.
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
for outside in [false, true] {
let lip = crate::lip::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 1.0, 1.5, outside).unwrap();
assert!(lip.validity().is_empty(), "{:?}", lip.validity());
let band = if outside { ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0) } else { ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0) };
let v = measure(&lip).unwrap().volume;
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
}
}
#[test]
fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
for scale in [0.001, 1.0, 1000.0] {
let body = box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.) * scale).unwrap();
let tol = (body.size() * 0.01).max(1e-3);
let mesh = body.tessellate(tol).unwrap();
let mut present = vec![false; body.face_count()];
for face in mesh.tri_face {
present[face as usize] = true;
}
assert_eq!(body.unmeshed_faces(), present.iter().filter(|p| !**p).count());
if scale == 1.0 {
assert_eq!(body.coarse_missing, Some((tol, 0)));
}
}
}
/// A trough whose floor falls about 1° toward a central drain, the fall starting right at the
/// trough edges: four faces meet at each bottom corner (two walls, two fall facets) and at the
/// drain (four facets). A tab on one end makes the body non-convex. The moved planes of four
/// faces don't meet at one point, so each such corner of the cavity becomes two corners joined
/// by a short edge. Checked against the volume integrated by hand.
#[test]
fn shell_where_four_faces_meet_at_a_corner() {
let (w, l, ht, t) = (100.0, 60.0, 40.0, 2.0);
// Drain depth: the long facets fall 1°.
let h = 30.0 * 1f64.to_radians().tan();
let facet = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let facets = [
facet(Vec3::new(0.0, -h, -30.0), Vec3::ZERO),
facet(Vec3::new(0.0, h, -30.0), Vec3::new(0.0, l, 0.0)),
facet(Vec3::new(-h, 0.0, -50.0), Vec3::ZERO),
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(w, 0.0, 0.0)),
];
let mut hs = vec![
HalfSpace { n: -Vec3::X, d: 0.0 },
HalfSpace { n: Vec3::X, d: w },
HalfSpace { n: -Vec3::Y, d: 0.0 },
HalfSpace { n: Vec3::Y, d: l },
HalfSpace { n: Vec3::Z, d: ht },
];
hs.extend(facets);
let trough = convex_polyhedron(&hs).unwrap();
let pyramid = w * l * h / 3.0;
assert!(rel(measure(&trough).unwrap().volume, w * l * ht + pyramid) < 1e-9);
let tab = box_solid(Vec3::new(w, 20.0, 20.0), Vec3::new(w + 20.0, 40.0, ht)).unwrap();
let body = boolean(&trough, &tab, BoolOp::Union).unwrap().unwrap();
let s = shell(&body, &[Vec3::new(50.0, 30.0, ht)], t).unwrap();
// The cavity: the walls in by t, above the floor facets moved in by t (midpoint rule; the
// floor is linear between its creases).
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d - t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
let (nx, ny) = (960, 560);
let (dx, dy) = ((w - 2.0 * t) / nx as f64, (l - 2.0 * t) / ny as f64);
let mut cavity = 0.0;
for i in 0..nx {
for j in 0..ny {
cavity += (ht - floor(t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy)) * dx * dy;
}
}
let tab_cavity = 20.0 * (20.0 - 2.0 * t) * (20.0 - t);
let want = w * l * ht + pyramid + 8000.0 - cavity - tab_cavity;
let m = measure(&s).unwrap();
assert!(rel(m.volume, want) < 1e-5, "{} vs {want}", m.volume);
}
/// The same trough cut as a pocket into a slab (four faces meet at each concave corner of the
/// pocket floor), shelled with its top open.
#[test]
fn shell_of_a_pocket_whose_floor_falls_to_a_drain() {
let (px, py, pw, pl, top, depth, t) = (20.0, 15.0, 100.0, 60.0, 50.0, 30.0, 3.0);
let h = 30.0 * 1f64.to_radians().tan();
let z0 = top - depth;
let facet = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let facets = [
facet(Vec3::new(0.0, -h, -30.0), Vec3::new(px, py, z0)),
facet(Vec3::new(0.0, h, -30.0), Vec3::new(px, py + pl, z0)),
facet(Vec3::new(-h, 0.0, -50.0), Vec3::new(px, py, z0)),
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(px + pw, py, z0)),
];
let mut hs = vec![
HalfSpace { n: -Vec3::X, d: -px },
HalfSpace { n: Vec3::X, d: px + pw },
HalfSpace { n: -Vec3::Y, d: -py },
HalfSpace { n: Vec3::Y, d: py + pl },
HalfSpace { n: Vec3::Z, d: top + 10.0 },
];
hs.extend(facets);
let pocket = convex_polyhedron(&hs).unwrap();
let slab = box_solid(Vec3::ZERO, Vec3::new(140.0, 90.0, top)).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let pocket_vol = pw * pl * depth + pw * pl * h / 3.0;
assert!(rel(measure(&basin).unwrap().volume, 140.0 * 90.0 * top - pocket_vol) < 1e-9);
let s = shell(&basin, &[Vec3::new(5.0, 5.0, top)], t).unwrap();
// The cavity's columns: from t up to the top, or up to the pocket floor moved down by t
// inside the pocket's walls moved out by t.
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d + t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
let (nx, ny) = (1340, 840);
let (dx, dy) = ((140.0 - 2.0 * t) / nx as f64, (90.0 - 2.0 * t) / ny as f64);
let mut cavity = 0.0;
for i in 0..nx {
for j in 0..ny {
let (x, y) = (t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy);
let inside = x > px - t && x < px + pw + t && y > py - t && y < py + pl + t;
let roof = if inside { floor(x, y) } else { top };
cavity += (roof - t) * dx * dy;
}
}
let want = 140.0 * 90.0 * top - pocket_vol - cavity;
let m = measure(&s).unwrap();
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
}
#[test]
fn shell_open_beside_a_round_as_large_as_the_wall_is_refused() {
// Opening the bottom of a box whose bottom outline is rounded: the cavity's side walls stand
// the thickness in from the outside, where a round at least that large meets the bottom, so
// the rim would have no thickness. Smaller rounds leave a rim.
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let loop_ = [Vec3::new(20.0, 0.0, 0.0), Vec3::new(40.0, 15.0, 0.0), Vec3::new(20.0, 30.0, 0.0), Vec3::new(0.0, 15.0, 0.0)];
let bottom = [Vec3::new(20.0, 15.0, 0.0)];
for r in [2.0, 2.4] {
let f = fillet(&b, &loop_, r).unwrap();
let e = shell(&f, &bottom, 2.0).unwrap_err();
assert!(e.to_string().starts_with("the operation failed: not supported yet: the rim"), "{r}: {e}");
}
let f = fillet(&b, &loop_, 1.5).unwrap();
assert!(shell(&f, &bottom, 2.0).unwrap().validity().is_empty());
}
#[test]
fn loft_joined_onto_a_box_top_is_one_shell() {
// #396: a loft from a Ø12 circle on the box top (z = 20) to a hexagon at z = 35. The union
// returned both shells side by side, touching on the disc, which an STL shows as two parts.
let bx = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 20.0)).unwrap();
let hex: Vec<Vec2> = (0..6).map(|k| Vec2::new(4.0 * (PI / 3.0 * k as f64).cos(), 4.0 * (PI / 3.0 * k as f64).sin())).collect();
let loft_at = |x: f64| {
let hex: Vec<Vec2> = hex.iter().map(|p| *p + Vec2::new(x, 0.0)).collect();
loft(&[(Plane::XY.offset(20.0), Loop2::circle(Vec2::new(x, 0.0), 6.0)), (Plane::XY.offset(35.0), Loop2::polygon(&hex))]).unwrap()
};
let l = loft_at(0.0);
let u = boolean(&bx, &l, BoolOp::Union).unwrap().unwrap();
assert_eq!((u.shell_count(), u.face_count()), (1, 6 + 8 - 1), "the loft's bottom disc goes");
assert!(u.validity().is_empty(), "{:?}", u.validity());
let (v, vl) = (measure(&u).unwrap().volume, measure(&l).unwrap().volume);
assert!(rel(v, 24000.0 + vl) < 1e-5, "{v} vs {}", 24000.0 + vl);
// Either order of the operands.
let w = boolean(&l, &bx, BoolOp::Union).unwrap().unwrap();
assert_eq!(w.shell_count(), 1);
assert!(rel(measure(&w).unwrap().volume, 24000.0 + vl) < 1e-5);
// Overhanging the box edge (x = 20) the disc is not inside the top face: whatever the join
// does, it never returns touching shells as one body.
if let Ok(Some(r)) = boolean(&bx, &loft_at(17.0), BoolOp::Union) {
assert!(r.shell_count() == 1 || r.union_lumps().is_ok(), "{} touching shells", r.shell_count());
}
}
/// A disc whose circular edge is rounded smaller than the wall (#319): moved in by the
/// thickness the round collapses to a sharp edge, where the floor meets the side along a
/// circle (two arcs with the same ends), not a straight line.
#[test]
fn shell_of_a_disc_rounded_smaller_than_the_wall() {
use std::f64::consts::PI;
let (rd, h, r) = (15.0, 5.0, 1.5);
let disc = cylinder(Vec3::new(0.0, 0.0, -h), Vec3::Z, rd, h).unwrap();
// The round takes a corner of area r²(1 − π/4) whose centroid is r(10 − 3π)/(3(4 − π))
// in from the rim, swept round the axis (Pappus).
let corner = r * r * (1.0 - PI / 4.0) * 2.0 * PI * (rd - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let (top, bottom) = ([Vec3::ZERO], [Vec3::new(0.0, 0.0, -h)]);
for (edge_z, open) in [(-h, &top), (-h, &bottom), (0.0, &bottom)] {
let rounded = fillet(&disc, &[Vec3::new(0.0, rd, edge_z), Vec3::new(0.0, -rd, edge_z)], r).unwrap();
let v0 = PI * rd * rd * h - corner;
for t in [1.6, 2.0] {
let s = shell(&rounded, open, t).unwrap_or_else(|e| panic!("round at z {edge_z}, t {t}: {e}"));
assert!(s.validity().is_empty(), "round at z {edge_z}, t {t}: {:?}", s.validity());
// The cavity: a plain cylinder the thickness in from the side and the closed end.
let want = v0 - PI * (rd - t).powi(2) * (h - t);
let v = measure(&s).unwrap().volume;
assert!(rel(v, want) < 1e-3, "round at z {edge_z}, t {t}: {v} vs {want}");
}
}
}
/// The cache-budget estimate counts edge and vertex uses on the borrowed boundaries; it must
/// equal what walking the cloned wires (`Solid::edge_iter`, `vertex_iter`) counts.
#[test]
fn retained_bytes_matches_the_iterator_counts() {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let hole = cylinder(Vec3::new(20.0, 15.0, -5.0), Vec3::Z, 5.0, 30.0).unwrap();
let drilled = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
for body in [&block, &hole, &drilled] {
let s = &body.solid;
let walked = s.face_iter().count() * 2048 + s.edge_iter().count() * 512 + s.vertex_iter().count() * 96;
assert_eq!(body.retained_bytes(), walked);
}
}
/// #881: a pleated band lofted to a nearly flat one, both 18 vertices from (-26, 3), counter-
/// clockwise and mirror-symmetric about x = 0. The zig-zag's centroid sits well above its first
/// vertex, so that vertex pointed nowhere near the lead's: the loft started three vertices round,
/// twisted (14 820 mm³, centre of mass at x = 5.3). Vertex to vertex, it is a prismoid.
#[test]
fn loft_pairs_the_vertices_of_matching_non_convex_sections() {
let bottom = [
(-26.0, 3.0),
(-24.76384, 3.0),
(-16.0568, 14.97217),
(-8.0, 4.90117),
(0.0, 14.90117),
(8.0, 4.90117),
(16.0568, 14.97217),
(24.76384, 3.0),
(26.0, 3.0),
(26.0, 0.0),
(23.23616, 0.0),
(15.9432, 10.02783),
(8.0, 0.09883),
(0.0, 10.09883),
(-8.0, 0.09883),
(-15.9432, 10.02783),
(-23.23616, 0.0),
(-26.0, 0.0),
];
let top = [
(-26.0, 3.0),
(-24.02812, 3.0),
(-16.0, 3.30105),
(-8.0, 3.00105),
(0.0, 3.30105),
(8.0, 3.00105),
(16.0, 3.30105),
(24.02812, 3.0),
(26.0, 3.0),
(26.0, 0.0),
(23.97188, 0.0),
(16.0, 0.29895),
(8.0, -0.00105),
(0.0, 0.29895),
(-8.0, -0.00105),
(-16.0, 0.29895),
(-23.97188, 0.0),
(-26.0, 0.0),
];
let v = |p: &[(f64, f64)]| p.iter().map(|&(x, y)| Vec2::new(x, y)).collect::<Vec<_>>();
let mid: Vec<Vec2> = v(&bottom).into_iter().zip(v(&top)).map(|(a, b)| (a + b) * 0.5).collect();
// Prismoid: h/6 · (A0 + 4 Amid + A1) = 17 914.43.
let area = |p: &[Vec2]| Loop2::polygon(p).signed_area().abs();
let want = 90.0 / 6.0 * (area(&v(&bottom)) + 4.0 * area(&mid) + area(&v(&top)));
let secs = [(Plane::XY, Loop2::polygon(&v(&bottom))), (Plane::XY.offset(90.0), Loop2::polygon(&v(&top)))];
let m = measure(&loft(&secs).unwrap()).unwrap();
// Twisted ruled faces measure a little under the prismoid (17 884; a square lofted to a
// skewed quad, one twisted face, 0.3 % under), so the bounds leave room for that.
assert!(rel(m.volume, want) < 5e-3, "{} vs {want}", m.volume);
assert!(m.centroid.x.abs() < 0.05, "centre of mass x {}", m.centroid.x);
}