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;