use ifc_lite_core::{build_entity_index, EntityDecoder, EntityScanner, GeoRefExtractor, IfcType};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct Georeferencing {
#[serde(skip_serializing_if = "Option::is_none")]
pub crs_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub geodetic_datum: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vertical_datum: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub map_projection: Option<String>,
pub eastings: f64,
pub northings: f64,
pub orthogonal_height: f64,
pub x_axis_abscissa: f64,
pub x_axis_ordinate: f64,
pub scale: f64,
pub rotation_degrees: f64,
pub transform_matrix: [f64; 16],
#[serde(skip_serializing_if = "Option::is_none", default)]
pub crs_description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub map_zone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub map_unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub map_unit_scale: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub source: Option<String>,
}
impl Georeferencing {
fn from_core(geo: &ifc_lite_core::GeoReference) -> Self {
Self {
crs_name: geo.crs_name.clone(),
geodetic_datum: geo.geodetic_datum.clone(),
vertical_datum: geo.vertical_datum.clone(),
map_projection: geo.map_projection.clone(),
eastings: geo.eastings,
northings: geo.northings,
orthogonal_height: geo.orthogonal_height,
x_axis_abscissa: geo.x_axis_abscissa,
x_axis_ordinate: geo.x_axis_ordinate,
scale: geo.scale,
rotation_degrees: geo.rotation().to_degrees(),
transform_matrix: geo.to_matrix(),
crs_description: geo.crs_description.clone(),
map_zone: geo.map_zone.clone(),
map_unit: geo.map_unit.clone(),
map_unit_scale: geo.map_unit_scale,
source: Some(geo.source.label().to_string()),
}
}
}
pub fn extract_georeferencing<T>(content: &T) -> Option<Georeferencing>
where
T: AsRef<[u8]> + ?Sized,
{
let content = content.as_ref();
let entity_index = build_entity_index(content);
let mut decoder = EntityDecoder::with_index(content, entity_index);
let mut entity_types: Vec<(u32, IfcType)> = Vec::new();
let mut scanner = EntityScanner::new(content);
while let Some((id, type_name, _start, _end)) = scanner.next_entity() {
match type_name {
"IFCMAPCONVERSION" => entity_types.push((id, IfcType::IfcMapConversion)),
"IFCPROJECTEDCRS" => entity_types.push((id, IfcType::IfcProjectedCRS)),
"IFCPROPERTYSET" => entity_types.push((id, IfcType::IfcPropertySet)),
"IFCSITE" => entity_types.push((id, IfcType::IfcSite)),
_ => {}
}
}
if entity_types.is_empty() {
return None;
}
match GeoRefExtractor::extract(&mut decoder, &entity_types) {
Ok(Some(geo)) => Some(Georeferencing::from_core(&geo)),
Ok(None) => None,
Err(e) => {
tracing::debug!(error = %e, "Georeferencing extraction failed");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const GEOREF_IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('georef fixture'),'2;1');
FILE_NAME('georef.ifc','2026-06-01T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('0$ScRe4drECQ4DMSqUjd6d',$,'P',$,$,$,$,(#2),#3);
#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
#3=IFCUNITASSIGNMENT((#6));
#4=IFCCARTESIANPOINT((0.,0.,0.));
#5=IFCAXIS2PLACEMENT3D(#4,$,$);
#6=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
#10=IFCPROJECTEDCRS('EPSG:32632','WGS84 / UTM zone 32N','WGS84',$,'UTM','32N',$);
#11=IFCMAPCONVERSION(#2,#10,1000.5,2000.25,42.0,0.866025,0.5,1.0);
ENDSEC;
END-ISO-10303-21;
"#;
#[test]
fn extracts_map_conversion_and_crs() {
let geo = extract_georeferencing(GEOREF_IFC).expect("expected georeferencing");
assert_eq!(geo.crs_name.as_deref(), Some("EPSG:32632"));
assert_eq!(geo.geodetic_datum.as_deref(), Some("WGS84"));
assert_eq!(geo.map_projection.as_deref(), Some("UTM"));
assert!((geo.eastings - 1000.5).abs() < 1e-6);
assert!((geo.northings - 2000.25).abs() < 1e-6);
assert!((geo.orthogonal_height - 42.0).abs() < 1e-6);
assert!(
(geo.rotation_degrees - 30.0).abs() < 1e-3,
"rotation should be ~30°, got {}",
geo.rotation_degrees
);
assert!((geo.transform_matrix[12] - 1000.5).abs() < 1e-6);
assert!((geo.transform_matrix[13] - 2000.25).abs() < 1e-6);
assert_eq!(geo.crs_description.as_deref(), Some("WGS84 / UTM zone 32N"));
assert_eq!(geo.map_zone.as_deref(), Some("32N"));
assert_eq!(geo.map_unit, None);
assert_eq!(geo.map_unit_scale, None);
assert_eq!(geo.source.as_deref(), Some("mapConversion"));
}
const IFC2X3_PSET_IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ifc2x3 georef pset fixture'),'2;1');
FILE_NAME('georef2x3.ifc','2026-06-01T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#1=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(1000.5),$);
#2=IFCPROPERTYSINGLEVALUE('Northings',$,IFCLENGTHMEASURE(2000.25),$);
#3=IFCPROPERTYSINGLEVALUE('OrthogonalHeight',$,IFCLENGTHMEASURE(42.),$);
#4=IFCPROPERTYSET('0PSet00000000000000001',$,'ePSet_MapConversion',$,(#1,#2,#3));
ENDSEC;
END-ISO-10303-21;
"#;
#[test]
fn extracts_ifc2x3_epset_map_conversion_fallback() {
let geo = extract_georeferencing(IFC2X3_PSET_IFC)
.expect("expected georeferencing from ePSet_MapConversion");
assert!((geo.eastings - 1000.5).abs() < 1e-6);
assert!((geo.northings - 2000.25).abs() < 1e-6);
assert!((geo.orthogonal_height - 42.0).abs() < 1e-6);
}
const MM_MAPUNIT_IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('georef mm fixture'),'2;1');
FILE_NAME('georef-mm.ifc','2026-06-12T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
#4=IFCCARTESIANPOINT((0.,0.,0.));
#5=IFCAXIS2PLACEMENT3D(#4,$,$);
#7=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
#10=IFCPROJECTEDCRS('EPSG:25832',$,'ETRS89',$,'UTM','32N',#7);
#11=IFCMAPCONVERSION(#2,#10,512000000.,5400000000.,0.,1.,0.,1.0);
ENDSEC;
END-ISO-10303-21;
"#;
#[test]
fn resolves_millimetre_map_unit_scale() {
let geo = extract_georeferencing(MM_MAPUNIT_IFC).expect("georef");
assert_eq!(geo.map_unit.as_deref(), Some("MILLIMETRE"));
assert_eq!(geo.map_unit_scale, Some(0.001));
assert_eq!(geo.map_zone.as_deref(), Some("32N"));
}
const TWO_CONVERSIONS_IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('georef two-conversions fixture'),'2;1');
FILE_NAME('georef-two.ifc','2026-06-12T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
#4=IFCCARTESIANPOINT((0.,0.,0.));
#5=IFCAXIS2PLACEMENT3D(#4,$,$);
#10=IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);
#11=IFCMAPCONVERSION(#2,#10,111.0,222.0,0.,1.,0.,1.0);
#12=IFCMAPCONVERSION(#2,#10,999.0,888.0,0.,1.,0.,1.0);
ENDSEC;
END-ISO-10303-21;
"#;
#[test]
fn first_map_conversion_wins() {
let geo = extract_georeferencing(TWO_CONVERSIONS_IFC).expect("georef");
assert!((geo.eastings - 111.0).abs() < 1e-9);
assert!((geo.northings - 222.0).abs() < 1e-9);
}
const NON_UNIT_AXIS_IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('georef non-unit-axis fixture'),'2;1');
FILE_NAME('georef-axis.ifc','2026-06-12T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);
#4=IFCCARTESIANPOINT((0.,0.,0.));
#5=IFCAXIS2PLACEMENT3D(#4,$,$);
#10=IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);
#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,0.,3.0,4.0,1.0);
ENDSEC;
END-ISO-10303-21;
"#;
#[test]
fn non_unit_axis_is_normalised() {
let geo = extract_georeferencing(NON_UNIT_AXIS_IFC).expect("georef");
assert!((geo.x_axis_abscissa - 0.6).abs() < 1e-9);
assert!((geo.x_axis_ordinate - 0.8).abs() < 1e-9);
assert!((geo.rotation_degrees - 53.13010235415598).abs() < 1e-9);
assert!((geo.transform_matrix[0] - 0.6).abs() < 1e-9);
assert!((geo.transform_matrix[1] - 0.8).abs() < 1e-9);
}
const SITE_ONLY_IFC: &str = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('georef site-only fixture'),'2;1');
FILE_NAME('georef-site.ifc','2026-06-12T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#4=IFCCARTESIANPOINT((0.,0.,0.));
#5=IFCAXIS2PLACEMENT3D(#4,$,$);
#11=IFCLOCALPLACEMENT($,#5);
#10=IFCSITE('0Site0000000000000001',$,'Site',$,$,#11,$,$,.ELEMENT.,(47,22,30,0),(8,32,15,0),420.5,$,$);
ENDSEC;
END-ISO-10303-21;
"#;
#[test]
fn site_lat_long_fallback_matches_ts_parser() {
let geo = extract_georeferencing(SITE_ONLY_IFC).expect("site georef");
assert_eq!(geo.source.as_deref(), Some("siteLocation"));
assert_eq!(geo.crs_name.as_deref(), Some("EPSG:4326"));
assert_eq!(geo.geodetic_datum.as_deref(), Some("WGS84"));
assert_eq!(geo.map_unit.as_deref(), Some("DEGREE"));
assert!((geo.northings - 47.375).abs() < 1e-9, "lat {}", geo.northings);
assert!((geo.eastings - 8.5375).abs() < 1e-9, "long {}", geo.eastings);
assert!((geo.orthogonal_height - 420.5).abs() < 1e-9);
}
#[test]
fn returns_none_without_georeferencing() {
let plain = r#"ISO-10303-21;
HEADER;
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('0$ScRe4drECQ4DMSqUjd6d',$,'P',$,$,$,$,$,$);
ENDSEC;
END-ISO-10303-21;
"#;
assert!(extract_georeferencing(plain).is_none());
}
}