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;