Skip to main content

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(&current) 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}