Skip to main content

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}