ifc_lite_server/services/data_model/spatial.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Spatial hierarchy extraction.
6//!
7//! Split across three files (#3965 follow-up, to stay under the module-size
8//! ratchet): this file builds the relationship maps (`canonical_parent`,
9//! `spatial_children_map`, `element_containment_map`) and drives the
10//! orphan-fill pass; `spatial_tree.rs` walks those maps into `SpatialNode`s
11//! with the cycle/depth guards; `spatial_elevation.rs` reads
12//! `IfcBuildingStorey.Elevation`. `spatial_tests.rs` stays attached to this
13//! file and exercises functions from all three via `use super::*` plus the
14//! re-exports below.
15
16#[path = "spatial_canonical_parent.rs"]
17mod spatial_canonical_parent;
18#[path = "spatial_elevation.rs"]
19mod spatial_elevation;
20#[path = "spatial_invariant.rs"]
21mod spatial_invariant;
22#[path = "spatial_lookups.rs"]
23mod spatial_lookups;
24#[path = "spatial_tree.rs"]
25mod spatial_tree;
26
27use self::spatial_canonical_parent::resolve_aggregate_canonical_parents;
28use self::spatial_elevation::extract_elevation_if_storey;
29use self::spatial_lookups::build_element_lookups;
30pub(super) use self::spatial_invariant::spatial_hierarchy_consistency_violations;
31#[cfg(test)]
32pub(super) use self::spatial_canonical_parent::{
33 resolve_aggregate_canonical_parents as resolve_aggregate_canonical_parents_for_tests,
34 resolve_aggregate_canonical_parents_with_budget,
35};
36use self::spatial_tree::build_spatial_nodes_recursive;
37use super::types::{EntityMetadata, Relationship, SpatialHierarchyData, SpatialNode};
38use ifc_lite_core::EntityDecoder;
39use rustc_hash::{FxHashMap, FxHashSet};
40use std::sync::Arc;
41
42/// Build spatial hierarchy from relationships.
43pub(super) fn build_spatial_hierarchy(
44 relationships: &[Relationship],
45 entities: &[EntityMetadata],
46 content: &[u8],
47 entity_index: &Arc<ifc_lite_core::EntityIndex>,
48 length_unit_scale: f64,
49) -> SpatialHierarchyData {
50 let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
51
52 // Build entity map for quick lookup
53 let entity_map: FxHashMap<u32, &EntityMetadata> =
54 entities.iter().map(|e| (e.entity_id, e)).collect();
55
56 // Types treated as spatial-structure nodes, mirroring
57 // packages/data/src/spatial-types.ts's SPATIAL_STRUCTURE_TYPE_ENUMS. IFCSPATIALZONE,
58 // IFCMARINEPART and IFCFACILITYPARTCOMMON were missing here (#3965): the TS side has
59 // carried them since #1075 (IfcSpatialZone) and #3248/#3249 (the IFC4X3 pair), so an
60 // aggregated instance of any of the three already built a node via
61 // build_spatial_nodes_recursive's blind children_ids walk - only the orphan-fill loop
62 // below and the containment promotion added by #3965 depend on this list.
63 let is_spatial_type = |type_name: &str| {
64 matches!(
65 type_name.to_uppercase().as_str(),
66 "IFCPROJECT"
67 | "IFCSITE"
68 | "IFCBUILDING"
69 | "IFCBUILDINGSTOREY"
70 | "IFCSPACE"
71 | "IFCSPATIALZONE"
72 | "IFCFACILITY"
73 | "IFCFACILITYPART"
74 | "IFCFACILITYPARTCOMMON"
75 | "IFCBRIDGE"
76 | "IFCBRIDGEPART"
77 | "IFCROAD"
78 | "IFCROADPART"
79 | "IFCRAILWAY"
80 | "IFCRAILWAYPART"
81 | "IFCMARINEFACILITY"
82 | "IFCMARINEPART"
83 )
84 };
85 let is_building_like_spatial_type = |type_name: &str| {
86 matches!(
87 type_name.to_uppercase().as_str(),
88 "IFCBUILDING"
89 | "IFCFACILITY"
90 | "IFCBRIDGE"
91 | "IFCROAD"
92 | "IFCRAILWAY"
93 | "IFCMARINEFACILITY"
94 )
95 };
96 // Space-like: bucket into element_to_space, mirroring
97 // packages/data/src/spatial-types.ts's isSpaceLikeSpatialType (IfcSpace and
98 // IfcSpatialZone roll their contained elements up the same way there).
99 let is_space_like_spatial_type =
100 |type_upper: &str| matches!(type_upper, "IFCSPACE" | "IFCSPATIALZONE");
101
102 // Separate spatial relationships from element containment
103 // IFCRELAGGREGATES: spatial parent -> spatial child (Project -> Site -> Building -> Storey)
104 // IFCRELCONTAINEDINSPATIALSTRUCTURE: spatial container -> elements (Storey -> Wall, Door,
105 // etc.), EXCEPT when the target is itself a spatial-structure type: an IfcSpace or
106 // IfcSpatialZone placed under its storey via containment instead of aggregation (the
107 // common Revit Family / Dynamo export pattern, #1075) is a tree NODE, not a leaf
108 // element, so it is promoted into spatial_children_map instead - mirroring
109 // packages/parser/src/spatial-hierarchy-builder.ts's addSpatialChild, which merges
110 // aggregated and contained spatial children into one deduped set (#3965).
111 let mut spatial_children_map: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
112 let mut element_containment_map: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
113
114 // Each spatial child gets exactly ONE canonical parent. Without this, a
115 // space aggregated under one storey AND contained under a different one
116 // (a malformed but real cross-linked authoring-tool export, #3973) would
117 // end up in both parents' children_ids, while build_spatial_nodes_recursive
118 // only ever builds one SpatialNode for it (a single-visit walk keyed by
119 // entity id) - so one parent's children_ids referenced a node that both
120 // belonged to another parent AND carried that other parent's parent_id, and
121 // which parent "won" depended on relationship-list/HashMap iteration order,
122 // not a rule. IfcRelAggregates is the canonical spatial-hierarchy
123 // relationship (IFCRELCONTAINEDINSPATIALSTRUCTURE promotion below exists
124 // only to cover the case where NO aggregates edge exists at all, #1075), so
125 // it always wins; ties within a kind resolve to the first occurrence in
126 // file order (`relationships` preserves source/parse order), never a
127 // HashMap's iteration order.
128 // Each spatial child's canonical IFCRELAGGREGATES parent, resolved per
129 // child in `spatial_canonical_parent.rs`: plain first-declared-wins,
130 // except a candidate that would close an aggregation cycle back through
131 // the child is skipped first (#4246 - the same rule as
132 // packages/parser/src/spatial-hierarchy-canonical-parent.ts's
133 // computeCanonicalParent). The IFCRELCONTAINEDINSPATIALSTRUCTURE
134 // promotion below (no Aggregates edge at all, #1075) is layered on top,
135 // unaffected by that rule.
136 let mut canonical_parent: FxHashMap<u32, u32> =
137 resolve_aggregate_canonical_parents(relationships);
138
139 for rel in relationships {
140 let rel_type_upper = rel.rel_type.to_uppercase();
141 if rel_type_upper == "IFCRELAGGREGATES" {
142 // Spatial hierarchy: parent -> child spatial nodes. Only the
143 // canonical (first-seen) aggregates edge feeds the tree; a
144 // duplicate aggregates edge naming a different parent for the same
145 // child is dropped rather than adding a second reference to it.
146 if canonical_parent.get(&rel.related_id) == Some(&rel.relating_id) {
147 spatial_children_map
148 .entry(rel.relating_id)
149 .or_default()
150 .push(rel.related_id);
151 }
152 } else if rel_type_upper == "IFCRELCONTAINEDINSPATIALSTRUCTURE" {
153 let target_is_spatial = entity_map
154 .get(&rel.related_id)
155 .map(|e| {
156 let target_type_upper = e.type_name.to_uppercase();
157 is_spatial_type(&target_type_upper) && target_type_upper != "IFCPROJECT"
158 })
159 .unwrap_or(false);
160 if target_is_spatial {
161 // Promote: a contained spatial child becomes a tree node,
162 // unless an IfcRelAggregates edge already claimed it for a
163 // (possibly different) parent - aggregation always wins, per
164 // canonical_parent above. Dedup against an edge to the same
165 // parent so a space that is both aggregated and contained
166 // under the same parent isn't listed twice.
167 let winner = *canonical_parent
168 .entry(rel.related_id)
169 .or_insert(rel.relating_id);
170 if winner == rel.relating_id {
171 let children = spatial_children_map.entry(rel.relating_id).or_default();
172 if !children.contains(&rel.related_id) {
173 children.push(rel.related_id);
174 }
175 }
176 } else {
177 // Element containment: spatial container -> elements
178 element_containment_map
179 .entry(rel.relating_id)
180 .or_default()
181 .push(rel.related_id);
182 }
183 }
184 }
185
186 // Find project (root)
187 let project_id = entities
188 .iter()
189 .find(|e| e.type_name.to_uppercase() == "IFCPROJECT")
190 .map(|e| e.entity_id)
191 .unwrap_or(0);
192
193 // Build all spatial nodes with full information
194 let mut nodes_map: FxHashMap<u32, SpatialNode> = FxHashMap::default();
195
196 // Collect all supported spatial entity IDs, including IFC4.3 facility hierarchies.
197 let spatial_entity_ids: Vec<u32> = entities
198 .iter()
199 .filter(|e| is_spatial_type(&e.type_name))
200 .map(|e| e.entity_id)
201 .collect();
202
203 // Build nodes recursively starting from project. `visited` tracks every
204 // entity the walk actually reached, including one excluded for depth
205 // (see build_spatial_nodes_recursive) - it survives the call so the
206 // orphan-fill loop below can tell "reached and deliberately excluded"
207 // apart from "never reached at all".
208 let mut visited: FxHashSet<u32> = FxHashSet::default();
209 if project_id != 0 {
210 build_spatial_nodes_recursive(
211 project_id,
212 0,
213 0,
214 "",
215 &spatial_children_map,
216 &element_containment_map,
217 &entity_map,
218 &mut decoder,
219 &mut nodes_map,
220 &mut visited,
221 length_unit_scale,
222 );
223 }
224
225 // Also process any spatial nodes genuinely unreachable from project - not
226 // merely absent from `nodes_map`. Two other cases are also absent from it
227 // and must NOT be rescued here:
228 // - `visited` but excluded (a depth-capped node itself, or a cyclic
229 // revisit) - it was reached and deliberately dropped, not orphaned;
230 // - named as someone's child in `canonical_parent` but never reached
231 // (a descendant of a depth-capped node: the recursion never got far
232 // enough to even visit it, since it stops before descending past the
233 // cap) - it still structurally belongs to a parent, so leaving the
234 // whole subtree dropped is consistent, not a dangling reference. Only
235 // an entity that is spatial-typed AND has no parent relationship
236 // anywhere in the file is a genuine orphan worth rescuing as a root.
237 // Rescuing either of the first two would reinsert a fake root (parent_id:
238 // 0, level: 0) while its real parent's children_ids either still names it
239 // as a child, or the parent chain above it was intentionally truncated -
240 // two representations of the same entity's place in the tree disagreeing.
241 // Snapshot the project-reachable node set before rescuing orphans: the
242 // first-declared storey ruling below (#4310) only competes among these,
243 // matching packages/parser, which never visits an orphan storey.
244 let project_reachable: FxHashSet<u32> = nodes_map.keys().copied().collect();
245
246 for &entity_id in &spatial_entity_ids {
247 if visited.contains(&entity_id) || canonical_parent.contains_key(&entity_id) {
248 continue;
249 }
250 if let std::collections::hash_map::Entry::Vacant(e) = nodes_map.entry(entity_id) {
251 if let Some(entity) = entity_map.get(&entity_id) {
252 let name = entity
253 .name
254 .clone()
255 .unwrap_or_else(|| format!("{}#{}", entity.type_name, entity_id));
256
257 e.insert(SpatialNode {
258 entity_id,
259 parent_id: 0,
260 level: 0,
261 path: name.clone(),
262 type_name: entity.type_name.clone(),
263 name: entity.name.clone(),
264 elevation: extract_elevation_if_storey(
265 &entity.type_name,
266 entity_id,
267 &mut decoder,
268 length_unit_scale,
269 ),
270 children_ids: spatial_children_map
271 .get(&entity_id)
272 .cloned()
273 .unwrap_or_default(),
274 element_ids: element_containment_map
275 .get(&entity_id)
276 .cloned()
277 .unwrap_or_default(),
278 });
279 }
280 }
281 }
282
283 // The orphan-fill loop above pulls a rescued node's `children_ids`
284 // straight from `spatial_children_map`, without the same filtering
285 // `build_spatial_nodes_recursive` applies to its own node after
286 // descending into its children. That reintroduces this PR's own bug one
287 // level removed: an entity whose canonical parent is itself an orphan
288 // (e.g. a Site never aggregated by Project, canonical-parenting a
289 // Building) is skipped by the orphan-fill loop's `canonical_parent`
290 // check - correctly, since it does have a parent - but that parent is
291 // rescued as a fake root and still lists it in `children_ids`, so the
292 // fake root ends up pointing at a child with no `SpatialNode` of its
293 // own: the exact dangling-reference shape this fix set out to eliminate.
294 // One final pass across every node (recursively-built ones included, as
295 // a no-op there) closes this the same way the recursive walker already
296 // closes it for its own descent.
297 let all_node_ids: FxHashSet<u32> = nodes_map.keys().copied().collect();
298 for node in nodes_map.values_mut() {
299 node.children_ids
300 .retain(|child_id| all_node_ids.contains(child_id));
301 }
302
303 // #3973 landed two pointwise fixes for the same defect shape - a fake root
304 // (or, before this PR, a resurrected depth-capped node) whose children_ids
305 // named an entity absent from nodes_map - each closing one path into the
306 // tree (recursive descent, then orphan-fill) without ruling the shape out
307 // structurally. Only a release build skips this: the check walks every
308 // node once and only runs when debug_assertions are compiled in, exactly
309 // like every other debug_assert! in this codebase.
310 debug_assert!(
311 {
312 let values: Vec<&SpatialNode> = nodes_map.values().collect();
313 let violations = spatial_hierarchy_consistency_violations(&values);
314 if !violations.is_empty() {
315 tracing::error!(violations = ?violations, "spatial hierarchy consistency violated");
316 }
317 violations.is_empty()
318 },
319 "build_spatial_hierarchy produced an inconsistent tree (see logged violations)"
320 );
321
322
323 let lookups = build_element_lookups(
324 relationships,
325 &entity_map,
326 &nodes_map,
327 &project_reachable,
328 is_spatial_type,
329 is_building_like_spatial_type,
330 is_space_like_spatial_type,
331 );
332
333 SpatialHierarchyData {
334 nodes: nodes_map.into_values().collect(),
335 project_id,
336 element_to_storey: lookups.storey,
337 element_to_building: lookups.building,
338 element_to_site: lookups.site,
339 element_to_space: lookups.space,
340 }
341}
342
343#[cfg(test)]
344#[path = "spatial_tests.rs"]
345mod spatial_tests;