use ifc_lite_geometry::{compose_instance_world_row_major, instance_rel_row_major_f32};
use ifc_lite_processing::{
process_geometry_streaming_filtered_with_options, MeshData, OpeningFilterMode,
ProcessingResult, StreamingOptions,
};
use rustc_hash::FxHashMap;
fn fixture_bytes(name: &str) -> Vec<u8> {
let path = format!(
"{}/../geometry/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
fn run(content: &[u8]) -> ProcessingResult {
process_geometry_streaming_filtered_with_options(
content,
OpeningFilterMode::Default,
StreamingOptions::default(),
|_, _, _| {},
|_| {},
|_| {},
)
}
fn world_vertices(m: &MeshData) -> Vec<[f64; 3]> {
let n = m.positions.len() / 3;
(0..n)
.map(|v| {
[
m.origin[0] + m.positions[v * 3] as f64,
m.origin[1] + m.positions[v * 3 + 1] as f64,
m.origin[2] + m.positions[v * 3 + 2] as f64,
]
})
.collect()
}
fn apply(t: &[f32; 16], p: [f64; 3]) -> [f64; 3] {
let (x, y, z) = (p[0], p[1], p[2]);
let wx = t[0] as f64 * x + t[1] as f64 * y + t[2] as f64 * z + t[3] as f64;
let wy = t[4] as f64 * x + t[5] as f64 * y + t[6] as f64 * z + t[7] as f64;
let wz = t[8] as f64 * x + t[9] as f64 * y + t[10] as f64 * z + t[11] as f64;
let ww = t[12] as f64 * x + t[13] as f64 * y + t[14] as f64 * z + t[15] as f64;
[wx / ww, wy / ww, wz / ww]
}
fn max_vertex_error(a: &[[f64; 3]], b: &[[f64; 3]]) -> f64 {
assert_eq!(
a.len(),
b.len(),
"vertex count mismatch ({} vs {})",
a.len(),
b.len()
);
a.iter()
.zip(b)
.map(|(p, q)| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt())
.fold(0.0f64, f64::max)
}
#[test]
fn multi_item_mapped_per_occurrence_target_collates_under_shared_template() {
let bytes = fixture_bytes("mapped_instances_multi_item.ifc");
let res = run(&bytes);
let mut groups: FxHashMap<u128, Vec<&MeshData>> = FxHashMap::default();
for m in &res.meshes {
if m.positions.is_empty() {
continue;
}
if let Some(im) = m.instance.as_ref() {
if im.instanceable {
groups.entry(im.rep_identity).or_default().push(m);
}
}
}
assert_eq!(
groups.len(),
2,
"expected 2 rep_identity templates (one per source solid), got {} — the \
per-occurrence-target re-hash was not removed",
groups.len()
);
for m in &res.meshes {
assert!(
m.origin.iter().all(|c| c.abs() < 1.0),
"unexpected RTC origin {:?}; reconstruction assumes rtc = 0",
m.origin
);
}
let rtc = [0.0f64; 3];
let tol = 1e-4;
for (rep, occ) in &groups {
assert_eq!(
occ.len(),
4,
"rep_identity {rep:#x}: expected 4 collated occurrences, got {}",
occ.len()
);
let mut seen: Vec<[f64; 16]> = Vec::new();
for m in occ {
let lt = m
.instance
.as_ref()
.unwrap()
.local_transform
.unwrap_or_else(|| {
panic!(
"rep_identity {rep:#x}: occurrence {} lost its MappingTarget \
(local_transform = None)",
m.express_id
)
});
assert!(
!seen.iter().any(|s| s == <),
"rep_identity {rep:#x}: two occurrences share a local_transform — the \
distinct per-occurrence targets were not recorded"
);
seen.push(lt);
}
let reference = occ[0];
let m_ref = compose_instance_world_row_major(reference.instance.as_ref().unwrap());
let ref_world = world_vertices(reference);
for m in &occ[1..] {
let m_k = compose_instance_world_row_major(m.instance.as_ref().unwrap());
let rel = instance_rel_row_major_f32(&m_k, &m_ref, rtc)
.expect("degenerate reference placement");
let recomposed: Vec<[f64; 3]> = ref_world.iter().map(|&p| apply(&rel, p)).collect();
let err = max_vertex_error(&recomposed, &world_vertices(m));
assert!(
err < tol,
"rep_identity {rep:#x}: occurrence {} world-vertex error {err:.3e} m exceeds {tol:.0e}",
m.express_id
);
}
}
}
#[test]
fn nested_mapped_composes_outer_and_inner_targets() {
let bytes = fixture_bytes("mapped_instances_nested.ifc");
let res = run(&bytes);
let mut groups: FxHashMap<u128, Vec<&MeshData>> = FxHashMap::default();
for m in &res.meshes {
if m.positions.is_empty() {
continue;
}
if let Some(im) = m.instance.as_ref() {
if im.instanceable {
groups.entry(im.rep_identity).or_default().push(m);
}
}
}
assert_eq!(
groups.len(),
1,
"expected 1 rep_identity template (single nested source solid), got {}",
groups.len()
);
for m in &res.meshes {
assert!(
m.origin.iter().all(|c| c.abs() < 1.0),
"unexpected RTC origin {:?}; reconstruction assumes rtc = 0",
m.origin
);
}
let rtc = [0.0f64; 3];
let tol = 1e-4;
let occ = groups.values().next().unwrap();
assert_eq!(occ.len(), 3, "expected 3 nested occurrences, got {}", occ.len());
let mut seen: Vec<[f64; 16]> = Vec::new();
for m in occ {
let lt = m
.instance
.as_ref()
.unwrap()
.local_transform
.expect("nested occurrence lost its composed MappingTarget");
assert!(
!seen.iter().any(|s| s == <),
"two nested occurrences share a composed local_transform"
);
seen.push(lt);
}
assert!(
seen.iter().any(|lt| lt[1].abs() > 0.5 || lt[4].abs() > 0.5),
"no composed local_transform carries the outer rotation"
);
let reference = occ[0];
let m_ref = compose_instance_world_row_major(reference.instance.as_ref().unwrap());
let ref_world = world_vertices(reference);
for m in &occ[1..] {
let m_k = compose_instance_world_row_major(m.instance.as_ref().unwrap());
let rel = instance_rel_row_major_f32(&m_k, &m_ref, rtc)
.expect("degenerate reference placement");
let recomposed: Vec<[f64; 3]> = ref_world.iter().map(|&p| apply(&rel, p)).collect();
let err = max_vertex_error(&recomposed, &world_vertices(m));
assert!(
err < tol,
"nested occurrence {} world-vertex error {err:.3e} m exceeds {tol:.0e}",
m.express_id
);
}
}
const SOLID_A: u32 = 11;
const SOLID_B: u32 = 15;
const PRODUCTS: [u32; 4] = [40, 47, 54, 61];
const MAPPED_ITEMS: [u32; 4] = [34, 41, 48, 55];
const REPRESENTATION_MAP: u32 = 18;
const OCCURRENCES_PER_SOLID: usize = 4;
#[test]
fn instanced_occurrences_carry_their_source_solid_item_id() {
let bytes = fixture_bytes("mapped_instances_multi_item.ifc");
let res = run(&bytes);
let non_empty: Vec<&MeshData> = res.meshes.iter().filter(|m| !m.positions.is_empty()).collect();
let refs: Vec<ifc_lite_geometry::InstanceMeshRef> = non_empty
.iter()
.map(|m| ifc_lite_geometry::InstanceMeshRef {
positions: &m.positions,
normals: &m.normals,
indices: &m.indices,
origin: m.origin,
instance_meta: m.instance.as_ref(),
entity_id: m.express_id,
color: m.color,
item_id: m.geometry_item_id,
})
.collect();
let shard = ifc_lite_geometry::collate_and_encode(
&refs,
OCCURRENCES_PER_SOLID,
[0.0, 0.0, 0.0],
);
let decoded = ifc_lite_geometry::decode_instanced(&shard).expect("decode IFNS shard");
assert_eq!(
decoded.templates.len(),
2,
"expected one template per source solid, got {}",
decoded.templates.len()
);
assert_eq!(decoded.instances.len(), 2 * OCCURRENCES_PER_SOLID);
let mut items: Vec<u32> = decoded
.instances
.iter()
.map(|i| {
i.item_id.unwrap_or_else(|| {
panic!(
"occurrence of #{} carries NO item id — the id was computed and dropped",
i.entity_id
)
})
})
.collect();
items.sort_unstable();
assert_eq!(
items,
[
vec![SOLID_A; OCCURRENCES_PER_SOLID],
vec![SOLID_B; OCCURRENCES_PER_SOLID]
]
.concat(),
"item ids must be {OCCURRENCES_PER_SOLID} each of the two source solids \
#{SOLID_A}/#{SOLID_B}, got {items:?}"
);
for inst in &decoded.instances {
let item = inst.item_id.expect("checked above");
assert!(
!PRODUCTS.contains(&item),
"item id {item} is a product express id, not a representation item"
);
assert!(
!MAPPED_ITEMS.contains(&item),
"item id {item} is an IfcMappedItem id, not the solid it maps"
);
assert_ne!(
item, REPRESENTATION_MAP,
"item id is the shared IfcRepresentationMap, not the per-item solid"
);
assert_ne!(item, inst.entity_id);
}
for m in &non_empty {
assert!(
m.geometry_item_id.is_none() || m.material_id.is_none(),
"mesh of #{} carries BOTH geometry_item_id {:?} and material_id {:?}",
m.express_id,
m.geometry_item_id,
m.material_id
);
}
}