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::{build_entity_index, 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 = build_entity_index(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    /// Millimetre MapUnit: the conversion offsets are authored in mm and the
219    /// served `map_unit_scale` must say 0.001 — the TS parser already did
220    /// this; the server previously ignored MapUnit entirely (alignment
221    /// audit). Values mirror packages/parser/test/georef-extractor.test.ts.
222    const MM_MAPUNIT_IFC: &str = r#"ISO-10303-21;
223HEADER;
224FILE_DESCRIPTION(('georef mm fixture'),'2;1');
225FILE_NAME('georef-mm.ifc','2026-06-12T00:00:00',(''),(''),'','','');
226FILE_SCHEMA(('IFC4'));
227ENDSEC;
228DATA;
229#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
230#4=IFCCARTESIANPOINT((0.,0.,0.));
231#5=IFCAXIS2PLACEMENT3D(#4,$,$);
232#7=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
233#10=IFCPROJECTEDCRS('EPSG:25832',$,'ETRS89',$,'UTM','32N',#7);
234#11=IFCMAPCONVERSION(#2,#10,512000000.,5400000000.,0.,1.,0.,1.0);
235ENDSEC;
236END-ISO-10303-21;
237"#;
238
239    #[test]
240    fn resolves_millimetre_map_unit_scale() {
241        let geo = extract_georeferencing(MM_MAPUNIT_IFC).expect("georef");
242        assert_eq!(geo.map_unit.as_deref(), Some("MILLIMETRE"));
243        assert_eq!(geo.map_unit_scale, Some(0.001));
244        assert_eq!(geo.map_zone.as_deref(), Some("32N"));
245    }
246
247    /// Two authored IfcMapConversions: the FIRST one wins, matching the TS
248    /// parser's `mapConversionIds[0]` pick (the server used to serve the
249    /// LAST one — alignment audit).
250    const TWO_CONVERSIONS_IFC: &str = r#"ISO-10303-21;
251HEADER;
252FILE_DESCRIPTION(('georef two-conversions fixture'),'2;1');
253FILE_NAME('georef-two.ifc','2026-06-12T00:00:00',(''),(''),'','','');
254FILE_SCHEMA(('IFC4'));
255ENDSEC;
256DATA;
257#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
258#4=IFCCARTESIANPOINT((0.,0.,0.));
259#5=IFCAXIS2PLACEMENT3D(#4,$,$);
260#10=IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);
261#11=IFCMAPCONVERSION(#2,#10,111.0,222.0,0.,1.,0.,1.0);
262#12=IFCMAPCONVERSION(#2,#10,999.0,888.0,0.,1.,0.,1.0);
263ENDSEC;
264END-ISO-10303-21;
265"#;
266
267    #[test]
268    fn first_map_conversion_wins() {
269        let geo = extract_georeferencing(TWO_CONVERSIONS_IFC).expect("georef");
270        assert!((geo.eastings - 111.0).abs() < 1e-9);
271        assert!((geo.northings - 222.0).abs() < 1e-9);
272    }
273
274    /// Non-unit XAxisAbscissa/Ordinate (a DIRECTION, not cos/sin): the
275    /// rotation and the transform matrix must agree with each other and
276    /// with the TS parser's atan2-normalised matrix. Pre-fix, the matrix
277    /// used the raw components as cos/sin and disagreed with
278    /// `rotation_degrees` inside the same payload (alignment audit).
279    const NON_UNIT_AXIS_IFC: &str = r#"ISO-10303-21;
280HEADER;
281FILE_DESCRIPTION(('georef non-unit-axis fixture'),'2;1');
282FILE_NAME('georef-axis.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#10=IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);
290#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,0.,3.0,4.0,1.0);
291ENDSEC;
292END-ISO-10303-21;
293"#;
294
295    #[test]
296    fn non_unit_axis_is_normalised() {
297        let geo = extract_georeferencing(NON_UNIT_AXIS_IFC).expect("georef");
298        // (3,4) direction → unit (0.6, 0.8); rotation ≈ 53.130°.
299        assert!((geo.x_axis_abscissa - 0.6).abs() < 1e-9);
300        assert!((geo.x_axis_ordinate - 0.8).abs() < 1e-9);
301        assert!((geo.rotation_degrees - 53.13010235415598).abs() < 1e-9);
302        // Matrix rotation cell == cos(rotation) — self-consistent payload.
303        assert!((geo.transform_matrix[0] - 0.6).abs() < 1e-9);
304        assert!((geo.transform_matrix[1] - 0.8).abs() < 1e-9);
305    }
306
307    /// Site-only model: `IfcSite.RefLatitude/RefLongitude` must produce a
308    /// georeference exactly like the TS parser's legacy-site fallback —
309    /// previously the server reported NO georeferencing for these models
310    /// while the browser said `hasGeoreference: true` (alignment audit).
311    /// Mirrors the values in packages/parser/test/georef-extractor.test.ts.
312    const SITE_ONLY_IFC: &str = r#"ISO-10303-21;
313HEADER;
314FILE_DESCRIPTION(('georef site-only fixture'),'2;1');
315FILE_NAME('georef-site.ifc','2026-06-12T00:00:00',(''),(''),'','','');
316FILE_SCHEMA(('IFC2X3'));
317ENDSEC;
318DATA;
319#4=IFCCARTESIANPOINT((0.,0.,0.));
320#5=IFCAXIS2PLACEMENT3D(#4,$,$);
321#11=IFCLOCALPLACEMENT($,#5);
322#10=IFCSITE('0Site0000000000000001',$,'Site',$,$,#11,$,$,.ELEMENT.,(47,22,30,0),(8,32,15,0),420.5,$,$);
323ENDSEC;
324END-ISO-10303-21;
325"#;
326
327    #[test]
328    fn site_lat_long_fallback_matches_ts_parser() {
329        let geo = extract_georeferencing(SITE_ONLY_IFC).expect("site georef");
330        assert_eq!(geo.source.as_deref(), Some("siteLocation"));
331        assert_eq!(geo.crs_name.as_deref(), Some("EPSG:4326"));
332        assert_eq!(geo.geodetic_datum.as_deref(), Some("WGS84"));
333        assert_eq!(geo.map_unit.as_deref(), Some("DEGREE"));
334        // 47°22'30" → 47.375; 8°32'15" → 8.5375 (longitude in eastings,
335        // latitude in northings — same packing as the TS fallback).
336        assert!((geo.northings - 47.375).abs() < 1e-9, "lat {}", geo.northings);
337        assert!((geo.eastings - 8.5375).abs() < 1e-9, "long {}", geo.eastings);
338        assert!((geo.orthogonal_height - 420.5).abs() < 1e-9);
339    }
340
341    #[test]
342    fn returns_none_without_georeferencing() {
343        let plain = r#"ISO-10303-21;
344HEADER;
345FILE_SCHEMA(('IFC4'));
346ENDSEC;
347DATA;
348#1=IFCPROJECT('0$ScRe4drECQ4DMSqUjd6d',$,'P',$,$,$,$,$,$);
349ENDSEC;
350END-ISO-10303-21;
351"#;
352        assert!(extract_georeferencing(plain).is_none());
353    }
354}