mod voids_common;
use ifc_lite_core::EntityDecoder;
use ifc_lite_geometry::{csg::ClippingProcessor, GeometryRouter, Mesh};
use rustc_hash::FxHashMap;
use voids_common::fixtures::{
long_wall_with_many_tessellated_openings, slab_with_opening_ifc,
wall_with_trapezoid_opening_ifc,
};
struct InlineCtx<'a> {
content: &'a str,
voided: &'a Mesh,
}
struct InlineCase {
name: &'static str,
build: fn() -> (String, u32, Vec<u32>),
check: fn(&InlineCtx),
}
fn assert_structural_invariants(name: &str, uncut: &Mesh, voided: &Mesh) {
assert!(!uncut.is_empty(), "[{name}] host mesh should not be empty");
assert!(
!voided.is_empty(),
"[{name}] voided mesh should not be empty"
);
assert!(
!voided.positions.is_empty(),
"[{name}] voided positions should not be empty"
);
assert!(
!voided.normals.is_empty(),
"[{name}] voided normals should not be empty"
);
assert_eq!(
voided.normals.len(),
voided.positions.len(),
"[{name}] normals and positions should have matching lengths"
);
assert!(
voided.positions.iter().all(|v| v.is_finite()),
"[{name}] all positions should be finite"
);
assert!(
voided.normals.iter().all(|v| v.is_finite()),
"[{name}] all normals should be finite"
);
let (host_min, host_max) = uncut.bounds();
let (cut_min, cut_max) = voided.bounds();
assert!(
cut_min.x >= host_min.x - 0.01 && cut_max.x <= host_max.x + 0.01,
"[{name}] voided X bounds should be within host bounds"
);
assert!(
cut_min.y >= host_min.y - 0.01 && cut_max.y <= host_max.y + 0.01,
"[{name}] voided Y bounds should be within host bounds"
);
}
fn check_slab_direct_subtract_merge_bounds(ctx: &InlineCtx) {
let mut decoder = EntityDecoder::new(ctx.content);
let router = GeometryRouter::with_units(ctx.content, &mut decoder);
let slab = decoder.decode_by_id(100).expect("Failed to decode slab");
let slab_mesh = router
.process_element(&slab, &mut decoder)
.expect("Failed to process slab");
let opening = decoder.decode_by_id(200).expect("Failed to decode opening");
let opening_mesh = router
.process_element(&opening, &mut decoder)
.expect("Failed to process opening");
assert!(!slab_mesh.is_empty(), "Slab mesh should not be empty");
assert!(!opening_mesh.is_empty(), "Opening mesh should not be empty");
let (min, max) = slab_mesh.bounds();
let width = max.x - min.x;
let depth = max.y - min.y;
let height = max.z - min.z;
assert!(
(width - 4.0).abs() < 0.1,
"Slab width should be ~4m, got {width:.2}"
);
assert!(
(depth - 3.0).abs() < 0.1,
"Slab depth should be ~3m, got {depth:.2}"
);
assert!(
(height - 0.3).abs() < 0.1,
"Slab height should be ~0.3m, got {height:.2}"
);
let clipper = ClippingProcessor::new();
let result_mesh = clipper
.subtract_mesh(&slab_mesh, &opening_mesh)
.unwrap_or_else(|e| panic!("CSG subtraction failed: {e}"));
assert!(!result_mesh.is_empty(), "CSG result should not be empty");
assert!(
!result_mesh.positions.is_empty(),
"Result mesh positions should not be empty"
);
assert!(
!result_mesh.normals.is_empty(),
"Result mesh normals should not be empty"
);
assert_eq!(
result_mesh.normals.len(),
result_mesh.positions.len(),
"Normals and positions should have matching lengths"
);
assert!(
result_mesh.positions.iter().all(|v| v.is_finite()),
"All positions should be finite"
);
assert!(
result_mesh.normals.iter().all(|v| v.is_finite()),
"All normals should be finite"
);
let (slab_min, slab_max) = slab_mesh.bounds();
let (result_min, result_max) = result_mesh.bounds();
assert!(
result_min.x >= slab_min.x - 0.01 && result_max.x <= slab_max.x + 0.01,
"Result X bounds should be within slab bounds"
);
assert!(
result_min.y >= slab_min.y - 0.01 && result_max.y <= slab_max.y + 0.01,
"Result Y bounds should be within slab bounds"
);
let mut combined = Mesh::new();
combined.merge(&slab_mesh);
combined.merge(&opening_mesh);
assert_eq!(
combined.triangle_count(),
slab_mesh.triangle_count() + opening_mesh.triangle_count(),
"Combined mesh should have sum of triangles"
);
}
fn check_trapezoid_not_overcut(ctx: &InlineCtx) {
let mut narrow_edge_vertices = 0usize;
for chunk in ctx.voided.positions.chunks_exact(3) {
let x = chunk[0];
let y = chunk[1];
let z = chunk[2];
let on_face = y.abs() > 0.14 && z.abs() < 0.01;
if on_face && (x.abs() - 0.3).abs() < 0.01 {
narrow_edge_vertices += 1;
}
}
assert!(
narrow_edge_vertices > 0,
"trapezoid cut must introduce boundary vertices at (±0.3, ±0.15, 0) — \
the narrow end of the opening. The opening was cut as its AABB \
(bounding rectangle) instead of its trapezoid shape."
);
}
fn check_all_tessellated_openings_cut(ctx: &InlineCtx) {
const N: usize = 15;
let voided = ctx.voided;
let centroid = |chunk: &[u32]| {
let p = |i: u32| {
let idx = i as usize * 3;
(
voided.positions[idx],
voided.positions[idx + 1],
voided.positions[idx + 2],
)
};
let (a, b, c) = (p(chunk[0]), p(chunk[1]), p(chunk[2]));
(
(a.0 + b.0 + c.0) / 3.0,
(a.1 + b.1 + c.1) / 3.0,
(a.2 + b.2 + c.2) / 3.0,
)
};
for i in 0..N {
let cx = -45.0 + (i as f64) * 5.0;
let mut covering_triangles = 0usize;
for tri in voided.indices.chunks_exact(3) {
let (cxt, cyt, czt) = centroid(tri);
let margin = 0.05_f32;
let x_in =
(cxt as f64) > cx - 0.5 + margin as f64 && (cxt as f64) < cx + 0.5 - margin as f64;
let z_in = czt > 0.0 + margin && czt < 2.0 - margin;
let on_front = (cyt + 0.15).abs() < 0.02;
if on_front && x_in && z_in {
covering_triangles += 1;
}
}
assert_eq!(
covering_triangles, 0,
"opening #{i} (x centre {cx:.1}) has {covering_triangles} wall-front \
triangles inside its footprint — the cut was skipped (likely due to \
CSG budget exhaustion from misrouting tessellated boxes to CSG)."
);
}
}
#[test]
fn inline_void_matrix() {
let cases = [
InlineCase {
name: "slab_with_rect_opening__direct_subtract_merge_bounds",
build: || (slab_with_opening_ifc(), 100, vec![200]),
check: check_slab_direct_subtract_merge_bounds,
},
InlineCase {
name: "wall_with_trapezoid_opening__issue_547_no_aabb_overcut",
build: || (wall_with_trapezoid_opening_ifc(), 100, vec![200]),
check: check_trapezoid_not_overcut,
},
InlineCase {
name: "long_wall_15_tessellated_openings__no_csg_budget_skip",
build: || {
const N: usize = 15;
let opening_ids = (0..N).map(|i| 1000 + i as u32 * 20 + 17).collect();
(
long_wall_with_many_tessellated_openings(N),
100,
opening_ids,
)
},
check: check_all_tessellated_openings_cut,
},
];
for case in &cases {
let (content, host_id, opening_ids) = (case.build)();
let mut decoder = EntityDecoder::new(&content);
let router = GeometryRouter::with_units(&content, &mut decoder);
let host = decoder
.decode_by_id(host_id)
.unwrap_or_else(|e| panic!("[{}] decode host: {e:?}", case.name));
let uncut = router
.process_element(&host, &mut decoder)
.unwrap_or_else(|e| panic!("[{}] process host: {e:?}", case.name));
let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
void_index.insert(host_id, opening_ids);
let voided = router
.process_element_with_voids(&host, &mut decoder, &void_index)
.unwrap_or_else(|e| panic!("[{}] process host with voids: {e:?}", case.name));
assert_structural_invariants(case.name, &uncut, &voided);
(case.check)(&InlineCtx {
content: &content,
voided: &voided,
});
}
}