1use std::collections::HashMap;
13
14use ifc_lite_core::step_encoding::step_string_literal_end;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18pub enum ContainerMergeStrategy {
19 Single,
21 ByName,
23 #[default]
25 NameThenSingle,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum StoreyMergeStrategy {
31 ByName,
33 ByElevation,
35 #[default]
37 ByNameThenElevation,
38}
39
40#[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
51pub 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 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
99pub 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 let inner = &raw[1..raw.len() - 1];
109 Some(inner.replace("''", "'").to_lowercase())
110}
111
112pub 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 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 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 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 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 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 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 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 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 assert_eq!(
334 lookup.match_site(unnamed, 1, &matched, ContainerMergeStrategy::NameThenSingle),
335 Some(7)
336 );
337 assert_eq!(lookup.match_site(unnamed, 2, &matched, ContainerMergeStrategy::Single), None);
339 }
340}