Skip to main content

ifc_lite_core/
georef.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//! IFC Georeferencing Support
6//!
7//! Handles IfcMapConversion and IfcProjectedCRS for coordinate transformations.
8//! Supports both IFC4 native entities and IFC2X3 ePSet_MapConversion fallback.
9
10use crate::decoder::EntityDecoder;
11use crate::error::Result;
12use crate::generated::IfcType;
13use crate::schema_gen::{AttributeValue, DecodedEntity};
14
15/// Read an `IfcPropertySingleValue.NominalValue` (index 2) as a string,
16/// unwrapping the typed-value wrapper `IFCLABEL('…')` / `IFCIDENTIFIER('…')`
17/// (parsed as a `List([type-name, value])`) that plain `get_string` doesn't
18/// see through. Property values in the IFC2x3 ePSets are always typed, so
19/// without this the CRS `Name`/`TargetCRS` labels came back empty.
20fn pset_value_string(prop: &DecodedEntity) -> Option<String> {
21    match prop.get(2)? {
22        AttributeValue::String(s) => Some(s.clone()),
23        AttributeValue::List(items) => match (items.first(), items.get(1)) {
24            (Some(AttributeValue::String(_)), Some(AttributeValue::String(v))) => Some(v.clone()),
25            _ => None,
26        },
27        _ => None,
28    }
29}
30
31/// Map an IFC unit label (e.g. "MILLIMETRE", "FOOT") to its metre scale.
32/// Mirrors the TS parser's `inferMapUnitScaleFromLabel` and the viewer's
33/// `inferMapUnitScale` so an ePSet_ProjectedCRS.MapUnit yields the same scale
34/// the native IfcProjectedCRS path resolves from the unit entity. Returns
35/// `None` for an absent/unknown unit (the ePSet convention then defers to the
36/// project length unit downstream).
37fn infer_map_unit_scale(label: &str) -> Option<f64> {
38    let n = label.to_uppercase();
39    if n.contains("US") && (n.contains("SURVEY") || n.contains("FTUS")) {
40        return Some(0.3048006096);
41    }
42    if n.contains("FOOT") || n.contains("FEET") {
43        return Some(0.3048);
44    }
45    if n.contains("MILLI") {
46        return Some(0.001);
47    }
48    if n.contains("CENTI") {
49        return Some(0.01);
50    }
51    if n.contains("DECI") {
52        return Some(0.1);
53    }
54    if n.contains("KILO") {
55        return Some(1000.0);
56    }
57    if n.contains("METRE") || n.contains("METER") {
58        return Some(1.0);
59    }
60    None
61}
62
63/// Where the georeferencing data was authored in the file.
64///
65/// Single discriminator shared (string-for-string) with the TS parser's
66/// `GeoreferenceInfo.source`, so server consumers and browser consumers see
67/// the same provenance for the same model.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum GeoRefSource {
70    /// IFC4 `IfcMapConversion` (+ optional `IfcProjectedCRS`).
71    MapConversion,
72    /// IFC2x3 `ePSet_MapConversion` property-set fallback.
73    EPSetMapConversion,
74    /// Legacy `IfcSite.RefLatitude`/`RefLongitude` (WGS84 degrees).
75    SiteLocation,
76}
77
78impl GeoRefSource {
79    /// Stable wire label (matches the TS parser's `source` union).
80    pub fn label(self) -> &'static str {
81        match self {
82            Self::MapConversion => "mapConversion",
83            Self::EPSetMapConversion => "ePSetMapConversion",
84            Self::SiteLocation => "siteLocation",
85        }
86    }
87}
88
89/// Georeferencing information extracted from IFC model
90#[derive(Debug, Clone)]
91pub struct GeoReference {
92    /// CRS name (e.g., "EPSG:32632")
93    pub crs_name: Option<String>,
94    /// CRS description from `IfcProjectedCRS.Description`.
95    pub crs_description: Option<String>,
96    /// Geodetic datum (e.g., "WGS84")
97    pub geodetic_datum: Option<String>,
98    /// Vertical datum (e.g., "NAVD88")
99    pub vertical_datum: Option<String>,
100    /// Map projection (e.g., "UTM Zone 32N")
101    pub map_projection: Option<String>,
102    /// Map zone (e.g., "32N") from `IfcProjectedCRS.MapZone`.
103    pub map_zone: Option<String>,
104    /// Map unit name resolved from `IfcProjectedCRS.MapUnit`
105    /// (e.g. "METRE", "MILLIMETRE"). `None` when no MapUnit is authored —
106    /// per spec the project length unit then applies.
107    pub map_unit: Option<String>,
108    /// Scale factor converting MapConversion values to metres, derived from
109    /// `MapUnit` (0.001 for millimetres). `None` when no MapUnit is authored.
110    pub map_unit_scale: Option<f64>,
111    /// Where the data was authored (`IfcMapConversion`, ePSet fallback, or
112    /// legacy `IfcSite` lat/long).
113    pub source: GeoRefSource,
114    /// False easting (X offset to map CRS)
115    pub eastings: f64,
116    /// False northing (Y offset to map CRS)
117    pub northings: f64,
118    /// Orthogonal height (Z offset)
119    pub orthogonal_height: f64,
120    /// X-axis abscissa (cos of rotation angle)
121    pub x_axis_abscissa: f64,
122    /// X-axis ordinate (sin of rotation angle)
123    pub x_axis_ordinate: f64,
124    /// Scale factor (default 1.0)
125    pub scale: f64,
126}
127
128impl Default for GeoReference {
129    fn default() -> Self {
130        Self {
131            crs_name: None,
132            crs_description: None,
133            geodetic_datum: None,
134            vertical_datum: None,
135            map_projection: None,
136            map_zone: None,
137            map_unit: None,
138            map_unit_scale: None,
139            source: GeoRefSource::MapConversion,
140            eastings: 0.0,
141            northings: 0.0,
142            orthogonal_height: 0.0,
143            x_axis_abscissa: 1.0, // No rotation (cos(0) = 1)
144            x_axis_ordinate: 0.0, // No rotation (sin(0) = 0)
145            scale: 1.0,
146        }
147    }
148}
149
150impl GeoReference {
151    /// Create new georeferencing info with defaults
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Check if georeferencing is present
157    #[inline]
158    pub fn has_georef(&self) -> bool {
159        self.crs_name.is_some()
160            || self.eastings != 0.0
161            || self.northings != 0.0
162            || self.orthogonal_height != 0.0
163    }
164
165    /// Get rotation angle in radians
166    #[inline]
167    pub fn rotation(&self) -> f64 {
168        self.x_axis_ordinate.atan2(self.x_axis_abscissa)
169    }
170
171    /// Normalize the X-axis direction to a unit vector.
172    ///
173    /// `IfcMapConversion.XAxisAbscissa/Ordinate` form a DIRECTION — files may
174    /// author non-unit components. `local_to_map`/`to_matrix` use them
175    /// directly as cos/sin, so without normalization those disagreed with
176    /// [`rotation`](Self::rotation) (which `atan2`-normalizes) within one
177    /// payload, and with the TS parser's matrix (alignment audit). Called at
178    /// parse time by every extraction path.
179    fn normalize_axis(&mut self) {
180        let len = self.x_axis_abscissa.hypot(self.x_axis_ordinate);
181        if len > f64::EPSILON && (len - 1.0).abs() > f64::EPSILON {
182            self.x_axis_abscissa /= len;
183            self.x_axis_ordinate /= len;
184        }
185    }
186
187    /// Transform local coordinates to map coordinates
188    ///
189    /// Per IFC4x3 `IfcMapConversion`: "a scaling of the three axes (x,y,z),
190    /// by the same Scale, followed by an anti-clockwise rotation about the
191    /// z-axis [...] and then a translation in (x,y,z) of Eastings,
192    /// Northings, OrthogonalHeight" — note the Scale applies to z as well
193    /// ("one scale is applied equally to x, y and z, to convert units").
194    #[inline]
195    pub fn local_to_map(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
196        let cos_r = self.x_axis_abscissa;
197        let sin_r = self.x_axis_ordinate;
198        let s = self.scale;
199
200        let e = s * (cos_r * x - sin_r * y) + self.eastings;
201        let n = s * (sin_r * x + cos_r * y) + self.northings;
202        let h = s * z + self.orthogonal_height;
203
204        (e, n, h)
205    }
206
207    /// Transform map coordinates to local coordinates
208    #[inline]
209    pub fn map_to_local(&self, e: f64, n: f64, h: f64) -> (f64, f64, f64) {
210        let cos_r = self.x_axis_abscissa;
211        let sin_r = self.x_axis_ordinate;
212        // Guard against division by zero
213        let inv_scale = if self.scale.abs() < f64::EPSILON {
214            1.0
215        } else {
216            1.0 / self.scale
217        };
218
219        let dx = e - self.eastings;
220        let dy = n - self.northings;
221
222        // Inverse rotation: transpose of rotation matrix
223        let x = inv_scale * (cos_r * dx + sin_r * dy);
224        let y = inv_scale * (-sin_r * dx + cos_r * dy);
225        // Scale applies to z too (IfcMapConversion scales all three axes).
226        let z = inv_scale * (h - self.orthogonal_height);
227
228        (x, y, z)
229    }
230
231    /// Get 4x4 transformation matrix (column-major for OpenGL/WebGL)
232    pub fn to_matrix(&self) -> [f64; 16] {
233        let cos_r = self.x_axis_abscissa;
234        let sin_r = self.x_axis_ordinate;
235        let s = self.scale;
236
237        // Column-major 4x4 matrix
238        [
239            s * cos_r,
240            s * sin_r,
241            0.0,
242            0.0,
243            -s * sin_r,
244            s * cos_r,
245            0.0,
246            0.0,
247            0.0,
248            0.0,
249            // Scale applies uniformly to x, y AND z (IfcMapConversion).
250            s,
251            0.0,
252            self.eastings,
253            self.northings,
254            self.orthogonal_height,
255            1.0,
256        ]
257    }
258}
259
260/// Extract georeferencing from IFC content
261pub struct GeoRefExtractor;
262
263impl GeoRefExtractor {
264    /// Extract georeferencing from decoder
265    ///
266    /// Precedence (identical to the TS parser): `IfcMapConversion` →
267    /// `ePSet_MapConversion` (IFC2x3) → legacy `IfcSite` lat/long.
268    pub fn extract(
269        decoder: &mut EntityDecoder,
270        entity_types: &[(u32, IfcType)],
271    ) -> Result<Option<GeoReference>> {
272        // Find IfcMapConversion and IfcProjectedCRS entities. FIRST one wins
273        // (same pick as the TS parser, which reads `mapConversionIds[0]`) —
274        // last-wins silently flipped the served conversion on files with
275        // several authored conversions (alignment audit).
276        let mut map_conversion_id: Option<u32> = None;
277        let mut projected_crs_id: Option<u32> = None;
278
279        for (id, ifc_type) in entity_types {
280            match ifc_type {
281                IfcType::IfcMapConversion => {
282                    if map_conversion_id.is_none() {
283                        map_conversion_id = Some(*id);
284                    }
285                }
286                IfcType::IfcProjectedCRS => {
287                    if projected_crs_id.is_none() {
288                        projected_crs_id = Some(*id);
289                    }
290                }
291                _ => {}
292            }
293        }
294
295        // If no map conversion, try IFC2X3 property set fallback, then the
296        // legacy IfcSite lat/long fallback (TS parity).
297        if map_conversion_id.is_none() {
298            if let Some(georef) = Self::extract_from_pset(decoder, entity_types)? {
299                return Ok(Some(georef));
300            }
301            return Self::extract_from_site(decoder, entity_types);
302        }
303
304        let mut georef = GeoReference::new();
305        georef.source = GeoRefSource::MapConversion;
306
307        // Parse IfcMapConversion
308        // Attributes: SourceCRS, TargetCRS, Eastings, Northings, OrthogonalHeight,
309        //             XAxisAbscissa, XAxisOrdinate, Scale
310        if let Some(id) = map_conversion_id {
311            let entity = decoder.decode_by_id(id)?;
312            Self::parse_map_conversion(&entity, &mut georef);
313        }
314
315        // Parse IfcProjectedCRS
316        // Attributes: Name, Description, GeodeticDatum, VerticalDatum,
317        //             MapProjection, MapZone, MapUnit
318        if let Some(id) = projected_crs_id {
319            let entity = decoder.decode_by_id(id)?;
320            Self::parse_projected_crs(&entity, decoder, &mut georef);
321        }
322
323        georef.normalize_axis();
324
325        if georef.has_georef() {
326            Ok(Some(georef))
327        } else {
328            Ok(None)
329        }
330    }
331
332    /// Parse IfcMapConversion entity
333    fn parse_map_conversion(entity: &DecodedEntity, georef: &mut GeoReference) {
334        // Index 2: Eastings
335        if let Some(e) = entity.get_float(2) {
336            georef.eastings = e;
337        }
338        // Index 3: Northings
339        if let Some(n) = entity.get_float(3) {
340            georef.northings = n;
341        }
342        // Index 4: OrthogonalHeight
343        if let Some(h) = entity.get_float(4) {
344            georef.orthogonal_height = h;
345        }
346        // Index 5: XAxisAbscissa (optional)
347        if let Some(xa) = entity.get_float(5) {
348            georef.x_axis_abscissa = xa;
349        }
350        // Index 6: XAxisOrdinate (optional)
351        if let Some(xo) = entity.get_float(6) {
352            georef.x_axis_ordinate = xo;
353        }
354        // Index 7: Scale (optional, default 1.0)
355        if let Some(s) = entity.get_float(7) {
356            georef.scale = s;
357        }
358    }
359
360    /// Parse IfcProjectedCRS entity
361    fn parse_projected_crs(
362        entity: &DecodedEntity,
363        decoder: &mut EntityDecoder,
364        georef: &mut GeoReference,
365    ) {
366        // Index 0: Name (e.g., "EPSG:32632")
367        if let Some(name) = entity.get_string(0) {
368            georef.crs_name = Some(name.to_string());
369        }
370        // Index 1: Description
371        if let Some(desc) = entity.get_string(1) {
372            georef.crs_description = Some(desc.to_string());
373        }
374        // Index 2: GeodeticDatum
375        if let Some(datum) = entity.get_string(2) {
376            georef.geodetic_datum = Some(datum.to_string());
377        }
378        // Index 3: VerticalDatum
379        if let Some(vdatum) = entity.get_string(3) {
380            georef.vertical_datum = Some(vdatum.to_string());
381        }
382        // Index 4: MapProjection
383        if let Some(proj) = entity.get_string(4) {
384            georef.map_projection = Some(proj.to_string());
385        }
386        // Index 5: MapZone
387        if let Some(zone) = entity.get_string(5) {
388            georef.map_zone = Some(zone.to_string());
389        }
390        // Index 6: MapUnit (IfcNamedUnit ref). Mirrors the TS parser: when a
391        // MapUnit IS authored, default to METRE/1.0 and refine from the
392        // IFCSIUNIT prefix — a millimetre-based conversion must scale by
393        // 0.001 on the server exactly like in the browser. When absent, the
394        // project length unit applies (spec default) and both stay `None`.
395        if let Some(unit_ref) = entity.get_ref(6) {
396            let mut unit_name = "METRE".to_string();
397            let mut unit_scale = 1.0_f64;
398            if let Ok(unit_entity) = decoder.decode_by_id(unit_ref) {
399                if unit_entity.ifc_type == IfcType::IfcSIUnit {
400                    // IFCSIUNIT: [0] Dimensions, [1] UnitType, [2] Prefix, [3] Name
401                    if let Some(prefix_attr) = unit_entity.get(2) {
402                        if !prefix_attr.is_null() {
403                            if let Some(prefix) = prefix_attr.as_enum() {
404                                let multiplier = crate::units::get_si_prefix_multiplier(prefix);
405                                if (multiplier - 1.0).abs() > f64::EPSILON {
406                                    unit_scale = multiplier;
407                                    let prefix_upper = prefix.to_ascii_uppercase();
408                                    unit_name = if prefix_upper == "MILLI" {
409                                        "MILLIMETRE".to_string()
410                                    } else {
411                                        format!("{prefix_upper}METRE")
412                                    };
413                                }
414                            }
415                        }
416                    }
417                }
418            }
419            georef.map_unit = Some(unit_name);
420            georef.map_unit_scale = Some(unit_scale);
421        }
422    }
423
424    /// Extract from IFC2X3 property sets (fallback)
425    fn extract_from_pset(
426        decoder: &mut EntityDecoder,
427        entity_types: &[(u32, IfcType)],
428    ) -> Result<Option<GeoReference>> {
429        // Locate the ePSet_MapConversion (required) and ePSet_ProjectedCRS
430        // (optional) property sets. The match is case-insensitive: the
431        // buildingSMART geo-referencing guide spells these `ePSet_…` (capital
432        // S), but real authoring tools (e.g. the `ifc-georeferencer`
433        // post-processor) write `ePset_…` (lowercase), and an exact match
434        // silently dropped those models to the legacy IfcSite/EPSG:4326
435        // fallback so they displayed the wrong CRS. IfcPropertySet.Name is
436        // attribute 2 (attribute 0 is GlobalId); reading attribute 0 here
437        // never matched the ePSet at all (issue #900 review).
438        let mut map_conversion_pset: Option<u32> = None;
439        let mut projected_crs_pset: Option<u32> = None;
440        for (id, ifc_type) in entity_types {
441            if *ifc_type != IfcType::IfcPropertySet {
442                continue;
443            }
444            let entity = decoder.decode_by_id(*id)?;
445            if let Some(name) = entity.get_string(2) {
446                let lower = name.to_ascii_lowercase();
447                if lower == "epset_mapconversion" && map_conversion_pset.is_none() {
448                    map_conversion_pset = Some(*id);
449                } else if lower == "epset_projectedcrs" && projected_crs_pset.is_none() {
450                    projected_crs_pset = Some(*id);
451                }
452            }
453        }
454
455        let Some(mc_id) = map_conversion_pset else {
456            return Ok(None);
457        };
458        let mc_entity = decoder.decode_by_id(mc_id)?;
459        Self::parse_pset_map_conversion(decoder, &mc_entity, projected_crs_pset)
460    }
461
462    /// Parse ePSet_MapConversion property set, plus the EPSG `Name` from an
463    /// optional ePSet_ProjectedCRS set (falling back to the MapConversion's
464    /// own `TargetCRS` label). Without the CRS name the EPSG code authored in
465    /// the file was never surfaced on the IFC2x3 path.
466    fn parse_pset_map_conversion(
467        decoder: &mut EntityDecoder,
468        pset: &DecodedEntity,
469        projected_crs_pset: Option<u32>,
470    ) -> Result<Option<GeoReference>> {
471        let mut georef = GeoReference::new();
472        georef.source = GeoRefSource::EPSetMapConversion;
473        let mut target_crs: Option<String> = None;
474
475        // HasProperties is typically at index 4
476        if let Some(props_list) = pset.get_list(4) {
477            for prop_attr in props_list {
478                if let Some(prop_id) = prop_attr.as_entity_ref() {
479                    let prop = decoder.decode_by_id(prop_id)?;
480                    // IfcPropertySingleValue: Name (0), Description (1), NominalValue (2)
481                    if let Some(name) = prop.get_string(0) {
482                        let value = prop.get_float(2);
483                        match name {
484                            "Eastings" => {
485                                if let Some(v) = value {
486                                    georef.eastings = v;
487                                }
488                            }
489                            "Northings" => {
490                                if let Some(v) = value {
491                                    georef.northings = v;
492                                }
493                            }
494                            "OrthogonalHeight" => {
495                                if let Some(v) = value {
496                                    georef.orthogonal_height = v;
497                                }
498                            }
499                            "XAxisAbscissa" => {
500                                if let Some(v) = value {
501                                    georef.x_axis_abscissa = v;
502                                }
503                            }
504                            "XAxisOrdinate" => {
505                                if let Some(v) = value {
506                                    georef.x_axis_ordinate = v;
507                                }
508                            }
509                            "Scale" => {
510                                if let Some(v) = value {
511                                    georef.scale = v;
512                                }
513                            }
514                            "TargetCRS" => {
515                                if let Some(v) = pset_value_string(&prop) {
516                                    target_crs = Some(v);
517                                }
518                            }
519                            _ => {}
520                        }
521                    }
522                }
523            }
524        }
525
526        // Pull the CRS name + datum fields from ePSet_ProjectedCRS if present.
527        if let Some(crs_id) = projected_crs_pset {
528            let crs_entity = decoder.decode_by_id(crs_id)?;
529            Self::parse_pset_projected_crs(decoder, &crs_entity, &mut georef);
530        }
531        // ePSet_ProjectedCRS.Name wins, but an empty/whitespace-only name must
532        // not block the TargetCRS fallback — the viewer gate requires a truthy
533        // CRS name, so leaving `crs_name = Some("")` would silently drop the
534        // model to the IfcSite/EPSG:4326 fallback. Treat blank as missing.
535        let crs_name_is_blank = georef
536            .crs_name
537            .as_ref()
538            .is_none_or(|name| name.trim().is_empty());
539        if crs_name_is_blank {
540            georef.crs_name = target_crs.filter(|name| !name.trim().is_empty());
541        }
542
543        georef.normalize_axis();
544
545        if georef.has_georef() {
546            Ok(Some(georef))
547        } else {
548            Ok(None)
549        }
550    }
551
552    /// Parse an ePSet_ProjectedCRS property set into the georef's CRS fields.
553    fn parse_pset_projected_crs(
554        decoder: &mut EntityDecoder,
555        pset: &DecodedEntity,
556        georef: &mut GeoReference,
557    ) {
558        let Some(props_list) = pset.get_list(4) else {
559            return;
560        };
561        for prop_attr in props_list {
562            let Some(prop_id) = prop_attr.as_entity_ref() else {
563                continue;
564            };
565            let Ok(prop) = decoder.decode_by_id(prop_id) else {
566                continue;
567            };
568            let Some(name) = prop.get_string(0) else {
569                continue;
570            };
571            let value = pset_value_string(&prop);
572            match name {
573                "Name" => georef.crs_name = value,
574                "Description" => georef.crs_description = value,
575                "GeodeticDatum" => georef.geodetic_datum = value,
576                "VerticalDatum" => georef.vertical_datum = value,
577                "MapProjection" => georef.map_projection = value,
578                "MapZone" => georef.map_zone = value,
579                "MapUnit" => {
580                    // Parity with the native IfcProjectedCRS path: derive the
581                    // metre scale from the unit label so consumers don't default
582                    // explicit non-metre ePSet offsets to metres.
583                    georef.map_unit_scale = value.as_deref().and_then(infer_map_unit_scale);
584                    georef.map_unit = value;
585                }
586                _ => {}
587            }
588        }
589    }
590
591    /// Legacy `IfcSite.RefLatitude`/`RefLongitude` fallback (TS parity).
592    ///
593    /// Mirrors the TS parser's `extractLegacySiteGeoreference`: WGS84
594    /// degrees land in eastings (longitude) / northings (latitude) with the
595    /// site `RefElevation` as orthogonal height, under an `EPSG:4326`
596    /// pseudo-CRS — so `hasGeoreference`/`has_georef` agree between the
597    /// browser and the server for site-only models.
598    fn extract_from_site(
599        decoder: &mut EntityDecoder,
600        entity_types: &[(u32, IfcType)],
601    ) -> Result<Option<GeoReference>> {
602        for (id, ifc_type) in entity_types {
603            if *ifc_type != IfcType::IfcSite {
604                continue;
605            }
606            let site = decoder.decode_by_id(*id)?;
607            // IfcSite: RefLatitude (9), RefLongitude (10), RefElevation (11).
608            let latitude = Self::compound_plane_angle_to_degrees(&site, 9);
609            let longitude = Self::compound_plane_angle_to_degrees(&site, 10);
610            let (Some(latitude), Some(longitude)) = (latitude, longitude) else {
611                continue;
612            };
613            let elevation = site.get_float(11).unwrap_or(0.0);
614
615            let mut georef = GeoReference::new();
616            georef.source = GeoRefSource::SiteLocation;
617            georef.crs_name = Some("EPSG:4326".to_string());
618            georef.crs_description = Some("Legacy IfcSite geolocation".to_string());
619            georef.geodetic_datum = Some("WGS84".to_string());
620            georef.map_projection = Some("Geographic".to_string());
621            georef.map_unit = Some("DEGREE".to_string());
622            georef.eastings = longitude;
623            georef.northings = latitude;
624            georef.orthogonal_height = elevation;
625            return Ok(Some(georef));
626        }
627        Ok(None)
628    }
629
630    /// Convert an `IfcCompoundPlaneAngleMeasure` attribute (list of 3-4
631    /// integers: degrees, minutes, seconds, optional millionth-seconds) to
632    /// decimal degrees. Same sign handling as the TS parser: any negative
633    /// component makes the whole angle negative.
634    fn compound_plane_angle_to_degrees(entity: &DecodedEntity, index: usize) -> Option<f64> {
635        let list = entity.get_list(index)?;
636        let mut numbers = Vec::with_capacity(4);
637        for value in list {
638            if let Some(v) = value.as_float() {
639                numbers.push(v);
640            }
641        }
642        if numbers.len() < 3 {
643            return None;
644        }
645        let millionths = numbers.get(3).copied().unwrap_or(0.0);
646        let sign = if numbers[0] < 0.0 || numbers[1] < 0.0 || numbers[2] < 0.0 || millionths < 0.0
647        {
648            -1.0
649        } else {
650            1.0
651        };
652        let degrees = numbers[0].abs();
653        let minutes = numbers[1].abs();
654        let seconds = numbers[2].abs();
655        let millionths = millionths.abs();
656        Some(sign * (degrees + minutes / 60.0 + (seconds + millionths / 1_000_000.0) / 3600.0))
657    }
658}
659
660/// RTC (Relative-To-Center) coordinate handler for large coordinates
661#[derive(Debug, Clone, Default)]
662pub struct RtcOffset {
663    /// Center offset (subtracted from all coordinates)
664    pub x: f64,
665    pub y: f64,
666    pub z: f64,
667}
668
669impl RtcOffset {
670    /// Create from centroid of positions
671    #[inline]
672    pub fn from_positions(positions: &[f32]) -> Self {
673        if positions.is_empty() {
674            return Self::default();
675        }
676
677        let count = positions.len() / 3;
678        let mut sum = (0.0f64, 0.0f64, 0.0f64);
679
680        for chunk in positions.chunks_exact(3) {
681            sum.0 += chunk[0] as f64;
682            sum.1 += chunk[1] as f64;
683            sum.2 += chunk[2] as f64;
684        }
685
686        Self {
687            x: sum.0 / count as f64,
688            y: sum.1 / count as f64,
689            z: sum.2 / count as f64,
690        }
691    }
692
693    /// Check if offset is significant (>10km from origin)
694    #[inline]
695    pub fn is_significant(&self) -> bool {
696        const THRESHOLD: f64 = 10000.0; // 10km
697        self.x.abs() > THRESHOLD || self.y.abs() > THRESHOLD || self.z.abs() > THRESHOLD
698    }
699
700    /// Apply offset to positions in-place
701    #[inline]
702    pub fn apply(&self, positions: &mut [f32]) {
703        for chunk in positions.chunks_exact_mut(3) {
704            chunk[0] = (chunk[0] as f64 - self.x) as f32;
705            chunk[1] = (chunk[1] as f64 - self.y) as f32;
706            chunk[2] = (chunk[2] as f64 - self.z) as f32;
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_georef_local_to_map() {
717        let mut georef = GeoReference::new();
718        georef.eastings = 500000.0;
719        georef.northings = 5000000.0;
720        georef.orthogonal_height = 100.0;
721
722        let (e, n, h) = georef.local_to_map(10.0, 20.0, 5.0);
723        assert!((e - 500010.0).abs() < 1e-10);
724        assert!((n - 5000020.0).abs() < 1e-10);
725        assert!((h - 105.0).abs() < 1e-10);
726    }
727
728    #[test]
729    fn test_georef_map_to_local() {
730        let mut georef = GeoReference::new();
731        georef.eastings = 500000.0;
732        georef.northings = 5000000.0;
733        georef.orthogonal_height = 100.0;
734
735        let (x, y, z) = georef.map_to_local(500010.0, 5000020.0, 105.0);
736        assert!((x - 10.0).abs() < 1e-10);
737        assert!((y - 20.0).abs() < 1e-10);
738        assert!((z - 5.0).abs() < 1e-10);
739    }
740
741    #[test]
742    fn test_georef_with_rotation() {
743        let mut georef = GeoReference::new();
744        georef.eastings = 0.0;
745        georef.northings = 0.0;
746        // 90 degree rotation
747        georef.x_axis_abscissa = 0.0;
748        georef.x_axis_ordinate = 1.0;
749
750        let (e, n, _) = georef.local_to_map(10.0, 0.0, 0.0);
751        // After 90 degree rotation: (10, 0) -> (0, 10)
752        assert!(e.abs() < 1e-10);
753        assert!((n - 10.0).abs() < 1e-10);
754    }
755
756    #[test]
757    fn test_rtc_offset() {
758        let positions = vec![
759            500000.0f32,
760            5000000.0,
761            0.0,
762            500010.0,
763            5000010.0,
764            10.0,
765            500020.0,
766            5000020.0,
767            20.0,
768        ];
769
770        let offset = RtcOffset::from_positions(&positions);
771        assert!(offset.is_significant());
772        assert!((offset.x - 500010.0).abs() < 1.0);
773        assert!((offset.y - 5000010.0).abs() < 1.0);
774    }
775
776    #[test]
777    fn test_rtc_apply() {
778        let mut positions = vec![500000.0f32, 5000000.0, 0.0, 500010.0, 5000010.0, 10.0];
779
780        let offset = RtcOffset {
781            x: 500000.0,
782            y: 5000000.0,
783            z: 0.0,
784        };
785
786        offset.apply(&mut positions);
787
788        assert!((positions[0] - 0.0).abs() < 1e-5);
789        assert!((positions[1] - 0.0).abs() < 1e-5);
790        assert!((positions[3] - 10.0).abs() < 1e-5);
791        assert!((positions[4] - 10.0).abs() < 1e-5);
792    }
793}