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}