Skip to main content

ifc_georef/authoring/
operation.rs

1//! Coordinate operations: how a local engineering grid relates to a map.
2//!
3//! # The rule that makes a map conversion meaningful
4//!
5//! `IfcMapConversion` carries `TargetCRSOnlyProjected`:
6//!
7//! ```text
8//! 'IFC4X3_ADD2.IFCPROJECTEDCRS' IN TYPEOF(SELF\IfcCoordinateOperation.TargetCRS)
9//! ```
10//!
11//! Eastings, northings and an orthogonal height are coordinates *on a
12//! projection plane*. Pointing them at an `IfcGeographicCRS` -- whose
13//! axes are latitude and longitude in degrees -- produces numbers that
14//! parse but denote nothing: a 400000.0 "easting" in a degree-based
15//! system is off the planet. So the target is checked here rather than
16//! left to a validator.
17//!
18//! # Why the X axis is two reals and not an angle
19//!
20//! `XAxisAbscissa` and `XAxisOrdinate` are the components of a
21//! direction vector, not a bearing. Supplying only one leaves the
22//! rotation underdetermined, and supplying `(0, 0)` gives a zero-length
23//! vector with no direction at all. Both are refused.
24
25use ifc_model::{Entity, EntityId, Model, Transaction, Value};
26
27use crate::authoring::invalid;
28use crate::GeorefResult;
29
30/// What to stage for an `IfcGeographicCRS`.
31#[derive(Debug, Clone, Copy, Default)]
32#[non_exhaustive]
33pub struct GeographicCrsDraft<'a> {
34    /// `Name`: the CRS identifier, e.g. `EPSG:4326`.
35    pub name: Option<&'a str>,
36    /// `Description`.
37    pub description: Option<&'a str>,
38    /// `GeodeticDatum`.
39    pub geodetic_datum: Option<&'a str>,
40    /// `PrimeMeridian`.
41    pub prime_meridian: Option<&'a str>,
42    /// `AngleUnit`: an `IfcNamedUnit` for latitude and longitude.
43    pub angle_unit: Option<EntityId>,
44    /// `HeightUnit`: an `IfcNamedUnit` for ellipsoidal height.
45    pub height_unit: Option<EntityId>,
46}
47
48impl<'a> GeographicCrsDraft<'a> {
49    /// Starts an empty draft with every attribute unset.
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Sets `Name`, the CRS identifier.
56    #[must_use]
57    pub const fn name(mut self, value: &'a str) -> Self {
58        self.name = Some(value);
59        self
60    }
61
62    /// Sets `Description`.
63    #[must_use]
64    pub const fn description(mut self, value: &'a str) -> Self {
65        self.description = Some(value);
66        self
67    }
68
69    /// Sets `GeodeticDatum`.
70    #[must_use]
71    pub const fn geodetic_datum(mut self, value: &'a str) -> Self {
72        self.geodetic_datum = Some(value);
73        self
74    }
75
76    /// Sets `PrimeMeridian`.
77    #[must_use]
78    pub const fn prime_meridian(mut self, value: &'a str) -> Self {
79        self.prime_meridian = Some(value);
80        self
81    }
82
83    /// Sets `AngleUnit`, an `IfcNamedUnit` reference.
84    #[must_use]
85    pub const fn angle_unit(mut self, value: EntityId) -> Self {
86        self.angle_unit = Some(value);
87        self
88    }
89
90    /// Sets `HeightUnit`, an `IfcNamedUnit` reference.
91    #[must_use]
92    pub const fn height_unit(mut self, value: EntityId) -> Self {
93        self.height_unit = Some(value);
94        self
95    }
96}
97
98/// Stage an `IfcGeographicCRS`.
99///
100/// Every attribute is optional in the schema, but a CRS with no name
101/// identifies nothing, so a blank name is refused rather than written
102/// as `$`.
103///
104/// # Errors
105///
106/// Refuses a name that is present but blank.
107pub fn create_geographic_crs(
108    tx: &mut Transaction,
109    draft: GeographicCrsDraft<'_>,
110) -> GeorefResult<EntityId> {
111    if let Some(name) = draft.name {
112        if name.trim().is_empty() {
113            return Err(invalid("IFCGEOGRAPHICCRS", "Name", name));
114        }
115    }
116
117    Ok(tx.create(Entity::new(
118        "IFCGEOGRAPHICCRS",
119        vec![
120            draft.name.map_or(Value::Null, |v| Value::Text(v.into())),
121            draft
122                .description
123                .map_or(Value::Null, |v| Value::Text(v.into())),
124            draft
125                .geodetic_datum
126                .map_or(Value::Null, |v| Value::Text(v.into())),
127            draft
128                .prime_meridian
129                .map_or(Value::Null, |v| Value::Text(v.into())),
130            draft.angle_unit.map_or(Value::Null, Value::Ref),
131            draft.height_unit.map_or(Value::Null, Value::Ref),
132        ],
133    )))
134}
135
136/// The offset and rotation placing a local grid on a projection.
137#[derive(Debug, Clone, Copy)]
138#[non_exhaustive]
139pub struct MapConversionDraft {
140    /// `SourceCRS`: usually the model's engineering context.
141    pub source_crs: EntityId,
142    /// `TargetCRS`: must be an `IfcProjectedCRS`.
143    pub target_crs: EntityId,
144    /// `Eastings`.
145    pub eastings: f64,
146    /// `Northings`.
147    pub northings: f64,
148    /// `OrthogonalHeight`.
149    pub orthogonal_height: f64,
150    /// `XAxisAbscissa` and `XAxisOrdinate`, the rotation vector.
151    ///
152    /// Both components or neither: a single one underdetermines the
153    /// rotation.
154    pub x_axis: Option<(f64, f64)>,
155    /// `Scale`.
156    pub scale: Option<f64>,
157}
158
159impl MapConversionDraft {
160    /// Starts a draft placing `source_crs` on `target_crs` at the given
161    /// offset, with no rotation or scale.
162    #[must_use]
163    pub const fn new(
164        source_crs: EntityId,
165        target_crs: EntityId,
166        eastings: f64,
167        northings: f64,
168        orthogonal_height: f64,
169    ) -> Self {
170        Self {
171            source_crs,
172            target_crs,
173            eastings,
174            northings,
175            orthogonal_height,
176            x_axis: None,
177            scale: None,
178        }
179    }
180
181    /// Sets `XAxisAbscissa` and `XAxisOrdinate`, the rotation vector, as
182    /// `(abscissa, ordinate)`.
183    #[must_use]
184    pub const fn x_axis(mut self, value: (f64, f64)) -> Self {
185        self.x_axis = Some(value);
186        self
187    }
188
189    /// Sets `Scale`.
190    #[must_use]
191    pub const fn scale(mut self, value: f64) -> Self {
192        self.scale = Some(value);
193        self
194    }
195}
196
197fn resolved_type(tx: &Transaction, model: &Model, target: EntityId) -> Option<String> {
198    tx.edits()
199        .iter()
200        .rev()
201        .find_map(|edit| match edit {
202            ifc_model::Edit::Create { id, entity } if *id == target => {
203                Some(entity.type_name.as_ref().to_owned())
204            }
205            _ => None,
206        })
207        .or_else(|| model.get(target).map(|e| e.type_name.as_ref().to_owned()))
208}
209
210fn check_conversion(
211    entity: &'static str,
212    tx: &Transaction,
213    model: &Model,
214    draft: &MapConversionDraft,
215) -> GeorefResult<()> {
216    let finite = [
217        ("Eastings", draft.eastings),
218        ("Northings", draft.northings),
219        ("OrthogonalHeight", draft.orthogonal_height),
220    ];
221    for (attribute, value) in finite {
222        if !value.is_finite() {
223            return Err(invalid(entity, attribute, value.to_string()));
224        }
225    }
226
227    match resolved_type(tx, model, draft.target_crs).as_deref() {
228        Some("IFCPROJECTEDCRS") => {}
229        Some(other) => {
230            return Err(invalid(
231                entity,
232                "TargetCRS",
233                format!("expected IFCPROJECTEDCRS, found {other}"),
234            ))
235        }
236        None => {
237            return Err(invalid(
238                entity,
239                "TargetCRS",
240                "target does not resolve to a staged entity",
241            ))
242        }
243    }
244
245    if let Some((abscissa, ordinate)) = draft.x_axis {
246        if !abscissa.is_finite() || !ordinate.is_finite() {
247            return Err(invalid(
248                entity,
249                "XAxisAbscissa",
250                format!("expected a finite direction, got ({abscissa}, {ordinate})"),
251            ));
252        }
253        if abscissa == 0.0 && ordinate == 0.0 {
254            return Err(invalid(
255                entity,
256                "XAxisAbscissa",
257                "a zero-length vector gives no rotation",
258            ));
259        }
260    }
261
262    // The reader refuses a `Scale` that is zero, negative or non-finite
263    // (`GeorefError::InvalidScale`), so writing one would stage a record
264    // this crate cannot read back (#254). The schema puts no WHERE rule on
265    // `Scale`; the refusal is the reader's, mirrored here.
266    if let Some(scale) = draft.scale {
267        if !positive_finite(scale) {
268            return Err(invalid(
269                entity,
270                "Scale",
271                format!("expected a positive finite scale, got {scale}"),
272            ));
273        }
274    }
275
276    Ok(())
277}
278
279/// What the reader accepts for `Scale` and the per-axis factors.
280fn positive_finite(value: f64) -> bool {
281    value.is_finite() && value > 0.0
282}
283
284fn conversion_attributes(draft: &MapConversionDraft) -> Vec<Value> {
285    let (abscissa, ordinate) = match draft.x_axis {
286        Some((a, o)) => (Value::Real(a), Value::Real(o)),
287        None => (Value::Null, Value::Null),
288    };
289    vec![
290        Value::Ref(draft.source_crs),
291        Value::Ref(draft.target_crs),
292        Value::Real(draft.eastings),
293        Value::Real(draft.northings),
294        Value::Real(draft.orthogonal_height),
295        abscissa,
296        ordinate,
297        draft.scale.map_or(Value::Null, Value::Real),
298    ]
299}
300
301/// Stage an `IfcMapConversion`.
302///
303/// # Errors
304///
305/// Refuses a non-finite coordinate, a `TargetCRS` that is not an
306/// `IfcProjectedCRS`, a partially specified or zero-length X axis, and
307/// a scale that is zero, negative or non-finite -- the values the reader
308/// refuses, so nothing is staged that cannot be read back.
309pub fn create_map_conversion(
310    tx: &mut Transaction,
311    model: &Model,
312    draft: MapConversionDraft,
313) -> GeorefResult<EntityId> {
314    check_conversion("IFCMAPCONVERSION", tx, model, &draft)?;
315    Ok(tx.create(Entity::new(
316        "IFCMAPCONVERSION",
317        conversion_attributes(&draft),
318    )))
319}
320
321/// Stage an `IfcMapConversionScaled`: per-axis scale factors.
322///
323/// Used where the projection distorts differently along each axis, so
324/// one uniform `Scale` cannot describe it.
325///
326/// # Errors
327///
328/// Everything [`create_map_conversion`] refuses, plus a factor on any
329/// axis that is zero, negative or non-finite -- a zero factor collapses
330/// that axis entirely, and the reader refuses all three.
331pub fn create_map_conversion_scaled(
332    tx: &mut Transaction,
333    model: &Model,
334    draft: MapConversionDraft,
335    factors: (f64, f64, f64),
336) -> GeorefResult<EntityId> {
337    check_conversion("IFCMAPCONVERSIONSCALED", tx, model, &draft)?;
338
339    // Mirrors the reader, which refuses a factor that is not positive and
340    // finite (`GeorefError::InvalidAttribute`, #252); see #254.
341    let (x, y, z) = factors;
342    for (attribute, value) in [("FactorX", x), ("FactorY", y), ("FactorZ", z)] {
343        if !positive_finite(value) {
344            return Err(invalid(
345                "IFCMAPCONVERSIONSCALED",
346                attribute,
347                format!("expected a positive finite factor, got {value}"),
348            ));
349        }
350    }
351
352    let mut attributes = conversion_attributes(&draft);
353    attributes.extend([Value::Real(x), Value::Real(y), Value::Real(z)]);
354    Ok(tx.create(Entity::new("IFCMAPCONVERSIONSCALED", attributes)))
355}
356
357/// Stage an IFC4X3 `IfcRigidOperation` with length coordinates: a
358/// translation with no rotation or scale.
359///
360/// `FirstCoordinate` and `SecondCoordinate` are `IfcMeasureValue`, a
361/// SELECT, and `SameCoordinateType` requires both to be
362/// `IfcLengthMeasure` or both `IfcPlaneAngleMeasure`; an untyped REAL
363/// satisfies neither. So they are written typed, as
364/// `IFCLENGTHMEASURE(..)`. The reader lowers this form to a translation
365/// when the target is an `IfcProjectedCRS`; for latitude/longitude offsets
366/// on an `IfcGeographicCRS` use [`create_angular_rigid_operation`].
367///
368/// The target is not checked here: `IfcRigidOperation` carries no
369/// `TargetCRSOnlyProjected` rule.
370///
371/// # Errors
372///
373/// Refuses a non-finite coordinate or height.
374pub fn create_rigid_operation(
375    tx: &mut Transaction,
376    source_crs: EntityId,
377    target_crs: EntityId,
378    coordinates: (f64, f64),
379    height: Option<f64>,
380) -> GeorefResult<EntityId> {
381    rigid_operation(
382        tx,
383        source_crs,
384        target_crs,
385        coordinates,
386        height,
387        "IFCLENGTHMEASURE",
388    )
389}
390
391/// Stage an IFC4X3 `IfcRigidOperation` with plane-angle coordinates, an
392/// offset of latitude and longitude on an `IfcGeographicCRS`.
393///
394/// Both coordinates are written as `IFCPLANEANGLEMEASURE(..)`, the other
395/// `SameCoordinateType` branch; `height` stays an `IfcLengthMeasure`.
396/// Read it back with [`crate::resolve_geographic_offset_in`].
397///
398/// # Errors
399///
400/// Refuses a non-finite coordinate or height.
401pub fn create_angular_rigid_operation(
402    tx: &mut Transaction,
403    source_crs: EntityId,
404    target_crs: EntityId,
405    coordinates: (f64, f64),
406    height: Option<f64>,
407) -> GeorefResult<EntityId> {
408    rigid_operation(
409        tx,
410        source_crs,
411        target_crs,
412        coordinates,
413        height,
414        "IFCPLANEANGLEMEASURE",
415    )
416}
417
418fn rigid_operation(
419    tx: &mut Transaction,
420    source_crs: EntityId,
421    target_crs: EntityId,
422    coordinates: (f64, f64),
423    height: Option<f64>,
424    measure: &'static str,
425) -> GeorefResult<EntityId> {
426    let (first, second) = coordinates;
427    for (attribute, value) in [("FirstCoordinate", first), ("SecondCoordinate", second)] {
428        if !value.is_finite() {
429            return Err(invalid("IFCRIGIDOPERATION", attribute, value.to_string()));
430        }
431    }
432    if let Some(value) = height {
433        if !value.is_finite() {
434            return Err(invalid("IFCRIGIDOPERATION", "Height", value.to_string()));
435        }
436    }
437    let typed = |value: f64| Value::Typed {
438        type_name: measure.into(),
439        value: Box::new(Value::Real(value)),
440    };
441
442    Ok(tx.create(Entity::new(
443        "IFCRIGIDOPERATION",
444        vec![
445            Value::Ref(source_crs),
446            Value::Ref(target_crs),
447            typed(first),
448            typed(second),
449            height.map_or(Value::Null, Value::Real),
450        ],
451    )))
452}
453
454/// Stage an `IfcWellKnownText`: a CRS definition in OGC WKT.
455///
456/// The literal is written as given. WKT is its own grammar and this
457/// crate does not parse it: reformatting or normalising the text here
458/// would change a definition the authoring tool exported verbatim, and
459/// the receiving system is the one that parses it.
460///
461/// # Errors
462///
463/// Refuses a blank literal and a `coordinate_reference_system` that is
464/// not an `IfcCoordinateReferenceSystem` subtype.
465pub fn create_well_known_text(
466    tx: &mut Transaction,
467    model: &Model,
468    well_known_text: &str,
469    coordinate_reference_system: EntityId,
470) -> GeorefResult<EntityId> {
471    const ENTITY: &str = "IFCWELLKNOWNTEXT";
472    const CRS_TYPES: &[&str] = &[
473        "IFCPROJECTEDCRS",
474        "IFCGEOGRAPHICCRS",
475        "IFCCOORDINATEREFERENCESYSTEM",
476    ];
477    if well_known_text.trim().is_empty() {
478        return Err(invalid(ENTITY, "WellKnownText", well_known_text));
479    }
480    let actual = tx
481        .edits()
482        .iter()
483        .rev()
484        .find_map(|edit| match edit {
485            ifc_model::Edit::Create { id, entity } if *id == coordinate_reference_system => {
486                Some(entity.type_name.as_ref().to_owned())
487            }
488            _ => None,
489        })
490        .or_else(|| {
491            model
492                .get(coordinate_reference_system)
493                .map(|entity| entity.type_name.as_ref().to_owned())
494        })
495        .unwrap_or_default();
496    if !CRS_TYPES
497        .iter()
498        .any(|kind| actual.eq_ignore_ascii_case(kind))
499    {
500        return Err(invalid(ENTITY, "CoordinateReferenceSystem", actual));
501    }
502    Ok(tx.create(Entity::new(
503        ENTITY,
504        vec![
505            Value::Text(well_known_text.into()),
506            Value::Ref(coordinate_reference_system),
507        ],
508    )))
509}