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