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 std::sync::Arc;
17
18use ifc_lite_core::{
19 keyword_eq, EntityDecoder, EntityIndex, EntityScanner, GeoRefExtractor, IfcType,
20};
21use serde::{Deserialize, Serialize};
22
23/// Georeferencing metadata (`IfcMapConversion` + `IfcProjectedCRS`).
24///
25/// Mirrors `ifc_lite_core::GeoReference` with two derived conveniences
26/// (`rotation_degrees`, `transform_matrix`) so consumers don't have to
27/// recompute the rotation or the local→map matrix.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct Georeferencing {
30 /// Projected CRS name from `IfcProjectedCRS.Name` (e.g. `"EPSG:32632"`).
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub crs_name: Option<String>,
33 /// Geodetic datum (e.g. `"WGS84"`).
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub geodetic_datum: Option<String>,
36 /// Vertical datum (e.g. `"NAVD88"`).
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub vertical_datum: Option<String>,
39 /// Map projection (e.g. `"UTM"`).
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub map_projection: Option<String>,
42 /// False easting — X offset to the map CRS, in the project's length unit.
43 pub eastings: f64,
44 /// False northing — Y offset to the map CRS, in the project's length unit.
45 pub northings: f64,
46 /// Orthogonal height — Z offset to the map CRS.
47 pub orthogonal_height: f64,
48 /// X-axis abscissa: cosine of the rotation to grid north.
49 pub x_axis_abscissa: f64,
50 /// X-axis ordinate: sine of the rotation to grid north.
51 pub x_axis_ordinate: f64,
52 /// Scale factor applied during the local→map transform (default `1.0`).
53 pub scale: f64,
54 /// Per-axis factors from `IfcMapConversionScaled` (default `1.0`).
55 #[serde(default = "default_axis_factor")]
56 pub factor_x: f64,
57 #[serde(default = "default_axis_factor")]
58 pub factor_y: f64,
59 #[serde(default = "default_axis_factor")]
60 pub factor_z: f64,
61 /// Rotation to grid north in degrees, derived from the X-axis direction.
62 pub rotation_degrees: f64,
63 /// Local→map transform as a column-major 4×4 matrix (16 values).
64 pub transform_matrix: [f64; 16],
65 /// CRS description from `IfcProjectedCRS.Description`.
66 #[serde(skip_serializing_if = "Option::is_none", default)]
67 pub crs_description: Option<String>,
68 /// Map zone (e.g. `"32N"`) from `IfcProjectedCRS.MapZone`.
69 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub map_zone: Option<String>,
71 /// Map unit name resolved from `IfcProjectedCRS.MapUnit` (e.g. `"METRE"`,
72 /// `"MILLIMETRE"`); absent when no MapUnit is authored.
73 #[serde(skip_serializing_if = "Option::is_none", default)]
74 pub map_unit: Option<String>,
75 /// Scale factor converting MapConversion values to metres (0.001 for
76 /// millimetres); absent when no MapUnit is authored.
77 #[serde(skip_serializing_if = "Option::is_none", default)]
78 pub map_unit_scale: Option<f64>,
79 /// Provenance: `"mapConversion"`, `"ePSetMapConversion"`, or
80 /// `"siteLocation"` — same labels as the TS parser's
81 /// `GeoreferenceInfo.source`.
82 #[serde(skip_serializing_if = "Option::is_none", default)]
83 pub source: Option<String>,
84}
85
86const fn default_axis_factor() -> f64 {
87 1.0
88}
89
90impl Default for Georeferencing {
91 fn default() -> Self {
92 Self {
93 crs_name: None,
94 geodetic_datum: None,
95 vertical_datum: None,
96 map_projection: None,
97 eastings: 0.0,
98 northings: 0.0,
99 orthogonal_height: 0.0,
100 x_axis_abscissa: 0.0,
101 x_axis_ordinate: 0.0,
102 scale: 0.0,
103 factor_x: 1.0,
104 factor_y: 1.0,
105 factor_z: 1.0,
106 rotation_degrees: 0.0,
107 transform_matrix: [0.0; 16],
108 crs_description: None,
109 map_zone: None,
110 map_unit: None,
111 map_unit_scale: None,
112 source: None,
113 }
114 }
115}
116
117impl Georeferencing {
118 fn from_core(geo: &ifc_lite_core::GeoReference) -> Self {
119 Self {
120 crs_name: geo.crs_name.clone(),
121 geodetic_datum: geo.geodetic_datum.clone(),
122 vertical_datum: geo.vertical_datum.clone(),
123 map_projection: geo.map_projection.clone(),
124 eastings: geo.eastings,
125 northings: geo.northings,
126 orthogonal_height: geo.orthogonal_height,
127 x_axis_abscissa: geo.x_axis_abscissa,
128 x_axis_ordinate: geo.x_axis_ordinate,
129 scale: geo.scale,
130 factor_x: geo.factor_x,
131 factor_y: geo.factor_y,
132 factor_z: geo.factor_z,
133 rotation_degrees: geo.rotation().to_degrees(),
134 transform_matrix: geo.to_matrix(),
135 crs_description: geo.crs_description.clone(),
136 map_zone: geo.map_zone.clone(),
137 map_unit: geo.map_unit.clone(),
138 map_unit_scale: geo.map_unit_scale,
139 source: geo.source.map(|s| s.label().to_string()),
140 }
141 }
142}
143
144/// Extract georeferencing from an IFC file, returning `None` when the model
145/// carries no `IfcMapConversion`, named `IfcProjectedCRS`,
146/// `ePSet_MapConversion` or `IfcSite` lat/long data.
147///
148/// Only the entity types the extractor needs (`IfcMapConversion`,
149/// `IfcProjectedCRS`, and `IfcPropertySet` for the IFC2x3 `ePSet_MapConversion`
150/// fallback) are collected from the scan — their `IfcType` is known from the
151/// entity name, so no decoding happens while building the candidate list.
152pub fn extract_georeferencing<T>(content: &T) -> Option<Georeferencing>
153where
154 T: AsRef<[u8]> + ?Sized,
155{
156 let content = content.as_ref();
157 // Parallel on native (byte-identical to `build_entity_index`), serial on wasm.
158 let entity_index = Arc::new(crate::build_entity_index_parallel(content));
159 extract_georeferencing_with_index(content, &entity_index)
160}
161
162/// [`extract_georeferencing`], reusing an index the caller already holds.
163///
164/// The extractor resolves its references by id, so this pass genuinely needs an
165/// index; what it does not need is a second one. A caller that has already
166/// scanned the file (the geometry pipeline holds one for its whole run) was
167/// paying a full extra scan to build a map it had in hand.
168///
169/// `entity_index` must have been built from this same `content`. Given that, the
170/// result is identical to the wrapper's.
171pub fn extract_georeferencing_with_index(
172 content: &[u8],
173 entity_index: &Arc<EntityIndex>,
174) -> Option<Georeferencing> {
175 let mut entity_types = Vec::new();
176 let mut scanner = EntityScanner::new(content);
177 while let Some((id, type_name, _, _)) = scanner.next_entity() {
178 if let Some(ifc_type) = georeferencing_candidate_type(type_name) {
179 entity_types.push((id, ifc_type));
180 }
181 }
182 extract_georeferencing_from_candidates(
183 &mut EntityDecoder::with_arc_index(content, entity_index.clone()), &entity_types)
184}
185
186/// Candidate classification shared by standalone extraction and the native
187/// geometry scan. Preserves file order, including all sites.
188///
189/// `type_name` is the STEP keyword exactly as the scanner read it, and STEP
190/// keyword case is not significant (ISO 10303-21), so the comparison is
191/// case-insensitive. A case-sensitive match silently classified every entity in
192/// a lowercase- or CamelCase-keyword file as a non-candidate, and the model then
193/// reported no georeferencing at all despite carrying complete data (#4497).
194/// `keyword_eq` rather than an uppercase copy: this runs once per entity in
195/// the scan loop, and the comparison is against fixed literals, so there is
196/// nothing an allocated canonical form would be reused for — the same shape
197/// `processor::quick_metadata::is_quick_spatial_type_ci` uses.
198pub(crate) fn georeferencing_candidate_type(type_name: &str) -> Option<IfcType> {
199 // Scaled's first eight attributes have the base conversion layout.
200 if keyword_eq(type_name, "IFCMAPCONVERSION")
201 || keyword_eq(type_name, "IFCMAPCONVERSIONSCALED")
202 {
203 return Some(IfcType::IfcMapConversion);
204 }
205 if keyword_eq(type_name, "IFCPROJECTEDCRS") {
206 return Some(IfcType::IfcProjectedCRS);
207 }
208 if keyword_eq(type_name, "IFCPROPERTYSET") {
209 return Some(IfcType::IfcPropertySet);
210 }
211 if keyword_eq(type_name, "IFCSITE") {
212 return Some(IfcType::IfcSite);
213 }
214 None
215}
216
217/// Reuse candidates from the geometry scan, avoiding the remaining whole-file
218/// scan after index reuse. A fresh decoder preserves the standalone extractor's
219/// lookup semantics, including duplicate ids, rather than inheriting scan caches.
220pub(crate) fn extract_georeferencing_from_candidates(
221 decoder: &mut EntityDecoder<'_>,
222 entity_types: &[(u32, IfcType)],
223) -> Option<Georeferencing> {
224 if entity_types.is_empty() {
225 return None;
226 }
227
228 match GeoRefExtractor::extract(decoder, entity_types) {
229 Ok(Some(geo)) => Some(Georeferencing::from_core(&geo)),
230 Ok(None) => None,
231 Err(e) => {
232 tracing::debug!(error = %e, "Georeferencing extraction failed");
233 None
234 }
235 }
236}
237
238#[cfg(test)]
239#[path = "georeferencing_tests.rs"]
240mod tests;