ifc_lite_server/services/data_model/
spatial_tree.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//! Cycle- and depth-guarded spatial-tree assembly, split out of `spatial.rs`
6//! (#3965 follow-up): walking `spatial_children_map` into `SpatialNode`s is a
7//! self-contained concern once the relationship maps have been built.
8//! `spatial_tests.rs` (attached to the parent `spatial` module, not this one)
9//! exercises `build_spatial_nodes_recursive` directly via `use super::*`.
10
11use super::spatial_elevation::extract_elevation_if_storey;
12use super::super::types::{EntityMetadata, SpatialNode};
13use ifc_lite_core::EntityDecoder;
14use rustc_hash::{FxHashMap, FxHashSet};
15
16/// Maximum recursion depth for building the spatial tree, mirroring
17/// `MAX_SPATIAL_TREE_DEPTH` in `apps/viewer/src/utils/serverDataModel.ts`. This is
18/// a second guard beyond the visited set in `build_spatial_nodes_recursive`: a
19/// pathologically deep but acyclic aggregation/containment chain could still
20/// exhaust the stack even with cycles ruled out.
21pub(super) const MAX_SPATIAL_TREE_DEPTH: u16 = 100;
22
23/// Recursively build spatial nodes with full information.
24// Threads the full recursion context (maps, caches, accumulators); grouping the
25// args into a struct would not change behavior and is out of scope here.
26#[allow(clippy::too_many_arguments)]
27pub(super) fn build_spatial_nodes_recursive(
28    entity_id: u32,
29    parent_id: u32,
30    level: u16,
31    parent_path: &str,
32    spatial_children_map: &FxHashMap<u32, Vec<u32>>,
33    element_containment_map: &FxHashMap<u32, Vec<u32>>,
34    entity_map: &FxHashMap<u32, &EntityMetadata>,
35    decoder: &mut EntityDecoder,
36    nodes_map: &mut FxHashMap<u32, SpatialNode>,
37    visited: &mut FxHashSet<u32>,
38    length_unit_scale: f64,
39) {
40    // Guard against cyclic IfcRelAggregates / promoted-IfcRelContainedInSpatialStructure
41    // edges, mirroring `ctx.visited` in
42    // `packages/parser/src/spatial-hierarchy-builder.ts`: a revisited node is
43    // skipped (left as whatever was already built, if anything) rather than
44    // rebuilt and re-descended into, which would otherwise recurse without
45    // bound. Unlike the browser path, this recursion runs in a process with
46    // `panic = 'abort'`, so an unguarded cycle here is not a catchable panic -
47    // it is a stack overflow that SIGABRTs the whole server (#3973: Storey A
48    // aggregates Storey B, Storey B "contains" Storey A).
49    if visited.contains(&entity_id) {
50        return;
51    }
52    // Mark as visited (considered) BEFORE the depth check below, not after: a
53    // node excluded for being too deep must still count as "reached" for the
54    // orphan-fill loop's purposes (see build_spatial_hierarchy). Marking it
55    // only on the success path left `visited` unable to distinguish "the walk
56    // never found this entity at all" (genuinely orphaned, worth rescuing)
57    // from "the walk found it and deliberately excluded it" (must stay
58    // excluded) - the orphan-fill loop only checked `nodes_map`, which is
59    // empty for both cases, so it reinserted every depth-capped entity as a
60    // fake root (parent_id: 0, level: 0) with its real children_ids intact -
61    // producing a node whose own parent/level said "root" while its parent's
62    // children_ids still named it as a child, and letting the Parquet export
63    // (which reads `parent_id` as authoritative) render it as a spurious
64    // extra root instead of the dropped subtree it actually is.
65    visited.insert(entity_id);
66    // Guard against a pathologically deep but acyclic chain, mirroring
67    // `MAX_SPATIAL_TREE_DEPTH` in `apps/viewer/src/utils/serverDataModel.ts`.
68    if level > MAX_SPATIAL_TREE_DEPTH {
69        return;
70    }
71
72    let entity = match entity_map.get(&entity_id) {
73        Some(e) => e,
74        None => return,
75    };
76
77    let entity_name = entity
78        .name
79        .as_ref()
80        .cloned()
81        .unwrap_or_else(|| format!("{}#{}", entity.type_name, entity_id));
82
83    let path = if parent_path.is_empty() {
84        entity_name.clone()
85    } else {
86        format!("{}/{}", parent_path, entity_name)
87    };
88
89    // Extract elevation for storeys (with unit scale applied)
90    let elevation =
91        extract_elevation_if_storey(&entity.type_name, entity_id, decoder, length_unit_scale);
92
93    // Get children and elements
94    let children_ids = spatial_children_map
95        .get(&entity_id)
96        .cloned()
97        .unwrap_or_default();
98    let element_ids = element_containment_map
99        .get(&entity_id)
100        .cloned()
101        .unwrap_or_default();
102
103    let node = SpatialNode {
104        entity_id,
105        parent_id,
106        level,
107        path: path.clone(),
108        type_name: entity.type_name.clone(),
109        name: entity.name.clone(),
110        elevation,
111        children_ids: children_ids.clone(),
112        element_ids,
113    };
114
115    nodes_map.insert(entity_id, node);
116
117    // Recursively process children
118    for &child_id in &children_ids {
119        build_spatial_nodes_recursive(
120            child_id,
121            entity_id,
122            level + 1,
123            &path,
124            spatial_children_map,
125            element_containment_map,
126            entity_map,
127            decoder,
128            nodes_map,
129            visited,
130            length_unit_scale,
131        );
132    }
133
134    // A child that hit the depth cap (or, in principle, any other early
135    // return above) is `visited` but was never inserted into `nodes_map`, so
136    // it must not remain in this node's own `children_ids` - otherwise this
137    // node would point at a child with no SpatialNode of its own, the exact
138    // dangling-reference shape the orphan-fill skip above exists to avoid.
139    // Two passes (read then write) rather than `retain` because the check
140    // needs `nodes_map` immutably while updating this node's own entry in it.
141    if let Some(existing_children) = nodes_map.get(&entity_id).map(|n| n.children_ids.clone()) {
142        let filtered: Vec<u32> = existing_children
143            .into_iter()
144            .filter(|child_id| nodes_map.contains_key(child_id))
145            .collect();
146        if let Some(node) = nodes_map.get_mut(&entity_id) {
147            node.children_ids = filtered;
148        }
149    }
150}