Skip to main content

ifc_lite_core/
model_bounds.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//! Model bounds calculation for large coordinate handling
6//!
7//! Scans IFC content to determine model bounding box in f64 precision.
8//! Used for calculating RTC (Relative-to-Center) offset before geometry processing
9//! to avoid Float32 precision loss with large coordinates (e.g., Swiss UTM).
10
11use crate::EntityScanner;
12use std::collections::HashSet;
13
14/// Model bounds in f64 precision
15#[derive(Debug, Clone)]
16pub struct ModelBounds {
17    /// Minimum X coordinate found
18    pub min_x: f64,
19    /// Minimum Y coordinate found
20    pub min_y: f64,
21    /// Minimum Z coordinate found
22    pub min_z: f64,
23    /// Maximum X coordinate found
24    pub max_x: f64,
25    /// Maximum Y coordinate found
26    pub max_y: f64,
27    /// Maximum Z coordinate found
28    pub max_z: f64,
29    /// Number of points sampled
30    pub sample_count: usize,
31}
32
33impl ModelBounds {
34    /// Create new bounds initialized to invalid state
35    pub fn new() -> Self {
36        Self {
37            min_x: f64::MAX,
38            min_y: f64::MAX,
39            min_z: f64::MAX,
40            max_x: f64::MIN,
41            max_y: f64::MIN,
42            max_z: f64::MIN,
43            sample_count: 0,
44        }
45    }
46
47    /// Check if bounds are valid (at least one point added)
48    #[inline]
49    pub fn is_valid(&self) -> bool {
50        self.sample_count > 0
51    }
52
53    /// Expand bounds to include a point
54    #[inline]
55    pub fn expand(&mut self, x: f64, y: f64, z: f64) {
56        self.min_x = self.min_x.min(x);
57        self.min_y = self.min_y.min(y);
58        self.min_z = self.min_z.min(z);
59        self.max_x = self.max_x.max(x);
60        self.max_y = self.max_y.max(y);
61        self.max_z = self.max_z.max(z);
62        self.sample_count += 1;
63    }
64
65    /// Get centroid (center of bounding box)
66    #[inline]
67    pub fn centroid(&self) -> (f64, f64, f64) {
68        if !self.is_valid() {
69            return (0.0, 0.0, 0.0);
70        }
71        (
72            (self.min_x + self.max_x) / 2.0,
73            (self.min_y + self.max_y) / 2.0,
74            (self.min_z + self.max_z) / 2.0,
75        )
76    }
77
78    /// Check if bounds contain large coordinates (>10km from origin)
79    #[inline]
80    pub fn has_large_coordinates(&self) -> bool {
81        const THRESHOLD: f64 = 10000.0; // 10km
82        if !self.is_valid() {
83            return false;
84        }
85        self.min_x.abs() > THRESHOLD
86            || self.min_y.abs() > THRESHOLD
87            || self.max_x.abs() > THRESHOLD
88            || self.max_y.abs() > THRESHOLD
89            || self.min_z.abs() > THRESHOLD
90            || self.max_z.abs() > THRESHOLD
91    }
92
93    /// Get the RTC offset (same as centroid for large coordinates, zero otherwise)
94    #[inline]
95    pub fn rtc_offset(&self) -> (f64, f64, f64) {
96        if self.has_large_coordinates() {
97            self.centroid()
98        } else {
99            (0.0, 0.0, 0.0)
100        }
101    }
102}
103
104impl Default for ModelBounds {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110/// Scan IFC content to extract model bounds from IfcCartesianPoint entities
111///
112/// This is a fast first-pass scan that extracts coordinate values directly from
113/// the IFC text without full entity decoding. It samples points to determine
114/// if the model has large coordinates that need RTC shifting.
115///
116/// # Performance
117/// This scans through the file once, looking for IFCCARTESIANPOINT patterns.
118/// It's much faster than full entity parsing since it only extracts coordinates.
119pub fn scan_model_bounds<T>(content: &T) -> ModelBounds
120where
121    T: AsRef<[u8]> + ?Sized,
122{
123    let content = content.as_ref();
124    let mut bounds = ModelBounds::new();
125
126    // Use EntityScanner for efficient scanning
127    let mut scanner = EntityScanner::new(content);
128
129    while let Some((_id, type_name, start, end)) = scanner.next_entity() {
130        // Only process cartesian points
131        if type_name != "IFCCARTESIANPOINT" {
132            continue;
133        }
134
135        // Extract the entity content
136        let entity_text = &content[start..end];
137
138        // Parse coordinates from IFCCARTESIANPOINT((x,y,z));
139        if let Some(coords) = extract_point_coordinates(entity_text) {
140            let x = coords.0;
141            let y = coords.1;
142            let z = coords.2.unwrap_or(0.0);
143
144            // Skip obviously invalid coordinates
145            if x.is_finite() && y.is_finite() && z.is_finite() {
146                bounds.expand(x, y, z);
147            }
148        }
149    }
150
151    bounds
152}
153
154/// Extract coordinates from IfcCartesianPoint text
155/// Format: IFCCARTESIANPOINT((x,y)) or IFCCARTESIANPOINT((x,y,z))
156fn extract_point_coordinates<T>(bytes: &T) -> Option<(f64, f64, Option<f64>)>
157where
158    T: AsRef<[u8]> + ?Sized,
159{
160    let bytes = bytes.as_ref();
161    let text = std::str::from_utf8(bytes).ok()?;
162    // Find the coordinate list between (( and ))
163    let start = text.find("((")?;
164    let end = text.rfind("))")?;
165
166    if start >= end {
167        return None;
168    }
169
170    let coord_str = &text[start + 2..end];
171
172    // Split by comma and parse
173    let parts: Vec<&str> = coord_str.split(',').collect();
174
175    if parts.len() < 2 {
176        return None;
177    }
178
179    let x = parts[0].trim().parse::<f64>().ok()?;
180    let y = parts[1].trim().parse::<f64>().ok()?;
181    let z = if parts.len() > 2 {
182        parts[2].trim().parse::<f64>().ok()
183    } else {
184        None
185    };
186
187    Some((x, y, z))
188}
189
190/// Scan model bounds focusing on placement coordinates
191///
192/// This variant specifically looks at IfcLocalPlacement and transformation
193/// coordinates, which are more representative of where geometry will be placed.
194/// Useful for models where cartesian points include local/relative coordinates.
195pub fn scan_placement_bounds<T>(content: &T) -> ModelBounds
196where
197    T: AsRef<[u8]> + ?Sized,
198{
199    let content = content.as_ref();
200    let mut bounds = ModelBounds::new();
201    let mut scanner = EntityScanner::new(content);
202
203    // Track which cartesian point IDs are referenced by placements (HashSet for O(1) lookups)
204    let mut placement_point_ids: HashSet<u32> = HashSet::new();
205
206    // First pass: find cartesian points referenced by Axis2Placement3D
207    while let Some((_id, type_name, start, end)) = scanner.next_entity() {
208        if type_name == "IFCAXIS2PLACEMENT3D" {
209            let entity_text = &content[start..end];
210            // Extract the Location reference (first attribute)
211            if let Some(ref_id) = extract_first_reference(entity_text) {
212                placement_point_ids.insert(ref_id);
213            }
214        }
215        // Also include IfcSite coordinates which often have real-world coords
216        if type_name == "IFCSITE" {
217            // IfcSite has RefLatitude, RefLongitude, RefElevation
218            // These are stored as IfcCompoundPlaneAngleMeasure, not coords
219            // But we can get bounds from the site's placement
220        }
221        // Store the entity ID for cartesian points
222        if type_name == "IFCCARTESIANPOINT" {
223            // Will be checked in second pass
224            continue;
225        }
226    }
227
228    // Second pass: extract coordinates from referenced points
229    scanner = EntityScanner::new(content);
230    while let Some((id, type_name, start, end)) = scanner.next_entity() {
231        if type_name == "IFCCARTESIANPOINT" {
232            // Check if this point is referenced by a placement
233            let is_placement_point = placement_point_ids.contains(&id);
234
235            // For placement points, always include them
236            // For other points, only include if they have large coordinates
237            let entity_text = &content[start..end];
238            if let Some(coords) = extract_point_coordinates(entity_text) {
239                let x = coords.0;
240                let y = coords.1;
241                let z = coords.2.unwrap_or(0.0);
242
243                // Skip invalid coordinates
244                if !x.is_finite() || !y.is_finite() || !z.is_finite() {
245                    continue;
246                }
247
248                // Include placement points and points with large coordinates (including Z axis)
249                if is_placement_point || x.abs() > 1000.0 || y.abs() > 1000.0 || z.abs() > 1000.0 {
250                    bounds.expand(x, y, z);
251                }
252            }
253        }
254    }
255
256    // If no placement points found, fall back to full scan
257    if !bounds.is_valid() {
258        return scan_model_bounds(content);
259    }
260
261    bounds
262}
263
264/// Extract first entity reference from text
265/// Looks for #xxx pattern
266fn extract_first_reference<T>(bytes: &T) -> Option<u32>
267where
268    T: AsRef<[u8]> + ?Sized,
269{
270    let bytes = bytes.as_ref();
271    let text = std::str::from_utf8(bytes).ok()?;
272    // Find opening paren of attribute list
273    let start = text.find('(')?;
274    let rest = &text[start + 1..];
275
276    // Find first # character
277    let hash_pos = rest.find('#')?;
278    let after_hash = &rest[hash_pos + 1..];
279
280    // Parse the number
281    let end_pos = after_hash
282        .find(|c: char| !c.is_ascii_digit())
283        .unwrap_or(after_hash.len());
284
285    if end_pos == 0 {
286        return None;
287    }
288
289    after_hash[..end_pos].parse().ok()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_bounds_creation() {
298        let bounds = ModelBounds::new();
299        assert!(!bounds.is_valid());
300        assert!(!bounds.has_large_coordinates());
301    }
302
303    #[test]
304    fn test_bounds_expand() {
305        let mut bounds = ModelBounds::new();
306        bounds.expand(100.0, 200.0, 50.0);
307        bounds.expand(150.0, 250.0, 75.0);
308
309        assert!(bounds.is_valid());
310        assert_eq!(bounds.min_x, 100.0);
311        assert_eq!(bounds.max_x, 150.0);
312        assert_eq!(bounds.min_y, 200.0);
313        assert_eq!(bounds.max_y, 250.0);
314
315        let centroid = bounds.centroid();
316        assert_eq!(centroid.0, 125.0);
317        assert_eq!(centroid.1, 225.0);
318    }
319
320    #[test]
321    fn test_large_coordinates_detection() {
322        let mut bounds = ModelBounds::new();
323        bounds.expand(2679012.0, 1247892.0, 432.0); // Swiss UTM coordinates
324
325        assert!(bounds.has_large_coordinates());
326
327        let offset = bounds.rtc_offset();
328        assert_eq!(offset.0, 2679012.0);
329        assert_eq!(offset.1, 1247892.0);
330    }
331
332    #[test]
333    fn test_small_coordinates_no_shift() {
334        let mut bounds = ModelBounds::new();
335        bounds.expand(0.0, 0.0, 0.0);
336        bounds.expand(100.0, 100.0, 10.0);
337
338        assert!(!bounds.has_large_coordinates());
339
340        let offset = bounds.rtc_offset();
341        assert_eq!(offset.0, 0.0);
342        assert_eq!(offset.1, 0.0);
343        assert_eq!(offset.2, 0.0);
344    }
345
346    #[test]
347    fn test_extract_point_coordinates_3d() {
348        let text = "IFCCARTESIANPOINT((2679012.123,1247892.456,432.789))";
349        let coords = extract_point_coordinates(text).unwrap();
350
351        assert!((coords.0 - 2679012.123).abs() < 0.001);
352        assert!((coords.1 - 1247892.456).abs() < 0.001);
353        assert!((coords.2.unwrap() - 432.789).abs() < 0.001);
354    }
355
356    #[test]
357    fn test_extract_point_coordinates_2d() {
358        let text = "IFCCARTESIANPOINT((100.5,200.5))";
359        let coords = extract_point_coordinates(text).unwrap();
360
361        assert_eq!(coords.0, 100.5);
362        assert_eq!(coords.1, 200.5);
363        assert!(coords.2.is_none());
364    }
365
366    #[test]
367    fn test_scan_model_bounds() {
368        let ifc_content = r#"
369ISO-10303-21;
370HEADER;
371FILE_DESCRIPTION((''),'2;1');
372ENDSEC;
373DATA;
374#1=IFCCARTESIANPOINT((2679012.0,1247892.0,432.0));
375#2=IFCCARTESIANPOINT((2679112.0,1247992.0,442.0));
376#3=IFCWALL('guid',$,$,$,$,$,$,$);
377ENDSEC;
378END-ISO-10303-21;
379"#;
380
381        let bounds = scan_model_bounds(ifc_content);
382
383        assert!(bounds.is_valid());
384        assert!(bounds.has_large_coordinates());
385        assert_eq!(bounds.sample_count, 2);
386
387        let centroid = bounds.centroid();
388        assert!((centroid.0 - 2679062.0).abs() < 0.001);
389        assert!((centroid.1 - 1247942.0).abs() < 0.001);
390    }
391
392    #[test]
393    fn test_scan_model_bounds_small_model() {
394        let ifc_content = r#"
395ISO-10303-21;
396DATA;
397#1=IFCCARTESIANPOINT((0.0,0.0,0.0));
398#2=IFCCARTESIANPOINT((10.0,10.0,5.0));
399ENDSEC;
400END-ISO-10303-21;
401"#;
402
403        let bounds = scan_model_bounds(ifc_content);
404
405        assert!(bounds.is_valid());
406        assert!(!bounds.has_large_coordinates());
407
408        let offset = bounds.rtc_offset();
409        assert_eq!(offset.0, 0.0); // No shift needed for small coordinates
410    }
411
412    #[test]
413    fn test_precision_preserved_with_rtc() {
414        // Simulate what happens with and without RTC
415
416        // Large Swiss UTM coordinates
417        let x1 = 2679012.123456_f64;
418        let x2 = 2679012.223456_f64; // 0.1m apart
419        let expected_diff = 0.1;
420
421        // WITHOUT RTC: Convert directly to f32 (loses precision)
422        let x1_f32_direct = x1 as f32;
423        let x2_f32_direct = x2 as f32;
424        let diff_direct = x2_f32_direct - x1_f32_direct;
425        let error_direct = (diff_direct as f64 - expected_diff).abs();
426
427        // WITH RTC: Subtract centroid first (in f64), then convert
428        let centroid = (x1 + x2) / 2.0;
429        let x1_shifted = (x1 - centroid) as f32;
430        let x2_shifted = (x2 - centroid) as f32;
431        let diff_rtc = x2_shifted - x1_shifted;
432        let error_rtc = (diff_rtc as f64 - expected_diff).abs();
433
434        println!("Without RTC: diff={}, error={}", diff_direct, error_direct);
435        println!("With RTC: diff={}, error={}", diff_rtc, error_rtc);
436
437        // RTC should give much better precision
438        // At ~2.7M magnitude, f32 has ~0.25m precision
439        // After shifting to small values, f32 has sub-mm precision
440        assert!(
441            error_rtc < error_direct * 0.1 || error_rtc < 0.0001,
442            "RTC should significantly improve precision"
443        );
444    }
445}