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::{EntityDecoder, EntityIndex, EntityScanner, GeoRefExtractor, IfcType};
19use serde::{Deserialize, Serialize};
20
21/// Georeferencing metadata (`IfcMapConversion` + `IfcProjectedCRS`).
22///
23/// Mirrors `ifc_lite_core::GeoReference` with two derived conveniences
24/// (`rotation_degrees`, `transform_matrix`) so consumers don't have to
25/// recompute the rotation or the local→map matrix.
26#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
27pub struct Georeferencing {
28 /// Projected CRS name from `IfcProjectedCRS.Name` (e.g. `"EPSG:32632"`).
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub crs_name: Option<String>,
31 /// Geodetic datum (e.g. `"WGS84"`).
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub geodetic_datum: Option<String>,
34 /// Vertical datum (e.g. `"NAVD88"`).
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub vertical_datum: Option<String>,
37 /// Map projection (e.g. `"UTM"`).
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub map_projection: Option<String>,
40 /// False easting — X offset to the map CRS, in the project's length unit.
41 pub eastings: f64,
42 /// False northing — Y offset to the map CRS, in the project's length unit.
43 pub northings: f64,
44 /// Orthogonal height — Z offset to the map CRS.
45 pub orthogonal_height: f64,
46 /// X-axis abscissa: cosine of the rotation to grid north.
47 pub x_axis_abscissa: f64,
48 /// X-axis ordinate: sine of the rotation to grid north.
49 pub x_axis_ordinate: f64,
50 /// Scale factor applied during the local→map transform (default `1.0`).
51 pub scale: f64,
52 /// Rotation to grid north in degrees, derived from the X-axis direction.
53 pub rotation_degrees: f64,
54 /// Local→map transform as a column-major 4×4 matrix (16 values).
55 pub transform_matrix: [f64; 16],
56 /// CRS description from `IfcProjectedCRS.Description`.
57 #[serde(skip_serializing_if = "Option::is_none", default)]
58 pub crs_description: Option<String>,
59 /// Map zone (e.g. `"32N"`) from `IfcProjectedCRS.MapZone`.
60 #[serde(skip_serializing_if = "Option::is_none", default)]
61 pub map_zone: Option<String>,
62 /// Map unit name resolved from `IfcProjectedCRS.MapUnit` (e.g. `"METRE"`,
63 /// `"MILLIMETRE"`); absent when no MapUnit is authored.
64 #[serde(skip_serializing_if = "Option::is_none", default)]
65 pub map_unit: Option<String>,
66 /// Scale factor converting MapConversion values to metres (0.001 for
67 /// millimetres); absent when no MapUnit is authored.
68 #[serde(skip_serializing_if = "Option::is_none", default)]
69 pub map_unit_scale: Option<f64>,
70 /// Provenance: `"mapConversion"`, `"ePSetMapConversion"`, or
71 /// `"siteLocation"` — same labels as the TS parser's
72 /// `GeoreferenceInfo.source`.
73 #[serde(skip_serializing_if = "Option::is_none", default)]
74 pub source: Option<String>,
75}
76
77impl Georeferencing {
78 fn from_core(geo: &ifc_lite_core::GeoReference) -> Self {
79 Self {
80 crs_name: geo.crs_name.clone(),
81 geodetic_datum: geo.geodetic_datum.clone(),
82 vertical_datum: geo.vertical_datum.clone(),
83 map_projection: geo.map_projection.clone(),
84 eastings: geo.eastings,
85 northings: geo.northings,
86 orthogonal_height: geo.orthogonal_height,
87 x_axis_abscissa: geo.x_axis_abscissa,
88 x_axis_ordinate: geo.x_axis_ordinate,
89 scale: geo.scale,
90 rotation_degrees: geo.rotation().to_degrees(),
91 transform_matrix: geo.to_matrix(),
92 crs_description: geo.crs_description.clone(),
93 map_zone: geo.map_zone.clone(),
94 map_unit: geo.map_unit.clone(),
95 map_unit_scale: geo.map_unit_scale,
96 source: Some(geo.source.label().to_string()),
97 }
98 }
99}
100
101/// Extract georeferencing from an IFC file, returning `None` when the model
102/// carries no `IfcMapConversion` / `ePSet_MapConversion` data.
103///
104/// Only the entity types the extractor needs (`IfcMapConversion`,
105/// `IfcProjectedCRS`, and `IfcPropertySet` for the IFC2x3 `ePSet_MapConversion`
106/// fallback) are collected from the scan — their `IfcType` is known from the
107/// entity name, so no decoding happens while building the candidate list.
108pub fn extract_georeferencing<T>(content: &T) -> Option<Georeferencing>
109where
110 T: AsRef<[u8]> + ?Sized,
111{
112 let content = content.as_ref();
113 // Parallel on native (byte-identical to `build_entity_index`), serial on wasm.
114 let entity_index = Arc::new(crate::build_entity_index_parallel(content));
115 extract_georeferencing_with_index(content, &entity_index)
116}
117
118/// [`extract_georeferencing`], reusing an index the caller already holds.
119///
120/// The extractor resolves its references by id, so this pass genuinely needs an
121/// index; what it does not need is a second one. A caller that has already
122/// scanned the file (the geometry pipeline holds one for its whole run) was
123/// paying a full extra scan to build a map it had in hand.
124///
125/// `entity_index` must have been built from this same `content`. Given that, the
126/// result is identical to the wrapper's.
127pub fn extract_georeferencing_with_index(
128 content: &[u8],
129 entity_index: &Arc<EntityIndex>,
130) -> Option<Georeferencing> {
131 let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
132
133 let mut entity_types: Vec<(u32, IfcType)> = Vec::new();
134 let mut scanner = EntityScanner::new(content);
135 while let Some((id, type_name, _start, _end)) = scanner.next_entity() {
136 match type_name {
137 // IFC4X3's concrete subtype of IfcMapConversion. The scanner
138 // classifies by the RAW STEP type name, so matching the supertype
139 // spelling alone found nothing in a file written this way and the
140 // extractor fell through to the ePSet / legacy-IfcSite fallbacks —
141 // reporting no map conversion, hence no transform, for a file that
142 // carries one. Its first eight attributes ARE IfcMapConversion's,
143 // and `GeoRefExtractor` reads them positionally, so classifying it
144 // as the supertype is exactly right; the FactorX/Y/Z it adds sit
145 // after them and are ignored. Same widening as the TS reader's
146 // `MAP_CONVERSION_TYPE_NAMES`.
147 "IFCMAPCONVERSION" | "IFCMAPCONVERSIONSCALED" => {
148 entity_types.push((id, IfcType::IfcMapConversion))
149 }
150 "IFCPROJECTEDCRS" => entity_types.push((id, IfcType::IfcProjectedCRS)),
151 "IFCPROPERTYSET" => entity_types.push((id, IfcType::IfcPropertySet)),
152 // Legacy IfcSite RefLatitude/RefLongitude fallback (TS parity).
153 "IFCSITE" => entity_types.push((id, IfcType::IfcSite)),
154 _ => {}
155 }
156 }
157
158 if entity_types.is_empty() {
159 return None;
160 }
161
162 match GeoRefExtractor::extract(&mut decoder, &entity_types) {
163 Ok(Some(geo)) => Some(Georeferencing::from_core(&geo)),
164 Ok(None) => None,
165 Err(e) => {
166 tracing::debug!(error = %e, "Georeferencing extraction failed");
167 None
168 }
169 }
170}
171
172#[cfg(test)]
173#[path = "georeferencing_tests.rs"]
174mod tests;