1use ifc_lite_core::{build_entity_index, 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 = 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 "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 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 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 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 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 assert!((geo.transform_matrix[0] - 0.6).abs() < 1e-9);
304 assert!((geo.transform_matrix[1] - 0.8).abs() < 1e-9);
305 }
306
307 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 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}