use super::IfcModel;
use super::entities::IfcEntity;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SceneNode {
pub name: String,
pub ifc_type: String,
pub entity_index: Option<usize>,
pub storey_index: Option<usize>,
pub children: Vec<SceneNode>,
}
impl SceneNode {
pub fn descendants_count(&self) -> usize {
1 + self
.children
.iter()
.map(SceneNode::descendants_count)
.sum::<usize>()
}
pub fn find_by_name<'a>(&'a self, name: &str) -> Option<&'a SceneNode> {
if self.name == name {
return Some(self);
}
for child in &self.children {
if let Some(found) = child.find_by_name(name) {
return Some(found);
}
}
None
}
pub fn flatten(&self) -> Vec<(usize, &SceneNode)> {
let mut out = Vec::new();
self.flatten_into(0, &mut out);
out
}
fn flatten_into<'a>(&'a self, depth: usize, out: &mut Vec<(usize, &'a SceneNode)>) {
out.push((depth, self));
for child in &self.children {
child.flatten_into(depth + 1, out);
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CategoryFilter {
pub hidden: std::collections::BTreeSet<String>,
}
impl CategoryFilter {
pub fn new() -> Self {
Self::default()
}
pub fn hide(&mut self, ifc_type: &str) {
self.hidden.insert(ifc_type.to_ascii_uppercase());
}
pub fn show(&mut self, ifc_type: &str) {
self.hidden.remove(&ifc_type.to_ascii_uppercase());
}
pub fn is_hidden(&self, ifc_type: &str) -> bool {
self.hidden.contains(&ifc_type.to_ascii_uppercase())
}
pub fn apply(&self, root: &SceneNode) -> SceneNode {
if self.hidden.is_empty() {
return root.clone();
}
SceneNode {
name: root.name.clone(),
ifc_type: root.ifc_type.clone(),
entity_index: root.entity_index,
storey_index: root.storey_index,
children: root
.children
.iter()
.filter(|child| !self.is_hidden(&child.ifc_type))
.map(|child| self.apply(child))
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelatedElement {
pub entity_index: usize,
pub name: String,
pub ifc_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelJoin {
pub end: String,
pub runs_through: bool,
pub wall: RelatedElement,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelRow {
pub label: String,
pub value: String,
}
impl PanelRow {
fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelProperty {
pub name: String,
pub value: String,
pub kind: String,
pub numeric: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelPropertyGroup {
pub name: String,
pub properties: Vec<PanelProperty>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PanelStorey {
pub index: usize,
pub name: String,
pub elevation_feet: f64,
pub elevation_label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelMaterial {
pub index: usize,
pub name: String,
pub element_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelLayer {
pub label: String,
pub value: String,
#[serde(default)]
pub material: Option<PanelMaterial>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelLayers {
pub name: String,
pub order: Option<String>,
pub rows: Vec<PanelLayer>,
pub total: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementInfoPanel {
pub name: String,
pub ifc_type: String,
pub type_guid: Option<String>,
#[serde(default)]
pub predefined_type: Option<String>,
pub storey_name: Option<String>,
pub storey_elevation_feet: Option<f64>,
#[serde(default)]
pub storey: Option<PanelStorey>,
pub location_feet: Option<[f64; 3]>,
pub rotation_radians: Option<f64>,
#[serde(default)]
pub placement_rows: Vec<PanelRow>,
#[serde(default)]
pub extent_rows: Vec<PanelRow>,
pub material_name: Option<String>,
#[serde(default)]
pub material: Option<PanelMaterial>,
#[serde(default)]
pub layers: Option<PanelLayers>,
pub properties: Vec<(String, String)>,
#[serde(default)]
pub property_group: Option<PanelPropertyGroup>,
#[serde(default)]
pub further_property_groups: Vec<PanelPropertyGroup>,
#[serde(default)]
pub host: Option<RelatedElement>,
#[serde(default)]
pub hosted: Vec<RelatedElement>,
#[serde(default)]
pub joins: Vec<PanelJoin>,
#[serde(default)]
pub missing: Vec<String>,
}
pub fn element_info_panel(model: &IfcModel, entity_index: usize) -> Option<ElementInfoPanel> {
let ent = model.entities.get(entity_index)?;
let IfcEntity::BuildingElement {
ifc_type,
name,
type_guid,
predefined_type,
storey_index,
material_index,
material_layer_set_index,
property_set,
location_feet,
rotation_radians,
extrusion,
host_element_index,
..
} = ent
else {
return None;
};
let storey = storey_index
.and_then(|i| model.building_storeys.get(i).map(|s| (i, s)))
.map(|(index, s)| PanelStorey {
index,
name: s.name.clone(),
elevation_feet: s.elevation_feet,
elevation_label: format_feet(s.elevation_feet),
});
let (storey_name, storey_elevation_feet) = match &storey {
Some(s) => (Some(s.name.clone()), Some(s.elevation_feet)),
None => (None, None),
};
let material = material_index
.and_then(|i| model.materials.get(i).map(|m| (i, m)))
.map(|(index, m)| PanelMaterial {
index,
name: m.name.clone(),
element_count: count_elements_using_material(model, index),
});
let material_name = material.as_ref().map(|m| m.name.clone());
let layers = material_layer_set_index
.and_then(|i| model.material_layer_sets.get(i))
.filter(|set| !set.layers.is_empty())
.map(|set| PanelLayers {
name: set.name.clone(),
order: model.material_layer_usages.get(&entity_index).map(|usage| {
match usage.direction {
crate::ifc::entities::LayerSetDirection::Axis3 => "top first",
_ => "exterior first",
}
.to_string()
}),
rows: set
.layers
.iter()
.map(|layer| {
let material = layer
.material_index
.and_then(|i| model.materials.get(i).map(|m| (i, m)))
.map(|(index, m)| PanelMaterial {
index,
name: m.name.clone(),
element_count: count_elements_using_material(model, index),
});
PanelLayer {
label: material
.as_ref()
.map_or_else(|| "Material not read".into(), |m| m.name.clone()),
value: format_feet(layer.thickness_feet),
material,
}
})
.collect(),
total: format_feet(set.layers.iter().map(|layer| layer.thickness_feet).sum()),
});
let properties: Vec<(String, String)> = property_set
.as_ref()
.map(|pset| {
pset.properties
.iter()
.map(|p| (p.name.clone(), format_property_value(&p.value)))
.collect()
})
.unwrap_or_default();
let panel_group = |pset: &crate::ifc::entities::PropertySet| PanelPropertyGroup {
name: pset.name.clone(),
properties: pset
.properties
.iter()
.map(|p| PanelProperty {
name: p.name.clone(),
value: format_property_value(&p.value),
kind: property_value_kind(&p.value).into(),
numeric: property_value_is_numeric(&p.value),
})
.collect(),
};
let property_group = property_set.as_ref().map(panel_group);
let further_property_groups: Vec<PanelPropertyGroup> = model
.entities
.iter()
.filter_map(|entity| match entity {
IfcEntity::ElementPropertySet { element, set } if *element == entity_index => {
Some(panel_group(set))
}
_ => None,
})
.collect();
let placement_rows = placement_rows(location_feet.as_ref(), rotation_radians.as_ref());
let extent_rows = extent_rows(extrusion.as_ref());
let missing = missing_fields(
storey.is_some(),
material.is_some(),
property_group
.as_ref()
.is_some_and(|g| !g.properties.is_empty()),
!placement_rows.is_empty(),
!extent_rows.is_empty(),
);
let host = host_element_index.and_then(|i| related_element(model, i));
let hosted: Vec<RelatedElement> = model
.entities
.iter()
.enumerate()
.filter(|(_, e)| {
matches!(
e,
IfcEntity::BuildingElement {
host_element_index: Some(h),
..
} if *h == entity_index
)
})
.filter_map(|(i, _)| related_element(model, i))
.collect();
let joins = wall_joins(model, property_set.as_ref());
Some(ElementInfoPanel {
name: name.clone(),
ifc_type: ifc_type.clone(),
type_guid: type_guid.clone(),
predefined_type: predefined_type.clone(),
storey_name,
storey_elevation_feet,
storey,
location_feet: *location_feet,
rotation_radians: *rotation_radians,
placement_rows,
extent_rows,
material_name,
material,
layers,
properties,
property_group,
further_property_groups,
host,
hosted,
joins,
missing,
})
}
fn wall_joins(
model: &IfcModel,
property_set: Option<&crate::ifc::entities::PropertySet>,
) -> Vec<PanelJoin> {
use super::entities::PropertyValue;
let Some(set) = property_set else {
return Vec::new();
};
let value = |name: String| {
set.properties
.iter()
.find(|p| p.name == name)
.map(|p| &p.value)
};
["Start", "End"]
.into_iter()
.filter_map(|end| {
let PropertyValue::Integer(id) = value(format!("Join{end}WallElementId"))? else {
return None;
};
let PropertyValue::Boolean(runs_through) = value(format!("Join{end}RunsThrough"))?
else {
return None;
};
let tag = id.to_string();
let index = model.entities.iter().position(|entity| {
matches!(entity, IfcEntity::BuildingElement { type_guid: Some(t), .. } if *t == tag)
})?;
Some(PanelJoin {
end: end.into(),
runs_through: *runs_through,
wall: related_element(model, index)?,
})
})
.collect()
}
fn count_elements_using_material(model: &IfcModel, material_index: usize) -> usize {
let in_set = |set: &usize| {
model.material_layer_sets.get(*set).is_some_and(|set| {
set.layers
.iter()
.any(|layer| layer.material_index == Some(material_index))
})
};
model
.entities
.iter()
.filter(|e| match e {
IfcEntity::BuildingElement {
material_index: direct,
material_layer_set_index: set,
..
} => *direct == Some(material_index) || set.as_ref().is_some_and(in_set),
_ => false,
})
.count()
}
fn format_feet(v: f64) -> String {
let rounded = if (v * 1000.0).round() == 0.0 { 0.0 } else { v };
format!("{rounded:.3} ft")
}
fn placement_rows(
location_feet: Option<&[f64; 3]>,
rotation_radians: Option<&f64>,
) -> Vec<PanelRow> {
let mut rows = Vec::new();
if let Some(loc) = location_feet {
rows.push(PanelRow::new("X", format_feet(loc[0])));
rows.push(PanelRow::new("Y", format_feet(loc[1])));
rows.push(PanelRow::new("Z", format_feet(loc[2])));
}
if let Some(rot) = rotation_radians {
rows.push(PanelRow::new(
"Rotation",
format!("{:.1}°", rot.to_degrees()),
));
}
rows
}
fn extent_rows(extrusion: Option<&super::entities::Extrusion>) -> Vec<PanelRow> {
let Some(ex) = extrusion else {
return Vec::new();
};
let mut rows = vec![
PanelRow::new("Width", format_feet(ex.width_feet)),
PanelRow::new("Depth", format_feet(ex.depth_feet)),
PanelRow::new("Height", format_feet(ex.height_feet)),
];
rows.push(PanelRow::new(
"Profile",
profile_label(ex.profile_override.as_ref()),
));
rows
}
fn profile_label(profile: Option<&super::entities::ProfileDef>) -> String {
use super::entities::ProfileDef;
match profile {
None | Some(ProfileDef::Rectangle { .. }) => "Rectangle".into(),
Some(ProfileDef::Circle { .. }) => "Circle".into(),
Some(ProfileDef::IShape { .. }) => "I-shape".into(),
Some(ProfileDef::TShape { .. }) => "T-shape".into(),
Some(ProfileDef::LShape { .. }) => "L-shape".into(),
Some(ProfileDef::UShape { .. }) => "U-shape".into(),
Some(ProfileDef::RectangleHollow { .. }) => "Rectangular tube".into(),
Some(ProfileDef::CircleHollow { .. }) => "Round tube".into(),
Some(ProfileDef::ArbitraryClosed { .. }) => "Arbitrary closed".into(),
Some(ProfileDef::ArbitraryWithVoids { .. }) => "Arbitrary with voids".into(),
}
}
fn missing_fields(
has_storey: bool,
has_material: bool,
has_properties: bool,
has_placement: bool,
has_extents: bool,
) -> Vec<String> {
let mut out = Vec::new();
if !has_storey {
out.push("storey".to_string());
}
if !has_material {
out.push("material".to_string());
}
if !has_properties {
out.push("properties".to_string());
}
if !has_placement {
out.push("placement".to_string());
}
if !has_extents {
out.push("extents".to_string());
}
out
}
fn related_element(model: &IfcModel, entity_index: usize) -> Option<RelatedElement> {
match model.entities.get(entity_index)? {
IfcEntity::BuildingElement { name, ifc_type, .. } => Some(RelatedElement {
entity_index,
name: name.clone(),
ifc_type: ifc_type.clone(),
}),
_ => None,
}
}
fn format_property_value(v: &super::entities::PropertyValue) -> String {
use super::entities::PropertyValue;
match v {
PropertyValue::Text(s) => s.clone(),
PropertyValue::Integer(i) => i.to_string(),
PropertyValue::Real(r) => format!("{r:.3}"),
PropertyValue::Boolean(b) => if *b { "Yes" } else { "No" }.to_string(),
PropertyValue::LengthFeet(f) | PropertyValue::PositiveLengthFeet(f) => format!("{f:.3} ft"),
PropertyValue::AngleRadians(r) => format!("{:.3}°", r.to_degrees()),
PropertyValue::AreaSquareFeet(a) => format!("{a:.2} sq ft"),
PropertyValue::VolumeCubicFeet(c) => format!("{c:.2} cu ft"),
PropertyValue::CountValue(c) => c.to_string(),
PropertyValue::TimeSeconds(t) => format!("{t:.1} s"),
PropertyValue::MassPounds(m) => format!("{m:.2} lb"),
}
}
fn property_value_kind(v: &super::entities::PropertyValue) -> &'static str {
use super::entities::PropertyValue;
match v {
PropertyValue::Text(_) => "text",
PropertyValue::Integer(_) => "integer",
PropertyValue::Real(_) => "real",
PropertyValue::Boolean(_) => "boolean",
PropertyValue::LengthFeet(_) | PropertyValue::PositiveLengthFeet(_) => "length",
PropertyValue::AngleRadians(_) => "angle",
PropertyValue::AreaSquareFeet(_) => "area",
PropertyValue::VolumeCubicFeet(_) => "volume",
PropertyValue::CountValue(_) => "count",
PropertyValue::TimeSeconds(_) => "time",
PropertyValue::MassPounds(_) => "mass",
}
}
fn property_value_is_numeric(v: &super::entities::PropertyValue) -> bool {
use super::entities::PropertyValue;
!matches!(v, PropertyValue::Text(_) | PropertyValue::Boolean(_))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScheduleRow {
pub entity_index: usize,
pub ifc_type: String,
pub name: String,
pub storey: String,
pub material: String,
pub guid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduleTypeGroup {
pub ifc_type: String,
pub count: usize,
pub entity_indices: Vec<usize>,
pub storeys: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Schedule {
pub rows: Vec<ScheduleRow>,
#[serde(default)]
pub groups: Vec<ScheduleTypeGroup>,
}
impl Schedule {
pub fn from_rows(rows: Vec<ScheduleRow>) -> Self {
let groups = group_rows_by_ifc_type(&rows);
Self { rows, groups }
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn filter_by_ifc_type(&self, ifc_type: &str) -> Schedule {
let target = ifc_type.to_ascii_uppercase();
Schedule::from_rows(
self.rows
.iter()
.filter(|r| r.ifc_type.to_ascii_uppercase() == target)
.cloned()
.collect(),
)
}
pub fn to_csv(&self) -> String {
let mut out = String::new();
out.push_str("entity_index,ifc_type,name,storey,material,guid\n");
for r in &self.rows {
out.push_str(&format!("{},", r.entity_index));
out.push_str(&csv_field(&r.ifc_type));
out.push(',');
out.push_str(&csv_field(&r.name));
out.push(',');
out.push_str(&csv_field(&r.storey));
out.push(',');
out.push_str(&csv_field(&r.material));
out.push(',');
out.push_str(&csv_field(&r.guid));
out.push('\n');
}
out
}
}
fn csv_field(s: &str) -> String {
let s = if s.starts_with(['=', '+', '-', '@', '\t', '\r']) {
format!("'{s}")
} else {
s.to_string()
};
if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s
}
}
pub fn build_schedule(model: &IfcModel) -> Schedule {
let mut rows: Vec<(f64, ScheduleRow)> = Vec::new();
for (idx, ent) in model.entities.iter().enumerate() {
if let IfcEntity::BuildingElement {
ifc_type,
name,
type_guid,
storey_index,
material_index,
..
} = ent
{
let (storey, elev) = storey_index
.and_then(|i| model.building_storeys.get(i))
.map(|s| (s.name.clone(), s.elevation_feet))
.unwrap_or_else(|| ("".into(), f64::INFINITY));
let material = material_index
.and_then(|i| model.materials.get(i))
.map(|m| m.name.clone())
.unwrap_or_default();
rows.push((
elev,
ScheduleRow {
entity_index: idx,
ifc_type: ifc_type.clone(),
name: name.clone(),
storey,
material,
guid: type_guid.clone().unwrap_or_default(),
},
));
}
}
rows.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.1.name.cmp(&b.1.name))
});
Schedule::from_rows(rows.into_iter().map(|(_, r)| r).collect())
}
fn group_rows_by_ifc_type(rows: &[ScheduleRow]) -> Vec<ScheduleTypeGroup> {
let mut order: Vec<String> = Vec::new();
let mut by_type: std::collections::HashMap<String, ScheduleTypeGroup> =
std::collections::HashMap::new();
for row in rows {
let group = by_type.entry(row.ifc_type.clone()).or_insert_with(|| {
order.push(row.ifc_type.clone());
ScheduleTypeGroup {
ifc_type: row.ifc_type.clone(),
count: 0,
entity_indices: Vec::new(),
storeys: Vec::new(),
}
});
group.count += 1;
group.entity_indices.push(row.entity_index);
if !row.storey.is_empty() && !group.storeys.iter().any(|s| s == &row.storey) {
group.storeys.push(row.storey.clone());
}
}
let mut groups: Vec<ScheduleTypeGroup> = order
.into_iter()
.filter_map(|t| by_type.remove(&t))
.collect();
groups.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.ifc_type.cmp(&b.ifc_type))
});
groups
}
pub fn distinct_ifc_types(root: &SceneNode) -> Vec<String> {
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
collect_types(root, &mut seen);
seen.into_iter().collect()
}
fn collect_types(node: &SceneNode, out: &mut std::collections::BTreeSet<String>) {
out.insert(node.ifc_type.clone());
for child in &node.children {
collect_types(child, out);
}
}
pub fn build_scene_graph(model: &IfcModel) -> SceneNode {
let project_name = model
.project_name
.clone()
.unwrap_or_else(|| "Project".into());
let mut per_storey: Vec<Vec<usize>> = vec![Vec::new(); model.building_storeys.len()];
let mut unassigned: Vec<usize> = Vec::new();
let mut hosted_children: std::collections::BTreeMap<usize, Vec<usize>> =
std::collections::BTreeMap::new();
let mut hosted_set: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
for (idx, ent) in model.entities.iter().enumerate() {
if let IfcEntity::BuildingElement {
storey_index,
host_element_index,
..
} = ent
{
if let Some(host) = host_element_index {
hosted_children.entry(*host).or_default().push(idx);
hosted_set.insert(idx);
continue;
}
match storey_index {
Some(si) if *si < per_storey.len() => per_storey[*si].push(idx),
_ => unassigned.push(idx),
}
}
}
let mut storey_nodes: Vec<SceneNode> = Vec::with_capacity(model.building_storeys.len() + 1);
for (si, storey) in model.building_storeys.iter().enumerate() {
let mut children: Vec<SceneNode> = Vec::with_capacity(per_storey[si].len());
for ent_idx in &per_storey[si] {
children.push(build_element_node(model, *ent_idx, &hosted_children));
}
storey_nodes.push(SceneNode {
name: storey.name.clone(),
ifc_type: "IFCBUILDINGSTOREY".into(),
entity_index: None,
storey_index: Some(si),
children,
});
}
if !unassigned.is_empty() {
let children: Vec<SceneNode> = unassigned
.iter()
.map(|i| build_element_node(model, *i, &hosted_children))
.collect();
storey_nodes.push(SceneNode {
name: "Unassigned".into(),
ifc_type: "IFCBUILDINGSTOREY".into(),
entity_index: None,
storey_index: None,
children,
});
}
SceneNode {
name: project_name,
ifc_type: "IFCPROJECT".into(),
entity_index: None,
storey_index: None,
children: storey_nodes,
}
}
fn build_element_node(
model: &IfcModel,
entity_idx: usize,
hosted_children: &std::collections::BTreeMap<usize, Vec<usize>>,
) -> SceneNode {
let (name, ifc_type) = match model.entities.get(entity_idx) {
Some(IfcEntity::BuildingElement { name, ifc_type, .. }) => (name.clone(), ifc_type.clone()),
_ => ("?".into(), "IFCBUILDINGELEMENTPROXY".into()),
};
let children: Vec<SceneNode> = hosted_children
.get(&entity_idx)
.into_iter()
.flat_map(|v| v.iter())
.map(|child_idx| build_element_node(model, *child_idx, hosted_children))
.collect();
SceneNode {
name,
ifc_type,
entity_index: Some(entity_idx),
storey_index: None,
children,
}
}
#[cfg(test)]
mod tests {
use super::super::Storey;
use super::super::entities::IfcEntity;
use super::*;
fn mk_element(
name: &str,
ifc_type: &str,
storey: Option<usize>,
host: Option<usize>,
) -> IfcEntity {
IfcEntity::BuildingElement {
ifc_type: ifc_type.into(),
name: name.into(),
type_guid: None,
predefined_type: None,
storey_index: storey,
material_index: None,
property_set: None,
location_feet: None,
rotation_radians: None,
extrusion: None,
host_element_index: host,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}
}
#[test]
fn scene_graph_root_is_project() {
let model = IfcModel {
project_name: Some("Test Project".into()),
..Default::default()
};
let scene = build_scene_graph(&model);
assert_eq!(scene.name, "Test Project");
assert_eq!(scene.ifc_type, "IFCPROJECT");
assert!(scene.entity_index.is_none());
}
#[test]
fn scene_graph_falls_back_to_default_project_name() {
let model = IfcModel::default();
let scene = build_scene_graph(&model);
assert_eq!(scene.name, "Project");
}
#[test]
fn scene_graph_one_storey_two_walls() {
let model = IfcModel {
project_name: Some("Single Storey".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None),
mk_element("Wall-2", "IFCWALL", Some(0), None),
],
..Default::default()
};
let scene = build_scene_graph(&model);
assert_eq!(scene.children.len(), 1); let storey = &scene.children[0];
assert_eq!(storey.name, "Ground");
assert_eq!(storey.children.len(), 2);
assert_eq!(storey.children[0].name, "Wall-1");
assert_eq!(storey.children[1].name, "Wall-2");
}
#[test]
fn scene_graph_nests_hosted_elements() {
let model = IfcModel {
project_name: Some("Hosted".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None), mk_element("Front Door", "IFCDOOR", Some(0), Some(0)), ],
..Default::default()
};
let scene = build_scene_graph(&model);
let storey = &scene.children[0];
assert_eq!(storey.children.len(), 1);
assert_eq!(storey.children[0].name, "Wall-1");
assert_eq!(storey.children[0].children.len(), 1);
assert_eq!(storey.children[0].children[0].name, "Front Door");
assert_eq!(storey.children[0].children[0].ifc_type, "IFCDOOR");
}
#[test]
fn scene_graph_unassigned_elements_go_to_synthetic_storey() {
let model = IfcModel {
project_name: Some("Unassigned".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None),
mk_element("Floating Column", "IFCCOLUMN", None, None),
],
..Default::default()
};
let scene = build_scene_graph(&model);
assert_eq!(scene.children.len(), 2); let unassigned = &scene.children[1];
assert_eq!(unassigned.name, "Unassigned");
assert_eq!(unassigned.children.len(), 1);
assert_eq!(unassigned.children[0].name, "Floating Column");
}
#[test]
fn descendants_count_includes_root() {
let model = IfcModel {
project_name: Some("Count".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None),
mk_element("Door-1", "IFCDOOR", Some(0), Some(0)),
],
..Default::default()
};
let scene = build_scene_graph(&model);
assert_eq!(scene.descendants_count(), 4);
}
#[test]
fn find_by_name_locates_nested_door() {
let model = IfcModel {
project_name: Some("Find".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None),
mk_element("Target Door", "IFCDOOR", Some(0), Some(0)),
],
..Default::default()
};
let scene = build_scene_graph(&model);
let found = scene.find_by_name("Target Door").unwrap();
assert_eq!(found.ifc_type, "IFCDOOR");
}
#[test]
fn find_by_name_returns_none_on_miss() {
let model = IfcModel::default();
let scene = build_scene_graph(&model);
assert!(scene.find_by_name("nonexistent").is_none());
}
#[test]
fn flatten_yields_depth_annotated_preorder() {
let model = IfcModel {
project_name: Some("Flatten".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None),
mk_element("Door-1", "IFCDOOR", Some(0), Some(0)),
],
..Default::default()
};
let scene = build_scene_graph(&model);
let flat = scene.flatten();
assert_eq!(flat.len(), 4);
assert_eq!(flat[0].0, 0);
assert_eq!(flat[0].1.ifc_type, "IFCPROJECT");
assert_eq!(flat[1].0, 1);
assert_eq!(flat[2].0, 2);
assert_eq!(flat[3].0, 3);
assert_eq!(flat[3].1.name, "Door-1");
}
#[test]
fn scene_graph_two_storeys_split_elements() {
let model = IfcModel {
project_name: Some("Two Storey".into()),
building_storeys: vec![
Storey {
name: "Ground".into(),
elevation_feet: 0.0,
},
Storey {
name: "Second".into(),
elevation_feet: 10.0,
},
],
entities: vec![
mk_element("W-Ground", "IFCWALL", Some(0), None),
mk_element("W-Second", "IFCWALL", Some(1), None),
],
..Default::default()
};
let scene = build_scene_graph(&model);
assert_eq!(scene.children.len(), 2);
assert_eq!(scene.children[0].children[0].name, "W-Ground");
assert_eq!(scene.children[1].children[0].name, "W-Second");
}
fn sample_scene() -> SceneNode {
let model = IfcModel {
project_name: Some("Scene".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall-1", "IFCWALL", Some(0), None),
mk_element("Door-1", "IFCDOOR", Some(0), Some(0)),
mk_element("Slab-1", "IFCSLAB", Some(0), None),
mk_element("Column-1", "IFCCOLUMN", Some(0), None),
],
..Default::default()
};
build_scene_graph(&model)
}
#[test]
fn category_filter_empty_returns_clone() {
let scene = sample_scene();
let filter = CategoryFilter::new();
let filtered = filter.apply(&scene);
assert_eq!(filtered, scene);
}
#[test]
fn category_filter_hides_matching_ifc_type() {
let scene = sample_scene();
let mut filter = CategoryFilter::new();
filter.hide("IFCWALL");
let filtered = filter.apply(&scene);
let storey = &filtered.children[0];
let names: Vec<&str> = storey.children.iter().map(|n| n.name.as_str()).collect();
assert!(!names.contains(&"Wall-1"));
assert!(!names.contains(&"Door-1")); assert!(names.contains(&"Slab-1"));
assert!(names.contains(&"Column-1"));
}
#[test]
fn category_filter_is_case_insensitive() {
let scene = sample_scene();
let mut filter = CategoryFilter::new();
filter.hide("ifcslab");
let filtered = filter.apply(&scene);
let storey = &filtered.children[0];
let names: Vec<&str> = storey.children.iter().map(|n| n.name.as_str()).collect();
assert!(!names.contains(&"Slab-1"));
}
#[test]
fn category_filter_show_removes_from_hide_list() {
let mut filter = CategoryFilter::new();
filter.hide("IFCWALL");
assert!(filter.is_hidden("IFCWALL"));
filter.show("IFCWALL");
assert!(!filter.is_hidden("IFCWALL"));
}
#[test]
fn category_filter_hides_multiple_types() {
let scene = sample_scene();
let mut filter = CategoryFilter::new();
filter.hide("IFCWALL");
filter.hide("IFCSLAB");
let filtered = filter.apply(&scene);
let storey = &filtered.children[0];
let names: Vec<&str> = storey.children.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, vec!["Column-1"]);
}
#[test]
fn distinct_ifc_types_enumerates_tree() {
let scene = sample_scene();
let types = distinct_ifc_types(&scene);
assert!(types.contains(&"IFCPROJECT".to_string()));
assert!(types.contains(&"IFCBUILDINGSTOREY".to_string()));
assert!(types.contains(&"IFCWALL".to_string()));
assert!(types.contains(&"IFCDOOR".to_string()));
assert!(types.contains(&"IFCSLAB".to_string()));
assert!(types.contains(&"IFCCOLUMN".to_string()));
}
#[test]
fn distinct_ifc_types_dedupes() {
let scene = sample_scene();
let types = distinct_ifc_types(&scene);
let wall_count = types.iter().filter(|t| *t == "IFCWALL").count();
assert_eq!(wall_count, 1);
}
#[test]
fn category_filter_serializable() {
let mut filter = CategoryFilter::new();
filter.hide("IFCWALL");
filter.hide("IFCCOLUMN");
let json = serde_json::to_string(&filter).unwrap();
let back: CategoryFilter = serde_json::from_str(&json).unwrap();
assert!(back.is_hidden("IFCWALL"));
assert!(back.is_hidden("IFCCOLUMN"));
assert!(!back.is_hidden("IFCSLAB"));
}
#[test]
fn info_panel_returns_none_for_out_of_range_index() {
let model = IfcModel::default();
assert!(element_info_panel(&model, 0).is_none());
}
#[test]
fn info_panel_returns_none_for_non_building_entity() {
let model = IfcModel {
entities: vec![IfcEntity::Project {
name: Some("P".into()),
description: None,
long_name: None,
}],
..Default::default()
};
assert!(element_info_panel(&model, 0).is_none());
}
#[test]
fn info_panel_surfaces_name_and_type() {
let model = IfcModel {
entities: vec![mk_element("Wall-1", "IFCWALL", Some(0), None)],
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
assert_eq!(panel.name, "Wall-1");
assert_eq!(panel.ifc_type, "IFCWALL");
assert_eq!(panel.storey_name.as_deref(), Some("Ground"));
assert_eq!(panel.storey_elevation_feet, Some(0.0));
}
#[test]
fn info_panel_resolves_material_through_model_list() {
let model = IfcModel {
entities: vec![IfcEntity::BuildingElement {
ifc_type: "IFCWALL".into(),
name: "Wall".into(),
type_guid: None,
predefined_type: None,
storey_index: None,
material_index: Some(0),
property_set: None,
location_feet: None,
rotation_radians: None,
extrusion: None,
host_element_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}],
materials: vec![super::super::MaterialInfo {
name: "Concrete".into(),
color_packed: None,
transparency: None,
}],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
assert_eq!(panel.material_name.as_deref(), Some("Concrete"));
}
#[test]
fn info_panel_formats_property_values() {
use super::super::entities::{Property, PropertySet, PropertyValue};
let pset = PropertySet {
name: "Pset_WallCommon".into(),
properties: vec![
Property {
name: "Height".into(),
value: PropertyValue::LengthFeet(10.5),
},
Property {
name: "IsExternal".into(),
value: PropertyValue::Boolean(true),
},
],
};
let model = IfcModel {
entities: vec![IfcEntity::BuildingElement {
ifc_type: "IFCWALL".into(),
name: "Wall".into(),
type_guid: None,
predefined_type: None,
storey_index: None,
material_index: None,
property_set: Some(pset),
location_feet: None,
rotation_radians: None,
extrusion: None,
host_element_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
assert_eq!(panel.properties.len(), 2);
let height = panel
.properties
.iter()
.find(|(n, _)| n == "Height")
.unwrap();
assert!(height.1.contains("10.500 ft"));
let ext = panel
.properties
.iter()
.find(|(n, _)| n == "IsExternal")
.unwrap();
assert_eq!(ext.1, "Yes");
}
#[derive(Default)]
struct Rich {
ifc_type: &'static str,
name: &'static str,
storey: Option<usize>,
material: Option<usize>,
pset: Option<super::super::entities::PropertySet>,
location: Option<[f64; 3]>,
rotation: Option<f64>,
extrusion: Option<super::super::entities::Extrusion>,
}
fn mk_rich_element(spec: Rich) -> IfcEntity {
IfcEntity::BuildingElement {
ifc_type: spec.ifc_type.into(),
name: spec.name.into(),
type_guid: None,
predefined_type: Some("STANDARD".into()),
storey_index: spec.storey,
material_index: spec.material,
property_set: spec.pset,
location_feet: spec.location,
rotation_radians: spec.rotation,
extrusion: spec.extrusion,
host_element_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}
}
#[test]
fn info_panel_resolves_storey_index_to_a_jump_target() {
let model = IfcModel {
entities: vec![mk_element("Col-1", "IFCCOLUMN", Some(1), None)],
building_storeys: vec![
Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
},
Storey {
name: "Level 2".into(),
elevation_feet: 12.5,
},
],
..Default::default()
};
let storey = element_info_panel(&model, 0).unwrap().storey.unwrap();
assert_eq!(storey.index, 1);
assert_eq!(storey.name, "Level 2");
assert_eq!(storey.elevation_feet, 12.5);
assert_eq!(storey.elevation_label, "12.500 ft");
}
#[test]
fn info_panel_omits_storey_when_the_index_is_unbound() {
let model = IfcModel {
entities: vec![mk_element("Col-1", "IFCCOLUMN", None, None)],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
assert!(panel.storey.is_none());
assert!(panel.missing.iter().any(|m| m == "storey"));
}
#[test]
fn info_panel_resolves_material_index_with_its_population() {
let model = IfcModel {
entities: vec![
mk_rich_element(Rich {
ifc_type: "IFCWALL",
name: "W1",
material: Some(0),
..Default::default()
}),
mk_rich_element(Rich {
ifc_type: "IFCWALL",
name: "W2",
material: Some(0),
..Default::default()
}),
mk_rich_element(Rich {
ifc_type: "IFCSLAB",
name: "S1",
material: Some(1),
..Default::default()
}),
],
materials: vec![
super::super::MaterialInfo {
name: "Concrete".into(),
color_packed: None,
transparency: None,
},
super::super::MaterialInfo {
name: "Glass".into(),
color_packed: None,
transparency: None,
},
],
..Default::default()
};
let material = element_info_panel(&model, 0).unwrap().material.unwrap();
assert_eq!(material.index, 0);
assert_eq!(material.name, "Concrete");
assert_eq!(material.element_count, 2);
}
#[test]
fn info_panel_groups_properties_with_kinds_and_units() {
use super::super::entities::{Property, PropertySet, PropertyValue};
let pset = PropertySet {
name: "Pset_WallCommon".into(),
properties: vec![
Property {
name: "Height".into(),
value: PropertyValue::LengthFeet(10.5),
},
Property {
name: "IsExternal".into(),
value: PropertyValue::Boolean(false),
},
Property {
name: "Reference".into(),
value: PropertyValue::Text("Interior - 5 1/2".into()),
},
Property {
name: "GrossArea".into(),
value: PropertyValue::AreaSquareFeet(84.25),
},
],
};
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCWALL",
name: "W1",
pset: Some(pset),
..Default::default()
})],
..Default::default()
};
let group = element_info_panel(&model, 0)
.unwrap()
.property_group
.unwrap();
assert_eq!(group.name, "Pset_WallCommon");
assert_eq!(group.properties.len(), 4);
let height = &group.properties[0];
assert_eq!(height.name, "Height");
assert_eq!(height.value, "10.500 ft");
assert_eq!(height.kind, "length");
assert!(height.numeric);
let is_external = &group.properties[1];
assert_eq!(is_external.value, "No");
assert_eq!(is_external.kind, "boolean");
assert!(!is_external.numeric);
let reference = &group.properties[2];
assert_eq!(reference.value, "Interior - 5 1/2");
assert_eq!(reference.kind, "text");
assert!(!reference.numeric);
let area = &group.properties[3];
assert_eq!(area.value, "84.25 sq ft");
assert_eq!(area.kind, "area");
}
#[test]
fn info_panel_labels_location_and_rotation_in_feet_and_degrees() {
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCCOLUMN",
name: "C1",
location: Some([12.5, -4.0, 0.0]),
rotation: Some(std::f64::consts::FRAC_PI_2),
..Default::default()
})],
..Default::default()
};
let rows = element_info_panel(&model, 0).unwrap().placement_rows;
assert_eq!(
rows.iter()
.map(|r| (r.label.as_str(), r.value.as_str()))
.collect::<Vec<_>>(),
vec![
("X", "12.500 ft"),
("Y", "-4.000 ft"),
("Z", "0.000 ft"),
("Rotation", "90.0°"),
]
);
}
#[test]
fn info_panel_prints_a_rounded_away_negative_as_plain_zero() {
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCDOOR",
name: "D1",
location: Some([1.0, -0.0, -1e-9]),
..Default::default()
})],
..Default::default()
};
let rows = element_info_panel(&model, 0).unwrap().placement_rows;
assert_eq!(rows[1].value, "0.000 ft");
assert_eq!(rows[2].value, "0.000 ft");
}
#[test]
fn info_panel_labels_extrusion_extents_in_feet() {
use super::super::entities::Extrusion;
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCWALL",
name: "W1",
extrusion: Some(Extrusion::rectangle(8.0, 0.5, 10.0)),
..Default::default()
})],
..Default::default()
};
let rows = element_info_panel(&model, 0).unwrap().extent_rows;
assert_eq!(
rows.iter()
.map(|r| (r.label.as_str(), r.value.as_str()))
.collect::<Vec<_>>(),
vec![
("Width", "8.000 ft"),
("Depth", "0.500 ft"),
("Height", "10.000 ft"),
("Profile", "Rectangle"),
]
);
}
#[test]
fn info_panel_names_a_non_rectangular_profile() {
use super::super::entities::Extrusion;
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCCOLUMN",
name: "C1",
extrusion: Some(Extrusion::circle(0.75, 10.0)),
..Default::default()
})],
..Default::default()
};
let rows = element_info_panel(&model, 0).unwrap().extent_rows;
assert_eq!(rows.last().unwrap().value, "Circle");
}
#[test]
fn info_panel_reports_every_absent_optional_as_missing() {
let model = IfcModel {
entities: vec![mk_element("W1", "IFCWALL", None, None)],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
assert_eq!(
panel.missing,
vec!["storey", "material", "properties", "placement", "extents"]
);
assert!(panel.placement_rows.is_empty());
assert!(panel.extent_rows.is_empty());
assert!(panel.property_group.is_none());
}
#[test]
fn info_panel_reports_nothing_missing_when_every_field_resolves() {
use super::super::entities::{Extrusion, Property, PropertySet, PropertyValue};
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCWALL",
name: "W1",
storey: Some(0),
material: Some(0),
pset: Some(PropertySet {
name: "Pset_WallCommon".into(),
properties: vec![Property {
name: "IsExternal".into(),
value: PropertyValue::Boolean(true),
}],
}),
location: Some([0.0, 0.0, 0.0]),
rotation: Some(0.0),
extrusion: Some(Extrusion::rectangle(8.0, 0.5, 10.0)),
})],
building_storeys: vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}],
materials: vec![super::super::MaterialInfo {
name: "Concrete".into(),
color_packed: None,
transparency: None,
}],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
assert!(panel.missing.is_empty());
assert_eq!(panel.predefined_type.as_deref(), Some("STANDARD"));
}
#[test]
fn info_panel_new_fields_survive_serde_as_snake_case() {
use super::super::entities::Extrusion;
let model = IfcModel {
entities: vec![mk_rich_element(Rich {
ifc_type: "IFCWALL",
name: "W1",
storey: Some(0),
location: Some([1.0, 2.0, 3.0]),
extrusion: Some(Extrusion::rectangle(1.0, 2.0, 3.0)),
..Default::default()
})],
building_storeys: vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
let json = serde_json::to_string(&panel).unwrap();
for key in [
"\"storey\"",
"\"elevation_label\"",
"\"placement_rows\"",
"\"extent_rows\"",
"\"missing\"",
"\"predefined_type\"",
] {
assert!(json.contains(key), "missing {key} in {json}");
}
let back: ElementInfoPanel = serde_json::from_str(&json).unwrap();
assert_eq!(back.storey.unwrap().name, "Level 1");
assert_eq!(back.placement_rows.len(), 3);
}
#[test]
fn info_panel_reports_host_and_hosted_openings() {
let model = IfcModel {
entities: vec![
mk_element("Wall-80747", "IFCWALL", Some(0), None),
mk_element("Door-80750", "IFCDOOR", Some(0), Some(0)),
mk_element("Window-80751", "IFCWINDOW", Some(0), Some(0)),
mk_element("Wall-80900", "IFCWALL", Some(0), None),
],
building_storeys: vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}],
..Default::default()
};
let door = element_info_panel(&model, 1).unwrap();
assert_eq!(
door.host,
Some(RelatedElement {
entity_index: 0,
name: "Wall-80747".into(),
ifc_type: "IFCWALL".into(),
})
);
assert!(door.hosted.is_empty());
let wall = element_info_panel(&model, 0).unwrap();
assert!(wall.host.is_none());
assert_eq!(
wall.hosted
.iter()
.map(|r| (r.entity_index, r.ifc_type.as_str()))
.collect::<Vec<_>>(),
vec![(1, "IFCDOOR"), (2, "IFCWINDOW")]
);
let bare = element_info_panel(&model, 3).unwrap();
assert!(bare.host.is_none());
assert!(bare.hosted.is_empty());
}
#[test]
fn info_panel_host_fields_survive_serde_as_snake_case() {
let model = IfcModel {
entities: vec![
mk_element("Wall-1", "IFCWALL", None, None),
mk_element("Door-1", "IFCDOOR", None, Some(0)),
],
..Default::default()
};
let panel = element_info_panel(&model, 1).unwrap();
let json = serde_json::to_string(&panel).unwrap();
assert!(json.contains("\"host\""));
assert!(json.contains("\"entity_index\""));
assert!(json.contains("\"ifc_type\""));
let back: ElementInfoPanel = serde_json::from_str(&json).unwrap();
assert_eq!(back.host.unwrap().name, "Wall-1");
}
#[test]
fn info_panel_is_serde_roundtrippable() {
let model = IfcModel {
entities: vec![mk_element("W", "IFCWALL", None, None)],
..Default::default()
};
let panel = element_info_panel(&model, 0).unwrap();
let json = serde_json::to_string(&panel).unwrap();
let back: ElementInfoPanel = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "W");
}
#[test]
fn empty_model_produces_empty_schedule() {
let s = build_schedule(&IfcModel::default());
assert!(s.is_empty());
assert_eq!(s.len(), 0);
}
#[test]
fn schedule_has_one_row_per_building_element() {
let model = IfcModel {
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("W1", "IFCWALL", Some(0), None),
mk_element("S1", "IFCSLAB", Some(0), None),
mk_element("C1", "IFCCOLUMN", Some(0), None),
],
..Default::default()
};
let s = build_schedule(&model);
assert_eq!(s.len(), 3);
}
#[test]
fn schedule_skips_non_building_entities() {
let model = IfcModel {
entities: vec![
mk_element("W1", "IFCWALL", None, None),
IfcEntity::Project {
name: Some("P".into()),
description: None,
long_name: None,
},
],
..Default::default()
};
assert_eq!(build_schedule(&model).len(), 1);
}
#[test]
fn schedule_rows_are_sorted_by_storey_then_name() {
let model = IfcModel {
building_storeys: vec![
Storey {
name: "2nd".into(),
elevation_feet: 10.0,
},
Storey {
name: "Ground".into(),
elevation_feet: 0.0,
},
],
entities: vec![
mk_element("Z-upstairs", "IFCWALL", Some(0), None),
mk_element("A-ground", "IFCWALL", Some(1), None),
mk_element("B-ground", "IFCWALL", Some(1), None),
],
..Default::default()
};
let s = build_schedule(&model);
assert_eq!(s.rows[0].name, "A-ground");
assert_eq!(s.rows[1].name, "B-ground");
assert_eq!(s.rows[2].name, "Z-upstairs");
}
#[test]
fn schedule_resolves_material_name() {
let model = IfcModel {
entities: vec![IfcEntity::BuildingElement {
ifc_type: "IFCWALL".into(),
name: "W1".into(),
type_guid: None,
predefined_type: None,
storey_index: None,
material_index: Some(0),
property_set: None,
location_feet: None,
rotation_radians: None,
extrusion: None,
host_element_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}],
materials: vec![super::super::MaterialInfo {
name: "Concrete".into(),
color_packed: None,
transparency: None,
}],
..Default::default()
};
let s = build_schedule(&model);
assert_eq!(s.rows[0].material, "Concrete");
}
#[test]
fn schedule_filter_by_ifc_type_is_case_insensitive() {
let model = IfcModel {
entities: vec![
mk_element("W1", "IFCWALL", None, None),
mk_element("S1", "IFCSLAB", None, None),
mk_element("W2", "IFCWALL", None, None),
],
..Default::default()
};
let full = build_schedule(&model);
let walls = full.filter_by_ifc_type("ifcwall");
assert_eq!(walls.len(), 2);
}
#[test]
fn schedule_csv_has_header_plus_rows() {
let model = IfcModel {
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![mk_element("W1", "IFCWALL", Some(0), None)],
..Default::default()
};
let csv = build_schedule(&model).to_csv();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines[0], "entity_index,ifc_type,name,storey,material,guid");
assert!(lines[1].contains("IFCWALL"));
assert!(lines[1].contains("W1"));
assert!(lines[1].contains("Ground"));
}
#[test]
fn schedule_csv_escapes_commas_in_names() {
let model = IfcModel {
entities: vec![mk_element("Wall, Fancy", "IFCWALL", None, None)],
..Default::default()
};
let csv = build_schedule(&model).to_csv();
assert!(csv.contains("\"Wall, Fancy\""));
}
#[test]
fn schedule_csv_escapes_double_quotes() {
let model = IfcModel {
entities: vec![mk_element("He said \"hi\"", "IFCWALL", None, None)],
..Default::default()
};
let csv = build_schedule(&model).to_csv();
assert!(csv.contains("\"He said \"\"hi\"\"\""));
}
#[test]
fn schedule_groups_by_ifc_type_biggest_first() {
let model = IfcModel {
building_storeys: vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("W1", "IFCWALL", Some(0), None),
mk_element("D1", "IFCDOOR", Some(0), None),
mk_element("W2", "IFCWALL", Some(0), None),
mk_element("W3", "IFCWALL", Some(0), None),
mk_element("S1", "IFCSLAB", Some(0), None),
],
..Default::default()
};
let s = build_schedule(&model);
assert_eq!(
s.groups
.iter()
.map(|g| (g.ifc_type.as_str(), g.count))
.collect::<Vec<_>>(),
vec![("IFCWALL", 3), ("IFCDOOR", 1), ("IFCSLAB", 1)]
);
assert_eq!(s.groups.iter().map(|g| g.count).sum::<usize>(), s.len());
}
#[test]
fn schedule_groups_carry_every_entity_index_for_scene_highlight() {
let model = IfcModel {
entities: vec![
mk_element("W1", "IFCWALL", None, None),
mk_element("S1", "IFCSLAB", None, None),
mk_element("W2", "IFCWALL", None, None),
],
..Default::default()
};
let s = build_schedule(&model);
let walls = s.groups.iter().find(|g| g.ifc_type == "IFCWALL").unwrap();
let mut indices = walls.entity_indices.clone();
indices.sort_unstable();
assert_eq!(indices, vec![0, 2]);
}
#[test]
fn schedule_groups_list_the_storeys_a_type_spans() {
let model = IfcModel {
building_storeys: vec![
Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
},
Storey {
name: "Level 2".into(),
elevation_feet: 12.0,
},
],
entities: vec![
mk_element("W1", "IFCWALL", Some(0), None),
mk_element("W2", "IFCWALL", Some(1), None),
mk_element("W3", "IFCWALL", None, None),
],
..Default::default()
};
let s = build_schedule(&model);
let walls = &s.groups[0];
assert_eq!(walls.count, 3);
assert_eq!(walls.storeys, vec!["Level 1", "Level 2"]);
}
#[test]
fn schedule_filter_rebuilds_its_groups() {
let model = IfcModel {
entities: vec![
mk_element("W1", "IFCWALL", None, None),
mk_element("S1", "IFCSLAB", None, None),
mk_element("W2", "IFCWALL", None, None),
],
..Default::default()
};
let walls = build_schedule(&model).filter_by_ifc_type("ifcwall");
assert_eq!(walls.groups.len(), 1);
assert_eq!(walls.groups[0].ifc_type, "IFCWALL");
assert_eq!(walls.groups[0].count, 2);
}
#[test]
fn empty_schedule_has_no_groups() {
assert!(build_schedule(&IfcModel::default()).groups.is_empty());
}
#[test]
fn schedule_serde_roundtrips() {
let model = IfcModel {
entities: vec![mk_element("W1", "IFCWALL", None, None)],
..Default::default()
};
let s = build_schedule(&model);
let json = serde_json::to_string(&s).unwrap();
let back: Schedule = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn scene_graph_skips_non_building_entities() {
let model = IfcModel {
project_name: Some("Skip".into()),
building_storeys: vec![Storey {
name: "Ground".into(),
elevation_feet: 0.0,
}],
entities: vec![
mk_element("Wall", "IFCWALL", Some(0), None),
IfcEntity::Project {
name: Some("Inner Project".into()),
description: None,
long_name: None,
},
],
..Default::default()
};
let scene = build_scene_graph(&model);
assert_eq!(scene.children[0].children.len(), 1);
assert_eq!(scene.children[0].children[0].name, "Wall");
}
}