use crate::types::response::{QuickMetadataEntitySummary, QuickMetadataSpatialNode};
use ifc_lite_core::{IfcType, IFC_TYPES};
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
#[derive(Clone)]
pub(super) struct QuickSpatialNodeEntry {
pub(super) express_id: u32,
pub(super) type_name: String,
pub(super) name: String,
pub(super) elevation: Option<f64>,
pub(super) children: Vec<u32>,
pub(super) elements: Vec<u32>,
pub(super) parent: Option<u32>,
}
fn is_quick_spatial_type(ifc_type: IfcType) -> bool {
ifc_type == IfcType::IfcProject
|| (ifc_type.is_subtype_of(IfcType::IfcSpatialElement)
&& !ifc_type.is_subtype_of(IfcType::IfcExternalSpatialStructureElement))
}
static QUICK_SPATIAL_TYPE_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
IFC_TYPES
.iter()
.filter(|ifc_type| is_quick_spatial_type(**ifc_type))
.map(|ifc_type| ifc_type.as_str())
.collect()
});
#[inline]
pub fn is_quick_spatial_type_ci(type_name: &str) -> bool {
QUICK_SPATIAL_TYPE_NAMES
.iter()
.any(|candidate| type_name.eq_ignore_ascii_case(candidate))
}
pub(super) fn parse_step_arguments(entity_bytes: &[u8]) -> Vec<&[u8]> {
let Some(open_idx) = entity_bytes.iter().position(|byte| *byte == b'(') else {
return Vec::new();
};
let Some(close_idx) = entity_bytes.iter().rposition(|byte| *byte == b')') else {
return Vec::new();
};
if close_idx <= open_idx {
return Vec::new();
}
let args = &entity_bytes[open_idx + 1..close_idx];
let mut parts = Vec::new();
let mut in_string = false;
let mut depth = 0i32;
let mut start = 0usize;
let bytes = args;
let mut index = 0usize;
while index < bytes.len() {
match bytes[index] {
b'\'' => {
if in_string && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
index += 1;
} else {
in_string = !in_string;
}
}
b'(' if !in_string => depth += 1,
b')' if !in_string => depth -= 1,
b',' if !in_string && depth == 0 => {
parts.push(args[start..index].trim_ascii());
start = index + 1;
}
_ => {}
}
index += 1;
}
if start <= args.len() {
parts.push(args[start..].trim_ascii());
}
parts
}
fn parse_step_string(token: &[u8]) -> Option<String> {
let trimmed = token.trim_ascii();
if trimmed.len() < 2 || trimmed[0] != b'\'' || trimmed[trimmed.len() - 1] != b'\'' {
return None;
}
let unescaped = String::from_utf8_lossy(&trimmed[1..trimmed.len() - 1]).replace("''", "'");
Some(ifc_lite_core::decode_ifc_string(&unescaped).into_owned())
}
pub(super) fn parse_step_ref(token: &[u8]) -> Option<u32> {
std::str::from_utf8(token.trim_ascii().strip_prefix(b"#")?)
.ok()?
.parse()
.ok()
}
pub(super) fn parse_step_ref_list(token: &[u8]) -> Vec<u32> {
let trimmed = token.trim_ascii();
let inner = trimmed
.strip_prefix(b"(")
.and_then(|value| value.strip_suffix(b")"))
.unwrap_or(trimmed);
inner.split(|byte| *byte == b',').filter_map(parse_step_ref).collect()
}
pub(super) fn extract_name_from_args(args: &[&[u8]], fallback: &str) -> String {
args.get(2)
.and_then(|token| parse_step_string(token))
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| fallback.to_string())
}
pub(super) fn extract_storey_elevation_from_args(args: &[&[u8]]) -> Option<f64> {
for index in [9usize, 8usize] {
if let Some(value) = args
.get(index)
.and_then(|token| std::str::from_utf8(token.trim_ascii()).ok())
.and_then(|token| token.parse::<f64>().ok())
{
return Some(value);
}
}
args.iter()
.filter_map(|token| std::str::from_utf8(token.trim_ascii()).ok())
.filter_map(|token| token.parse::<f64>().ok())
.find(|value| value.abs() < 10_000.0)
}
pub(super) fn build_quick_spatial_tree_node(
express_id: u32,
nodes: &HashMap<u32, QuickSpatialNodeEntry>,
element_summaries: &HashMap<u32, QuickMetadataEntitySummary>,
) -> Result<QuickMetadataSpatialNode, String> {
let mut ancestors = HashSet::new();
build_quick_spatial_tree_node_inner(express_id, nodes, element_summaries, &mut ancestors)
}
fn build_quick_spatial_tree_node_inner(
express_id: u32,
nodes: &HashMap<u32, QuickSpatialNodeEntry>,
element_summaries: &HashMap<u32, QuickMetadataEntitySummary>,
ancestors: &mut HashSet<u32>,
) -> Result<QuickMetadataSpatialNode, String> {
let node = nodes
.get(&express_id)
.ok_or_else(|| format!("Quick spatial node #{express_id} not found"))?;
ancestors.insert(express_id);
let mut children = Vec::with_capacity(node.children.len());
for child_id in &node.children {
if ancestors.contains(child_id) {
continue;
}
children.push(build_quick_spatial_tree_node_inner(
*child_id,
nodes,
element_summaries,
ancestors,
)?);
}
ancestors.remove(&express_id);
let elements = node
.elements
.iter()
.map(|element_id| {
element_summaries
.get(element_id)
.cloned()
.unwrap_or(QuickMetadataEntitySummary {
express_id: *element_id,
type_name: "IfcProduct".to_string(),
name: format!("IfcProduct #{}", element_id),
global_id: None,
kind: "element".to_string(),
has_children: false,
element_count: None,
elevation: None,
})
})
.collect();
Ok(QuickMetadataSpatialNode {
summary: QuickMetadataEntitySummary {
express_id: node.express_id,
type_name: node.type_name.clone(),
name: node.name.clone(),
global_id: None,
kind: "spatial".to_string(),
has_children: !node.children.is_empty() || !node.elements.is_empty(),
element_count: Some(node.elements.len()),
elevation: node.elevation,
},
children,
elements,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn node(id: u32, children: Vec<u32>) -> QuickSpatialNodeEntry {
QuickSpatialNodeEntry {
express_id: id,
type_name: "IfcSpace".to_string(),
name: format!("#{id}"),
elevation: None,
children,
elements: vec![],
parent: None,
}
}
#[test]
fn parse_step_string_un_doubles_exactly_once() {
assert_eq!(parse_step_string(b"'O''Brien'").as_deref(), Some("O'Brien"));
assert_eq!(parse_step_string(b"''''''").as_deref(), Some("''"));
assert_eq!(parse_step_string(br"'C:\\temp'").as_deref(), Some(r"C:\temp"));
assert_eq!(parse_step_string(br"'caf\X2\00E9\X0\'").as_deref(), Some("caf\u{e9}"));
assert_eq!(parse_step_string(b"'Plain Name'").as_deref(), Some("Plain Name"));
}
#[test]
fn cyclic_aggregate_graph_does_not_stack_overflow() {
let mut nodes = HashMap::new();
nodes.insert(1, node(1, vec![2]));
nodes.insert(2, node(2, vec![1]));
let summaries = HashMap::new();
let tree = build_quick_spatial_tree_node(1, &nodes, &summaries);
assert!(tree.is_ok(), "cyclic tree should build (cycle pruned), got {tree:?}");
}
#[test]
fn storey_elevation_prefers_index_9_over_index_8() {
let args: Vec<&[u8]> = vec![
b"$", b"$", b"$", b"$", b"$", b"$", b"$", b"$", b"3.5", b"7.25",
];
assert_eq!(
extract_storey_elevation_from_args(&args),
Some(7.25),
"index 9 (the real Elevation attribute) must win over index 8"
);
}
#[test]
fn quick_spatial_predicate_matches_the_generated_spatial_branch() {
use ifc_lite_core::{IfcType, IFC_TYPES};
fn rule(ty: IfcType) -> bool {
ty == IfcType::IfcProject
|| (ty.is_subtype_of(IfcType::IfcSpatialElement)
&& !ty.is_subtype_of(IfcType::IfcExternalSpatialStructureElement))
}
let mut expected_true = 0usize;
let mut missing = Vec::new();
let mut extra = Vec::new();
for ty in IFC_TYPES {
let name = ty.as_str();
let want = rule(*ty);
if want {
expected_true += 1;
}
let got = is_quick_spatial_type_ci(name);
if want && !got {
missing.push(name);
}
if !want && got {
extra.push(name);
}
}
assert!(
IFC_TYPES.len() > 800,
"generated IFC_TYPES looks truncated: {} entries",
IFC_TYPES.len()
);
assert!(
expected_true >= 17,
"the spatial branch should cover at least 17 types, got {expected_true}"
);
assert!(
missing.is_empty() && extra.is_empty(),
"quick-metadata spatial predicate has drifted from the generated schema\n \
missing (severed from the spatial tree): {missing:?}\n \
extra (invented spatial nodes): {extra:?}"
);
}
#[test]
fn quick_spatial_predicate_controls() {
for name in ["IFCWALL", "IFCRELAGGREGATES", "IFCPROJECTLIBRARY", "IFCZONE"] {
assert!(!is_quick_spatial_type_ci(name), "{name} must not be a spatial node");
}
for name in ["IFCEXTERNALSPATIALELEMENT", "IFCEXTERNALSPATIALSTRUCTUREELEMENT"] {
assert!(!is_quick_spatial_type_ci(name), "{name} must not be a spatial node");
}
for name in ["IfcMarineFacility", "IFCMARINEFACILITY", "ifcmarinefacility"] {
assert!(is_quick_spatial_type_ci(name), "{name} must be a spatial node");
}
}
}