Skip to main content

ifc_lite_geometry/router/
rtc_offset.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//! RTC (Relative-to-Center) offset detection: sampling element translations
6//! and first geometry vertices to decide whether a model needs re-basing.
7
8use super::GeometryRouter;
9use crate::LARGE_COORD_THRESHOLD_METERS;
10use ifc_lite_core::{has_geometry_by_name, DecodedEntity, EntityDecoder, IfcType};
11
12/// Whether a near-origin element with this `RepresentationType` may cast a
13/// "no-shift" `(0,0,0)` RTC vote when the vertex probe can't cheaply read a
14/// coordinate. This is [`is_body_representation`](super::is_body_representation)
15/// MINUS `"Surface3D"` — meshable does NOT imply RTC-votable.
16///
17/// A `Surface3D` rep (`IfcBSplineSurfaceWithKnots`, `IfcSectionedSurface`,
18/// trimmed/curve-bounded surfaces) keeps its geometry in absolute model-space
19/// control points that `sample_first_geometry_vertex` cannot navigate, so a
20/// near-origin identity-placed Surface3D element would fall through to voting
21/// its placement `(0,0,0)` even though its real geometry can sit on a national
22/// grid hundreds of km away. On IFC4X3 corridor models with many such surfaces
23/// ahead of a few large-coordinate solids, those origin votes drag the median
24/// to zero and/or exhaust the 50-sample budget, suppressing a legitimate
25/// rebase — the #1526 curve-only pollution rebuilt via Surface3D. So Surface3D
26/// must ABSTAIN here, like a curve/axis rep. Scoped to RTC voting only: the
27/// meshing / void / layer paths still treat Surface3D as body geometry.
28fn is_rtc_votable_representation(rep_type: &str) -> bool {
29    rep_type != "Surface3D" && super::is_body_representation(rep_type)
30}
31
32impl GeometryRouter {
33    /// Compute median-based RTC offset from sampled translations.
34    /// Returns `(0,0,0)` if empty or coordinates are within 10km of origin.
35    fn rtc_offset_from_translations(translations: &[(f64, f64, f64)]) -> (f64, f64, f64) {
36        if translations.is_empty() {
37            return (0.0, 0.0, 0.0);
38        }
39
40        let mut x: Vec<f64> = translations.iter().map(|(x, _, _)| *x).collect();
41        let mut y: Vec<f64> = translations.iter().map(|(_, y, _)| *y).collect();
42        let mut z: Vec<f64> = translations.iter().map(|(_, _, z)| *z).collect();
43
44        x.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
45        y.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
46        z.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
47
48        let mid = x.len() / 2;
49        let centroid = (
50            *x.get(mid).unwrap_or(&0.0),
51            *y.get(mid).unwrap_or(&0.0),
52            *z.get(mid).unwrap_or(&0.0),
53        );
54
55        const THRESHOLD: f64 = 10000.0;
56        if centroid.0.abs() > THRESHOLD
57            || centroid.1.abs() > THRESHOLD
58            || centroid.2.abs() > THRESHOLD
59        {
60            return centroid;
61        }
62
63        (0.0, 0.0, 0.0)
64    }
65
66    /// Sample a building element's world-space position for RTC offset detection.
67    ///
68    /// First checks the placement transform translation. If placement is near
69    /// the origin (< 100 m), also probes the first geometry vertex — infrastructure
70    /// models (12d Model, Civil 3D) embed large world coordinates directly in
71    /// Brep/tessellated geometry with an identity placement.
72    fn sample_element_translation(
73        &self,
74        entity: &DecodedEntity,
75        decoder: &mut EntityDecoder,
76    ) -> Option<(f64, f64, f64)> {
77        let has_rep = entity.get(6).map(|a| !a.is_null()).unwrap_or(false);
78        if !has_rep {
79            return None;
80        }
81        let mut transform = self
82            .get_placement_transform_from_element(entity, decoder)
83            .ok()?;
84        self.scale_transform(&mut transform);
85        let tx = transform[(0, 3)];
86        let ty = transform[(1, 3)];
87        let tz = transform[(2, 3)];
88        if !tx.is_finite() || !ty.is_finite() || !tz.is_finite() {
89            return None;
90        }
91
92        // If placement is near origin, also check actual geometry vertex coordinates.
93        // Infrastructure models embed world coords (e.g. 280 000, 6 214 000) directly
94        // in geometry vertices with identity placement — placement-only sampling
95        // would miss the large coordinates and fail to detect the need for RTC.
96        const NEAR_ORIGIN: f64 = 1000.0;
97        if tx.abs() < NEAR_ORIGIN && ty.abs() < NEAR_ORIGIN && tz.abs() < NEAR_ORIGIN {
98            if let Some((vx, vy, vz)) = self.sample_first_geometry_vertex(entity, decoder) {
99                // Transform vertex by placement to get world-space position.
100                // The vertex is in raw file units but the placement transform is
101                // already unit-scaled, so we must scale the vertex first.
102                let world = transform.transform_point(&nalgebra::Point3::new(
103                    vx * self.unit_scale,
104                    vy * self.unit_scale,
105                    vz * self.unit_scale,
106                ));
107                if world.x.is_finite() && world.y.is_finite() && world.z.is_finite() {
108                    return Some((world.x, world.y, world.z));
109                }
110            }
111            // Placement sits at the origin and we could not cheaply read a body
112            // vertex. If this element has NO meshable body/surface representation
113            // at all — only a curve/axis (e.g. an IfcAlignmentSegment carrying just
114            // its 'Axis'/'Segment' curve) — it carries no reliable world position
115            // and must NOT vote (0,0,0) into the RTC sample set. Infrastructure
116            // files pair a handful of large-coordinate solids with many
117            // origin-placed alignment segments, and those spurious origin votes
118            // would drag the median back to zero and suppress the re-basing the
119            // solids actually need. Report "no evidence" instead.
120            //
121            // A body element we simply could not sample cheaply (e.g. a swept
122            // solid near the origin, which the vertex probe does not walk) still
123            // votes (0,0,0): its geometry genuinely sits at the origin, and that
124            // "no shift" vote is what keeps origin-local building models with a
125            // far georef datum from falling through to the placement-bounds
126            // fallback (which would re-base them off-screen).
127            if !self.element_has_body_representation(entity, decoder) {
128                return None;
129            }
130        }
131
132        Some((tx, ty, tz))
133    }
134
135    /// True when the element carries at least one RTC-votable body shape
136    /// representation (see [`is_rtc_votable_representation`]), as opposed to
137    /// only curve/axis/footprint reps (e.g. an IfcAlignmentSegment) OR a
138    /// `Surface3D` rep whose coordinates the vertex probe cannot read. Used to
139    /// decide whether an origin-placed element with no cheaply-samplable vertex
140    /// may still cast a "no shift" (0,0,0) vote during RTC detection.
141    ///
142    /// NOTE: this uses [`is_rtc_votable_representation`], NOT
143    /// [`is_body_representation`](super::is_body_representation) — the two
144    /// differ only in `Surface3D`, which is meshable but not RTC-votable (see
145    /// the predicate's doc; #1526).
146    fn element_has_body_representation(
147        &self,
148        entity: &DecodedEntity,
149        decoder: &mut EntityDecoder,
150    ) -> bool {
151        let Some(rep_attr) = entity.get(6) else {
152            return false;
153        };
154        if rep_attr.is_null() {
155            return false;
156        }
157        let Ok(Some(rep)) = decoder.resolve_ref(rep_attr) else {
158            return false;
159        };
160        if rep.ifc_type != IfcType::IfcProductDefinitionShape {
161            return false;
162        }
163        let Some(reps_attr) = rep.get(2) else {
164            return false;
165        };
166        let Ok(reps) = decoder.resolve_ref_list(reps_attr) else {
167            return false;
168        };
169        reps.iter().any(|sr| {
170            sr.ifc_type == IfcType::IfcShapeRepresentation
171                && super::effective_rep_type(sr)
172                    .map(is_rtc_votable_representation)
173                    .unwrap_or(false)
174        })
175    }
176
177    /// Read the first geometry vertex (f64) from an element's representation.
178    ///
179    /// Navigates the IFC representation hierarchy to extract a single vertex
180    /// coordinate without processing the full geometry. Handles the two most
181    /// common representation types:
182    /// - **Brep**: element → IfcProductDefinitionShape → IfcShapeRepresentation
183    ///   → IfcFacetedBrep → IfcClosedShell → IfcFace → IfcFaceBound → IfcPolyLoop
184    ///   → first IfcCartesianPoint
185    /// - **Tessellated**: element → IfcProductDefinitionShape → IfcShapeRepresentation
186    ///   → IfcTriangulatedFaceSet/IfcPolygonalFaceSet → IfcCartesianPointList3D
187    ///   → first coordinate triple
188    fn sample_first_geometry_vertex(
189        &self,
190        entity: &DecodedEntity,
191        decoder: &mut EntityDecoder,
192    ) -> Option<(f64, f64, f64)> {
193        // element attr 6 = Representation (IfcProductDefinitionShape)
194        let rep_attr = entity.get(6)?;
195        if rep_attr.is_null() {
196            return None;
197        }
198        let rep = decoder.resolve_ref(rep_attr).ok()??;
199        if rep.ifc_type != IfcType::IfcProductDefinitionShape {
200            return None;
201        }
202
203        // attr 2 = Representations (list of IfcShapeRepresentation)
204        let reps_attr = rep.get(2)?;
205        let reps = decoder.resolve_ref_list(reps_attr).ok()?;
206
207        for shape_rep in &reps {
208            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
209                continue;
210            }
211            // attr 3 = Items (list of geometry items)
212            let items = match shape_rep.get(3).and_then(|a| a.as_list()) {
213                Some(list) => list,
214                None => continue,
215            };
216
217            for item_ref in items {
218                let item_id = match item_ref.as_entity_ref() {
219                    Some(id) => id,
220                    None => continue,
221                };
222
223                // Try fast CartesianPoint extraction (if item itself is a point)
224                if let Some(coords) = decoder.get_cartesian_point_fast(item_id) {
225                    return Some(coords);
226                }
227
228                let item = match decoder.decode_by_id(item_id) {
229                    Ok(e) => e,
230                    Err(_) => continue,
231                };
232
233                match item.ifc_type {
234                    // ── Brep path ──
235                    // IfcFacetedBrep attr 0 = Outer (IfcClosedShell)
236                    IfcType::IfcFacetedBrep | IfcType::IfcFacetedBrepWithVoids => {
237                        if let Some(pt) = self.brep_first_vertex(&item, decoder) {
238                            return Some(pt);
239                        }
240                    }
241
242                    // ── Tessellated path ──
243                    // attr 0 = Coordinates (IfcCartesianPointList3D)
244                    IfcType::IfcTriangulatedFaceSet
245                    | IfcType::IfcTriangulatedIrregularNetwork
246                    | IfcType::IfcPolygonalFaceSet => {
247                        if let Some(pt) = self.tessellated_first_vertex(&item, decoder) {
248                            return Some(pt);
249                        }
250                    }
251
252                    // ── Surface model path ──
253                    IfcType::IfcFaceBasedSurfaceModel | IfcType::IfcShellBasedSurfaceModel => {
254                        // attr 0 = FbsmFaces / SbsmBoundary (set of shells)
255                        if let Some(shells_attr) = item.get(0) {
256                            if let Some(shells) = shells_attr.as_list() {
257                                if let Some(shell_ref) = shells.first() {
258                                    if let Some(shell_id) = shell_ref.as_entity_ref() {
259                                        if let Ok(shell) = decoder.decode_by_id(shell_id) {
260                                            // Reuse brep_first_vertex which navigates shell → face → loop → point
261                                            if let Some(pt) =
262                                                self.shell_first_vertex(&shell, decoder)
263                                            {
264                                                return Some(pt);
265                                            }
266                                        }
267                                    }
268                                }
269                            }
270                        }
271                    }
272
273                    _ => continue,
274                }
275            }
276        }
277        None
278    }
279
280    /// Extract first vertex from a Brep entity (IfcFacetedBrep).
281    /// Navigates: Brep → ClosedShell → Face → FaceBound → PolyLoop → CartesianPoint
282    fn brep_first_vertex(
283        &self,
284        brep: &DecodedEntity,
285        decoder: &mut EntityDecoder,
286    ) -> Option<(f64, f64, f64)> {
287        let shell_id = brep.get_ref(0)?;
288        let shell = decoder.decode_by_id(shell_id).ok()?;
289        self.shell_first_vertex(&shell, decoder)
290    }
291
292    /// Extract first vertex from a shell entity (IfcClosedShell / IfcOpenShell).
293    fn shell_first_vertex(
294        &self,
295        shell: &DecodedEntity,
296        decoder: &mut EntityDecoder,
297    ) -> Option<(f64, f64, f64)> {
298        let faces = shell.get(0)?.as_list()?;
299        let face_id = faces.first()?.as_entity_ref()?;
300        let face = decoder.decode_by_id(face_id).ok()?;
301        let bounds = face.get(0)?.as_list()?;
302        let bound_id = bounds.first()?.as_entity_ref()?;
303        let bound = decoder.decode_by_id(bound_id).ok()?;
304        let loop_id = bound.get_ref(0)?;
305        // Try fast cartesian point extraction from polyloop
306        if let Some(coords) = decoder.get_polyloop_coords_cached(loop_id) {
307            if let Some(&(x, y, z)) = coords.first() {
308                return Some((x, y, z));
309            }
310        }
311        // Fallback: decode the loop and get first point
312        let loop_entity = decoder.decode_by_id(loop_id).ok()?;
313        if loop_entity.ifc_type == IfcType::IfcPolyLoop {
314            let polygon = loop_entity.get(0)?.as_list()?;
315            let pt_id = polygon.first()?.as_entity_ref()?;
316            return decoder.get_cartesian_point_fast(pt_id);
317        }
318        None
319    }
320
321    /// Extract first vertex from a tessellated entity.
322    /// Navigates: FaceSet → CartesianPointList3D → first coordinate triple
323    fn tessellated_first_vertex(
324        &self,
325        faceset: &DecodedEntity,
326        decoder: &mut EntityDecoder,
327    ) -> Option<(f64, f64, f64)> {
328        let coord_id = faceset.get_ref(0)?;
329        let coord_entity = decoder.decode_by_id(coord_id).ok()?;
330        let coord_list = coord_entity.get(0)?.as_list()?;
331        let first_triple = coord_list.first()?.as_list()?;
332        let x = first_triple.first()?.as_float()?;
333        let y = first_triple.get(1)?.as_float()?;
334        let z = first_triple.get(2)?.as_float()?;
335        Some((x, y, z))
336    }
337
338    fn raw_coordinate_is_large(&self, point: (f64, f64, f64)) -> bool {
339        let max_abs = point.0.abs().max(point.1.abs()).max(point.2.abs());
340        max_abs * self.unit_scale > LARGE_COORD_THRESHOLD_METERS
341    }
342
343    pub(super) fn representation_item_uses_raw_large_coordinates(
344        &self,
345        item: &DecodedEntity,
346        decoder: &mut EntityDecoder,
347    ) -> bool {
348        let first_vertex = match item.ifc_type {
349            IfcType::IfcFacetedBrep | IfcType::IfcFacetedBrepWithVoids => {
350                self.brep_first_vertex(item, decoder)
351            }
352            IfcType::IfcTriangulatedFaceSet
353            | IfcType::IfcTriangulatedIrregularNetwork
354            | IfcType::IfcPolygonalFaceSet => self.tessellated_first_vertex(item, decoder),
355            IfcType::IfcFaceBasedSurfaceModel | IfcType::IfcShellBasedSurfaceModel => {
356                let Some(shells_attr) = item.get(0) else {
357                    return false;
358                };
359                let Some(shells) = shells_attr.as_list() else {
360                    return false;
361                };
362                let Some(shell_ref) = shells.first() else {
363                    return false;
364                };
365                let Some(shell_id) = shell_ref.as_entity_ref() else {
366                    return false;
367                };
368                match decoder.decode_by_id(shell_id) {
369                    Ok(shell) => self.shell_first_vertex(&shell, decoder),
370                    Err(_) => None,
371                }
372            }
373            _ => None,
374        };
375
376        first_vertex
377            .map(|point| self.raw_coordinate_is_large(point))
378            .unwrap_or(false)
379    }
380
381    /// Detect RTC offset by scanning the file for building elements.
382    /// Used by synchronous parse paths.
383    pub fn detect_rtc_offset_from_first_element<T>(
384        &self,
385        content: &T,
386        decoder: &mut EntityDecoder,
387    ) -> (f64, f64, f64)
388    where
389        T: AsRef<[u8]> + ?Sized,
390    {
391        let content = content.as_ref();
392        use ifc_lite_core::EntityScanner;
393
394        let mut scanner = EntityScanner::new(content);
395        let mut translations: Vec<(f64, f64, f64)> = Vec::new();
396        const MAX_SAMPLES: usize = 50;
397
398        while let Some((_id, type_name, start, end)) = scanner.next_entity() {
399            if translations.len() >= MAX_SAMPLES {
400                break;
401            }
402            // Use the canonical has_geometry_by_name check from the schema
403            // instead of a hardcoded list — any entity class with geometry
404            // is a valid candidate for RTC offset sampling.
405            if !has_geometry_by_name(type_name) {
406                continue;
407            }
408            if let Ok(entity) = decoder.decode_at(start, end) {
409                if let Some(t) = self.sample_element_translation(&entity, decoder) {
410                    translations.push(t);
411                }
412            }
413        }
414
415        Self::rtc_offset_from_translations(&translations)
416    }
417
418    /// Detect RTC offset using pre-collected geometry jobs (avoids re-scanning the file).
419    /// Returns `None` when no usable translation samples were found, allowing
420    /// callers to distinguish "no shift needed" from "detection had no data".
421    pub fn detect_rtc_offset_from_jobs(
422        &self,
423        jobs: &[(u32, usize, usize, IfcType)],
424        decoder: &mut EntityDecoder,
425    ) -> Option<(f64, f64, f64)> {
426        const MAX_SAMPLES: usize = 50;
427        // Cap on USABLE samples, not raw jobs: `take` follows `filter_map` so
428        // elements that abstain (origin-placed curve/axis-only reps such as
429        // IfcAlignmentSegment, which return None) do not consume the sample
430        // budget. Otherwise a file that emits 50+ alignment segments before its
431        // real large-coordinate solids would fill the window with abstentions,
432        // sample zero positions, and miss the re-basing the solids need.
433        // Matches `detect_rtc_offset_from_first_element`, which likewise counts
434        // pushed samples rather than scanned entities.
435        let translations: Vec<(f64, f64, f64)> = jobs
436            .iter()
437            .filter_map(|&(id, start, end, _)| {
438                let entity = decoder.decode_at_with_id(id, start, end).ok()?;
439                self.sample_element_translation(&entity, decoder)
440            })
441            .take(MAX_SAMPLES)
442            .collect();
443
444        if translations.is_empty() {
445            return None;
446        }
447        Some(Self::rtc_offset_from_translations(&translations))
448    }
449
450    /// Detect the RTC offset from sampled jobs, falling back to a full-file
451    /// placement-bounds scan when no usable translation samples were found.
452    ///
453    /// Single shared entry point for the server processing path and the wasm
454    /// prepasses so both sides make the identical needs-shift decision: a
455    /// model whose sampled placements fail to decode while raw geometry
456    /// carries >10 km coordinates must be re-based identically everywhere
457    /// (previously the wasm prepasses silently fell back to (0,0,0) and the
458    /// browser rendered f32 vertex jitter that the server never saw).
459    pub fn detect_rtc_offset_with_fallback(
460        &self,
461        jobs: &[(u32, usize, usize, IfcType)],
462        decoder: &mut EntityDecoder,
463        content: &[u8],
464    ) -> (f64, f64, f64) {
465        match self.detect_rtc_offset_from_jobs(jobs, decoder) {
466            Some(offset) => offset,
467            None => ifc_lite_core::scan_placement_bounds(content).rtc_offset(),
468        }
469    }
470}