Files
solidcraft-upstream/crates/kernel/src/body_lump_tests.rs
T

304 lines
15 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 super::*;
use crate::{BoolOp, boolean, box_solid, measure, sphere, step_export, step_import};
fn cube(half: f64) -> Body {
box_solid(Vec3::new(-half, -half, -half), Vec3::new(half, half, half)).unwrap()
}
fn nested_island() -> Body {
let hollow = boolean(&cube(50.0), &cube(40.0), BoolOp::Cut).unwrap().unwrap();
// This order puts the containing outer shell ahead of the disconnected island.
let joined = boolean(&cube(20.0), &hollow, BoolOp::Union).unwrap().unwrap();
boolean(&joined, &cube(10.0), BoolOp::Cut).unwrap().unwrap()
}
fn assert_material(body: &Body, expected: f64, probes: &[(Vec3, bool)]) {
assert!(body.validity().is_empty(), "{:?}", body.validity());
let mesh = body.tessellate_with(0.01, true).unwrap();
assert!((mesh.measure().volume - expected).abs() < expected * 1e-6, "{} vs {expected}", mesh.measure().volume);
for (p, want) in probes {
assert_eq!(mesh.contains(*p), *want, "material at {p:?}");
}
}
fn check_nested(body: &Body) {
let probes = [(Vec3::ZERO, false), (Vec3::X * 15.0, true), (Vec3::X * 30.0, false), (Vec3::X * 45.0, true)];
assert_material(body, 544_000.0, &probes);
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) {
assert!((got - want).abs() < 1e-6, "{volumes:?}");
}
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in probes {
assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), usize::from(want), "lump material at {p:?}");
}
}
#[test]
fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() {
let body = nested_island();
check_nested(&body);
let before = step_export(&[&body], "nested cavities").unwrap();
let shells = body.deep_copy().boundaries().clone();
assert_eq!(shells.len(), 4);
for a in 0..4 {
for b in 0..4 {
for c in 0..4 {
for d in 0..4 {
let order = [a, b, c, d];
if order.iter().enumerate().any(|(i, x)| order.iter().skip(i + 1).any(|y| x == y)) {
continue;
}
let permuted = guard("permuted cavity shells", || {
Body::new(Solid::try_new(order.into_iter().map(|i| shells[i].clone()).collect()).unwrap())
})
.unwrap();
check_nested(&permuted);
}
}
}
}
assert_eq!(step_export(&[&body], "nested cavities").unwrap(), before);
}
#[test]
fn nested_voids_survive_step_round_trip_and_further_cuts() {
let body = nested_island();
let text = step_export(&[&body], "nested cavities").unwrap();
let imported = step_import(&text).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let mut lumps = Vec::new();
for imported in imported.bodies {
lumps.extend(imported.body.lumps().unwrap());
}
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) {
assert!((got - want).abs() < 1e-3, "{volumes:?}");
}
let opening = box_solid(Vec3::new(5.0, -2.0, -2.0), Vec3::new(25.0, 2.0, 2.0)).unwrap();
let mut cut_volume = 0.0;
for lump in &lumps {
if let Some(cut) = boolean(lump, &opening, BoolOp::Cut).unwrap() {
cut_volume += measure(&cut).unwrap().volume;
assert!(!cut.tessellate_with(0.01, true).unwrap().contains(Vec3::X * 15.0));
}
}
// Only x=10..20 is material along this 4×4 opening: 160 mm³ removed.
assert!((cut_volume - 543_840.0).abs() < 1e-3, "{cut_volume}");
}
#[test]
fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
let hollow = boolean(&cube(20.0), &sphere(Vec3::ZERO, 10.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let island = sphere(Vec3::ZERO, 4.0).unwrap();
let joined = boolean(&island, &hollow, BoolOp::Union).unwrap().unwrap();
let reverse = boolean(&hollow, &island, BoolOp::Union).unwrap().unwrap();
assert!((measure(&joined).unwrap().volume - measure(&reverse).unwrap().volume).abs() < 1e-4);
let body = boolean(&joined, &sphere(Vec3::ZERO, 2.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
let pi = std::f64::consts::PI;
let want = [4.0 / 3.0 * pi * (64.0 - 8.0), 64_000.0 - 4.0 / 3.0 * pi * 1000.0];
for (got, want) in volumes.iter().zip(want) {
assert!((got - want).abs() < want * 0.002, "{got} vs {want}");
}
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), want);
}
let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect();
assert_eq!(imported.len(), 2);
let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
assert_eq!(imported_meshes.iter().filter(|m| m.contains(p)).count(), want);
}
}
fn joined_shells(shells: Vec<mt::Shell>) -> Body {
guard("invalid ownership fixture", || Body::new(Solid::try_new(shells).unwrap())).unwrap()
}
#[test]
fn unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry() {
let outer = cube(50.0);
for (min, max) in [
(Vec3::new(60.0, 60.0, 60.0), Vec3::new(70.0, 70.0, 70.0)),
(Vec3::new(45.0, -5.0, -5.0), Vec3::new(55.0, 5.0, 5.0)),
(Vec3::new(40.0, -5.0, -5.0), Vec3::new(50.0, 5.0, 5.0)),
] {
let mut inner = box_solid(min, max).unwrap().deep_copy();
inner.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(inner.boundaries()).cloned().collect());
let before = step_export(&[&body], "invalid ownership").unwrap();
assert!(body.lumps().is_err());
assert_eq!(step_export(&[&body], "invalid ownership").unwrap(), before);
}
let mut a = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0)).unwrap().deep_copy();
a.not();
let mut b = box_solid(Vec3::new(0.0, -10.0, -10.0), Vec3::new(20.0, 10.0, 10.0)).unwrap().deep_copy();
b.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(a.boundaries()).chain(b.boundaries()).cloned().collect());
assert!(body.lumps().is_err());
}
#[test]
fn shell_grouping_preserves_large_disconnected_patterns_and_bounds_hostile_work() {
let mesh = cube(1.0).tessellate_with(0.01, true).unwrap();
let copies: Vec<_> = (0..512)
.map(|i| {
let mut m = mesh.clone();
for p in &mut m.positions {
p.x += f64::from(i) * 4.0;
}
m
})
.collect();
assert_eq!(shell_groups::groups(&copies, &vec![8.0; 512], 1e-8).unwrap().len(), 512);
let excessive = vec![Mesh::default(); shell_groups::MAX_SHELLS + 1];
assert!(shell_groups::groups(&excessive, &vec![1.0; excessive.len()], 1e-8).unwrap_err().to_string().contains("shell count"));
let mut oversized = mesh.clone();
oversized.positions.resize(shell_groups::MAX_POINTS + 1, Vec3::ZERO);
assert!(shell_groups::groups(&[oversized], &[8.0], 1e-8).unwrap_err().to_string().contains("grouping limit"));
let mut repeated = mesh.clone();
repeated.triangles = mesh.triangles.iter().copied().cycle().take(7000).collect();
assert!(shell_groups::groups(&[repeated.clone(), repeated], &[8.0, 8.0], 1e-8).unwrap_err().to_string().contains("boundary work"));
let mut outer = cube(2.0).tessellate_with(0.01, true).unwrap();
let mut inner = mesh;
outer.triangles = outer.triangles.iter().copied().cycle().take(4000).collect();
inner.triangles = inner.triangles.iter().copied().cycle().take(4000).collect();
inner.positions.resize(4000, Vec3::ZERO);
assert!(shell_groups::groups(&[outer, inner], &[64.0, -8.0], 1e-8).unwrap_err().to_string().contains("containment work"));
}
#[test]
fn translated_and_scaled_nested_voids_keep_material_membership() {
let body = nested_island();
for scale in [0.01, 0.25, 2.0] {
let origin = Vec3::new(1234.0, -987.0, 765.0);
let transformed = crate::transform_matrix(
&body,
[[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0], [origin.x, origin.y, origin.z, 1.0]],
)
.unwrap();
let lumps = transformed
.lumps()
.unwrap_or_else(|e| panic!("scale={scale}: {e}; bounds={:?}", transformed.tessellate_with(1e-4, true).unwrap().bounds()));
assert_eq!(lumps.len(), 2);
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with((scale * 0.01).max(1e-5), true).unwrap()).collect();
for (x, want) in [(0.0, 0), (15.0, 1), (30.0, 0), (45.0, 1)] {
assert_eq!(meshes.iter().filter(|m| m.contains(origin + Vec3::X * (x * scale))).count(), want);
}
}
}
#[test]
fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let a = sphere(Vec3::ZERO, 10.0).unwrap();
for distance in [19.999, 20.001] {
let b = sphere(direction * distance, 10.0).unwrap();
for shells in [
a.deep_copy().boundaries().iter().chain(b.deep_copy().boundaries()).cloned().collect(),
b.deep_copy().boundaries().iter().chain(a.deep_copy().boundaries()).cloned().collect(),
] {
assert!(joined_shells(shells).separated_lumps().is_err(), "distance {distance}");
}
}
let far = sphere(direction * 21.0, 10.0).unwrap();
let safe = crate::ops::join_pieces(vec![a, far]).unwrap().unwrap();
assert_eq!(safe.separated_lumps().unwrap().len(), 2);
let outer = sphere(Vec3::ZERO, 10.0).unwrap();
let mut protruding = sphere(direction * 9.001, 1.0).unwrap().deep_copy();
protruding.not();
let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect());
assert!(invalid.separated_lumps().is_err());
}
fn certified_lump_witness() -> Body {
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
// The current trimmed lower prism: a rectangular tool with a circular stop
// removed. Its cap footprint differs materially from the original sketch.
let region = Region2 {
outer: Loop2::polygon(&[Vec2::new(20., 20.), Vec2::new(30., 20.), Vec2::new(30., 30.), Vec2::new(20., 30.)]),
holes: vec![Loop2::circle(Vec2::new(25., 25.), 2.)],
};
let lower = crate::extrude(&Plane::XY, &[region], 0., 1.).unwrap().pop().unwrap();
let named = box_solid(Vec3::new(40., 20., 5.), Vec3::new(42., 23., 7.)).unwrap().with_box_certificate(202).unwrap();
let unknown = box_solid(Vec3::new(50., 20., 5.), Vec3::new(51., 21., 6.)).unwrap().with_face_sources(None);
let sources: std::collections::HashMap<_, _> = [&lower, &named]
.into_iter()
.flat_map(|body| body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(face, source)| (face.id(), source.clone())))
.collect();
// Deliberately put the larger named box first, then the trimmed tool. An
// index-based copy of the source table would attach certificates wrongly.
let shells = [&named, &unknown, &lower].into_iter().flat_map(|b| b.solid.boundaries().iter().cloned()).collect();
let body = Body::new(Solid::try_new(shells).unwrap()).unwrap();
let table = body.solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect();
body.with_face_sources(Some(std::sync::Arc::new(table)))
}
#[test]
fn grouping_lumps_preserves_only_unchanged_face_certificates_and_trimmed_caps() {
let body = certified_lump_witness();
let expected: std::collections::HashMap<_, _> =
body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(f, source)| (f.id(), (f.orientation(), source.clone()))).collect();
let before = step_export(&[&body], "source").unwrap();
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 3);
let mut total_faces = 0;
let mut raw_caps = 0;
let mut named_faces = 0;
let mut unknown_faces = 0;
for lump in &lumps {
assert_eq!(lump.face_sources().unwrap().len(), lump.face_count());
for (f, source) in lump.solid.face_iter().zip(lump.face_sources().unwrap()) {
let (orientation, original) = expected.get(&f.id()).expect("grouping must retain original face IDs");
assert_eq!(*orientation, f.orientation());
assert_eq!(source, original);
match source {
Some(FaceSource::ExtrudeCap { region, high, plane }) => {
assert_eq!(region.holes.len(), 1);
assert!((region.area() - (100. - 4. * std::f64::consts::PI)).abs() < 1e-10);
assert!((plane.origin.z - if *high { 1. } else { 0. }).abs() < 1e-10);
raw_caps += 1;
}
Some(FaceSource::Box { producer, .. }) => {
assert_eq!(*producer, Some(202));
named_faces += 1;
}
None => unknown_faces += 1,
_ => {}
}
total_faces += 1;
}
let mesh = lump.tessellate(1e-5).unwrap();
if mesh.contains(Vec3::new(21., 21., 0.5)) {
assert!(!mesh.contains(Vec3::new(25., 25., 0.5)), "trimmed stop hole must not refill");
assert!((mesh.measure().volume - (100. - 4. * std::f64::consts::PI)).abs() < 0.002);
}
}
assert_eq!(total_faces, body.face_count());
assert_eq!(raw_caps, 2);
assert_eq!(named_faces, 6);
assert_eq!(unknown_faces, 6);
assert_eq!(step_export(&[&body], "source").unwrap(), before);
}
#[test]
fn grouping_lumps_does_not_infer_absent_or_misaligned_sources() {
let body = certified_lump_witness();
let absent = body.clone().with_face_sources(None);
assert!(absent.lumps().unwrap().iter().all(|b| b.face_sources().is_none()));
let malformed = body.with_face_sources(Some(std::sync::Arc::new(vec![None])));
assert!(malformed.lumps().unwrap_err().to_string().contains("face source table"));
}