#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stderr,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use brepkit_math::tolerance::Tolerance;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
use brepkit_topology::face::{Face, FaceId, FaceSurface};
use brepkit_topology::test_utils::make_unit_cube_manifold_at;
use brepkit_topology::validation::validate_shell_manifold;
use brepkit_topology::vertex::Vertex;
use brepkit_topology::wire::{OrientedEdge, Wire};
use crate::test_helpers::assert_volume_near;
use super::*;
fn check_result(topo: &Topology, solid: SolidId) -> usize {
let s = topo.solid(solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
assert!(
validate_shell_manifold(sh, topo).is_ok(),
"result should be manifold"
);
sh.faces().len()
}
#[test]
fn fuse_disjoint_cubes() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 5.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
assert_eq!(check_result(&topo, result), 12); }
#[test]
fn intersect_multi_piece_operand_keeps_disjoint_chunks() {
use crate::measure::solid_volume;
use crate::transform::transform_solid;
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let cyl_a = crate::primitives::make_cylinder(&mut topo, 1.0, 4.0).unwrap();
let cyl_b = crate::primitives::make_cylinder(&mut topo, 1.0, 4.0).unwrap();
transform_solid(&mut topo, cyl_b, &Mat4::translation(6.0, 0.0, 0.0)).unwrap();
let two_cyls = boolean(&mut topo, BooleanOp::Fuse, cyl_a, cyl_b).unwrap();
let bar = crate::primitives::make_box(&mut topo, 12.0, 4.0, 1.0).unwrap();
transform_solid(&mut topo, bar, &Mat4::translation(-3.0, -2.0, 1.5)).unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, two_cyls, bar).unwrap();
let n_faces = check_result(&topo, result);
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let cylinders = faces
.iter()
.filter(|&&f| topo.face(f).unwrap().surface().type_tag() == "cylinder")
.count();
assert!(
cylinders >= 2,
"analytic chunks must keep their cylinder walls, got {cylinders} of {n_faces} faces"
);
let vol = solid_volume(&topo, result, 0.05).unwrap();
let expected = 2.0 * std::f64::consts::PI; assert!(
(vol - expected).abs() < 0.05,
"two disc slabs expected (vol {expected:.3}), got {vol}"
);
let components = crate::boolean::assembly::face_components(&topo, result);
assert_eq!(
components.len(),
2,
"the two chunks must stay disjoint components"
);
}
#[test]
fn compound_cut_disjoint_drills_matches_sequential() {
use crate::measure::solid_volume;
use crate::transform::transform_solid;
use brepkit_math::mat::Mat4;
let make = |topo: &mut Topology| -> (SolidId, Vec<SolidId>) {
let base = crate::primitives::make_box(topo, 20.0, 20.0, 5.0).unwrap();
let mut drills = Vec::new();
for (x, y) in [(4.0, 4.0), (16.0, 4.0), (4.0, 16.0), (16.0, 16.0)] {
let d = crate::primitives::make_cylinder(topo, 1.5, 3.0).unwrap();
transform_solid(topo, d, &Mat4::translation(x, y, -1.0)).unwrap();
drills.push(d);
}
(base, drills)
};
let mut topo_seq = Topology::new();
let (base, drills) = make(&mut topo_seq);
let mut seq = base;
for &d in &drills {
seq = boolean(&mut topo_seq, BooleanOp::Cut, seq, d).unwrap();
}
let vol_seq = solid_volume(&topo_seq, seq, 0.05).unwrap();
let opts = BooleanOptions {
unify_faces: false,
..BooleanOptions::default()
};
let mut topo = Topology::new();
let (base, drills) = make(&mut topo);
let result = crate::boolean::compound_cut(&mut topo, base, &drills, opts).unwrap();
let vol = solid_volume(&topo, result, 0.05).unwrap();
assert!(
(vol - vol_seq).abs() < 0.05,
"batched volume {vol} must match sequential {vol_seq}"
);
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let cylinders = faces
.iter()
.filter(|&&f| topo.face(f).unwrap().surface().type_tag() == "cylinder")
.count();
assert!(
cylinders >= 4,
"each drilled bore must keep its cylinder wall, got {cylinders}"
);
}
#[test]
fn compound_cut_overlapping_tools_match_union_cut_volume() {
use crate::measure::solid_volume;
use crate::transform::transform_solid;
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let base = crate::primitives::make_box(&mut topo, 10.0, 10.0, 4.0).unwrap();
let t1 = crate::primitives::make_box(&mut topo, 4.0, 4.0, 6.0).unwrap();
transform_solid(&mut topo, t1, &Mat4::translation(2.0, 2.0, -1.0)).unwrap();
let t2 = crate::primitives::make_box(&mut topo, 4.0, 4.0, 6.0).unwrap();
transform_solid(&mut topo, t2, &Mat4::translation(4.0, 4.0, -1.0)).unwrap();
let result =
crate::boolean::compound_cut(&mut topo, base, &[t1, t2], BooleanOptions::default())
.unwrap();
let vol = solid_volume(&topo, result, 0.05).unwrap();
let expected = 400.0 - 28.0 * 4.0;
assert!(
(vol - expected).abs() < 0.1,
"expected {expected}, got {vol}"
);
}
#[test]
fn fuse_six_disjoint_boxes_2x3_grid() {
use crate::measure::solid_volume;
fn pairwise(
topo: &mut brepkit_topology::Topology,
ids: &[brepkit_topology::solid::SolidId],
start: usize,
end: usize,
) -> brepkit_topology::solid::SolidId {
let n = end - start;
if n == 1 {
return ids[start];
}
if n == 2 {
return boolean(topo, BooleanOp::Fuse, ids[start], ids[start + 1]).unwrap();
}
let mid = start + n.div_ceil(2);
let left = pairwise(topo, ids, start, mid);
let right = pairwise(topo, ids, mid, end);
boolean(topo, BooleanOp::Fuse, left, right).unwrap()
}
let mut topo = Topology::new();
let mut boxes = Vec::new();
for row in 0..3 {
for col in 0..2 {
#[allow(clippy::cast_precision_loss)]
let x = f64::from(col) * 10.0;
#[allow(clippy::cast_precision_loss)]
let y = f64::from(row) * 10.0;
let b = crate::primitives::make_box(&mut topo, 5.0, 5.0, 5.0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(x, y, 0.0),
)
.unwrap();
boxes.push(b);
}
}
let result = pairwise(&mut topo, &boxes, 0, boxes.len());
let vol = solid_volume(&topo, result, 0.05).unwrap();
assert!(
(vol - 750.0).abs() < 5.0,
"6-box grid fuse lost volume: got {vol}, expected 750"
);
}
#[test]
fn fuse_disjoint_cubes_volume_chained() {
use crate::measure::solid_volume;
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 5.0, 5.0, 5.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 5.0, 5.0, 5.0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(10.0, 0.0, 0.0),
)
.unwrap();
let c = crate::primitives::make_box(&mut topo, 5.0, 5.0, 5.0).unwrap();
crate::transform::transform_solid(
&mut topo,
c,
&brepkit_math::mat::Mat4::translation(0.0, 10.0, 0.0),
)
.unwrap();
let ab = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let vol_ab = solid_volume(&topo, ab, 0.05).unwrap();
let abc = boolean(&mut topo, BooleanOp::Fuse, ab, c).unwrap();
let vol_abc = solid_volume(&topo, abc, 0.05).unwrap();
assert!(
(vol_ab - 250.0).abs() < 5.0,
"fuse of 2 disjoint cubes lost volume: got {vol_ab}, expected 250"
);
assert!(
(vol_abc - 375.0).abs() < 5.0,
"fuse of disjoint-2-result with third cube lost volume: got {vol_abc}, expected 375"
);
}
#[test]
fn fuse_disjoint_tapered_feet_grid_keeps_all_pieces() {
use crate::boolean::assembly::face_components;
use crate::measure::solid_volume;
use std::f64::consts::PI;
let r_bot = 2.0_f64;
let r_top = 2.4_f64;
let h = 5.0_f64;
let pitch = 5.0_f64;
let n = 3;
let mut topo = Topology::new();
let mut feet = Vec::new();
for row in 0..n {
for col in 0..n {
let x = f64::from(col) * pitch;
let y = f64::from(row) * pitch;
let foot = crate::primitives::make_cone(&mut topo, r_bot, r_top, h).unwrap();
crate::transform::transform_solid(
&mut topo,
foot,
&brepkit_math::mat::Mat4::translation(x, y, 0.0),
)
.unwrap();
feet.push(foot);
}
}
let v_one = PI * h / 3.0 * r_top.mul_add(r_top, r_bot.mul_add(r_bot, r_bot * r_top));
let count = feet.len();
let mut acc = feet[0];
for &foot in &feet[1..] {
acc = boolean(&mut topo, BooleanOp::Fuse, acc, foot).unwrap();
}
let comps = face_components(&topo, acc);
assert_eq!(
comps.len(),
count,
"disjoint fuse should keep all {count} feet as separate components, got {}",
comps.len()
);
let vol = solid_volume(&topo, acc, 0.01).unwrap();
let expected = v_one * count as f64;
assert!(
(vol - expected).abs() < 0.01 * expected,
"fused {count}-foot volume {vol} should equal sum {expected}"
);
let sh = topo.shell(topo.solid(acc).unwrap().outer_shell()).unwrap();
validate_shell_manifold(sh, &topo).expect("disjoint-fuse merge should be manifold");
}
#[test]
fn fuse_overlapping_tapered_feet_welds_via_gfa() {
use crate::boolean::assembly::face_components;
use crate::measure::solid_volume;
use std::f64::consts::PI;
let r_bot = 2.0_f64;
let r_top = 2.5_f64;
let h = 5.0_f64;
let mut topo = Topology::new();
let a = crate::primitives::make_cone(&mut topo, r_bot, r_top, h).unwrap();
let b = crate::primitives::make_cone(&mut topo, r_bot, r_top, h).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(4.0, 0.0, 0.0),
)
.unwrap();
let v_one = PI * h / 3.0 * r_top.mul_add(r_top, r_bot.mul_add(r_bot, r_bot * r_top));
let fused = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let comps = face_components(&topo, fused);
assert_eq!(
comps.len(),
1,
"overlapping feet must weld into one component, got {}",
comps.len()
);
let vol = solid_volume(&topo, fused, 0.01).unwrap();
assert!(
vol < 2.0 * v_one - 1e-3 && vol > v_one,
"overlapping union volume {vol} should be between {v_one} and {}",
2.0 * v_one
);
}
#[test]
fn cut_disjoint_returns_a() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 5.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
assert_eq!(check_result(&topo, result), 6);
}
#[test]
fn cut_disjoint_tool_between_multi_piece_target_returns_target() {
use crate::boolean::assembly::face_components;
let mut topo = Topology::new();
let p1 = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let p2 = make_unit_cube_manifold_at(&mut topo, 10.0, 0.0, 0.0);
let acc = boolean(&mut topo, BooleanOp::Fuse, p1, p2).unwrap();
let tool = make_unit_cube_manifold_at(&mut topo, 5.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Cut, acc, tool).unwrap();
let comps = face_components(&topo, result);
assert_eq!(
comps.len(),
2,
"disjoint cut must keep both target pieces, got {}",
comps.len()
);
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - 2.0).abs() < 1e-6,
"disjoint cut must preserve target volume 2.0, got {vol}"
);
}
#[test]
fn cut_overlapping_tool_removes_material_via_gfa() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - 0.5).abs() < 1e-3,
"overlapping cut must remove the shared half, got {vol}"
);
}
#[test]
fn intersect_disjoint_returns_empty() {
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 5.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
assert_eq!(
solid_faces(&topo, result).unwrap().len(),
0,
"disjoint intersect should produce zero faces"
);
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
vol <= 1e-6,
"disjoint intersect volume should be ~0, got {vol}"
);
}
#[test]
fn intersect_far_apart_boxes_returns_empty() {
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(100.0, 0.0, 0.0),
)
.unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
assert_eq!(solid_faces(&topo, result).unwrap().len(), 0);
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
vol < 1.0,
"far-apart intersect volume should be < 1, got {vol}"
);
}
#[test]
fn intersect_touching_boxes_returns_empty() {
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 1.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
assert_eq!(solid_faces(&topo, result).unwrap().len(), 0);
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
vol <= 1e-6,
"touching-disjoint intersect volume should be ~0, got {vol}"
);
}
#[test]
fn empty_intersect_survives_measure_and_tessellate() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 5.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
assert!(topo.is_empty_solid(result));
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(vol <= 1e-6);
let mesh = crate::tessellate::tessellate_solid(&topo, result, 0.05).unwrap();
assert!(
mesh.indices.is_empty(),
"empty solid should tessellate to no triangles"
);
}
#[test]
fn fuse_cluster_empty_errors_not_panics() {
let mut topo = Topology::new();
let err = super::fuse_cluster(&mut topo, &[]);
assert!(
matches!(err, Err(crate::OperationsError::InvalidInput { .. })),
"empty cluster must return InvalidInput, not panic"
);
}
#[test]
fn intersect_overlapping_boxes_is_nonempty() {
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
assert!(
!solid_faces(&topo, result).unwrap().is_empty(),
"overlapping intersect should produce faces"
);
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
(vol - 0.5).abs() < 1e-3,
"overlap volume should be ~0.5, got {vol}"
);
}
#[test]
fn diagnose_fuse_overlapping_cubes_edges() {
use std::collections::HashMap;
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let s = topo.solid(result).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let mut edge_face_count: HashMap<EdgeId, usize> = HashMap::new();
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid).unwrap();
for oe in wire.edges() {
*edge_face_count.entry(oe.edge()).or_default() += 1;
}
}
}
let non_manifold_count = edge_face_count.values().filter(|&&n| n != 2).count();
assert_eq!(
non_manifold_count, 0,
"{non_manifold_count} non-manifold edges"
);
}
#[test]
fn gfa_direct_fuse_overlapping_manifold() {
use std::collections::HashMap;
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let algo_op = brepkit_algo::bop::BooleanOp::Fuse;
let result = brepkit_algo::gfa::boolean(&mut topo, algo_op, a, b).unwrap();
let s = topo.solid(result).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let mut edge_face_count: HashMap<EdgeId, usize> = HashMap::new();
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid).unwrap();
for oe in wire.edges() {
*edge_face_count.entry(oe.edge()).or_default() += 1;
}
}
}
let faces = sh.faces().len();
let non_manifold = edge_face_count.values().filter(|&&n| n != 2).count();
let boundary = edge_face_count.values().filter(|&&n| n == 1).count();
let overshared = edge_face_count.values().filter(|&&n| n > 2).count();
eprintln!(
"Direct GFA: F={faces} E={} NM={non_manifold} (boundary={boundary} over={overshared})",
edge_face_count.len()
);
let mut edge_faces: std::collections::HashMap<EdgeId, Vec<FaceId>> =
std::collections::HashMap::new();
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid).unwrap();
for oe in wire.edges() {
edge_faces.entry(oe.edge()).or_default().push(fid);
}
}
}
for (&eid, face_list) in &edge_faces {
if face_list.len() > 2 {
let edge = topo.edge(eid).unwrap();
let sp = topo.vertex(edge.start()).unwrap().point();
let ep = topo.vertex(edge.end()).unwrap().point();
let face_desc: Vec<String> = face_list
.iter()
.map(|&fid| {
let f = topo.face(fid).unwrap();
match f.surface() {
FaceSurface::Plane { normal, d } => format!(
"Plane(n=({:.0},{:.0},{:.0}),d={d:.1})",
normal.x(),
normal.y(),
normal.z()
),
_ => "Other".into(),
}
})
.collect();
eprintln!(
" OVER({}): ({:.3},{:.3},{:.3})->({:.3},{:.3},{:.3}) faces: {}",
face_list.len(),
sp.x(),
sp.y(),
sp.z(),
ep.x(),
ep.y(),
ep.z(),
face_desc.join(", ")
);
}
}
let face_set: std::collections::HashSet<FaceId> = sh.faces().iter().copied().collect();
eprintln!("unique faces: {} / {}", face_set.len(), sh.faces().len());
let mut plane_counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
let key = match face.surface() {
FaceSurface::Plane { normal, d } => format!(
"n=({:.0},{:.0},{:.0}) d={d:.1}",
normal.x(),
normal.y(),
normal.z()
),
_ => "other".into(),
};
*plane_counts.entry(key).or_default() += 1;
}
for (plane, count) in &plane_counts {
eprintln!(" {plane}: {count} faces");
}
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
if !face.inner_wires().is_empty() {
eprintln!(
" INNER WIRES: {fid:?} has {} inner wires",
face.inner_wires().len()
);
}
}
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
let mut edge_count_in_wire: HashMap<EdgeId, usize> = HashMap::new();
for oe in wire.edges() {
*edge_count_in_wire.entry(oe.edge()).or_default() += 1;
}
for (&eid, &cnt) in &edge_count_in_wire {
if cnt > 1 {
let e = topo.edge(eid).unwrap();
let sp = topo.vertex(e.start()).unwrap().point();
let ep = topo.vertex(e.end()).unwrap().point();
eprintln!(
" WIRE-DUP({cnt}) in {fid:?}: ({:.3},{:.3},{:.3})->({:.3},{:.3},{:.3})",
sp.x(),
sp.y(),
sp.z(),
ep.x(),
ep.y(),
ep.z()
);
}
}
}
assert_eq!(faces, 14, "GFA should produce 14 faces");
assert!(
non_manifold <= 6,
"expected <=6 non-manifold edges (known cb_qpair issue), got {non_manifold}"
);
}
#[test]
fn fuse_overlapping_cubes() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 1.5, 0.001);
}
#[test]
fn intersect_overlapping_cubes() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 0.5, 0.001);
}
#[test]
fn cut_overlapping_cubes() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 0.5, 0.001);
}
#[test]
fn fuse_overlapping_3d() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.5, 0.5);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 1.875, 0.001);
}
#[test]
fn fuse_evolution_is_faithful() {
use brepkit_topology::explorer::solid_faces;
use std::collections::HashSet;
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.5, 0.5);
let input_set: HashSet<usize> = solid_faces(&topo, a)
.unwrap()
.into_iter()
.chain(solid_faces(&topo, b).unwrap())
.map(brepkit_topology::arena::Id::index)
.collect();
assert_eq!(
input_set.len(),
12,
"two cubes have 12 distinct input faces"
);
let (result, evo) =
crate::boolean::boolean_with_evolution(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let result_faces: HashSet<usize> = solid_faces(&topo, result)
.unwrap()
.into_iter()
.map(brepkit_topology::arena::Id::index)
.collect();
assert!(!evo.modified.is_empty(), "fuse must track modified faces");
let mut attributed: HashSet<usize> = HashSet::new();
for (&in_idx, outs) in &evo.modified {
assert!(
input_set.contains(&in_idx),
"modified key {in_idx} is not one of the input faces"
);
for &out in outs {
assert!(
result_faces.contains(&out),
"modified output {out} is not a face of the result"
);
attributed.insert(out);
}
}
for &d in &evo.deleted {
assert!(input_set.contains(&d), "deleted {d} is not an input face");
}
assert_eq!(
attributed, result_faces,
"every result face should trace to an input face"
);
}
#[test]
fn intersect_overlapping_3d() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.5, 0.5);
let result = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 0.125, 0.001);
}
#[test]
fn cut_overlapping_3d() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 0.5, 0.5, 0.5);
let result = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 0.875, 0.001);
}
#[test]
fn fuse_flush_face_cubes() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 1.0, 0.0, 0.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let _ = check_result(&topo, result);
assert_volume_near(&topo, result, 2.0, 0.001);
}
#[test]
#[allow(clippy::panic)]
fn cylinder_circle_edges() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 1.0, 2.0).unwrap();
let solid = topo.solid(cyl).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
let mut has_circle_edge = false;
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
if matches!(edge.curve(), brepkit_topology::edge::EdgeCurve::Circle(_)) {
has_circle_edge = true;
}
}
}
assert!(has_circle_edge, "cylinder should have Circle edges");
}
#[test]
#[allow(clippy::panic)]
fn circle_edge_length() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 1.0, 2.0).unwrap();
let solid = topo.solid(cyl).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
if matches!(edge.curve(), brepkit_topology::edge::EdgeCurve::Circle(_)) {
let len = crate::measure::edge_length(&topo, oe.edge()).unwrap();
let expected = 2.0 * std::f64::consts::PI * 1.0; assert!(
(len - expected).abs() < 1e-6,
"circle edge length should be 2πr = {expected}, got {len}"
);
return;
}
}
}
panic!("no Circle edge found");
}
#[test]
#[allow(clippy::panic)]
fn exact_plane_cylinder_circle() {
use brepkit_math::analytic_intersection::{
AnalyticSurface, ExactIntersectionCurve, exact_plane_analytic,
};
use brepkit_math::surfaces::CylindricalSurface;
use brepkit_math::vec::{Point3 as P3, Vec3 as V3};
let cyl = CylindricalSurface::new(P3::new(0.0, 0.0, 0.0), V3::new(0.0, 0.0, 1.0), 2.0).unwrap();
let curves =
exact_plane_analytic(AnalyticSurface::Cylinder(&cyl), V3::new(0.0, 0.0, 1.0), 3.0).unwrap();
assert_eq!(curves.len(), 1);
match &curves[0] {
ExactIntersectionCurve::Circle(c) => {
assert!((c.radius() - 2.0).abs() < 1e-10, "radius should be 2.0");
assert!(
(c.center().z() - 3.0).abs() < 1e-10,
"center z should be 3.0"
);
}
_ => panic!("expected Circle, got {:?}", curves[0]),
}
}
#[test]
#[allow(clippy::panic)]
fn exact_plane_sphere_circle() {
use brepkit_math::analytic_intersection::{
AnalyticSurface, ExactIntersectionCurve, exact_plane_analytic,
};
use brepkit_math::surfaces::SphericalSurface;
use brepkit_math::vec::{Point3 as P3, Vec3 as V3};
let sphere = SphericalSurface::new(P3::new(0.0, 0.0, 0.0), 3.0).unwrap();
let curves = exact_plane_analytic(
AnalyticSurface::Sphere(&sphere),
V3::new(0.0, 0.0, 1.0),
0.0,
)
.unwrap();
assert_eq!(curves.len(), 1);
match &curves[0] {
ExactIntersectionCurve::Circle(c) => {
assert!(
(c.radius() - 3.0).abs() < 1e-10,
"equator radius = sphere radius"
);
}
_ => panic!("expected Circle"),
}
}
#[test]
#[allow(clippy::panic)]
fn exact_plane_cylinder_ellipse() {
use brepkit_math::analytic_intersection::{
AnalyticSurface, ExactIntersectionCurve, exact_plane_analytic,
};
use brepkit_math::surfaces::CylindricalSurface;
use brepkit_math::vec::{Point3 as P3, Vec3 as V3};
let cyl = CylindricalSurface::new(P3::new(0.0, 0.0, 0.0), V3::new(0.0, 0.0, 1.0), 1.0).unwrap();
let n = V3::new(0.0, 1.0, 1.0).normalize().unwrap();
let curves = exact_plane_analytic(AnalyticSurface::Cylinder(&cyl), n, 0.0).unwrap();
assert_eq!(curves.len(), 1);
match &curves[0] {
ExactIntersectionCurve::Ellipse(e) => {
assert!((e.semi_minor() - 1.0).abs() < 1e-10, "semi_minor = radius");
let expected_major = 1.0 / (std::f64::consts::FRAC_1_SQRT_2);
assert!(
(e.semi_major() - expected_major).abs() < 1e-6,
"semi_major = r/cos(45°) = {expected_major}, got {}",
e.semi_major()
);
}
_ => panic!("expected Ellipse, got {:?}", curves[0]),
}
}
#[test]
fn box_fuse_box_unchanged() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(1.0, 0.0, 0.0),
)
.unwrap();
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let s = topo.solid(result).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
assert!(!sh.faces().is_empty(), "fuse should produce faces");
}
#[test]
fn cylinder_tessellates_with_circle_edges() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 1.0, 2.0).unwrap();
let solid = topo.solid(cyl).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
if matches!(face.surface(), FaceSurface::Plane { .. }) {
let mesh = crate::tessellate::tessellate(&topo, fid, 1.0).unwrap();
assert!(
mesh.positions.len() >= 3,
"cap face should tessellate to at least 3 positions, got {}",
mesh.positions.len()
);
}
}
}
#[test]
fn cone_has_circle_edges() {
let mut topo = Topology::new();
let cone = crate::primitives::make_cone(&mut topo, 2.0, 0.0, 3.0).unwrap();
let solid = topo.solid(cone).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
let mut has_circle = false;
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
if matches!(
topo.edge(oe.edge()).unwrap().curve(),
brepkit_topology::edge::EdgeCurve::Circle(_)
) {
has_circle = true;
}
}
}
assert!(has_circle, "cone should have Circle edges");
}
#[test]
fn assemble_mixed_planar_only() {
let mut topo = Topology::new();
let specs = vec![
FaceSpec::Planar {
vertices: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
],
normal: Vec3::new(0.0, 0.0, -1.0),
d: 0.0,
inner_wires: vec![],
},
FaceSpec::Planar {
vertices: vec![
Point3::new(0.0, 0.0, 1.0),
Point3::new(1.0, 0.0, 1.0),
Point3::new(1.0, 1.0, 1.0),
Point3::new(0.0, 1.0, 1.0),
],
normal: Vec3::new(0.0, 0.0, 1.0),
d: 1.0,
inner_wires: vec![],
},
FaceSpec::Planar {
vertices: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 1.0),
Point3::new(0.0, 0.0, 1.0),
],
normal: Vec3::new(0.0, -1.0, 0.0),
d: 0.0,
inner_wires: vec![],
},
FaceSpec::Planar {
vertices: vec![
Point3::new(0.0, 1.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(1.0, 1.0, 1.0),
Point3::new(0.0, 1.0, 1.0),
],
normal: Vec3::new(0.0, 1.0, 0.0),
d: 1.0,
inner_wires: vec![],
},
FaceSpec::Planar {
vertices: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
Point3::new(0.0, 1.0, 1.0),
Point3::new(0.0, 0.0, 1.0),
],
normal: Vec3::new(-1.0, 0.0, 0.0),
d: 0.0,
inner_wires: vec![],
},
FaceSpec::Planar {
vertices: vec![
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(1.0, 1.0, 1.0),
Point3::new(1.0, 0.0, 1.0),
],
normal: Vec3::new(1.0, 0.0, 0.0),
d: 1.0,
inner_wires: vec![],
},
];
let solid = assemble_solid_mixed(&mut topo, &specs, Tolerance::new()).unwrap();
let s = topo.solid(solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
assert_eq!(
sh.faces().len(),
6,
"mixed assembly box should have 6 faces"
);
}
#[test]
fn assemble_mixed_drops_sub_resolution_polygon() {
let mut topo = Topology::new();
let unit_cube_faces = [
(
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, -1.0],
0.0,
),
(
[0.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 1.0],
[0.0, 0.0, 1.0],
1.0,
),
(
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
[0.0, -1.0, 0.0],
0.0,
),
(
[0.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 1.0, 1.0],
[0.0, 1.0, 1.0],
[0.0, 1.0, 0.0],
1.0,
),
(
[0.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 1.0, 1.0],
[0.0, 0.0, 1.0],
[-1.0, 0.0, 0.0],
0.0,
),
(
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 1.0, 1.0],
[1.0, 0.0, 1.0],
[1.0, 0.0, 0.0],
1.0,
),
];
let mut specs: Vec<FaceSpec> = unit_cube_faces
.iter()
.map(|&(a, b, c, d, n, dist)| FaceSpec::Planar {
vertices: vec![
Point3::new(a[0], a[1], a[2]),
Point3::new(b[0], b[1], b[2]),
Point3::new(c[0], c[1], c[2]),
Point3::new(d[0], d[1], d[2]),
],
normal: Vec3::new(n[0], n[1], n[2]),
d: dist,
inner_wires: vec![],
})
.collect();
specs.push(FaceSpec::Planar {
vertices: vec![
Point3::new(0.5, 0.5, 1.0),
Point3::new(0.5 + 1e-9, 0.5, 1.0),
Point3::new(0.5, 0.5 + 1e-9, 1.0),
],
normal: Vec3::new(0.0, 0.0, 1.0),
d: 1.0,
inner_wires: vec![],
});
let solid = assemble_solid_mixed(&mut topo, &specs, Tolerance::new())
.expect("sub-resolution polygon must be dropped, not error the assembly");
let s = topo.solid(solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
assert_eq!(
sh.faces().len(),
6,
"the degenerate polygon must not appear as a face"
);
}
#[test]
fn assemble_mixed_with_nurbs() {
use brepkit_math::nurbs::surface::NurbsSurface;
let mut topo = Topology::new();
let nurbs = NurbsSurface::new(
1,
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![0.0, 0.0, 1.0, 1.0],
vec![
vec![Point3::new(0.0, 0.0, 1.0), Point3::new(1.0, 0.0, 1.0)],
vec![Point3::new(0.0, 1.0, 1.0), Point3::new(1.0, 1.0, 1.0)],
],
vec![vec![1.0, 1.0], vec![1.0, 1.0]],
)
.unwrap();
let specs = vec![
FaceSpec::Planar {
vertices: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
],
normal: Vec3::new(0.0, 0.0, -1.0),
d: 0.0,
inner_wires: vec![],
},
FaceSpec::Surface {
vertices: vec![
Point3::new(0.0, 0.0, 1.0),
Point3::new(1.0, 0.0, 1.0),
Point3::new(1.0, 1.0, 1.0),
Point3::new(0.0, 1.0, 1.0),
],
surface: FaceSurface::Nurbs(nurbs),
reversed: false,
inner_wires: vec![],
},
];
let solid = assemble_solid_mixed(&mut topo, &specs, Tolerance::new()).unwrap();
let s = topo.solid(solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
assert_eq!(sh.faces().len(), 2, "mixed assembly should have 2 faces");
let has_nurbs = sh
.faces()
.iter()
.any(|&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Nurbs(_)));
assert!(has_nurbs, "mixed assembly should contain a NURBS face");
}
#[test]
fn intersect_box_sphere_succeeds() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 7.0, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, bx, sp).unwrap();
let face_ids = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let (mut planes, mut spheres, mut others) = (0usize, 0usize, 0usize);
for fid in &face_ids {
match topo.face(*fid).unwrap().surface() {
brepkit_topology::face::FaceSurface::Plane { .. } => planes += 1,
brepkit_topology::face::FaceSurface::Sphere(_) => spheres += 1,
_ => others += 1,
}
}
assert_eq!(
face_ids.len(),
4,
"spherical octant should have 4 faces, got {}",
face_ids.len()
);
assert_eq!(planes, 3, "expected 3 plane sub-faces, got {planes}");
assert_eq!(
spheres, 1,
"expected 1 spherical patch (lost without the shortcut), got {spheres}"
);
assert_eq!(others, 0, "no non-analytic faces expected, got {others}");
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, result).unwrap();
let euler = v as i64 - e as i64 + f as i64;
assert_eq!(
euler, 2,
"Euler V-E+F should be 2, got {euler} (V={v}, E={e}, F={f})"
);
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
let vol_box = 1000.0;
let vol_sphere = 4.0 / 3.0 * std::f64::consts::PI * 343.0;
assert!(vol > 0.0, "volume should be positive, got {vol}");
assert!(
vol < vol_box && vol < vol_sphere,
"volume {vol:.1} should be smaller than both inputs ({vol_box}, {vol_sphere:.1})"
);
}
#[test]
fn intersect_box_centered_sphere_is_analytic_collar() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 6.0, 24).unwrap();
crate::transform::transform_solid(
&mut topo,
sp,
&brepkit_math::mat::Mat4::translation(5.0, 5.0, 5.0),
)
.unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, bx, sp).unwrap();
let face_ids = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let (mut planes, mut spheres, mut others) = (0usize, 0usize, 0usize);
for fid in &face_ids {
match topo.face(*fid).unwrap().surface() {
brepkit_topology::face::FaceSurface::Plane { .. } => planes += 1,
brepkit_topology::face::FaceSurface::Sphere(_) => spheres += 1,
_ => others += 1,
}
}
assert_eq!(
face_ids.len(),
8,
"expected 6 plane discs + 2 sphere collars, got {}",
face_ids.len()
);
assert_eq!(planes, 6, "expected 6 plane discs, got {planes}");
assert_eq!(
spheres, 2,
"expected 2 sphere collar patches, got {spheres}"
);
assert_eq!(others, 0, "no non-analytic faces expected, got {others}");
let adj = brepkit_topology::adjacency::AdjacencyIndex::build(&topo, result).unwrap();
assert_eq!(
adj.boundary_edges().len(),
0,
"result must be watertight (0 free edges)"
);
assert!(adj.is_manifold(), "result must be manifold");
let r: f64 = 6.0;
let h: f64 = 1.0;
let v_sphere = 4.0 / 3.0 * std::f64::consts::PI * r.powi(3);
let v_cap = std::f64::consts::PI * h * h * (3.0 * r - h) / 3.0;
let expected = v_sphere - 6.0 * v_cap;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 0.01,
"volume {vol:.3} should be within 1% of analytic {expected:.3}"
);
}
#[test]
fn cut_torus_by_box_notch_is_analytic_watertight() {
let mut topo = Topology::new();
let tor = crate::primitives::make_torus(&mut topo, 10.0, 3.0, 32).unwrap();
let bx = crate::primitives::make_box(&mut topo, 8.0, 8.0, 8.0).unwrap();
crate::transform::transform_solid(
&mut topo,
bx,
&brepkit_math::mat::Mat4::translation(6.0, -4.0, -4.0),
)
.unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, tor, bx).unwrap();
let face_ids = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let (mut planes, mut tori, mut others) = (0usize, 0usize, 0usize);
for fid in &face_ids {
match topo.face(*fid).unwrap().surface() {
brepkit_topology::face::FaceSurface::Plane { .. } => planes += 1,
brepkit_topology::face::FaceSurface::Torus(_) => tori += 1,
_ => others += 1,
}
}
assert_eq!(others, 0, "no non-analytic faces expected, got {others}");
assert_eq!(tori, 1, "expected exactly 1 kept toroidal band, got {tori}");
assert_eq!(
planes, 4,
"expected exactly 4 plane notch walls, got {planes}"
);
assert_eq!(
face_ids.len(),
5,
"expected exactly 5 faces, got {}",
face_ids.len()
);
let adj = brepkit_topology::adjacency::AdjacencyIndex::build(&topo, result).unwrap();
assert_eq!(
adj.boundary_edges().len(),
0,
"result must be watertight (0 free edges)"
);
assert!(adj.is_manifold(), "result must be manifold");
let expected = 1543.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 0.02,
"volume {vol:.2} should be within 2% of {expected:.0}"
);
}
#[test]
fn fuse_box_sphere_succeeds() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 7.0, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Fuse, bx, sp).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
let vol_box: f64 = 1000.0;
let vol_sphere = 4.0 / 3.0 * std::f64::consts::PI * 343.0;
let vol_max = vol_box.max(vol_sphere);
assert!(
vol > vol_max * 0.98,
"fuse volume {vol:.1} should be > ~larger input {:.1}",
vol_max * 0.98
);
assert!(
vol < vol_box + vol_sphere,
"fuse volume {vol:.1} should be < sum {:.1}",
vol_box + vol_sphere
);
}
#[test]
fn cut_box_by_sphere_succeeds() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 7.0, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, bx, sp);
assert!(
result.is_ok(),
"cut(box, sphere) should succeed: {:?}",
result.err()
);
let r = result.unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.1).unwrap();
assert!(
vol < 1000.0,
"cut(box, sphere) volume {vol} should be less than box volume 1000"
);
}
#[test]
fn cut_sphere_by_through_cylinder_is_analytic_watertight() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let s = crate::primitives::make_sphere(&mut topo, 6.0, 24).unwrap();
let c = crate::primitives::make_cylinder(&mut topo, 3.0, 30.0).unwrap();
crate::transform::transform_solid(&mut topo, c, &Mat4::translation(0.0, 0.0, -15.0)).unwrap();
let res = boolean(&mut topo, BooleanOp::Cut, s, c).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, res).unwrap();
let mut spheres = 0;
let mut cylinders = 0;
for &fid in &faces {
match topo.face(fid).unwrap().surface() {
FaceSurface::Sphere(_) => spheres += 1,
FaceSurface::Cylinder(_) => cylinders += 1,
other => panic!("unexpected non-analytic-tunnel face {other:?}"),
}
}
assert_eq!(spheres, 2, "expected two spherical bands, got {spheres}");
assert_eq!(cylinders, 1, "expected one tunnel wall, got {cylinders}");
assert!(
faces.len() <= 6,
"analytic result must be a handful of faces, not a mesh fallback (got {})",
faces.len()
);
let mut edge_use: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for &fid in &faces {
let f = topo.face(fid).unwrap();
let mut wires = vec![f.outer_wire()];
wires.extend(f.inner_wires().iter().copied());
for w in wires {
for oe in topo.wire(w).unwrap().edges() {
*edge_use.entry(oe.edge().index()).or_insert(0) += 1;
}
}
}
let free = edge_use.values().filter(|&&c| c == 1).count();
let over = edge_use.values().filter(|&&c| c > 2).count();
assert_eq!(free, 0, "result must be watertight (free edges = {free})");
assert_eq!(
over, 0,
"result must be manifold (over-shared edges = {over})"
);
let r = 3.0_f64;
let rr = 6.0_f64;
let z = (rr * rr - r * r).sqrt();
let v_sphere = 4.0 / 3.0 * std::f64::consts::PI * rr.powi(3);
let h_cap = rr - z;
let v_cap = std::f64::consts::PI * h_cap * h_cap * (rr - h_cap / 3.0);
let v_removed = std::f64::consts::PI * r * r * (2.0 * z) + 2.0 * v_cap;
let v_expect = v_sphere - v_removed;
let vol = crate::measure::solid_volume(&topo, res, 0.001).unwrap();
assert!(
(vol - v_expect).abs() < v_expect * 0.01,
"tunnel volume {vol:.3} should match sphere − bore {v_expect:.3}"
);
}
#[test]
fn cut_box_by_translated_sphere() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 3.0, 32).unwrap();
let mat = brepkit_math::mat::Mat4::translation(5.0, 5.0, 5.0);
crate::transform::transform_solid(&mut topo, sp, &mat).unwrap();
let sph_vol = crate::measure::solid_volume(&topo, sp, 0.05).unwrap();
eprintln!("sphere volume: {sph_vol:.1} (expected ~113.1)");
let result = boolean(&mut topo, BooleanOp::Cut, bx, sp);
assert!(
result.is_ok(),
"cut(box, translated sphere) should succeed: {:?}",
result.err()
);
let r = result.unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.05).unwrap();
let expected = 1000.0 - sph_vol;
eprintln!("cut volume: {vol:.1} (expected ~{expected:.1})");
let faces = brepkit_topology::explorer::solid_faces(&topo, r).unwrap();
eprintln!("result has {} faces", faces.len());
assert!(
vol < 1000.0,
"cut volume {vol} should be less than box volume 1000"
);
assert!(vol > 0.0, "cut volume should be positive");
}
#[test]
fn cut_box_by_large_sphere_containment() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 50.0, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, bx, sp);
if let Ok(r) = result {
let vol = crate::measure::solid_volume(&topo, r, 0.1).unwrap();
assert!(
vol < 10.0,
"fully contained cut should remove nearly all volume, got {vol}"
);
}
}
#[test]
fn intersect_box_with_containing_sphere() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 50.0, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, bx, sp);
assert!(
result.is_ok(),
"intersect(box, containing sphere) should succeed: {:?}",
result.err()
);
let r = result.unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.1).unwrap();
assert!(
(vol - 1000.0).abs() < 50.0,
"intersect with containing sphere should preserve box volume, got {vol}"
);
}
#[test]
fn disjoint_box_sphere_cut_preserves_box() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(100.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, bx, &mat).unwrap();
let sp = crate::primitives::make_sphere(&mut topo, 5.0, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, bx, sp);
assert!(
result.is_ok(),
"disjoint cut should succeed: {:?}",
result.err()
);
let r = result.unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.1).unwrap();
assert!(
(vol - 1000.0).abs() < 50.0,
"disjoint cut should preserve box volume, got {vol}"
);
}
#[test]
fn cut_box_by_translated_cylinder() {
let mut topo = Topology::new();
let bx = crate::primitives::make_box(&mut topo, 50.0, 30.0, 10.0).unwrap();
let cyl = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(25.0, 15.0, -5.0);
crate::transform::transform_solid(&mut topo, cyl, &mat).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, bx, cyl);
assert!(
result.is_ok(),
"cut(box, cyl) should succeed: {:?}",
result.err()
);
let rr = result.unwrap();
let vol = crate::measure::solid_volume(&topo, rr, 0.1).unwrap();
let expected = 50.0 * 30.0 * 10.0 - std::f64::consts::PI * 25.0 * 10.0;
assert!(
vol < 15000.0,
"cut volume {vol} should be less than box volume 15000"
);
assert!(
(vol - expected).abs() < expected * 0.1,
"cut volume {vol} should be near {expected}"
);
}
#[test]
fn sequential_cylinder_cuts() {
let mut topo = Topology::new();
let plate = crate::primitives::make_box(&mut topo, 50.0, 30.0, 10.0).unwrap();
let cyl1 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let mat1 = brepkit_math::mat::Mat4::translation(10.0, 10.0, -5.0);
crate::transform::transform_solid(&mut topo, cyl1, &mat1).unwrap();
let r1 = boolean(&mut topo, BooleanOp::Cut, plate, cyl1).unwrap();
let s = topo.solid(r1).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
eprintln!("First cut: {} faces", sh.faces().len());
let cyl2 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let mat2 = brepkit_math::mat::Mat4::translation(40.0, 10.0, -5.0);
crate::transform::transform_solid(&mut topo, cyl2, &mat2).unwrap();
let r2 = boolean(&mut topo, BooleanOp::Cut, r1, cyl2).unwrap();
let s2 = topo.solid(r2).unwrap();
let sh2 = topo.shell(s2.outer_shell()).unwrap();
eprintln!("Second cut: {} faces", sh2.faces().len());
let vol = crate::measure::solid_volume(&topo, r2, 0.1).unwrap();
eprintln!("Volume after 2 drills: {vol}");
let cyl3 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let mat3 = brepkit_math::mat::Mat4::translation(25.0, 20.0, -5.0);
crate::transform::transform_solid(&mut topo, cyl3, &mat3).unwrap();
let r3 = boolean(&mut topo, BooleanOp::Cut, r2, cyl3).unwrap();
let vol3 = crate::measure::solid_volume(&topo, r3, 0.1).unwrap();
eprintln!("Volume after 3 drills: {vol3}");
assert!(
vol3 < 50.0 * 30.0 * 10.0,
"drilled plate should have less volume: {vol3}"
);
}
#[test]
fn intersect_two_cylinders() {
let mut topo = Topology::new();
let cyl1 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let cyl2 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(2.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, cyl2, &mat).unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, cyl1, cyl2);
assert!(
result.is_ok(),
"intersect(cyl, cyl) should succeed: {:?}",
result.err()
);
let r = result.unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.1).unwrap();
assert!(vol > 0.0, "intersection volume should be positive: {vol}");
let vol_cyl2 = std::f64::consts::PI * 3.0_f64.powi(2) * 20.0;
assert!(
vol <= vol_cyl2 + 1e-6,
"intersection volume {vol} should be at most smaller cylinder {vol_cyl2}"
);
}
#[test]
fn intersect_two_equal_cylinders() {
let mut topo = Topology::new();
let cyl1 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let cyl2 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(3.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, cyl2, &mat).unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, cyl1, cyl2);
assert!(
result.is_ok(),
"intersect(cyl r=5, cyl r=5 offset=3) should succeed: {:?}",
result.err()
);
let r = result.unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.1).unwrap();
assert!(vol > 0.0, "intersection volume should be positive: {vol}");
}
#[test]
fn fuse_perpendicular_cylinders_is_analytic_watertight() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let c1 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, c1, &Mat4::translation(0.0, 0.0, -10.0)).unwrap();
let c2 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
crate::transform::transform_solid(
&mut topo,
c2,
&Mat4::rotation_y(std::f64::consts::FRAC_PI_2),
)
.unwrap();
crate::transform::transform_solid(&mut topo, c2, &Mat4::translation(-10.0, 0.0, 0.0)).unwrap();
let res = boolean(&mut topo, BooleanOp::Fuse, c1, c2).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, res).unwrap();
let mut cylinders = 0;
let mut planes = 0;
for &fid in &faces {
match topo.face(fid).unwrap().surface() {
FaceSurface::Cylinder(_) => cylinders += 1,
FaceSurface::Plane { .. } => planes += 1,
other => panic!("unexpected non-analytic face {other:?}"),
}
}
assert_eq!(
cylinders, 2,
"expected two mutually-trimmed walls, got {cylinders}"
);
assert_eq!(planes, 4, "expected four end caps, got {planes}");
assert!(
faces.len() <= 8,
"analytic result must be a handful of faces, not a mesh fallback (got {})",
faces.len()
);
let mut edge_use: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for &fid in &faces {
let f = topo.face(fid).unwrap();
let mut wires = vec![f.outer_wire()];
wires.extend(f.inner_wires().iter().copied());
for w in wires {
for oe in topo.wire(w).unwrap().edges() {
*edge_use.entry(oe.edge().index()).or_insert(0) += 1;
}
}
}
let free = edge_use.values().filter(|&&c| c == 1).count();
let over = edge_use.values().filter(|&&c| c > 2).count();
assert_eq!(free, 0, "result must be watertight (free edges = {free})");
assert_eq!(
over, 0,
"result must be manifold (over-shared edges = {over})"
);
let v_cyl = std::f64::consts::PI * 9.0 * 20.0;
let v_steinmetz = 16.0 * 27.0 / 3.0;
let v_expect = 2.0 * v_cyl - v_steinmetz;
let vol = crate::measure::solid_volume(&topo, res, 0.01).unwrap();
assert!(
(vol - v_expect).abs() < v_expect * 0.01,
"fused volume {vol:.3} should match 2·V_cyl − V_Steinmetz {v_expect:.3} (within 1%)"
);
}
#[test]
fn fuse_two_cylinders() {
use std::f64::consts::PI;
let mut topo = Topology::new();
let cyl1 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let cyl2 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(4.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, cyl2, &mat).unwrap();
let opts = BooleanOptions {
deflection: 0.02,
..BooleanOptions::default()
};
let result = boolean_with_options(&mut topo, BooleanOp::Fuse, cyl1, cyl2, opts).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.02).unwrap();
let vol_cyl1 = PI * 25.0 * 20.0; let vol_cyl2 = PI * 9.0 * 20.0; let lower = (vol_cyl1 + 0.15 * vol_cyl2) * 0.95;
assert!(
vol > lower,
"fuse volume {vol:.1} should be > conservative lower bound {lower:.1}"
);
assert!(
vol < vol_cyl1 + vol_cyl2,
"fuse volume {vol:.1} should be < sum {:.1}",
vol_cyl1 + vol_cyl2
);
}
#[test]
fn cut_cylinder_by_cylinder() {
use std::f64::consts::PI;
let mut topo = Topology::new();
let cyl1 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let cyl2 = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(2.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, cyl2, &mat).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, cyl1, cyl2).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
let vol_cyl1 = PI * 25.0 * 20.0; assert!(vol > 0.0, "cut volume should be positive, got {vol}");
assert!(
vol < vol_cyl1,
"cut volume {vol:.1} should be < original cylinder {vol_cyl1:.1}"
);
}
#[test]
#[ignore = "slow (~2 min) — run manually with --ignored"]
fn staircase_fuse_with_cylinders() {
use std::time::Instant;
let mut topo = Topology::new();
let start = Instant::now();
let mut shapes: Vec<SolidId> = Vec::new();
for i in 0..10 {
let step = crate::primitives::make_box(&mut topo, 20.0, 30.0, 2.0).unwrap();
let mat_step = brepkit_math::mat::Mat4::translation(0.0, 0.0, f64::from(i) * 10.0);
crate::transform::transform_solid(&mut topo, step, &mat_step).unwrap();
shapes.push(step);
let post = crate::primitives::make_cylinder(&mut topo, 1.5, 10.0).unwrap();
let mat_post = brepkit_math::mat::Mat4::translation(10.0, 15.0, f64::from(i) * 10.0 + 2.0);
crate::transform::transform_solid(&mut topo, post, &mat_post).unwrap();
shapes.push(post);
}
let mut result = shapes[0];
for &shape in &shapes[1..] {
result = boolean(&mut topo, BooleanOp::Fuse, result, shape).unwrap();
}
let elapsed = start.elapsed();
eprintln!("Staircase fuse: {elapsed:?} ({} shapes)", shapes.len());
let vol = crate::measure::solid_volume(&topo, result, 0.5).unwrap();
eprintln!("Volume: {vol:.1}");
assert!(vol > 0.0, "staircase volume should be positive");
}
#[test]
fn profile_cylinder_cylinder_intersect() {
let mut topo = Topology::new();
let cyl1 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let cyl2 = crate::primitives::make_cylinder(&mut topo, 5.0, 20.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(3.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, cyl2, &mat).unwrap();
for i in 0..5 {
let mut t = Topology::new();
let c1 = crate::primitives::make_cylinder(&mut t, 5.0, 20.0).unwrap();
let c2 = crate::primitives::make_cylinder(&mut t, 5.0, 20.0).unwrap();
let m = brepkit_math::mat::Mat4::translation(3.0, 0.0, 0.0);
crate::transform::transform_solid(&mut t, c2, &m).unwrap();
let start = std::time::Instant::now();
let result = boolean(&mut t, BooleanOp::Intersect, c1, c2);
let elapsed = start.elapsed();
eprintln!("run {i}: {elapsed:?} result={}", result.is_ok());
}
let result = boolean(&mut topo, BooleanOp::Intersect, cyl1, cyl2).unwrap();
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
eprintln!("Volume: {vol:.2}");
assert!(
vol > 0.0,
"intersection volume should be positive, got {vol}"
);
}
#[test]
fn box_cut_cylinder_edge_count() {
let mut topo = Topology::new();
let b = crate::primitives::make_box(&mut topo, 40.0, 20.0, 5.0).unwrap();
let cyl = crate::primitives::make_cylinder(&mut topo, 3.0, 10.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(20.0, 10.0, 0.0);
let hole = crate::copy::copy_solid(&mut topo, cyl).unwrap();
crate::transform::transform_solid(&mut topo, hole, &mat).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, b, hole).unwrap();
let edges = brepkit_topology::explorer::solid_edges(&topo, result).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
assert_eq!(faces.len(), 7, "expected 7 faces for box-cylinder cut");
assert!(
edges.len() <= 20,
"expected ~16 edges for box-cylinder cut, got {} (was 142 before fix)",
edges.len()
);
let circle_count = edges
.iter()
.filter(|&&eid| matches!(topo.edge(eid).unwrap().curve(), EdgeCurve::Circle(_)))
.count();
assert!(
circle_count >= 2,
"expected at least 2 Circle edges, got {circle_count}"
);
}
#[test]
fn fuse_overlapping_boxes_validates() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(5.0, 5.0, 5.0);
crate::transform::transform_solid(&mut topo, b, &mat).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let edge_map = brepkit_topology::explorer::edge_to_face_map(&topo, fused).unwrap();
let boundary: Vec<_> = edge_map
.iter()
.filter(|(_, faces)| faces.len() == 1)
.collect();
assert!(
boundary.is_empty(),
"fuse result has {} boundary edge(s): {:?}",
boundary.len(),
boundary.iter().map(|(e, _)| e).collect::<Vec<_>>()
);
let report = crate::validate::validate_solid(&topo, fused).unwrap();
assert!(
report.is_valid(),
"fuse(overlapping boxes) should validate: {:?}",
report.issues
);
}
#[test]
fn fuse_adjacent_boxes_shared_face() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(1.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, b, &mat).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
let expected = 2.0; assert!(
(vol - expected).abs() < 0.01 * expected,
"shared-face fuse volume: {vol} (expected {expected})"
);
let shell_id = topo.solid(fused).unwrap().outer_shell();
let face_count = topo.shell(shell_id).unwrap().faces().len();
assert!(
face_count <= 10,
"shared-face fuse should have at most 10 faces, got {face_count}"
);
}
#[test]
fn fuse_adjacent_boxes_with_unify() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(1.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, b, &mat).unwrap();
let opts = BooleanOptions {
unify_faces: true,
..Default::default()
};
let fused = boolean_with_options(&mut topo, BooleanOp::Fuse, a, b, opts).unwrap();
let vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
assert!(
(vol - 2.0).abs() < 0.02,
"unified fuse volume: {vol} (expected 2.0)"
);
let shell_id = topo.solid(fused).unwrap().outer_shell();
let face_count = topo.shell(shell_id).unwrap().faces().len();
assert!(
face_count <= 10,
"unified fuse should have at most 10 faces, got {face_count}"
);
}
#[test]
fn test_boolean_heal_after_boolean_option() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(1.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, b, &mat).unwrap();
let opts = BooleanOptions {
heal_after_boolean: true,
..Default::default()
};
let fused = boolean_with_options(&mut topo, BooleanOp::Fuse, a, b, opts).unwrap();
let vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
assert!(
(vol - 2.0).abs() < 0.02,
"healed fuse volume: {vol} (expected 2.0)"
);
crate::validate::validate_solid(&topo, fused).unwrap();
}
#[test]
fn fuse_adjacent_boxes_3x1_grid() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let c = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let mat_b = brepkit_math::mat::Mat4::translation(1.0, 0.0, 0.0);
let mat_c = brepkit_math::mat::Mat4::translation(2.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, b, &mat_b).unwrap();
crate::transform::transform_solid(&mut topo, c, &mat_c).unwrap();
let cid = topo.add_compound(brepkit_topology::compound::Compound::new(vec![a, b, c]));
let fused = crate::compound_ops::fuse_all(&mut topo, cid).unwrap();
let vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
assert!(
(vol - 3.0).abs() < 0.03,
"3×1 grid fuse volume: {vol} (expected 3.0)"
);
}
#[test]
fn near_tolerance_overlap() {
let mut topo = Topology::new();
let tol = brepkit_math::tolerance::Tolerance::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 1.0 - tol.linear, 0.0, 0.0);
let _result = boolean(&mut topo, BooleanOp::Fuse, a, b);
}
#[test]
fn boolean_nearly_touching() {
let mut topo = Topology::new();
let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
let b = make_unit_cube_manifold_at(&mut topo, 1.0 + 1e-9, 0.0, 0.0);
let _result = boolean(&mut topo, BooleanOp::Fuse, a, b);
}
#[test]
fn compound_cut_empty_tools_returns_target() {
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let result = compound_cut(&mut topo, target, &[], BooleanOptions::default()).unwrap();
assert_eq!(result, target);
}
#[test]
fn compound_cut_single_tool_matches_boolean() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let cyl = crate::primitives::make_cylinder(&mut topo, 0.5, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, cyl, &Mat4::translation(1.0, 1.0, 0.0)).unwrap();
let result = compound_cut(&mut topo, target, &[cyl], BooleanOptions::default()).unwrap();
let box_vol = 8.0;
let cyl_vol = std::f64::consts::PI * 0.25 * 2.0;
assert_volume_near(&topo, result, box_vol - cyl_vol, 0.05);
}
#[test]
fn compound_cut_two_disjoint_cylinders() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 4.0, 4.0, 2.0).unwrap();
let c1 = crate::primitives::make_cylinder(&mut topo, 0.3, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, c1, &Mat4::translation(1.0, 1.0, 0.0)).unwrap();
let c2 = crate::primitives::make_cylinder(&mut topo, 0.3, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, c2, &Mat4::translation(3.0, 3.0, 0.0)).unwrap();
let result = compound_cut(&mut topo, target, &[c1, c2], BooleanOptions::default()).unwrap();
let box_vol = 32.0;
let cyl_vol = std::f64::consts::PI * 0.09 * 2.0;
assert_volume_near(&topo, result, box_vol - 2.0 * cyl_vol, 0.05);
}
#[test]
fn compound_cut_all_tools_disjoint_returns_unchanged_volume() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let c1 = crate::primitives::make_cylinder(&mut topo, 0.3, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, c1, &Mat4::translation(10.0, 0.0, 0.0)).unwrap();
let c2 = crate::primitives::make_cylinder(&mut topo, 0.3, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, c2, &Mat4::translation(-10.0, 0.0, 0.0)).unwrap();
let result = compound_cut(&mut topo, target, &[c1, c2], BooleanOptions::default()).unwrap();
assert_volume_near(&topo, result, 8.0, 0.001);
}
#[test]
fn compound_cut_matches_sequential_2x2_grid() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 4.0, 4.0, 2.0).unwrap();
let r = 0.3;
let spacing = 2.0;
let mut tools = Vec::new();
for row in 0..2 {
for col in 0..2 {
#[allow(clippy::cast_precision_loss)]
let x = 1.0 + (col as f64) * spacing;
#[allow(clippy::cast_precision_loss)]
let y = 1.0 + (row as f64) * spacing;
let c = crate::primitives::make_cylinder(&mut topo, r, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, c, &Mat4::translation(x, y, 0.0)).unwrap();
tools.push(c);
}
}
let mut seq_target = crate::primitives::make_box(&mut topo, 4.0, 4.0, 2.0).unwrap();
for &tool in &tools {
let tool_copy = crate::copy::copy_solid(&mut topo, tool).unwrap();
seq_target = boolean_with_options(
&mut topo,
BooleanOp::Cut,
seq_target,
tool_copy,
BooleanOptions::default(),
)
.unwrap();
}
let seq_vol = crate::measure::solid_volume(&topo, seq_target, 0.05).unwrap();
let result = compound_cut(&mut topo, target, &tools, BooleanOptions::default()).unwrap();
assert!(
is_closed_manifold(&topo, result).expect("is_closed_manifold query failed"),
"compound_cut result must be a closed manifold solid"
);
let compound_vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
let expected = 2.0f64.mul_add(4.0 * 4.0, -(4.0 * std::f64::consts::PI * r * r * 2.0));
let seq_rel = (seq_vol - expected).abs() / expected;
assert!(
seq_rel < 0.01,
"sequential volume {seq_vol:.4} should be within 1% of {expected:.4} (rel={seq_rel:.4})"
);
let rel = (compound_vol - expected).abs() / expected;
assert!(
rel < 0.01,
"compound_cut volume {compound_vol:.4} should be within 1% of {expected:.4} (rel={rel:.4})"
);
}
#[test]
fn compound_cut_matches_sequential_3x3_grid() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 10.0, 10.0, 2.0).unwrap();
let r = 0.5;
let mut tools = Vec::new();
for row in 0..3 {
for col in 0..3 {
#[allow(clippy::cast_precision_loss)]
let x = 2.0 + (col as f64) * 3.0;
#[allow(clippy::cast_precision_loss)]
let y = 2.0 + (row as f64) * 3.0;
let c = crate::primitives::make_cylinder(&mut topo, r, 4.0).unwrap();
crate::transform::transform_solid(&mut topo, c, &Mat4::translation(x, y, -1.0))
.unwrap();
tools.push(c);
}
}
let mut seq_topo = topo.clone();
let mut seq_target = target;
for &tool in &tools {
let tool_copy = crate::copy::copy_solid(&mut seq_topo, tool).unwrap();
seq_target = boolean_with_options(
&mut seq_topo,
BooleanOp::Cut,
seq_target,
tool_copy,
BooleanOptions::default(),
)
.unwrap();
}
let seq_vol = crate::measure::solid_volume(&seq_topo, seq_target, 0.05).unwrap();
let result = compound_cut(&mut topo, target, &tools, BooleanOptions::default()).unwrap();
assert!(
is_closed_manifold(&topo, result).expect("is_closed_manifold query failed"),
"compound_cut result must be a closed manifold solid"
);
let compound_vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
#[allow(clippy::cast_precision_loss)]
let n_tools = tools.len() as f64;
let expected = 2.0f64.mul_add(10.0 * 10.0, -(n_tools * std::f64::consts::PI * r * r * 2.0));
let seq_rel = (seq_vol - expected).abs() / expected;
assert!(
seq_rel < 0.01,
"sequential volume {seq_vol:.4} should be within 1% of {expected:.4} (rel={seq_rel:.4})"
);
let rel = (compound_vol - expected).abs() / expected;
assert!(
rel < 0.01,
"compound_cut volume {compound_vol:.4} should be within 1% of {expected:.4} (rel={rel:.4})"
);
let agree = (compound_vol - seq_vol).abs() / expected;
assert!(
agree < 0.01,
"compound {compound_vol:.4} and sequential {seq_vol:.4} should agree within 1% (rel={agree:.4})"
);
}
#[test]
fn compound_cut_matches_sequential_4x4_grid() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 20.0, 20.0, 2.0).unwrap();
let r = 0.5;
let mut tools = Vec::new();
for row in 0..4 {
for col in 0..4 {
#[allow(clippy::cast_precision_loss)]
let x = 2.0 + (col as f64) * 4.0;
#[allow(clippy::cast_precision_loss)]
let y = 2.0 + (row as f64) * 4.0;
let c = crate::primitives::make_cylinder(&mut topo, r, 4.0).unwrap();
crate::transform::transform_solid(&mut topo, c, &Mat4::translation(x, y, -1.0))
.unwrap();
tools.push(c);
}
}
let mut seq_topo = topo.clone();
let mut seq_target = target;
for &tool in &tools {
let tool_copy = crate::copy::copy_solid(&mut seq_topo, tool).unwrap();
seq_target = boolean_with_options(
&mut seq_topo,
BooleanOp::Cut,
seq_target,
tool_copy,
BooleanOptions::default(),
)
.unwrap();
}
let seq_vol = crate::measure::solid_volume(&seq_topo, seq_target, 0.05).unwrap();
let result = compound_cut(&mut topo, target, &tools, BooleanOptions::default()).unwrap();
assert!(
is_closed_manifold(&topo, result).expect("is_closed_manifold query failed"),
"compound_cut result must be a closed manifold solid"
);
let compound_vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
#[allow(clippy::cast_precision_loss)]
let n_tools = tools.len() as f64;
let expected = 2.0f64.mul_add(20.0 * 20.0, -(n_tools * std::f64::consts::PI * r * r * 2.0));
let seq_rel = (seq_vol - expected).abs() / expected;
assert!(
seq_rel < 0.01,
"sequential volume {seq_vol:.4} should be within 1% of {expected:.4} (rel={seq_rel:.4})"
);
let rel = (compound_vol - expected).abs() / expected;
assert!(
rel < 0.01,
"compound_cut volume {compound_vol:.4} should be within 1% of {expected:.4} (rel={rel:.4})"
);
let agree = (compound_vol - seq_vol).abs() / expected;
assert!(
agree < 0.01,
"compound {compound_vol:.4} and sequential {seq_vol:.4} should agree within 1% (rel={agree:.4})"
);
}
#[test]
fn compound_cut_shelled_target_many_tools() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let opts = BooleanOptions {
unify_faces: false,
..Default::default()
};
let target = crate::primitives::make_box(&mut topo, 40.0, 40.0, 10.0).unwrap();
let inner_box = crate::primitives::make_box(&mut topo, 36.0, 36.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, inner_box, &Mat4::translation(2.0, 2.0, 2.0))
.unwrap();
let target = boolean_with_options(&mut topo, BooleanOp::Cut, target, inner_box, opts).unwrap();
let mut tools = Vec::new();
for row in 0..5 {
for col in 0..5 {
#[allow(clippy::cast_precision_loss)]
let x = 4.0 + (col as f64) * 7.0;
#[allow(clippy::cast_precision_loss)]
let y = 4.0 + (row as f64) * 7.0;
let tool = crate::primitives::make_box(&mut topo, 3.0, 3.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, tool, &Mat4::translation(x, y, -5.0))
.unwrap();
tools.push(tool);
}
}
let mut seq_topo = topo.clone();
let mut seq_result = target;
let t0 = std::time::Instant::now();
for &tool in &tools {
let tool_copy = crate::copy::copy_solid(&mut seq_topo, tool).unwrap();
seq_result =
boolean_with_options(&mut seq_topo, BooleanOp::Cut, seq_result, tool_copy, opts)
.unwrap();
}
let dt_seq = t0.elapsed();
let seq_vol = crate::measure::solid_volume(&seq_topo, seq_result, 0.05).unwrap();
let t0 = std::time::Instant::now();
let result = compound_cut(&mut topo, target, &tools, opts).unwrap();
let dt_compound = t0.elapsed();
assert!(
is_closed_manifold(&topo, result).expect("is_closed_manifold query failed"),
"compound_cut shelled result must be a closed manifold solid"
);
let compound_vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
let rel = (compound_vol - seq_vol).abs() / seq_vol;
eprintln!(
"shelled target + 25 tools: compound={:.1}ms (vol={compound_vol:.1}), sequential={:.1}ms (vol={seq_vol:.1}), rel={rel:.4}",
dt_compound.as_secs_f64() * 1000.0,
dt_seq.as_secs_f64() * 1000.0,
);
assert!(
rel < 0.05,
"compound_cut volume {compound_vol:.1} != sequential {seq_vol:.1} (rel={rel:.4})"
);
}
#[test]
fn compound_cut_shelled_target_9_tools() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let opts = BooleanOptions {
unify_faces: false,
..Default::default()
};
let target = crate::primitives::make_box(&mut topo, 40.0, 40.0, 10.0).unwrap();
let inner_box = crate::primitives::make_box(&mut topo, 36.0, 36.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, inner_box, &Mat4::translation(2.0, 2.0, 2.0))
.unwrap();
let target = boolean_with_options(&mut topo, BooleanOp::Cut, target, inner_box, opts).unwrap();
let mut tools = Vec::new();
for row in 0..3 {
for col in 0..3 {
#[allow(clippy::cast_precision_loss)]
let x = 8.0 + (col as f64) * 12.0;
#[allow(clippy::cast_precision_loss)]
let y = 8.0 + (row as f64) * 12.0;
let tool = crate::primitives::make_box(&mut topo, 3.0, 3.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, tool, &Mat4::translation(x, y, -5.0))
.unwrap();
tools.push(tool);
}
}
let mut seq_topo = topo.clone();
let mut seq_result = target;
for &tool in &tools {
let tool_copy = crate::copy::copy_solid(&mut seq_topo, tool).unwrap();
seq_result =
boolean_with_options(&mut seq_topo, BooleanOp::Cut, seq_result, tool_copy, opts)
.unwrap();
}
let seq_vol = crate::measure::solid_volume(&seq_topo, seq_result, 0.05).unwrap();
let result = compound_cut(&mut topo, target, &tools, opts).unwrap();
assert!(
is_closed_manifold(&topo, result).expect("is_closed_manifold query failed"),
"compound_cut shelled result must be a closed manifold solid"
);
let compound_vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
compound_vol > seq_vol * 0.1,
"compound_cut produced near-zero volume ({compound_vol:.4}); \
expected ~{seq_vol:.4}"
);
let rel = (compound_vol - seq_vol).abs() / seq_vol;
assert!(
rel < 2.0,
"compound={compound_vol:.4} != seq={seq_vol:.4} (rel={rel:.4})"
);
}
#[test]
fn fuse_ring_inside_shelled_box() {
let mut topo = Topology::new();
let outer = 10.0;
let height = 10.0;
let wall = 1.0;
let box_solid = crate::primitives::make_box(&mut topo, outer, outer, height).unwrap();
let top_faces: Vec<brepkit_topology::face::FaceId> = {
let s = topo.solid(box_solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let tol = brepkit_math::tolerance::Tolerance::loose();
sh.faces()
.iter()
.filter(|&&fid| {
if let Ok(f) = topo.face(fid)
&& let brepkit_topology::face::FaceSurface::Plane { normal, .. } = f.surface()
{
return tol.approx_eq(normal.z(), 1.0);
}
false
})
.copied()
.collect()
};
assert_eq!(top_faces.len(), 1, "should find exactly one +Z face");
let shelled = crate::shell_op::shell(&mut topo, box_solid, wall, &top_faces).unwrap();
let shell_vol = crate::measure::solid_volume(&topo, shelled, 0.01).unwrap();
let ring_outer = crate::primitives::make_box(&mut topo, outer - 4.0, outer - 4.0, 3.0).unwrap();
crate::transform::transform_solid(
&mut topo,
ring_outer,
&brepkit_math::mat::Mat4::translation(2.0, 2.0, 7.0),
)
.unwrap();
let ring_inner = crate::primitives::make_box(&mut topo, outer - 8.0, outer - 8.0, 3.0).unwrap();
crate::transform::transform_solid(
&mut topo,
ring_inner,
&brepkit_math::mat::Mat4::translation(4.0, 4.0, 7.0),
)
.unwrap();
let ring = boolean(&mut topo, BooleanOp::Cut, ring_outer, ring_inner).unwrap();
let ring_vol = crate::measure::solid_volume(&topo, ring, 0.01).unwrap();
let expected = shell_vol + ring_vol;
let fused = boolean(&mut topo, BooleanOp::Fuse, shelled, ring).unwrap();
let fused_vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
let rel_err = (fused_vol - expected).abs() / expected;
assert!(
rel_err < 0.25,
"fuse ring inside shelled box: vol={fused_vol:.1} expected={expected:.1} \
(shell={shell_vol:.1}, ring={ring_vol:.1}, rel_err={rel_err:.3})"
);
}
#[test]
fn fuse_ring_inside_shelled_cylinder() {
let mut topo = Topology::new();
let r = 10.0;
let h = 16.0;
let wall = 1.2;
let cyl = crate::primitives::make_cylinder(&mut topo, r, h).unwrap();
let top_faces: Vec<brepkit_topology::face::FaceId> = {
let s = topo.solid(cyl).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let tol = brepkit_math::tolerance::Tolerance::loose();
sh.faces()
.iter()
.filter(|&&fid| {
if let Ok(f) = topo.face(fid)
&& let brepkit_topology::face::FaceSurface::Plane { normal, .. } = f.surface()
{
return tol.approx_eq(normal.z(), 1.0);
}
false
})
.copied()
.collect()
};
let shelled = crate::shell_op::shell(&mut topo, cyl, wall, &top_faces).unwrap();
let shell_vol = crate::measure::solid_volume(&topo, shelled, 0.01).unwrap();
let ring_outer = crate::primitives::make_cylinder(&mut topo, 7.0, 3.0).unwrap();
crate::transform::transform_solid(
&mut topo,
ring_outer,
&brepkit_math::mat::Mat4::translation(0.0, 0.0, h - 3.0),
)
.unwrap();
let ring_inner = crate::primitives::make_cylinder(&mut topo, 5.0, 3.0).unwrap();
crate::transform::transform_solid(
&mut topo,
ring_inner,
&brepkit_math::mat::Mat4::translation(0.0, 0.0, h - 3.0),
)
.unwrap();
let ring = boolean(&mut topo, BooleanOp::Cut, ring_outer, ring_inner).unwrap();
let ring_vol = crate::measure::solid_volume(&topo, ring, 0.01).unwrap();
let expected = shell_vol + ring_vol;
let fused = boolean(&mut topo, BooleanOp::Fuse, shelled, ring).unwrap();
let fused_vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
let rel_err = (fused_vol - expected).abs() / expected;
assert!(
rel_err < 0.35,
"fuse ring inside shelled cylinder: vol={fused_vol:.1} expected={expected:.1} \
(shell={shell_vol:.1}, ring={ring_vol:.1}, rel_err={rel_err:.3})"
);
}
#[test]
fn fuse_ring_overlapping_shelled_box_height() {
let mut topo = Topology::new();
let outer = 20.0;
let h = 16.0;
let wall = 1.2;
let box_solid = crate::primitives::make_box(&mut topo, outer, outer, h).unwrap();
let top_faces: Vec<brepkit_topology::face::FaceId> = {
let s = topo.solid(box_solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let tol = brepkit_math::tolerance::Tolerance::loose();
sh.faces()
.iter()
.filter(|&&fid| {
if let Ok(f) = topo.face(fid)
&& let brepkit_topology::face::FaceSurface::Plane { normal, .. } = f.surface()
{
return tol.approx_eq(normal.z(), 1.0);
}
false
})
.copied()
.collect()
};
let shelled = crate::shell_op::shell(&mut topo, box_solid, wall, &top_faces).unwrap();
let shell_vol = crate::measure::solid_volume(&topo, shelled, 0.01).unwrap();
let ring_outer_w = outer - 6.0;
let ring_inner_w = outer - 10.0;
let ring_h = 5.0;
let ring_z = h - 2.0;
let ring_o =
crate::primitives::make_box(&mut topo, ring_outer_w, ring_outer_w, ring_h).unwrap();
crate::transform::transform_solid(
&mut topo,
ring_o,
&brepkit_math::mat::Mat4::translation(3.0, 3.0, ring_z),
)
.unwrap();
let ring_i =
crate::primitives::make_box(&mut topo, ring_inner_w, ring_inner_w, ring_h).unwrap();
crate::transform::transform_solid(
&mut topo,
ring_i,
&brepkit_math::mat::Mat4::translation(5.0, 5.0, ring_z),
)
.unwrap();
let ring = boolean(&mut topo, BooleanOp::Cut, ring_o, ring_i).unwrap();
let ring_vol = crate::measure::solid_volume(&topo, ring, 0.01).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, shelled, ring).unwrap();
let fused_vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
let min_expected = shell_vol + ring_vol * 0.5;
assert!(
fused_vol >= min_expected,
"fuse ring overlapping shell: vol={fused_vol:.1}, min_expected={min_expected:.1} \
(shell={shell_vol:.1}, ring={ring_vol:.1})"
);
assert!(
fused_vol <= (shell_vol + ring_vol) * 2.0,
"fuse ring overlapping shell: vol={fused_vol:.1} > 2x sum={:.1}",
(shell_vol + ring_vol) * 2.0
);
}
#[test]
fn cut_lofted_frustums_consistent_normals() {
use crate::copy::copy_solid;
use crate::loft::loft;
use crate::transform::transform_solid;
#[allow(clippy::cast_precision_loss)]
fn make_rounded_rect_profile(
topo: &mut Topology,
hw: f64,
hd: f64,
r: f64,
z: f64,
nq: usize,
) -> FaceId {
let tol_val = 1e-7;
let r = r.min(hw.min(hd));
let mut pts = Vec::new();
pts.push(Point3::new(-hw + r, -hd, z));
pts.push(Point3::new(hw - r, -hd, z));
for i in 0..nq {
let a = -std::f64::consts::FRAC_PI_2
+ std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(hw - r + r * a.cos(), -hd + r + r * a.sin(), z));
}
pts.push(Point3::new(hw, hd - r, z));
for i in 0..nq {
let a = std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(hw - r + r * a.cos(), hd - r + r * a.sin(), z));
}
pts.push(Point3::new(-hw + r, hd, z));
for i in 0..nq {
let a = std::f64::consts::FRAC_PI_2
+ std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(-hw + r + r * a.cos(), hd - r + r * a.sin(), z));
}
pts.push(Point3::new(-hw, -hd + r, z));
for i in 0..nq {
let a =
std::f64::consts::PI + std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(-hw + r + r * a.cos(), -hd + r + r * a.sin(), z));
}
let n = pts.len();
let vids: Vec<_> = pts
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, tol_val)))
.collect();
let eids: Vec<_> = (0..n)
.map(|i| topo.add_edge(Edge::new(vids[i], vids[(i + 1) % n], EdgeCurve::Line)))
.collect();
let wire = Wire::new(
eids.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: z,
},
))
}
let mut topo = Topology::new();
let zs = [-1.2, 0.0, 0.7, 2.5, 4.4];
let outer_insets = [2.6, 2.6, 1.9, 1.9, 0.0];
let wall = 2.6;
let base_hw = 62.25; let base_hd = 62.25;
let corner_r = 3.75;
let nq = 8;
let outer_profiles: Vec<FaceId> = zs
.iter()
.zip(outer_insets.iter())
.map(|(&z, &inset)| {
let hw = base_hw - inset;
let hd = base_hd - inset;
let r = f64::max(corner_r - inset, 0.1);
make_rounded_rect_profile(&mut topo, hw, hd, r, z, nq)
})
.collect();
let outer = loft(&mut topo, &outer_profiles).unwrap();
let inner_profiles: Vec<FaceId> = zs
.iter()
.zip(outer_insets.iter())
.map(|(&z, &inset)| {
let hw = base_hw - inset - wall;
let hd = base_hd - inset - wall;
let r = (corner_r - inset - wall).max(0.1);
make_rounded_rect_profile(&mut topo, hw, hd, r, z, nq)
})
.collect();
let inner = loft(&mut topo, &inner_profiles).unwrap();
let outer_vol = crate::measure::solid_volume(&topo, outer, 0.01).unwrap();
let inner_vol = crate::measure::solid_volume(&topo, inner, 0.01).unwrap();
assert!(outer_vol > 0.0, "outer vol={outer_vol}");
assert!(inner_vol > 0.0, "inner vol={inner_vol}");
let lip = boolean(&mut topo, BooleanOp::Cut, outer, inner).unwrap();
let lip_vol = crate::measure::solid_volume(&topo, lip, 0.01).unwrap();
let expected = outer_vol - inner_vol;
eprintln!(
"outer={outer_vol:.1}, inner={inner_vol:.1}, \
expected_lip={expected:.1}, actual_lip={lip_vol:.1}"
);
assert!(
lip_vol > 0.0,
"lip volume should be positive, got {lip_vol}"
);
assert!(
(lip_vol - expected).abs() / expected < 0.10,
"lip volume {lip_vol:.1} should be ~{expected:.1}"
);
let lip_up = copy_solid(&mut topo, lip).unwrap();
let mat = brepkit_math::mat::Mat4::translation(0.0, 0.0, 100.0);
transform_solid(&mut topo, lip_up, &mat).unwrap();
let lip_up_vol = crate::measure::solid_volume(&topo, lip_up, 0.01).unwrap();
eprintln!("lip@origin={lip_vol:.1}, lip@z100={lip_up_vol:.1}");
assert!(
(lip_up_vol - lip_vol).abs() / lip_vol.max(1.0) < 0.05,
"lip volume not translation-invariant: origin={lip_vol:.1}, z100={lip_up_vol:.1}"
);
let faces = brepkit_topology::explorer::solid_faces(&topo, lip).unwrap();
let mut per_face_signed = 0.0_f64;
#[allow(unused_assignments)]
let mut per_face_abs = 0.0_f64;
let mut face_tris = 0;
for &fid in &faces {
let mesh = crate::tessellate::tessellate(&topo, fid, 0.01).unwrap();
let tri_count = mesh.indices.len() / 3;
face_tris += tri_count;
for t in 0..tri_count {
let p0 = mesh.positions[mesh.indices[t * 3] as usize];
let p1 = mesh.positions[mesh.indices[t * 3 + 1] as usize];
let p2 = mesh.positions[mesh.indices[t * 3 + 2] as usize];
let a = Vec3::new(p0.x(), p0.y(), p0.z());
let b = Vec3::new(p1.x(), p1.y(), p1.z());
let c = Vec3::new(p2.x(), p2.y(), p2.z());
per_face_signed += a.dot(b.cross(c));
}
}
per_face_signed /= 6.0;
per_face_abs = per_face_signed.abs();
eprintln!(
"per-face tess: faces={}, tris={face_tris}, signed={per_face_signed:.1}, abs={per_face_abs:.1}",
faces.len()
);
assert!(
(per_face_abs - lip_vol).abs() / lip_vol.max(1.0) < 0.10,
"per-face volume {per_face_abs:.1} != watertight volume {lip_vol:.1}"
);
let faces_up = brepkit_topology::explorer::solid_faces(&topo, lip_up).unwrap();
let mut per_face_signed_up = 0.0_f64;
for &fid in &faces_up {
let mesh = crate::tessellate::tessellate(&topo, fid, 0.01).unwrap();
let tri_count = mesh.indices.len() / 3;
for t in 0..tri_count {
let p0 = mesh.positions[mesh.indices[t * 3] as usize];
let p1 = mesh.positions[mesh.indices[t * 3 + 1] as usize];
let p2 = mesh.positions[mesh.indices[t * 3 + 2] as usize];
let a = Vec3::new(p0.x(), p0.y(), p0.z());
let b = Vec3::new(p1.x(), p1.y(), p1.z());
let c = Vec3::new(p2.x(), p2.y(), p2.z());
per_face_signed_up += a.dot(b.cross(c));
}
}
per_face_signed_up /= 6.0;
let per_face_abs_up = per_face_signed_up.abs();
eprintln!("per-face @z100: signed={per_face_signed_up:.1}, abs={per_face_abs_up:.1}");
assert!(
(per_face_abs_up - per_face_abs).abs() / per_face_abs.max(1.0) < 0.05,
"per-face volume not translation-invariant: origin={per_face_abs:.1}, z100={per_face_abs_up:.1}"
);
}
#[test]
fn cut_lofted_frustums_octagon_profiles() {
use crate::copy::copy_solid;
use crate::loft::loft;
use crate::transform::transform_solid;
fn make_octagon_profile(topo: &mut Topology, hw: f64, hd: f64, r: f64, z: f64) -> FaceId {
let tol_val = 1e-7;
let pts = [
Point3::new(-hw + r, -hd, z),
Point3::new(-hw, -hd + r, z),
Point3::new(-hw, hd - r, z),
Point3::new(-hw + r, hd, z),
Point3::new(hw - r, hd, z),
Point3::new(hw, hd - r, z),
Point3::new(hw, -hd + r, z),
Point3::new(hw - r, -hd, z),
];
let n = pts.len();
let vids: Vec<_> = pts
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, tol_val)))
.collect();
let eids: Vec<_> = (0..n)
.map(|i| topo.add_edge(Edge::new(vids[i], vids[(i + 1) % n], EdgeCurve::Line)))
.collect();
let wire = Wire::new(
eids.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: z,
},
))
}
let mut topo = Topology::new();
let zs = [-1.2, 0.0, 0.7, 2.5, 4.4];
let outer_insets = [2.6, 2.6, 1.9, 1.9, 0.0];
let wall = 2.6;
let base_hw = 62.75; let base_hd = 62.75;
let corner_r = 3.75;
let outer_profiles: Vec<FaceId> = zs
.iter()
.zip(outer_insets.iter())
.map(|(&z, &inset)| {
let hw = base_hw - inset;
let hd = base_hd - inset;
let r = f64::max(corner_r - inset, 0.1);
make_octagon_profile(&mut topo, hw, hd, r, z)
})
.collect();
let outer = loft(&mut topo, &outer_profiles).unwrap();
let inner_profiles: Vec<FaceId> = zs
.iter()
.zip(outer_insets.iter())
.map(|(&z, &inset)| {
let hw = base_hw - inset - wall;
let hd = base_hd - inset - wall;
let r = f64::max(corner_r - inset - wall, 0.1);
make_octagon_profile(&mut topo, hw, hd, r, z)
})
.collect();
let inner = loft(&mut topo, &inner_profiles).unwrap();
let outer_vol = crate::measure::solid_volume(&topo, outer, 0.01).unwrap();
let inner_vol = crate::measure::solid_volume(&topo, inner, 0.01).unwrap();
let lip = boolean(&mut topo, BooleanOp::Cut, outer, inner).unwrap();
let lip_vol = crate::measure::solid_volume(&topo, lip, 0.01).unwrap();
let expected = outer_vol - inner_vol;
assert!(
lip_vol > 0.0,
"lip volume should be positive, got {lip_vol}"
);
let lip_up = copy_solid(&mut topo, lip).unwrap();
let mat = brepkit_math::mat::Mat4::translation(0.0, 0.0, 16.0);
transform_solid(&mut topo, lip_up, &mat).unwrap();
let lip_up_vol = crate::measure::solid_volume(&topo, lip_up, 0.01).unwrap();
assert!(
(lip_up_vol - lip_vol).abs() / lip_vol.max(1.0) < 0.05,
"octagon lip not translation-invariant: origin={lip_vol:.1}, z16={lip_up_vol:.1} \
(outer={outer_vol:.1}, inner={inner_vol:.1}, expected={expected:.1})"
);
}
#[test]
fn test_boolean_concave_face_chord_clip() {
let mut topo = Topology::new();
let box_a = crate::primitives::make_box(&mut topo, 2.0, 1.0, 1.0).unwrap();
let box_b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let translate = brepkit_math::mat::Mat4::translation(0.0, 1.0, 0.0);
crate::transform::transform_solid(&mut topo, box_b, &translate).unwrap();
let no_unify = BooleanOptions {
unify_faces: false,
..Default::default()
};
let l_shape = boolean_with_options(&mut topo, BooleanOp::Fuse, box_a, box_b, no_unify).unwrap();
assert_volume_near(&topo, l_shape, 3.0, 0.001);
let slab = crate::primitives::make_box(&mut topo, 1.0, 1.0, 2.0).unwrap();
let slab_translate = brepkit_math::mat::Mat4::translation(0.5, 0.5, -0.5);
crate::transform::transform_solid(&mut topo, slab, &slab_translate).unwrap();
let result = boolean_with_options(&mut topo, BooleanOp::Cut, l_shape, slab, no_unify).unwrap();
assert_volume_near(&topo, result, 2.25, 0.001);
}
#[test]
fn test_boolean_convex_face_chord_clip_regression() {
let mut topo = Topology::new();
let base = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let tool = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let translate = brepkit_math::mat::Mat4::translation(1.5, 0.5, 0.5);
crate::transform::transform_solid(&mut topo, tool, &translate).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, base, tool).unwrap();
assert_volume_near(&topo, result, 7.5, 0.001);
}
#[test]
fn test_boolean_large_scale_vertex_merge() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 100.0, 100.0, 100.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 100.0, 100.0, 100.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(50.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, b, &mat).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
assert!(
faces.len() >= 6 && faces.len() < 100,
"expected 6..100 faces for large-scale cut, got {}",
faces.len()
);
assert_volume_near(&topo, result, 500_000.0, 0.01);
}
#[test]
fn boolean_fuse_box_cylinder_positive_volume() {
let mut topo = Topology::new();
let b = crate::primitives::make_box(&mut topo, 4.0, 4.0, 4.0).unwrap();
let c = crate::primitives::make_cylinder(&mut topo, 1.0, 2.0).unwrap();
let t = brepkit_math::mat::Mat4::translation(0.0, 0.0, 1.0);
crate::transform::transform_solid(&mut topo, c, &t).unwrap();
let result = boolean(&mut topo, BooleanOp::Fuse, b, c);
assert!(result.is_ok(), "fuse should succeed: {:?}", result.err());
let result_solid = result.unwrap();
let vol = crate::measure::solid_volume(&topo, result_solid, 0.01).unwrap();
assert!(vol > 0.0, "fused solid should have positive volume: {vol}");
}
#[test]
fn boolean_fuse_overlapping_boxes_positive_volume() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let t = brepkit_math::mat::Mat4::translation(1.0, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, b, &t).unwrap();
let result = boolean(&mut topo, BooleanOp::Fuse, a, b);
assert!(result.is_ok(), "fuse should succeed: {:?}", result.err());
let vol = crate::measure::solid_volume(&topo, result.unwrap(), 0.01).unwrap();
assert!(
vol > 0.0,
"fused overlapping boxes should have positive volume: {vol}"
);
}
#[test]
fn compound_cut_sequential_reduces_volume() {
let mut topo = Topology::new();
let target = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let original_vol = crate::measure::solid_volume(&topo, target, 0.01).unwrap();
let mut tools = Vec::new();
for i in 0..5 {
let cyl = crate::primitives::make_cylinder(&mut topo, 0.5, 12.0).unwrap();
let offset = 2.0 * (i as f64) + 1.0;
let t = brepkit_math::mat::Mat4::translation(offset, 5.0, 0.0);
crate::transform::transform_solid(&mut topo, cyl, &t).unwrap();
tools.push(cyl);
}
let result = compound_cut(&mut topo, target, &tools, BooleanOptions::default());
assert!(
result.is_ok(),
"compound_cut with 5 tools should succeed: {:?}",
result.err()
);
let result_id = result.unwrap();
let vol = crate::measure::solid_volume(&topo, result_id, 0.01).unwrap();
assert!(
vol > 0.0 && vol < original_vol,
"volume should decrease: original={original_vol}, result={vol}"
);
let s = topo.solid(result_id).unwrap();
let shell = topo.shell(s.outer_shell()).unwrap();
let face_count = shell.faces().len();
assert!(
face_count < 500,
"face count should be bounded: got {face_count}"
);
}
#[test]
fn euler_characteristic_box_is_two() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let euler = crate::validate::euler_characteristic(&topo, solid).unwrap();
assert_eq!(euler, 2, "box Euler V-E+F should be 2, got {euler}");
}
#[test]
fn sequential_boolean_face_count_bounded() {
let mut topo = Topology::new();
let mut result = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
for i in 1..5 {
let next = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(i as f64, 0.0, 0.0);
crate::transform::transform_solid(&mut topo, next, &mat).unwrap();
result = boolean(&mut topo, BooleanOp::Fuse, result, next).unwrap();
}
let face_count = check_result(&topo, result);
assert!(
face_count < 50,
"sequential fuse of 5 boxes should have < 50 faces, got {face_count}"
);
let euler = crate::validate::euler_characteristic(&topo, result).unwrap();
assert_eq!(euler, 2, "staircase Euler should be 2, got {euler}");
}
#[test]
fn sequential_cut_preserves_surface_types() {
let mut topo = Topology::new();
let base = crate::primitives::make_box(&mut topo, 10.0, 10.0, 5.0).unwrap();
let mut result = base;
for i in 0..3 {
let cyl = crate::primitives::make_cylinder(&mut topo, 1.0, 8.0).unwrap();
let offset = 2.5 + 2.5 * (i as f64);
let t = brepkit_math::mat::Mat4::translation(offset, 5.0, -1.5);
crate::transform::transform_solid(&mut topo, cyl, &t).unwrap();
result = boolean(&mut topo, BooleanOp::Cut, result, cyl).unwrap();
}
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let has_cylinder = faces
.iter()
.any(|&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cylinder(_)));
assert!(
has_cylinder,
"sequential cylinder cuts should preserve FaceSurface::Cylinder"
);
assert_volume_near(&topo, result, 500.0 - 3.0 * std::f64::consts::PI * 5.0, 0.1);
}
#[test]
fn non_convex_face_survives_subsequent_cut() {
let mut topo = Topology::new();
let box_a = crate::primitives::make_box(&mut topo, 2.0, 1.0, 1.0).unwrap();
let box_b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let t = brepkit_math::mat::Mat4::translation(0.0, 1.0, 0.0);
crate::transform::transform_solid(&mut topo, box_b, &t).unwrap();
let l_shape = boolean(&mut topo, BooleanOp::Fuse, box_a, box_b).unwrap();
assert_volume_near(&topo, l_shape, 3.0, 0.01);
let cutter = crate::primitives::make_box(&mut topo, 0.5, 0.5, 2.0).unwrap();
let t2 = brepkit_math::mat::Mat4::translation(0.75, 0.75, -0.5);
crate::transform::transform_solid(&mut topo, cutter, &t2).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, l_shape, cutter).unwrap();
assert_volume_near(&topo, result, 2.8125, 0.025);
}
#[test]
fn fuse_shelled_box_with_socket_loft() {
use brepkit_math::curves::Circle3D;
fn make_rr_profile_with_arcs(topo: &mut Topology, hw: f64, hd: f64, r: f64, z: f64) -> FaceId {
let tol_val = 1e-7;
let r = r.min(hw.min(hd));
let corner_centers = [
Point3::new(hw - r, -hd + r, z), Point3::new(hw - r, hd - r, z), Point3::new(-hw + r, hd - r, z), Point3::new(-hw + r, -hd + r, z), ];
let arc_pts = [
(Point3::new(hw - r, -hd, z), Point3::new(hw, -hd + r, z)),
(Point3::new(hw, hd - r, z), Point3::new(hw - r, hd, z)),
(Point3::new(-hw + r, hd, z), Point3::new(-hw, hd - r, z)),
(Point3::new(-hw, -hd + r, z), Point3::new(-hw + r, -hd, z)),
];
let axis = Vec3::new(0.0, 0.0, 1.0);
let mut vids = Vec::new();
let mut edges = Vec::new();
for i in 0..4 {
let (p_start, _) = arc_pts[i];
let (_, p_end) = arc_pts[i];
vids.push(topo.add_vertex(Vertex::new(p_start, tol_val)));
vids.push(topo.add_vertex(Vertex::new(p_end, tol_val)));
}
edges.push(topo.add_edge(Edge::new(vids[7], vids[0], EdgeCurve::Line)));
let br_circle = Circle3D::new(corner_centers[0], axis, r).unwrap();
edges.push(topo.add_edge(Edge::new(vids[0], vids[1], EdgeCurve::Circle(br_circle))));
edges.push(topo.add_edge(Edge::new(vids[1], vids[2], EdgeCurve::Line)));
let tr_circle = Circle3D::new(corner_centers[1], axis, r).unwrap();
edges.push(topo.add_edge(Edge::new(vids[2], vids[3], EdgeCurve::Circle(tr_circle))));
edges.push(topo.add_edge(Edge::new(vids[3], vids[4], EdgeCurve::Line)));
let tl_circle = Circle3D::new(corner_centers[2], axis, r).unwrap();
edges.push(topo.add_edge(Edge::new(vids[4], vids[5], EdgeCurve::Circle(tl_circle))));
edges.push(topo.add_edge(Edge::new(vids[5], vids[6], EdgeCurve::Line)));
let bl_circle = Circle3D::new(corner_centers[3], axis, r).unwrap();
edges.push(topo.add_edge(Edge::new(vids[6], vids[7], EdgeCurve::Circle(bl_circle))));
let wire = Wire::new(
edges
.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: z,
},
))
}
fn make_rr_profile_poly(
topo: &mut Topology,
hw: f64,
hd: f64,
r: f64,
z: f64,
nq: usize,
) -> FaceId {
let tol_val = 1e-7;
let r = r.min(hw.min(hd));
let mut pts = Vec::new();
pts.push(Point3::new(-hw + r, -hd, z));
pts.push(Point3::new(hw - r, -hd, z));
for i in 0..nq {
let a = -std::f64::consts::FRAC_PI_2
+ std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(hw - r + r * a.cos(), -hd + r + r * a.sin(), z));
}
pts.push(Point3::new(hw, hd - r, z));
for i in 0..nq {
let a = std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(hw - r + r * a.cos(), hd - r + r * a.sin(), z));
}
pts.push(Point3::new(-hw + r, hd, z));
for i in 0..nq {
let a = std::f64::consts::FRAC_PI_2
+ std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(-hw + r + r * a.cos(), hd - r + r * a.sin(), z));
}
pts.push(Point3::new(-hw, -hd + r, z));
for i in 0..nq {
let a =
std::f64::consts::PI + std::f64::consts::FRAC_PI_2 * (i as f64 + 1.0) / nq as f64;
pts.push(Point3::new(-hw + r + r * a.cos(), -hd + r + r * a.sin(), z));
}
let n = pts.len();
let vids: Vec<_> = pts
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, tol_val)))
.collect();
let eids: Vec<_> = (0..n)
.map(|i| topo.add_edge(Edge::new(vids[i], vids[(i + 1) % n], EdgeCurve::Line)))
.collect();
let wire = Wire::new(
eids.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: z,
},
))
}
let mut topo = Topology::new();
let hw: f64 = 20.75;
let hd: f64 = 20.75;
let r: f64 = 4.0;
let nq: usize = 8;
let profile = make_rr_profile_with_arcs(&mut topo, hw, hd, r, 0.0);
let box_solid =
crate::extrude::extrude(&mut topo, profile, Vec3::new(0.0, 0.0, 1.0), 16.0).unwrap();
let box_vol = crate::measure::solid_volume(&topo, box_solid, 0.01).unwrap();
eprintln!("box volume: {box_vol:.1}");
assert!(box_vol > 0.0);
let box_shell = topo
.shell(topo.solid(box_solid).unwrap().outer_shell())
.unwrap();
let cyl_count = box_shell
.faces()
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cylinder(_)))
.count();
eprintln!("box cylinder faces: {cyl_count}");
assert!(cyl_count >= 4, "box should have cylinder faces at corners");
let top_faces: Vec<FaceId> = box_shell
.faces()
.iter()
.filter(|&&fid| {
let face = topo.face(fid).unwrap();
if let FaceSurface::Plane { normal, .. } = face.surface() {
normal.z() > 0.5
} else {
false
}
})
.copied()
.collect();
assert_eq!(top_faces.len(), 1, "should have exactly 1 top face");
let shelled = crate::shell_op::shell(&mut topo, box_solid, 1.2, &top_faces).unwrap();
let shelled_vol = crate::measure::solid_volume(&topo, shelled, 0.01).unwrap();
eprintln!("shelled volume: {shelled_vol:.1}");
assert!(shelled_vol > 0.0);
let socket_top = make_rr_profile_poly(&mut topo, hw, hd, r, 0.0, nq);
let socket_bot = make_rr_profile_poly(
&mut topo,
hw - 2.0,
hd - 2.0,
(r - 2.0_f64).max(0.1),
-5.0,
nq,
);
let socket = crate::loft::loft(&mut topo, &[socket_bot, socket_top]).unwrap();
let socket_vol = crate::measure::solid_volume(&topo, socket, 0.01).unwrap();
eprintln!("socket volume: {socket_vol:.1}");
assert!(socket_vol > 0.0);
let fused = boolean(&mut topo, BooleanOp::Fuse, shelled, socket).unwrap();
let fused_shell = topo
.shell(topo.solid(fused).unwrap().outer_shell())
.unwrap();
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, fused).unwrap();
#[allow(clippy::cast_possible_wrap)]
let euler = (v as i64) - (e as i64) + (f as i64);
let fused_vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
eprintln!("fused: F={f}, E={e}, V={v}, euler={euler}, vol={fused_vol:.1}");
let val_result = brepkit_topology::validation::validate_shell_manifold(fused_shell, &topo);
let is_manifold = val_result.is_ok();
if let Err(ref issues) = val_result {
eprintln!("manifold issues: {issues:?}");
}
let fused_faces = brepkit_topology::explorer::solid_faces(&topo, fused).unwrap();
let mut edge_use: std::collections::HashMap<brepkit_topology::edge::EdgeId, usize> =
std::collections::HashMap::new();
let mut inner_wire_count: i64 = 0;
for &fid in &fused_faces {
let face = topo.face(fid).unwrap();
inner_wire_count += face.inner_wires().len() as i64;
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
for oe in topo.wire(wid).unwrap().edges() {
*edge_use.entry(oe.edge()).or_default() += 1;
}
}
}
let bad_edges = edge_use.values().filter(|&&uses| uses != 2).count();
assert_eq!(
bad_edges, 0,
"every edge should be used exactly twice (watertight manifold)"
);
assert!(
fused_faces.len() < 100,
"analytic fuse expected, got {} faces (mesh fallback?)",
fused_faces.len()
);
let cyl_faces = fused_faces
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cylinder(_)))
.count();
assert!(
cyl_faces >= 4,
"fused solid should keep the 4 corner cylinder faces, got {cyl_faces}"
);
let expected_vol = shelled_vol + socket_vol;
assert!(
(fused_vol - expected_vol).abs() < expected_vol * 0.005,
"fused volume {fused_vol:.1} should equal operand sum {expected_vol:.1}"
);
assert!(fused_vol > 0.0, "fused volume should be positive");
assert!(
euler - inner_wire_count == 2,
"fused hole-aware euler should be 2, got {} (F={f}, E={e}, V={v}, holes={inner_wire_count})",
euler - inner_wire_count
);
assert!(is_manifold, "fused solid should be manifold");
}
#[test]
fn fuse_coincident_rrect_cap_with_frustum() {
use brepkit_math::curves::Circle3D;
fn make_rr_arcs(topo: &mut Topology, hw: f64, hd: f64, r: f64, z: f64) -> FaceId {
let r = r.min(hw.min(hd));
let cc = [
Point3::new(hw - r, -hd + r, z),
Point3::new(hw - r, hd - r, z),
Point3::new(-hw + r, hd - r, z),
Point3::new(-hw + r, -hd + r, z),
];
let ap = [
(Point3::new(hw - r, -hd, z), Point3::new(hw, -hd + r, z)),
(Point3::new(hw, hd - r, z), Point3::new(hw - r, hd, z)),
(Point3::new(-hw + r, hd, z), Point3::new(-hw, hd - r, z)),
(Point3::new(-hw, -hd + r, z), Point3::new(-hw + r, -hd, z)),
];
let axis = Vec3::new(0.0, 0.0, 1.0);
let mut v = Vec::new();
for p in &ap {
v.push(topo.add_vertex(Vertex::new(p.0, 1e-7)));
v.push(topo.add_vertex(Vertex::new(p.1, 1e-7)));
}
let mut e = Vec::new();
e.push(topo.add_edge(Edge::new(v[7], v[0], EdgeCurve::Line)));
for i in 0..4 {
e.push(topo.add_edge(Edge::new(
v[2 * i],
v[2 * i + 1],
EdgeCurve::Circle(Circle3D::new(cc[i], axis, r).unwrap()),
)));
if i < 3 {
e.push(topo.add_edge(Edge::new(v[2 * i + 1], v[2 * i + 2], EdgeCurve::Line)));
}
}
let wire = Wire::new(
e.iter().map(|&id| OrientedEdge::new(id, true)).collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane { normal: axis, d: z },
))
}
let mut topo = Topology::new();
let (hw, hd, r) = (20.0, 20.0, 4.0);
let face_a = make_rr_arcs(&mut topo, hw, hd, r, 0.0);
let solid_a =
crate::extrude::extrude(&mut topo, face_a, Vec3::new(0.0, 0.0, 1.0), 10.0).unwrap();
let b_bot = make_rr_arcs(&mut topo, hw - 3.0, hd - 3.0, r - 1.0, -10.0);
let b_top = make_rr_arcs(&mut topo, hw, hd, r, 0.0);
let solid_b = crate::loft::loft(&mut topo, &[b_bot, b_top]).unwrap();
let vol_a = crate::measure::solid_volume(&topo, solid_a, 0.01).unwrap();
let vol_b = crate::measure::solid_volume(&topo, solid_b, 0.01).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, solid_a, solid_b).unwrap();
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, fused).unwrap();
#[allow(clippy::cast_possible_wrap)]
let euler = (v as i64) - (e as i64) + (f as i64);
let fused_vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
let shell = topo
.shell(topo.solid(fused).unwrap().outer_shell())
.unwrap();
let manifold = brepkit_topology::validation::validate_shell_manifold(shell, &topo);
eprintln!(
"rrect+frustum cap fuse: F={f} E={e} V={v} euler={euler} vol={fused_vol:.1} (a+b={:.1}) manifold={}",
vol_a + vol_b,
manifold.is_ok()
);
assert!(
(fused_vol - (vol_a + vol_b)).abs() < 1.0,
"fused vol {fused_vol:.1} != a+b {:.1}",
vol_a + vol_b
);
assert!(
manifold.is_ok(),
"fused solid should be manifold: {manifold:?}"
);
assert_eq!(euler, 2, "should be genus-0");
}
#[test]
fn gfa_box_sphere_cut() {
let mut topo = Topology::default();
let box_solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let sphere = crate::primitives::make_sphere(&mut topo, 0.5, 16).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, box_solid, sphere);
assert!(
result.is_ok(),
"GFA box-sphere cut should succeed: {result:?}"
);
let solid = result.unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, solid).unwrap();
assert!(
(6..=50).contains(&faces.len()),
"box-sphere cut should have 6-50 faces, got {}",
faces.len()
);
}
#[test]
fn gfa_box_cylinder_fuse() {
let mut topo = Topology::default();
let box_solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let cyl = crate::primitives::make_cylinder(&mut topo, 0.5, 2.0).unwrap();
let result = boolean(&mut topo, BooleanOp::Fuse, box_solid, cyl);
assert!(
result.is_ok(),
"GFA box-cylinder fuse should succeed: {result:?}"
);
let solid = result.unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, solid).unwrap();
assert!(
(7..=50).contains(&faces.len()),
"box-cylinder fuse should have 7-50 faces, got {}",
faces.len()
);
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
assert!(
vol > 8.0,
"fuse volume ({vol}) should exceed box volume (8.0)"
);
}
#[test]
fn gfa_box_cone_intersect() {
let mut topo = Topology::default();
let box_solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let cone = crate::primitives::make_cone(&mut topo, 1.0, 0.0, 2.0).unwrap();
let result = boolean(&mut topo, BooleanOp::Intersect, box_solid, cone);
assert!(
result.is_ok(),
"GFA box-cone intersect should succeed: {result:?}"
);
let solid = result.unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, solid).unwrap();
assert!(
(2..=30).contains(&faces.len()),
"box-cone intersect should have 2-30 faces, got {}",
faces.len()
);
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap_or(0.0);
if vol > 0.0 {
let cone_vol = std::f64::consts::PI / 3.0;
assert!(
vol < cone_vol + 0.5,
"intersect volume ({vol}) should be less than cone ({cone_vol})"
);
}
}
#[test]
fn d4_shelled_box_fuse_lip() {
let mut topo = Topology::default();
let box_solid = crate::primitives::make_box(&mut topo, 10.0, 10.0, 5.0).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, box_solid).unwrap();
let top_face = faces
.iter()
.find(|&&fid| {
let f = topo.face(fid).unwrap();
if let brepkit_topology::face::FaceSurface::Plane { normal, d } = f.surface() {
normal.z() > 0.9 && *d > 4.0
} else {
false
}
})
.copied()
.unwrap();
let shelled = crate::shell_op::shell(&mut topo, box_solid, 1.0, &[top_face]).unwrap();
let (sf, se, sv) = brepkit_topology::explorer::solid_entity_counts(&topo, shelled).unwrap();
let s_euler = sv as i64 - se as i64 + sf as i64;
eprintln!("shelled: F={sf} E={se} V={sv} euler={s_euler}");
let translate = |topo: &mut Topology, solid: SolidId, dx: f64, dy: f64, dz: f64| {
let mat = brepkit_math::mat::Mat4::translation(dx, dy, dz);
crate::transform::transform_solid(topo, solid, &mat)
};
let outer = crate::primitives::make_box(&mut topo, 12.0, 12.0, 3.0).unwrap();
translate(&mut topo, outer, 0.0, 0.0, 2.5).unwrap();
let inner = crate::primitives::make_box(&mut topo, 8.0, 8.0, 3.0).unwrap();
translate(&mut topo, inner, 0.0, 0.0, 2.5).unwrap();
let no_unify = BooleanOptions {
unify_faces: false,
..BooleanOptions::default()
};
let lip = boolean_with_options(&mut topo, BooleanOp::Cut, outer, inner, no_unify).unwrap();
let (lf, le, lv) = brepkit_topology::explorer::solid_entity_counts(&topo, lip).unwrap();
let l_euler = lv as i64 - le as i64 + lf as i64;
eprintln!("lip: F={lf} E={le} V={lv} euler={l_euler}");
let result = boolean_with_options(&mut topo, BooleanOp::Fuse, shelled, lip, no_unify);
match result {
Ok(fused) => {
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, fused).unwrap();
let euler = v as i64 - e as i64 + f as i64;
let inner_loops: i64 = {
let s = topo.solid(fused).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
sh.faces()
.iter()
.map(|&fid| topo.face(fid).unwrap().inner_wires().len() as i64)
.sum()
};
let adj = euler - inner_loops;
eprintln!(
"fused: F={f} E={e} V={v} euler={euler} inner_loops={inner_loops} adj_euler={adj}"
);
let sh = topo
.shell(topo.solid(fused).unwrap().outer_shell())
.unwrap();
let mut efc: std::collections::HashMap<usize, u32> = std::collections::HashMap::new();
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
for wid in
std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
{
for oe in topo.wire(wid).unwrap().edges() {
*efc.entry(oe.edge().index()).or_default() += 1;
}
}
}
let nm_count = efc.values().filter(|c| **c > 2).count();
let bd_count = efc.values().filter(|c| **c < 2).count();
eprintln!("non-manifold edges: {nm_count} boundary edges: {bd_count}");
let face_ids: Vec<_> = sh.faces().to_vec();
let mut face_adj: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
let mut edge_faces: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (fi, &fid) in face_ids.iter().enumerate() {
let face = topo.face(fid).unwrap();
for wid in
std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
{
for oe in topo.wire(wid).unwrap().edges() {
edge_faces.entry(oe.edge().index()).or_default().push(fi);
}
}
}
for faces_at_edge in edge_faces.values() {
for &fi in faces_at_edge {
for &fj in faces_at_edge {
if fi != fj {
face_adj.entry(fi).or_default().push(fj);
}
}
}
}
let mut visited = vec![false; face_ids.len()];
let mut components = 0u32;
for start in 0..face_ids.len() {
if visited[start] {
continue;
}
components += 1;
let mut stack = vec![start];
while let Some(fi) = stack.pop() {
if visited[fi] {
continue;
}
visited[fi] = true;
if let Some(neighbors) = face_adj.get(&fi) {
for &nfi in neighbors {
if !visited[nfi] {
stack.push(nfi);
}
}
}
}
}
eprintln!("connected components: {components}");
assert_eq!(adj, 2, "adjusted Euler should be 2, got {adj}");
}
Err(e) => panic!("fuse failed: {e}"),
}
}
#[test]
fn coplanar_box_cut_d1a2() {
let _ = env_logger::try_init();
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 0.5, 0.5, 1.0).unwrap();
let xlate = brepkit_math::mat::Mat4::translation(0.25, 0.25, 0.0);
crate::transform::transform_solid(&mut topo, b, &xlate).unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let face_count = check_result(&topo, result);
eprintln!("face count: {face_count}");
assert_volume_near(&topo, result, 0.75, 0.01);
}
fn count_nm_and_boundary_edges_696(topo: &Topology, solid: SolidId) -> (usize, usize) {
let mut edge_count: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
let faces = brepkit_topology::explorer::solid_faces(topo, solid).unwrap();
for fid in &faces {
let face = topo.face(*fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid).unwrap();
for oe in wire.edges() {
*edge_count.entry(oe.edge().index()).or_default() += 1;
}
}
}
let nm = edge_count.values().filter(|&&c| c > 2).count();
let bd = edge_count.values().filter(|&&c| c < 2).count();
(nm, bd)
}
#[test]
#[ignore = "diagnostic — prints topology degradation per step, see #696"]
#[allow(clippy::too_many_lines, clippy::items_after_statements)]
fn n_iteration_repro_dovetail_pipeline_issue_696() {
use brepkit_math::mat::Mat4;
use brepkit_topology::builder::{make_face_from_wire, make_polygon_wire};
let mut topo = Topology::new();
fn report(topo: &Topology, solid: SolidId, label: &str) {
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(topo, solid).unwrap();
#[allow(clippy::cast_possible_wrap)]
let euler = (v as i64) - (e as i64) + (f as i64);
let (nm, bd) = count_nm_and_boundary_edges_696(topo, solid);
let faces = brepkit_topology::explorer::solid_faces(topo, solid).unwrap();
let mut wire_open = 0;
for fid in &faces {
let face = topo.face(*fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
{
let wire = topo.wire(wid).unwrap();
if brepkit_topology::validation::validate_wire_closed(wire, topo).is_err() {
wire_open += 1;
}
}
}
let euler_ok = if euler == 2 { "✓" } else { "✗" };
eprintln!(
"{label:<28} F={f:>4} E={e:>4} V={v:>4} Euler={euler:>4} {euler_ok} \
NM={nm:>3} bd={bd:>3} wires_open={wire_open}"
);
}
fn make_tongue(topo: &mut Topology, wall_x: f64, bp_y: f64, protrude_dir: f64) -> SolidId {
const PROTRUSION: f64 = 1.5;
const BASE_HALF: f64 = 1.0;
const TIP_HALF: f64 = 1.3;
let p = PROTRUSION;
let bw = BASE_HALF;
let tw = TIP_HALF;
let d = protrude_dir;
let pts = vec![
Point3::new(wall_x, bp_y + bw, 0.0),
Point3::new(wall_x + d * p, bp_y + tw, 0.0),
Point3::new(wall_x + d * p, bp_y - tw, 0.0),
Point3::new(wall_x, bp_y - bw, 0.0),
];
let wire = make_polygon_wire(topo, &pts, 1e-7).unwrap();
let face = make_face_from_wire(topo, wire).unwrap();
crate::extrude::extrude(topo, face, Vec3::new(0.0, 0.0, 1.0), 8.0).unwrap()
}
eprintln!("\n=== #696 dovetail pipeline progression ===");
eprintln!("step F / E / V / Euler / NM / bd / wires_open");
let slab = crate::primitives::make_box(&mut topo, 168.0, 168.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, slab, &Mat4::translation(-84.0, -84.0, -8.0))
.unwrap();
let mut current = slab;
report(&topo, current, "0. slab");
let mut n_pockets = 0;
for row in 0..4 {
for col in 0..4 {
let pocket = crate::primitives::make_box(&mut topo, 37.0, 37.0, 6.0).unwrap();
#[allow(clippy::cast_precision_loss)]
let cx = -63.0 + (col as f64) * 42.0;
#[allow(clippy::cast_precision_loss)]
let cy = -63.0 + (row as f64) * 42.0;
crate::transform::transform_solid(
&mut topo,
pocket,
&Mat4::translation(cx - 18.5, cy - 18.5, -2.0),
)
.unwrap();
current = boolean(&mut topo, BooleanOp::Cut, current, pocket).unwrap();
n_pockets += 1;
if n_pockets == 1 || n_pockets == 2 || n_pockets % 4 == 0 {
report(
&topo,
current,
&format!("{n_pockets}. pocket cut #{n_pockets}"),
);
}
}
}
let wall_x_left = -84.0;
let wall_x_right = 84.0;
let wall_y_front = -84.0;
let wall_y_back = 84.0;
let mut step = 16;
for k in 1..=3 {
#[allow(clippy::cast_precision_loss)]
let bp = -84.0 + (k as f64) * 42.0;
let t = make_tongue(&mut topo, wall_x_left, bp, -1.0);
crate::transform::transform_solid(&mut topo, t, &Mat4::translation(0.0, 0.0, -8.0))
.unwrap();
current = boolean(&mut topo, BooleanOp::Fuse, current, t).unwrap();
step += 1;
let t = make_tongue(&mut topo, wall_x_right, bp, 1.0);
crate::transform::transform_solid(&mut topo, t, &Mat4::translation(0.0, 0.0, -8.0))
.unwrap();
current = boolean(&mut topo, BooleanOp::Fuse, current, t).unwrap();
step += 1;
let pts = vec![
Point3::new(bp + 1.0, wall_y_front - 0.0, 0.0),
Point3::new(bp + 1.3, wall_y_front - 1.5, 0.0),
Point3::new(bp - 1.3, wall_y_front - 1.5, 0.0),
Point3::new(bp - 1.0, wall_y_front - 0.0, 0.0),
];
let wire = make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let face = make_face_from_wire(&mut topo, wire).unwrap();
let t = crate::extrude::extrude(&mut topo, face, Vec3::new(0.0, 0.0, 1.0), 8.0).unwrap();
crate::transform::transform_solid(&mut topo, t, &Mat4::translation(0.0, 0.0, -8.0))
.unwrap();
current = boolean(&mut topo, BooleanOp::Fuse, current, t).unwrap();
step += 1;
let pts = vec![
Point3::new(bp - 1.0, wall_y_back + 0.0, 0.0),
Point3::new(bp - 1.3, wall_y_back + 1.5, 0.0),
Point3::new(bp + 1.3, wall_y_back + 1.5, 0.0),
Point3::new(bp + 1.0, wall_y_back + 0.0, 0.0),
];
let wire = make_polygon_wire(&mut topo, &pts, 1e-7).unwrap();
let face = make_face_from_wire(&mut topo, wire).unwrap();
let t = crate::extrude::extrude(&mut topo, face, Vec3::new(0.0, 0.0, 1.0), 8.0).unwrap();
crate::transform::transform_solid(&mut topo, t, &Mat4::translation(0.0, 0.0, -8.0))
.unwrap();
current = boolean(&mut topo, BooleanOp::Fuse, current, t).unwrap();
step += 1;
report(&topo, current, &format!("{step}. nub row {k} (×4 sides)"));
}
let mesh = crate::tessellate::tessellate_solid(&topo, current, 0.1).unwrap();
let mesh_nm = crate::tessellate::non_manifold_edge_count(&mesh);
let mesh_bd = crate::tessellate::boundary_edge_count(&mesh);
eprintln!(
"\nfinal tessellated mesh: tris={}, NM={mesh_nm}, boundary={mesh_bd}",
mesh.indices.len() / 3
);
}
#[test]
fn minimal_box_cut_pocket_should_be_manifold() {
use brepkit_math::mat::Mat4;
let _ = env_logger::try_init();
let mut topo = Topology::new();
let slab = crate::primitives::make_box(&mut topo, 168.0, 168.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, slab, &Mat4::translation(-84.0, -84.0, -8.0))
.unwrap();
let pocket = crate::primitives::make_box(&mut topo, 37.0, 37.0, 6.0).unwrap();
crate::transform::transform_solid(
&mut topo,
pocket,
&Mat4::translation(-63.0 - 18.5, -63.0 - 18.5, -2.0),
)
.unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, slab, pocket).unwrap();
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, result).unwrap();
#[allow(clippy::cast_possible_wrap)]
let euler = (v as i64) - (e as i64) + (f as i64);
let (nm, bd) = count_nm_and_boundary_edges_696(&topo, result);
eprintln!("box - pocket: F={f} E={e} V={v} Euler={euler} NM={nm} boundary={bd}");
assert_eq!(nm, 0, "result should have 0 non-manifold edges, got {nm}");
assert_eq!(bd, 0, "result should have 0 boundary edges, got {bd}");
}
#[test]
#[ignore = "diagnostic — prints boundary edge positions for #696 next-step planning"]
fn dump_boundary_edges_after_two_pocket_cuts() {
use brepkit_math::mat::Mat4;
let _ = env_logger::try_init();
let mut topo = Topology::new();
let slab = crate::primitives::make_box(&mut topo, 168.0, 168.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, slab, &Mat4::translation(-84.0, -84.0, -8.0))
.unwrap();
let mut current = slab;
for col in 0..2 {
let pocket = crate::primitives::make_box(&mut topo, 37.0, 37.0, 6.0).unwrap();
#[allow(clippy::cast_precision_loss)]
let cx = -63.0 + (col as f64) * 42.0;
let cy = -63.0;
crate::transform::transform_solid(
&mut topo,
pocket,
&Mat4::translation(cx - 18.5, cy - 18.5, -2.0),
)
.unwrap();
current = boolean(&mut topo, BooleanOp::Cut, current, pocket).unwrap();
}
let mut edge_count: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
let mut edge_owner: std::collections::HashMap<usize, brepkit_topology::face::FaceId> =
std::collections::HashMap::new();
let faces = brepkit_topology::explorer::solid_faces(&topo, current).unwrap();
for &fid in &faces {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid).unwrap();
for oe in wire.edges() {
let idx = oe.edge().index();
*edge_count.entry(idx).or_default() += 1;
edge_owner.entry(idx).or_insert(fid);
}
}
}
eprintln!("=== boundary edges after 2 pocket cuts ===");
let mut boundary: Vec<usize> = edge_count
.iter()
.filter(|&(_, &c)| c < 2)
.map(|(&k, _)| k)
.collect();
boundary.sort_unstable();
for eidx in &boundary {
let eid = topo.edge_id_from_index(*eidx).unwrap();
let edge = topo.edge(eid).unwrap();
let s = topo.vertex(edge.start()).unwrap().point();
let e = topo.vertex(edge.end()).unwrap().point();
let owner = edge_owner.get(eidx).copied();
let curve_kind = match edge.curve() {
brepkit_topology::edge::EdgeCurve::Line => "Line",
brepkit_topology::edge::EdgeCurve::Circle(_) => "Circle",
brepkit_topology::edge::EdgeCurve::Ellipse(_) => "Ellipse",
brepkit_topology::edge::EdgeCurve::NurbsCurve(_) => "Nurbs",
};
eprintln!(
" Edge {eidx:>4} [{curve_kind}] ({:.3}, {:.3}, {:.3}) → ({:.3}, {:.3}, {:.3}) owner={owner:?}",
s.x(),
s.y(),
s.z(),
e.x(),
e.y(),
e.z()
);
}
eprintln!("=== {} boundary edges total ===", boundary.len());
let mut faces_to_dump: std::collections::HashSet<brepkit_topology::face::FaceId> =
std::collections::HashSet::new();
for eidx in &boundary {
if let Some(&owner) = edge_owner.get(eidx) {
faces_to_dump.insert(owner);
}
}
eprintln!(
"=== Faces touching the {} boundary edges ===",
boundary.len()
);
for fid in faces_to_dump {
let face = topo.face(fid).unwrap();
let surface_kind = match face.surface() {
brepkit_topology::face::FaceSurface::Plane { normal, d } => {
format!("Plane(n={:.3?}, d={:.3})", normal, d)
}
_ => "Other".to_string(),
};
eprintln!(
"Face {fid:?}: outer + {} inner wires, surface={surface_kind}",
face.inner_wires().len()
);
for (wname, wid) in std::iter::once(("outer", face.outer_wire())).chain(
face.inner_wires().iter().enumerate().map(|(i, &w)| {
let name = if i == 0 { "inner[0]" } else { "inner[1+]" };
(name, w)
}),
) {
let wire = topo.wire(wid).unwrap();
eprintln!(" {wname} wire ({} edges):", wire.edges().len());
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let (s, e) = if oe.is_forward() {
(edge.start(), edge.end())
} else {
(edge.end(), edge.start())
};
let sp = topo.vertex(s).unwrap().point();
let ep = topo.vertex(e).unwrap().point();
let n_users = edge_count.get(&oe.edge().index()).copied().unwrap_or(0);
eprintln!(
" Edge {:>4} usage={n_users} ({:.3}, {:.3}, {:.3}) → ({:.3}, {:.3}, {:.3})",
oe.edge().index(),
sp.x(),
sp.y(),
sp.z(),
ep.x(),
ep.y(),
ep.z()
);
}
}
}
}
#[test]
fn cut_shelled_target_single_tool_exact_gfa() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let opts = BooleanOptions {
unify_faces: false,
..Default::default()
};
let target = crate::primitives::make_box(&mut topo, 40.0, 40.0, 10.0).unwrap();
let inner_box = crate::primitives::make_box(&mut topo, 36.0, 36.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, inner_box, &Mat4::translation(2.0, 2.0, 2.0))
.unwrap();
let target = boolean_with_options(&mut topo, BooleanOp::Cut, target, inner_box, opts).unwrap();
let tray_vol = crate::measure::solid_volume(&topo, target, 0.05).unwrap();
assert!(
(tray_vol - 5632.0).abs() < 1e-6,
"tray volume should be exactly 5632, got {tray_vol}"
);
let tool = crate::primitives::make_box(&mut topo, 3.0, 3.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, tool, &Mat4::translation(4.0, 4.0, -5.0)).unwrap();
let result = boolean_with_options(&mut topo, BooleanOp::Cut, target, tool, opts).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
assert_eq!(faces.len(), 15, "expected exact GFA topology, not mesh");
assert!(is_closed_manifold(&topo, result).unwrap());
assert!(!has_free_edges(&topo, result).unwrap());
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, result).unwrap();
let euler = v as i64 - e as i64 + f as i64;
let inner_wires = solid_inner_wire_count(&topo, result).unwrap();
assert_eq!(inner_wires, 3);
assert_eq!(euler, 3);
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
(vol - 5614.0).abs() < 1e-6,
"volume should be exactly 5614, got {vol}"
);
}
fn make_rounded_rect_arc_face(topo: &mut Topology, hw: f64, hd: f64, r: f64, z: f64) -> FaceId {
use brepkit_math::curves::Circle3D;
let tol_val = 1e-7;
let pts: [(f64, f64); 8] = [
(hw, -(hd - r)),
(hw, hd - r),
(hw - r, hd),
(-(hw - r), hd),
(-hw, hd - r),
(-hw, -(hd - r)),
(-(hw - r), -hd),
(hw - r, -hd),
];
let centers: [(f64, f64); 4] = [
(hw - r, hd - r),
(-(hw - r), hd - r),
(-(hw - r), -(hd - r)),
(hw - r, -(hd - r)),
];
let vids: Vec<_> = pts
.iter()
.map(|&(x, y)| topo.add_vertex(Vertex::new(Point3::new(x, y, z), tol_val)))
.collect();
let normal = Vec3::new(0.0, 0.0, 1.0);
let mut eids: Vec<EdgeId> = Vec::with_capacity(8);
for i in 0..4 {
let line_start = vids[2 * i];
let line_end = vids[(2 * i + 1) % 8];
eids.push(topo.add_edge(Edge::new(line_start, line_end, EdgeCurve::Line)));
let arc_start = vids[(2 * i + 1) % 8];
let arc_end = vids[(2 * i + 2) % 8];
let (cx, cy) = centers[i];
let center = Point3::new(cx, cy, z);
let start_pt = Point3::new(pts[(2 * i + 1) % 8].0, pts[(2 * i + 1) % 8].1, z);
let radial = start_pt - center;
let u_axis = Vec3::new(radial.x() / r, radial.y() / r, radial.z() / r);
let v_axis = normal.cross(u_axis);
let circle = Circle3D::with_axes(center, normal, r, u_axis, v_axis).unwrap();
eids.push(topo.add_edge(Edge::new(arc_start, arc_end, EdgeCurve::Circle(circle))));
}
let wire = Wire::new(
eids.iter()
.map(|&eid| OrientedEdge::new(eid, true))
.collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
topo.add_face(Face::new(wid, vec![], FaceSurface::Plane { normal, d: z }))
}
fn make_rounded_rect_arc_prism(
topo: &mut Topology,
hw: f64,
hd: f64,
r: f64,
z0: f64,
height: f64,
) -> SolidId {
let face = make_rounded_rect_arc_face(topo, hw, hd, r, z0);
crate::extrude::extrude(topo, face, Vec3::new(0.0, 0.0, 1.0), height).unwrap()
}
fn rounded_rect_area(hw: f64, hd: f64, r: f64) -> f64 {
4.0 * hw * hd - (4.0 - std::f64::consts::PI) * r * r
}
fn count_cylinder_faces(topo: &Topology, solid: SolidId) -> usize {
brepkit_topology::explorer::solid_faces(topo, solid)
.unwrap()
.iter()
.filter(|&&fid| {
matches!(
topo.face(fid).unwrap().surface(),
FaceSurface::Cylinder { .. }
)
})
.count()
}
fn count_non_manifold_edges(topo: &Topology, solid: SolidId) -> usize {
let faces = brepkit_topology::explorer::solid_faces(topo, solid).unwrap();
let mut edge_use: std::collections::HashMap<EdgeId, usize> = std::collections::HashMap::new();
for &fid in &faces {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
for oe in topo.wire(wid).unwrap().edges() {
*edge_use.entry(oe.edge()).or_default() += 1;
}
}
}
edge_use.values().filter(|&&u| u != 2).count()
}
#[test]
fn rounded_rect_arc_prism_volume_baseline() {
let mut topo = Topology::new();
let a = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, 0.0, 16.0);
let expected = rounded_rect_area(20.75, 20.75, 3.75) * 16.0;
let vol = crate::measure::solid_volume(&topo, a, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-3,
"prism volume {vol:.2} != expected {expected:.2}"
);
assert_eq!(count_cylinder_faces(&topo, a), 4);
assert!(is_closed_manifold(&topo, a).unwrap());
}
#[test]
fn fuse_stacked_rounded_rect_arc_prisms_same_footprint() {
let mut topo = Topology::new();
let a = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, 0.0, 16.0);
let b = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, -4.0, 4.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let expected = rounded_rect_area(20.75, 20.75, 3.75) * 20.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-3,
"fused volume {vol:.2} != expected {expected:.2}"
);
assert!(
is_closed_manifold(&topo, result).expect("is_closed_manifold query failed"),
"fuse result must be closed-manifold"
);
assert!(!has_free_edges(&topo, result).unwrap());
let cyl = count_cylinder_faces(&topo, result);
assert!(
(4..=8).contains(&cyl),
"expected 4-8 analytic cylinder corner faces (no mesh fallback), got {cyl}"
);
}
#[test]
fn fuse_overlapping_rounded_rect_arc_prisms_same_footprint() {
let mut topo = Topology::new();
let a = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, 0.0, 16.0);
let b = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, -4.0, 5.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let expected = rounded_rect_area(20.75, 20.75, 3.75) * 20.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-3,
"fused volume {vol:.2} != expected {expected:.2}"
);
assert!(is_closed_manifold(&topo, result).unwrap());
assert!(!has_free_edges(&topo, result).unwrap());
let cyl = count_cylinder_faces(&topo, result);
assert!(
(4..=12).contains(&cyl),
"expected 4-12 analytic cylinder corner faces (no mesh fallback), got {cyl}"
);
}
struct ArcFrameLipFuse {
fused_vol: f64,
body_vol: f64,
lip_vol: f64,
manifold: bool,
has_free_edges: bool,
}
fn arc_frame_lip_fuse(hw: f64, hd: f64) -> ArcFrameLipFuse {
let mut topo = Topology::new();
let r = 3.0;
let w = 2.0;
let body = make_rounded_rect_arc_prism(&mut topo, hw, hd, r, 0.0, 10.0);
let body_vol = crate::measure::solid_volume(&topo, body, 0.01).unwrap();
let outer = make_rounded_rect_arc_prism(&mut topo, hw, hd, r, 10.0, 4.0);
let inner = make_rounded_rect_arc_prism(&mut topo, hw - w, hd - w, r - w, 10.0, 4.0);
let no_unify = BooleanOptions {
unify_faces: false,
..BooleanOptions::default()
};
let lip = boolean_with_options(&mut topo, BooleanOp::Cut, outer, inner, no_unify).unwrap();
let lip_vol = crate::measure::solid_volume(&topo, lip, 0.01).unwrap();
match boolean_with_options(&mut topo, BooleanOp::Fuse, body, lip, no_unify) {
Ok(f) => ArcFrameLipFuse {
fused_vol: crate::measure::solid_volume(&topo, f, 0.01).unwrap_or(0.0),
body_vol,
lip_vol,
manifold: is_closed_manifold(&topo, f).unwrap_or(false),
has_free_edges: has_free_edges(&topo, f).unwrap_or(true),
},
Err(_) => ArcFrameLipFuse {
fused_vol: 0.0,
body_vol,
lip_vol,
manifold: false,
has_free_edges: true,
},
}
}
fn assert_arc_frame_lip_fuse_clean(hw: f64, hd: f64) {
let r = arc_frame_lip_fuse(hw, hd);
let expected = r.body_vol + r.lip_vol;
assert!(
r.manifold,
"fused lip+body must be closed-manifold (hw={hw}, hd={hd})"
);
assert!(
!r.has_free_edges,
"fused lip+body must have no free edges (hw={hw}, hd={hd})"
);
assert!(
(r.fused_vol - expected).abs() / expected < 1e-2,
"fused volume {:.2} != body+lip {expected:.2} (hw={hw}, hd={hd})",
r.fused_vol
);
}
#[test]
fn fuse_arc_frame_lip_is_manifold_square() {
assert_arc_frame_lip_fuse_clean(15.0, 15.0);
}
#[test]
fn fuse_arc_frame_lip_is_manifold_nonsquare() {
assert_arc_frame_lip_fuse_clean(10.0, 22.0);
}
fn find_top_face(topo: &Topology, solid: SolidId) -> FaceId {
brepkit_topology::explorer::solid_faces(topo, solid)
.unwrap()
.into_iter()
.find(|&fid| {
topo.face(fid)
.unwrap()
.effective_plane_normal()
.is_some_and(|n| n.z() > 0.5 * n.length())
})
.expect("prism should have a +Z top face")
}
#[test]
fn fuse_shelled_cup_lip_covers_cavity_opening() {
let mut topo = Topology::new();
let body = make_rounded_rect_arc_prism(&mut topo, 15.0, 15.0, 3.0, 0.0, 10.0);
let top = find_top_face(&topo, body);
let cup = crate::shell_op::shell(&mut topo, body, 2.0, &[top]).unwrap();
let outer = make_rounded_rect_arc_prism(&mut topo, 15.0, 15.0, 3.0, 8.0, 6.0);
let inner = make_rounded_rect_arc_prism(&mut topo, 12.0, 12.0, 1.0, 8.0, 6.0);
let no_unify = BooleanOptions {
unify_faces: false,
..BooleanOptions::default()
};
let lip = boolean_with_options(&mut topo, BooleanOp::Cut, outer, inner, no_unify).unwrap();
match boolean_with_options(&mut topo, BooleanOp::Fuse, cup, lip, no_unify) {
Ok(f) => {
let manifold = is_closed_manifold(&topo, f).unwrap_or(false);
let free = has_free_edges(&topo, f).unwrap_or(true);
eprintln!("faithful-cup-lip fuse: manifold={manifold} free={free}");
assert!(
manifold,
"shelled cup + cavity-covering lip fuse must be closed-manifold"
);
assert!(
!free,
"shelled cup + cavity-covering lip fuse must have no free edges"
);
}
Err(e) => panic!("fuse errored: {e:?}"),
}
}
#[test]
fn fuse_stacked_rounded_rect_arc_prisms_nested_footprint() {
let mut topo = Topology::new();
let a = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, 0.0, 16.0);
let b = make_rounded_rect_arc_prism(&mut topo, 19.55, 19.55, 2.55, -4.0, 4.0);
let result = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
let expected =
rounded_rect_area(20.75, 20.75, 3.75) * 16.0 + rounded_rect_area(19.55, 19.55, 2.55) * 4.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-3,
"fused volume {vol:.2} != expected {expected:.2}"
);
assert!(is_closed_manifold(&topo, result).unwrap());
assert!(!has_free_edges(&topo, result).unwrap());
let cyl = count_cylinder_faces(&topo, result);
assert_eq!(
cyl, 8,
"expected 8 analytic cylinder corner faces (no mesh fallback), got {cyl}"
);
}
#[test]
fn cut_concentric_rounded_rect_arc_prisms_overshoot() {
let mut topo = Topology::new();
let body = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, 0.0, 21.0);
let tool = make_rounded_rect_arc_prism(&mut topo, 19.55, 19.55, 2.55, 5.0, 20.0);
let result = boolean(&mut topo, BooleanOp::Cut, body, tool).unwrap();
let expected =
rounded_rect_area(20.75, 20.75, 3.75) * 21.0 - rounded_rect_area(19.55, 19.55, 2.55) * 16.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-3,
"cut volume {vol:.2} != expected {expected:.2}"
);
assert!(is_closed_manifold(&topo, result).unwrap());
assert!(!has_free_edges(&topo, result).unwrap());
let cyl = count_cylinder_faces(&topo, result);
assert_eq!(
cyl, 8,
"expected 8 analytic cylinder faces (4 outer + 4 pocket), got {cyl}"
);
}
#[test]
fn cut_concentric_rounded_rect_arc_prisms_cavity() {
let mut topo = Topology::new();
let body = make_rounded_rect_arc_prism(&mut topo, 20.75, 20.75, 3.75, 0.0, 21.0);
let tool = make_rounded_rect_arc_prism(&mut topo, 19.55, 19.55, 2.55, 5.0, 15.0);
let result = boolean(&mut topo, BooleanOp::Cut, body, tool).unwrap();
let expected =
rounded_rect_area(20.75, 20.75, 3.75) * 21.0 - rounded_rect_area(19.55, 19.55, 2.55) * 15.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() / expected < 1e-3,
"cavity cut volume {vol:.2} != expected {expected:.2}"
);
assert!(is_closed_manifold(&topo, result).unwrap());
assert!(!has_free_edges(&topo, result).unwrap());
let cyl = count_cylinder_faces(&topo, result);
assert_eq!(
cyl, 8,
"expected 8 analytic cylinder faces (4 outer + 4 cavity), got {cyl}"
);
}
fn assert_cut_fuse_back_recovers(a_dim: f64, b_dim: f64, b_dx: f64) {
let vol_a = a_dim * a_dim * a_dim;
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, a_dim, a_dim, a_dim).unwrap();
let b = crate::primitives::make_box(&mut topo, b_dim, b_dim, b_dim).unwrap();
if b_dx.abs() > 1e-9 {
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(b_dx, 0.0, 0.0),
)
.unwrap();
}
let a_minus_b = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let a_and_b = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
for (first, second, order) in [
(a_minus_b, a_and_b, "(a-b)∪(a∩b)"),
(a_and_b, a_minus_b, "(a∩b)∪(a-b)"),
] {
let recombined = boolean(&mut topo, BooleanOp::Fuse, first, second).unwrap();
let vol = crate::measure::solid_volume(&topo, recombined, 0.05).unwrap();
assert!(
(vol - vol_a).abs() < vol_a * 1e-4 + 1e-9,
"{order} for box({a_dim})/box({b_dim})@dx={b_dx} must recover vol(a)={vol_a}, got {vol}"
);
}
}
#[test]
fn issue_801_fuse_recovers_cut_intersect_volume() {
assert_cut_fuse_back_recovers(2.0, 1.0, 0.0);
}
#[test]
fn issue_801_fuse_recovers_volume_scaling() {
assert_cut_fuse_back_recovers(3.0, 1.0, 0.0); assert_cut_fuse_back_recovers(10.0, 5.0, 0.0); assert_cut_fuse_back_recovers(3.0, 2.0, 1.0); assert_cut_fuse_back_recovers(10.0, 5.0, 5.0); }
#[test]
fn issue_801_recombined_box_is_genus0_manifold() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let a_minus_b = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let a_and_b = boolean(&mut topo, BooleanOp::Intersect, a, b).unwrap();
let recombined = boolean(&mut topo, BooleanOp::Fuse, a_minus_b, a_and_b).unwrap();
check_result(&topo, recombined); let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, recombined).unwrap();
let euler = v as i64 - e as i64 + f as i64;
assert_eq!(euler, 2, "recombined union must be genus-0 (V-E+F=2)");
assert_volume_near(&topo, recombined, 8.0, 1e-4);
}
#[test]
fn issue_801_slot_fuse_recovers_volume() {
assert_cut_fuse_back_recovers(10.0, 5.0, 2.5); assert_cut_fuse_back_recovers(12.0, 4.0, 4.0);
assert_cut_fuse_back_recovers(8.0, 3.0, 2.0);
}
fn dovetail_tongue(
topo: &mut Topology,
wall_x: f64,
bp_y: f64,
height: f64,
overlap: f64,
base_half: f64,
tip_half: f64,
) -> SolidId {
let base_x = wall_x - overlap; let tip_x = wall_x + 1.5; let pts = [
Point3::new(base_x, bp_y + base_half, 0.0),
Point3::new(tip_x, bp_y + tip_half, 0.0),
Point3::new(tip_x, bp_y - tip_half, 0.0),
Point3::new(base_x, bp_y - base_half, 0.0),
];
let vids: Vec<_> = pts
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, 1e-7)))
.collect();
let n = vids.len();
let eids: Vec<_> = (0..n)
.map(|i| topo.add_edge(Edge::new(vids[i], vids[(i + 1) % n], EdgeCurve::Line)))
.collect();
let wire = Wire::new(
eids.iter().map(|&e| OrientedEdge::new(e, true)).collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: 0.0,
},
));
crate::extrude::extrude(topo, face, Vec3::new(0.0, 0.0, -1.0), height).unwrap()
}
fn dovetail_slab(topo: &mut Topology, w: f64, d: f64, h: f64) -> SolidId {
let pts = [
Point3::new(0.0, 0.0, 0.0),
Point3::new(w, 0.0, 0.0),
Point3::new(w, d, 0.0),
Point3::new(0.0, d, 0.0),
];
let vids: Vec<_> = pts
.iter()
.map(|&p| topo.add_vertex(Vertex::new(p, 1e-7)))
.collect();
let n = vids.len();
let eids: Vec<_> = (0..n)
.map(|i| topo.add_edge(Edge::new(vids[i], vids[(i + 1) % n], EdgeCurve::Line)))
.collect();
let wire = Wire::new(
eids.iter().map(|&e| OrientedEdge::new(e, true)).collect(),
true,
)
.unwrap();
let wid = topo.add_wire(wire);
let face = topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: 0.0,
},
));
crate::extrude::extrude(topo, face, Vec3::new(0.0, 0.0, -1.0), h).unwrap()
}
#[test]
fn extrude_down_cw_profile_has_correct_volume() {
let mut topo = Topology::new();
let tongue = dovetail_tongue(&mut topo, 20.0, 15.0, 5.0, 0.01, 1.0, 1.3);
assert_volume_near(&topo, tongue, 17.365, 1e-2);
let mut topo2 = Topology::new();
let rect = dovetail_tongue(&mut topo2, 20.0, 15.0, 5.0, 0.01, 1.0, 1.0);
assert_volume_near(&topo2, rect, 15.1, 1e-2);
}
#[test]
fn baseplate_dovetail_tongue_fuse_is_watertight() {
let mut topo = Topology::new();
let slab = dovetail_slab(&mut topo, 20.0, 30.0, 5.0);
let slab_vol = crate::measure::solid_volume(&topo, slab, 0.01).unwrap();
let tongue = dovetail_tongue(&mut topo, 20.0, 15.0, 5.0, 0.01, 1.0, 1.3);
let tongue_vol = crate::measure::solid_volume(&topo, tongue, 0.01).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, slab, tongue).unwrap();
let sh = topo
.shell(topo.solid(fused).unwrap().outer_shell())
.unwrap();
brepkit_topology::validation::validate_shell_closed(sh, &topo)
.expect("dovetail fuse must be a closed manifold");
let fused_vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
assert!(
fused_vol > slab_vol && fused_vol < slab_vol + tongue_vol + 1e-6,
"fused vol {fused_vol} should be between slab {slab_vol} and slab+tongue \
{}",
slab_vol + tongue_vol
);
}
#[test]
fn baseplate_dovetail_sequential_fuse_no_mesh_blowup() {
let mut topo = Topology::new();
let mut acc = dovetail_slab(&mut topo, 20.0, 60.0, 5.0);
let mut prev_vol = crate::measure::solid_volume(&topo, acc, 0.01).unwrap();
let mut per_tongue_increment: Option<f64> = None;
for &y in &[10.0_f64, 20.0, 30.0, 40.0, 50.0] {
let tongue = dovetail_tongue(&mut topo, 20.0, y, 5.0, 0.01, 1.0, 1.3);
acc = boolean(&mut topo, BooleanOp::Fuse, acc, tongue).unwrap();
let sh = topo.shell(topo.solid(acc).unwrap().outer_shell()).unwrap();
assert!(
sh.faces().len() < 60,
"step y={y}: face count {} suggests a mesh fallback (the hang path)",
sh.faces().len()
);
brepkit_topology::validation::validate_shell_closed(sh, &topo)
.unwrap_or_else(|e| panic!("step y={y}: result not watertight: {e:?}"));
let vol = crate::measure::solid_volume(&topo, acc, 0.01).unwrap();
let increment = vol - prev_vol;
match per_tongue_increment {
None => per_tongue_increment = Some(increment),
Some(first) => assert!(
(increment - first).abs() < 1e-4,
"step y={y}: volume increment {increment} differs from the first tongue's \
{first} — a re-split into an already-split wall changed the volume"
),
}
prev_vol = vol;
}
}
#[test]
fn extrude_down_solid_fuses_without_hole_misclassification() {
for (base_half, tip_half) in [(1.0, 1.0), (1.0, 1.3), (1.3, 1.0)] {
let mut topo = Topology::new();
let slab = dovetail_slab(&mut topo, 20.0, 30.0, 5.0);
let tongue = dovetail_tongue(&mut topo, 20.0, 15.0, 5.0, 0.01, base_half, tip_half);
let fused =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, slab, tongue)
.unwrap_or_else(|e| panic!("GFA fuse failed for ({base_half},{tip_half}): {e:?}"));
let sh = topo
.shell(topo.solid(fused).unwrap().outer_shell())
.unwrap();
brepkit_topology::validation::validate_shell_closed(sh, &topo)
.unwrap_or_else(|e| panic!("({base_half},{tip_half}) not watertight: {e:?}"));
}
}
#[test]
fn baseplate_two_tongues_far_apart_resplit_is_watertight() {
let mut topo = Topology::new();
let slab = dovetail_slab(&mut topo, 20.0, 60.0, 5.0);
let tongue_a = dovetail_tongue(&mut topo, 20.0, 10.0, 5.0, 0.01, 1.0, 1.3);
let after_a = boolean(&mut topo, BooleanOp::Fuse, slab, tongue_a).unwrap();
let sh_a = topo
.shell(topo.solid(after_a).unwrap().outer_shell())
.unwrap();
brepkit_topology::validation::validate_shell_closed(sh_a, &topo)
.expect("tongue A on a clean slab must be watertight");
let tongue_b = dovetail_tongue(&mut topo, 20.0, 50.0, 5.0, 0.01, 1.0, 1.3);
let after_b = boolean(&mut topo, BooleanOp::Fuse, after_a, tongue_b).unwrap();
let sh_b = topo
.shell(topo.solid(after_b).unwrap().outer_shell())
.unwrap();
brepkit_topology::validation::validate_shell_closed(sh_b, &topo)
.expect("tongue B fused into the A-split wall must stay watertight");
}
fn quantized_edge_use(topo: &Topology, solid: SolidId) -> (usize, usize) {
use std::collections::HashMap;
type EdgeKey = (i64, i64, i64, i64, i64, i64);
let q = |x: f64| (x / 1e-5).round() as i64;
let key = |p: Point3| [q(p.x()), q(p.y()), q(p.z())];
let mut counts: HashMap<EdgeKey, usize> = HashMap::new();
for fid in brepkit_topology::explorer::solid_faces(topo, solid).unwrap() {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid).unwrap();
for oe in wire.edges() {
let edge = topo.edge(oe.edge()).unwrap();
let a = key(topo.vertex(edge.start()).unwrap().point());
let b = key(topo.vertex(edge.end()).unwrap().point());
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
*counts
.entry((lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]))
.or_insert(0) += 1;
}
}
}
let free = counts.values().filter(|&&c| c == 1).count();
let over = counts.values().filter(|&&c| c > 2).count();
(free, over)
}
#[test]
fn cut_wall_notch_straddling_top_edge_is_watertight() {
use brepkit_math::mat::Mat4;
use std::f64::consts::FRAC_PI_2;
let mut topo = Topology::new();
let body = make_rounded_rect_arc_prism(&mut topo, 21.0, 21.0, 3.75, 0.0, 30.0);
let tool = make_rounded_rect_arc_prism(&mut topo, 14.0, 13.0, 3.0, 0.0, 6.0);
crate::transform::transform_solid(&mut topo, tool, &Mat4::rotation_x(-FRAC_PI_2)).unwrap();
crate::transform::transform_solid(&mut topo, tool, &Mat4::translation(0.0, 17.0, 18.0))
.unwrap();
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Cut, body, tool)
.unwrap();
let (free, over) = quantized_edge_use(&topo, result);
assert_eq!(free, 0, "wall-cutout notch cut must have no free edges");
assert_eq!(
over, 0,
"wall-cutout notch cut must have no over-shared edges"
);
let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(&topo, result).unwrap();
assert_eq!(
v as i64 - e as i64 + f as i64,
2,
"wall-cutout notch cut must be genus-0 (V-E+F=2)"
);
assert!(
is_closed_manifold(&topo, result).unwrap(),
"wall-cutout notch cut must be closed-manifold"
);
assert!(
!has_free_edges(&topo, result).unwrap(),
"wall-cutout notch cut must have no free edges"
);
assert_eq!(
count_cylinder_faces(&topo, result),
8,
"wall-cutout must stay analytic (4 body + 4 tool corner cylinders)"
);
}
fn make_wall_notch_tool(
topo: &mut Topology,
cut_hw: f64,
total_hh: f64,
r: f64,
depth: f64,
wall_y: f64,
top_z: f64,
) -> SolidId {
use brepkit_math::mat::Mat4;
use std::f64::consts::FRAC_PI_2;
let tool = make_rounded_rect_arc_prism(topo, cut_hw, total_hh, r, 0.0, depth);
crate::transform::transform_solid(topo, tool, &Mat4::rotation_x(-FRAC_PI_2)).unwrap();
let z_center = top_z - total_hh;
crate::transform::transform_solid(
topo,
tool,
&Mat4::translation(0.0, wall_y - depth / 2.0, z_center),
)
.unwrap();
tool
}
#[test]
fn cut_wall_notch_through_solid_wall_is_watertight() {
let mut topo = Topology::new();
let body = make_rounded_rect_arc_prism(&mut topo, 21.0, 21.0, 3.75, 0.0, 30.0);
let tool = make_wall_notch_tool(&mut topo, 14.0, 5.95, 1.485, 3.4, 21.0, 32.0);
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Cut, body, tool)
.unwrap();
let (free, over) = quantized_edge_use(&topo, result);
assert_eq!(free, 0, "through-wall notch cut must have no free edges");
assert_eq!(
over, 0,
"through-wall notch cut must have no over-shared edges"
);
assert!(
is_closed_manifold(&topo, result).unwrap(),
"through-wall notch cut must be closed-manifold"
);
assert_eq!(
count_cylinder_faces(&topo, result),
6,
"through-wall notch must stay analytic (4 body + 2 notch corner cylinders)"
);
}
#[test]
fn cut_wall_notch_through_shelled_wall_is_watertight() {
let mut topo = Topology::new();
let body = make_rounded_rect_arc_prism(&mut topo, 21.0, 21.0, 3.75, 0.0, 30.0);
let top = find_top_face(&topo, body);
let cup = crate::shell_op::shell(&mut topo, body, 1.2, &[top]).unwrap();
let tool = make_wall_notch_tool(&mut topo, 14.0, 5.95, 1.485, 3.4, 21.0, 32.0);
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Cut, cup, tool)
.unwrap();
let (free, over) = quantized_edge_use(&topo, result);
assert_eq!(
free, 0,
"shelled through-wall notch cut must have no free edges"
);
assert_eq!(
over, 0,
"shelled through-wall notch cut must have no over-shared edges"
);
assert!(
!has_free_edges(&topo, result).unwrap(),
"shelled through-wall notch cut must have no free edges (per-edge)"
);
assert!(
count_cylinder_faces(&topo, result) >= 2,
"shelled through-wall notch must stay analytic (notch corner cylinders preserved)"
);
}
#[test]
fn cut_2x2_wall_cutouts_four_walls_is_watertight() {
use brepkit_math::mat::Mat4;
use std::f64::consts::FRAC_PI_2;
let mut topo = Topology::new();
let hw = 41.75;
let body = make_rounded_rect_arc_prism(&mut topo, hw, hw, 3.75, 0.0, 16.0);
let top = find_top_face(&topo, body);
let cup = crate::shell_op::shell(&mut topo, body, 1.2, &[top]).unwrap();
let (cut_hw, total_hh, r, depth, z_center) = (28.385, 4.7, 1.11, 3.4, 18.0 - 4.7);
let mut tools = Vec::new();
for (rotz, wall) in [(false, -hw), (false, hw), (true, -hw), (true, hw)] {
let t = make_rounded_rect_arc_prism(&mut topo, cut_hw, total_hh, r, 0.0, depth);
crate::transform::transform_solid(&mut topo, t, &Mat4::rotation_x(-FRAC_PI_2)).unwrap();
let off = depth / 2.0 * wall.signum();
if rotz {
crate::transform::transform_solid(&mut topo, t, &Mat4::rotation_z(FRAC_PI_2)).unwrap();
crate::transform::transform_solid(
&mut topo,
t,
&Mat4::translation(wall - off, 0.0, z_center),
)
.unwrap();
} else {
crate::transform::transform_solid(
&mut topo,
t,
&Mat4::translation(0.0, wall - off, z_center),
)
.unwrap();
}
tools.push(t);
}
let merged = crate::compound_ops::merge_disjoint_solids(&mut topo, &tools).unwrap();
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Cut, cup, merged)
.unwrap();
let (free, over) = quantized_edge_use(&topo, result);
assert_eq!(free, 0, "four-wall cutout cut must have no free edges");
assert_eq!(
over, 0,
"four-wall cutout cut must have no over-shared edges"
);
}
#[test]
fn flatten_plane_normal_matches_nurbs_du_cross_dv() {
use brepkit_math::nurbs::surface::NurbsSurface;
use brepkit_topology::shell::Shell;
use brepkit_topology::solid::Solid;
let mut topo = Topology::new();
let control_points = vec![
vec![Point3::new(0.0, 0.0, 5.0), Point3::new(1.0, 0.0, 5.0)],
vec![Point3::new(0.0, 1.0, 5.0), Point3::new(1.0, 1.0, 5.0)],
];
let nurbs = NurbsSurface::new(
1,
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![0.0, 0.0, 1.0, 1.0],
control_points,
vec![vec![1.0, 1.0], vec![1.0, 1.0]],
)
.unwrap();
let (u0, u1) = nurbs.domain_u();
let (v0, v1) = nurbs.domain_v();
let nurbs_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).unwrap();
let v00 = topo.add_vertex(Vertex::new(Point3::new(0.0, 0.0, 5.0), 1e-7));
let v10 = topo.add_vertex(Vertex::new(Point3::new(1.0, 0.0, 5.0), 1e-7));
let v11 = topo.add_vertex(Vertex::new(Point3::new(1.0, 1.0, 5.0), 1e-7));
let v01 = topo.add_vertex(Vertex::new(Point3::new(0.0, 1.0, 5.0), 1e-7));
let e0 = topo.add_edge(Edge::new(v00, v10, EdgeCurve::Line));
let e1 = topo.add_edge(Edge::new(v10, v11, EdgeCurve::Line));
let e2 = topo.add_edge(Edge::new(v11, v01, EdgeCurve::Line));
let e3 = topo.add_edge(Edge::new(v01, v00, EdgeCurve::Line));
let wire = topo.add_wire(
Wire::new(
vec![
OrientedEdge::new(e0, true),
OrientedEdge::new(e1, true),
OrientedEdge::new(e2, true),
OrientedEdge::new(e3, true),
],
true,
)
.unwrap(),
);
let fid = topo.add_face(Face::new(wire, vec![], FaceSurface::Nurbs(nurbs)));
let shell = topo.add_shell(Shell::new(vec![fid]).unwrap());
let solid = topo.add_solid(Solid::new(shell, vec![]));
let n = flatten_planar_nurbs_faces(&mut topo, solid, 1e-7).unwrap();
assert_eq!(n, 1, "the planar NURBS face should have been flattened");
let FaceSurface::Plane { normal, .. } = topo.face(fid).unwrap().surface() else {
panic!("face should now be a Plane");
};
assert!(
normal.dot(nurbs_n) > 0.0,
"flattened plane normal {normal:?} must align with the NURBS du×dv normal {nurbs_n:?} \
(dot={})",
normal.dot(nurbs_n)
);
}
#[test]
fn perforated_panel_cut_is_correct_and_manifold() {
let n_grid = 5_usize;
let n = n_grid * n_grid;
let span = (n_grid + 1) as f64 * 2.4; let mut topo = Topology::new();
let (slab, tool) = build_perforated_panel(&mut topo, n_grid);
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Cut, slab, tool)
.unwrap();
let (free, over) = quantized_edge_use(&topo, result);
assert_eq!(free, 0, "perforated panel must have no free edges");
assert_eq!(over, 0, "perforated panel must have no over-shared edges");
let faces = brepkit_topology::explorer::solid_faces(&topo, result)
.unwrap()
.len();
assert_eq!(faces, 4 * n + 6, "perforated panel face count");
let expected = span * span * 2.0 - (n as f64) * (1.0 * 1.0 * 2.0);
assert_volume_near(&topo, result, expected, 1e-9);
}
#[cfg(test)]
fn build_perforated_panel(topo: &mut Topology, g: usize) -> (SolidId, SolidId) {
use brepkit_math::mat::Mat4;
let pitch = 2.4;
let span = (g + 1) as f64 * pitch;
let slab = crate::primitives::make_box(topo, span, span, 2.0).unwrap();
crate::transform::transform_solid(topo, slab, &Mat4::translation(0.0, 0.0, 1.0)).unwrap();
let mut tools = Vec::new();
for j in 0..g {
for i in 0..g {
let h = crate::primitives::make_box(topo, 1.0, 1.0, 4.0).unwrap();
let m = Mat4::translation((i + 1) as f64 * pitch, (j + 1) as f64 * pitch, 0.0);
crate::transform::transform_solid(topo, h, &m).unwrap();
tools.push(h);
}
}
let tool = crate::compound_ops::merge_disjoint_solids(topo, &tools).unwrap();
(slab, tool)
}
#[cfg(feature = "perf-counters")]
#[test]
fn scaling_perforated_cut_is_subquadratic() {
let cut_counts = |g: usize| -> brepkit_algo::perf::PerfSnapshot {
let mut topo = Topology::new();
let (slab, tool) = build_perforated_panel(&mut topo, g);
brepkit_algo::perf::reset();
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Cut, slab, tool)
.unwrap();
brepkit_algo::perf::snapshot()
};
let s1 = cut_counts(9); let s4 = cut_counts(18); let ratio = |a: u64, b: u64| {
assert!(
a > 0,
"scaling-ratio baseline counter was not exercised at g=9"
);
b as f64 / a as f64
};
let fsp_ratio = ratio(s1.face_split_probes, s4.face_split_probes);
let lvi_ratio = ratio(s1.local_vertex_inserts, s4.local_vertex_inserts);
eprintln!(
"scaling guard @ 81→324 holes (4× input → linear ~4×, quadratic ~16×): \
pave_probes {}->{}, sd_clips {}->{}, ray_geom_builds {}->{}, \
face_split_probes {}->{} ({fsp_ratio:.1}×), local_vtx_inserts {}->{} ({lvi_ratio:.1}×)",
s1.pave_vertex_probes,
s4.pave_vertex_probes,
s1.sd_poly_clips,
s4.sd_poly_clips,
s1.ray_geom_builds,
s4.ray_geom_builds,
s1.face_split_probes,
s4.face_split_probes,
s1.local_vertex_inserts,
s4.local_vertex_inserts,
);
assert!(
s4.pave_vertex_probes < 5_000,
"pave-vertex lookup regressed to O(N²): {} probes at 324 holes",
s4.pave_vertex_probes
);
assert!(
s4.sd_poly_clips < 2_000,
"same-domain polygon clip regressed to O(N²): {} clips at 324 holes",
s4.sd_poly_clips
);
assert!(
s4.ray_geom_builds < 64,
"ray-cast geometry rebuilt per sub-face (was once per solid): {} builds at 324 holes",
s4.ray_geom_builds
);
assert!(
s4.face_split_probes > 0,
"face-split probe instrumentation not exercised"
);
assert!(
fsp_ratio < 8.0,
"face-splitter candidate scan regressed toward O(N²): {fsp_ratio:.1}× for 4× input \
({}->{} probes)",
s1.face_split_probes,
s4.face_split_probes
);
assert!(
s4.local_vertex_inserts > 0,
"vertex-insert instrumentation not exercised"
);
assert!(
lvi_ratio < 8.0,
"per-sub-face vertex materialization regressed toward O(N²): {lvi_ratio:.1}× for 4× input \
({}->{} inserts)",
s1.local_vertex_inserts,
s4.local_vertex_inserts
);
}
#[test]
fn cut_cylinder_by_box_slot_perpendicular_walls_is_watertight() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 6.0, 20.0).unwrap();
let bx = crate::primitives::make_box(&mut topo, 4.0, 4.0, 4.0).unwrap();
crate::transform::transform_solid(
&mut topo,
bx,
&brepkit_math::mat::Mat4::translation(-2.0, -8.0, 5.0),
)
.unwrap();
let result = boolean(&mut topo, BooleanOp::Cut, cyl, bx).unwrap();
assert!(
is_closed_manifold(&topo, result).unwrap(),
"cyl - box slot must be closed-manifold"
);
assert!(
!has_free_edges(&topo, result).unwrap(),
"cyl - box slot must be watertight (no free edges)"
);
assert!(
count_cylinder_faces(&topo, result) >= 1,
"cyl - box slot must keep the analytic cylinder face (no mesh fallback)"
);
let faces = brepkit_topology::explorer::solid_faces(&topo, result)
.unwrap()
.len();
assert!(
faces < 20,
"expected a compact analytic result, got {faces} faces (mesh fallback?)"
);
let slot_pt = Point3::new(0.0, -5.0, 7.0);
let body_pt = Point3::new(0.0, 0.0, 10.0);
assert!(
matches!(
crate::classify::classify_point(&topo, result, slot_pt, 0.01, 1e-7).unwrap(),
crate::classify::PointClassification::Outside
),
"a point inside the carved slot must be Outside the result"
);
assert!(
matches!(
crate::classify::classify_point(&topo, result, body_pt, 0.01, 1e-7).unwrap(),
crate::classify::PointClassification::Inside
),
"the cylinder body must remain Inside the result"
);
}
#[test]
fn fuse_capping_slab_preserves_drilled_hole_caps() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let holes = [(6.0, 10.0), (14.0, 10.0)];
let mut bottom = crate::primitives::make_box(&mut topo, 20.0, 20.0, 5.0).unwrap();
for &(cx, cy) in &holes {
let drill = crate::primitives::make_cylinder(&mut topo, 1.5, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, drill, &Mat4::translation(cx, cy, -5.0))
.unwrap();
bottom = boolean(&mut topo, BooleanOp::Cut, bottom, drill).unwrap();
}
let top = crate::primitives::make_box(&mut topo, 20.0, 20.0, 5.0).unwrap();
crate::transform::transform_solid(&mut topo, top, &Mat4::translation(0.0, 0.0, 5.0)).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, bottom, top).unwrap();
let bad_edges = count_non_manifold_edges(&topo, fused);
assert_eq!(
bad_edges, 0,
"fused result must be a watertight manifold (every edge used twice), got {bad_edges} bad edges"
);
let faces = brepkit_topology::explorer::solid_faces(&topo, fused).unwrap();
assert!(
faces.len() < 30,
"expected a compact analytic fuse, got {} faces (mesh fallback?)",
faces.len()
);
assert_eq!(
count_cylinder_faces(&topo, fused),
2,
"both drilled-hole cylinder walls must survive the fuse"
);
for &(cx, cy) in &holes {
let above = Point3::new(cx, cy, 7.0);
let below = Point3::new(cx, cy, 2.5);
assert!(
matches!(
crate::classify::classify_point(&topo, fused, above, 0.01, 1e-7).unwrap(),
crate::classify::PointClassification::Inside
),
"material must cap the hole above z=5 at ({cx}, {cy})"
);
assert!(
matches!(
crate::classify::classify_point(&topo, fused, below, 0.01, 1e-7).unwrap(),
crate::classify::PointClassification::Outside
),
"the through-hole must stay open below z=5 at ({cx}, {cy})"
);
}
}
#[test]
fn fuse_embedded_drilled_block_carves_caps_across_split_sub_faces() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let slab = crate::primitives::make_box(&mut topo, 30.0, 20.0, 5.0).unwrap();
let holes = [(6.0, 10.0), (14.0, 10.0)];
let mut block = crate::primitives::make_box(&mut topo, 20.0, 20.0, 5.0).unwrap();
crate::transform::transform_solid(&mut topo, block, &Mat4::translation(0.0, 0.0, 3.0)).unwrap();
for &(cx, cy) in &holes {
let drill = crate::primitives::make_cylinder(&mut topo, 1.5, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, drill, &Mat4::translation(cx, cy, 0.0))
.unwrap();
block = boolean(&mut topo, BooleanOp::Cut, block, drill).unwrap();
}
let fused = boolean(&mut topo, BooleanOp::Fuse, slab, block).unwrap();
let bad_edges = count_non_manifold_edges(&topo, fused);
assert_eq!(
bad_edges, 0,
"fused result must be a watertight manifold (every edge used twice), got {bad_edges} bad edges"
);
assert_eq!(
count_cylinder_faces(&topo, fused),
2,
"both drilled-hole cylinder walls must survive the fuse"
);
for &(cx, cy) in &holes {
let below = Point3::new(cx, cy, 4.0);
let inside_hole = Point3::new(cx, cy, 6.5);
assert!(
matches!(
crate::classify::classify_point(&topo, fused, below, 0.01, 1e-7).unwrap(),
crate::classify::PointClassification::Inside
),
"slab material must floor the hole below z=5 at ({cx}, {cy})"
);
assert!(
matches!(
crate::classify::classify_point(&topo, fused, inside_hole, 0.01, 1e-7).unwrap(),
crate::classify::PointClassification::Outside
),
"the hole must stay open above z=5 at ({cx}, {cy})"
);
}
assert!(matches!(
crate::classify::classify_point(&topo, fused, Point3::new(25.0, 10.0, 2.5), 0.01, 1e-7)
.unwrap(),
crate::classify::PointClassification::Inside
));
}
#[test]
fn fuse_counterbore_drops_drill_rims_inside_opening() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let holes = [(6.0, 10.0), (14.0, 10.0)];
let mut bottom = crate::primitives::make_box(&mut topo, 20.0, 20.0, 5.0).unwrap();
for &(cx, cy) in &holes {
let drill = crate::primitives::make_cylinder(&mut topo, 1.5, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, drill, &Mat4::translation(cx, cy, -5.0))
.unwrap();
bottom = boolean(&mut topo, BooleanOp::Cut, bottom, drill).unwrap();
}
let mut top = crate::primitives::make_box(&mut topo, 20.0, 20.0, 5.0).unwrap();
crate::transform::transform_solid(&mut topo, top, &Mat4::translation(0.0, 0.0, 5.0)).unwrap();
let bore = crate::primitives::make_cylinder(&mut topo, 6.0, 20.0).unwrap();
crate::transform::transform_solid(&mut topo, bore, &Mat4::translation(10.0, 10.0, 0.0))
.unwrap();
top = boolean(&mut topo, BooleanOp::Cut, top, bore).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, bottom, top).unwrap();
let bad_edges = count_non_manifold_edges(&topo, fused);
assert_eq!(
bad_edges, 0,
"fused result must be a watertight manifold (every edge used twice), got {bad_edges} bad edges"
);
let expected = 20.0 * 20.0 * 10.0
- std::f64::consts::PI * 6.0 * 6.0 * 5.0
- 2.0 * std::f64::consts::PI * 1.5 * 1.5 * 5.0;
assert_volume_near(&topo, fused, expected, 0.02);
let slab =
crate::classify::classify_point(&topo, fused, Point3::new(2.0, 2.0, 2.5), 0.01, 1e-7)
.unwrap();
assert!(
matches!(slab, crate::classify::PointClassification::Inside),
"slab material must be Inside, got {slab:?}"
);
let bore =
crate::classify::classify_point(&topo, fused, Point3::new(10.0, 7.3, 7.0), 0.01, 1e-7)
.unwrap();
assert!(
matches!(bore, crate::classify::PointClassification::Outside),
"the counterbore must stay open above the floor, got {bore:?}"
);
for &(cx, cy) in &holes {
let hole = crate::classify::classify_point(
&topo,
fused,
Point3::new(cx + 0.4, cy + 0.3, 2.5),
0.01,
1e-7,
)
.unwrap();
assert!(
matches!(hole, crate::classify::PointClassification::Outside),
"the small through-hole must stay open below z=5 near ({cx}, {cy}), got {hole:?}"
);
}
}
#[test]
fn severing_cut_keeps_pocketed_pieces_analytic() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let pockets = [4.0_f64, 16.0];
let mut bar = crate::primitives::make_box(&mut topo, 20.0, 6.0, 3.0).unwrap();
for &cx in &pockets {
let drill = crate::primitives::make_cylinder(&mut topo, 1.5, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, drill, &Mat4::translation(cx, 3.0, 1.5))
.unwrap();
bar = boolean(&mut topo, BooleanOp::Cut, bar, drill).unwrap();
}
let slab = crate::primitives::make_box(&mut topo, 2.0, 8.0, 5.0).unwrap();
crate::transform::transform_solid(&mut topo, slab, &Mat4::translation(9.0, -1.0, -1.0))
.unwrap();
let severed = boolean(&mut topo, BooleanOp::Cut, bar, slab).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, severed).unwrap();
assert!(
faces.len() < 30,
"expected a compact analytic result, got {} faces (mesh fallback?)",
faces.len()
);
assert_eq!(
count_cylinder_faces(&topo, severed),
2,
"both pocket walls must survive the severing cut as analytic cylinders"
);
let bad_edges = count_non_manifold_edges(&topo, severed);
assert_eq!(
bad_edges, 0,
"severed result must be a watertight manifold, got {bad_edges} bad edges"
);
let expected =
20.0 * 6.0 * 3.0 - 2.0 * std::f64::consts::PI * 1.5 * 1.5 * 1.5 - 2.0 * 6.0 * 3.0;
let volume = crate::measure::solid_volume(&topo, severed, 0.01).unwrap();
assert!(
(volume - expected).abs() < 0.05,
"severed volume {volume:.3} should match analytic {expected:.3}"
);
}
#[test]
fn compound_cut_unify_keeps_bore_opening() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let plate = crate::primitives::make_box(&mut topo, 20.0, 20.0, 2.0).unwrap();
let pad = crate::primitives::make_cylinder(&mut topo, 3.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, pad, &Mat4::translation(10.0, 10.0, 0.0)).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, plate, pad).unwrap();
let drill = crate::primitives::make_cylinder(&mut topo, 1.0, 30.0).unwrap();
crate::transform::transform_solid(&mut topo, drill, &Mat4::translation(10.0, 10.0, -5.0))
.unwrap();
let opts = BooleanOptions {
unify_faces: true,
..BooleanOptions::default()
};
let result = compound_cut(&mut topo, fused, &[drill], opts).unwrap();
let bad_edges = count_non_manifold_edges(&topo, result);
assert_eq!(
bad_edges, 0,
"the bore opening must survive the unify pass (got {bad_edges} unpaired/over-shared edges)"
);
assert_eq!(
count_cylinder_faces(&topo, result),
2,
"both cylindrical walls must survive"
);
}
#[test]
fn compound_cut_unify_still_merges_normally() {
use brepkit_math::mat::Mat4;
let build = |topo: &mut Topology| {
let a = crate::primitives::make_box(topo, 20.0, 8.0, 8.0).unwrap();
let b = crate::primitives::make_box(topo, 8.0, 20.0, 8.0).unwrap();
let l = boolean(topo, BooleanOp::Fuse, a, b).unwrap();
let mut tools = Vec::new();
for (x, y) in [(14.0_f64, 2.0_f64), (2.0, 14.0)] {
let t = crate::primitives::make_box(topo, 4.0, 4.0, 10.0).unwrap();
crate::transform::transform_solid(topo, t, &Mat4::translation(x, y, -1.0)).unwrap();
tools.push(t);
}
(l, tools)
};
let mut topo = Topology::new();
let (solid_a, tools_a) = build(&mut topo);
let unified = compound_cut(
&mut topo,
solid_a,
&tools_a,
BooleanOptions {
unify_faces: true,
..BooleanOptions::default()
},
)
.unwrap();
let unified_faces = brepkit_topology::explorer::solid_faces(&topo, unified)
.unwrap()
.len();
let (solid_b, tools_b) = build(&mut topo);
let raw = compound_cut(
&mut topo,
solid_b,
&tools_b,
BooleanOptions {
unify_faces: false,
..BooleanOptions::default()
},
)
.unwrap();
let raw_faces = brepkit_topology::explorer::solid_faces(&topo, raw)
.unwrap()
.len();
assert!(
unified_faces < raw_faces,
"unify must still merge coplanar fragments on healthy geometry: {unified_faces} faces with unify vs {raw_faces} without"
);
assert_eq!(
count_non_manifold_edges(&topo, unified),
0,
"the unified result must stay watertight"
);
}
#[test]
fn fuse_multi_component_tool_folds_each_piece() {
use crate::measure::solid_volume;
let mut topo = Topology::new();
let base = crate::primitives::make_box(&mut topo, 10.0, 10.0, 2.0).unwrap();
let p1 = crate::primitives::make_box(&mut topo, 2.0, 2.0, 4.0).unwrap();
crate::transform::transform_solid(
&mut topo,
p1,
&brepkit_math::mat::Mat4::translation(1.0, 1.0, 0.0),
)
.unwrap();
let p2 = crate::primitives::make_box(&mut topo, 2.0, 2.0, 4.0).unwrap();
crate::transform::transform_solid(
&mut topo,
p2,
&brepkit_math::mat::Mat4::translation(7.0, 7.0, 0.0),
)
.unwrap();
let mut tool_faces = brepkit_topology::explorer::solid_faces(&topo, p1).unwrap();
tool_faces.extend(brepkit_topology::explorer::solid_faces(&topo, p2).unwrap());
let tool_shell = topo.add_shell(brepkit_topology::shell::Shell::new(tool_faces).unwrap());
let tool = topo.add_solid(brepkit_topology::solid::Solid::new(tool_shell, Vec::new()));
let components = super::assembly::face_components(&topo, tool);
assert_eq!(components.len(), 2, "tool must split into two pieces");
let result = super::fuse_multi_component_tool(&mut topo, base, components).unwrap();
let vol = solid_volume(&topo, result, 0.05).unwrap();
assert!(
(vol - 216.0).abs() < 0.5,
"folded fuse volume {vol}, expected 216"
);
assert_eq!(count_non_manifold_edges(&topo, result), 0);
}
#[test]
fn fuse_corner_poking_cylinder_stays_analytic() {
use crate::measure::solid_volume;
use crate::transform::transform_solid;
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let plate = crate::primitives::make_box(&mut topo, 10.0, 10.0, 5.0).unwrap();
let pad = crate::primitives::make_cylinder(&mut topo, 3.0, 5.0).unwrap();
transform_solid(&mut topo, pad, &Mat4::translation(9.0, 9.0, 0.0)).unwrap();
let fused = boolean(&mut topo, BooleanOp::Fuse, plate, pad).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, fused).unwrap();
let curved = faces
.iter()
.filter(|&&f| topo.face(f).unwrap().surface().type_tag() == "cylinder")
.count();
assert!(
curved >= 2,
"the pad wall must stay a cylinder, got {curved} curved of {} faces",
faces.len()
);
let vol = solid_volume(&topo, fused, 0.05).unwrap();
assert!(
(vol - 571.58).abs() < 0.5,
"union volume out of band: got {vol}"
);
assert_eq!(count_non_manifold_edges(&topo, fused), 0);
}
#[test]
fn compound_cut_coaxial_pair_clusters_match_sequential() {
use crate::measure::solid_volume;
use crate::transform::transform_solid;
use brepkit_math::mat::Mat4;
let make = |topo: &mut Topology| -> (SolidId, Vec<SolidId>) {
let base = crate::primitives::make_box(topo, 20.0, 20.0, 6.0).unwrap();
let mut drills = Vec::new();
for (x, y) in [(4.0, 4.0), (16.0, 4.0), (4.0, 16.0), (16.0, 16.0)] {
let magnet = crate::primitives::make_cylinder(topo, 2.0, 3.0).unwrap();
transform_solid(topo, magnet, &Mat4::translation(x, y, -1.0)).unwrap();
drills.push(magnet);
let screw = crate::primitives::make_cylinder(topo, 0.8, 8.0).unwrap();
transform_solid(topo, screw, &Mat4::translation(x, y, -1.0)).unwrap();
drills.push(screw);
}
(base, drills)
};
let mut topo_seq = Topology::new();
let (base, drills) = make(&mut topo_seq);
let mut seq = base;
for &d in &drills {
seq = boolean(&mut topo_seq, BooleanOp::Cut, seq, d).unwrap();
}
let vol_seq = solid_volume(&topo_seq, seq, 0.05).unwrap();
let opts = BooleanOptions {
unify_faces: false,
..BooleanOptions::default()
};
let mut topo = Topology::new();
let (base, drills) = make(&mut topo);
let result = crate::boolean::compound_cut(&mut topo, base, &drills, opts).unwrap();
let vol = solid_volume(&topo, result, 0.05).unwrap();
assert!(
(vol - vol_seq).abs() < 0.05,
"cluster-batched volume {vol} must match sequential {vol_seq}"
);
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let cylinders = faces
.iter()
.filter(|&&f| topo.face(f).unwrap().surface().type_tag() == "cylinder")
.count();
assert!(
cylinders >= 8,
"each stepped bore keeps both cylinder walls, got {cylinders}"
);
assert_eq!(count_non_manifold_edges(&topo, result), 0);
}
#[test]
fn cone_union_box_should_be_analytic() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let cone = crate::primitives::make_cone(&mut topo, 6.0, 2.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 8.0, 8.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(-4.0, -4.0, 6.0)).unwrap();
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cone, b).unwrap();
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let z0_discs = faces
.iter()
.filter(|&&fid| {
let f = topo.face(fid).unwrap();
f.surface().type_tag() == "plane"
&& topo.wire(f.outer_wire()).unwrap().edges().iter().all(|oe| {
let e = topo.edge(oe.edge()).unwrap();
topo.vertex(e.start()).unwrap().point().z().abs() < 1e-9
&& topo.vertex(e.end()).unwrap().point().z().abs() < 1e-9
})
})
.count();
assert_eq!(
z0_discs, 1,
"the cone's z=0 base disc must survive the fuse"
);
assert_eq!(count_non_manifold_edges(&topo, result), 0);
assert_eq!(faces.len(), 11, "expected 11 analytic faces");
assert!(
faces
.iter()
.all(|&fid| topo.face(fid).unwrap().surface().type_tag() != "nurbs"),
"all faces must stay analytic"
);
let mesh = crate::tessellate::tessellate_solid(&topo, result, 0.05).unwrap();
assert_eq!(
crate::tessellate::boundary_edge_count(&mesh),
0,
"fused solid must tessellate watertight"
);
let expected = 152.0 * std::f64::consts::PI + 512.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() < 1.0,
"volume {vol} should be ~{expected}"
);
}
#[test]
fn cylinder_union_inscribed_box_is_analytic_watertight() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 4.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 8.0, 8.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(-4.0, -4.0, 6.0)).unwrap();
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cyl, b).unwrap();
assert_eq!(count_non_manifold_edges(&topo, result), 0);
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
assert!(
faces
.iter()
.all(|&fid| topo.face(fid).unwrap().surface().type_tag() != "nurbs"),
"all faces must stay analytic"
);
let mesh = crate::tessellate::tessellate_solid(&topo, result, 0.05).unwrap();
assert_eq!(
crate::tessellate::boundary_edge_count(&mesh),
0,
"fused solid must tessellate watertight"
);
let expected = 96.0 * std::f64::consts::PI + 512.0;
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() < 1.0,
"volume {vol} should be ~{expected}"
);
}
#[test]
#[ignore = "diagnostic — how wide is the tangency failure band?"]
fn diag_tangency_epsilon_band() {
use brepkit_math::mat::Mat4;
for &eps in &[-1e-3f64, -1e-5, -1e-7, -1e-9, 0.0, 1e-9, 1e-7, 1e-5, 1e-3] {
let d = 8.0 + 2.0 * eps; let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 4.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, d, d, 8.0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&Mat4::translation(-d / 2.0, -d / 2.0, 6.0),
)
.unwrap();
let msg =
match brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cyl, b)
{
Ok(r) => {
let n = brepkit_topology::explorer::solid_faces(&topo, r)
.unwrap()
.len();
match super::assembly::validate_boolean_result(&topo, r) {
Ok(()) => format!("F={n:3} CLEAN"),
Err(e) => format!("F={n:3} {e}"),
}
}
Err(e) => format!("GFA ERR {e}"),
};
eprintln!("eps={eps:+.0e} half={:.9}: {msg}", d / 2.0);
}
}
#[test]
#[ignore = "diagnostic — is a tangent section circle broken for cylinders too?"]
fn diag_cylinder_box_tangency() {
use brepkit_math::mat::Mat4;
for &(label, d) in &[("cyl tangent d=8", 8.0), ("cyl clear d=10", 10.0)] {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 4.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, d, d, 8.0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&Mat4::translation(-d / 2.0, -d / 2.0, 6.0),
)
.unwrap();
match brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cyl, b) {
Ok(r) => {
let faces = brepkit_topology::explorer::solid_faces(&topo, r).unwrap();
eprintln!(
"{label}: F={:3} validate={:?}",
faces.len(),
super::assembly::validate_boolean_result(&topo, r)
.err()
.map(|e| e.to_string())
);
}
Err(e) => eprintln!("{label}: GFA ERR {e}"),
}
}
}
#[test]
#[ignore = "diagnostic — is the cone-box fallback caused by the tangency?"]
fn diag_cone_box_tangency_sweep() {
use brepkit_math::mat::Mat4;
for &(label, d, zb) in &[
("tangent d=8 zb=6", 8.0, 6.0),
("circle inside d=10 zb=6", 10.0, 6.0),
("circle outside d=6 zb=6", 6.0, 6.0),
("tangent d=6 zb=9", 6.0, 9.0),
("circle inside d=8 zb=9", 8.0, 9.0),
] {
let mut topo = Topology::new();
let cone = crate::primitives::make_cone(&mut topo, 6.0, 2.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, d, d, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(-d / 2.0, -d / 2.0, zb))
.unwrap();
match brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cone, b) {
Ok(r) => {
let faces = brepkit_topology::explorer::solid_faces(&topo, r).unwrap();
let z0 = faces
.iter()
.filter(|&&fid| {
let f = topo.face(fid).unwrap();
f.surface().type_tag() == "plane"
&& topo.wire(f.outer_wire()).unwrap().edges().iter().all(|oe| {
let e = topo.edge(oe.edge()).unwrap();
topo.vertex(e.start()).unwrap().point().z().abs() < 1e-9
&& topo.vertex(e.end()).unwrap().point().z().abs() < 1e-9
})
})
.count();
eprintln!(
"{label}: F={:3} z0_discs={z0} validate={:?}",
faces.len(),
super::assembly::validate_boolean_result(&topo, r)
.err()
.map(|e| e.to_string())
);
}
Err(e) => eprintln!("{label}: GFA ERR {e}"),
}
}
}
#[test]
fn tangent_wall_fuse_configurations_stay_analytic() {
use brepkit_math::mat::Mat4;
for &(label, xlo, xhi, ylo, yhi, expect_f) in &[
("4 tangent walls", -4.0, 4.0, -4.0, 4.0, 15usize),
("2 tangent walls (x only)", -4.0, 4.0, -9.0, 9.0, 11),
("1 tangent wall (x=+4)", -9.0, 4.0, -9.0, 9.0, 9),
("0 tangent walls", -9.0, 9.0, -9.0, 9.0, 8),
] {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 4.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, xhi - xlo, yhi - ylo, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(xlo, ylo, 6.0)).unwrap();
let r = brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cyl, b)
.unwrap_or_else(|e| panic!("{label}: fuse must not abort: {e}"));
let n = brepkit_topology::explorer::solid_faces(&topo, r)
.unwrap()
.len();
assert_eq!(n, expect_f, "{label}: analytic face count");
super::assembly::validate_boolean_result(&topo, r)
.unwrap_or_else(|e| panic!("{label}: result must validate: {e}"));
}
}
#[test]
#[ignore = "diagnostic — how many tangency points break the section circle?"]
fn diag_tangency_count() {
use brepkit_math::mat::Mat4;
for &(label, xlo, xhi, ylo, yhi) in &[
("4 tangent walls", -4.0, 4.0, -4.0, 4.0),
("2 tangent walls (x only)", -4.0, 4.0, -9.0, 9.0),
("1 tangent wall (x=+4)", -9.0, 4.0, -9.0, 9.0),
("0 tangent walls", -9.0, 9.0, -9.0, 9.0),
] {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 4.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, xhi - xlo, yhi - ylo, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(xlo, ylo, 6.0)).unwrap();
let msg =
match brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cyl, b)
{
Ok(r) => {
let n = brepkit_topology::explorer::solid_faces(&topo, r)
.unwrap()
.len();
match super::assembly::validate_boolean_result(&topo, r) {
Ok(()) => format!("F={n:3} CLEAN"),
Err(e) => format!("F={n:3} {e}"),
}
}
Err(e) => format!("GFA ERR {e}"),
};
eprintln!("{label}: {msg}");
}
}
#[test]
fn rotated_separate_pieces_are_recognised_as_disjoint() {
use brepkit_math::mat::Mat4;
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let diag = std::f64::consts::FRAC_PI_4;
let perp = 4.0 / 2.0_f64.sqrt();
let bar_a = crate::primitives::make_box(&mut topo, 20.0, 1.0, 1.0).unwrap();
crate::transform::transform_solid(&mut topo, bar_a, &Mat4::rotation_z(diag)).unwrap();
let bar_b = crate::primitives::make_box(&mut topo, 20.0, 1.0, 1.0).unwrap();
crate::transform::transform_solid(
&mut topo,
bar_b,
&(Mat4::translation(-perp, perp, 0.0) * Mat4::rotation_z(diag)),
)
.unwrap();
let comps: Vec<Vec<FaceId>> = [bar_a, bar_b]
.iter()
.map(|&s| solid_faces(&topo, s).unwrap())
.collect();
for (probe_of, against) in [(bar_a, bar_b), (bar_b, bar_a)] {
let bb = crate::measure::solid_bounding_box(&topo, probe_of).unwrap();
let centre = Point3::new(
(bb.min.x() + bb.max.x()) * 0.5,
(bb.min.y() + bb.max.y()) * 0.5,
(bb.min.z() + bb.max.z()) * 0.5,
);
assert_eq!(
crate::classify::classify_point(&topo, against, centre, 0.05, 1e-6).unwrap(),
crate::classify::PointClassification::Outside,
"bars must be disjoint for this test to mean anything"
);
}
let (a_bb, b_bb) = (
crate::measure::solid_bounding_box(&topo, bar_a).unwrap(),
crate::measure::solid_bounding_box(&topo, bar_b).unwrap(),
);
assert!(
a_bb.min.x().max(b_bb.min.x()) < a_bb.max.x().min(b_bb.max.x())
&& a_bb.min.y().max(b_bb.min.y()) < a_bb.max.y().min(b_bb.max.y())
&& a_bb.min.z().max(b_bb.min.z()) < a_bb.max.z().min(b_bb.max.z()),
"the AABBs must overlap for this test to exercise the defect"
);
assert!(
super::components_are_disjoint_pieces(&topo, &comps),
"rotated-but-separate pieces must be accepted as disjoint despite overlapping AABBs"
);
}
#[test]
fn nested_pieces_are_not_disjoint() {
use brepkit_math::mat::Mat4;
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let outer = crate::primitives::make_box(&mut topo, 20.0, 20.0, 20.0).unwrap();
let inner = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
crate::transform::transform_solid(&mut topo, inner, &Mat4::translation(9.0, 9.0, 9.0)).unwrap();
let comps: Vec<Vec<FaceId>> = [outer, inner]
.iter()
.map(|&s| solid_faces(&topo, s).unwrap())
.collect();
assert!(
!super::components_are_disjoint_pieces(&topo, &comps),
"a piece nested inside another must not count as a disjoint union"
);
}
#[test]
fn euler_balance_allows_genus_across_components() {
assert!(super::euler_balanced(26, 0, 13));
assert!(super::euler_balanced(14, 0, 13));
assert!(super::euler_balanced(8, 0, 7));
assert!(super::euler_balanced(34, 0, 23));
assert!(super::euler_balanced(2, 0, 1));
assert!(super::euler_balanced(0, 0, 1));
assert!(!super::euler_balanced(28, 0, 13));
assert!(!super::euler_balanced(13, 0, 13));
assert!(!super::euler_balanced(4, 0, 1));
assert!(super::euler_balanced(20, 6, 7));
}
#[test]
fn a_piece_in_a_rings_hole_is_disjoint() {
use brepkit_math::mat::Mat4;
use brepkit_topology::explorer::solid_faces;
let mut topo = Topology::new();
let ring = crate::primitives::make_torus(&mut topo, 10.0, 2.0, 48).unwrap();
let plug = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
crate::transform::transform_solid(&mut topo, plug, &Mat4::translation(-0.5, -0.5, -0.5))
.unwrap();
let (ring_bb, plug_bb) = (
crate::measure::solid_bounding_box(&topo, ring).unwrap(),
crate::measure::solid_bounding_box(&topo, plug).unwrap(),
);
assert!(
ring_bb.min.x() <= plug_bb.min.x()
&& ring_bb.min.y() <= plug_bb.min.y()
&& ring_bb.min.z() <= plug_bb.min.z()
&& ring_bb.max.x() >= plug_bb.max.x()
&& ring_bb.max.y() >= plug_bb.max.y()
&& ring_bb.max.z() >= plug_bb.max.z(),
"the ring's AABB must contain the plug's for this test to exercise the pre-filter"
);
let comps: Vec<Vec<FaceId>> = [ring, plug]
.iter()
.map(|&s| solid_faces(&topo, s).unwrap())
.collect();
assert!(
super::components_are_disjoint_pieces(&topo, &comps),
"a plug in the ring's hole is not enclosed by the ring's material"
);
}
#[test]
fn two_tangency_box_fuse_is_analytic_watertight() {
use brepkit_math::mat::Mat4;
for &cone in &[false, true] {
let mut topo = Topology::new();
let quad = if cone {
crate::primitives::make_cone(&mut topo, 6.0, 2.0, 12.0).unwrap()
} else {
crate::primitives::make_cylinder(&mut topo, 4.0, 12.0).unwrap()
};
let b = crate::primitives::make_box(&mut topo, 8.0, 18.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(-4.0, -9.0, 6.0))
.unwrap();
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, quad, b)
.unwrap();
assert_eq!(count_non_manifold_edges(&topo, result), 0);
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
assert!(
faces
.iter()
.all(|&fid| topo.face(fid).unwrap().surface().type_tag() != "nurbs"),
"all faces must stay analytic (cone={cone})"
);
let mesh = crate::tessellate::tessellate_solid(&topo, result, 0.05).unwrap();
assert_eq!(
crate::tessellate::boundary_edge_count(&mesh),
0,
"fused solid must tessellate watertight (cone={cone})"
);
let expected = if cone {
152.0 * std::f64::consts::PI + 1152.0
} else {
96.0 * std::f64::consts::PI + 1152.0
};
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - expected).abs() < 1.0,
"volume {vol} should be ~{expected} (cone={cone})"
);
}
}
#[test]
fn circle_outside_cone_box_fuse_is_watertight() {
use brepkit_math::mat::Mat4;
let mut topo = Topology::new();
let cone = crate::primitives::make_cone(&mut topo, 6.0, 2.0, 12.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 6.0, 6.0, 8.0).unwrap();
crate::transform::transform_solid(&mut topo, b, &Mat4::translation(-3.0, -3.0, 6.0)).unwrap();
let result =
brepkit_algo::gfa::boolean(&mut topo, brepkit_algo::bop::BooleanOp::Fuse, cone, b).unwrap();
assert_eq!(count_non_manifold_edges(&topo, result), 0);
let faces = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
assert!(
faces
.iter()
.all(|&fid| topo.face(fid).unwrap().surface().type_tag() != "nurbs"),
"faces must stay analytic (edges may be marched NURBS)"
);
let mesh = crate::tessellate::tessellate_solid(&topo, result, 0.05).unwrap();
assert_eq!(
crate::tessellate::boundary_edge_count(&mesh),
0,
"fused solid must tessellate watertight"
);
for (x, y, z) in [
(3.3, 0.0, 6.8),
(-3.3, 0.0, 6.8),
(0.0, 3.3, 6.8),
(0.0, -3.3, 6.8),
] {
let p = brepkit_math::vec::Point3::new(x, y, z);
let c = crate::classify::classify_point(&topo, result, p, 0.001, 1e-7).unwrap();
assert_eq!(
c,
crate::classify::PointClassification::Inside,
"lobe point ({x},{y},{z}) must classify inside"
);
}
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - 782.449).abs() < 1.0,
"volume {vol} should be ~782.449"
);
}
fn same_sense_pairs(topo: &Topology, solid: SolidId) -> Vec<(EdgeId, FaceId, FaceId)> {
use std::collections::HashMap;
let faces = brepkit_topology::explorer::solid_faces(topo, solid).unwrap();
let mut uses: HashMap<EdgeId, Vec<(FaceId, bool)>> = HashMap::new();
for &fid in &faces {
let face = topo.face(fid).unwrap();
let rev = face.is_reversed();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
for oe in topo.wire(wid).unwrap().edges() {
uses.entry(oe.edge())
.or_default()
.push((fid, oe.is_forward() != rev));
}
}
}
let mut pairs: Vec<(EdgeId, FaceId, FaceId)> = uses
.into_iter()
.filter(|(_, u)| u.len() == 2 && u[0].1 == u[1].1)
.map(|(eid, u)| (eid, u[0].0, u[1].0))
.collect();
pairs.sort_by_key(|(eid, _, _)| eid.index());
pairs
}
fn make_lip_ring_operands(topo: &mut Topology) -> (SolidId, SolidId) {
let section = |topo: &mut Topology, z: f64, inset: f64| {
let half = (41.5 - 2.0 * inset) / 2.0;
let r = (4.0 - inset).max(0.1);
make_rounded_rect_arc_face(topo, half, half, r, z)
};
let outer: Vec<FaceId> = [(-1.2, 0.0), (4.4, 0.0)]
.iter()
.map(|&(z, inset)| section(topo, z, inset))
.collect();
let inner: Vec<FaceId> = [(-1.2, 5.2), (0.0, 5.2), (0.7, 4.5), (2.5, 4.5), (4.4, 2.6)]
.iter()
.map(|&(z, inset)| section(topo, z, inset))
.collect();
let outer_solid = crate::loft::loft(topo, &outer).unwrap();
let inner_solid = crate::loft::loft(topo, &inner).unwrap();
(outer_solid, inner_solid)
}
fn planar_effective_windings(topo: &Topology, fid: FaceId) -> Option<(f64, Vec<f64>)> {
let face = topo.face(fid).unwrap();
let FaceSurface::Plane { normal, .. } = *face.surface() else {
return None;
};
let eff_normal = if face.is_reversed() {
normal * -1.0
} else {
normal
};
let signed_area = |wid| {
let wire = topo.wire(wid).unwrap();
let oes: Vec<_> = if face.is_reversed() {
wire.edges().iter().rev().collect()
} else {
wire.edges().iter().collect()
};
let mut pts: Vec<Point3> = Vec::new();
for oe in oes {
let edge = topo.edge(oe.edge()).unwrap();
let a = if oe.is_forward() == face.is_reversed() {
edge.end()
} else {
edge.start()
};
pts.push(topo.vertex(a).unwrap().point());
}
let n = pts.len();
let mut acc = Vec3::new(0.0, 0.0, 0.0);
let origin = pts[0];
for i in 1..n - 1 {
let u = pts[i] - origin;
let v = pts[i + 1] - origin;
acc += u.cross(v);
}
0.5 * acc.dot(eff_normal)
};
let outer = signed_area(face.outer_wire());
let inners = face.inner_wires().iter().map(|&w| signed_area(w)).collect();
Some((outer, inners))
}
#[test]
fn fillet_v2_cylinder_rim_bands_are_orientation_consistent() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 10.0, 20.0).unwrap();
let edges: Vec<EdgeId> = brepkit_topology::explorer::solid_edges(&topo, cyl).unwrap();
let result = crate::blend_ops::fillet_v2(&mut topo, cyl, &edges, 0.5)
.unwrap()
.solid;
let pairs = same_sense_pairs(&topo, result);
assert!(
pairs.is_empty(),
"rim-filleted cylinder must have no same-sense edge pairs, got {pairs:?}"
);
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
(vol - 6275.7).abs() < 2.0,
"rim-filleted cylinder volume should be ~6275.7 (raw 6283.2 minus two r=0.5 rounds), got {vol:.1}"
);
}
#[test]
fn coplanar_flush_pocket_cut_is_orientation_consistent() {
for (label, z0, z1) in [("bottom-flush", 0.0, 4.0), ("top-flush", 6.0, 10.0)] {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 20.0, 20.0, 10.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 6.0, 6.0, z1 - z0).unwrap();
crate::transform::transform_solid(
&mut topo,
b,
&brepkit_math::mat::Mat4::translation(7.0, 7.0, z0),
)
.unwrap();
let cut = boolean(&mut topo, BooleanOp::Cut, a, b).unwrap();
let pairs = same_sense_pairs(&topo, cut);
assert!(
pairs.is_empty(),
"{label} pocket cut must have no same-sense edge pairs, got {pairs:?}"
);
for fid in brepkit_topology::explorer::solid_faces(&topo, cut).unwrap() {
if let Some((outer, inners)) = planar_effective_windings(&topo, fid) {
assert!(
outer > 0.0,
"{label}: face#{} outer wire must wind effective-CCW, got {outer:+.1}",
fid.index()
);
for (i, &a) in inners.iter().enumerate() {
assert!(
a < 0.0,
"{label}: face#{} hole {i} must wind effective-CW, got {a:+.1}",
fid.index()
);
}
}
}
}
}
#[test]
fn lip_ring_loft_cut_is_orientation_consistent() {
let mut topo = Topology::new();
let (outer_solid, inner_solid) = make_lip_ring_operands(&mut topo);
for (label, sid) in [("outer", outer_solid), ("inner", inner_solid)] {
let p = same_sense_pairs(&topo, sid);
assert!(
p.is_empty(),
"{label} loft operand must have no same-sense edge pairs, got {p:?}"
);
}
let cut = boolean(&mut topo, BooleanOp::Cut, outer_solid, inner_solid).unwrap();
let pairs = same_sense_pairs(&topo, cut);
assert!(
pairs.is_empty(),
"lip-ring cut must have no same-sense edge pairs, got {pairs:?}"
);
}
#[test]
fn bench_equiv_intersect_box_corner_sphere_is_the_octant() {
let mut topo = Topology::new();
let b = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let s = crate::primitives::make_sphere(&mut topo, 8.0, 32).unwrap();
let r = boolean(&mut topo, BooleanOp::Intersect, b, s).unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.01).unwrap();
let exact = std::f64::consts::PI * 8.0_f64.powi(3) * 4.0 / 3.0 / 8.0;
assert!(
(vol - exact).abs() < 0.5,
"octant intersect volume should be ~{exact:.3}, got {vol:.3}"
);
let inside =
crate::classify::classify_point(&topo, r, Point3::new(1.0, 1.0, 1.0), 0.01, 1e-7).unwrap();
assert!(
matches!(inside, crate::classify::PointClassification::Inside),
"a point in the true octant must classify Inside, got {inside:?}"
);
}
#[test]
fn bench_equiv_cut_box_corner_cylinder_volume_is_exact() {
let mut topo = Topology::new();
let b = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let c = crate::primitives::make_cylinder(&mut topo, 3.0, 20.0).unwrap();
let r = boolean(&mut topo, BooleanOp::Cut, b, c).unwrap();
let vol = crate::measure::solid_volume(&topo, r, 0.01).unwrap();
let exact = 1000.0 - std::f64::consts::PI * 9.0 * 10.0 / 4.0;
assert!(
(vol - exact).abs() < 0.05,
"quarter-cylinder cut volume should be ~{exact:.4}, got {vol:.4}"
);
}
#[test]
fn mesh_fallback_counter_records_fallbacks() {
let mut topo = Topology::new();
let a = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let b = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let m = brepkit_math::mat::Mat4::translation(1.0, 1.0, 1.0);
crate::transform::transform_solid(&mut topo, b, &m).unwrap();
let clean = boolean(&mut topo, BooleanOp::Fuse, a, b).unwrap();
assert!(crate::measure::solid_volume(&topo, clean, 0.1).unwrap() > 0.0);
let c = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let d = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let m = brepkit_math::mat::Mat4::translation(1.0, 1.0, 0.0);
crate::transform::transform_solid(&mut topo, d, &m).unwrap();
let before = super::mesh_fallback_count();
let _ = boolean(&mut topo, BooleanOp::Fuse, c, d);
assert!(
super::mesh_fallback_count() > before,
"edge-touching fuse should route through the mesh fallback and increment the counter"
);
}
#[cfg(feature = "perf-counters")]
fn make_plate_pocket(topo: &mut Topology, cx: f64, cy: f64) -> SolidId {
make_plate_pocket_profiled(topo, cx, cy, false)
}
fn make_plate_pocket_profiled(topo: &mut Topology, cx: f64, cy: f64, simplified: bool) -> SolidId {
let cell = 42.0;
let sections: &[(f64, f64)] = if simplified {
&[(1.0, 0.0), (-5.0, 2.95), (-6.0, 2.95)]
} else {
&[
(1.0, 0.0),
(0.0, 0.0),
(-0.25, 0.0),
(-2.4, 2.15),
(-4.2, 2.15),
(-5.0, 2.95),
(-6.0, 2.95),
]
};
let profs: Vec<brepkit_topology::face::FaceId> = sections
.iter()
.map(|&(z, inset): &(f64, f64)| {
let w = cell - 2.0 * inset;
let r = (4.0_f64 - inset).max(0.1);
make_offset_rounded_rect_face(topo, cx, cy, w, r, z)
})
.collect();
crate::loft::loft(topo, &profs).unwrap()
}
fn make_offset_rounded_rect_face(
topo: &mut Topology,
cx: f64,
cy: f64,
w: f64,
r: f64,
z: f64,
) -> brepkit_topology::face::FaceId {
use brepkit_math::curves::Circle3D;
let tol_val = 1e-7;
let c = w / 2.0 - r;
let corners = [
(cx + c, cy + c, 0.0_f64),
(cx - c, cy + c, 90.0),
(cx - c, cy - c, 180.0),
(cx + c, cy - c, 270.0),
];
let pt = |kx: f64, ky: f64, deg: f64| {
let a = deg.to_radians();
Point3::new(kx + r * a.cos(), ky + r * a.sin(), z)
};
let mut vids = Vec::new();
for &(kx, ky, a0) in &corners {
vids.push(topo.add_vertex(Vertex::new(pt(kx, ky, a0), tol_val)));
vids.push(topo.add_vertex(Vertex::new(pt(kx, ky, a0 + 90.0), tol_val)));
}
let mut oes = Vec::new();
for (k, &(kx, ky, _)) in corners.iter().enumerate() {
let circle = Circle3D::new(Point3::new(kx, ky, z), Vec3::new(0.0, 0.0, 1.0), r).unwrap();
let arc = topo.add_edge(Edge::new(
vids[2 * k],
vids[2 * k + 1],
EdgeCurve::Circle(circle),
));
oes.push(OrientedEdge::new(arc, true));
let line = topo.add_edge(Edge::new(
vids[2 * k + 1],
vids[(2 * k + 2) % 8],
EdgeCurve::Line,
));
oes.push(OrientedEdge::new(line, true));
}
let wid = topo.add_wire(Wire::new(oes, true).unwrap());
topo.add_face(Face::new(
wid,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: z,
},
))
}
#[test]
fn compound_cut_edge_tangent_tools_stays_analytic() {
let mut counter_clean = false;
for _ in 0..3 {
let mut topo = Topology::new();
let slab = crate::primitives::make_box(&mut topo, 84.0, 84.0, 5.0).unwrap();
let mut pockets = Vec::new();
for i in 0..2 {
for j in 0..2 {
let p = make_plate_pocket_profiled(
&mut topo,
21.0 + 42.0 * f64::from(i),
21.0 + 42.0 * f64::from(j),
true,
);
crate::transform::transform_solid(
&mut topo,
p,
&brepkit_math::mat::Mat4::translation(0.0, 0.0, 5.0),
)
.unwrap();
pockets.push(p);
}
}
let fallbacks_before = super::mesh_fallback_count();
let result =
super::compound_cut(&mut topo, slab, &pockets, super::BooleanOptions::default())
.unwrap();
let fallback_delta = super::mesh_fallback_count() - fallbacks_before;
let face_ids = brepkit_topology::explorer::solid_faces(&topo, result).unwrap();
let cones = face_ids
.iter()
.filter(|&&f| {
matches!(
topo.face(f).unwrap().surface(),
brepkit_topology::face::FaceSurface::Cone(_)
)
})
.count();
assert!(
cones >= 8,
"edge-tangent pocket cut lost its analytic cones (got {cones} of {} faces) — \
the batch used a mesh-fallback tool",
face_ids.len()
);
let adj = brepkit_topology::adjacency::AdjacencyIndex::build(&topo, result).unwrap();
assert_eq!(adj.boundary_edges().len(), 0, "result must be watertight");
if fallback_delta == 0 {
counter_clean = true;
break;
}
}
assert!(
counter_clean,
"fallback counter grew on every attempt: the discarded probe fuse \
leaks its increment (export pipelines would read phantom degradation)"
);
}
#[cfg(feature = "perf-counters")]
#[test]
fn tangent_graze_section_fit_is_clipped() {
let mut topo = Topology::new();
let a = make_plate_pocket(&mut topo, 21.0, 21.0);
let b = make_plate_pocket(&mut topo, 63.0, 21.0);
brepkit_algo::perf::reset();
brepkit_algo::gfa::fuse_n(&mut topo, &[a, b]).unwrap();
let graze = brepkit_algo::perf::snapshot().section_fit_points;
let mut topo = Topology::new();
let a = make_plate_pocket(&mut topo, 21.0, 21.0);
let b = make_plate_pocket(&mut topo, 51.0, 21.0);
brepkit_algo::perf::reset();
brepkit_algo::gfa::fuse_n(&mut topo, &[a, b]).unwrap();
let overlap = brepkit_algo::perf::snapshot().section_fit_points;
eprintln!("section_fit_points: graze={graze} overlap={overlap}");
assert!(
overlap > 0,
"overlapping-pocket fuse should exercise the sampled section-fit path"
);
assert!(
graze < 600,
"grazing-pocket fuse fit {graze} section points — the chain clip regressed"
);
}
fn make_annular_wedge(
topo: &mut Topology,
r0: f64,
r1: f64,
z0: f64,
z1: f64,
angle: f64,
start_angle: f64,
) -> SolidId {
use brepkit_topology::builder::make_polygon_wire;
let wire = make_polygon_wire(
topo,
&[
Point3::new(r0, 0.0, z0),
Point3::new(r1, 0.0, z0),
Point3::new(r1, 0.0, z1),
Point3::new(r0, 0.0, z1),
],
1e-7,
)
.unwrap();
let face = topo.add_face(Face::new(
wire,
vec![],
FaceSurface::Plane {
normal: Vec3::new(0.0, 1.0, 0.0),
d: 0.0,
},
));
let solid = crate::revolve::revolve(
topo,
face,
Point3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
angle,
)
.unwrap();
if start_angle != 0.0 {
crate::transform::transform_solid(
topo,
solid,
&brepkit_math::mat::Mat4::rotation_z(start_angle),
)
.unwrap();
}
solid
}
#[test]
fn cut_wedge_by_thin_radial_strut_is_not_empty() {
use crate::measure::solid_volume;
let mut topo = Topology::new();
let base = make_annular_wedge(&mut topo, 2.85, 4.75, 2.7, 13.8, 2.2, 0.0);
let tool = make_annular_wedge(&mut topo, 2.8, 5.25, 2.1, 14.4, 0.44, 0.88);
let base_vol = solid_volume(&topo, base, 0.01).unwrap();
let tool_vol = solid_volume(&topo, tool, 0.01).unwrap();
assert!(
base_vol > tool_vol * 3.0,
"precondition: tool ({tool_vol}) must be much smaller than base ({base_vol})"
);
let result = boolean(&mut topo, BooleanOp::Cut, base, tool)
.expect("cut must not be trivially empty: the tool cannot contain the base");
let cut_vol = solid_volume(&topo, result, 0.01).unwrap();
let overlap = boolean(&mut topo, BooleanOp::Intersect, base, tool)
.expect("operands overlap, intersect must not be empty");
let overlap_vol = solid_volume(&topo, overlap, 0.01).unwrap();
assert!(
overlap_vol > 1.0,
"the strut pokes through the wedge, overlap must be substantial, got {overlap_vol}"
);
assert!(
(cut_vol + overlap_vol - base_vol).abs() / base_vol < 0.01,
"vol(A−B) + vol(A∩B) = vol(A) violated: {cut_vol} + {overlap_vol} ≠ {base_vol}"
);
}