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