ifc_spatial/tree/build.rs
1//! Assembling the containment tree from relationship entities.
2//!
3//! # Tolerating real files
4//!
5//! The canonical hierarchy is project → site → building → storey → element, and
6//! plenty of real exports do not follow it: sites are omitted, elements hang
7//! directly off the building, storeys are duplicated, and occasionally a
8//! relationship points at an entity that is not in the file. The tree records
9//! what the file says rather than asserting the ideal shape, and reports the
10//! anomalies separately so a caller can decide whether to care.
11
12use std::collections::BTreeMap;
13
14use ifc_model::{EntityId, Model};
15
16use super::anomaly::SpatialAnomaly;
17use super::kind::{Classifier, SpatialKind};
18use crate::relation::{Relationship, RelationshipKind};
19use ifc_schema::SchemaVersion;
20
21/// One entity's place in the containment tree.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SpatialNode {
24 /// The entity this node describes.
25 pub id: EntityId,
26 /// Its spatial role.
27 pub kind: SpatialKind,
28 /// Its container, if any relationship names one.
29 pub parent: Option<EntityId>,
30 /// Sub-containers, in file order.
31 pub children: Vec<EntityId>,
32 /// Physical elements placed directly in this container, in file order.
33 pub elements: Vec<EntityId>,
34}
35
36/// The containment tree of one model.
37#[derive(Debug, Clone, Default)]
38pub struct SpatialTree {
39 nodes: BTreeMap<EntityId, SpatialNode>,
40 /// Element to its direct container, so `container_of` is a lookup.
41 container_of_element: BTreeMap<EntityId, EntityId>,
42 /// Container to the elements `IfcRelReferencedInSpatialStructure`
43 /// references there, and the inverse. Kept apart from containment.
44 pub(super) referenced: BTreeMap<EntityId, Vec<EntityId>>,
45 pub(super) referenced_in: BTreeMap<EntityId, Vec<EntityId>>,
46 roots: Vec<EntityId>,
47 orphans: Vec<EntityId>,
48 pub(super) dangling: Vec<(EntityId, EntityId)>,
49 pub(super) anomalies: Vec<SpatialAnomaly>,
50 /// The release containers were classified against, if one was bound.
51 release: Option<SchemaVersion>,
52}
53
54impl SpatialTree {
55 /// Build the containment tree of `model`.
56 ///
57 /// One pass over the aggregation, containment and spatial reference
58 /// relationships. Cost is linear in the number of relationship entities,
59 /// not in model size, because relationships are found through the type
60 /// index.
61 ///
62 /// Containers are classified against the release the file's
63 /// `FILE_SCHEMA` declares (see [`release`](Self::release)): an entity is
64 /// a container when that release declares it an `IfcSpatialElement`
65 /// (IFC2X3: `IfcSpatialStructureElement`), or it is the `IfcProject`.
66 #[must_use]
67 pub fn build(model: &Model) -> Self {
68 let classifier = Classifier::for_model(model);
69 let mut tree = Self {
70 release: classifier.bound_release(),
71 ..Self::default()
72 };
73 // One schema walk per distinct type name, not per entity.
74 let mut kinds: BTreeMap<&str, SpatialKind> = BTreeMap::new();
75
76 // Ensure every spatial entity has a node, even one no relationship
77 // mentions -- a lone IfcBuildingStorey is still part of the file.
78 for id in model.ids() {
79 let Some(entity) = model.get(id) else {
80 continue;
81 };
82 let kind = *kinds
83 .entry(&entity.type_name)
84 .or_insert_with(|| classifier.classify(&entity.type_name));
85 if kind.is_container() {
86 tree.nodes.insert(
87 id,
88 SpatialNode {
89 id,
90 kind,
91 parent: None,
92 children: Vec::new(),
93 elements: Vec::new(),
94 },
95 );
96 }
97 }
98
99 for relationship in crate::relation::all(model) {
100 tree.apply(model, &relationship);
101 }
102 tree.apply_references(model);
103
104 // A container with no parent is a root. Sorted for determinism, then
105 // ordered so the project (if any) leads.
106 tree.roots = tree
107 .nodes
108 .values()
109 .filter(|node| node.parent.is_none())
110 .map(|node| node.id)
111 .collect();
112 tree.roots.sort_by_key(|id| {
113 let kind = tree.nodes[id].kind;
114 (kind, *id)
115 });
116
117 // Every root beyond the most-general one is a detached branch: the file
118 // declares a container that nothing aggregates. Reported rather than
119 // hidden, because a viewer would otherwise silently lose it.
120 if tree.roots.len() > 1 {
121 tree.orphans = tree.roots[1..].to_vec();
122 }
123 tree
124 }
125
126 /// Record one relationship's effect on the tree.
127 fn apply(&mut self, model: &Model, relationship: &Relationship) {
128 let Some(parent) = relationship.relating else {
129 return;
130 };
131 // A relationship naming an entity the file does not contain is a
132 // defect worth reporting, not a reason to abandon the tree.
133 if model.get(parent).is_none() {
134 self.dangling.push((relationship.id, parent));
135 return;
136 }
137 if !self.nodes.contains_key(&parent) {
138 // Containment into something the release does not declare a
139 // spatial element is malformed: reported, not dropped. An
140 // aggregation whose whole is not a container is an element
141 // aggregating its parts: valid IFC, but not spatial structure.
142 if relationship.kind == RelationshipKind::ContainedIn {
143 self.anomalies
144 .push(SpatialAnomaly::ContainedInNonContainer {
145 relation: relationship.id,
146 structure: parent,
147 });
148 }
149 return;
150 }
151 // Only aggregation and containment build the tree. Other families
152 // with a container at the relating end (IfcRelDeclares from the
153 // project, IfcRelCoversSpaces from a space, ...) place nothing; their
154 // absent targets are still reported below.
155 let places = matches!(
156 relationship.kind,
157 RelationshipKind::Aggregates | RelationshipKind::ContainedIn
158 );
159
160 for &child in &relationship.related {
161 if model.get(child).is_none() {
162 self.dangling.push((relationship.id, child));
163 continue;
164 }
165 if !places {
166 continue;
167 }
168
169 if self.nodes.contains_key(&child) {
170 // Containment relationships name elements, not containers;
171 // a container arriving here means an aggregation edge.
172 if relationship.kind == RelationshipKind::ContainedIn {
173 continue;
174 }
175 let Some(node) = self.nodes.get_mut(&child) else {
176 continue;
177 };
178 // A second parent is a malformed file. Keep the first so the
179 // tree stays a tree, and report the one rejected.
180 match node.parent {
181 None => {
182 node.parent = Some(parent);
183 if let Some(parent_node) = self.nodes.get_mut(&parent) {
184 parent_node.children.push(child);
185 }
186 }
187 Some(kept) if kept != parent => {
188 self.anomalies.push(SpatialAnomaly::AggregatedTwice {
189 child,
190 kept,
191 rejected: parent,
192 relation: relationship.id,
193 });
194 }
195 Some(_) => {}
196 }
197 } else {
198 // First container wins, and only the winner lists the element,
199 // so `elements_of` and `container_of` cannot disagree.
200 match self.container_of_element.get(&child) {
201 None => {
202 self.container_of_element.insert(child, parent);
203 if let Some(parent_node) = self.nodes.get_mut(&parent) {
204 parent_node.elements.push(child);
205 }
206 }
207 Some(&kept) if kept != parent => {
208 self.anomalies.push(SpatialAnomaly::ContainedTwice {
209 element: child,
210 kept,
211 rejected: parent,
212 relation: relationship.id,
213 });
214 }
215 Some(_) => {}
216 }
217 }
218 }
219 }
220}
221
222impl SpatialTree {
223 /// The release the containers were classified against: the one the
224 /// file's `FILE_SCHEMA` names, when it names exactly one bundled release
225 /// (IFC2X3, IFC4, IFC4X3).
226 ///
227 /// `None` when the header names none, several, or another release: an
228 /// entity is then a container when any bundled release declares it a
229 /// spatial element, as [`SpatialKind::classify`] answers.
230 #[must_use]
231 pub fn release(&self) -> Option<SchemaVersion> {
232 self.release
233 }
234
235 /// The node describing `id`, if it is a spatial container.
236 #[must_use]
237 pub fn node(&self, id: EntityId) -> Option<&SpatialNode> {
238 self.nodes.get(&id)
239 }
240
241 /// Containers with no parent, most-general kind first.
242 ///
243 /// A conformant file yields exactly one root, the `IfcProject`. More than
244 /// one means the file omits an aggregation relationship somewhere.
245 #[must_use]
246 pub fn roots(&self) -> &[EntityId] {
247 &self.roots
248 }
249
250 /// Every spatial container in the model, ordered by entity id.
251 pub fn containers(&self) -> impl Iterator<Item = &SpatialNode> {
252 self.nodes.values()
253 }
254
255 /// Containers of a given kind, ordered by entity id.
256 pub fn of_kind(&self, kind: SpatialKind) -> impl Iterator<Item = &SpatialNode> + '_ {
257 self.nodes.values().filter(move |node| node.kind == kind)
258 }
259
260 /// Elements placed directly in `container`.
261 ///
262 /// Direct only: an element in a space inside a storey is not returned for
263 /// the storey. Use [`elements_recursive`](Self::elements_recursive) for the
264 /// transitive set.
265 #[must_use]
266 pub fn elements_of(&self, container: EntityId) -> &[EntityId] {
267 self.nodes
268 .get(&container)
269 .map_or(&[], |node| node.elements.as_slice())
270 }
271
272 /// Every element in `container` or any container beneath it.
273 ///
274 /// Breadth-first, so a container's own elements precede those of its
275 /// children. Bounded by the tree's depth, which is finite because each node
276 /// holds at most one parent.
277 #[must_use]
278 pub fn elements_recursive(&self, container: EntityId) -> Vec<EntityId> {
279 let mut out = Vec::new();
280 let mut queue = std::collections::VecDeque::from([container]);
281 let mut seen = std::collections::BTreeSet::new();
282 while let Some(current) = queue.pop_front() {
283 if !seen.insert(current) {
284 continue;
285 }
286 let Some(node) = self.nodes.get(¤t) else {
287 continue;
288 };
289 out.extend_from_slice(&node.elements);
290 queue.extend(node.children.iter().copied());
291 }
292 out
293 }
294
295 /// The chain of containers above `id`, nearest first.
296 ///
297 /// Empty for a root. Terminates even if the file contains a containment
298 /// cycle, because the walk stops at the first repeated entity.
299 #[must_use]
300 pub fn ancestors(&self, id: EntityId) -> Vec<EntityId> {
301 let mut out = Vec::new();
302 let mut seen = std::collections::BTreeSet::new();
303 let mut current = self.nodes.get(&id).and_then(|node| node.parent);
304 while let Some(parent) = current {
305 if !seen.insert(parent) {
306 break;
307 }
308 out.push(parent);
309 current = self.nodes.get(&parent).and_then(|node| node.parent);
310 }
311 out
312 }
313
314 /// The container holding `element`, if any relationship places it.
315 ///
316 /// Answers the question a drawing or take-off actually asks: which storey
317 /// is this wall on? Backed by a map built during assembly, so this is a
318 /// lookup rather than a scan over every container.
319 #[must_use]
320 pub fn container_of(&self, element: EntityId) -> Option<EntityId> {
321 self.container_of_element.get(&element).copied()
322 }
323
324 /// Relationship/target pairs naming an entity the model does not contain.
325 #[must_use]
326 pub fn dangling(&self) -> &[(EntityId, EntityId)] {
327 &self.dangling
328 }
329
330 /// Second parents the file states and the tree rejected, in the order
331 /// relationships were applied.
332 ///
333 /// Empty for a conformant file. Each entry names the relationship whose
334 /// claim was dropped, so a caller can point at the offending record.
335 #[must_use]
336 pub fn anomalies(&self) -> &[SpatialAnomaly] {
337 &self.anomalies
338 }
339
340 /// Containers that no relationship places under a parent, excluding the
341 /// most-general root.
342 ///
343 /// A conformant file has none: every site is aggregated into the project,
344 /// every building into a site. Entries here mean the file omits an
345 /// aggregation relationship, which a viewer would show as a detached
346 /// branch.
347 #[must_use]
348 pub fn orphans(&self) -> &[EntityId] {
349 &self.orphans
350 }
351}