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(¤t) 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}