#[cfg(not(feature = "csg_topology_gate"))]
use super::topology_diagnostic::OPEN_TOPOLOGY_MESSAGE;
use super::*;
fn aabb_to_mesh(min: Point3<f64>, max: Point3<f64>) -> Mesh {
let mut mesh = Mesh::with_capacity(8, 36);
let v0 = Point3::new(min.x, min.y, min.z);
let v1 = Point3::new(max.x, min.y, min.z);
let v2 = Point3::new(max.x, max.y, min.z);
let v3 = Point3::new(min.x, max.y, min.z);
let v4 = Point3::new(min.x, min.y, max.z);
let v5 = Point3::new(max.x, min.y, max.z);
let v6 = Point3::new(max.x, max.y, max.z);
let v7 = Point3::new(min.x, max.y, max.z);
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v2, v1));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v3, v2));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v4, v5, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v4, v6, v7));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v4, v7));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v7, v3));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v1, v2, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v1, v6, v5));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v1, v5));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v5, v4));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v3, v7, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v3, v6, v2));
mesh
}
#[test]
fn subtract_mesh_many_chunks_match_sequential() {
fn vol(m: &Mesh) -> f64 {
let p = |i: u32| {
let k = i as usize * 3;
[
m.positions[k] as f64,
m.positions[k + 1] as f64,
m.positions[k + 2] as f64,
]
};
let mut v = 0.0;
for t in m.indices.chunks_exact(3) {
let (a, b, c) = (p(t[0]), p(t[1]), p(t[2]));
v += a[0] * (b[1] * c[2] - c[1] * b[2])
- a[1] * (b[0] * c[2] - c[0] * b[2])
+ a[2] * (b[0] * c[1] - c[0] * b[1]);
}
(v / 6.0).abs()
}
let csg = ClippingProcessor::new();
let wall = aabb_to_mesh(Point3::new(0., 0., 0.), Point3::new(40., 3., 0.2));
let cutters: Vec<Mesh> = (0..20)
.map(|i| {
let x = 1.0 + i as f64 * 2.0; aabb_to_mesh(Point3::new(x, 1., -0.5), Point3::new(x + 1.0, 2., 0.7))
})
.collect();
let refs: Vec<&Mesh> = cutters.iter().collect();
let batched = csg
.subtract_mesh_many(&wall, &refs)
.expect("chunked subtract must conform");
let mut seq = wall.clone();
for c in &cutters {
seq = csg.subtract_mesh(&seq, c).expect("sequential subtract");
}
let (vb, vs) = (vol(&batched), vol(&seq));
assert!(
(vb - vs).abs() < 1e-4,
"chunked volume {vb} != sequential {vs} on 20 disjoint cutters"
);
assert!(
vb < vol(&wall) - 3.0,
"expected ~20 holes removed; wall {} -> {vb}",
vol(&wall)
);
}
#[test]
fn test_plane_signed_distance() {
let plane = Plane::new(Point3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 1.0));
assert_eq!(plane.signed_distance(&Point3::new(0.0, 0.0, 5.0)), 5.0);
assert_eq!(plane.signed_distance(&Point3::new(0.0, 0.0, -5.0)), -5.0);
assert_eq!(plane.signed_distance(&Point3::new(5.0, 5.0, 0.0)), 0.0);
}
#[test]
fn test_clip_triangle_all_front() {
let processor = ClippingProcessor::new();
let triangle = Triangle::new(
Point3::new(0.0, 0.0, 1.0),
Point3::new(1.0, 0.0, 1.0),
Point3::new(0.5, 1.0, 1.0),
);
let plane = Plane::new(Point3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 1.0));
match processor.clip_triangle(&triangle, &plane) {
ClipResult::AllFront(_) => {}
_ => panic!("Expected AllFront"),
}
}
#[test]
fn test_clip_triangle_all_behind() {
let processor = ClippingProcessor::new();
let triangle = Triangle::new(
Point3::new(0.0, 0.0, -1.0),
Point3::new(1.0, 0.0, -1.0),
Point3::new(0.5, 1.0, -1.0),
);
let plane = Plane::new(Point3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 1.0));
match processor.clip_triangle(&triangle, &plane) {
ClipResult::AllBehind => {}
_ => panic!("Expected AllBehind"),
}
}
#[test]
fn test_clip_triangle_split_one_front() {
let processor = ClippingProcessor::new();
let triangle = Triangle::new(
Point3::new(0.0, 0.0, 1.0), Point3::new(1.0, 0.0, -1.0), Point3::new(0.5, 1.0, -1.0), );
let plane = Plane::new(Point3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 1.0));
match processor.clip_triangle(&triangle, &plane) {
ClipResult::Split(triangles) => {
assert_eq!(triangles.len(), 1);
}
_ => panic!("Expected Split"),
}
}
#[test]
fn test_clip_triangle_split_two_front() {
let processor = ClippingProcessor::new();
let triangle = Triangle::new(
Point3::new(0.0, 0.0, 1.0), Point3::new(1.0, 0.0, 1.0), Point3::new(0.5, 1.0, -1.0), );
let plane = Plane::new(Point3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 1.0));
match processor.clip_triangle(&triangle, &plane) {
ClipResult::Split(triangles) => {
assert_eq!(triangles.len(), 2);
}
_ => panic!("Expected Split with 2 triangles"),
}
}
#[test]
fn test_triangle_normal() {
let triangle = Triangle::new(
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
);
let normal = triangle.normal();
assert!((normal.z - 1.0).abs() < 1e-6);
}
#[test]
fn test_triangle_area() {
let triangle = Triangle::new(
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
);
let area = triangle.area();
assert!((area - 0.5).abs() < 1e-6);
}
#[test]
fn degenerate_triangle_normal_is_plus_z_not_nan() {
let collapsed = Triangle::new(
Point3::new(1.0, 2.0, 3.0),
Point3::new(1.0, 2.0, 3.0),
Point3::new(4.0, 5.0, 6.0),
);
let collinear = Triangle::new(
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(2.0, 0.0, 0.0),
);
for (label, tri) in [("collapsed", collapsed), ("collinear", collinear)] {
let n = tri.normal();
assert!(
n.x.is_finite() && n.y.is_finite() && n.z.is_finite(),
"{label} triangle normal must be finite, got {n:?}"
);
assert_eq!(
n,
Vector3::new(0.0, 0.0, 1.0),
"{label} triangle must get the +Z convention"
);
}
}
#[test]
fn clip_mesh_never_emits_non_finite_normals() {
let clipper = ClippingProcessor::new();
let mut mesh = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
add_triangle_to_mesh(
&mut mesh,
&Triangle::new(
Point3::new(0.0, 0.0, 0.0),
Point3::new(0.5, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
),
);
assert!(
mesh.normals.iter().all(|v| v.is_finite()),
"the sliver's own normal must already be finite at insertion"
);
let plane = Plane::new(Point3::new(0.0, 0.0, 0.5), Vector3::new(0.0, 0.0, 1.0));
let flipped = Plane::new(plane.point, -plane.normal);
for (label, p) in [("front", &plane), ("back", &flipped)] {
let out = clipper.clip_mesh(&mesh, p).expect("clip must succeed");
assert!(
out.normals.iter().all(|v| v.is_finite()),
"{label} half produced a non-finite normal"
);
}
}
#[test]
fn plane_eps_is_invariant_under_negating_the_normal() {
use super::plane_eps::PlaneEps;
let mesh = Mesh {
positions: vec![
5.0e4, 0.0, 0.0, 0.0, 3.0e3, 0.0, 0.0, 0.0, 7.0e2, ],
indices: vec![0, 1, 2],
..Default::default()
};
let eps = PlaneEps::new(&mesh, 1e-6);
let normals = [
Vector3::new(0.0, 0.0, 1.0),
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(1.0, 1.0, 1.0).normalize(),
Vector3::new(0.6, 0.0, 0.8),
Vector3::new(1.0, 2.0, 3.0).normalize(),
];
for n in normals {
let pos = eps.for_normal(&n);
let neg = eps.for_normal(&(-n));
assert_eq!(
pos, neg,
"eps({n:?}) = {pos:e} but eps({:?}) = {neg:e}: the classification \
tolerance must depend on the plane's ORIENTATION, not on which \
way its normal happens to point. `router/layers.rs` clips one \
remainder with both `+n` and `-n` and welds the halves, so a \
direction-dependent epsilon opens a gap or an overlap at every \
material interface",
-n
);
assert!(
pos > 1e-6,
"fixture is vacuous: at this magnitude eps({n:?}) = {pos:e} must \
be set by the projected term, not by the 1e-6 floor, or the \
equality above is trivially true"
);
}
}
#[test]
fn difference_result_wrong_piece_check_is_per_axis_not_longest_dimension() {
let host = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(5.0, 0.4, 7.0));
let result = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(5.0, 0.41, 7.0));
assert!(
ClippingProcessor::difference_result_looks_degenerate(&host, &result),
"a result overshooting the host's thin Y face by 1 cm (4 mm per-axis \
slack on that axis) must be flagged as a wrong-piece degenerate result"
);
}
fn open_box_mesh(min: Point3<f64>, max: Point3<f64>) -> Mesh {
let mut mesh = Mesh::with_capacity(8, 30);
let v0 = Point3::new(min.x, min.y, min.z);
let v1 = Point3::new(max.x, min.y, min.z);
let v2 = Point3::new(max.x, max.y, min.z);
let v3 = Point3::new(min.x, max.y, min.z);
let v4 = Point3::new(min.x, min.y, max.z);
let v5 = Point3::new(max.x, min.y, max.z);
let v6 = Point3::new(max.x, max.y, max.z);
let v7 = Point3::new(min.x, max.y, max.z);
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v2, v1));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v3, v2));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v4, v7));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v7, v3));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v1, v2, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v1, v6, v5));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v1, v5));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v5, v4));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v3, v7, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v3, v6, v2));
mesh
}
#[cfg(not(feature = "csg_topology_gate"))] #[test]
fn topology_tear_recorded_by_every_boolean_op_without_gating() {
let open_host = open_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let overlapping = aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5));
let expected = BoolFailureReason::KernelError(OPEN_TOPOLOGY_MESSAGE.to_string());
let cases: Vec<(&str, BoolOp, Mesh)> = {
let p = ClippingProcessor::new();
let subtract = p.subtract_mesh(&open_host, &through_cutter).unwrap();
let batched = p.subtract_mesh_many(&open_host, &[&through_cutter]).unwrap();
let union = p.union_mesh(&open_host, &overlapping).unwrap();
let intersection = p.intersection_mesh(&open_host, &overlapping).unwrap();
let failures = p.take_failures();
assert_eq!(
failures.iter().map(|f| (f.op, f.reason.clone())).collect::<Vec<_>>(),
vec![
(BoolOp::Difference, expected.clone()),
(BoolOp::Difference, expected.clone()),
(BoolOp::Union, expected.clone()),
(BoolOp::Intersection, expected.clone()),
],
"each of the four accept paths must record exactly one open-topology tear"
);
vec![
("subtract_mesh", BoolOp::Difference, subtract),
("subtract_mesh_many", BoolOp::Difference, batched),
("union_mesh", BoolOp::Union, union),
("intersection_mesh", BoolOp::Intersection, intersection),
]
};
let p = ClippingProcessor::new();
for (name, _, mesh) in &cases {
assert!(!mesh.is_empty(), "{name} must return the kernel result, not an empty fallback");
assert!(p.validate_mesh(mesh), "{name}: validate_mesh must still accept the torn result");
assert!(
!crate::router::voids::prism_cut::closure_checks::directed_closed(mesh)
&& !crate::router::voids::prism_cut::closure_checks::closed_or_hairline(mesh),
"{name}: the returned mesh must fail BOTH halves of the audit's predicate, \
or this test cannot tell the hairline tolerance from its absence"
);
}
assert!(p.take_failures().is_empty());
}
#[test]
fn topology_tear_not_recorded_for_closed_results() {
let closed_host = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let overlapping = aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5));
let p = ClippingProcessor::new();
p.subtract_mesh(&closed_host, &through_cutter).unwrap();
p.subtract_mesh_many(&closed_host, &[&through_cutter]).unwrap();
p.union_mesh(&closed_host, &overlapping).unwrap();
p.intersection_mesh(&closed_host, &overlapping).unwrap();
assert_eq!(
p.take_failures(),
vec![],
"closed results must not record an open-topology tear"
);
}
#[cfg(all(
not(feature = "csg_topology_gate"),
not(feature = "csg_manifold_gate")
))]
#[test]
fn topology_tear_recorded_once_per_batched_subtract_not_once_per_chunk() {
let open_host = open_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(20.0, 1.0, 1.0));
let cutters: Vec<Mesh> = (0..17)
.map(|i| {
let x = 0.5 + f64::from(i);
aabb_to_mesh(Point3::new(x, -0.1, 0.3), Point3::new(x + 0.4, 1.1, 0.7))
})
.collect();
let expected = vec![BoolFailure {
op: BoolOp::Difference,
reason: BoolFailureReason::KernelError(OPEN_TOPOLOGY_MESSAGE.to_string()),
product_id: None,
}];
for cutter_count in [16usize, 17] {
let refs: Vec<&Mesh> = cutters.iter().take(cutter_count).collect();
let p = ClippingProcessor::new();
let result = p.subtract_mesh_many(&open_host, &refs).unwrap();
assert!(
result.triangle_count() > open_host.triangle_count(),
"{cutter_count} cutters: the group was rejected, so this proves nothing"
);
assert!(
!crate::router::voids::prism_cut::closure_checks::directed_closed(&result)
&& !crate::router::voids::prism_cut::closure_checks::closed_or_hairline(&result),
"{cutter_count} cutters: the returned mesh must fail BOTH halves of the predicate"
);
assert_eq!(
p.take_failures(),
expected,
"{cutter_count} cutters: one returned mesh, one record, whatever the chunk count"
);
}
}
fn t_junction_box_mesh(min: Point3<f64>, max: Point3<f64>) -> Mesh {
let mut mesh = Mesh::with_capacity(8, 39);
let v0 = Point3::new(min.x, min.y, min.z);
let v1 = Point3::new(max.x, min.y, min.z);
let v2 = Point3::new(max.x, max.y, min.z);
let v3 = Point3::new(min.x, max.y, min.z);
let v4 = Point3::new(min.x, min.y, max.z);
let v5 = Point3::new(max.x, min.y, max.z);
let v6 = Point3::new(max.x, max.y, max.z);
let v7 = Point3::new(min.x, max.y, max.z);
let m = Point3::new(min.x, min.y, 0.5 * (min.z + max.z));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v2, v1));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v3, v2));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v4, v5, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v4, v6, v7));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, m, v7));
add_triangle_to_mesh(&mut mesh, &Triangle::new(m, v4, v7));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v7, v3));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v1, v2, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v1, v6, v5));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v1, v5));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v0, v5, v4));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v3, v7, v6));
add_triangle_to_mesh(&mut mesh, &Triangle::new(v3, v6, v2));
mesh
}
#[test]
fn hairline_t_junction_is_not_recorded_as_a_topology_tear() {
use crate::router::voids::prism_cut::closure_checks::{closed_or_hairline, directed_closed};
let hairline = t_junction_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
assert!(
!directed_closed(&hairline),
"fixture must fail the strict audit, or it cannot separate the two predicates"
);
assert!(
closed_or_hairline(&hairline),
"fixture must pass the hairline gate, or it cannot separate the two predicates"
);
let p = ClippingProcessor::new();
p.record_topology_tear(BoolOp::Union, &hairline);
assert_eq!(
p.take_failures(),
vec![],
"a T-junction the analytic path accepts at every gate must not be recorded as a tear"
);
}
#[cfg(not(feature = "csg_topology_gate"))] #[test]
fn topology_tear_recorded_once_per_union_meshes_not_once_per_intermediate() {
use crate::router::voids::prism_cut::closure_checks::{closed_or_hairline, directed_closed};
let parts = vec![
open_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0)),
aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5)),
aabb_to_mesh(Point3::new(1.2, 1.2, 1.2), Point3::new(2.2, 2.2, 2.2)),
];
let p = ClippingProcessor::new();
let result = p.union_meshes(&parts).unwrap();
assert!(
!directed_closed(&result) && !closed_or_hairline(&result),
"the returned union must be torn, or one record is not the right answer either"
);
assert_eq!(
p.take_failures(),
vec![BoolFailure {
op: BoolOp::Union,
reason: BoolFailureReason::KernelError(OPEN_TOPOLOGY_MESSAGE.to_string()),
product_id: None,
}],
"one returned mesh, one record, whatever the intermediate count"
);
let p = ClippingProcessor::new();
let passthrough = p.union_meshes(&[parts[0].clone(), Mesh::new()]).unwrap();
assert_eq!(passthrough.triangle_count(), parts[0].triangle_count());
assert_eq!(
p.take_failures(),
vec![],
"no pair ever met, so there is no union result to record a tear against"
);
}
#[test]
fn union_fallback_validates_indices_before_topology_audit() {
let mut malformed = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
malformed.indices[0] = malformed.vertex_count() as u32;
let p = ClippingProcessor::new();
let returned = p.union_mesh(&malformed, &Mesh::new()).unwrap();
assert_eq!(returned.indices, malformed.indices, "the fallback remains non-gating");
assert!(
!p.validate_mesh(&returned),
"fixture must retain the out-of-bounds index or it cannot prove the guard"
);
assert!(
p.take_failures().is_empty(),
"an empty-operand pass-through is not a kernel result and must not gain a diagnostic"
);
}
#[cfg(not(feature = "csg_topology_gate"))]
#[test]
fn topology_gate_is_a_true_noop_without_the_feature() {
let open_host = open_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let overlapping = aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5));
let p = ClippingProcessor::new();
let subtract = p.subtract_mesh(&open_host, &through_cutter).unwrap();
let batched = p.subtract_mesh_many(&open_host, &[&through_cutter]).unwrap();
let union = p.union_mesh(&open_host, &overlapping).unwrap();
let intersection = p.intersection_mesh(&open_host, &overlapping).unwrap();
for (name, mesh) in [
("subtract_mesh", &subtract),
("subtract_mesh_many", &batched),
("union_mesh", &union),
("intersection_mesh", &intersection),
] {
assert!(
!mesh.is_empty() && mesh.triangle_count() > 4,
"{name}: gate must not have rejected this torn result without the feature"
);
}
assert!(
p.take_failures().iter().all(|f| f.reason != BoolFailureReason::OpenTopologyRejected),
"OpenTopologyRejected must never be recorded without csg_topology_gate"
);
}
#[cfg(feature = "csg_topology_gate")]
#[test]
fn topology_gate_rejects_every_torn_boolean_result_when_enabled() {
let open_host = open_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let overlapping = aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5));
let p = ClippingProcessor::new();
let subtract = p.subtract_mesh(&open_host, &through_cutter).unwrap();
let batched = p.subtract_mesh_many(&open_host, &[&through_cutter]).unwrap();
let union = p.union_mesh(&open_host, &overlapping).unwrap();
let intersection = p.intersection_mesh(&open_host, &overlapping).unwrap();
assert_eq!(subtract.indices, open_host.indices, "subtract_mesh must fall back to the un-cut host");
assert_eq!(batched.indices, open_host.indices, "subtract_mesh_many must fall back to the un-cut host");
assert!(intersection.is_empty(), "intersection_mesh must fall back to an empty mesh");
let mut expected_union_merge = open_host.clone();
expected_union_merge.merge(&overlapping);
assert_eq!(
union.triangle_count(),
expected_union_merge.triangle_count(),
"union_mesh must fall back to the plain merge"
);
let failures = p.take_failures();
assert_eq!(
failures.iter().map(|f| (f.op, f.reason.clone())).collect::<Vec<_>>(),
vec![
(BoolOp::Difference, BoolFailureReason::OpenTopologyRejected),
(BoolOp::Difference, BoolFailureReason::OpenTopologyRejected),
(BoolOp::Union, BoolFailureReason::OpenTopologyRejected),
(BoolOp::Intersection, BoolFailureReason::OpenTopologyRejected),
],
"each of the four accept paths must reject and record OpenTopologyRejected exactly once"
);
}
#[path = "world_frame_tests.rs"]
mod world_frame_tests;
fn finned_box_mesh(min: Point3<f64>, max: Point3<f64>) -> Mesh {
let mut mesh = aabb_to_mesh(min, max);
add_triangle_to_mesh(
&mut mesh,
&Triangle::new(
Point3::new(min.x, min.y, min.z),
Point3::new(max.x, min.y, min.z),
Point3::new(
0.5 * (min.x + max.x),
min.y - 0.5 * (max.y - min.y),
0.5 * (min.z + max.z),
),
),
);
mesh
}
#[cfg(feature = "csg_manifold_gate")]
#[test]
fn manifold_gate_rejects_a_non_manifold_result_at_the_accept_seam() {
let host = finned_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let overlapping = aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5));
let p = ClippingProcessor::new();
let subtract = p.subtract_mesh(&host, &through_cutter).unwrap();
let batched = p.subtract_mesh_many(&host, &[&through_cutter]).unwrap();
let union = p.union_mesh(&host, &overlapping).unwrap();
assert_eq!(
subtract.indices, host.indices,
"subtract_mesh must fall back to the un-cut host"
);
assert_eq!(
batched.indices, host.indices,
"subtract_mesh_many must fall back to the un-cut host"
);
let mut expected_union_merge = host.clone();
expected_union_merge.merge(&overlapping);
assert_eq!(
union.triangle_count(),
expected_union_merge.triangle_count(),
"union_mesh must fall back to the plain merge"
);
let rejected: Vec<BoolOp> = p
.take_failures()
.iter()
.filter(|f| matches!(f.reason, BoolFailureReason::NonManifoldRejected { .. }))
.map(|f| f.op)
.collect();
assert_eq!(
rejected,
vec![BoolOp::Difference, BoolOp::Difference, BoolOp::Union],
"each accept path must reject and record NonManifoldRejected exactly once"
);
}
#[cfg(feature = "csg_manifold_gate")]
#[test]
fn manifold_gate_reports_the_intersection_op_when_it_rejects() {
let torn = finned_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let p = ClippingProcessor::new();
assert!(
p.manifold_gate_reject(BoolOp::Intersection, &torn),
"a finned mesh must be rejected"
);
assert_eq!(
p.take_failures()
.iter()
.map(|f| (f.op, f.reason.clone()))
.collect::<Vec<_>>(),
vec![(
BoolOp::Intersection,
BoolFailureReason::NonManifoldRejected {
over_used: 1,
same_direction: 0,
}
)],
"the rejection must be attributed to the op that was passed in, with its counts"
);
}
#[cfg(feature = "csg_manifold_gate")]
#[test]
fn manifold_gate_accepts_a_clean_boolean_result() {
let host = aabb_to_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let overlapping = aabb_to_mesh(Point3::new(0.5, 0.5, 0.5), Point3::new(1.5, 1.5, 1.5));
let p = ClippingProcessor::new();
let cut = p.subtract_mesh(&host, &through_cutter).unwrap();
let union = p.union_mesh(&host, &overlapping).unwrap();
let intersection = p.intersection_mesh(&host, &overlapping).unwrap();
assert!(
cut.triangle_count() > host.triangle_count(),
"the clean cut must be the kernel result, not the un-cut host"
);
assert!(!union.is_empty() && !intersection.is_empty());
assert!(
p.take_failures()
.iter()
.all(|f| !matches!(f.reason, BoolFailureReason::NonManifoldRejected { .. })),
"clean results must record no multiplicity rejection"
);
}
#[cfg(all(
not(feature = "csg_manifold_gate"),
not(feature = "csg_topology_gate")
))]
#[test]
fn manifold_gate_is_a_true_noop_without_the_feature() {
let host = finned_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let through_cutter = aabb_to_mesh(Point3::new(0.4, 0.4, -0.1), Point3::new(0.6, 0.6, 1.1));
let p = ClippingProcessor::new();
let subtract = p.subtract_mesh(&host, &through_cutter).unwrap();
assert_ne!(
subtract.indices, host.indices,
"without the feature the torn kernel result must be returned, not the un-cut host"
);
assert!(
p.take_failures()
.iter()
.all(|f| !matches!(f.reason, BoolFailureReason::NonManifoldRejected { .. })),
"NonManifoldRejected must never be recorded without csg_manifold_gate"
);
}
#[test]
fn union_with_an_empty_operand_records_no_failure() {
let torn = finned_box_mesh(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 1.0));
let empty = Mesh::new();
let p = ClippingProcessor::new();
let right = p.union_mesh(&torn, &empty).unwrap();
let left = p.union_mesh(&empty, &torn).unwrap();
assert_eq!(
right.indices, torn.indices,
"an empty second operand must pass the first through untouched"
);
assert_eq!(
left.indices, torn.indices,
"an empty first operand must pass the second through untouched"
);
assert_eq!(
p.take_failures()
.iter()
.map(|f| format!("{:?}", f.reason))
.collect::<Vec<_>>(),
Vec::<String>::new(),
"a union that never ran must not be blamed for its operand's topology"
);
}