use rvt::RevitFile;
use rvt::ifc::{RvtDocExporter, write_step};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn split_args(args: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let (mut quoted, mut depth) = (false, 0usize);
for c in args.chars() {
match c {
'\'' => {
quoted = !quoted;
current.push(c);
}
'(' if !quoted => {
depth += 1;
current.push(c);
}
')' if !quoted => {
depth = depth.saturating_sub(1);
current.push(c);
}
',' if !quoted && depth == 0 => out.push(std::mem::take(&mut current)),
_ => current.push(c),
}
}
out.push(current);
out
}
fn decode_step_string(raw: &str) -> String {
let mut out = String::new();
let mut rest = raw;
while let Some(at) = rest.find('\\') {
out.push_str(&rest[..at]);
rest = &rest[at..];
if let Some(tail) = rest.strip_prefix("\\X2\\") {
let end = tail.find("\\X0\\").unwrap_or(tail.len());
let units: Vec<u16> = tail.as_bytes()[..end]
.chunks(4)
.filter_map(|c| u16::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
.collect();
out.push_str(&String::from_utf16_lossy(&units));
rest = tail.get(end + 4..).unwrap_or("");
} else if let Some(tail) = rest.strip_prefix("\\X\\") {
let byte = tail.get(..2).and_then(|h| u8::from_str_radix(h, 16).ok());
out.push(byte.map_or('?', char::from));
rest = tail.get(2..).unwrap_or("");
} else {
out.push('\\');
rest = &rest[1..];
}
}
out.push_str(rest);
out.replace("''", "'")
}
fn names_by_tag(step: &str) -> BTreeMap<u64, (String, String)> {
let mut out = BTreeMap::new();
for line in step.lines() {
let Some((_, body)) = line.split_once('=') else {
continue;
};
let Some((entity, args)) = body.split_once('(') else {
continue;
};
let entity = entity.trim();
if entity.ends_with("TYPE") || entity == "IFCOPENINGELEMENT" || entity == "IFCOWNERHISTORY"
{
continue;
}
let args = args.trim_end().trim_end_matches(';');
let args = args.strip_suffix(')').unwrap_or(args);
let fields = split_args(args);
let (Some(name), Some(object_type), Some(tag)) =
(fields.get(2), fields.get(4), fields.get(7))
else {
continue;
};
let Ok(tag) = tag.trim_matches('\'').parse() else {
continue;
};
let unquote = |raw: &str| {
decode_step_string(
raw.strip_prefix('\'')
.and_then(|n| n.strip_suffix('\''))
.unwrap_or(raw),
)
};
out.insert(tag, (unquote(name), unquote(object_type)));
}
out
}
fn check(rvt: &Path, reference: &Path) -> usize {
let mut rf = RevitFile::open(rvt).expect("open");
let result = RvtDocExporter
.export_with_diagnostics(&mut rf)
.expect("export");
let ours = names_by_tag(&write_step(&result.model));
let theirs = names_by_tag(&std::fs::read_to_string(reference).expect("reference IFC"));
let mut named = 0;
for (tag, (name, object_type)) in &ours {
if !(name.ends_with(&format!(":{tag}")) && name.matches(':').count() >= 2) {
continue;
}
named += 1;
assert_eq!(
Some(object_type.as_str()),
name.strip_suffix(&format!(":{tag}")),
"ObjectType of {tag} in {}",
rvt.display()
);
if let Some(reference) = theirs.get(tag) {
assert_eq!(
(name, object_type),
(&reference.0, &reference.1),
"name of {tag} in {}",
rvt.display()
);
}
}
named
}
fn corpus() -> Option<PathBuf> {
std::env::var_os("RVT_PROJECT_CORPUS_DIR").map(PathBuf::from)
}
#[test]
fn core_interior_names_are_revits() {
let Some(dir) = corpus() else {
eprintln!("skipping: RVT_PROJECT_CORPUS_DIR is not set");
return;
};
let rvt = dir.join("2024_Core_Interior.rvt");
let reference = dir.join("../IFC Exports/2024_Core_Interior_slim.ifc");
if !rvt.exists() || !reference.exists() {
eprintln!("skipping: no Core Interior model and reference export");
return;
}
assert_eq!(check(&rvt, &reference), 854);
}
#[test]
fn re1_names_are_revits() {
let Some(dir) = corpus() else {
eprintln!("skipping: RVT_PROJECT_CORPUS_DIR is not set");
return;
};
let mut checked = 0;
for (model, expected) in [
("Architecture", 72),
("Mechanical", 43),
("Plumbing", 61),
("Electrical", 46),
] {
let rvt = dir.join(format!("RE1-{model}.rvt"));
let reference = dir.join(format!("RE1-{model}.ifc"));
if !rvt.exists() || !reference.exists() {
continue;
}
assert_eq!(check(&rvt, &reference), expected, "RE1 {model}");
checked += 1;
}
if checked == 0 {
eprintln!("skipping: no RE1 model under RVT_PROJECT_CORPUS_DIR");
}
}
fn check_system_type_names(rvt: &Path, reference: &Path, release: u32) -> Vec<(String, usize)> {
let theirs = names_by_tag(&std::fs::read_to_string(reference).expect("reference IFC"));
let mut rf = RevitFile::open(rvt).expect("open");
let mvp = rvt::partition_schema_mvp::recover_partition_schema_mvp(
&mut rf,
release,
rvt::walker::WalkerLimits::default(),
)
.expect("partition MVP");
let field = |element: &rvt::walker::DecodedElement, wanted: &str| {
element.fields.iter().find_map(|(name, value)| match value {
rvt::walker::InstanceField::String(v) if name == wanted => Some(v.clone()),
_ => None,
})
};
let mut checked = BTreeMap::new();
for element in mvp
.walls
.iter()
.chain(mvp.slabs.iter())
.chain(mvp.products.iter())
{
let family = field(element, rvt::partition_schema_mvp::FAMILY_NAME_FIELD);
let derived = field(element, rvt::partition_schema_mvp::FAMILY_NAME_SOURCE_FIELD)
.is_some_and(|source| source == rvt::partition_schema_mvp::SYSTEM_FAMILY_SOURCE);
if family.is_some() && !derived {
continue;
}
let Some(ours) = field(element, rvt::partition_schema_mvp::TYPE_NAME_FIELD) else {
continue;
};
let id = element.id.expect("record id");
let (_, object_type) = theirs.get(&u64::from(id)).expect("Revit exports it");
let want = object_type
.split_once(':')
.map(|(_, t)| t)
.expect("Family:Type");
assert_eq!(ours, want, "{} {id}", element.class);
if let Some(family) = family {
assert_eq!(
&format!("{family}:{ours}"),
object_type,
"{} {id}",
element.class
);
}
*checked.entry(element.class.clone()).or_insert(0) += 1;
}
checked.into_iter().collect()
}
#[test]
fn core_interior_system_type_names_are_revits() {
let Some(dir) = corpus() else {
eprintln!("skipping: RVT_PROJECT_CORPUS_DIR is not set");
return;
};
let rvt = dir.join("2024_Core_Interior.rvt");
let reference = dir.join("../IFC Exports/2024_Core_Interior_slim.ifc");
if !rvt.exists() || !reference.exists() {
eprintln!("skipping: no Core Interior model and reference export");
return;
}
assert_eq!(
check_system_type_names(&rvt, &reference, 2024),
[
("BuildingPad".to_string(), 1),
("Floor".to_string(), 99),
("Wall".to_string(), 360),
]
);
}
#[test]
fn re1_system_type_names_are_revits() {
let Some(dir) = corpus() else {
eprintln!("skipping: RVT_PROJECT_CORPUS_DIR is not set");
return;
};
let rvt = dir.join("RE1-Architecture.rvt");
let reference = dir.join("RE1-Architecture.ifc");
if !rvt.exists() || !reference.exists() {
eprintln!("skipping: no RE1 Architecture model and reference export");
return;
}
assert_eq!(
check_system_type_names(&rvt, &reference, 2025),
[
("Ceiling".to_string(), 6),
("CurtainWall".to_string(), 1),
("Floor".to_string(), 2),
("Wall".to_string(), 7),
]
);
}
#[test]
fn step_string_escapes_decode() {
assert_eq!(decode_step_string("M_Folha \\X\\FAnica"), "M_Folha única");
assert_eq!(
decode_step_string("M_Folha \\X2\\00FA\\X0\\nica"),
"M_Folha única"
);
assert_eq!(decode_step_string("36'' x 84''"), "36' x 84'");
}