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::DecodedEntity;
14
15#[path = "georef_parse.rs"]
16mod georef_parse;
17use georef_parse::{compound_angle_has_literal_negative_zero, compound_plane_angle_to_degrees};
18
19#[path = "georef_pset.rs"]
20mod georef_pset;
21
22/// Where the georeferencing data was authored in the file.
23///
24/// Single discriminator shared (string-for-string) with the TS parser's
25/// `GeoreferenceInfo.source`, so server consumers and browser consumers see
26/// the same provenance for the same model.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum GeoRefSource {
29    /// IFC4 `IfcMapConversion` (+ optional `IfcProjectedCRS`).
30    MapConversion,
31    /// IFC2x3 `ePSet_MapConversion` property-set fallback.
32    EPSetMapConversion,
33    /// Legacy `IfcSite.RefLatitude`/`RefLongitude` (WGS84 degrees).
34    SiteLocation,
35}
36
37impl GeoRefSource {
38    /// Stable wire label (matches the TS parser's `source` union).
39    pub fn label(self) -> &'static str {
40        match self {
41            Self::MapConversion => "mapConversion",
42            Self::EPSetMapConversion => "ePSetMapConversion",
43            Self::SiteLocation => "siteLocation",
44        }
45    }
46}
47
48/// Georeferencing information extracted from IFC model
49#[derive(Debug, Clone)]
50pub struct GeoReference {
51    /// CRS name (e.g., "EPSG:32632")
52    pub crs_name: Option<String>,
53    /// CRS description from `IfcProjectedCRS.Description`.
54    pub crs_description: Option<String>,
55    /// Geodetic datum (e.g., "WGS84")
56    pub geodetic_datum: Option<String>,
57    /// Vertical datum (e.g., "NAVD88")
58    pub vertical_datum: Option<String>,
59    /// Map projection (e.g., "UTM Zone 32N")
60    pub map_projection: Option<String>,
61    /// Map zone (e.g., "32N") from `IfcProjectedCRS.MapZone`.
62    pub map_zone: Option<String>,
63    /// Map unit name resolved from `IfcProjectedCRS.MapUnit`
64    /// (e.g. "METRE", "MILLIMETRE"). `None` when no MapUnit is authored —
65    /// per spec the project length unit then applies.
66    pub map_unit: Option<String>,
67    /// Scale factor converting MapConversion values to metres, derived from
68    /// `MapUnit` (0.001 for millimetres). `None` when no MapUnit is authored.
69    pub map_unit_scale: Option<f64>,
70    /// Where the data was authored (`IfcMapConversion`, ePSet fallback, or
71    /// legacy `IfcSite` lat/long). `None` when only a named
72    /// `IfcProjectedCRS` was found: no conversion was authored, or the one
73    /// authored was refused, so nothing placed the model. The TS twin leaves
74    /// `source` unset in the same case.
75    pub source: Option<GeoRefSource>,
76    /// False easting (X offset to map CRS)
77    pub eastings: f64,
78    /// False northing (Y offset to map CRS)
79    pub northings: f64,
80    /// Orthogonal height (Z offset)
81    pub orthogonal_height: f64,
82    /// X-axis abscissa (cos of rotation angle)
83    pub x_axis_abscissa: f64,
84    /// X-axis ordinate (sin of rotation angle)
85    pub x_axis_ordinate: f64,
86    /// Scale factor (default 1.0)
87    pub scale: f64,
88    /// Per-axis factors from `IfcMapConversionScaled.FactorX/Y/Z` (IFC4X3),
89    /// each 1.0 when absent. Per the schema they scale coordinates, not
90    /// units (`scale` converts units): each multiplies its own local axis
91    /// before the rotation, on top of `scale`. Ignoring them applied a
92    /// conversion authored with factors of 0.3048 at 3.28x.
93    pub factor_x: f64,
94    pub factor_y: f64,
95    pub factor_z: f64,
96    /// True once an `IfcMapConversion` (or its `Scaled` subtype) was parsed.
97    /// Presence is then reported even for a conversion whose translations are
98    /// all zero (a rotation- or scale-only conversion), matching the TS
99    /// twin, which sets `hasGeoreference` whenever a conversion parsed.
100    pub has_map_conversion: bool,
101}
102
103impl Default for GeoReference {
104    fn default() -> Self {
105        Self {
106            crs_name: None,
107            crs_description: None,
108            geodetic_datum: None,
109            vertical_datum: None,
110            map_projection: None,
111            map_zone: None,
112            map_unit: None,
113            map_unit_scale: None,
114            source: None,
115            eastings: 0.0,
116            northings: 0.0,
117            orthogonal_height: 0.0,
118            x_axis_abscissa: 1.0, // No rotation (cos(0) = 1)
119            x_axis_ordinate: 0.0, // No rotation (sin(0) = 0)
120            scale: 1.0,
121            factor_x: 1.0,
122            factor_y: 1.0,
123            factor_z: 1.0,
124            has_map_conversion: false,
125        }
126    }
127}
128
129impl GeoReference {
130    /// Create new georeferencing info with defaults
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    /// Check if georeferencing is present.
136    ///
137    /// A parsed `IfcMapConversion` is presence on its own: the value test
138    /// alone dropped a rotation-only conversion (zero offsets, 30 degrees to
139    /// grid north) as "no georeferencing" while the browser reported one.
140    #[inline]
141    pub fn has_georef(&self) -> bool {
142        self.has_map_conversion
143            || self.crs_name.is_some()
144            || self.eastings != 0.0
145            || self.northings != 0.0
146            || self.orthogonal_height != 0.0
147    }
148
149    /// Get rotation angle in radians
150    #[inline]
151    pub fn rotation(&self) -> f64 {
152        self.x_axis_ordinate.atan2(self.x_axis_abscissa)
153    }
154
155    /// Normalize the X-axis direction to a unit vector, and refuse the
156    /// transform components that have no usable value.
157    ///
158    /// `IfcMapConversion.XAxisAbscissa/Ordinate` form a DIRECTION — files may
159    /// author non-unit components. `local_to_map`/`to_matrix` use them
160    /// directly as cos/sin, so without normalization those disagreed with
161    /// [`rotation`](Self::rotation) (which `atan2`-normalizes) within one
162    /// payload, and with the TS parser's matrix (alignment audit). Called at
163    /// parse time by every extraction path.
164    ///
165    /// A direction with no usable length (both components authored `0.`, or
166    /// a non-finite component reaching here from the ePSet path) is reset to
167    /// the identity `(1, 0)`, the angle the TS twin's `atan2(0, 0)` gives.
168    /// Passed through, `(0, 0)` collapsed every map coordinate to
169    /// `(Eastings, Northings)` and an infinite length divided into NaN. An
170    /// `IfcMapConversion` with a non-finite component never gets here:
171    /// `parse_map_conversion` refuses it whole.
172    ///
173    /// `Scale` and `FactorX/Y/Z` follow the same rule: zero collapses every
174    /// coordinate onto the translation and a non-finite value poisons it, so
175    /// either resets to 1.0 (the TS twin reads a zero `Scale` as 1.0 through
176    /// `scale || 1.0`).
177    fn sanitize_transform(&mut self) {
178        let len = self.x_axis_abscissa.hypot(self.x_axis_ordinate);
179        if !(len.is_finite() && len > f64::EPSILON) {
180            self.x_axis_abscissa = 1.0;
181            self.x_axis_ordinate = 0.0;
182        } else if (len - 1.0).abs() > f64::EPSILON {
183            self.x_axis_abscissa /= len;
184            self.x_axis_ordinate /= len;
185        }
186        for s in [&mut self.scale, &mut self.factor_x, &mut self.factor_y, &mut self.factor_z] {
187            if !(s.is_finite() && *s != 0.0) {
188                *s = 1.0;
189            }
190        }
191    }
192
193    /// Effective per-axis scale: the uniform `Scale` times the
194    /// `IfcMapConversionScaled` factor for that axis.
195    #[inline]
196    fn axis_scales(&self) -> (f64, f64, f64) {
197        (
198            self.scale * self.factor_x,
199            self.scale * self.factor_y,
200            self.scale * self.factor_z,
201        )
202    }
203
204    /// Transform local coordinates to map coordinates
205    ///
206    /// Per IFC4x3 `IfcMapConversion`: "a scaling of the three axes (x,y,z),
207    /// by the same Scale, followed by an anti-clockwise rotation about the
208    /// z-axis [...] and then a translation in (x,y,z) of Eastings,
209    /// Northings, OrthogonalHeight" — note the Scale applies to z as well
210    /// ("one scale is applied equally to x, y and z, to convert units").
211    /// `IfcMapConversionScaled` adds a per-axis factor on top of that.
212    #[inline]
213    pub fn local_to_map(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
214        let cos_r = self.x_axis_abscissa;
215        let sin_r = self.x_axis_ordinate;
216        let (sx, sy, sz) = self.axis_scales();
217        let (x, y, z) = (sx * x, sy * y, sz * z);
218
219        let e = cos_r * x - sin_r * y + self.eastings;
220        let n = sin_r * x + cos_r * y + self.northings;
221        let h = z + self.orthogonal_height;
222
223        (e, n, h)
224    }
225
226    /// Transform map coordinates to local coordinates
227    #[inline]
228    pub fn map_to_local(&self, e: f64, n: f64, h: f64) -> (f64, f64, f64) {
229        let cos_r = self.x_axis_abscissa;
230        let sin_r = self.x_axis_ordinate;
231        // Guard against division by zero, per axis.
232        let inv = |s: f64| if s.abs() < f64::EPSILON { 1.0 } else { 1.0 / s };
233        let (sx, sy, sz) = self.axis_scales();
234
235        let dx = e - self.eastings;
236        let dy = n - self.northings;
237
238        // Inverse rotation (transpose), then undo the per-axis scale.
239        let x = inv(sx) * (cos_r * dx + sin_r * dy);
240        let y = inv(sy) * (-sin_r * dx + cos_r * dy);
241        // Scale applies to z too (IfcMapConversion scales all three axes).
242        let z = inv(sz) * (h - self.orthogonal_height);
243
244        (x, y, z)
245    }
246
247    /// Get 4x4 transformation matrix (column-major for OpenGL/WebGL)
248    pub fn to_matrix(&self) -> [f64; 16] {
249        let cos_r = self.x_axis_abscissa;
250        let sin_r = self.x_axis_ordinate;
251        let (sx, sy, sz) = self.axis_scales();
252
253        // Column-major 4x4 matrix: scale each local axis, then rotate.
254        [
255            sx * cos_r,
256            sx * sin_r,
257            0.0,
258            0.0,
259            -sy * sin_r,
260            sy * cos_r,
261            0.0,
262            0.0,
263            0.0,
264            0.0,
265            // Scale applies to z as well (IfcMapConversion scales all three axes).
266            sz,
267            0.0,
268            self.eastings,
269            self.northings,
270            self.orthogonal_height,
271            1.0,
272        ]
273    }
274}
275
276/// Extract georeferencing from IFC content
277pub struct GeoRefExtractor;
278
279/// Resolve an `IfcConversionBasedUnit`'s `ConversionFactor` to metres.
280///
281/// `IFCMEASUREWITHUNIT: [0] ValueComponent, [1] UnitComponent` — the value is
282/// expressed IN the unit component, so a prefixed SI component multiplies it
283/// (0.3048 expressed in millimetres is not 0.3048 metres). Twin of
284/// `resolveMeasureWithUnit` in packages/parser/src/georef-extractor.ts.
285fn resolve_measure_with_unit(
286    decoder: &mut EntityDecoder,
287    conversion_unit: &DecodedEntity,
288) -> Option<f64> {
289    let measure_ref = conversion_unit.get_ref(3)?;
290    let measure = decoder.decode_by_id(measure_ref).ok()?;
291
292    let value_attr = measure.get(0)?;
293    let value = value_attr
294        .as_float()
295        .or_else(|| value_attr.as_int().map(|v| v as f64))?;
296    if !value.is_finite() || value <= 0.0 {
297        return None;
298    }
299
300    let mut component_scale = 1.0_f64;
301    if let Some(component_ref) = measure.get_ref(1) {
302        if let Ok(component) = decoder.decode_by_id(component_ref) {
303            if component.ifc_type == IfcType::IfcSIUnit {
304                if let Some(prefix_attr) = component.get(2) {
305                    if !prefix_attr.is_null() {
306                        if let Some(prefix) = prefix_attr.as_enum() {
307                            component_scale = crate::units::get_si_prefix_multiplier(prefix);
308                        }
309                    }
310                }
311            }
312        }
313    }
314
315    Some(value * component_scale)
316}
317
318impl GeoRefExtractor {
319    /// Extract georeferencing from decoder
320    ///
321    /// Precedence (identical to the TS parser): `IfcMapConversion` or a named
322    /// `IfcProjectedCRS` → `ePSet_MapConversion` (IFC2x3) → legacy `IfcSite`
323    /// lat/long. A refused conversion counts as no conversion, and an
324    /// `IfcProjectedCRS` whose mandatory `Name` is unset or blank declares no
325    /// CRS, so neither holds back the fallbacks on its own.
326    pub fn extract(
327        decoder: &mut EntityDecoder,
328        entity_types: &[(u32, IfcType)],
329    ) -> Result<Option<GeoReference>> {
330        // Find IfcMapConversion and IfcProjectedCRS entities. FIRST one wins
331        // (same pick as the TS parser, which reads `mapConversionIds[0]`) —
332        // last-wins silently flipped the served conversion on files with
333        // several authored conversions (alignment audit).
334        let mut map_conversion_id: Option<u32> = None;
335        let mut projected_crs_id: Option<u32> = None;
336
337        for (id, ifc_type) in entity_types {
338            match ifc_type {
339                // The Scaled subtype by its own type too: only the processing
340                // scan relabels it as the supertype, and a caller typing it
341                // with `IfcType::from_str` lost the whole conversion.
342                IfcType::IfcMapConversion | IfcType::IfcMapConversionScaled => {
343                    if map_conversion_id.is_none() {
344                        map_conversion_id = Some(*id);
345                    }
346                }
347                IfcType::IfcProjectedCRS => {
348                    if projected_crs_id.is_none() {
349                        projected_crs_id = Some(*id);
350                    }
351                }
352                _ => {}
353            }
354        }
355
356        let mut georef = GeoReference::new();
357
358        // Parse IfcMapConversion
359        // Attributes: SourceCRS, TargetCRS, Eastings, Northings, OrthogonalHeight,
360        //             XAxisAbscissa, XAxisOrdinate, Scale
361        if let Some(id) = map_conversion_id {
362            Self::parse_map_conversion(&decoder.decode_by_id(id)?, &mut georef);
363        }
364
365        // Parse IfcProjectedCRS
366        // Attributes: Name, Description, GeodeticDatum, VerticalDatum,
367        //             MapProjection, MapZone, MapUnit
368        if let Some(id) = projected_crs_id {
369            match decoder.decode_by_id(id) {
370                Ok(entity) => Self::parse_projected_crs(&entity, decoder, &mut georef),
371                // Beside a parsed conversion the CRS's MapUnit scales every
372                // coordinate, so an undecodable one stays an error. On its own
373                // it declares nothing and the fallbacks still run.
374                Err(error) if georef.has_map_conversion => return Err(error),
375                Err(_) => {}
376            }
377        }
378
379        // Neither a parsed conversion nor a named CRS: try the IFC2X3 property
380        // set fallback, then the legacy IfcSite lat/long fallback (TS parity,
381        // #4695). A refused conversion left `georef` untouched, so it counts as
382        // no conversion here.
383        if !georef.has_georef() {
384            if let Some(georef) = Self::extract_from_pset(decoder, entity_types)? {
385                return Ok(Some(georef));
386            }
387            return Self::extract_from_site(decoder, entity_types);
388        }
389
390        georef.sanitize_transform();
391        Ok(Some(georef))
392    }
393
394    /// Parse IfcMapConversion entity (and the IFC4X3 `IfcMapConversionScaled`
395    /// subtype, whose first eight attributes have the same layout).
396    ///
397    /// Leaves `georef` untouched when Eastings through Scale (attributes
398    /// 2..=7) hold a number the double range cannot represent: the whole
399    /// conversion is refused rather than one component replaced by its
400    /// default, the rule the TS twin (`extractMapConversion`) applies.
401    fn parse_map_conversion(entity: &DecodedEntity, georef: &mut GeoReference) {
402        if (2..=10).any(|index| entity.get_float(index).is_some_and(|v| !v.is_finite())) {
403            return;
404        }
405        georef.has_map_conversion = true;
406        georef.source = Some(GeoRefSource::MapConversion);
407        // Index 2: Eastings
408        if let Some(e) = entity.get_float(2) {
409            georef.eastings = e;
410        }
411        // Index 3: Northings
412        if let Some(n) = entity.get_float(3) {
413            georef.northings = n;
414        }
415        // Index 4: OrthogonalHeight
416        if let Some(h) = entity.get_float(4) {
417            georef.orthogonal_height = h;
418        }
419        // Index 5: XAxisAbscissa (optional)
420        if let Some(xa) = entity.get_float(5) {
421            georef.x_axis_abscissa = xa;
422        }
423        // Index 6: XAxisOrdinate (optional)
424        if let Some(xo) = entity.get_float(6) {
425            georef.x_axis_ordinate = xo;
426        }
427        // Index 7: Scale (optional, default 1.0)
428        if let Some(s) = entity.get_float(7) {
429            georef.scale = s;
430        }
431        // Index 8..10: IfcMapConversionScaled.FactorX/Y/Z (absent on the
432        // plain supertype). These are the only reason the subtype exists;
433        // reading it "as its supertype" silently applied a feet-based scaled
434        // conversion unscaled. `sanitize_transform` refuses a zero or
435        // non-finite factor.
436        for (index, factor) in [
437            (8, &mut georef.factor_x),
438            (9, &mut georef.factor_y),
439            (10, &mut georef.factor_z),
440        ] {
441            if let Some(f) = entity.get_float(index) {
442                *factor = f;
443            }
444        }
445    }
446
447    /// Parse IfcProjectedCRS entity
448    fn parse_projected_crs(
449        entity: &DecodedEntity,
450        decoder: &mut EntityDecoder,
451        georef: &mut GeoReference,
452    ) {
453        // Index 0: Name (e.g., "EPSG:32632"). Blank reads as unset, the rule
454        // the ePSet path applies: the viewer gates on a non-empty CRS name,
455        // so `Some("")` would claim a georeference nothing downstream shows.
456        if let Some(name) = entity.get_string(0).filter(|name| !name.trim().is_empty()) {
457            georef.crs_name = Some(name.to_string());
458        }
459        // Index 1: Description
460        if let Some(desc) = entity.get_string(1) {
461            georef.crs_description = Some(desc.to_string());
462        }
463        // Index 2: GeodeticDatum
464        if let Some(datum) = entity.get_string(2) {
465            georef.geodetic_datum = Some(datum.to_string());
466        }
467        // Index 3: VerticalDatum
468        if let Some(vdatum) = entity.get_string(3) {
469            georef.vertical_datum = Some(vdatum.to_string());
470        }
471        // Index 4: MapProjection
472        if let Some(proj) = entity.get_string(4) {
473            georef.map_projection = Some(proj.to_string());
474        }
475        // Index 5: MapZone
476        if let Some(zone) = entity.get_string(5) {
477            georef.map_zone = Some(zone.to_string());
478        }
479        // Index 6: MapUnit (IfcNamedUnit ref). Mirrors the TS parser
480        // (packages/parser/src/georef-extractor.ts): when a MapUnit IS
481        // authored, default to METRE/1.0 and refine from the unit entity — a
482        // millimetre-based or foot-based conversion must scale the same way on
483        // the server as in the browser. When absent, the project length unit
484        // applies (spec default) and both stay `None`.
485        //
486        // IfcNamedUnit is IfcSIUnit OR IfcConversionBasedUnit, and attribute 2
487        // means something different in each: `Prefix` on the first, `Name` on
488        // the second. Reading slot 2 as a prefix unconditionally meant a
489        // foot-based MapUnit — the exact form ifc-lite's own exporter writes
490        // (packages/export/src/step-georeferencing.ts) — matched no prefix and
491        // fell through to METRE at scale 1, a silent 3.28x error on every
492        // coordinate.
493        if let Some(unit_ref) = entity.get_ref(6) {
494            let mut unit_name = "METRE".to_string();
495            let mut unit_scale = 1.0_f64;
496            if let Ok(unit_entity) = decoder.decode_by_id(unit_ref) {
497                match unit_entity.ifc_type {
498                    IfcType::IfcSIUnit => {
499                        // IFCSIUNIT: [0] Dimensions, [1] UnitType, [2] Prefix, [3] Name
500                        if let Some(prefix_attr) = unit_entity.get(2) {
501                            if !prefix_attr.is_null() {
502                                if let Some(prefix) = prefix_attr.as_enum() {
503                                    let multiplier =
504                                        crate::units::get_si_prefix_multiplier(prefix);
505                                    if (multiplier - 1.0).abs() > f64::EPSILON {
506                                        unit_scale = multiplier;
507                                        let prefix_upper = prefix.to_ascii_uppercase();
508                                        unit_name = if prefix_upper == "MILLI" {
509                                            "MILLIMETRE".to_string()
510                                        } else {
511                                            format!("{prefix_upper}METRE")
512                                        };
513                                    }
514                                }
515                            }
516                        }
517                    }
518                    IfcType::IfcConversionBasedUnit => {
519                        // IFCCONVERSIONBASEDUNIT: [0] Dimensions, [1] UnitType,
520                        // [2] Name, [3] ConversionFactor (IfcMeasureWithUnit)
521                        let name = unit_entity
522                            .get(2)
523                            .and_then(|attr| attr.as_string())
524                            .map(|n| n.trim_matches('\'').trim().to_ascii_uppercase());
525                        // The name table first (it carries the exact defined
526                        // ratios, e.g. the US survey foot's 1200/3937), then
527                        // the file's own declared factor for an unknown name.
528                        let scale = name
529                            .as_deref()
530                            .and_then(crate::units::get_conversion_based_unit_factor)
531                            .or_else(|| {
532                                resolve_measure_with_unit(decoder, &unit_entity)
533                            });
534                        if let Some(scale) = scale {
535                            if scale.is_finite() && scale > 0.0 {
536                                unit_scale = scale;
537                                if let Some(name) = name {
538                                    unit_name = name;
539                                }
540                            }
541                        }
542                    }
543                    _ => {}
544                }
545            }
546            georef.map_unit = Some(unit_name);
547            georef.map_unit_scale = Some(unit_scale);
548        }
549    }
550
551    /// Legacy `IfcSite.RefLatitude`/`RefLongitude` fallback (TS parity).
552    ///
553    /// Mirrors the TS parser's `extractLegacySiteGeoreference`: WGS84
554    /// degrees land in eastings (longitude) / northings (latitude) with the
555    /// site `RefElevation` as orthogonal height, under an `EPSG:4326`
556    /// pseudo-CRS — so `hasGeoreference`/`has_georef` agree between the
557    /// browser and the server for site-only models.
558    fn extract_from_site(
559        decoder: &mut EntityDecoder,
560        entity_types: &[(u32, IfcType)],
561    ) -> Result<Option<GeoReference>> {
562        for (id, ifc_type) in entity_types {
563            if *ifc_type != IfcType::IfcSite {
564                continue;
565            }
566            let site = decoder.decode_by_id(*id)?;
567            // IfcSite: RefLatitude (9), RefLongitude (10), RefElevation (11).
568            let latitude = compound_plane_angle_to_degrees(&site, 9);
569            let longitude = compound_plane_angle_to_degrees(&site, 10);
570            let (Some(mut latitude), Some(mut longitude)) = (latitude, longitude) else {
571                continue;
572            };
573
574            // The `-0` leniency (TS parity, see `compoundPlaneAngleToDecimalDegrees`
575            // in `georef-extractor.ts`): a writer that signs a zero-magnitude
576            // component of the compound angle (e.g. `(-0, 30, 0)` for 0°30'S)
577            // survives here only via the entity's RAW record bytes.
578            // `compound_plane_angle_to_degrees` above already lost that sign —
579            // its components come from `AttributeValue::Integer(i64)`, and the
580            // shared tokenizer's `integer()` parses `-0` through
581            // `lexical_core::parse::<i64>`, which has no negative-zero
582            // representation, so the sign never reaches `AttributeValue` at
583            // all. Re-scanning the raw bytes for this one legacy-site
584            // attribute pair (RefLatitude/RefLongitude only, not the hot
585            // tokenizer) recovers it without touching the shared integer path.
586            if let Some(bytes) = decoder.get_raw_bytes(*id) {
587                if latitude >= 0.0 && compound_angle_has_literal_negative_zero(bytes, 9) {
588                    latitude = -latitude;
589                }
590                if longitude >= 0.0 && compound_angle_has_literal_negative_zero(bytes, 10) {
591                    longitude = -longitude;
592                }
593            }
594
595            let elevation = site.get_float(11).unwrap_or(0.0);
596
597            let mut georef = GeoReference::new();
598            georef.source = Some(GeoRefSource::SiteLocation);
599            georef.crs_name = Some("EPSG:4326".to_string());
600            georef.crs_description = Some("Legacy IfcSite geolocation".to_string());
601            georef.geodetic_datum = Some("WGS84".to_string());
602            georef.map_projection = Some("Geographic".to_string());
603            georef.map_unit = Some("DEGREE".to_string());
604            georef.eastings = longitude;
605            georef.northings = latitude;
606            georef.orthogonal_height = elevation;
607            return Ok(Some(georef));
608        }
609        Ok(None)
610    }
611}
612
613#[cfg(test)]
614#[path = "georef_tests.rs"]
615mod georef_tests;