use ifc_lite_processing::{process_geometry, MeshData, ProcessingResult};
const BOX: [f64; 3] = [4.0, 1.0, 2.0];
const SITE_T: [f64; 3] = [10.0, 20.0, 0.0];
const EPS: f64 = 1e-5;
fn model(site_placement: &str) -> String {
format!(
r##"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
FILE_NAME('','2026-01-01T00:00:00',(''),(''),'test','test','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
#2=IFCUNITASSIGNMENT((#1));
#3=IFCCARTESIANPOINT((0.,0.,0.));
#4=IFCAXIS2PLACEMENT3D(#3,$,$);
#5=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-06,#4,$);
#6=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#5,$,.MODEL_VIEW.,$);
#7=IFCPROJECT('11tEAnIV5BixApwp1YzpwS',$,'t',$,$,$,$,(#5),#2);
{site_placement}
#34=IFCLOCALPLACEMENT($,#33);
#35=IFCSITE('1s1tEAnIV5BixApwp1Yzp0',$,'site',$,$,#34,$,$,.ELEMENT.,$,$,$,$,$);
#8=IFCCARTESIANPOINT((0.,0.));
#9=IFCCARTESIANPOINT((4.,0.));
#10=IFCCARTESIANPOINT((4.,1.));
#11=IFCCARTESIANPOINT((0.,1.));
#12=IFCPOLYLINE((#8,#9,#10,#11,#8));
#13=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#12);
#14=IFCCARTESIANPOINT((0.,0.,0.));
#15=IFCAXIS2PLACEMENT3D(#14,$,$);
#16=IFCDIRECTION((0.,0.,1.));
#17=IFCEXTRUDEDAREASOLID(#13,#15,#16,2.);
#18=IFCSHAPEREPRESENTATION(#6,'Body','SweptSolid',(#17));
#19=IFCPRODUCTDEFINITIONSHAPE($,$,(#18));
#20=IFCCARTESIANPOINT((0.,0.,0.));
#21=IFCAXIS2PLACEMENT3D(#20,$,$);
#22=IFCLOCALPLACEMENT(#34,#21);
#23=IFCBUILDINGELEMENTPROXY('36FTsOKg956eWgO6DwnT8U',$,'box',$,$,#22,#19,$,$);
ENDSEC;
END-ISO-10303-21;
"##
)
}
const ROTATED_SITE_PLACEMENT: &str = r##"#30=IFCCARTESIANPOINT((10.,20.,0.));
#31=IFCDIRECTION((0.,0.,1.));
#32=IFCDIRECTION((0.866025403784439,0.5,0.));
#33=IFCAXIS2PLACEMENT3D(#30,#31,#32);"##;
const TRANSLATED_SITE_PLACEMENT: &str = r##"#30=IFCCARTESIANPOINT((10.,20.,0.));
#33=IFCAXIS2PLACEMENT3D(#30,$,$);"##;
const IDENTITY_SITE_PLACEMENT: &str = r##"#30=IFCCARTESIANPOINT((0.,0.,0.));
#33=IFCAXIS2PLACEMENT3D(#30,$,$);"##;
fn proxy_meshes(result: &ProcessingResult) -> Vec<&MeshData> {
let meshes: Vec<&MeshData> = result
.meshes
.iter()
.filter(|m| m.express_id == 23 && m.geometry_class == 0)
.collect();
assert!(!meshes.is_empty(), "expected meshes for the proxy (#23)");
meshes
}
fn frame_vertices(meshes: &[&MeshData]) -> Vec<[f64; 3]> {
let mut out = Vec::new();
for m in meshes {
for p in m.positions.chunks_exact(3) {
out.push([
p[0] as f64 + m.origin[0],
p[1] as f64 + m.origin[1],
p[2] as f64 + m.origin[2],
]);
}
}
assert!(!out.is_empty(), "proxy meshes have vertices");
out
}
fn bbox(v: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
let mut mn = [f64::INFINITY; 3];
let mut mx = [f64::NEG_INFINITY; 3];
for p in v {
for i in 0..3 {
mn[i] = mn[i].min(p[i]);
mx[i] = mx[i].max(p[i]);
}
}
(mn, mx)
}
fn approx(got: [f64; 3], want: [f64; 3], what: &str) {
for i in 0..3 {
assert!(
(got[i] - want[i]).abs() < EPS,
"{what}: axis {i} got {} want {}",
got[i],
want[i]
);
}
}
fn assert_normals_unit_and_axis_aligned(meshes: &[&MeshData]) {
let mut checked = 0usize;
for m in meshes {
for n in m.normals.chunks_exact(3) {
checked += 1;
let (x, y, z) = (n[0] as f64, n[1] as f64, n[2] as f64);
let len = (x * x + y * y + z * z).sqrt();
assert!(
(len - 1.0).abs() < 1e-3,
"normal not unit length: ({x}, {y}, {z})"
);
let ax = [x.abs(), y.abs(), z.abs()];
let max = ax[0].max(ax[1]).max(ax[2]);
let off_axis: f64 = ax.iter().sum::<f64>() - max;
assert!(
max > 0.999 && off_axis < 1e-3,
"normal not axis-aligned in the site-local frame: ({x}, {y}, {z})"
);
}
}
assert!(checked > 0, "no normals emitted; the axis-alignment check must not pass vacuously");
}
fn assert_vertices_match_corners(verts: &[[f64; 3]], expected: &[[f64; 3]], what: &str) {
for v in verts {
let matched = expected.iter().any(|e| {
(v[0] - e[0]).abs() < EPS && (v[1] - e[1]).abs() < EPS && (v[2] - e[2]).abs() < EPS
});
assert!(
matched,
"{what}: vertex ({}, {}, {}) matches no expected box corner",
v[0], v[1], v[2]
);
}
}
#[test]
fn rotated_site_meshes_are_inverse_rotated_into_site_local_frame() {
let ifc = model(ROTATED_SITE_PLACEMENT);
let result = process_geometry(&ifc);
assert_eq!(
result.mesh_coordinate_space.as_deref(),
Some("site_local"),
"non-identity site translation must select the site_local tier"
);
approx(
result.metadata.coordinate_info.origin_shift,
SITE_T,
"origin_shift (RTC) must be the site translation",
);
let c = 30f64.to_radians().cos();
let s = 30f64.to_radians().sin();
let st = result.site_transform.as_ref().expect("site transform resolved");
assert_eq!(st.len(), 16, "column-major 4x4");
for (idx, want) in [(0, c), (1, s), (4, -s), (5, c), (10, 1.0)] {
assert!(
(st[idx] - want).abs() < 1e-9,
"site_transform[{idx}] got {} want {want}",
st[idx]
);
}
approx([st[12], st[13], st[14]], SITE_T, "site_transform translation");
let mut expected = Vec::with_capacity(8);
for xi in [0.0, BOX[0]] {
for yi in [0.0, BOX[1]] {
for zi in [0.0, BOX[2]] {
let (lx, ly, lz) = (xi, yi, zi);
let wx = SITE_T[0] + c * lx - s * ly;
let wy = SITE_T[1] + s * lx + c * ly;
let wz = SITE_T[2] + lz;
let dx = wx - SITE_T[0];
let dy = wy - SITE_T[1];
let dz = wz - SITE_T[2];
expected.push([c * dx + s * dy, -s * dx + c * dy, dz]);
}
}
}
let meshes = proxy_meshes(&result);
let verts = frame_vertices(&meshes);
let (mn, mx) = bbox(&verts);
approx(mn, [0.0, 0.0, 0.0], "site-local AABB min");
approx(mx, BOX, "site-local AABB max");
assert_vertices_match_corners(&verts, &expected, "rotated site");
assert_normals_unit_and_axis_aligned(&meshes);
}
#[test]
fn translated_only_site_still_selects_site_local_and_keeps_axes() {
let ifc = model(TRANSLATED_SITE_PLACEMENT);
let result = process_geometry(&ifc);
assert_eq!(result.mesh_coordinate_space.as_deref(), Some("site_local"));
approx(
result.metadata.coordinate_info.origin_shift,
SITE_T,
"origin_shift (RTC) must be the site translation",
);
let meshes = proxy_meshes(&result);
let verts = frame_vertices(&meshes);
let (mn, mx) = bbox(&verts);
approx(mn, [0.0, 0.0, 0.0], "translated-site AABB min");
approx(mx, BOX, "translated-site AABB max");
assert_normals_unit_and_axis_aligned(&meshes);
}
#[test]
fn identity_site_passes_through_unchanged() {
let ifc = model(IDENTITY_SITE_PLACEMENT);
let result = process_geometry(&ifc);
assert_eq!(
result.mesh_coordinate_space.as_deref(),
Some("raw_ifc"),
"identity site must not trigger the site_local tier"
);
approx(
result.metadata.coordinate_info.origin_shift,
[0.0, 0.0, 0.0],
"identity site keeps a zero origin shift",
);
let meshes = proxy_meshes(&result);
let verts = frame_vertices(&meshes);
let (mn, mx) = bbox(&verts);
approx(mn, [0.0, 0.0, 0.0], "identity AABB min");
approx(mx, BOX, "identity AABB max");
assert_normals_unit_and_axis_aligned(&meshes);
}