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 reach more than [`crate::limits::LARGE_COORD_THRESHOLD_METERS`]
79    /// (1 km, in METRES; was 10 km before #4934) from the origin.
80    ///
81    /// The sampled values are raw `IfcCartesianPoint` coordinates in the
82    /// file's length unit, so the caller passes `length_unit_scale` (file
83    /// unit to metres, e.g. 0.001 for millimetres, 1000.0 for kilometres)
84    /// and the comparison happens in metres. Gating on the raw values against
85    /// a metre-shaped constant let a kilometre-unit model 500 000 km out read
86    /// as "500 < 1000" and skip the RTC rebase, and the caller's scaling
87    /// afterwards could not undo a decision already taken.
88    #[inline]
89    pub fn has_large_coordinates(&self, length_unit_scale: f64) -> bool {
90        if !self.is_valid() {
91            return false;
92        }
93        [
94            self.min_x,
95            self.min_y,
96            self.min_z,
97            self.max_x,
98            self.max_y,
99            self.max_z,
100        ]
101        .iter()
102        .any(|v| (v * length_unit_scale).abs() > crate::limits::LARGE_COORD_THRESHOLD_METERS)
103    }
104
105    /// The RTC verdict in metres: `None` when no point was sampled, `Large`
106    /// with the unit-scaled bbox centre when a corner is past the threshold
107    /// (see [`has_large_coordinates`](Self::has_large_coordinates)), `Small`
108    /// otherwise. The centre can be inside the threshold while a corner is not,
109    /// which is why the decision is carried rather than re-derived from it.
110    #[inline]
111    pub fn rtc_offset(&self, length_unit_scale: f64) -> Option<crate::RtcVerdict> {
112        if !self.is_valid() {
113            return None;
114        }
115        Some(if self.has_large_coordinates(length_unit_scale) {
116            let (x, y, z) = self.centroid();
117            let s = length_unit_scale;
118            crate::RtcVerdict::Large { anchor: (x * s, y * s, z * s) }
119        } else {
120            crate::RtcVerdict::Small
121        })
122    }
123}
124
125impl Default for ModelBounds {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Scan IFC content to extract model bounds from IfcCartesianPoint entities
132///
133/// This is a fast first-pass scan that extracts coordinate values directly from
134/// the IFC text without full entity decoding. It samples points to determine
135/// if the model has large coordinates that need RTC shifting.
136///
137/// # Performance
138/// This scans through the file once, looking for IFCCARTESIANPOINT patterns.
139/// It's much faster than full entity parsing since it only extracts coordinates.
140pub fn scan_model_bounds<T>(content: &T) -> ModelBounds
141where
142    T: AsRef<[u8]> + ?Sized,
143{
144    let content = content.as_ref();
145    let mut bounds = ModelBounds::new();
146
147    // Use EntityScanner for efficient scanning
148    let mut scanner = EntityScanner::new(content);
149
150    while let Some((_id, type_name, start, end)) = scanner.next_entity() {
151        // Only process cartesian points. STEP keyword case is not
152        // significant (ISO 10303-21) and the scanner returns it as written.
153        if !crate::keyword_eq(type_name, "IFCCARTESIANPOINT") {
154            continue;
155        }
156
157        // Extract the entity content
158        let entity_text = &content[start..end];
159
160        // Parse coordinates from IFCCARTESIANPOINT((x,y,z));
161        if let Some(coords) = extract_point_coordinates(entity_text) {
162            let x = coords.0;
163            let y = coords.1;
164            let z = coords.2.unwrap_or(0.0);
165
166            // Skip obviously invalid coordinates
167            if x.is_finite() && y.is_finite() && z.is_finite() {
168                bounds.expand(x, y, z);
169            }
170        }
171    }
172
173    bounds
174}
175
176/// Extract coordinates from IfcCartesianPoint text
177/// Format: IFCCARTESIANPOINT((x,y)) or IFCCARTESIANPOINT((x,y,z))
178fn extract_point_coordinates<T>(bytes: &T) -> Option<(f64, f64, Option<f64>)>
179where
180    T: AsRef<[u8]> + ?Sized,
181{
182    let bytes = bytes.as_ref();
183    let text = std::str::from_utf8(bytes).ok()?;
184    // Find the coordinate list between (( and ))
185    let start = text.find("((")?;
186    let end = text.rfind("))")?;
187
188    if start >= end {
189        return None;
190    }
191
192    let coord_str = &text[start + 2..end];
193
194    // Split by comma and parse
195    let parts: Vec<&str> = coord_str.split(',').collect();
196
197    if parts.len() < 2 {
198        return None;
199    }
200
201    let x = parts[0].trim().parse::<f64>().ok()?;
202    let y = parts[1].trim().parse::<f64>().ok()?;
203    let z = if parts.len() > 2 {
204        parts[2].trim().parse::<f64>().ok()
205    } else {
206        None
207    };
208
209    Some((x, y, z))
210}
211
212/// Scan model bounds focusing on placement coordinates
213///
214/// This variant specifically looks at IfcLocalPlacement and transformation
215/// coordinates, which are more representative of where geometry will be placed.
216/// Useful for models where cartesian points include local/relative coordinates.
217pub fn scan_placement_bounds<T>(content: &T) -> ModelBounds
218where
219    T: AsRef<[u8]> + ?Sized,
220{
221    let content = content.as_ref();
222    let mut bounds = ModelBounds::new();
223    let mut scanner = EntityScanner::new(content);
224
225    // Track which cartesian point IDs are referenced by placements (HashSet for O(1) lookups)
226    let mut placement_point_ids: HashSet<u32> = HashSet::new();
227
228    // First pass: find cartesian points referenced by Axis2Placement3D
229    while let Some((_id, type_name, start, end)) = scanner.next_entity() {
230        if crate::keyword_eq(type_name, "IFCAXIS2PLACEMENT3D") {
231            let entity_text = &content[start..end];
232            // Extract the Location reference (first attribute)
233            if let Some(ref_id) = extract_first_reference(entity_text) {
234                placement_point_ids.insert(ref_id);
235            }
236        }
237    }
238
239    // Second pass: extract coordinates from referenced points
240    scanner = EntityScanner::new(content);
241    while let Some((id, type_name, start, end)) = scanner.next_entity() {
242        if crate::keyword_eq(type_name, "IFCCARTESIANPOINT") {
243            // Check if this point is referenced by a placement
244            let is_placement_point = placement_point_ids.contains(&id);
245
246            // For placement points, always include them
247            // For other points, only include if they have large coordinates
248            let entity_text = &content[start..end];
249            if let Some(coords) = extract_point_coordinates(entity_text) {
250                let x = coords.0;
251                let y = coords.1;
252                let z = coords.2.unwrap_or(0.0);
253
254                // Skip invalid coordinates
255                if !x.is_finite() || !y.is_finite() || !z.is_finite() {
256                    continue;
257                }
258
259                // Include placement points and points with large coordinates (including Z axis)
260                if is_placement_point || x.abs() > 1000.0 || y.abs() > 1000.0 || z.abs() > 1000.0 {
261                    bounds.expand(x, y, z);
262                }
263            }
264        }
265    }
266
267    // If no placement points found, fall back to full scan
268    if !bounds.is_valid() {
269        return scan_model_bounds(content);
270    }
271
272    bounds
273}
274
275/// Extract first entity reference from text
276/// Looks for #xxx pattern
277fn extract_first_reference<T>(bytes: &T) -> Option<u32>
278where
279    T: AsRef<[u8]> + ?Sized,
280{
281    let bytes = bytes.as_ref();
282    let text = std::str::from_utf8(bytes).ok()?;
283    // Find opening paren of attribute list
284    let start = text.find('(')?;
285    let rest = &text[start + 1..];
286
287    // Find first # character
288    let hash_pos = rest.find('#')?;
289    let after_hash = &rest[hash_pos + 1..];
290
291    // Parse the number
292    let end_pos = after_hash
293        .find(|c: char| !c.is_ascii_digit())
294        .unwrap_or(after_hash.len());
295
296    if end_pos == 0 {
297        return None;
298    }
299
300    after_hash[..end_pos].parse().ok()
301}
302
303#[cfg(test)]
304#[path = "model_bounds_tests.rs"]
305mod tests;