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;