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}