use super::*;
#[test]
fn georef_discovery_does_not_retain_unrelated_property_sets() {
let mut source = String::from("DATA;\n");
let mut types = Vec::new();
for id in 1..=1000 {
source.push_str(&format!("#{id}=IFCPROPERTYSET('g',$,'Unrelated',$,(#2001));\n"));
types.push((id, IfcType::IfcPropertySet));
}
source.push_str("#1001=IFCPROPERTYSET('g',$,'ePsEt_MapConversion',$,(#2001));\n#2001=IFCPROPERTYSINGLEVALUE('Eastings',$,IFCLENGTHMEASURE(42.),$);\nENDSEC;");
types.push((1001, IfcType::IfcPropertySet));
let mut decoder = EntityDecoder::new(&source);
let geo = GeoRefExtractor::extract(&mut decoder, &types).unwrap().unwrap();
assert_eq!(geo.eastings, 42.0);
assert_eq!(geo.source, Some(GeoRefSource::EPSetMapConversion));
assert!(decoder.cache_size() <= 2, "only the selected set and its value should be retained");
assert_eq!(decoder.decode_by_id(500).unwrap().get_string(2), Some("Unrelated"));
}
#[test]
fn test_georef_local_to_map() {
let mut georef = GeoReference::new();
georef.eastings = 500000.0;
georef.northings = 5000000.0;
georef.orthogonal_height = 100.0;
let (e, n, h) = georef.local_to_map(10.0, 20.0, 5.0);
assert!((e - 500010.0).abs() < 1e-10);
assert!((n - 5000020.0).abs() < 1e-10);
assert!((h - 105.0).abs() < 1e-10);
}
#[test]
fn test_georef_map_to_local() {
let mut georef = GeoReference::new();
georef.eastings = 500000.0;
georef.northings = 5000000.0;
georef.orthogonal_height = 100.0;
let (x, y, z) = georef.map_to_local(500010.0, 5000020.0, 105.0);
assert!((x - 10.0).abs() < 1e-10);
assert!((y - 20.0).abs() < 1e-10);
assert!((z - 5.0).abs() < 1e-10);
}
#[test]
fn test_georef_with_rotation() {
let mut georef = GeoReference::new();
georef.eastings = 0.0;
georef.northings = 0.0;
georef.x_axis_abscissa = 0.0;
georef.x_axis_ordinate = 1.0;
let (e, n, _) = georef.local_to_map(10.0, 0.0, 0.0);
assert!(e.abs() < 1e-10);
assert!((n - 10.0).abs() < 1e-10);
}
#[test]
fn test_georef_local_to_map_rotation_sign_is_a_true_rotation() {
let mut georef = GeoReference::new();
georef.eastings = 0.0;
georef.northings = 0.0;
let c = std::f64::consts::FRAC_1_SQRT_2;
georef.x_axis_abscissa = c;
georef.x_axis_ordinate = c;
let (e, n, _) = georef.local_to_map(10.0, 4.0, 0.0);
assert!((e - c * 6.0).abs() < 1e-10, "e = cos*x - sin*y, got {e}");
assert!((n - c * 14.0).abs() < 1e-10, "n = sin*x + cos*y, got {n}");
}
#[test]
fn test_georef_map_to_local_with_rotation_round_trips_local_to_map() {
let mut georef = GeoReference::new();
georef.eastings = 500000.0;
georef.northings = 4000000.0;
georef.orthogonal_height = 50.0;
georef.scale = 2.0;
let angle = std::f64::consts::FRAC_PI_6; georef.x_axis_abscissa = angle.cos();
georef.x_axis_ordinate = angle.sin();
let (lx, ly, lz) = (12.0, -7.0, 3.0);
let (e, n, h) = georef.local_to_map(lx, ly, lz);
let (x, y, z) = georef.map_to_local(e, n, h);
assert!((x - lx).abs() < 1e-9, "map_to_local must invert local_to_map (x), got {x}");
assert!((y - ly).abs() < 1e-9, "map_to_local must invert local_to_map (y), got {y}");
assert!((z - lz).abs() < 1e-9, "map_to_local must invert local_to_map (z), got {z}");
}
#[test]
fn test_extract_from_site_honours_negative_zero_degree_ref3546() {
let ifc_content = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Test'),'2;1');
FILE_NAME('test.ifc','2024-01-01',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,(-0,30,0),(-0,45,0),0.,$,$);
ENDSEC;
END-ISO-10303-21;
"#;
let mut decoder = EntityDecoder::new(ifc_content);
let entity_types = vec![(1u32, IfcType::IfcSite)];
let georef = GeoRefExtractor::extract(&mut decoder, &entity_types)
.expect("decode ok")
.expect("legacy site georeference extracted");
assert_eq!(georef.source, Some(GeoRefSource::SiteLocation));
assert!(
(georef.northings - (-0.5)).abs() < 1e-9,
"expected northings -0.5 (0°30'S), got {}",
georef.northings
);
assert!(
(georef.eastings - (-0.75)).abs() < 1e-9,
"expected eastings -0.75 (0°45'W), got {}",
georef.eastings
);
}
#[test]
fn test_extract_from_site_honours_negative_zero_in_each_compound_angle_component_ref3546() {
let cases = [
("(0,-0,30)", -(30.0 / 3600.0)),
("(0,0,-0,30)", -(30.0 / 1_000_000.0 / 3600.0)),
("(0,0,30,-0)", -(30.0 / 3600.0)),
];
for (angle, expected_northings) in cases {
let ifc_content = format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('Test'),'2;1');\nFILE_NAME('test.ifc','2024-01-01',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,{angle},(14,28,0),0.,$,$);\nENDSEC;\nEND-ISO-10303-21;\n"
);
let mut decoder = EntityDecoder::new(&ifc_content);
let georef = GeoRefExtractor::extract(&mut decoder, &[(1, IfcType::IfcSite)])
.expect("decode ok")
.expect("legacy site georeference extracted");
assert!(
(georef.northings - expected_northings).abs() < 1e-12,
"{angle} should produce {expected_northings}, got {}",
georef.northings
);
}
}
#[test]
fn test_extract_from_site_canonical_negative_degree_unaffected() {
let ifc_content = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Test'),'2;1');
FILE_NAME('test.ifc','2024-01-01',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,(-50,2,20),(14,28,0),0.,$,$);
ENDSEC;
END-ISO-10303-21;
"#;
let mut decoder = EntityDecoder::new(ifc_content);
let entity_types = vec![(1u32, IfcType::IfcSite)];
let georef = GeoRefExtractor::extract(&mut decoder, &entity_types)
.expect("decode ok")
.expect("legacy site georeference extracted");
let expected_lat = -(50.0 + 2.0 / 60.0 + 20.0 / 3600.0);
let expected_lon = 14.0 + 28.0 / 60.0;
assert!((georef.northings - expected_lat).abs() < 1e-9);
assert!((georef.eastings - expected_lon).abs() < 1e-9);
}
#[test]
fn test_extract_from_site_negative_zero_scan_ignores_commas_inside_quoted_strings() {
let ifc_content = r#"ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Test'),'2;1');
FILE_NAME('test.ifc','2024-01-01',(''),(''),'','','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCSITE('1abc',$,'Site, (annex)',$,$,$,$,$,.ELEMENT.,(-0,30,0),(-0,45,0),0.,$,$);
ENDSEC;
END-ISO-10303-21;
"#;
let mut decoder = EntityDecoder::new(ifc_content);
let entity_types = vec![(1u32, IfcType::IfcSite)];
let georef = GeoRefExtractor::extract(&mut decoder, &entity_types)
.expect("decode ok")
.expect("legacy site georeference extracted");
assert!((georef.northings - (-0.5)).abs() < 1e-9);
assert!((georef.eastings - (-0.75)).abs() < 1e-9);
}
#[test]
fn issue_4687_negative_zero_scan_treats_comments_as_trivia() {
for site in [
"#1=IFCSITE('1abc',$,/* a, b */'Site',$,$,$,$,$,.ELEMENT.,(-0,30,0),(-0,45,0),0.,$,$);",
"#1=IFCSITE('1abc',$,/* it's */'Site',$,$,$,$,$,.ELEMENT.,(-0,30,0),(-0,45,0),0.,$,$);",
"#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,(-0 /* deg */,30,0),( /* deg */ -0,45,0),0.,$,$);",
] {
let ifc_content = format!("DATA;\n{site}\nENDSEC;\n");
let mut decoder = EntityDecoder::new(&ifc_content);
let georef = GeoRefExtractor::extract(&mut decoder, &[(1u32, IfcType::IfcSite)])
.expect("decode ok")
.expect("legacy site georeference extracted");
assert!((georef.northings - (-0.5)).abs() < 1e-9, "{site}: {}", georef.northings);
assert!((georef.eastings - (-0.75)).abs() < 1e-9, "{site}: {}", georef.eastings);
}
}
fn ifc4x3_with_conversion(map_conversion_line: &str) -> String {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('Test'),'2;1');\nFILE_NAME('t.ifc','2026-01-01',(''),(''),'','','');\nFILE_SCHEMA(('IFC4X3_ADD2'));\nENDSEC;\nDATA;\n#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#5,$);\n#4=IFCCARTESIANPOINT((0.,0.,0.));\n#5=IFCAXIS2PLACEMENT3D(#4,$,$);\n#10=IFCPROJECTEDCRS('EPSG:32632',$,$,$,$,$,$);\n{map_conversion_line}\nENDSEC;\nEND-ISO-10303-21;\n"
)
}
fn extract_map_conversion(map_conversion_line: &str) -> Option<GeoReference> {
extract_map_conversion_as(map_conversion_line, IfcType::IfcMapConversion)
}
fn extract_map_conversion_as(map_conversion_line: &str, conversion_type: IfcType) -> Option<GeoReference> {
let content = ifc4x3_with_conversion(map_conversion_line);
let mut decoder = EntityDecoder::new(&content);
let types = vec![(11u32, conversion_type), (10, IfcType::IfcProjectedCRS)];
GeoRefExtractor::extract(&mut decoder, &types).expect("decode ok")
}
#[test]
fn scaled_map_conversion_factors_scale_each_axis() {
let geo = extract_map_conversion(
"#11=IFCMAPCONVERSIONSCALED(#2,#10,1000.,2000.,42.,1.,0.,$,0.3048,0.3048,0.3048);",
)
.expect("scaled conversion is a georeference");
assert_eq!((geo.factor_x, geo.factor_y, geo.factor_z), (0.3048, 0.3048, 0.3048));
assert_eq!(geo.scale, 1.0, "the inherited uniform Scale stays at its default");
let (e, n, h) = geo.local_to_map(10.0, 20.0, 5.0);
assert!((e - 1003.048).abs() < 1e-9, "e = {e}");
assert!((n - 2006.096).abs() < 1e-9, "n = {n}");
assert!((h - 43.524).abs() < 1e-9, "h = {h}");
let m = geo.to_matrix();
assert!((m[0] - 0.3048).abs() < 1e-12 && (m[5] - 0.3048).abs() < 1e-12 && (m[10] - 0.3048).abs() < 1e-12,
"matrix diagonal must carry the factors, got [{}, {}, {}]", m[0], m[5], m[10]);
let (x, y, z) = geo.map_to_local(e, n, h);
assert!((x - 10.0).abs() < 1e-9 && (y - 20.0).abs() < 1e-9 && (z - 5.0).abs() < 1e-9);
}
#[test]
fn scaled_map_conversion_applies_factors_before_rotation() {
let geo = extract_map_conversion(
"#11=IFCMAPCONVERSIONSCALED(#2,#10,0.,0.,0.,0.,1.,2.,3.,5.,7.);",
)
.expect("georeference");
let (e, n, h) = geo.local_to_map(1.0, 1.0, 1.0);
assert!((e + 10.0).abs() < 1e-9, "e = {e}");
assert!((n - 6.0).abs() < 1e-9, "n = {n}");
assert!((h - 14.0).abs() < 1e-9, "h = {h}");
let (x, y, z) = geo.map_to_local(e, n, h);
assert!((x - 1.0).abs() < 1e-9 && (y - 1.0).abs() < 1e-9 && (z - 1.0).abs() < 1e-9);
}
#[test]
fn scaled_map_conversion_typed_as_its_own_type_is_extracted() {
let geo = extract_map_conversion_as(
"#11=IFCMAPCONVERSIONSCALED(#2,#10,1000.,2000.,42.,1.,0.,$,0.3048,0.3048,0.3048);",
IfcType::from_str("IFCMAPCONVERSIONSCALED"),
)
.expect("a scaled conversion typed as itself is a georeference");
assert_eq!(geo.source, Some(GeoRefSource::MapConversion));
let (e, _, _) = geo.local_to_map(10.0, 20.0, 5.0);
assert!((e - 1003.048).abs() < 1e-9, "factors applied, e = {e}");
}
#[test]
fn plain_map_conversion_keeps_unit_factors() {
let geo = extract_map_conversion("#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,42.,1.,0.,2.);")
.expect("georeference");
assert_eq!((geo.factor_x, geo.factor_y, geo.factor_z), (1.0, 1.0, 1.0));
assert_eq!(geo.scale, 2.0);
assert_eq!(geo.local_to_map(10.0, 20.0, 5.0), (1020.0, 2040.0, 52.0));
}
#[test]
fn zero_length_axis_direction_resets_to_identity() {
let geo = extract_map_conversion("#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,42.,0.,0.,1.);")
.expect("georeference");
assert_eq!((geo.x_axis_abscissa, geo.x_axis_ordinate), (1.0, 0.0));
assert_eq!(geo.local_to_map(10.0, 20.0, 5.0), (1010.0, 2020.0, 47.0));
assert_ne!(geo.local_to_map(1.0, 0.0, 0.0), geo.local_to_map(2.0, 0.0, 0.0));
}
#[test]
fn map_conversion_with_a_non_finite_component_is_refused_whole() {
for slot in 2..=10 {
let mut values = [
"1000.", "2000.", "42.", "1.", "0.", "1.", "1.", "1.", "1.",
];
values[slot - 2] = "1.0E999";
let line = format!("#11=IFCMAPCONVERSIONSCALED(#2,#10,{});", values.join(","));
let geo = extract_map_conversion(&line).expect("the IfcProjectedCRS still claims georeferencing");
assert!(!geo.has_map_conversion, "slot {slot}: conversion must be refused");
assert_eq!(geo.crs_name.as_deref(), Some("EPSG:32632"));
assert_eq!(geo.local_to_map(10.0, 20.0, 5.0), (10.0, 20.0, 5.0), "slot {slot}");
}
let content = ifc4x3_with_conversion(
"#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,42.,1.0E999,0.,1.);\n#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,(51,30,0),(14,28,0),0.,$,$);",
);
let mut decoder = EntityDecoder::new(&content);
let types = [(11u32, IfcType::IfcMapConversion), (1, IfcType::IfcSite)];
let geo = GeoRefExtractor::extract(&mut decoder, &types)
.expect("decode ok")
.expect("with no CRS, the IfcSite fallback answers");
assert_eq!(geo.source, Some(GeoRefSource::SiteLocation));
}
#[test]
fn zero_scale_or_factor_resets_to_one() {
let geo = extract_map_conversion("#11=IFCMAPCONVERSION(#2,#10,1000.,2000.,42.,1.,0.,0.);")
.expect("georeference");
assert_eq!(geo.scale, 1.0);
assert_eq!(geo.local_to_map(10.0, 20.0, 5.0), (1010.0, 2020.0, 47.0));
let geo = extract_map_conversion(
"#11=IFCMAPCONVERSIONSCALED(#2,#10,1000.,2000.,42.,1.,0.,$,3.,0.,2.);",
)
.expect("georeference");
assert_eq!((geo.factor_x, geo.factor_y, geo.factor_z), (3.0, 1.0, 2.0));
assert_eq!(geo.local_to_map(10.0, 20.0, 5.0), (1030.0, 2020.0, 52.0));
}
#[test]
fn rotation_only_map_conversion_is_reported() {
let content = ifc4x3_with_conversion(
"#11=IFCMAPCONVERSION(#2,#12,0.,0.,0.,0.8660254037844387,0.5,1.);\n#12=IFCGEOGRAPHICCRS('EPSG:4326',$,$,$,$,$,$);",
);
let mut decoder = EntityDecoder::new(&content);
let geo = GeoRefExtractor::extract(&mut decoder, &[(11u32, IfcType::IfcMapConversion)])
.expect("decode ok")
.expect("a parsed map conversion is a georeference even with zero offsets");
assert!(geo.has_map_conversion);
assert_eq!(geo.crs_name, None);
assert!((geo.rotation().to_degrees() - 30.0).abs() < 1e-9);
}
#[test]
fn compound_plane_angle_with_non_numeric_component_is_refused() {
let angles = ["($,51,30,0)", "(51,$,30,0)", "(51,30,$,0)", "(51,30,0,$)"];
for angle in angles {
let content = format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('Test'),'2;1');\nFILE_NAME('t.ifc','2026-01-01',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,{angle},(14,28,0),0.,$,$);\nENDSEC;\nEND-ISO-10303-21;\n"
);
let mut decoder = EntityDecoder::new(&content);
let geo = GeoRefExtractor::extract(&mut decoder, &[(1, IfcType::IfcSite)]).expect("decode ok");
assert!(geo.is_none(), "{angle} must be refused, got {:?}", geo.map(|g| g.northings));
}
let content = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('Test'),'2;1');\nFILE_NAME('t.ifc','2026-01-01',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n#1=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,(51,30,0,500000),(14,28,0),0.,$,$);\nENDSEC;\nEND-ISO-10303-21;\n";
let mut decoder = EntityDecoder::new(content);
let geo = GeoRefExtractor::extract(&mut decoder, &[(1, IfcType::IfcSite)])
.expect("decode ok")
.expect("numeric angle resolves");
let expected = 51.0 + 30.0 / 60.0 + 0.5 / 3600.0;
assert!((geo.northings - expected).abs() < 1e-12, "got {}", geo.northings);
}
#[test]
fn undecodable_projected_crs_blocks_nothing_without_a_conversion() {
let file = |conversion: &str| {
format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('Test'),'2;1');\nFILE_NAME('t.ifc','2026-01-01',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n#1=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,$,$);\n#2=IFCPROJECTEDCRS(%%%);\n{conversion}\n#4=IFCSITE('1abc',$,'Site',$,$,$,$,$,.ELEMENT.,(51,30,0),(14,28,0),0.,$,$);\nENDSEC;\nEND-ISO-10303-21;\n"
)
};
let content = file("");
let mut decoder = EntityDecoder::new(&content);
let types = [(2, IfcType::IfcProjectedCRS), (4, IfcType::IfcSite)];
let geo = GeoRefExtractor::extract(&mut decoder, &types)
.expect("an undecodable CRS alone is not an extraction error")
.expect("the site fallback answers");
assert_eq!(geo.source, Some(GeoRefSource::SiteLocation));
let content = file("#3=IFCMAPCONVERSION(#1,#2,1000.,2000.,42.,1.,0.,1.);");
let mut decoder = EntityDecoder::new(&content);
let types = [
(2, IfcType::IfcProjectedCRS),
(3, IfcType::IfcMapConversion),
(4, IfcType::IfcSite),
];
assert!(GeoRefExtractor::extract(&mut decoder, &types).is_err());
}