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}