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