ifc_georef/conversion/operation.rs
1//! The resolved project-to-map value and the entry points that dispatch
2//! every `IfcCoordinateOperation` subtype.
3//!
4//! | Subtype | Release | Lowered by |
5//! | --- | --- | --- |
6//! | `IfcMapConversion` | IFC4, IFC4X3 | `map.rs` |
7//! | `IfcMapConversionScaled` | IFC4X3 | `map.rs`, per-axis factors |
8//! | `IfcRigidOperation`, length coordinates | IFC4X3 | `rigid.rs`, a translation |
9//! | `IfcRigidOperation`, plane-angle coordinates | IFC4X3 | refused here; [`crate::resolve_geographic_offset_in`] |
10//!
11//! A plane-angle rigid operation offsets latitude and longitude on an
12//! `IfcGeographicCRS`. No metre-to-metre affine transform expresses that,
13//! so it is refused with [`GeorefError::CoordinateMeasureMismatch`] rather
14//! than approximated.
15//!
16//! # Kernel-free parameters
17//!
18//! [`ProjectToMap`] carries the resolved operation as plain `f64` (its
19//! authored eastings, northings and height, and the metre-to-metre linear
20//! part and translation behind [`ProjectToMap::map_point`]). The
21//! `axiolid_core::Transform3` view of the same operation exists only with
22//! the default `transform` feature, so a semantic consumer can read every
23//! parameter without linking a geometry kernel (#268).
24
25#[cfg(feature = "transform")]
26use axiolid_core::Transform3;
27use ifc_model::value::Value;
28use ifc_model::{Entity, EntityId, Model};
29
30use crate::context::OperationSource;
31use crate::crs::{LengthUnit, ProjectedCrs};
32use crate::error::{GeorefError, GeorefResult};
33use crate::view::GeorefView;
34
35use super::{map, rigid};
36
37/// Which coordinate-operation subtype a [`ProjectToMap`] was lowered from.
38#[derive(Debug, Clone, Copy, PartialEq)]
39#[non_exhaustive]
40pub enum OperationKind {
41 /// `IfcMapConversion`.
42 MapConversion,
43 /// IFC4X3 `IfcMapConversionScaled`, with its `(FactorX, FactorY,
44 /// FactorZ)` as authored. They are folded into `transform`.
45 MapConversionScaled {
46 /// `(FactorX, FactorY, FactorZ)`.
47 factors: (f64, f64, f64),
48 },
49 /// IFC4X3 `IfcRigidOperation` with `IfcLengthMeasure` coordinates.
50 RigidOperation {
51 /// `Height` in map units as authored. `None` when the file states
52 /// no height: the transform then shifts heights by nothing, and
53 /// this records that no vertical offset was declared.
54 height: Option<f64>,
55 },
56}
57
58/// A resolved project-to-map coordinate operation, normalised to metres.
59#[derive(Debug, Clone, PartialEq)]
60#[non_exhaustive]
61pub struct ProjectToMap {
62 /// The operation's `SourceCRS`, an
63 /// `IfcCoordinateReferenceSystemSelect`: an
64 /// `IfcGeometricRepresentationContext` or an
65 /// `IfcCoordinateReferenceSystem`, in IFC4 and IFC4X3 alike. `source`
66 /// says which.
67 pub source_crs: EntityId,
68 /// The validated source, typed.
69 pub source: OperationSource,
70 /// The coordinate-operation entity this was resolved from.
71 pub operation: EntityId,
72 /// Which subtype `operation` is, with its subtype-only attributes.
73 pub kind: OperationKind,
74 /// The target projected CRS.
75 pub target_crs: ProjectedCrs,
76 /// Affine operation from neutral project metres to neutral map metres.
77 ///
78 /// Only with the default `transform` feature. The same operation is
79 /// always available as plain numbers through [`Self::map_point`],
80 /// [`Self::linear_part`] and [`Self::translation`].
81 #[cfg(feature = "transform")]
82 pub transform: Transform3,
83 /// `Eastings` as authored, in `map_unit`. For a rigid operation,
84 /// `FirstCoordinate`, the offset along the target CRS's first axis.
85 pub eastings: f64,
86 /// `Northings` as authored, in `map_unit`. For a rigid operation,
87 /// `SecondCoordinate`, the offset along the target CRS's second axis.
88 pub northings: f64,
89 /// `OrthogonalHeight` as authored, in `map_unit`. For a rigid
90 /// operation, `Height`, or `0.0` when the file states none (see
91 /// [`OperationKind::RigidOperation`]).
92 pub orthogonal_height: f64,
93 /// Length unit the project authored its coordinates in.
94 pub project_unit: LengthUnit,
95 /// Length unit the map coordinates are expressed in, resolved: the
96 /// target CRS's `MapUnit` when stated, otherwise the project length
97 /// unit, which IFC prescribes for an omitted `MapUnit`. `transform`,
98 /// [`Self::map_point`] and [`Self::translation`] use this unit. Whether
99 /// the file stated it is [`Self::declared_map_unit`].
100 pub map_unit: LengthUnit,
101 /// IFC's declared scale before source/target unit normalization. `1.0`
102 /// for a rigid operation, which has none.
103 pub declared_scale: f64,
104 /// Normalized `(XAxisAbscissa, XAxisOrdinate)`: the project's local X
105 /// axis, as a unit vector, expressed in the map's XY plane. Exposed
106 /// (rather than only folded into `transform`) because grid-north
107 /// resolution needs the rotation alone, without `transform`'s scale
108 /// and translation. `(1, 0)` for a rigid operation.
109 pub x_axis_direction: (f64, f64),
110 /// Linear part, metres to metres, as three columns (the images of the
111 /// project's X, Y and Z axes).
112 linear: [[f64; 3]; 3],
113 /// Translation, map metres: the map position of the project origin.
114 translation: [f64; 3],
115}
116
117impl ProjectToMap {
118 /// Assemble the resolved value from its metre-to-metre affine parts,
119 /// deriving the `Transform3` view when the feature is on.
120 #[allow(clippy::too_many_arguments)]
121 pub(super) fn new(
122 source: OperationSource,
123 operation: EntityId,
124 kind: OperationKind,
125 target_crs: ProjectedCrs,
126 authored: [f64; 3],
127 units: (LengthUnit, LengthUnit),
128 declared_scale: f64,
129 x_axis_direction: (f64, f64),
130 linear: [[f64; 3]; 3],
131 translation: [f64; 3],
132 ) -> Self {
133 let (project_unit, map_unit) = units;
134 Self {
135 source_crs: source.entity(),
136 source,
137 operation,
138 kind,
139 target_crs,
140 #[cfg(feature = "transform")]
141 transform: transform3(&linear, &translation),
142 eastings: authored[0],
143 northings: authored[1],
144 orthogonal_height: authored[2],
145 project_unit,
146 map_unit,
147 declared_scale,
148 x_axis_direction,
149 linear,
150 translation,
151 }
152 }
153
154 /// Carry a point from project metres to map metres.
155 ///
156 /// Plain arithmetic on the resolved parameters, identical to
157 /// `transform.transform_point3` (same operation order, so the same
158 /// rounding) and available without the `transform` feature.
159 #[must_use]
160 pub fn map_point(&self, point: [f64; 3]) -> [f64; 3] {
161 let [c0, c1, c2] = self.linear;
162 let t = self.translation;
163 std::array::from_fn(|i| c0[i] * point[0] + c1[i] * point[1] + c2[i] * point[2] + t[i])
164 }
165
166 /// The linear part, metres to metres, as three columns: the images of
167 /// the project's unit X, Y and Z axes. Scale, unit conversion, per-axis
168 /// factors and rotation are folded in.
169 #[must_use]
170 pub fn linear_part(&self) -> [[f64; 3]; 3] {
171 self.linear
172 }
173
174 /// The target CRS's `MapUnit` exactly as authored: `None` when the
175 /// file leaves it unset, even though [`Self::map_unit`] then resolves
176 /// to the project length unit. With `MapUnit` stated, this and
177 /// `map_unit` are the same unit.
178 ///
179 /// The value is [`ProjectedCrs::map_unit`] of [`Self::target_crs`];
180 /// this accessor names it next to the resolved unit (#296).
181 #[must_use]
182 pub fn declared_map_unit(&self) -> Option<&LengthUnit> {
183 self.target_crs.map_unit.as_ref()
184 }
185
186 /// The translation in map metres: where the project origin lands.
187 /// `[eastings, northings, orthogonal_height]` converted from
188 /// `map_unit` to metres.
189 #[must_use]
190 pub fn translation(&self) -> [f64; 3] {
191 self.translation
192 }
193}
194
195/// The `Transform3` view of an affine operation held as plain numbers.
196#[cfg(feature = "transform")]
197fn transform3(linear: &[[f64; 3]; 3], translation: &[f64; 3]) -> Transform3 {
198 use axiolid_core::{Mat3, Vec3};
199 let column = |c: &[f64; 3]| Vec3::new(c[0], c[1], c[2]);
200 Transform3::from_mat3_translation(
201 Mat3::from_cols(column(&linear[0]), column(&linear[1]), column(&linear[2])),
202 column(translation),
203 )
204}
205
206/// Resolve a project-to-map coordinate operation and normalize both frames
207/// to metres.
208///
209/// `project_metres_per_unit` is the project's `IfcUnitAssignment` length scale.
210/// It is explicit here because project units are owned by the caller's model
211/// loading boundary, while `MapUnit` is owned by the target CRS.
212///
213/// When the header pins IFC4 or IFC4X3 this is
214/// [`resolve_project_to_map_in`] on that view, so an IFC4X3-only entity in
215/// an IFC4 file is refused as undeclared. Without a usable header the
216/// IFC4X3 set is accepted unpinned: the attribute layouts coincide (see
217/// `view.rs`), but "not declared in this schema" cannot be told apart.
218pub fn resolve_project_to_map(
219 model: &Model,
220 id: EntityId,
221 project_metres_per_unit: f64,
222) -> GeorefResult<ProjectToMap> {
223 match GeorefView::for_model(model) {
224 Ok(view) => resolve_project_to_map_in(&view, id, project_metres_per_unit),
225 Err(_) => dispatch(model, None, id, project_metres_per_unit),
226 }
227}
228
229/// Resolve a project-to-map coordinate operation within a schema-pinned
230/// [`GeorefView`].
231///
232/// Refuses an entity the pinned schema does not declare
233/// (`IfcMapConversionScaled`, `IfcRigidOperation` or `IfcGeographicCRS`
234/// read from an IFC4 file) with [`GeorefError::UnsupportedOperation`]
235/// naming the release, and checks the operation's source against the
236/// pinned release's `IfcCoordinateReferenceSystemSelect`.
237pub fn resolve_project_to_map_in(
238 view: &GeorefView,
239 id: EntityId,
240 project_metres_per_unit: f64,
241) -> GeorefResult<ProjectToMap> {
242 view.require_known_type(id)?;
243 dispatch(view.model, Some(view), id, project_metres_per_unit)
244}
245
246fn dispatch(
247 model: &Model,
248 view: Option<&GeorefView>,
249 id: EntityId,
250 project_metres_per_unit: f64,
251) -> GeorefResult<ProjectToMap> {
252 let entity = model.get(id).ok_or(GeorefError::MissingEntity {
253 referrer: id,
254 missing: id,
255 })?;
256 if !project_metres_per_unit.is_finite() || project_metres_per_unit <= 0.0 {
257 return Err(GeorefError::InvalidUnit {
258 entity: id,
259 detail: "project length scale must be finite and positive",
260 });
261 }
262 let project_unit = LengthUnit {
263 name: "PROJECT_LENGTH_UNIT".into(),
264 metres_per_unit: project_metres_per_unit,
265 };
266 let actual_type = entity.type_name.to_ascii_uppercase();
267 let operation = Operation {
268 model,
269 view,
270 id,
271 entity,
272 };
273 match actual_type.as_str() {
274 "IFCMAPCONVERSION" => map::lower(&operation, project_unit, false),
275 "IFCMAPCONVERSIONSCALED" => map::lower(&operation, project_unit, true),
276 "IFCRIGIDOPERATION" => rigid::lower(&operation, project_unit),
277 _ => Err(GeorefError::WrongType {
278 entity: id,
279 expected: "IFCCOORDINATEOPERATION",
280 actual: entity.type_name.to_string(),
281 }),
282 }
283}
284
285/// The operation being lowered, with the view that pinned it (if any).
286pub(super) struct Operation<'m, 'v> {
287 pub model: &'m Model,
288 pub view: Option<&'v GeorefView<'m>>,
289 pub id: EntityId,
290 pub entity: &'m Entity,
291}
292
293impl Operation<'_, '_> {
294 pub fn required_ref(&self, index: usize, name: &'static str) -> GeorefResult<EntityId> {
295 self.entity
296 .reference(index)
297 .ok_or(GeorefError::MissingAttribute {
298 entity: self.id,
299 index,
300 name,
301 })
302 }
303
304 pub fn required_number(&self, index: usize, name: &'static str) -> GeorefResult<f64> {
305 self.entity
306 .attribute(index)
307 .and_then(|v| v.unwrap_typed().as_f64())
308 .ok_or(GeorefError::MissingAttribute {
309 entity: self.id,
310 index,
311 name,
312 })
313 }
314
315 pub fn optional_number(&self, index: usize, name: &'static str) -> GeorefResult<Option<f64>> {
316 match self.entity.attribute(index).map(Value::unwrap_typed) {
317 None | Some(Value::Null) => Ok(None),
318 Some(value) => value
319 .as_f64()
320 .map(Some)
321 .ok_or(GeorefError::InvalidAttribute {
322 entity: self.id,
323 index,
324 name,
325 }),
326 }
327 }
328
329 /// Refuse a non-finite value, naming the slot it came from.
330 pub fn finite(&self, index: usize, name: &'static str, value: f64) -> GeorefResult<f64> {
331 if value.is_finite() {
332 Ok(value)
333 } else {
334 Err(GeorefError::InvalidAttribute {
335 entity: self.id,
336 index,
337 name,
338 })
339 }
340 }
341}