ifc_lite_export/merged/
spatial.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Spatial-structure unification for merged export. Ports `buildSpatialLookup`,
3//! `unifySpatialEntities` and `matchRootContainer` from `merged-exporter.ts`:
4//! match a later model's `IfcSite` / `IfcBuilding` / `IfcBuildingStorey` onto the
5//! first model's so the federation shares one spatial tree instead of stacking N
6//! disjoint ones.
7//!
8//! Matching is by lowercased `Name` (attribute 2) and — for storeys — a fallback
9//! on `Elevation` (attribute 9) within a tolerance. All extraction is text-level
10//! against the raw STEP line, mirroring the JS which also has no schema table.
11
12use std::collections::HashMap;
13
14use ifc_lite_core::step_encoding::step_string_literal_end;
15
16/// Strategy for matching a later model's `IfcSite` / `IfcBuilding` onto the first.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18pub enum ContainerMergeStrategy {
19    /// Only unify when both models declare exactly one instance.
20    Single,
21    /// Unify by lowercased `Name`.
22    ByName,
23    /// Name first, single-instance fallback (JS default when the option is omitted).
24    #[default]
25    NameThenSingle,
26}
27
28/// Strategy for matching a later model's `IfcBuildingStorey` onto the first.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum StoreyMergeStrategy {
31    /// Match by lowercased `Name` only.
32    ByName,
33    /// Match by `Elevation` only (within tolerance).
34    ByElevation,
35    /// Name first, elevation fallback (JS default).
36    #[default]
37    ByNameThenElevation,
38}
39
40/// First-model spatial index used to match later models' containers onto it.
41#[derive(Default)]
42pub struct SpatialLookup {
43    sites_by_name: HashMap<String, u32>,
44    buildings_by_name: HashMap<String, u32>,
45    storeys_by_name: HashMap<String, u32>,
46    storeys_by_elevation: Vec<(u32, f64)>,
47    site_ids: Vec<u32>,
48    building_ids: Vec<u32>,
49}
50
51/// Extract the trimmed content of the 0-based top-level attribute `n` of a STEP
52/// entity line (`#id=TYPE(a,b,c,…);`), honouring quoted strings and nested
53/// parentheses. Returns `None` when the line has no argument list or `n` is out
54/// of range.
55pub fn nth_attr(line: &str, n: usize) -> Option<&str> {
56    let bytes = line.as_bytes();
57    let open = bytes.iter().position(|&b| b == b'(')?;
58    let mut depth = 0i32;
59    let mut idx = 0usize;
60    let mut arg_start = open + 1;
61    let mut i = open;
62    while i < bytes.len() {
63        match bytes[i] {
64            // The shared rule for where a literal ends: a `\S\'` operand is
65            // text, not the closing quote (#7589).
66            b'\'' => {
67                i = step_string_literal_end(bytes, i)?;
68                continue;
69            }
70            b'(' => {
71                depth += 1;
72                if depth == 1 {
73                    arg_start = i + 1;
74                }
75            }
76            b')' => {
77                depth -= 1;
78                if depth == 0 {
79                    if idx == n {
80                        return Some(line[arg_start..i].trim());
81                    }
82                    return None;
83                }
84            }
85            b',' if depth == 1 => {
86                if idx == n {
87                    return Some(line[arg_start..i].trim());
88                }
89                idx += 1;
90                arg_start = i + 1;
91            }
92            _ => {}
93        }
94        i += 1;
95    }
96    None
97}
98
99/// The `Name` (attribute 2) of a rooted entity, lowercased for matching. Returns
100/// `None` when the attribute is null (`$`) or not a quoted string.
101pub fn entity_name(line: &str) -> Option<String> {
102    let raw = nth_attr(line, 2)?;
103    if raw == "$" || raw == "*" || !raw.starts_with('\'') || !raw.ends_with('\'') || raw.len() < 2 {
104        return None;
105    }
106    // Unquote and un-double `''` → `'`; keep other bytes verbatim (both the first
107    // model and later models decode identically, so matching stays consistent).
108    let inner = &raw[1..raw.len() - 1];
109    Some(inner.replace("''", "'").to_lowercase())
110}
111
112/// The `Elevation` (attribute 9) of an `IfcBuildingStorey`, if numeric.
113pub fn storey_elevation(line: &str) -> Option<f64> {
114    let raw = nth_attr(line, 9)?;
115    let trimmed = raw.trim();
116    if trimmed.is_empty() || trimmed == "$" || trimmed == "*" {
117        return None;
118    }
119    trimmed.parse::<f64>().ok()
120}
121
122impl SpatialLookup {
123    /// Build the first-model spatial index from `entities` (id → raw line text).
124    /// `order` gives the scan order so name-map "last wins" matches the JS `Map`.
125    pub fn build(order: &[u32], line_of: &dyn Fn(u32) -> Option<String>, type_of: &dyn Fn(u32) -> Option<String>) -> Self {
126        let mut lookup = SpatialLookup::default();
127        for &id in order {
128            let Some(type_upper) = type_of(id) else { continue };
129            match type_upper.as_str() {
130                "IFCSITE" => {
131                    lookup.site_ids.push(id);
132                    if let Some(line) = line_of(id) {
133                        if let Some(name) = entity_name(&line) {
134                            lookup.sites_by_name.insert(name, id);
135                        }
136                    }
137                }
138                "IFCBUILDING" => {
139                    lookup.building_ids.push(id);
140                    if let Some(line) = line_of(id) {
141                        if let Some(name) = entity_name(&line) {
142                            lookup.buildings_by_name.insert(name, id);
143                        }
144                    }
145                }
146                "IFCBUILDINGSTOREY" => {
147                    if let Some(line) = line_of(id) {
148                        if let Some(name) = entity_name(&line) {
149                            lookup.storeys_by_name.insert(name, id);
150                        }
151                        if let Some(elev) = storey_elevation(&line) {
152                            lookup.storeys_by_elevation.push((id, elev));
153                        }
154                    }
155                }
156                _ => {}
157            }
158        }
159        lookup
160    }
161
162    /// Match a later model's site/building `line` onto a first-model container.
163    /// `first_ids` / `by_name` select the site-or-building index. `count_in_model`
164    /// is how many of this type the later model has (for the single-instance
165    /// rule). `matched` records first-model ids already claimed this model.
166    fn match_container(
167        &self,
168        line: &str,
169        first_ids: &[u32],
170        by_name: &HashMap<String, u32>,
171        count_in_model: usize,
172        matched: &std::collections::HashSet<u32>,
173        strategy: ContainerMergeStrategy,
174    ) -> Option<u32> {
175        let by_name_match = || -> Option<u32> {
176            let name = entity_name(line)?;
177            let candidate = *by_name.get(&name)?;
178            (!matched.contains(&candidate)).then_some(candidate)
179        };
180        let by_single = || -> Option<u32> {
181            if count_in_model == 1 && first_ids.len() == 1 {
182                let candidate = first_ids[0];
183                return (!matched.contains(&candidate)).then_some(candidate);
184            }
185            None
186        };
187        match strategy {
188            ContainerMergeStrategy::Single => by_single(),
189            ContainerMergeStrategy::ByName => by_name_match(),
190            ContainerMergeStrategy::NameThenSingle => by_name_match().or_else(by_single),
191        }
192    }
193
194    /// Match a later model's site `line`; see [`Self::match_container`].
195    pub fn match_site(
196        &self,
197        line: &str,
198        count_in_model: usize,
199        matched: &std::collections::HashSet<u32>,
200        strategy: ContainerMergeStrategy,
201    ) -> Option<u32> {
202        self.match_container(line, &self.site_ids, &self.sites_by_name, count_in_model, matched, strategy)
203    }
204
205    /// Match a later model's building `line`; see [`Self::match_container`].
206    pub fn match_building(
207        &self,
208        line: &str,
209        count_in_model: usize,
210        matched: &std::collections::HashSet<u32>,
211        strategy: ContainerMergeStrategy,
212    ) -> Option<u32> {
213        self.match_container(line, &self.building_ids, &self.buildings_by_name, count_in_model, matched, strategy)
214    }
215
216    /// Match a later model's storey `line` onto a first-model storey. Name first
217    /// (unless `ByElevation`), then an elevation fallback (unless `ByName`) with
218    /// tolerance `max(0.5, |elevation|·0.01)` in first-model units;
219    /// `elevation_factor` rescales the later model's elevation into the primary
220    /// unit under normalization (1.0 otherwise).
221    pub fn match_storey(
222        &self,
223        line: &str,
224        matched: &std::collections::HashSet<u32>,
225        strategy: StoreyMergeStrategy,
226        elevation_factor: f64,
227    ) -> Option<u32> {
228        if strategy != StoreyMergeStrategy::ByElevation {
229            if let Some(name) = entity_name(line) {
230                if let Some(&candidate) = self.storeys_by_name.get(&name) {
231                    if !matched.contains(&candidate) {
232                        return Some(candidate);
233                    }
234                }
235            }
236        }
237        if strategy == StoreyMergeStrategy::ByName {
238            return None;
239        }
240        let elevation = storey_elevation(line)? * elevation_factor;
241        for &(id, entry_elev) in &self.storeys_by_elevation {
242            if matched.contains(&id) {
243                continue;
244            }
245            let tolerance = 0.5f64.max(entry_elev.abs() * 0.01);
246            if (elevation - entry_elev).abs() <= tolerance {
247                return Some(id);
248            }
249        }
250        None
251    }
252}
253
254#[cfg(test)]
255mod spatial_tests {
256    use super::*;
257    use std::collections::HashSet;
258
259    #[test]
260    fn nth_attr_handles_strings_and_nested_lists() {
261        let line = "#5=IFCBUILDINGSTOREY('guid',#6,'Level 1',$,$,#7,$,$,.ELEMENT.,3000.);";
262        assert_eq!(nth_attr(line, 0), Some("'guid'"));
263        assert_eq!(nth_attr(line, 2), Some("'Level 1'"));
264        assert_eq!(nth_attr(line, 9), Some("3000."));
265        let nested = "#1=IFCFOO('a',(#2,#3),$);";
266        assert_eq!(nth_attr(nested, 1), Some("(#2,#3)"));
267    }
268
269    #[test]
270    fn nth_attr_ignores_commas_and_parens_inside_strings() {
271        let line = "#1=IFCFOO('a,b (c)',#2,'x');";
272        assert_eq!(nth_attr(line, 0), Some("'a,b (c)'"));
273        assert_eq!(nth_attr(line, 1), Some("#2"));
274        assert_eq!(nth_attr(line, 2), Some("'x'"));
275    }
276
277    #[test]
278    fn nth_attr_keeps_a_page_operand_inside_its_string_7589() {
279        let line = r"#1=IFCBUILDINGSTOREY('g',#2,'Para \S\' 5',$,#4);";
280        assert_eq!(nth_attr(line, 2), Some(r"'Para \S\' 5'"));
281        assert_eq!(nth_attr(line, 4), Some("#4"));
282    }
283
284    #[test]
285    fn entity_name_lowercases_and_unquotes() {
286        let line = "#5=IFCSITE('g',$,'Main SITE',$,$,$,$,$,$);";
287        assert_eq!(entity_name(line).as_deref(), Some("main site"));
288        let null_name = "#5=IFCSITE('g',$,$,$,$,$,$,$,$);";
289        assert_eq!(entity_name(null_name), None);
290    }
291
292    #[test]
293    fn storey_matches_by_name_then_elevation() {
294        let first_storey = "#10=IFCBUILDINGSTOREY('g',$,'Level 1',$,$,#1,$,$,.ELEMENT.,0.);";
295        let lookup = SpatialLookup {
296            storeys_by_name: HashMap::from([("level 1".to_string(), 10u32)]),
297            storeys_by_elevation: vec![(10, 0.0), (11, 3000.0)],
298            ..Default::default()
299        };
300        let matched = HashSet::new();
301        // Name hit.
302        let by_name_line = "#20=IFCBUILDINGSTOREY('g2',$,'Level 1',$,$,#2,$,$,.ELEMENT.,5.);";
303        assert_eq!(
304            lookup.match_storey(by_name_line, &matched, StoreyMergeStrategy::ByNameThenElevation, 1.0),
305            Some(10)
306        );
307        // No name hit, elevation within tolerance of the 3000 storey.
308        let by_elev_line = "#21=IFCBUILDINGSTOREY('g3',$,'Second',$,$,#3,$,$,.ELEMENT.,3000.2);";
309        assert_eq!(
310            lookup.match_storey(by_elev_line, &matched, StoreyMergeStrategy::ByNameThenElevation, 1.0),
311            Some(11)
312        );
313        // by-name only → no elevation fallback.
314        assert_eq!(
315            lookup.match_storey(by_elev_line, &matched, StoreyMergeStrategy::ByName, 1.0),
316            None
317        );
318        let _ = first_storey;
319    }
320
321    #[test]
322    fn site_matches_single_and_by_name() {
323        let lookup = SpatialLookup {
324            site_ids: vec![7],
325            sites_by_name: HashMap::from([("terrain".to_string(), 7u32)]),
326            ..Default::default()
327        };
328        let matched = HashSet::new();
329        let named = "#30=IFCSITE('g',$,'Terrain',$,$,$,$,$,$);";
330        assert_eq!(lookup.match_site(named, 1, &matched, ContainerMergeStrategy::ByName), Some(7));
331        let unnamed = "#30=IFCSITE('g',$,$,$,$,$,$,$,$);";
332        // No name → single fallback (default) still matches the lone site.
333        assert_eq!(
334            lookup.match_site(unnamed, 1, &matched, ContainerMergeStrategy::NameThenSingle),
335            Some(7)
336        );
337        // Single strategy with two sites in this model → no match.
338        assert_eq!(lookup.match_site(unnamed, 2, &matched, ContainerMergeStrategy::Single), None);
339    }
340}