Skip to main content

ifc_lite_processing/
georeferencing.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//! Georeferencing extraction for the HTTP server response.
6//!
7//! The browser parser (`@ifc-lite/parse`) exposes `IfcMapConversion` /
8//! `IfcProjectedCRS` georeferencing via `extractGeoreferencing`. The server
9//! previously surfaced only a coarse `is_geo_referenced` boolean, so consumers
10//! couldn't recover the real-world CRS, false eastings/northings, or grid-north
11//! rotation. This module reuses the shared `ifc_lite_core::GeoRefExtractor`
12//! (the same extraction the desktop/native paths use) and maps it into a
13//! serializable, server-friendly shape carried inline on every geometry
14//! endpoint's `ModelMetadata` (issue #900 parity follow-up).
15
16use ifc_lite_core::{EntityDecoder, EntityScanner, GeoRefExtractor, IfcType};
17use serde::{Deserialize, Serialize};
18
19/// Georeferencing metadata (`IfcMapConversion` + `IfcProjectedCRS`).
20///
21/// Mirrors `ifc_lite_core::GeoReference` with two derived conveniences
22/// (`rotation_degrees`, `transform_matrix`) so consumers don't have to
23/// recompute the rotation or the local→map matrix.
24#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
25pub struct Georeferencing {
26    /// Projected CRS name from `IfcProjectedCRS.Name` (e.g. `"EPSG:32632"`).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub crs_name: Option<String>,
29    /// Geodetic datum (e.g. `"WGS84"`).
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub geodetic_datum: Option<String>,
32    /// Vertical datum (e.g. `"NAVD88"`).
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub vertical_datum: Option<String>,
35    /// Map projection (e.g. `"UTM"`).
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub map_projection: Option<String>,
38    /// False easting — X offset to the map CRS, in the project's length unit.
39    pub eastings: f64,
40    /// False northing — Y offset to the map CRS, in the project's length unit.
41    pub northings: f64,
42    /// Orthogonal height — Z offset to the map CRS.
43    pub orthogonal_height: f64,
44    /// X-axis abscissa: cosine of the rotation to grid north.
45    pub x_axis_abscissa: f64,
46    /// X-axis ordinate: sine of the rotation to grid north.
47    pub x_axis_ordinate: f64,
48    /// Scale factor applied during the local→map transform (default `1.0`).
49    pub scale: f64,
50    /// Rotation to grid north in degrees, derived from the X-axis direction.
51    pub rotation_degrees: f64,
52    /// Local→map transform as a column-major 4×4 matrix (16 values).
53    pub transform_matrix: [f64; 16],
54    /// CRS description from `IfcProjectedCRS.Description`.
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub crs_description: Option<String>,
57    /// Map zone (e.g. `"32N"`) from `IfcProjectedCRS.MapZone`.
58    #[serde(skip_serializing_if = "Option::is_none", default)]
59    pub map_zone: Option<String>,
60    /// Map unit name resolved from `IfcProjectedCRS.MapUnit` (e.g. `"METRE"`,
61    /// `"MILLIMETRE"`); absent when no MapUnit is authored.
62    #[serde(skip_serializing_if = "Option::is_none", default)]
63    pub map_unit: Option<String>,
64    /// Scale factor converting MapConversion values to metres (0.001 for
65    /// millimetres); absent when no MapUnit is authored.
66    #[serde(skip_serializing_if = "Option::is_none", default)]
67    pub map_unit_scale: Option<f64>,
68    /// Provenance: `"mapConversion"`, `"ePSetMapConversion"`, or
69    /// `"siteLocation"` — same labels as the TS parser's
70    /// `GeoreferenceInfo.source`.
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub source: Option<String>,
73}
74
75impl Georeferencing {
76    fn from_core(geo: &ifc_lite_core::GeoReference) -> Self {
77        Self {
78            crs_name: geo.crs_name.clone(),
79            geodetic_datum: geo.geodetic_datum.clone(),
80            vertical_datum: geo.vertical_datum.clone(),
81            map_projection: geo.map_projection.clone(),
82            eastings: geo.eastings,
83            northings: geo.northings,
84            orthogonal_height: geo.orthogonal_height,
85            x_axis_abscissa: geo.x_axis_abscissa,
86            x_axis_ordinate: geo.x_axis_ordinate,
87            scale: geo.scale,
88            rotation_degrees: geo.rotation().to_degrees(),
89            transform_matrix: geo.to_matrix(),
90            crs_description: geo.crs_description.clone(),
91            map_zone: geo.map_zone.clone(),
92            map_unit: geo.map_unit.clone(),
93            map_unit_scale: geo.map_unit_scale,
94            source: Some(geo.source.label().to_string()),
95        }
96    }
97}
98
99/// Extract georeferencing from an IFC file, returning `None` when the model
100/// carries no `IfcMapConversion` / `ePSet_MapConversion` data.
101///
102/// Only the entity types the extractor needs (`IfcMapConversion`,
103/// `IfcProjectedCRS`, and `IfcPropertySet` for the IFC2x3 `ePSet_MapConversion`
104/// fallback) are collected from the scan — their `IfcType` is known from the
105/// entity name, so no decoding happens while building the candidate list.
106pub fn extract_georeferencing<T>(content: &T) -> Option<Georeferencing>
107where
108    T: AsRef<[u8]> + ?Sized,
109{
110    let content = content.as_ref();
111    let entity_index = crate::build_entity_index_parallel(content);
112    let mut decoder = EntityDecoder::with_index(content, entity_index);
113
114    let mut entity_types: Vec<(u32, IfcType)> = Vec::new();
115    let mut scanner = EntityScanner::new(content);
116    while let Some((id, type_name, _start, _end)) = scanner.next_entity() {
117        match type_name {
118            "IFCMAPCONVERSION" => entity_types.push((id, IfcType::IfcMapConversion)),
119            "IFCPROJECTEDCRS" => entity_types.push((id, IfcType::IfcProjectedCRS)),
120            "IFCPROPERTYSET" => entity_types.push((id, IfcType::IfcPropertySet)),
121            // Legacy IfcSite RefLatitude/RefLongitude fallback (TS parity).
122            "IFCSITE" => entity_types.push((id, IfcType::IfcSite)),
123            _ => {}
124        }
125    }
126
127    if entity_types.is_empty() {
128        return None;
129    }
130
131    match GeoRefExtractor::extract(&mut decoder, &entity_types) {
132        Ok(Some(geo)) => Some(Georeferencing::from_core(&geo)),
133        Ok(None) => None,
134        Err(e) => {
135            tracing::debug!(error = %e, "Georeferencing extraction failed");
136            None
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    const GEOREF_IFC: &str = r#"ISO-10303-21;
146HEADER;
147FILE_DESCRIPTION(('georef fixture'),'2;1');
148FILE_NAME('georef.ifc','2026-06-01T00:00:00',(''),(''),'','','');
149FILE_SCHEMA(('IFC4'));
150ENDSEC;
151DATA;
152#1=IFCPROJECT('0$ScRe4drECQ4DMSqUjd6d',$,'P',$,$,$,$,(#2),#3);
153#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
154#3=IFCUNITASSIGNMENT((#6));
155#4=IFCCARTESIANPOINT((0.,0.,0.));
156#5=IFCAXIS2PLACEMENT3D(#4,$,$);
157#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
158#10=IFCPROJECTEDCRS('EPSG:32632','WGS84 / UTM zone 32N','WGS84',$,'UTM','32N',$);
159#11=IFCMAPCONVERSION(#2,#10,1000.5,2000.25,42.0,0.866025,0.5,1.0);
160ENDSEC;
161END-ISO-10303-21;
162"#;
163
164    #[test]
165    fn extracts_map_conversion_and_crs() {
166        let geo = extract_georeferencing(GEOREF_IFC).expect("expected georeferencing");
167        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:32632"));
168        assert_eq!(geo.geodetic_datum.as_deref(), Some("WGS84"));
169        assert_eq!(geo.map_projection.as_deref(), Some("UTM"));
170        assert!((geo.eastings - 1000.5).abs() < 1e-6);
171        assert!((geo.northings - 2000.25).abs() < 1e-6);
172        assert!((geo.orthogonal_height - 42.0).abs() < 1e-6);
173        // XAxisAbscissa/Ordinate = cos/sin(30°) → rotation_degrees ≈ 30.
174        assert!(
175            (geo.rotation_degrees - 30.0).abs() < 1e-3,
176            "rotation should be ~30°, got {}",
177            geo.rotation_degrees
178        );
179        // Translation column of the local→map matrix carries the offsets.
180        assert!((geo.transform_matrix[12] - 1000.5).abs() < 1e-6);
181        assert!((geo.transform_matrix[13] - 2000.25).abs() < 1e-6);
182        // New parity fields (alignment audit): description/zone + provenance.
183        assert_eq!(geo.crs_description.as_deref(), Some("WGS84 / UTM zone 32N"));
184        assert_eq!(geo.map_zone.as_deref(), Some("32N"));
185        // No MapUnit authored → project length unit applies (both None).
186        assert_eq!(geo.map_unit, None);
187        assert_eq!(geo.map_unit_scale, None);
188        assert_eq!(geo.source.as_deref(), Some("mapConversion"));
189    }
190
191    /// IFC2x3 models carry georeferencing via an `ePSet_MapConversion` property
192    /// set rather than `IfcMapConversion`. Regression for the core extractor bug
193    /// that read `IfcPropertySet.Name` from attribute 0 (GlobalId) instead of 2.
194    const IFC2X3_PSET_IFC: &str = r#"ISO-10303-21;
195HEADER;
196FILE_DESCRIPTION(('ifc2x3 georef pset fixture'),'2;1');
197FILE_NAME('georef2x3.ifc','2026-06-01T00:00:00',(''),(''),'','','');
198FILE_SCHEMA(('IFC2X3'));
199ENDSEC;
200DATA;
201#1=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(1000.5),$);
202#2=IFCPROPERTYSINGLEVALUE('Northings',$,IFCLENGTHMEASURE(2000.25),$);
203#3=IFCPROPERTYSINGLEVALUE('OrthogonalHeight',$,IFCLENGTHMEASURE(42.),$);
204#4=IFCPROPERTYSET('0PSet00000000000000001',$,'ePSet_MapConversion',$,(#1,#2,#3));
205ENDSEC;
206END-ISO-10303-21;
207"#;
208
209    #[test]
210    fn extracts_ifc2x3_epset_map_conversion_fallback() {
211        let geo = extract_georeferencing(IFC2X3_PSET_IFC)
212            .expect("expected georeferencing from ePSet_MapConversion");
213        assert!((geo.eastings - 1000.5).abs() < 1e-6);
214        assert!((geo.northings - 2000.25).abs() < 1e-6);
215        assert!((geo.orthogonal_height - 42.0).abs() < 1e-6);
216    }
217
218    /// Real-world fixture mirroring the `ifc-georeferencer` post-processor:
219    /// lowercase `ePset_…` property-set names and a separate `ePset_ProjectedCRS`
220    /// carrying the EPSG `Name`. The exact-match + missing-CRS bug dropped these
221    /// to the legacy IfcSite EPSG:4326 and showed the wrong CRS.
222    const IFC2X3_EPSET_LOWERCASE_IFC: &str = r#"ISO-10303-21;
223HEADER;
224FILE_DESCRIPTION(('ifc-georeferencer pset fixture'),'2;1');
225FILE_NAME('georef-lc.ifc','2026-06-26T00:00:00',(''),(''),'','','');
226FILE_SCHEMA(('IFC2X3'));
227ENDSEC;
228DATA;
229#1=IFCPROPERTYSINGLEVALUE('TargetCRS',$,IFCLABEL('EPSG:7415'),$);
230#2=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(160073528.13858587),$);
231#3=IFCPROPERTYSINGLEVALUE('Northings',$,IFCLENGTHMEASURE(384153306.2191765),$);
232#4=IFCPROPERTYSINGLEVALUE('OrthogonalHeight',$,IFCLENGTHMEASURE(0.),$);
233#5=IFCPROPERTYSET('2If4Y3Lpv6dgTDkC5x_dnr',$,'ePset_MapConversion',$,(#1,#2,#3,#4));
234#6=IFCPROPERTYSINGLEVALUE('Name',$,IFCLABEL('EPSG:7415'),$);
235#7=IFCPROPERTYSET('27AKTMp8j58fBEhvkJkcNJ',$,'ePset_ProjectedCRS',$,(#6));
236ENDSEC;
237END-ISO-10303-21;
238"#;
239
240    #[test]
241    fn extracts_ifc2x3_lowercase_epset_with_projected_crs_name() {
242        let geo = extract_georeferencing(IFC2X3_EPSET_LOWERCASE_IFC)
243            .expect("expected georeferencing from lowercase ePset_ sets");
244        assert_eq!(geo.source.as_deref(), Some("ePSetMapConversion"));
245        // CRS name surfaced from ePset_ProjectedCRS.Name (was previously lost).
246        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:7415"));
247        assert!((geo.eastings - 160073528.13858587).abs() < 1e-3);
248    }
249
250    /// Without an ePset_ProjectedCRS set, the CRS name falls back to the
251    /// MapConversion's own `TargetCRS` label.
252    const IFC2X3_EPSET_TARGETCRS_ONLY_IFC: &str = r#"ISO-10303-21;
253HEADER;
254FILE_DESCRIPTION(('ifc-georeferencer targetcrs-only fixture'),'2;1');
255FILE_NAME('georef-tc.ifc','2026-06-26T00:00:00',(''),(''),'','','');
256FILE_SCHEMA(('IFC2X3'));
257ENDSEC;
258DATA;
259#1=IFCPROPERTYSINGLEVALUE('TargetCRS',$,IFCLABEL('EPSG:28992'),$);
260#2=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(1000.5),$);
261#3=IFCPROPERTYSINGLEVALUE('Northings',$,IFCLENGTHMEASURE(2000.25),$);
262#4=IFCPROPERTYSINGLEVALUE('OrthogonalHeight',$,IFCLENGTHMEASURE(0.),$);
263#5=IFCPROPERTYSET('2If4Y3Lpv6dgTDkC5x_dnr',$,'ePset_MapConversion',$,(#1,#2,#3,#4));
264ENDSEC;
265END-ISO-10303-21;
266"#;
267
268    #[test]
269    fn falls_back_to_target_crs_when_no_projected_crs_pset() {
270        let geo = extract_georeferencing(IFC2X3_EPSET_TARGETCRS_ONLY_IFC)
271            .expect("expected georeferencing from ePset_MapConversion");
272        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:28992"));
273    }
274
275    /// Millimetre MapUnit: the conversion offsets are authored in mm and the
276    /// served `map_unit_scale` must say 0.001 — the TS parser already did
277    /// this; the server previously ignored MapUnit entirely (alignment
278    /// audit). Values mirror packages/parser/test/georef-extractor.test.ts.
279    const MM_MAPUNIT_IFC: &str = r#"ISO-10303-21;
280HEADER;
281FILE_DESCRIPTION(('georef mm fixture'),'2;1');
282FILE_NAME('georef-mm.ifc','2026-06-12T00:00:00',(''),(''),'','','');
283FILE_SCHEMA(('IFC4'));
284ENDSEC;
285DATA;
286#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
287#4=IFCCARTESIANPOINT((0.,0.,0.));
288#5=IFCAXIS2PLACEMENT3D(#4,$,$);
289#7=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
290#10=IFCPROJECTEDCRS('EPSG:25832',$,'ETRS89',$,'UTM','32N',#7);
291#11=IFCMAPCONVERSION(#2,#10,512000000.,5400000000.,0.,1.,0.,1.0);
292ENDSEC;
293END-ISO-10303-21;
294"#;
295
296    #[test]
297    fn resolves_millimetre_map_unit_scale() {
298        let geo = extract_georeferencing(MM_MAPUNIT_IFC).expect("georef");
299        assert_eq!(geo.map_unit.as_deref(), Some("MILLIMETRE"));
300        assert_eq!(geo.map_unit_scale, Some(0.001));
301        assert_eq!(geo.map_zone.as_deref(), Some("32N"));
302    }
303
304    /// An empty `ePset_ProjectedCRS.Name` must not block the `TargetCRS`
305    /// fallback — otherwise `crs_name` stays "" and the viewer gate (which
306    /// requires a truthy name) silently drops the model to EPSG:4326.
307    const IFC2X3_EPSET_EMPTY_CRS_NAME_IFC: &str = r#"ISO-10303-21;
308HEADER;
309FILE_DESCRIPTION(('ifc-georeferencer empty-crs-name fixture'),'2;1');
310FILE_NAME('georef-empty.ifc','2026-06-26T00:00:00',(''),(''),'','','');
311FILE_SCHEMA(('IFC2X3'));
312ENDSEC;
313DATA;
314#1=IFCPROPERTYSINGLEVALUE('TargetCRS',$,IFCLABEL('EPSG:28992'),$);
315#2=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(1000.5),$);
316#3=IFCPROPERTYSINGLEVALUE('Northings',$,IFCLENGTHMEASURE(2000.25),$);
317#4=IFCPROPERTYSINGLEVALUE('OrthogonalHeight',$,IFCLENGTHMEASURE(0.),$);
318#5=IFCPROPERTYSET('2If4Y3Lpv6dgTDkC5x_dnr',$,'ePset_MapConversion',$,(#1,#2,#3,#4));
319#6=IFCPROPERTYSINGLEVALUE('Name',$,IFCLABEL(''),$);
320#7=IFCPROPERTYSET('27AKTMp8j58fBEhvkJkcNJ',$,'ePset_ProjectedCRS',$,(#6));
321ENDSEC;
322END-ISO-10303-21;
323"#;
324
325    #[test]
326    fn empty_projected_crs_name_falls_back_to_target_crs() {
327        let geo = extract_georeferencing(IFC2X3_EPSET_EMPTY_CRS_NAME_IFC)
328            .expect("expected georeferencing from ePset_MapConversion");
329        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:28992"));
330    }
331
332    /// An explicit ePSet `MapUnit` label resolves to the matching metre scale,
333    /// parity with the native IfcProjectedCRS path (which reads it from the
334    /// unit entity). Direct consumers must not default these offsets to metres.
335    const IFC2X3_EPSET_FOOT_MAPUNIT_IFC: &str = r#"ISO-10303-21;
336HEADER;
337FILE_DESCRIPTION(('ifc-georeferencer foot-mapunit fixture'),'2;1');
338FILE_NAME('georef-foot.ifc','2026-06-26T00:00:00',(''),(''),'','','');
339FILE_SCHEMA(('IFC2X3'));
340ENDSEC;
341DATA;
342#2=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(1000.5),$);
343#3=IFCPROPERTYSINGLEVALUE('Northings',$,IFCLENGTHMEASURE(2000.25),$);
344#4=IFCPROPERTYSINGLEVALUE('OrthogonalHeight',$,IFCLENGTHMEASURE(0.),$);
345#5=IFCPROPERTYSET('2If4Y3Lpv6dgTDkC5x_dnr',$,'ePset_MapConversion',$,(#2,#3,#4));
346#6=IFCPROPERTYSINGLEVALUE('Name',$,IFCLABEL('EPSG:2225'),$);
347#8=IFCPROPERTYSINGLEVALUE('MapUnit',$,IFCLABEL('FOOT'),$);
348#7=IFCPROPERTYSET('27AKTMp8j58fBEhvkJkcNJ',$,'ePset_ProjectedCRS',$,(#6,#8));
349ENDSEC;
350END-ISO-10303-21;
351"#;
352
353    #[test]
354    fn epset_map_unit_label_resolves_scale() {
355        let geo = extract_georeferencing(IFC2X3_EPSET_FOOT_MAPUNIT_IFC).expect("georef");
356        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:2225"));
357        assert_eq!(geo.map_unit.as_deref(), Some("FOOT"));
358        assert_eq!(geo.map_unit_scale, Some(0.3048));
359    }
360
361    /// Two authored IfcMapConversions: the FIRST one wins, matching the TS
362    /// parser's `mapConversionIds[0]` pick (the server used to serve the
363    /// LAST one — alignment audit).
364    const TWO_CONVERSIONS_IFC: &str = r#"ISO-10303-21;
365HEADER;
366FILE_DESCRIPTION(('georef two-conversions fixture'),'2;1');
367FILE_NAME('georef-two.ifc','2026-06-12T00:00:00',(''),(''),'','','');
368FILE_SCHEMA(('IFC4'));
369ENDSEC;
370DATA;
371#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
372#4=IFCCARTESIANPOINT((0.,0.,0.));
373#5=IFCAXIS2PLACEMENT3D(#4,$,$);
374#10=IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);
375#11=IFCMAPCONVERSION(#2,#10,111.0,222.0,0.,1.,0.,1.0);
376#12=IFCMAPCONVERSION(#2,#10,999.0,888.0,0.,1.,0.,1.0);
377ENDSEC;
378END-ISO-10303-21;
379"#;
380
381    #[test]
382    fn first_map_conversion_wins() {
383        let geo = extract_georeferencing(TWO_CONVERSIONS_IFC).expect("georef");
384        assert!((geo.eastings - 111.0).abs() < 1e-9);
385        assert!((geo.northings - 222.0).abs() < 1e-9);
386    }
387
388    /// Non-unit XAxisAbscissa/Ordinate (a DIRECTION, not cos/sin): the
389    /// rotation and the transform matrix must agree with each other and
390    /// with the TS parser's atan2-normalised matrix. Pre-fix, the matrix
391    /// used the raw components as cos/sin and disagreed with
392    /// `rotation_degrees` inside the same payload (alignment audit).
393    const NON_UNIT_AXIS_IFC: &str = r#"ISO-10303-21;
394HEADER;
395FILE_DESCRIPTION(('georef non-unit-axis fixture'),'2;1');
396FILE_NAME('georef-axis.ifc','2026-06-12T00:00:00',(''),(''),'','','');
397FILE_SCHEMA(('IFC4'));
398ENDSEC;
399DATA;
400#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
401#4=IFCCARTESIANPOINT((0.,0.,0.));
402#5=IFCAXIS2PLACEMENT3D(#4,$,$);
403#10=IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);
404#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,0.,3.0,4.0,1.0);
405ENDSEC;
406END-ISO-10303-21;
407"#;
408
409    #[test]
410    fn non_unit_axis_is_normalised() {
411        let geo = extract_georeferencing(NON_UNIT_AXIS_IFC).expect("georef");
412        // (3,4) direction → unit (0.6, 0.8); rotation ≈ 53.130°.
413        assert!((geo.x_axis_abscissa - 0.6).abs() < 1e-9);
414        assert!((geo.x_axis_ordinate - 0.8).abs() < 1e-9);
415        assert!((geo.rotation_degrees - 53.13010235415598).abs() < 1e-9);
416        // Matrix rotation cell == cos(rotation) — self-consistent payload.
417        assert!((geo.transform_matrix[0] - 0.6).abs() < 1e-9);
418        assert!((geo.transform_matrix[1] - 0.8).abs() < 1e-9);
419    }
420
421    /// Site-only model: `IfcSite.RefLatitude/RefLongitude` must produce a
422    /// georeference exactly like the TS parser's legacy-site fallback —
423    /// previously the server reported NO georeferencing for these models
424    /// while the browser said `hasGeoreference: true` (alignment audit).
425    /// Mirrors the values in packages/parser/test/georef-extractor.test.ts.
426    const SITE_ONLY_IFC: &str = r#"ISO-10303-21;
427HEADER;
428FILE_DESCRIPTION(('georef site-only fixture'),'2;1');
429FILE_NAME('georef-site.ifc','2026-06-12T00:00:00',(''),(''),'','','');
430FILE_SCHEMA(('IFC2X3'));
431ENDSEC;
432DATA;
433#4=IFCCARTESIANPOINT((0.,0.,0.));
434#5=IFCAXIS2PLACEMENT3D(#4,$,$);
435#11=IFCLOCALPLACEMENT($,#5);
436#10=IFCSITE('0Site0000000000000001',$,'Site',$,$,#11,$,$,.ELEMENT.,(47,22,30,0),(8,32,15,0),420.5,$,$);
437ENDSEC;
438END-ISO-10303-21;
439"#;
440
441    #[test]
442    fn site_lat_long_fallback_matches_ts_parser() {
443        let geo = extract_georeferencing(SITE_ONLY_IFC).expect("site georef");
444        assert_eq!(geo.source.as_deref(), Some("siteLocation"));
445        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:4326"));
446        assert_eq!(geo.geodetic_datum.as_deref(), Some("WGS84"));
447        assert_eq!(geo.map_unit.as_deref(), Some("DEGREE"));
448        // 47°22'30" → 47.375; 8°32'15" → 8.5375 (longitude in eastings,
449        // latitude in northings — same packing as the TS fallback).
450        assert!((geo.northings - 47.375).abs() < 1e-9, "lat {}", geo.northings);
451        assert!((geo.eastings - 8.5375).abs() < 1e-9, "long {}", geo.eastings);
452        assert!((geo.orthogonal_height - 420.5).abs() < 1e-9);
453    }
454
455    #[test]
456    fn returns_none_without_georeferencing() {
457        let plain = r#"ISO-10303-21;
458HEADER;
459FILE_SCHEMA(('IFC4'));
460ENDSEC;
461DATA;
462#1=IFCPROJECT('0$ScRe4drECQ4DMSqUjd6d',$,'P',$,$,$,$,$,$);
463ENDSEC;
464END-ISO-10303-21;
465"#;
466        assert!(extract_georeferencing(plain).is_none());
467    }
468}