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