1use ifc_lite_core::{EntityDecoder, EntityScanner, GeoRefExtractor, IfcType};
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
25pub struct Georeferencing {
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub crs_name: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub geodetic_datum: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub vertical_datum: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub map_projection: Option<String>,
38 pub eastings: f64,
40 pub northings: f64,
42 pub orthogonal_height: f64,
44 pub x_axis_abscissa: f64,
46 pub x_axis_ordinate: f64,
48 pub scale: f64,
50 pub rotation_degrees: f64,
52 pub transform_matrix: [f64; 16],
54 #[serde(skip_serializing_if = "Option::is_none", default)]
56 pub crs_description: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none", default)]
59 pub map_zone: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none", default)]
63 pub map_unit: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none", default)]
67 pub map_unit_scale: Option<f64>,
68 #[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
99pub 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 "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 assert!(
175 (geo.rotation_degrees - 30.0).abs() < 1e-3,
176 "rotation should be ~30°, got {}",
177 geo.rotation_degrees
178 );
179 assert!((geo.transform_matrix[12] - 1000.5).abs() < 1e-6);
181 assert!((geo.transform_matrix[13] - 2000.25).abs() < 1e-6);
182 assert_eq!(geo.crs_description.as_deref(), Some("WGS84 / UTM zone 32N"));
184 assert_eq!(geo.map_zone.as_deref(), Some("32N"));
185 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 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 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 assert_eq!(geo.crs_name.as_deref(), Some("EPSG:7415"));
247 assert!((geo.eastings - 160073528.13858587).abs() < 1e-3);
248 }
249
250 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 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 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 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 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 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 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 assert!((geo.transform_matrix[0] - 0.6).abs() < 1e-9);
418 assert!((geo.transform_matrix[1] - 0.8).abs() < 1e-9);
419 }
420
421 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 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}