use crate::Result;
pub mod annotation;
pub mod body_geometry;
pub mod camera;
pub mod category_map;
pub mod clipping;
pub mod compare;
pub mod entities;
pub mod export_content;
pub mod from_decoded;
pub mod gltf;
pub mod measure;
pub mod pbr;
pub mod pset_validate;
pub mod scene_graph;
pub mod schedule_csv;
pub mod share;
pub mod sheet;
pub mod step_writer;
pub use export_content::{
ELEMENT_RECORD_PROPERTY_SET, ExportContentPolicy, is_misleading_proxy_class,
};
pub use from_decoded::{BuilderOptions, ElementInput, build_ifc_model, entity_type_histogram};
pub use step_writer::write_step;
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct IfcModel {
pub project_name: Option<String>,
pub description: Option<String>,
pub entities: Vec<entities::IfcEntity>,
pub classifications: Vec<entities::Classification>,
pub units: Vec<entities::UnitAssignment>,
pub building_storeys: Vec<Storey>,
pub materials: Vec<MaterialInfo>,
pub material_layer_sets: Vec<entities::MaterialLayerSet>,
pub material_profile_sets: Vec<entities::MaterialProfileSet>,
pub representation_maps: Vec<entities::RepresentationMap>,
#[serde(default)]
pub global_ids: RevitGlobalIds,
#[serde(default)]
pub element_layers: std::collections::BTreeMap<u32, ElementLayers>,
#[serde(default)]
pub material_layer_usages: std::collections::BTreeMap<usize, entities::MaterialLayerSetUsage>,
#[serde(default)]
pub element_type_materials: std::collections::BTreeMap<u32, Vec<String>>,
#[serde(default)]
pub element_type_ids: std::collections::BTreeMap<u32, u32>,
#[serde(default)]
pub material_constituent_sets: Vec<entities::MaterialConstituentSet>,
#[serde(default)]
pub opening_cuts: std::collections::BTreeMap<usize, entities::OpeningCut>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ElementLayers {
pub exterior_normal: [f64; 2],
pub layers: Vec<LayerBand>,
#[serde(default)]
pub stacked: bool,
#[serde(default)]
pub system_family: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LayerBand {
pub width_feet: f64,
pub color_packed: Option<u32>,
#[serde(default)]
pub name: Option<String>,
pub transparency: f64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RevitGlobalIds {
#[serde(default)]
pub document: Option<String>,
pub elements: std::collections::BTreeMap<usize, String>,
pub storeys: std::collections::BTreeMap<usize, String>,
#[serde(default)]
pub types: std::collections::BTreeMap<u32, String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Storey {
pub name: String,
pub elevation_feet: f64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MaterialInfo {
pub name: String,
pub color_packed: Option<u32>,
pub transparency: Option<f64>,
}
pub const EXPORT_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportDiagnosticsMode {
Placeholder,
Default,
DiagnosticProxies,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportResult {
pub model: IfcModel,
pub diagnostics: ExportDiagnostics,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportDiagnostics {
pub schema_version: u32,
pub mode: ExportDiagnosticsMode,
pub input: ExportInputDiagnostics,
pub decoded: DecodedExportDiagnostics,
pub exported: ExportedModelDiagnostics,
pub skipped: Vec<SkippedExportItem>,
pub unsupported_features: Vec<String>,
pub warnings: Vec<String>,
pub confidence: ExportConfidenceSummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_coverage: Option<SourceCoverageDiagnostics>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formats_latest_integrity: Option<crate::compression::FormatsLatestIntegrity>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SourceCoverageDiagnostics {
pub status: SourceCoverageStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decoded_element_fraction: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exported_element_fraction: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geometry_element_fraction: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceCoverageStatus {
Unset,
Unknown,
Measured,
}
impl SourceCoverageDiagnostics {
pub fn unset() -> Self {
Self {
status: SourceCoverageStatus::Unset,
notes: Some(
"Export source-coverage fractions are unset: no trustworthy denominator was available; fields stay null."
.to_string(),
),
decoded_element_fraction: None,
exported_element_fraction: None,
geometry_element_fraction: None,
}
}
pub fn measure(
production_walker_elements: usize,
building_elements: usize,
building_elements_with_geometry: usize,
elem_table_element_count: Option<usize>,
) -> Self {
let mut notes: Vec<String> = Vec::new();
let decoded_element_fraction = match elem_table_element_count {
None => {
notes.push(
"decoded_element_fraction left null: Global/ElemTable declared ElementIds unavailable."
.into(),
);
None
}
Some(0) => {
notes.push(
"decoded_element_fraction left null: Global/ElemTable declares no ElementIds."
.into(),
);
None
}
Some(_declared) if production_walker_elements == 0 => {
Some(0.0)
}
Some(declared) if production_walker_elements <= declared => {
Some(production_walker_elements as f32 / declared as f32)
}
Some(declared) => {
notes.push(format!(
"decoded_element_fraction left null: production walker recovered {production_walker_elements} elements but Global/ElemTable declares only {declared} ElementIds (do not invent a ratio)."
));
None
}
};
let exported_element_fraction = if production_walker_elements > 0 {
Some(building_elements as f32 / production_walker_elements as f32)
} else {
notes.push(
"exported_element_fraction left null: production_walker_elements is 0.".into(),
);
None
};
let geometry_element_fraction = if building_elements > 0 {
Some(building_elements_with_geometry as f32 / building_elements as f32)
} else {
notes.push(
"geometry_element_fraction left null: exported building_elements is 0.".into(),
);
None
};
let any_measured = decoded_element_fraction.is_some()
|| exported_element_fraction.is_some()
|| geometry_element_fraction.is_some();
if !any_measured {
return Self::unset();
}
notes.insert(
0,
"Measured from export diagnostics counts only (production walker, exported building elements, ElementIds declared by Global/ElemTable). Not a converter-grade completeness claim.".into(),
);
Self {
status: SourceCoverageStatus::Measured,
notes: Some(notes.join(" ")),
decoded_element_fraction,
exported_element_fraction,
geometry_element_fraction,
}
}
}
fn elem_table_declared_element_count(rf: &mut crate::RevitFile) -> Option<usize> {
crate::elem_table::declared_element_ids(rf)
.ok()
.map(|ids| ids.iter().filter(|&&id| id != 0).count())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportInputDiagnostics {
pub revit_version: Option<u32>,
pub build: Option<String>,
pub original_path: Option<String>,
pub project_name: Option<String>,
pub stream_count: usize,
pub has_basic_file_info: bool,
pub has_part_atom: bool,
pub has_formats_latest: bool,
pub has_global_latest: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DecodedExportDiagnostics {
pub production_walker_elements: usize,
pub diagnostic_proxy_candidates: usize,
pub arcwall_records: usize,
pub class_counts: std::collections::BTreeMap<String, usize>,
#[serde(default)]
pub production_class_counts: std::collections::BTreeMap<String, usize>,
#[serde(default)]
pub parameter_value_count: usize,
#[serde(default)]
pub mean_element_confidence: Option<f32>,
#[serde(default)]
pub elements_below_min_confidence: usize,
#[serde(default = "default_min_element_confidence_serde")]
pub min_element_confidence: f32,
pub recovered_unit_identifiers: Vec<String>,
pub unknown_unit_identifiers: Vec<String>,
}
fn default_min_element_confidence_serde() -> f32 {
crate::walker::DEFAULT_MIN_ELEMENT_CONFIDENCE
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportedModelDiagnostics {
pub total_entities: usize,
pub building_elements: usize,
pub building_elements_with_geometry: usize,
#[serde(default)]
pub building_elements_carried_by_parts: usize,
pub by_ifc_type: std::collections::BTreeMap<String, usize>,
pub classification_count: usize,
pub unit_assignment_count: usize,
pub material_count: usize,
pub storey_count: usize,
#[serde(default)]
pub storey_names: Vec<String>,
#[serde(default)]
pub storey_elevations_feet: Vec<f64>,
#[serde(default)]
pub storey_bound_elements: usize,
#[serde(default)]
pub material_names_sample: Vec<String>,
#[serde(default)]
pub layered_element_count: usize,
#[serde(default)]
pub body_sources: std::collections::BTreeMap<String, usize>,
#[serde(default)]
pub bounding_box_bodies: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkippedExportItem {
pub reason: String,
pub count: usize,
pub classes: std::collections::BTreeMap<String, usize>,
pub sample_names: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportConfidenceSummary {
pub level: String,
pub score: f32,
pub has_project_metadata: bool,
pub has_typed_elements: bool,
pub has_geometry: bool,
pub has_diagnostic_proxies: bool,
pub warning_count: usize,
#[serde(default)]
pub unexported_element_records: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExportQualityMode {
Scaffold,
TypedNoGeometry,
Geometry,
Strict,
}
impl ExportQualityMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Scaffold => "scaffold",
Self::TypedNoGeometry => "typed-no-geometry",
Self::Geometry => "geometry",
Self::Strict => "strict",
}
}
pub fn parse(value: &str) -> std::result::Result<Self, ExportQualityModeParseError> {
match value.trim().to_ascii_lowercase().as_str() {
"scaffold" => Ok(Self::Scaffold),
"typed-no-geometry" | "typed_no_geometry" => Ok(Self::TypedNoGeometry),
"geometry" => Ok(Self::Geometry),
"strict" => Ok(Self::Strict),
_ => Err(ExportQualityModeParseError {
value: value.to_string(),
}),
}
}
pub fn validate(
self,
diagnostics: &ExportDiagnostics,
) -> std::result::Result<(), ExportQualityValidationError> {
let mut failures = Vec::new();
match self {
Self::Scaffold => {}
Self::TypedNoGeometry => {
require_typed_elements(diagnostics, &mut failures);
}
Self::Geometry => {
require_typed_elements(diagnostics, &mut failures);
require_geometry(diagnostics, &mut failures);
}
Self::Strict => {
require_typed_elements(diagnostics, &mut failures);
require_geometry(diagnostics, &mut failures);
if !diagnostics.confidence.has_project_metadata {
failures.push("no project metadata was recovered".to_string());
}
if diagnostics.exported.unit_assignment_count == 0 {
failures.push("no Revit unit assignment was recovered".to_string());
}
if diagnostics.exported.storey_count == 0 {
failures.push("no Revit level/storey data was recovered".to_string());
}
if !diagnostics.unsupported_features.is_empty() {
failures.push(format!(
"unsupported exporter features remain: {}",
diagnostics.unsupported_features.join(", ")
));
}
if !diagnostics.warnings.is_empty() {
failures.push(format!(
"{} export warning(s) remain",
diagnostics.warnings.len()
));
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(ExportQualityValidationError {
mode: self,
reason: failures.join("; "),
confidence_level: diagnostics.confidence.level.clone(),
})
}
}
}
impl std::fmt::Display for ExportQualityMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ExportQualityMode {
type Err = ExportQualityModeParseError;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
Self::parse(value)
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"unknown IFC export mode `{value}`; expected scaffold, typed-no-geometry, geometry, or strict"
)]
pub struct ExportQualityModeParseError {
pub value: String,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"IFC export mode `{mode}` cannot be satisfied: {reason} (confidence level: {confidence_level})"
)]
pub struct ExportQualityValidationError {
pub mode: ExportQualityMode,
pub reason: String,
pub confidence_level: String,
}
fn require_typed_elements(diagnostics: &ExportDiagnostics, failures: &mut Vec<String>) {
if !diagnostics.confidence.has_typed_elements {
failures.push(format!(
"no validated typed IFC elements were exported (building_elements={}, confidence_level={})",
diagnostics.exported.building_elements, diagnostics.confidence.level
));
}
}
fn require_geometry(diagnostics: &ExportDiagnostics, failures: &mut Vec<String>) {
if !diagnostics.confidence.has_geometry {
failures.push(format!(
"no exported building element has geometry (building_elements_with_geometry={})",
diagnostics.exported.building_elements_with_geometry
));
}
}
pub trait Exporter {
fn export(&self, rf: &mut crate::RevitFile) -> Result<IfcModel>;
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("IFC export not yet implemented: {reason}")]
pub struct NotYetImplemented {
pub reason: String,
}
pub struct PlaceholderExporter;
impl Exporter for PlaceholderExporter {
fn export(&self, rf: &mut crate::RevitFile) -> Result<IfcModel> {
let project_name = rf
.part_atom()
.ok()
.and_then(|pa| pa.title)
.or_else(|| rf.basic_file_info().ok().and_then(|bfi| bfi.original_path));
Ok(IfcModel {
project_name,
description: Some(
"Partial IFC export via rvt-rs PlaceholderExporter. \
Geometry, categories, and elements are pending Layer 5b \
walker + Phase 5 geometry work."
.into(),
),
entities: Vec::new(),
classifications: Vec::new(),
units: Vec::new(),
building_storeys: Vec::new(),
materials: Vec::new(),
material_layer_sets: Vec::new(),
material_profile_sets: Vec::new(),
representation_maps: Vec::new(),
global_ids: Default::default(),
element_layers: Default::default(),
material_layer_usages: Default::default(),
element_type_materials: Default::default(),
element_type_ids: Default::default(),
material_constituent_sets: Vec::new(),
opening_cuts: Default::default(),
})
}
}
impl PlaceholderExporter {
pub fn export_with_diagnostics(&self, rf: &mut crate::RevitFile) -> Result<ExportResult> {
let model = self.export(rf)?;
let diagnostics = build_export_diagnostics(rf, &model, ExportDiagnosticsMode::Placeholder);
Ok(ExportResult { model, diagnostics })
}
}
pub struct RvtDocExporter;
impl Exporter for RvtDocExporter {
fn export(&self, rf: &mut crate::RevitFile) -> Result<IfcModel> {
self.export_with_limits(rf, crate::walker::WalkerLimits::default())
}
}
impl RvtDocExporter {
pub fn export_with_limits(
&self,
rf: &mut crate::RevitFile,
limits: crate::walker::WalkerLimits,
) -> Result<IfcModel> {
self.export_with_mode_and_limits(rf, ExportQualityMode::Scaffold, limits)
}
pub fn export_with_mode_and_limits(
&self,
rf: &mut crate::RevitFile,
quality_mode: ExportQualityMode,
limits: crate::walker::WalkerLimits,
) -> Result<IfcModel> {
export_rvt_doc(rf, RvtDocExportMode::Default, quality_mode, limits)
}
pub fn export_with_diagnostics(&self, rf: &mut crate::RevitFile) -> Result<ExportResult> {
self.export_with_diagnostics_and_limits(rf, crate::walker::WalkerLimits::default())
}
pub fn export_with_diagnostics_and_limits(
&self,
rf: &mut crate::RevitFile,
limits: crate::walker::WalkerLimits,
) -> Result<ExportResult> {
self.export_with_diagnostics_mode_and_limits(rf, ExportQualityMode::Scaffold, limits)
}
pub fn export_with_diagnostics_mode_and_limits(
&self,
rf: &mut crate::RevitFile,
quality_mode: ExportQualityMode,
limits: crate::walker::WalkerLimits,
) -> Result<ExportResult> {
let model = self.export_with_mode_and_limits(rf, quality_mode, limits)?;
let diagnostics = build_export_diagnostics_with_limits(
rf,
&model,
ExportDiagnosticsMode::Default,
limits,
);
Ok(ExportResult { model, diagnostics })
}
}
pub struct DiagnosticRvtDocExporter;
impl Exporter for DiagnosticRvtDocExporter {
fn export(&self, rf: &mut crate::RevitFile) -> Result<IfcModel> {
self.export_with_limits(rf, crate::walker::WalkerLimits::default())
}
}
impl DiagnosticRvtDocExporter {
pub fn export_with_limits(
&self,
rf: &mut crate::RevitFile,
limits: crate::walker::WalkerLimits,
) -> Result<IfcModel> {
export_rvt_doc(
rf,
RvtDocExportMode::DiagnosticProxies,
ExportQualityMode::Scaffold,
limits,
)
}
pub fn export_with_diagnostics(&self, rf: &mut crate::RevitFile) -> Result<ExportResult> {
self.export_with_diagnostics_and_limits(rf, crate::walker::WalkerLimits::default())
}
pub fn export_with_diagnostics_and_limits(
&self,
rf: &mut crate::RevitFile,
limits: crate::walker::WalkerLimits,
) -> Result<ExportResult> {
let model = self.export_with_limits(rf, limits)?;
let diagnostics = build_export_diagnostics_with_limits(
rf,
&model,
ExportDiagnosticsMode::DiagnosticProxies,
limits,
);
Ok(ExportResult { model, diagnostics })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RvtDocExportMode {
Default,
DiagnosticProxies,
}
fn export_rvt_doc(
rf: &mut crate::RevitFile,
mode: RvtDocExportMode,
quality_mode: ExportQualityMode,
walker_limits: crate::walker::WalkerLimits,
) -> Result<IfcModel> {
let policy = export_content::ExportContentPolicy::for_quality_mode(quality_mode);
let part = rf.part_atom().ok();
let bfi = rf.basic_file_info().ok();
let project_name = part
.as_ref()
.and_then(|pa| pa.title.clone())
.or_else(|| bfi.as_ref().and_then(|b| b.original_path.clone()));
let description = {
let mut d = Vec::new();
if let Some(b) = &bfi {
d.push(format!("Revit {} export", b.version));
if let Some(message) =
crate::arc_wall_record::ArcWallRecord::standard_decoder_status(b.version)
.diagnostic_message()
{
d.push(message);
}
}
if let Some(p) = &part {
if let Some(id) = &p.id {
d.push(format!("id={id}"));
}
}
if d.is_empty() {
None
} else {
Some(d.join("; "))
}
};
let mut classifications = Vec::new();
if let Some(p) = &part {
let omni_items: Vec<_> = p
.categories
.iter()
.filter(|c| c.term.starts_with(char::is_numeric) && c.term.contains('.'))
.map(|c| entities::ClassificationItem {
code: c.term.clone(),
name: None,
})
.collect();
if !omni_items.is_empty() {
classifications.push(entities::Classification {
source: entities::ClassificationSource::OmniClass,
edition: None,
items: omni_items,
});
}
}
let mut entities = vec![entities::IfcEntity::Project {
name: project_name.clone(),
description: description.clone(),
long_name: part.as_ref().and_then(|p| p.title.clone()),
}];
let mut building_storeys = Vec::new();
let mut materials = Vec::new();
let (element_layers, element_type_materials, unplaced_wall_layers, element_type_ids) =
append_production_walker_elements(
rf,
&mut entities,
&mut building_storeys,
&mut materials,
policy,
walker_limits,
);
if mode == RvtDocExportMode::DiagnosticProxies {
append_diagnostic_walker_proxy_candidates(rf, &mut entities, walker_limits);
}
let mut level_storey_bind = crate::level_bind::LevelStoreyBind::new();
if let Some(revit_version) = bfi.as_ref().map(|b| b.version) {
apply_partition_level_storeys(
rf,
revit_version,
&mut entities,
&mut building_storeys,
&mut level_storey_bind,
);
}
if let Some(revit_version) = bfi.as_ref().map(|b| b.version) {
if let Ok(scan) = crate::partition_arc_walls::scan_partition_arc_walls_with_limits(
rf,
revit_version,
walker_limits,
) {
let level_names = collect_partition_building_storey_names(rf);
let recovery = crate::partition_arc_walls::recover_storeys_from_arc_walls(
&scan.walls,
&level_names,
);
if building_storeys.is_empty() {
building_storeys = recovery.storeys;
}
let existing_ids: std::collections::HashSet<u32> = entities
.iter()
.filter_map(|e| match e {
entities::IfcEntity::BuildingElement { type_guid, .. } => {
type_guid.as_ref().and_then(|g| g.parse().ok())
}
_ => None,
})
.collect();
for wall in &scan.walls {
if let Some(id) = wall.element_id() {
if existing_ids.contains(&id) {
continue;
}
}
let geometry = if policy.include_geometry {
arcwall_geometry_from_partition_wall(wall)
} else {
None
};
let storey_index = wall.base_elevation_feet().and_then(|elev| {
crate::partition_arc_walls::storey_index_for_elevation(&building_storeys, elev)
});
let property_set = arcwall_property_set(wall);
let name = match wall.element_id() {
Some(id) => format!("ArcWall-{id}"),
None => format!("ArcWall-{}-{}", wall.partition, wall.offset),
};
entities.push(entities::IfcEntity::BuildingElement {
ifc_type: "IFCWALL".to_string(),
name,
type_guid: wall.element_id().map(|id| id.to_string()),
predefined_type: category_map::lookup("ArcWall")
.and_then(|m| m.predefined_type)
.map(str::to_string),
storey_index,
material_index: None,
property_set,
location_feet: geometry.as_ref().map(|(location, _, _)| *location),
rotation_radians: geometry.as_ref().map(|(_, rotation, _)| *rotation),
extrusion: geometry.map(|(_, _, extrusion)| extrusion),
host_element_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
});
}
}
}
apply_record_level_reference_storeys(&mut entities, &building_storeys, &level_storey_bind);
apply_element_record_storeys(&mut entities, &mut building_storeys);
if !policy.include_geometry {
export_content::strip_building_element_geometry(&mut entities);
}
let recovered_units = recover_project_units(rf);
let global_ids = revit_model_global_ids(rf, &entities, &building_storeys, &element_type_ids);
let (material_layer_sets, material_layer_usages) =
material_layer_sets_from_layers(&mut entities, &element_layers, &mut materials);
let mut material_constituent_sets =
material_constituent_sets_from_types(&entities, &element_type_materials, &mut materials);
let layer_constituent_sets = layer_materials_without_layer_sets(
&mut entities,
&element_layers,
&unplaced_wall_layers,
&material_constituent_sets,
&mut materials,
);
material_constituent_sets.extend(layer_constituent_sets);
let material_profile_sets =
material_profile_sets_from_sections(&mut entities, &mut material_constituent_sets);
let opening_cuts = opening_cuts_through_hosts(&entities);
Ok(IfcModel {
project_name,
description,
entities,
classifications,
units: recovered_units.assignments,
building_storeys,
materials,
material_layer_sets,
material_profile_sets,
representation_maps: Vec::new(),
global_ids,
element_layers,
material_layer_usages,
element_type_materials,
element_type_ids,
material_constituent_sets,
opening_cuts,
})
}
fn opening_cuts_through_hosts(
entities: &[entities::IfcEntity],
) -> std::collections::BTreeMap<usize, entities::OpeningCut> {
const MAX_THICKNESS_FEET: f64 = 3.0;
let mut cuts = std::collections::BTreeMap::new();
for (index, entity) in entities.iter().enumerate() {
let entities::IfcEntity::BuildingElement {
host_element_index: Some(host),
extrusion: Some(body),
solid_shape: None,
representation_map_index: None,
location_feet: Some(location),
rotation_radians,
..
} = entity
else {
continue;
};
if body.profile_override.is_some() {
continue;
}
let window_cut =
|base: Option<entities::OpeningCut>| filler_type_cut(entity, base).or(base);
if let Some(cut) = tapered_host_window_cut(entity, &entities[*host]) {
if let Some(cut) = window_cut(Some(cut)) {
cuts.insert(index, cut);
}
continue;
}
let Some(entities::IfcEntity::BuildingElement {
location_feet: host_location,
rotation_radians: host_rotation,
..
}) = entities.get(*host)
else {
continue;
};
let Some((outline, _)) =
body_geometry::element_body(&entities[*host], &[]).and_then(|host_body| {
let host_placement = body_geometry::Placement::new(*host_location, *host_rotation);
body_geometry::plan_outline(host_body, &host_placement)
})
else {
if let Some(cut) = window_cut(None) {
cuts.insert(index, cut);
}
continue;
};
let angle = rotation_radians.unwrap_or(0.0);
let axes = [[angle.cos(), angle.sin()], [-angle.sin(), angle.cos()]];
let band = |axis: [f64; 2]| {
outline
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &(x, y)| {
let v = (x - location[0]) * axis[0] + (y - location[1]) * axis[1];
(lo.min(v), hi.max(v))
})
};
let (x_band, y_band) = (band(axes[0]), band(axes[1]));
let (x_width, y_width) = (x_band.1 - x_band.0, y_band.1 - y_band.0);
let cut = if y_width <= MAX_THICKNESS_FEET && y_width < x_width / 2.0 && y_width > 0.0 {
Some(entities::OpeningCut {
x_dim_feet: body.width_feet,
y_dim_feet: y_width,
centre_feet: [0.0, (y_band.0 + y_band.1) / 2.0],
z_range_feet: None,
})
} else if x_width <= MAX_THICKNESS_FEET && x_width < y_width / 2.0 && x_width > 0.0 {
Some(entities::OpeningCut {
x_dim_feet: x_width,
y_dim_feet: body.depth_feet,
centre_feet: [(x_band.0 + x_band.1) / 2.0, 0.0],
z_range_feet: None,
})
} else {
None
};
if let Some(cut) = window_cut(cut) {
cuts.insert(index, cut);
}
}
cuts
}
fn length_property(entity: &entities::IfcEntity, wanted: &str) -> Option<f64> {
let entities::IfcEntity::BuildingElement {
property_set: Some(set),
..
} = entity
else {
return None;
};
set.properties
.iter()
.find_map(|property| match &property.value {
entities::PropertyValue::LengthFeet(value) if property.name == wanted => Some(*value),
_ => None,
})
}
fn tapered_host_window_cut(
filler: &entities::IfcEntity,
host: &entities::IfcEntity,
) -> Option<entities::OpeningCut> {
let entities::IfcEntity::BuildingElement {
ifc_type,
location_feet: Some(location),
rotation_radians: Some(rotation),
extrusion: Some(body),
..
} = filler
else {
return None;
};
let entities::IfcEntity::BuildingElement {
location_feet: Some(host_location),
rotation_radians: Some(host_rotation),
property_set: Some(host_set),
..
} = host
else {
return None;
};
if !ifc_type.eq_ignore_ascii_case("IfcWindow")
|| !host_set.properties.iter().any(|property| {
property.name == "BodySource"
&& matches!(&property.value, entities::PropertyValue::Text(source)
if source == export_content::WALL_TAPERED_BODY_SOURCE)
})
{
return None;
}
let thickness = length_property(host, "Thickness").filter(|t| t.is_finite() && *t > 0.0)?;
let [ox, oy] =
export_content::INSTANCE_ORIGIN_PROPERTIES.map(|name| length_property(filler, name));
let origin = [ox?, oy?];
if (rotation - host_rotation).sin().abs() > 1e-6 {
return None;
}
let host_left = [-host_rotation.sin(), host_rotation.cos()];
let filler_left = [-rotation.sin(), rotation.cos()];
let side = host_left[0] * filler_left[0] + host_left[1] * filler_left[1];
let across = |point: [f64; 2]| {
(point[0] - host_location[0]) * host_left[0] + (point[1] - host_location[1]) * host_left[1]
};
let offset = side * (across(origin) - across([location[0], location[1]]));
offset.is_finite().then_some(entities::OpeningCut {
x_dim_feet: body.width_feet,
y_dim_feet: thickness,
centre_feet: [0.0, offset],
z_range_feet: None,
})
}
fn real_property(entity: &entities::IfcEntity, wanted: &str) -> Option<f64> {
let entities::IfcEntity::BuildingElement {
property_set: Some(set),
..
} = entity
else {
return None;
};
set.properties
.iter()
.find_map(|property| match &property.value {
entities::PropertyValue::Real(value) if property.name == wanted => Some(*value),
_ => None,
})
}
pub const WINDOW_TRIM_MAX_FEET: f64 = 0.5;
fn filler_type_cut(
filler: &entities::IfcEntity,
base: Option<entities::OpeningCut>,
) -> Option<entities::OpeningCut> {
let entities::IfcEntity::BuildingElement {
ifc_type,
location_feet: Some(location),
rotation_radians,
extrusion: Some(body),
..
} = filler
else {
return None;
};
if !ifc_type.eq_ignore_ascii_case("IfcWindow") && !ifc_type.eq_ignore_ascii_case("IfcDoor") {
return None;
}
let [cx, cy, ax, ay, base_z, width, height] = export_content::FILLER_OPENING_PROPERTIES;
let centre = [length_property(filler, cx)?, length_property(filler, cy)?];
let [ax, ay] = [real_property(filler, ax)?, real_property(filler, ay)?];
let base_z = length_property(filler, base_z)?;
let width = positive_length_property(filler, width)?;
let height = positive_length_property(filler, height)?;
let angle = rotation_radians.unwrap_or(0.0);
let local_x = [angle.cos(), angle.sin()];
let local_y = [-angle.sin(), angle.cos()];
let dot = |a: [f64; 2], b: [f64; 2]| a[0] * b[0] + a[1] * b[1];
let offset = [centre[0] - location[0], centre[1] - location[1]];
let along_x = (dot([ax, ay], local_x).abs() - 1.0).abs() <= 1e-6;
let along_y = (dot([ax, ay], local_y).abs() - 1.0).abs() <= 1e-6;
let spare = -1e-6..=WINDOW_TRIM_MAX_FEET;
let fits = |lo: f64, hi: f64, box_lo: f64, box_hi: f64| {
spare.contains(&(lo - box_lo)) && spare.contains(&(box_hi - hi))
};
let z0 = base_z - location[2];
if !fits(z0, z0 + height, 0.0, body.height_feet) {
return None;
}
if along_x && base.is_none_or(|cut| cut.x_dim_feet == body.width_feet) {
let c = dot(offset, local_x);
let half = body.width_feet / 2.0;
fits(c - width / 2.0, c + width / 2.0, -half, half).then(|| {
let (y_dim_feet, y_centre) = base.map_or((body.depth_feet, 0.0), |cut| {
(cut.y_dim_feet, cut.centre_feet[1])
});
entities::OpeningCut {
x_dim_feet: width,
y_dim_feet,
centre_feet: [c, y_centre],
z_range_feet: Some([z0, height]),
}
})
} else if along_y && base.is_none_or(|cut| cut.y_dim_feet == body.depth_feet) {
let c = dot(offset, local_y);
let half = body.depth_feet / 2.0;
fits(c - width / 2.0, c + width / 2.0, -half, half).then(|| {
let (x_dim_feet, x_centre) = base.map_or((body.width_feet, 0.0), |cut| {
(cut.x_dim_feet, cut.centre_feet[0])
});
entities::OpeningCut {
x_dim_feet,
y_dim_feet: width,
centre_feet: [x_centre, c],
z_range_feet: Some([z0, height]),
}
})
} else {
None
}
}
fn positive_length_property(entity: &entities::IfcEntity, wanted: &str) -> Option<f64> {
let entities::IfcEntity::BuildingElement {
property_set: Some(set),
..
} = entity
else {
return None;
};
set.properties
.iter()
.find_map(|property| match &property.value {
entities::PropertyValue::PositiveLengthFeet(value) if property.name == wanted => {
Some(*value)
}
_ => None,
})
}
fn material_profile_sets_from_sections(
entities: &mut [entities::IfcEntity],
constituent_sets: &mut Vec<entities::MaterialConstituentSet>,
) -> Vec<entities::MaterialProfileSet> {
let mut sets: Vec<entities::MaterialProfileSet> = Vec::new();
for (entity_index, entity) in entities.iter_mut().enumerate() {
let entities::IfcEntity::BuildingElement {
property_set: Some(property_set),
extrusion,
solid_shape,
material_profile_set_index,
..
} = entity
else {
continue;
};
let text = |wanted: &str| {
property_set
.properties
.iter()
.find_map(|property| match &property.value {
entities::PropertyValue::Text(text) if property.name == wanted => {
Some(text.clone())
}
_ => None,
})
};
if text("SectionShape").as_deref() != Some("I") {
continue;
}
let Some(type_name) = text("TypeName") else {
continue;
};
let profile = match (solid_shape.as_ref(), extrusion.as_ref()) {
(
Some(entities::SolidShape::PlacedExtrusion {
profile: profile @ entities::ProfileDef::IShape { .. },
..
}),
_,
) => profile.clone(),
(
None,
Some(entities::Extrusion {
profile_override: Some(profile @ entities::ProfileDef::IShape { .. }),
..
}),
) => profile.clone(),
_ => continue,
};
let Some(constituents) = constituent_sets
.iter_mut()
.find(|set| set.elements.contains(&entity_index))
else {
continue;
};
let [material_index] = constituents.material_indices[..] else {
continue;
};
constituents
.elements
.retain(|&element| element != entity_index);
let existing = sets.iter().position(|set| {
set.name == type_name
&& set.profiles.iter().all(|held| {
held.material_index == material_index && held.profile.as_ref() == Some(&profile)
})
});
let index = existing.unwrap_or_else(|| {
sets.push(entities::MaterialProfileSet {
name: type_name.clone(),
description: None,
profiles: vec![entities::MaterialProfile {
material_index,
profile_name: type_name.clone(),
description: None,
profile: Some(profile.clone()),
}],
});
sets.len() - 1
});
*material_profile_set_index = Some(index);
}
constituent_sets.retain(|set| !set.elements.is_empty());
sets
}
fn material_constituent_sets_from_types(
entities: &[entities::IfcEntity],
element_type_materials: &std::collections::BTreeMap<u32, Vec<String>>,
materials: &mut Vec<MaterialInfo>,
) -> Vec<entities::MaterialConstituentSet> {
let mut sets: Vec<entities::MaterialConstituentSet> = Vec::new();
let mut set_of: std::collections::BTreeMap<Vec<usize>, usize> = Default::default();
for (entity_index, entity) in entities.iter().enumerate() {
let entities::IfcEntity::BuildingElement {
type_guid,
material_index: None,
material_layer_set_index: None,
material_profile_set_index: None,
..
} = entity
else {
continue;
};
let Some(names) = type_guid
.as_deref()
.and_then(|tag| tag.parse::<u32>().ok())
.and_then(|id| element_type_materials.get(&id))
else {
continue;
};
let mut sorted: Vec<&String> = names.iter().collect();
sorted.sort();
sorted.dedup();
let indices: Vec<usize> = sorted
.into_iter()
.map(
|name| match materials.iter().position(|m| &m.name == name) {
Some(found) => found,
None => {
materials.push(MaterialInfo {
name: name.clone(),
color_packed: None,
transparency: None,
});
materials.len() - 1
}
},
)
.collect();
let set = *set_of.entry(indices.clone()).or_insert_with(|| {
sets.push(entities::MaterialConstituentSet {
material_indices: indices,
names: Vec::new(),
elements: Vec::new(),
});
sets.len() - 1
});
sets[set].elements.push(entity_index);
}
sets
}
fn layer_material_index(band: &LayerBand, name: &str, materials: &mut Vec<MaterialInfo>) -> usize {
match materials.iter().position(|m| m.name == name) {
Some(found) => {
let material = &mut materials[found];
if material.color_packed.is_none() && band.color_packed.is_some() {
material.color_packed = band.color_packed;
material.transparency = Some(band.transparency);
}
found
}
None => {
materials.push(MaterialInfo {
name: name.to_string(),
color_packed: band.color_packed,
transparency: band.color_packed.map(|_| band.transparency),
});
materials.len() - 1
}
}
}
fn layer_materials_without_layer_sets(
entities: &mut [entities::IfcEntity],
element_layers: &std::collections::BTreeMap<u32, ElementLayers>,
unplaced_wall_layers: &std::collections::BTreeMap<u32, Vec<LayerBand>>,
taken: &[entities::MaterialConstituentSet],
materials: &mut Vec<MaterialInfo>,
) -> Vec<entities::MaterialConstituentSet> {
let taken: std::collections::BTreeSet<usize> = taken
.iter()
.flat_map(|set| set.elements.iter().copied())
.collect();
let mut sets: Vec<entities::MaterialConstituentSet> = Vec::new();
let mut set_of: std::collections::BTreeMap<(Vec<usize>, Vec<String>), usize> =
Default::default();
for (index, entity) in entities.iter_mut().enumerate() {
let entities::IfcEntity::BuildingElement {
type_guid,
material_index,
material_layer_set_index: None,
material_profile_set_index: None,
..
} = entity
else {
continue;
};
if material_index.is_some() || taken.contains(&index) {
continue;
}
let id = type_guid.as_deref().and_then(|tag| tag.parse::<u32>().ok());
let Some(layers) = id
.and_then(|id| element_layers.get(&id))
.filter(|layers| !layers.stacked)
.map(|layers| &layers.layers)
.or_else(|| id.and_then(|id| unplaced_wall_layers.get(&id)))
.filter(|layers| !layers.is_empty())
else {
continue;
};
let Some(names) = layers
.iter()
.map(|band| band.name.clone())
.collect::<Option<Vec<String>>>()
else {
continue;
};
let indices: Vec<usize> = layers
.iter()
.zip(&names)
.map(|(band, name)| layer_material_index(band, name, materials))
.collect();
if let [only] = indices.as_slice() {
*material_index = Some(*only);
continue;
}
let mut seen: std::collections::BTreeMap<&str, usize> = Default::default();
let labels: Vec<String> = names
.iter()
.map(|name| {
let count = seen.entry(name.as_str()).or_insert(0);
*count += 1;
if *count == 1 {
name.clone()
} else {
format!("{name} ({count})")
}
})
.collect();
let set = *set_of
.entry((indices.clone(), labels.clone()))
.or_insert_with(|| {
sets.push(entities::MaterialConstituentSet {
material_indices: indices,
names: labels,
elements: Vec::new(),
});
sets.len() - 1
});
sets[set].elements.push(index);
}
sets
}
fn material_layer_set_usage(
layers: &ElementLayers,
extrusion: &entities::Extrusion,
rotation_radians: Option<f64>,
) -> Option<entities::MaterialLayerSetUsage> {
let total: f64 = layers.layers.iter().map(|band| band.width_feet).sum();
let fits =
|extent: f64| (extent - total).abs() <= body_geometry::LAYER_THICKNESS_TOLERANCE_FEET;
if layers.stacked {
let height = extrusion.height_feet;
return fits(height).then_some(entities::MaterialLayerSetUsage {
direction: entities::LayerSetDirection::Axis3,
positive: false,
offset_feet: height,
});
}
let (outer, _) = body_geometry::extrusion_rings(extrusion)?;
let angle = rotation_radians.unwrap_or(0.0);
let (cos, sin) = (angle.cos(), angle.sin());
let [nx, ny] = layers.exterior_normal;
let local = [nx * cos + ny * sin, -nx * sin + ny * cos];
let (axis, direction, toward) = if local[1].abs() >= local[0].abs() {
(1, entities::LayerSetDirection::Axis2, local[1])
} else {
(0, entities::LayerSetDirection::Axis1, local[0])
};
if local[1 - axis].abs() > 1e-6 {
return None;
}
let coordinate = |p: &(f64, f64)| if axis == 0 { p.0 } else { p.1 };
let (low, high) = outer
.iter()
.map(coordinate)
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
(lo.min(v), hi.max(v))
});
if !fits(high - low) {
return None;
}
Some(if toward > 0.0 {
entities::MaterialLayerSetUsage {
direction,
positive: false,
offset_feet: high,
}
} else {
entities::MaterialLayerSetUsage {
direction,
positive: true,
offset_feet: low,
}
})
}
type LayerSetKey = (String, Vec<(Option<usize>, u64)>);
fn material_layer_sets_from_layers(
entities: &mut [entities::IfcEntity],
element_layers: &std::collections::BTreeMap<u32, ElementLayers>,
materials: &mut Vec<MaterialInfo>,
) -> (
Vec<entities::MaterialLayerSet>,
std::collections::BTreeMap<usize, entities::MaterialLayerSetUsage>,
) {
let mut sets: Vec<entities::MaterialLayerSet> = Vec::new();
let mut set_of: std::collections::BTreeMap<LayerSetKey, usize> =
std::collections::BTreeMap::new();
let mut usages = std::collections::BTreeMap::new();
for (index, entity) in entities.iter_mut().enumerate() {
let entities::IfcEntity::BuildingElement {
type_guid,
extrusion: Some(extrusion),
rotation_radians,
material_layer_set_index,
property_set,
..
} = entity
else {
continue;
};
let Some(layers) = type_guid
.as_deref()
.and_then(|tag| tag.parse::<u32>().ok())
.and_then(|id| element_layers.get(&id))
else {
continue;
};
if layers.layers.len() < 2 && layers.layers.iter().all(|band| band.name.is_none()) {
continue;
}
let Some(usage) = material_layer_set_usage(layers, extrusion, *rotation_radians) else {
continue;
};
let type_name = property_set
.as_ref()
.and_then(|set| {
set.properties
.iter()
.find_map(|property| match &property.value {
entities::PropertyValue::Text(text)
if property.name == export_content::TYPE_NAME_PROPERTY =>
{
Some(text.clone())
}
_ => None,
})
})
.unwrap_or_default();
let key: Vec<(Option<usize>, u64)> = layers
.layers
.iter()
.map(|band| {
let material = band.name.as_ref().map(|name| {
match materials.iter().position(|m| &m.name == name) {
Some(found) => {
let material = &mut materials[found];
if material.color_packed.is_none() && band.color_packed.is_some() {
material.color_packed = band.color_packed;
material.transparency = Some(band.transparency);
}
found
}
None => {
materials.push(MaterialInfo {
name: name.clone(),
color_packed: band.color_packed,
transparency: band.color_packed.map(|_| band.transparency),
});
materials.len() - 1
}
}
});
(material, band.width_feet.to_bits())
})
.collect();
let set_name = match &layers.system_family {
Some(family) if !type_name.is_empty() => format!("{family}:{type_name}"),
_ => type_name,
};
let set = *set_of
.entry((set_name.clone(), key.clone()))
.or_insert_with(|| {
sets.push(entities::MaterialLayerSet {
name: set_name,
layers: key
.iter()
.map(|&(material_index, width)| entities::MaterialLayer {
material_index,
thickness_feet: f64::from_bits(width),
name: None,
})
.collect(),
description: None,
});
sets.len() - 1
});
*material_layer_set_index = Some(set);
usages.insert(index, usage);
}
(sets, usages)
}
fn revit_model_global_ids(
rf: &mut crate::RevitFile,
entities: &[entities::IfcEntity],
storeys: &[Storey],
element_type_ids: &std::collections::BTreeMap<u32, u32>,
) -> RevitGlobalIds {
let mut out = RevitGlobalIds {
document: rf
.basic_file_info()
.ok()
.and_then(|info| info.document_guid().map(str::to_owned)),
..RevitGlobalIds::default()
};
let Ok(ids) = crate::revit_global_ids::revit_global_ids(rf) else {
return out;
};
if ids.is_empty() {
return out;
}
if let Ok(version) = rf.basic_file_info().map(|info| info.version) {
let levels = crate::partition_level_records::recover_partition_levels(rf, version)
.unwrap_or_default();
for (index, storey) in storeys.iter().enumerate() {
let mut named = levels.iter().filter(|level| level.name == storey.name);
if let (Some(level), None) = (named.next(), named.next()) {
if let Some(global_id) = ids.get(&level.element_id) {
out.storeys.insert(index, global_id.clone());
}
}
}
}
let mut seen = std::collections::BTreeSet::new();
for (index, entity) in entities.iter().enumerate() {
let entities::IfcEntity::BuildingElement {
type_guid: Some(tag),
..
} = entity
else {
continue;
};
let Ok(id) = tag.parse::<u32>() else {
continue;
};
if !seen.insert(id) {
continue;
}
if let Some(global_id) = ids.get(&id) {
out.elements.insert(index, global_id.clone());
}
}
for type_id in element_type_ids.values() {
if let Some(global_id) = ids.get(type_id) {
out.types.insert(*type_id, global_id.clone());
}
}
out
}
fn slab_thickness_unresolved(entity: &entities::IfcEntity) -> bool {
let entities::IfcEntity::BuildingElement {
ifc_type,
property_set,
..
} = entity
else {
return false;
};
if ifc_type != "IFCSLAB" {
return false;
}
!property_set.as_ref().is_some_and(|set| {
set.properties.iter().any(|property| {
property.name == "ThicknessResolved"
&& matches!(property.value, entities::PropertyValue::Boolean(true))
})
})
}
pub(crate) const RECORD_BBOX_BODY_SOURCE: &str = "partition_element_record_bbox";
pub(crate) const STOREY_BIND_SOURCE_PROPERTY: &str = "StoreyBindSource";
pub(crate) const STOREY_BIND_RECORD_ELEVATION: &str = "record_base_elevation";
pub(crate) const STOREY_BIND_RECORD_TOP_ELEVATION: &str = "record_top_elevation";
const STOREY_BIND_TOP_FACE_TYPES: &[&str] = &["IFCSLAB", "IFCSHADINGDEVICE"];
const STOREY_ELEVATION_SOURCE_TYPES: &[&str] = &["IFCCOLUMN"];
fn record_storey_elevation_source_feet(entity: &entities::IfcEntity) -> Option<f64> {
let entities::IfcEntity::BuildingElement { ifc_type, .. } = entity else {
return None;
};
if !STOREY_ELEVATION_SOURCE_TYPES.contains(&ifc_type.as_str()) {
return None;
}
record_base_elevation_feet(entity)
}
fn record_storey_bind_elevation_feet(entity: &entities::IfcEntity) -> Option<(f64, &'static str)> {
let base = record_base_elevation_feet(entity)?;
let entities::IfcEntity::BuildingElement {
ifc_type,
extrusion,
..
} = entity
else {
return None;
};
if !STOREY_BIND_TOP_FACE_TYPES.contains(&ifc_type.as_str()) {
return Some((base, STOREY_BIND_RECORD_ELEVATION));
}
let height = extrusion.as_ref()?.height_feet;
if !height.is_finite() || height <= 0.0 {
return None;
}
Some((base + height, STOREY_BIND_RECORD_TOP_ELEVATION))
}
fn record_base_elevation_feet(entity: &entities::IfcEntity) -> Option<f64> {
let entities::IfcEntity::BuildingElement {
property_set,
location_feet,
..
} = entity
else {
return None;
};
let from_record = property_set.as_ref().is_some_and(|set| {
set.properties.iter().any(|property| {
property.name == "BodySource"
&& matches!(
&property.value,
entities::PropertyValue::Text(text)
if text == RECORD_BBOX_BODY_SOURCE
|| text == crate::element_record_wall_joins::WALL_BODY_JOIN_TRIMMED
|| text == crate::element_record_column_cuts::COLUMN_BODY_JOIN_CUT
|| text == crate::element_record_beam_cuts::BEAM_BODY_COLUMN_CUT
)
})
});
if !from_record {
return None;
}
location_feet.map(|[_, _, z]| z)
}
fn apply_partition_level_storeys(
rf: &mut crate::RevitFile,
revit_version: u32,
entities: &mut [entities::IfcEntity],
building_storeys: &mut Vec<Storey>,
level_storey_bind: &mut crate::level_bind::LevelStoreyBind,
) {
let Ok(levels) = crate::partition_level_records::recover_partition_levels(rf, revit_version)
else {
return;
};
if levels.is_empty() {
return;
}
for entity in entities.iter_mut() {
if let entities::IfcEntity::BuildingElement { storey_index, .. } = entity {
*storey_index = None;
}
}
*building_storeys = levels
.into_iter()
.filter(|level| level.building_story != Some(false))
.enumerate()
.map(|(index, level)| {
level_storey_bind.record_level(Some(level.element_id), index);
Storey {
name: level.name,
elevation_feet: level.elevation_feet,
}
})
.collect();
}
pub(crate) const STOREY_BIND_RECORD_LEVEL_REFERENCE: &str = "record_level_reference";
fn apply_record_level_reference_storeys(
entities: &mut [entities::IfcEntity],
building_storeys: &[Storey],
level_storey_bind: &crate::level_bind::LevelStoreyBind,
) {
if level_storey_bind.is_empty() {
return;
}
for entity in entities.iter_mut() {
let Some(level_id) = record_level_element_id(entity) else {
continue;
};
let Some(index) = level_storey_bind.storey_index_for_level_id(level_id) else {
continue;
};
if index >= building_storeys.len() {
continue;
}
if let entities::IfcEntity::BuildingElement {
storey_index,
property_set,
..
} = entity
{
*storey_index = Some(index);
if let Some(set) = property_set.as_mut() {
set.properties.push(entities::Property {
name: STOREY_BIND_SOURCE_PROPERTY.into(),
value: entities::PropertyValue::Text(STOREY_BIND_RECORD_LEVEL_REFERENCE.into()),
});
}
}
}
}
fn storey_bind_source(entity: &entities::IfcEntity) -> Option<&str> {
let entities::IfcEntity::BuildingElement { property_set, .. } = entity else {
return None;
};
property_set
.as_ref()?
.properties
.iter()
.find_map(|property| {
if property.name != STOREY_BIND_SOURCE_PROPERTY {
return None;
}
match &property.value {
entities::PropertyValue::Text(text) => Some(text.as_str()),
_ => None,
}
})
}
fn record_level_element_id(entity: &entities::IfcEntity) -> Option<u32> {
let entities::IfcEntity::BuildingElement { property_set, .. } = entity else {
return None;
};
property_set
.as_ref()?
.properties
.iter()
.find_map(|property| {
if property.name != crate::element_record_level_refs::LEVEL_ELEMENT_ID_PROPERTY {
return None;
}
match property.value {
entities::PropertyValue::Integer(value) => u32::try_from(value).ok(),
_ => None,
}
})
}
fn apply_element_record_storeys(
entities: &mut [entities::IfcEntity],
building_storeys: &mut Vec<Storey>,
) {
use crate::element_record_storeys as records;
let measured = records::distinct_base_elevations_feet(
entities
.iter()
.filter_map(record_storey_elevation_source_feet),
);
if measured.len() < records::MIN_DISTINCT_ELEVATIONS {
return;
}
if records::storeys_lack_elevation_evidence(building_storeys) {
let level_names: Vec<String> = building_storeys
.iter()
.map(|storey| storey.name.clone())
.collect();
let recovery = records::storeys_from_base_elevations(&measured, &level_names);
if recovery.storeys.is_empty() {
return;
}
for entity in entities.iter_mut() {
if let entities::IfcEntity::BuildingElement { storey_index, .. } = entity {
*storey_index = None;
}
}
*building_storeys = recovery.storeys;
}
for entity in entities.iter_mut() {
if matches!(
entity,
entities::IfcEntity::BuildingElement {
storey_index: Some(_),
..
}
) {
continue;
}
let Some((elevation, bind_source)) = record_storey_bind_elevation_feet(entity) else {
continue;
};
let Some(index) = records::unique_storey_index_for_elevation(building_storeys, elevation)
else {
continue;
};
if let entities::IfcEntity::BuildingElement {
storey_index,
property_set,
..
} = entity
{
*storey_index = Some(index);
if let Some(set) = property_set.as_mut() {
set.properties.push(entities::Property {
name: STOREY_BIND_SOURCE_PROPERTY.into(),
value: entities::PropertyValue::Text(bind_source.into()),
});
}
}
}
}
pub(crate) const UNRESOLVED_ARCWALL_THICKNESS_FEET: f64 = 8.0 / 12.0;
fn collect_partition_building_storey_names(rf: &mut crate::RevitFile) -> Vec<String> {
let Ok(records) = rf.partition_string_records() else {
return Vec::new();
};
crate::partition_name_candidates::building_storey_name_candidates(
records.iter().map(|r| r.value.as_str()),
)
}
fn arcwall_property_set(
wall: &crate::partition_arc_walls::PartitionArcWall,
) -> Option<entities::PropertySet> {
let mut properties = Vec::new();
if let Some(id) = wall.element_id() {
properties.push(entities::Property {
name: "ElementId".into(),
value: entities::PropertyValue::Integer(id as i64),
});
}
if let Some(id) = wall.type_id() {
properties.push(entities::Property {
name: "TypeId".into(),
value: entities::PropertyValue::Integer(id as i64),
});
}
if let Some(elev) = wall.base_elevation_feet() {
properties.push(entities::Property {
name: "BaseElevation".into(),
value: entities::PropertyValue::LengthFeet(elev),
});
}
if let Some(height) = wall.height_feet() {
properties.push(entities::Property {
name: "UnconnectedHeight".into(),
value: entities::PropertyValue::LengthFeet(height),
});
}
properties.push(entities::Property {
name: "ThicknessResolved".into(),
value: entities::PropertyValue::Boolean(wall.thickness_feet().is_some()),
});
properties.push(entities::Property {
name: "PartitionOffset".into(),
value: entities::PropertyValue::Integer(wall.offset as i64),
});
properties.push(entities::Property {
name: "PartitionStream".into(),
value: entities::PropertyValue::Text(wall.partition.clone()),
});
if properties.is_empty() {
None
} else {
Some(entities::PropertySet {
name: "RvtArcWall".into(),
properties,
})
}
}
fn arcwall_geometry_from_partition_wall(
wall: &crate::partition_arc_walls::PartitionArcWall,
) -> Option<([f64; 3], f64, entities::Extrusion)> {
arcwall_geometry_from_record(&wall.record, wall.thickness_feet())
}
fn arcwall_geometry_from_record(
record: &crate::arc_wall_record::ArcWallRecord,
thickness_feet: Option<f64>,
) -> Option<([f64; 3], f64, entities::Extrusion)> {
let (sx, sy, sz) = record.start_point();
let (ex, ey, ez) = record.end_point();
if ![sx, sy, sz, ex, ey, ez]
.into_iter()
.all(|coord| coord.is_finite())
{
return None;
}
let length_feet = from_decoded::wall_segment_length_feet([sx, sy], [ex, ey]);
if !length_feet.is_finite() || length_feet < 0.01 {
return None;
}
let height_feet = record.height_feet()?;
let depth_feet = thickness_feet.unwrap_or(UNRESOLVED_ARCWALL_THICKNESS_FEET);
let location_feet = [(sx + ex) / 2.0, (sy + ey) / 2.0, sz.min(ez)];
let rotation_radians = from_decoded::wall_segment_angle_radians([sx, sy], [ex, ey]);
Some((
location_feet,
rotation_radians,
entities::Extrusion {
width_feet: length_feet,
depth_feet,
height_feet,
profile_override: None,
},
))
}
#[derive(Debug, Default)]
struct RecoveredProjectUnits {
assignments: Vec<entities::UnitAssignment>,
unknown_identifiers: Vec<String>,
}
#[derive(Debug, Clone)]
struct UnitCandidate {
identifier: String,
count: usize,
mapping: String,
}
fn recover_project_units(rf: &mut crate::RevitFile) -> RecoveredProjectUnits {
let records = rf.partition_string_records().unwrap_or_default();
recover_project_units_from_identifiers(records.iter().map(|record| record.value.as_str()))
}
fn recover_project_units_from_identifiers<I, S>(identifiers: I) -> RecoveredProjectUnits
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut counts = std::collections::BTreeMap::<String, usize>::new();
for identifier in identifiers {
let value = identifier.as_ref().trim();
if value.starts_with("autodesk.unit.") {
*counts.entry(value.to_string()).or_insert(0) += 1;
}
}
let mut per_type = std::collections::HashMap::<entities::IfcUnitType, UnitCandidate>::new();
let mut unknown_identifiers = Vec::new();
for (identifier, count) in counts {
let forge_unit = entities::ForgeUnit::from_forge_identifier(&identifier);
let Some(emission) = forge_unit.ifc_emission() else {
unknown_identifiers.push(identifier);
continue;
};
let unit_type = unit_type_for_emission(&emission);
let candidate = UnitCandidate {
identifier,
count,
mapping: ifc_mapping_label(&emission),
};
match per_type.get(&unit_type) {
Some(current)
if current.count > candidate.count
|| (current.count == candidate.count
&& current.identifier <= candidate.identifier) => {}
_ => {
per_type.insert(unit_type, candidate);
}
}
}
let assignments = [
entities::IfcUnitType::Length,
entities::IfcUnitType::Area,
entities::IfcUnitType::Volume,
entities::IfcUnitType::PlaneAngle,
entities::IfcUnitType::Mass,
entities::IfcUnitType::Time,
]
.into_iter()
.filter_map(|unit_type| per_type.remove(&unit_type))
.map(|candidate| entities::UnitAssignment {
forge_identifier: candidate.identifier,
ifc_mapping: Some(candidate.mapping),
})
.collect();
RecoveredProjectUnits {
assignments,
unknown_identifiers,
}
}
fn unit_type_for_emission(emission: &entities::IfcUnitEmission) -> entities::IfcUnitType {
match emission {
entities::IfcUnitEmission::Si { unit_type, .. }
| entities::IfcUnitEmission::ConversionBased { unit_type, .. } => *unit_type,
}
}
fn ifc_mapping_label(emission: &entities::IfcUnitEmission) -> String {
match emission {
entities::IfcUnitEmission::Si {
unit_type,
prefix,
name,
} => match prefix {
Some(prefix) => format!("{}:{prefix}:{name}", unit_type.as_step_token()),
None => format!("{}:{name}", unit_type.as_step_token()),
},
entities::IfcUnitEmission::ConversionBased {
unit_type,
derived_name,
factor_to_si,
si_base_name,
} => format!(
"{}:{derived_name}:factor_to_{si_base_name}={factor_to_si}",
unit_type.as_step_token()
),
}
}
type WalkerElementData = (
std::collections::BTreeMap<u32, ElementLayers>,
std::collections::BTreeMap<u32, Vec<String>>,
std::collections::BTreeMap<u32, Vec<LayerBand>>,
std::collections::BTreeMap<u32, u32>,
);
fn append_production_walker_elements(
rf: &mut crate::RevitFile,
entities: &mut Vec<entities::IfcEntity>,
building_storeys: &mut Vec<Storey>,
materials: &mut Vec<MaterialInfo>,
policy: export_content::ExportContentPolicy,
walker_limits: crate::walker::WalkerLimits,
) -> WalkerElementData {
if let Ok(decoded_iter) = crate::walker::iter_elements_with_limits(
rf,
crate::walker::PRODUCTION_ELEMENT_MIN_SCORE,
walker_limits,
) {
let filtered = decoded_iter
.filter(|e| e.meets_confidence(crate::walker::DEFAULT_MIN_ELEMENT_CONFIDENCE));
let append = export_content::append_typed_production_elements(
filtered,
entities,
building_storeys,
policy,
);
materials.extend(append.materials);
return (
append.element_layers,
append.element_type_materials,
append.unplaced_wall_layers,
append.element_type_ids,
);
}
Default::default()
}
fn append_diagnostic_walker_proxy_candidates(
rf: &mut crate::RevitFile,
entities: &mut Vec<entities::IfcEntity>,
walker_limits: crate::walker::WalkerLimits,
) {
for candidate in collect_diagnostic_walker_proxy_candidates(rf, walker_limits).candidates {
let name = match candidate.decoded.id {
Some(id) => format!("{}-{}", candidate.decoded.class, id),
None => format!(
"{}-offset-{:x}",
candidate.decoded.class, candidate.decoded.byte_range.start
),
};
let property_set = diagnostic_candidate_property_set(&candidate.decoded, candidate.score);
entities.push(entities::IfcEntity::BuildingElement {
ifc_type: "IFCBUILDINGELEMENTPROXY".to_string(),
name,
type_guid: candidate.decoded.id.map(|id| id.to_string()),
predefined_type: None,
storey_index: None,
material_index: None,
property_set: Some(property_set),
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,
});
}
}
#[derive(Debug, Clone)]
struct DiagnosticProxyCandidate {
decoded: crate::walker::DecodedElement,
score: i64,
}
#[derive(Debug, Default, Clone)]
struct DiagnosticProxyCandidateCollection {
candidates: Vec<DiagnosticProxyCandidate>,
warnings: Vec<String>,
}
fn collect_diagnostic_walker_proxy_candidates(
rf: &mut crate::RevitFile,
walker_limits: crate::walker::WalkerLimits,
) -> DiagnosticProxyCandidateCollection {
let mut collection = DiagnosticProxyCandidateCollection::default();
let formats_raw = match rf.read_stream(crate::streams::FORMATS_LATEST) {
Ok(raw) => raw,
Err(err) => {
collection
.warnings
.push(format!("Unable to read Formats/Latest: {err}"));
return collection;
}
};
let formats_d = match crate::compression::inflate_stream_at(
crate::streams::FORMATS_LATEST,
&formats_raw,
0,
) {
Ok(bytes) => bytes,
Err(err) => {
collection
.warnings
.push(format!("Unable to inflate Formats/Latest: {err}"));
return collection;
}
};
let schema = match crate::formats::parse_schema(&formats_d) {
Ok(schema) => schema,
Err(err) => {
collection
.warnings
.push(format!("Unable to parse Formats/Latest schema: {err}"));
return collection;
}
};
let raw = match rf.read_stream(crate::streams::GLOBAL_LATEST) {
Ok(raw) => raw,
Err(err) => {
collection
.warnings
.push(format!("Unable to read Global/Latest: {err}"));
return collection;
}
};
let (_, latest) =
match crate::compression::inflate_stream_auto(crate::streams::GLOBAL_LATEST, &raw) {
Ok(inflated) => inflated,
Err(err) => {
collection
.warnings
.push(format!("Unable to inflate Global/Latest: {err}"));
return collection;
}
};
let class_by_name: std::collections::HashMap<&str, &crate::formats::ClassEntry> = schema
.classes
.iter()
.map(|class| (class.name.as_str(), class))
.collect();
let scan = crate::walker::scan_candidates_with_limits(
&schema,
&latest,
crate::walker::DIAGNOSTIC_ELEMENT_MIN_SCORE,
walker_limits,
);
if let Some(hit) = scan.limit_hit {
collection
.warnings
.push(format!("{}: {}", hit.code(), hit.message()));
}
let mut seen_ids = std::collections::BTreeSet::<u32>::new();
let mut seen_offsets = std::collections::BTreeSet::<usize>::new();
for candidate in scan.candidates {
if candidate.score >= crate::walker::PRODUCTION_ELEMENT_MIN_SCORE {
continue;
}
let Some(class) = class_by_name.get(candidate.class_name.as_str()).copied() else {
continue;
};
let mut decoded = crate::walker::decode_instance_with_limits(
&latest,
candidate.offset,
class,
walker_limits,
);
let self_id = crate::walker::find_self_id_field(class)
.and_then(|index| decoded.fields.get(index))
.and_then(|(_, field)| match field {
crate::walker::InstanceField::ElementId { id, .. } if *id != 0 => Some(*id),
_ => None,
});
if let Some(id) = self_id {
if !seen_ids.insert(id) {
continue;
}
decoded.id = Some(id);
} else if !seen_offsets.insert(candidate.offset) {
continue;
}
collection.candidates.push(DiagnosticProxyCandidate {
decoded,
score: candidate.score,
});
}
collection
}
fn diagnostic_candidate_property_set(
decoded: &crate::walker::DecodedElement,
score: i64,
) -> entities::PropertySet {
let mut properties = vec![
entities::Property {
name: "DiagnosticReason".into(),
value: entities::PropertyValue::Text(
"low-confidence schema scan candidate; omitted from default export".into(),
),
},
entities::Property {
name: "DecodedClass".into(),
value: entities::PropertyValue::Text(decoded.class.clone()),
},
entities::Property {
name: "SourceStream".into(),
value: entities::PropertyValue::Text(crate::streams::GLOBAL_LATEST.into()),
},
entities::Property {
name: "ByteStart".into(),
value: entities::PropertyValue::Integer(usize_to_i64_saturating(
decoded.byte_range.start,
)),
},
entities::Property {
name: "ByteEnd".into(),
value: entities::PropertyValue::Integer(usize_to_i64_saturating(
decoded.byte_range.end,
)),
},
entities::Property {
name: "CandidateScore".into(),
value: entities::PropertyValue::Integer(score),
},
];
if let Some(id) = decoded.id {
properties.push(entities::Property {
name: "ElementId".into(),
value: entities::PropertyValue::Integer(i64::from(id)),
});
}
entities::PropertySet {
name: "Pset_RvtRsDiagnosticCandidate".into(),
properties,
}
}
fn usize_to_i64_saturating(value: usize) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
pub fn build_export_diagnostics(
rf: &mut crate::RevitFile,
model: &IfcModel,
mode: ExportDiagnosticsMode,
) -> ExportDiagnostics {
build_export_diagnostics_with_limits(rf, model, mode, crate::walker::WalkerLimits::default())
}
pub fn build_export_diagnostics_with_limits(
rf: &mut crate::RevitFile,
model: &IfcModel,
mode: ExportDiagnosticsMode,
walker_limits: crate::walker::WalkerLimits,
) -> ExportDiagnostics {
let bfi = rf.basic_file_info().ok();
let part = rf.part_atom().ok();
let stream_names = rf.stream_names();
let diagnostic_candidates = collect_diagnostic_walker_proxy_candidates(rf, walker_limits);
let exported = exported_model_diagnostics(model);
let diagnostic_proxy_elements = count_diagnostic_proxy_elements(model);
let arcwall_records = count_arcwall_records(model);
let candidate_class_counts = diagnostic_candidate_class_counts(&diagnostic_candidates);
let walker_stats = production_walker_stats(rf, walker_limits);
let production_class_counts = walker_stats.class_counts.clone();
let production_walker_elements: usize = production_class_counts.values().copied().sum();
let mut skipped = Vec::new();
if walker_stats.elements_below_min_confidence > 0 {
skipped.push(SkippedExportItem {
reason: "below_min_element_confidence".into(),
count: walker_stats.elements_below_min_confidence,
classes: std::collections::BTreeMap::new(),
sample_names: Vec::new(),
});
}
if mode == ExportDiagnosticsMode::Default && !diagnostic_candidates.candidates.is_empty() {
skipped.push(SkippedExportItem {
reason: "low_confidence_schema_scan_candidate".into(),
count: diagnostic_candidates.candidates.len(),
classes: candidate_class_counts.clone(),
sample_names: diagnostic_candidates
.candidates
.iter()
.take(12)
.map(diagnostic_candidate_display_name)
.collect(),
});
}
let geometry_gaps = geometry_gap_skipped_items(model);
skipped.extend(geometry_gaps.iter().cloned());
let unattributed_frames = bfi
.as_ref()
.and_then(|b| {
crate::partition_element_records::scan_unattributed_frames(rf, b.version).ok()
})
.unwrap_or_default();
let unattributed_total: usize = unattributed_frames.values().sum();
if unattributed_total > 0 {
skipped.push(SkippedExportItem {
reason: "element_record_without_element_id".into(),
count: unattributed_total,
classes: unattributed_frames,
sample_names: Vec::new(),
});
}
let volumeless = bfi
.as_ref()
.and_then(|b| {
crate::partition_element_records::scan_volumeless_instances(rf, b.version).ok()
})
.unwrap_or_default();
let volumeless_total: usize = volumeless.values().sum();
if volumeless_total > 0 {
skipped.push(SkippedExportItem {
reason: "element_record_without_volume".into(),
count: volumeless_total,
classes: volumeless,
sample_names: Vec::new(),
});
}
let non_primary = bfi
.as_ref()
.and_then(|b| {
crate::partition_design_options::scan_non_primary_option_instances(rf, b.version).ok()
})
.unwrap_or_default();
let non_primary_total: usize = non_primary.values().sum();
if non_primary_total > 0 {
skipped.push(SkippedExportItem {
reason: "element_record_in_non_primary_design_option".into(),
count: non_primary_total,
classes: non_primary,
sample_names: Vec::new(),
});
}
let empty_panels = bfi
.as_ref()
.and_then(|b| crate::partition_schema_mvp::scan_empty_curtain_panels(rf, b.version).ok())
.unwrap_or_default();
if empty_panels.count > 0 {
skipped.push(SkippedExportItem {
reason: "element_record_empty_curtain_panel".into(),
count: empty_panels.count,
classes: std::collections::BTreeMap::from([(
"CurtainWallPanel".to_string(),
empty_panels.count,
)]),
sample_names: Vec::new(),
});
}
let recovered_units = recover_project_units(rf);
let mut warnings = diagnostic_candidates.warnings;
if !empty_panels.types_with_material.is_empty() {
warnings.push(format!(
"{} empty curtain panel type(s) carry a material, which an empty panel should not: {:?}",
empty_panels.types_with_material.len(),
empty_panels.types_with_material
));
}
if let Some(version) = bfi.as_ref().map(|b| b.version) {
if let Ok(scan) = crate::partition_arc_walls::scan_partition_arc_walls_with_limits(
rf,
version,
walker_limits,
) {
if let Some(hit) = scan.limit_hit {
warnings.push(format!("{}: {}", hit.code(), hit.message()));
}
}
}
if exported.building_elements == 0 {
warnings.push("No building elements were exported; output is scaffold-only.".into());
} else if bfi.as_ref().map(|b| b.version)
== Some(crate::partition_element_records_2023::REVIT_2023)
{
warnings.push(
"Revit 2023: elements come from their records with their ElementId, category and \
bounding box, doors and windows with their host wall, rooms with their outline, \
and, where its Levels are read, each element on the storey of the Level its \
record names; family instances, walls, floors and roofs carry their family and \
type names, and family instances their type's materials. Layers become \
material layer sets where their materials are named, a layer that takes its \
category's material taking the object styles' one, and walls keep their record box. \
Wall joins and profiles, design options, parameters and \
\"Export to IFC As\" overrides are not read for 2023, and components nested in \
doors and windows are left out."
.into(),
);
}
append_geometry_gap_warnings(&mut warnings, &exported, &geometry_gaps);
if model.units.is_empty() {
warnings
.push("No Revit display units were recovered; the IFC declares the SI units its values are written in.".into());
}
let mut unmapped_unit_values: Vec<_> = recovered_units
.unknown_identifiers
.iter()
.filter(|identifier| identifier.starts_with("autodesk.unit.unit:"))
.cloned()
.collect();
if !unmapped_unit_values.is_empty() {
let sample = {
unmapped_unit_values.truncate(8);
unmapped_unit_values
};
warnings.push(format!(
"Unmapped Revit unit identifiers were preserved in diagnostics: {}",
sample.join(", ")
));
}
if model.building_storeys.is_empty() {
warnings.push(
"No Revit levels were recovered; STEP output uses the fallback spatial storey.".into(),
);
} else {
let elevation_fallback = model
.building_storeys
.iter()
.filter(|storey| storey.name.starts_with("Elevation "))
.count();
let named = model.building_storeys.len() - elevation_fallback;
let level_reference_bound = model
.entities
.iter()
.filter(|entity| storey_bind_source(entity) == Some(STOREY_BIND_RECORD_LEVEL_REFERENCE))
.count();
if named > 0 && level_reference_bound == 0 {
warnings.push(format!(
"{named} building storey name(s) came from partition Level-like strings (RE-15/#86 via PR #117); ElementId↔Level binding is still pending."
));
} else if named > 0 {
warnings.push(format!(
"{named} building storey name(s) came from partition Level records; {level_reference_bound} building element(s) reached their storey through the Level ElementId their own element record names (#219, RE-27; the base constraint where it names two, RE-59), the rest through a measured elevation match."
));
}
if elevation_fallback > 0 {
warnings.push(format!(
"{elevation_fallback} building storey(s) lack a confident partition Level name and keep elevation fallback labels."
));
}
let level_names = production_class_counts
.get("Level")
.copied()
.unwrap_or_default();
if elevation_fallback == model.building_storeys.len() && exported.storey_bound_elements > 0
{
warnings.push(format!(
"{} building storey elevation(s) were measured from partition element-record bounding boxes and {} building element(s) bound to them by an exact record-face elevation match (base face, or top face for slabs and shading devices) (#213, #212); the {level_names} recovered Level name string(s) could not be paired with those elevations and are not asserted as storey names.",
model.building_storeys.len(),
exported.storey_bound_elements,
));
}
}
let unresolved_thickness = model
.entities
.iter()
.filter(|entity| {
matches!(
entity,
entities::IfcEntity::BuildingElement {
name,
property_set: Some(pset),
..
} if name.starts_with("ArcWall-")
&& pset.properties.iter().any(|p| {
p.name == "ThicknessResolved"
&& matches!(p.value, entities::PropertyValue::Boolean(false))
})
)
})
.count();
if unresolved_thickness > 0 {
warnings.push(format!(
"{unresolved_thickness} ArcWall records lack recovered thickness; RE-15/#88 falsified exact inch widths in the standard trailer, so IFC depth uses an unresolved placeholder pending WallType width join."
));
}
if let Some(item) = skipped
.iter()
.find(|item| item.reason == "low_confidence_schema_scan_candidate")
{
warnings.push(format!(
"Suppressed {} low-confidence schema scan candidates from default export.",
item.count
));
}
if unattributed_total > 0 {
warnings.insert(
0,
format!(
"{unattributed_total} model element record(s) in this file use an element-record layout whose ElementId this release cannot locate, so they are not exported and the model is incomplete (RE-30)."
),
);
}
if mode == ExportDiagnosticsMode::DiagnosticProxies && diagnostic_proxy_elements > 0 {
warnings.push(
"Diagnostic proxy elements are low-confidence scan candidates, not validated model elements."
.into(),
);
}
if mode == ExportDiagnosticsMode::Placeholder {
warnings.push("Placeholder export mode omits decoded elements by design.".into());
}
let formats_latest_integrity = match rf.read_stream(crate::streams::FORMATS_LATEST) {
Ok(stored) => {
let diag = crate::compression::diagnose_formats_latest_integrity(&stored);
if diag.integrity_status == crate::compression::FormatsIntegrityStatus::Uncertain {
warnings.push(format!(
"Formats/Latest multipage integrity uncertain (checksum-page strip disabled; {}).",
crate::compression::RVT_FORMATS_MULTIPAGE_UNVERIFIED
));
}
Some(diag)
}
Err(_) => None,
};
let has_project_metadata = model.project_name.is_some()
|| bfi.is_some()
|| part.as_ref().and_then(|p| p.title.as_ref()).is_some();
let confidence = export_confidence_summary(
mode,
&exported,
has_project_metadata,
diagnostic_proxy_elements,
warnings.len(),
unattributed_total,
);
ExportDiagnostics {
schema_version: EXPORT_DIAGNOSTICS_SCHEMA_VERSION,
mode,
input: ExportInputDiagnostics {
revit_version: bfi.as_ref().map(|b| b.version),
build: bfi.as_ref().and_then(|b| b.build.clone()),
original_path: bfi
.as_ref()
.and_then(|b| b.original_path.as_ref())
.map(|path| crate::redact::redact_sensitive(path)),
project_name: model
.project_name
.clone()
.or_else(|| part.as_ref().and_then(|p| p.title.clone())),
stream_count: stream_names.len(),
has_basic_file_info: bfi.is_some(),
has_part_atom: part.is_some(),
has_formats_latest: stream_names
.iter()
.any(|name| name == crate::streams::FORMATS_LATEST),
has_global_latest: stream_names
.iter()
.any(|name| name == crate::streams::GLOBAL_LATEST),
},
decoded: DecodedExportDiagnostics {
production_walker_elements,
diagnostic_proxy_candidates: diagnostic_candidates.candidates.len(),
arcwall_records,
class_counts: candidate_class_counts,
parameter_value_count: parameter_value_count_from_class_counts(
&production_class_counts,
),
production_class_counts,
mean_element_confidence: walker_stats.mean_element_confidence,
elements_below_min_confidence: walker_stats.elements_below_min_confidence,
min_element_confidence: crate::walker::DEFAULT_MIN_ELEMENT_CONFIDENCE,
recovered_unit_identifiers: model
.units
.iter()
.map(|unit| unit.forge_identifier.clone())
.collect(),
unknown_unit_identifiers: recovered_units.unknown_identifiers,
},
source_coverage: {
let coverage = SourceCoverageDiagnostics::measure(
production_walker_elements,
exported.building_elements,
exported.building_elements_with_geometry,
elem_table_declared_element_count(rf),
);
Some(coverage)
},
exported,
skipped,
unsupported_features: unsupported_export_features(model),
warnings,
confidence,
formats_latest_integrity,
}
}
fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics {
let mut by_ifc_type = std::collections::BTreeMap::<String, usize>::new();
let mut building_elements = 0usize;
let mut building_elements_with_geometry = 0usize;
let mut storey_bound_elements = 0usize;
let wholes: std::collections::BTreeSet<usize> = model
.entities
.iter()
.filter_map(|entity| match entity {
entities::IfcEntity::Aggregate { whole, .. } => Some(*whole),
_ => None,
})
.collect();
let mut building_elements_carried_by_parts = 0usize;
let mut body_sources = std::collections::BTreeMap::<String, usize>::new();
let mut bounding_box_bodies = 0usize;
for (index, entity) in model.entities.iter().enumerate() {
if let entities::IfcEntity::BuildingElement {
ifc_type,
location_feet,
extrusion,
solid_shape,
representation_map_index,
storey_index,
property_set,
..
} = entity
{
if ifc_type == "IFCOPENINGELEMENT" {
*by_ifc_type.entry(ifc_type.clone()).or_insert(0) += 1;
continue;
}
let properties = property_set.iter().flat_map(|set| &set.properties);
let mut source = None;
let mut profile_resolved = false;
for property in properties {
match (property.name.as_str(), &property.value) {
("BodySource", entities::PropertyValue::Text(text)) => source = Some(text),
("ProfileResolved", entities::PropertyValue::Boolean(true)) => {
profile_resolved = true
}
_ => {}
}
}
if let Some(source) = source {
*body_sources.entry(source.clone()).or_insert(0) += 1;
if source == export_content::RECORD_BBOX_BODY_SOURCE && !profile_resolved {
bounding_box_bodies += 1;
}
}
building_elements += 1;
*by_ifc_type.entry(ifc_type.clone()).or_insert(0) += 1;
if storey_index.is_some() {
storey_bound_elements += 1;
}
if location_feet.is_some()
&& (extrusion.is_some()
|| solid_shape.is_some()
|| representation_map_index.is_some())
{
building_elements_with_geometry += 1;
} else if wholes.contains(&index) {
building_elements_carried_by_parts += 1;
}
}
}
const MATERIAL_NAME_SAMPLE_CAP: usize = 12;
ExportedModelDiagnostics {
total_entities: model.entities.len(),
building_elements,
building_elements_with_geometry,
building_elements_carried_by_parts,
by_ifc_type,
classification_count: model.classifications.len(),
unit_assignment_count: model.units.len(),
material_count: model.materials.len(),
storey_count: model.building_storeys.len(),
storey_names: model
.building_storeys
.iter()
.map(|s| s.name.clone())
.collect(),
storey_elevations_feet: model
.building_storeys
.iter()
.map(|s| s.elevation_feet)
.collect(),
storey_bound_elements,
material_names_sample: model
.materials
.iter()
.take(MATERIAL_NAME_SAMPLE_CAP)
.map(|m| m.name.clone())
.collect(),
layered_element_count: model.element_layers.len(),
body_sources,
bounding_box_bodies,
}
}
#[derive(Debug)]
struct GeometryGapBucket {
reason: &'static str,
count: usize,
classes: std::collections::BTreeMap<String, usize>,
sample_names: Vec<String>,
}
impl GeometryGapBucket {
fn new(reason: &'static str) -> Self {
Self {
reason,
count: 0,
classes: std::collections::BTreeMap::new(),
sample_names: Vec::new(),
}
}
fn add(&mut self, ifc_type: &str, name: &str) {
self.count += 1;
*self.classes.entry(ifc_type.to_string()).or_insert(0) += 1;
if self.sample_names.len() < 12 {
self.sample_names.push(name.to_string());
}
}
fn into_item(self) -> Option<SkippedExportItem> {
(self.count > 0).then(|| SkippedExportItem {
reason: self.reason.into(),
count: self.count,
classes: self.classes,
sample_names: self.sample_names,
})
}
}
fn geometry_gap_skipped_items(model: &IfcModel) -> Vec<SkippedExportItem> {
let mut unsupported_curve = GeometryGapBucket::new("unsupported_geometry_curve");
let mut unsupported_profile = GeometryGapBucket::new("unsupported_geometry_profile");
let mut unresolved_host = GeometryGapBucket::new("unsupported_geometry_unresolved_host");
let mut missing_level = GeometryGapBucket::new("unsupported_geometry_missing_level");
let mut missing_dimensions = GeometryGapBucket::new("unsupported_geometry_missing_dimensions");
let mut floor_boundary_only = GeometryGapBucket::new("floor_boundary_annotation_only");
let mut opening_index_only = GeometryGapBucket::new("opening_index_without_host_geometry");
for entity in &model.entities {
let entities::IfcEntity::BuildingElement {
ifc_type,
name,
storey_index,
location_feet,
extrusion,
host_element_index,
solid_shape,
representation_map_index,
property_set,
..
} = entity
else {
continue;
};
let has_body_shape =
extrusion.is_some() || solid_shape.is_some() || representation_map_index.is_some();
let floor_annotation = property_set
.as_ref()
.is_some_and(|p| p.name == "RvtFloorGeometry");
let opening_annotation = property_set
.as_ref()
.is_some_and(|p| p.name == "RvtArcWallRectOpening");
if floor_annotation && !has_body_shape {
floor_boundary_only.add(ifc_type, name);
continue;
}
if opening_annotation {
opening_index_only.add(ifc_type, name);
continue;
}
if storey_index.is_none() {
missing_level.add(ifc_type, name);
}
if location_feet.is_none() {
unsupported_curve.add(ifc_type, name);
}
if !has_body_shape {
missing_dimensions.add(ifc_type, name);
}
if location_feet.is_some() && !has_body_shape {
unsupported_profile.add(ifc_type, name);
}
if hosted_geometry_type(ifc_type) && host_element_index.is_none() {
unresolved_host.add(ifc_type, name);
}
}
[
unsupported_curve,
unsupported_profile,
unresolved_host,
missing_level,
missing_dimensions,
floor_boundary_only,
opening_index_only,
]
.into_iter()
.filter_map(GeometryGapBucket::into_item)
.collect()
}
fn hosted_geometry_type(ifc_type: &str) -> bool {
matches!(
ifc_type,
"IFCDOOR" | "IFCWINDOW" | "IFCOPENINGELEMENT" | "IFCOPENINGSTANDARDCASE"
)
}
fn append_geometry_gap_warnings(
warnings: &mut Vec<String>,
exported: &ExportedModelDiagnostics,
geometry_gaps: &[SkippedExportItem],
) {
if exported.building_elements > 0 && exported.building_elements_with_geometry == 0 {
warnings.push(
"No exported building elements include decoded geometry; see skipped diagnostics for missing curve/profile/dimension data."
.into(),
);
}
if geometry_gaps.is_empty() {
return;
}
let summary = geometry_gaps
.iter()
.map(|item| format!("{}={}", item.reason, item.count))
.collect::<Vec<_>>()
.join(", ");
warnings.push(format!(
"Unsupported or incomplete geometry was reported: {summary}."
));
}
fn count_arcwall_records(model: &IfcModel) -> usize {
model
.entities
.iter()
.filter(|entity| {
matches!(
entity,
entities::IfcEntity::BuildingElement { name, .. } if name.starts_with("ArcWall-")
)
})
.count()
}
fn count_diagnostic_proxy_elements(model: &IfcModel) -> usize {
model
.entities
.iter()
.filter(|entity| {
matches!(
entity,
entities::IfcEntity::BuildingElement {
property_set: Some(property_set),
..
} if property_set.name == "Pset_RvtRsDiagnosticCandidate"
)
})
.count()
}
fn diagnostic_candidate_class_counts(
collection: &DiagnosticProxyCandidateCollection,
) -> std::collections::BTreeMap<String, usize> {
let mut out = std::collections::BTreeMap::new();
for candidate in &collection.candidates {
*out.entry(candidate.decoded.class.clone()).or_insert(0) += 1;
}
out
}
fn diagnostic_candidate_display_name(candidate: &DiagnosticProxyCandidate) -> String {
match candidate.decoded.id {
Some(id) => format!("{}-{id}", candidate.decoded.class),
None => format!(
"{}-offset-{:x}",
candidate.decoded.class, candidate.decoded.byte_range.start
),
}
}
fn unsupported_export_features(model: &IfcModel) -> Vec<String> {
let exported = exported_model_diagnostics(model);
let mut features = Vec::new();
if model.units.is_empty() {
features.push("project_units_from_revit_bytes".into());
}
if model.building_storeys.is_empty() {
features.push("revit_levels_to_ifc_storeys".into());
}
if exported.building_elements_with_geometry == 0 {
features.push("real_file_element_geometry".into());
} else if exported.building_elements_with_geometry < exported.building_elements {
features.push("partial_element_geometry".into());
}
features.push("revit_compound_assemblies_and_walltype_widths".into());
if model.materials.is_empty() {
features.push("revit_material_display_names".into());
}
let slab_count = exported.by_ifc_type.get("IFCSLAB").copied().unwrap_or(0);
if slab_count == 0 {
features.push("floor_plan_loop_slab_export".into());
} else if model.entities.iter().any(slab_thickness_unresolved) {
features.push("floor_slab_extrusion_thickness".into());
}
let has_typed_door = exported.by_ifc_type.get("IFCDOOR").copied().unwrap_or(0) > 0;
let has_typed_window = exported.by_ifc_type.get("IFCWINDOW").copied().unwrap_or(0) > 0;
if !has_typed_door || !has_typed_window {
features.push("typed_door_window_discrimination_and_host_binding".into());
} else if model.entities.iter().any(|e| {
matches!(
e,
entities::IfcEntity::BuildingElement { ifc_type, host_element_index, .. }
if (ifc_type == "IFCDOOR" || ifc_type == "IFCWINDOW")
&& host_element_index.is_none()
)
}) {
features.push("door_window_host_wall_binding".into());
}
features.push("opening_index_to_ifc_openingelement_host_join".into());
let has_schema_wall = model.entities.iter().any(|e| {
matches!(
e,
entities::IfcEntity::BuildingElement { ifc_type, name, property_set, .. }
if ifc_type == "IFCWALL"
&& name.starts_with("Wall-")
&& !matches!(
property_set,
Some(set) if set.name == ELEMENT_RECORD_PROPERTY_SET
)
)
});
if !has_schema_wall {
features.push("schema_field_wall_instances".into());
}
let has_parameter_property_set = model.entities.iter().any(|e| {
matches!(
e,
entities::IfcEntity::BuildingElement { property_set: Some(set), .. }
if !set.name.starts_with("Rvt")
)
});
if !has_parameter_property_set {
features.push("revit_element_parameters_to_ifc_property_sets".into());
}
features
}
#[derive(Debug, Default)]
struct ProductionWalkerStats {
class_counts: std::collections::BTreeMap<String, usize>,
mean_element_confidence: Option<f32>,
elements_below_min_confidence: usize,
}
fn production_walker_stats(
rf: &mut crate::RevitFile,
walker_limits: crate::walker::WalkerLimits,
) -> ProductionWalkerStats {
let mut stats = ProductionWalkerStats::default();
let Ok(iter) = crate::walker::iter_elements_with_limits(
rf,
crate::walker::PRODUCTION_ELEMENT_MIN_SCORE,
walker_limits,
) else {
return stats;
};
let mut sum = 0.0_f32;
let mut n = 0usize;
let min_c = crate::walker::DEFAULT_MIN_ELEMENT_CONFIDENCE;
for el in iter {
*stats.class_counts.entry(el.class.clone()).or_insert(0) += 1;
sum += el.provenance.confidence;
n += 1;
if !el.meets_confidence(min_c) {
stats.elements_below_min_confidence += 1;
}
}
if n > 0 {
stats.mean_element_confidence = Some(sum / n as f32);
}
stats
}
fn parameter_value_count_from_class_counts(
class_counts: &std::collections::BTreeMap<String, usize>,
) -> usize {
class_counts
.iter()
.filter(|(class, _)| crate::elements::parameters::is_aproperty_class(class))
.map(|(_, n)| *n)
.sum()
}
fn export_confidence_summary(
mode: ExportDiagnosticsMode,
exported: &ExportedModelDiagnostics,
has_project_metadata: bool,
diagnostic_proxy_elements: usize,
warning_count: usize,
unexported_element_records: usize,
) -> ExportConfidenceSummary {
let has_typed_elements = exported
.by_ifc_type
.iter()
.any(|(ifc_type, count)| *count > 0 && ifc_type != "IFCBUILDINGELEMENTPROXY");
let has_geometry = exported.building_elements_with_geometry > 0;
let has_diagnostic_proxies = diagnostic_proxy_elements > 0;
let level = if mode == ExportDiagnosticsMode::Placeholder || exported.building_elements == 0 {
"scaffold"
} else if has_geometry {
"geometry"
} else if has_diagnostic_proxies {
"diagnostic_partial"
} else if has_typed_elements {
"typed_no_geometry"
} else {
"proxy_only"
};
let mut score = 0.10f32;
if has_project_metadata {
score += 0.15;
}
if exported.unit_assignment_count > 0 {
score += 0.10;
}
let mut element_score = 0.0f32;
if exported.building_elements > 0 {
element_score += 0.20;
}
if has_typed_elements {
element_score += 0.20;
}
if has_geometry {
element_score += 0.25;
}
if unexported_element_records > 0 {
let exported_elements = exported.building_elements as f32;
element_score *=
exported_elements / (exported_elements + unexported_element_records as f32);
}
score += element_score;
if has_diagnostic_proxies {
score -= 0.05;
}
ExportConfidenceSummary {
level: level.into(),
score: score.clamp(0.0, 1.0),
has_project_metadata,
has_typed_elements,
has_geometry,
has_diagnostic_proxies,
warning_count,
unexported_element_records,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn column_record_element(
id: u32,
centre: (f64, f64),
base_z: f64,
) -> crate::walker::DecodedElement {
use crate::walker::{DecodedElement, ElementProvenance, InstanceField};
let float = |value: f64| InstanceField::Float { value, size: 8 };
DecodedElement {
id: Some(id),
class: "Column".into(),
fields: vec![
("m_locationX".into(), float(centre.0)),
("m_locationY".into(), float(centre.1)),
("m_locationZ".into(), float(base_z)),
("m_bboxWidth".into(), float(2.0)),
("m_bboxDepth".into(), float(2.0)),
("m_bboxHeight".into(), float(15.0)),
(
"m_source".into(),
InstanceField::String("partition_element_record".into()),
),
],
byte_range: 0..88,
provenance: ElementProvenance::partition(
"Partitions/46",
0,
"partition_element_record",
"partition_schema_mvp::column_category_record",
0.8,
Some("level_binding_unresolved"),
),
}
}
fn append_columns(decoded: Vec<crate::walker::DecodedElement>) -> Vec<entities::IfcEntity> {
let mut entities = Vec::new();
let mut storeys = Vec::new();
export_content::append_typed_production_elements(
decoded.into_iter(),
&mut entities,
&mut storeys,
export_content::ExportContentPolicy::for_quality_mode(ExportQualityMode::Geometry),
);
entities
}
fn record_element_of_class(id: u32, class: &str, base_z: f64) -> crate::walker::DecodedElement {
let mut decoded = column_record_element(id, (10.0, 10.0), base_z);
decoded.class = class.into();
decoded
}
#[test]
fn only_column_records_supply_storey_elevations() {
let mut entities = append_columns(vec![
record_element_of_class(1, "Column", 0.0),
record_element_of_class(2, "Column", 31.0),
record_element_of_class(3, "Window", 35.73),
record_element_of_class(4, "Wall", 31.0),
record_element_of_class(5, "Door", 0.0),
]);
let mut storeys = vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}];
apply_element_record_storeys(&mut entities, &mut storeys);
assert_eq!(
storeys.iter().map(|s| s.elevation_feet).collect::<Vec<_>>(),
vec![0.0, 31.0],
"the window sill height must not become a storey"
);
let bound: Vec<Option<usize>> = entities
.iter()
.map(|entity| match entity {
entities::IfcEntity::BuildingElement { storey_index, .. } => *storey_index,
_ => None,
})
.collect();
assert_eq!(
bound,
vec![Some(0), Some(1), None, Some(1), Some(0)],
"walls and doors bind by exact match; the window stays unbound"
);
}
#[test]
fn slabs_bind_to_a_storey_by_their_top_face() {
let mut slab = record_element_of_class(3, "Floor", 30.8333);
slab.fields.retain(|(n, _)| n != "m_bboxHeight");
slab.fields.push((
"m_bboxHeight".into(),
crate::walker::InstanceField::Float {
value: 0.1667,
size: 8,
},
));
let mut interface = record_element_of_class(4, "Floor", 30.3333);
interface.fields.retain(|(n, _)| n != "m_bboxHeight");
interface.fields.push((
"m_bboxHeight".into(),
crate::walker::InstanceField::Float {
value: 0.5,
size: 8,
},
));
let mut entities = append_columns(vec![
record_element_of_class(1, "Column", 0.0),
record_element_of_class(2, "Column", 31.0),
slab,
interface,
]);
let mut storeys = vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}];
apply_element_record_storeys(&mut entities, &mut storeys);
let bound: Vec<Option<usize>> = entities
.iter()
.map(|entity| match entity {
entities::IfcEntity::BuildingElement { storey_index, .. } => *storey_index,
_ => None,
})
.collect();
assert_eq!(
bound,
vec![Some(0), Some(1), Some(1), None],
"the topping's top (31 ft) binds; the structural slab's top (30.8333 ft) matches no storey and stays unbound"
);
}
#[test]
fn a_bodyless_slab_does_not_bind_by_its_base() {
let mut slab = record_element_of_class(3, "Floor", 31.0);
slab.fields.retain(|(n, _)| n != "m_bboxHeight");
let mut entities = append_columns(vec![
record_element_of_class(1, "Column", 0.0),
record_element_of_class(2, "Column", 31.0),
slab,
]);
let mut storeys = vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}];
apply_element_record_storeys(&mut entities, &mut storeys);
match &entities[2] {
entities::IfcEntity::BuildingElement { storey_index, .. } => {
assert_eq!(*storey_index, None);
}
_ => panic!("expected building element"),
}
}
#[test]
fn a_named_level_reference_binds_an_element_no_elevation_join_reaches() {
let mut slab = record_element_of_class(3, "Floor", 30.6667);
slab.fields.retain(|(n, _)| n != "m_bboxHeight");
slab.fields.push((
"m_bboxHeight".into(),
crate::walker::InstanceField::Float {
value: 0.1667,
size: 8,
},
));
slab.fields.push((
crate::element_record_level_refs::LEVEL_REFERENCE_FIELD.into(),
crate::walker::InstanceField::ElementId { tag: 0, id: 20274 },
));
let mut window = record_element_of_class(4, "Window", 35.73);
window.fields.push((
crate::element_record_level_refs::LEVEL_REFERENCE_FIELD.into(),
crate::walker::InstanceField::ElementId { tag: 0, id: 20274 },
));
let mut entities = append_columns(vec![slab, window]);
let storeys = vec![
Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
},
Storey {
name: "Level 3".into(),
elevation_feet: 31.0,
},
];
let mut bind = crate::level_bind::LevelStoreyBind::new();
bind.record_level(Some(20268), 0);
bind.record_level(Some(20274), 1);
apply_record_level_reference_storeys(&mut entities, &storeys, &bind);
let bound: Vec<Option<usize>> = entities
.iter()
.map(|entity| match entity {
entities::IfcEntity::BuildingElement { storey_index, .. } => *storey_index,
_ => None,
})
.collect();
assert_eq!(bound, vec![Some(1), Some(1)]);
let entities::IfcEntity::BuildingElement { property_set, .. } = &entities[0] else {
panic!("expected a building element");
};
assert!(
property_set
.as_ref()
.expect("record element carries RvtElementRecordGeometry")
.properties
.iter()
.any(|p| p.name == STOREY_BIND_SOURCE_PROPERTY
&& matches!(&p.value, entities::PropertyValue::Text(text)
if text == STOREY_BIND_RECORD_LEVEL_REFERENCE)),
"a level-reference bind must say so rather than claim the elevation join"
);
}
#[test]
fn an_unknown_level_reference_stays_unbound() {
let mut slab = record_element_of_class(3, "Floor", 30.6667);
slab.fields.push((
crate::element_record_level_refs::LEVEL_REFERENCE_FIELD.into(),
crate::walker::InstanceField::ElementId { tag: 0, id: 99_999 },
));
let mut entities = append_columns(vec![slab]);
let storeys = vec![Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
}];
let mut bind = crate::level_bind::LevelStoreyBind::new();
bind.record_level(Some(20268), 0);
apply_record_level_reference_storeys(&mut entities, &storeys, &bind);
match &entities[0] {
entities::IfcEntity::BuildingElement { storey_index, .. } => {
assert_eq!(*storey_index, None);
}
_ => panic!("expected building element"),
}
}
#[test]
fn the_elevation_join_leaves_a_level_reference_bind_alone() {
let mut slab = record_element_of_class(3, "Floor", 30.6667);
slab.fields.retain(|(n, _)| n != "m_bboxHeight");
slab.fields.push((
"m_bboxHeight".into(),
crate::walker::InstanceField::Float {
value: 0.1667,
size: 8,
},
));
slab.fields.push((
crate::element_record_level_refs::LEVEL_REFERENCE_FIELD.into(),
crate::walker::InstanceField::ElementId { tag: 0, id: 20274 },
));
let mut entities = append_columns(vec![
column_record_element(1, (10.0, 10.0), 0.0),
column_record_element(2, (30.0, 10.0), 31.0),
slab,
]);
let mut storeys = vec![
Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
},
Storey {
name: "Level 3".into(),
elevation_feet: 31.0,
},
];
let mut bind = crate::level_bind::LevelStoreyBind::new();
bind.record_level(Some(20274), 1);
apply_record_level_reference_storeys(&mut entities, &storeys, &bind);
apply_element_record_storeys(&mut entities, &mut storeys);
let bound: Vec<Option<usize>> = entities
.iter()
.map(|entity| match entity {
entities::IfcEntity::BuildingElement { storey_index, .. } => *storey_index,
_ => None,
})
.collect();
assert_eq!(bound, vec![Some(0), Some(1), Some(1)]);
let entities::IfcEntity::BuildingElement { property_set, .. } = &entities[2] else {
panic!("expected a building element");
};
let sources: Vec<&str> = property_set
.as_ref()
.expect("record element carries RvtElementRecordGeometry")
.properties
.iter()
.filter(|p| p.name == STOREY_BIND_SOURCE_PROPERTY)
.filter_map(|p| match &p.value {
entities::PropertyValue::Text(text) => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(sources, vec![STOREY_BIND_RECORD_LEVEL_REFERENCE]);
}
#[test]
fn record_base_elevation_round_trips_through_the_production_path() {
let entities = append_columns(vec![column_record_element(1, (10.0, 10.0), 76.0)]);
assert_eq!(entities.len(), 1);
assert_eq!(record_base_elevation_feet(&entities[0]), Some(76.0));
}
#[test]
fn element_records_supply_storey_elevations_and_containment() {
let mut entities = append_columns(vec![
column_record_element(1, (10.0, 10.0), 0.0),
column_record_element(2, (30.0, 10.0), 0.0),
column_record_element(3, (10.0, 30.0), 15.0),
column_record_element(4, (30.0, 30.0), 31.0),
]);
let mut storeys = vec![
Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
},
Storey {
name: "Roof".into(),
elevation_feet: 0.0,
},
];
apply_element_record_storeys(&mut entities, &mut storeys);
assert_eq!(
storeys.iter().map(|s| s.elevation_feet).collect::<Vec<_>>(),
vec![0.0, 15.0, 31.0],
);
assert_eq!(storeys[0].name, "Elevation 0.000 ft");
let bound: Vec<Option<usize>> = entities
.iter()
.map(|entity| match entity {
entities::IfcEntity::BuildingElement { storey_index, .. } => *storey_index,
_ => None,
})
.collect();
assert_eq!(bound, vec![Some(0), Some(0), Some(1), Some(2)]);
let entities::IfcEntity::BuildingElement { property_set, .. } = &entities[3] else {
panic!("expected a building element");
};
assert!(
property_set
.as_ref()
.expect("record element carries RvtElementRecordGeometry")
.properties
.iter()
.any(|p| p.name == STOREY_BIND_SOURCE_PROPERTY),
"a bound element must record how it reached its storey"
);
}
#[test]
fn existing_elevations_survive_and_unmatched_elements_stay_unbound() {
let mut entities = append_columns(vec![
column_record_element(1, (10.0, 10.0), 12.0),
column_record_element(2, (30.0, 10.0), 7.5),
]);
let mut storeys = vec![
Storey {
name: "Ground".into(),
elevation_feet: 0.0,
},
Storey {
name: "First".into(),
elevation_feet: 12.0,
},
];
apply_element_record_storeys(&mut entities, &mut storeys);
assert_eq!(storeys.len(), 2);
assert_eq!(storeys[1].name, "First");
let bound: Vec<Option<usize>> = entities
.iter()
.map(|entity| match entity {
entities::IfcEntity::BuildingElement { storey_index, .. } => *storey_index,
_ => None,
})
.collect();
assert_eq!(bound, vec![Some(1), None]);
}
#[test]
fn exported_diagnostics_include_storey_and_material_name_samples() {
let model = IfcModel {
building_storeys: vec![
Storey {
name: "Level 1".into(),
elevation_feet: 0.0,
},
Storey {
name: "Roof".into(),
elevation_feet: 10.0,
},
],
materials: vec![
MaterialInfo {
name: "Concrete".into(),
color_packed: None,
transparency: None,
},
MaterialInfo {
name: "Glass".into(),
color_packed: None,
transparency: None,
},
],
..Default::default()
};
let exported = exported_model_diagnostics(&model);
assert_eq!(exported.storey_count, 2);
assert_eq!(exported.storey_names, vec!["Level 1", "Roof"]);
assert_eq!(exported.material_count, 2);
assert_eq!(exported.material_names_sample, vec!["Concrete", "Glass"]);
}
#[test]
fn placeholder_exporter_default_model_has_no_name() {
let m = IfcModel::default();
assert!(m.project_name.is_none());
}
fn arcwall_record_with_coords(coords: [f64; 6]) -> crate::arc_wall_record::ArcWallRecord {
crate::arc_wall_record::ArcWallRecord {
tag: crate::arc_wall_record::ARC_WALL_TAG,
variant: crate::arc_wall_record::ARC_WALL_VARIANT_STANDARD,
fixed_header_0: crate::arc_wall_record::SCHEMA_FAMILY_MARKER,
count_version: 1,
type_code: 3,
coords,
coords_dup: coords,
trailer: crate::arc_wall_record::RECORD_TRAILER,
}
}
#[test]
fn arcwall_geometry_uses_midpoint_rotation_length_and_decoded_height() {
let record = arcwall_record_with_coords([0.0, 0.0, 2.0, 3.0, 4.0, 14.0]);
let (location, rotation, extrusion) =
arcwall_geometry_from_record(&record, Some(0.5)).expect("valid geometry");
assert_eq!(location, [1.5, 2.0, 2.0]);
assert!((rotation - 4.0f64.atan2(3.0)).abs() < 1e-9);
assert!((extrusion.width_feet - 5.0).abs() < 1e-9);
assert!((extrusion.depth_feet - 0.5).abs() < 1e-9);
assert!((extrusion.height_feet - 12.0).abs() < 1e-9);
}
#[test]
fn arcwall_geometry_rejects_missing_height_instead_of_inventing() {
let record = arcwall_record_with_coords([0.0, 0.0, 5.0, 3.0, 4.0, 5.0]);
assert!(arcwall_geometry_from_record(&record, Some(0.5)).is_none());
}
#[test]
fn arcwall_geometry_uses_unresolved_thickness_placeholder_when_missing() {
let record = arcwall_record_with_coords([0.0, 0.0, 0.0, 4.0, 0.0, 8.0]);
let (_, _, extrusion) =
arcwall_geometry_from_record(&record, None).expect("height present");
assert!((extrusion.depth_feet - UNRESOLVED_ARCWALL_THICKNESS_FEET).abs() < 1e-9);
assert!((extrusion.height_feet - 8.0).abs() < 1e-9);
}
#[test]
fn arcwall_geometry_rejects_degenerate_xy_segment() {
let record = arcwall_record_with_coords([1.0, 2.0, 0.0, 1.0, 2.0, 10.0]);
assert!(arcwall_geometry_from_record(&record, Some(0.5)).is_none());
}
fn diagnostic_building_element(
ifc_type: &str,
name: &str,
storey_index: Option<usize>,
location_feet: Option<[f64; 3]>,
extrusion: Option<entities::Extrusion>,
host_element_index: Option<usize>,
) -> entities::IfcEntity {
entities::IfcEntity::BuildingElement {
ifc_type: ifc_type.to_string(),
name: name.to_string(),
type_guid: None,
predefined_type: None,
storey_index,
material_index: None,
property_set: None,
location_feet,
rotation_radians: None,
extrusion,
host_element_index,
material_layer_set_index: None,
material_profile_set_index: None,
solid_shape: None,
representation_map_index: None,
}
}
#[test]
fn geometry_gap_diagnostics_report_missing_curve_profile_host_level_and_dimensions() {
let model = IfcModel {
entities: vec![
diagnostic_building_element(
"IFCWALL",
"WallWithoutGeometry",
None,
None,
None,
None,
),
diagnostic_building_element(
"IFCWINDOW",
"WindowWithoutHostOrProfile",
Some(0),
Some([1.0, 2.0, 3.0]),
None,
None,
),
],
..IfcModel::default()
};
let gaps = geometry_gap_skipped_items(&model);
let by_reason: std::collections::BTreeMap<&str, usize> = gaps
.iter()
.map(|item| (item.reason.as_str(), item.count))
.collect();
assert_eq!(by_reason["unsupported_geometry_curve"], 1);
assert_eq!(by_reason["unsupported_geometry_profile"], 1);
assert_eq!(by_reason["unsupported_geometry_unresolved_host"], 1);
assert_eq!(by_reason["unsupported_geometry_missing_level"], 1);
assert_eq!(by_reason["unsupported_geometry_missing_dimensions"], 2);
assert!(gaps.iter().any(|item| {
item.sample_names
.iter()
.any(|name| name == "WindowWithoutHostOrProfile")
}));
let exported = exported_model_diagnostics(&model);
let mut warnings = Vec::new();
append_geometry_gap_warnings(&mut warnings, &exported, &gaps);
assert!(warnings.iter().any(|warning| {
warning.contains("No exported building elements include decoded geometry")
}));
assert!(
warnings
.iter()
.any(|warning| warning.contains("unsupported_geometry_unresolved_host=1"))
);
}
#[test]
fn project_unit_recovery_selects_modal_metric_units() {
let recovered = recover_project_units_from_identifiers([
"autodesk.unit.unit:meters-1.0.0",
"autodesk.unit.unit:meters-1.0.0",
"autodesk.unit.unit:millimeters-1.0.1",
"autodesk.unit.unit:squareMeters-1.0.1",
"autodesk.unit.unit:cubicMeters-1.0.1",
"autodesk.unit.unit:degrees-1.0.1",
]);
let ids: Vec<_> = recovered
.assignments
.iter()
.map(|unit| unit.forge_identifier.as_str())
.collect();
assert!(ids.contains(&"autodesk.unit.unit:meters-1.0.0"));
assert!(ids.contains(&"autodesk.unit.unit:squareMeters-1.0.1"));
assert!(ids.contains(&"autodesk.unit.unit:cubicMeters-1.0.1"));
assert!(ids.contains(&"autodesk.unit.unit:degrees-1.0.1"));
assert!(!ids.contains(&"autodesk.unit.unit:millimeters-1.0.1"));
assert!(recovered.unknown_identifiers.is_empty());
}
#[test]
fn project_unit_recovery_maps_imperial_and_preserves_unknowns() {
let recovered = recover_project_units_from_identifiers([
"autodesk.unit.unit:feet-1.0.1",
"autodesk.unit.unit:feet-1.0.1",
"autodesk.unit.unit:meters-1.0.0",
"autodesk.unit.unit:squareFeet-1.0.1",
"autodesk.unit.unit:cubicFeet-1.0.1",
"autodesk.unit.unit:pounds-1.0.1",
"autodesk.unit.symbol:degree-1.0.1",
]);
let ids: Vec<_> = recovered
.assignments
.iter()
.map(|unit| unit.forge_identifier.as_str())
.collect();
assert!(ids.contains(&"autodesk.unit.unit:feet-1.0.1"));
assert!(ids.contains(&"autodesk.unit.unit:squareFeet-1.0.1"));
assert!(ids.contains(&"autodesk.unit.unit:cubicFeet-1.0.1"));
assert!(ids.contains(&"autodesk.unit.unit:pounds-1.0.1"));
assert_eq!(
recovered.unknown_identifiers,
vec!["autodesk.unit.symbol:degree-1.0.1"]
);
}
#[test]
fn source_coverage_measures_export_and_geometry_fractions() {
let cov = SourceCoverageDiagnostics::measure(
74,
29,
24,
Some(1370),
);
assert_eq!(cov.status, SourceCoverageStatus::Measured);
assert!((cov.decoded_element_fraction.unwrap() - (74.0 / 1370.0)).abs() < 1e-6);
assert!((cov.exported_element_fraction.unwrap() - (29.0 / 74.0)).abs() < 1e-6);
assert!((cov.geometry_element_fraction.unwrap() - (24.0 / 29.0)).abs() < 1e-6);
assert!(
cov.notes
.as_deref()
.unwrap_or("")
.contains("Measured from export diagnostics")
);
}
#[test]
fn source_coverage_fail_closed_when_walker_exceeds_elem_table_header() {
let cov = SourceCoverageDiagnostics::measure(3353, 82, 0, Some(1411));
assert_eq!(cov.status, SourceCoverageStatus::Measured);
assert_eq!(cov.decoded_element_fraction, None);
assert!((cov.exported_element_fraction.unwrap() - (82.0 / 3353.0)).abs() < 1e-6);
assert!((cov.geometry_element_fraction.unwrap() - 0.0).abs() < 1e-6);
assert!(
cov.notes
.as_deref()
.unwrap_or("")
.contains("decoded_element_fraction left null")
);
}
#[test]
fn source_coverage_unset_when_no_denominators() {
let cov = SourceCoverageDiagnostics::measure(0, 0, 0, None);
assert_eq!(cov.status, SourceCoverageStatus::Unset);
assert_eq!(cov.decoded_element_fraction, None);
assert_eq!(cov.exported_element_fraction, None);
assert_eq!(cov.geometry_element_fraction, None);
}
#[test]
fn source_coverage_zero_decoded_when_table_present_but_walker_empty() {
let cov = SourceCoverageDiagnostics::measure(0, 0, 0, Some(100));
assert_eq!(cov.status, SourceCoverageStatus::Measured);
assert_eq!(cov.decoded_element_fraction, Some(0.0));
assert_eq!(cov.exported_element_fraction, None);
assert_eq!(cov.geometry_element_fraction, None);
}
fn walls_with_geometry(count: usize) -> ExportedModelDiagnostics {
ExportedModelDiagnostics {
total_entities: count,
building_elements: count,
building_elements_with_geometry: count,
building_elements_carried_by_parts: 0,
by_ifc_type: [("IFCWALL".to_string(), count)].into_iter().collect(),
classification_count: 0,
unit_assignment_count: 1,
material_count: 0,
storey_count: 0,
storey_names: Vec::new(),
storey_elevations_feet: Vec::new(),
storey_bound_elements: 0,
material_names_sample: Vec::new(),
layered_element_count: 0,
body_sources: Default::default(),
bounding_box_bodies: 0,
}
}
#[test]
fn unexported_element_records_scale_the_element_terms_of_the_score() {
let exported = walls_with_geometry(20);
let complete =
export_confidence_summary(ExportDiagnosticsMode::Default, &exported, true, 0, 0, 0);
assert_eq!(complete.level, "geometry");
assert!((complete.score - 1.0).abs() < 1e-6);
assert_eq!(complete.unexported_element_records, 0);
let partial =
export_confidence_summary(ExportDiagnosticsMode::Default, &exported, true, 0, 1, 18);
assert_eq!(partial.level, "geometry");
assert_eq!(partial.unexported_element_records, 18);
let expected = 0.35 + 0.65 * 20.0 / 38.0;
assert!((partial.score - expected).abs() < 1e-6, "{}", partial.score);
}
#[test]
fn metadata_terms_survive_when_every_element_record_is_unexported() {
let exported = walls_with_geometry(0);
let summary =
export_confidence_summary(ExportDiagnosticsMode::Default, &exported, true, 0, 1, 50);
assert_eq!(summary.level, "scaffold");
assert!((summary.score - 0.35).abs() < 1e-6, "{}", summary.score);
}
}