use ifc_lite_core::{build_entity_index, EntityDecoder, EntityScanner};
use ifc_lite_geometry::{propagate_voids_to_parts, GeometryRouter, Mesh};
use rustc_hash::FxHashMap;
const IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('rect-param gate fixture'),'2;1');
FILE_NAME('rect_param_gate.ifc','2026-07-03T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('0RectParamGate000000A',$,'RectParamGate',$,$,$,$,(#10),#7);
#7=IFCUNITASSIGNMENT((#8));
#8=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#11,$);
#11=IFCAXIS2PLACEMENT3D(#12,$,$);
#12=IFCCARTESIANPOINT((0.,0.,0.));
#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
#110=IFCLOCALPLACEMENT($,#111);
#111=IFCAXIS2PLACEMENT3D(#12,#112,#113);
#112=IFCDIRECTION((0.,0.,1.));
#113=IFCDIRECTION((0.8,0.6,0.));
#130=IFCRECTANGLEPROFILEDEF(.AREA.,'Wall',#131,4.0,0.3);
#131=IFCAXIS2PLACEMENT2D(#132,#133);
#132=IFCCARTESIANPOINT((0.,0.));
#133=IFCDIRECTION((1.,0.));
#140=IFCEXTRUDEDAREASOLID(#130,#141,#142,2.5);
#141=IFCAXIS2PLACEMENT3D(#12,$,$);
#142=IFCDIRECTION((0.,0.,1.));
#150=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#140));
#151=IFCPRODUCTDEFINITIONSHAPE($,$,(#150));
#100=IFCWALL('0RectParamGateWall00A',$,'Wall',$,$,#110,#151,$,$);
#210=IFCLOCALPLACEMENT(#110,#211);
#211=IFCAXIS2PLACEMENT3D(#212,#213,#214);
#212=IFCCARTESIANPOINT((0.,-0.5,1.25));
#213=IFCDIRECTION((0.,1.,0.));
#214=IFCDIRECTION((1.,0.,0.));
#227=IFCRECTANGLEPROFILEDEF(.AREA.,'Opening',#228,1.0,1.5);
#228=IFCAXIS2PLACEMENT2D(#229,#133);
#229=IFCCARTESIANPOINT((0.8,-0.15));
#231=IFCEXTRUDEDAREASOLID(#227,#141,#142,1.0);
#240=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#231));
#241=IFCPRODUCTDEFINITIONSHAPE($,$,(#240));
#200=IFCOPENINGELEMENT('0RectParamGateOpen00A',$,'Opening',$,$,#210,#241,$,.OPENING.);
#300=IFCRELVOIDSELEMENT('0RectParamGateVoid00A',$,$,$,#100,#200);
ENDSEC;
END-ISO-10303-21;
"#;
const HOST_ID: u32 = 100;
fn build_void_index(content: &str, decoder: &mut EntityDecoder) -> FxHashMap<u32, Vec<u32>> {
let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
let mut scanner = EntityScanner::new(content);
let mut scan_decoder = EntityDecoder::new(content);
while let Some((id, type_name, start, end)) = scanner.next_entity() {
if type_name == "IFCRELVOIDSELEMENT" {
if let Ok(entity) = scan_decoder.decode_at_with_id(id, start, end) {
if let (Some(host_id), Some(opening_id)) = (entity.get_ref(4), entity.get_ref(5)) {
void_index.entry(host_id).or_default().push(opening_id);
}
}
}
}
let _ = propagate_voids_to_parts(&mut void_index, content, decoder);
void_index
}
fn mesh_volume(mesh: &Mesh) -> f64 {
mesh.indices
.chunks_exact(3)
.map(|t| {
let v = |i: u32| {
let b = i as usize * 3;
[
mesh.positions[b] as f64,
mesh.positions[b + 1] as f64,
mesh.positions[b + 2] as f64,
]
};
let (a, b, c) = (v(t[0]), v(t[1]), v(t[2]));
a[0] * (b[1] * c[2] - b[2] * c[1]) + a[1] * (b[2] * c[0] - b[0] * c[2])
+ a[2] * (b[0] * c[1] - b[1] * c[0])
})
.sum::<f64>()
/ 6.0
}
fn watertight(mesh: &Mesh) -> (bool, usize, usize) {
let key = |i: u32| -> (i64, i64, i64) {
let b = i as usize * 3;
let q = |v: f32| (v as f64 / 1.0e-4).round() as i64;
(q(mesh.positions[b]), q(mesh.positions[b + 1]), q(mesh.positions[b + 2]))
};
let mut edges: FxHashMap<((i64, i64, i64), (i64, i64, i64)), i32> = FxHashMap::default();
for tri in mesh.indices.chunks_exact(3) {
let (ka, kb, kc) = (key(tri[0]), key(tri[1]), key(tri[2]));
if ka == kb || kb == kc || kc == ka {
continue;
}
for (x, y) in [(ka, kb), (kb, kc), (kc, ka)] {
let e = if x < y { (x, y) } else { (y, x) };
*edges.entry(e).or_insert(0) += 1;
}
}
let bad = edges.values().filter(|&&c| c != 2).count();
(!edges.is_empty() && bad == 0, edges.len(), bad)
}
fn analytic_cut_volume() -> f64 {
const WALL_LENGTH: f64 = 4.0;
const WALL_THICKNESS: f64 = 0.3;
const WALL_HEIGHT: f64 = 2.5;
const OPENING_LENGTH: f64 = 1.0;
const OPENING_HEIGHT: f64 = 1.5;
let clamped_opening_depth = WALL_THICKNESS;
let host_vol = WALL_LENGTH * WALL_THICKNESS * WALL_HEIGHT;
let opening_vol = OPENING_LENGTH * OPENING_HEIGHT * clamped_opening_depth;
(host_vol - opening_vol).abs()
}
const WALL_X: [f64; 3] = [0.8, 0.6, 0.0];
const WALL_Y: [f64; 3] = [-0.6, 0.8, 0.0];
const WALL_Z: [f64; 3] = [0.0, 0.0, 1.0];
fn vertices_in_wall_frame(mesh: &Mesh) -> Vec<[f64; 3]> {
mesh.positions
.chunks_exact(3)
.map(|v| {
let p = [v[0] as f64, v[1] as f64, v[2] as f64];
let dot = |a: [f64; 3]| a[0] * p[0] + a[1] * p[1] + a[2] * p[2];
[dot(WALL_X), dot(WALL_Y), dot(WALL_Z)]
})
.collect()
}
fn wall_frame_bounds(pts: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for p in pts {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
fn expected_hole_corners(z_base: f64) -> Vec<[f64; 3]> {
let xs = [0.3_f64, 1.3];
let ys = [-0.15_f64, 0.15];
let zs = [z_base + 0.65, z_base + 2.15];
let mut out = Vec::with_capacity(8);
for &x in &xs {
for &y in &ys {
for &z in &zs {
out.push([x, y, z]);
}
}
}
out
}
fn nearest(pts: &[[f64; 3]], p: [f64; 3]) -> (f64, [f64; 3]) {
let mut best = (f64::INFINITY, [0.0; 3]);
for &q in pts {
let d = ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2)).sqrt();
if d < best.0 {
best = (d, q);
}
}
best
}
#[test]
fn param_fast_path_fires_watertight_and_matches_analytic_on_the_shipped_default() {
let entity_index = build_entity_index(IFC);
let mut decoder = EntityDecoder::with_index(IFC, entity_index);
let router = GeometryRouter::with_units(IFC, &mut decoder);
let void_index = build_void_index(IFC, &mut decoder);
assert!(
void_index.contains_key(&HOST_ID),
"fixture rot: the wall must be a void host"
);
let host = decoder.decode_by_id(HOST_ID).expect("decode wall");
let before_param_fires = ifc_lite_geometry::rect_fast::take_param_fires();
let result = router
.process_element_with_voids(&host, &mut decoder, &void_index)
.expect("process wall with voids");
let emitted_param_cuts = ifc_lite_geometry::rect_fast::take_param_fires();
assert_eq!(
before_param_fires, 0,
"unexpected pre-existing PARAM_FIRES count before the call under test"
);
let stats = router.take_rect_fast_stats();
assert!(
stats.fired > 0,
"the parametric fast path must ENGAGE on a rotated rectangular wall with a \
through rectangular opening (fired={}, defers: host_not_box={} not_through={} \
off_face={} near_edge={} no_openings={})",
stats.fired,
stats.defer_host_not_box,
stats.defer_not_through,
stats.defer_off_face,
stats.defer_near_edge,
stats.defer_no_openings,
);
assert!(
emitted_param_cuts > 0,
"the parametric cut must be EMITTED (survive the watertight self-check), \
not merely engaged then discarded to the exact kernel (emitted={emitted_param_cuts})"
);
let (wt, edges, bad) = watertight(&result);
assert!(wt, "fired cut must be watertight ({bad} bad edges over {edges})");
let pv = mesh_volume(&result).abs();
let truth = analytic_cut_volume();
let rel = (pv - truth).abs() / truth.max(1.0e-9);
assert!(
rel < 0.02,
"fired cut volume {pv:.5} must match analytic ground truth {truth:.5} within 2% (rel={rel:.4})"
);
let local = vertices_in_wall_frame(&result);
let (lo, hi) = wall_frame_bounds(&local);
assert!((lo[0] - -2.0).abs() < 1e-3 && (hi[0] - 2.0).abs() < 1e-3,
"host wall-frame x extent {:?}..{:?} must be the fixture's 4.0 length", lo[0], hi[0]);
assert!((lo[1] - -0.15).abs() < 1e-3 && (hi[1] - 0.15).abs() < 1e-3,
"host wall-frame y extent {:?}..{:?} must be the fixture's 0.3 thickness", lo[1], hi[1]);
assert!((hi[2] - lo[2] - 2.5).abs() < 1e-3,
"host wall-frame z span {} must be the fixture's 2.5 height", hi[2] - lo[2]);
for corner in expected_hole_corners(lo[2]) {
let (dist, found) = nearest(&local, corner);
assert!(
dist < 1.0e-3,
"hole corner {corner:?} (wall frame) missing from the cut mesh: nearest \
vertex {found:?} is {dist:.5} m away — the opening landed in the wrong \
place or with the wrong in-plane dimensions"
);
}
let via_probe = router.parametric_rect_probe(&host, &mut decoder).expect("host probe");
let via_all = router
.parametric_rect_probe_all(&host, &mut decoder)
.expect("host probe_all");
assert_eq!(via_all.len(), 1, "single-item host must yield one box");
let a = &via_probe;
let b = &via_all[0];
assert!((a.center - b.center).norm() < 1e-12, "host center drift between probe paths");
assert!((a.r - b.r).norm() < 1e-12, "host frame drift between probe paths");
for i in 0..3 {
assert!((a.half[i] - b.half[i]).abs() < 1e-12, "host half-extent drift between probe paths");
}
}