ifc-georef 0.5.0

Georeferencing: map conversion, coordinate reference systems, site placement.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
//! Coordinate operations: how a local engineering grid relates to a map.
//!
//! # The rule that makes a map conversion meaningful
//!
//! `IfcMapConversion` carries `TargetCRSOnlyProjected`:
//!
//! ```text
//! 'IFC4X3_ADD2.IFCPROJECTEDCRS' IN TYPEOF(SELF\IfcCoordinateOperation.TargetCRS)
//! ```
//!
//! Eastings, northings and an orthogonal height are coordinates *on a
//! projection plane*. Pointing them at an `IfcGeographicCRS` -- whose
//! axes are latitude and longitude in degrees -- produces numbers that
//! parse but denote nothing: a 400000.0 "easting" in a degree-based
//! system is off the planet. So the target is checked here rather than
//! left to a validator.
//!
//! # Why the X axis is two reals and not an angle
//!
//! `XAxisAbscissa` and `XAxisOrdinate` are the components of a
//! direction vector, not a bearing. Supplying only one leaves the
//! rotation underdetermined, and supplying `(0, 0)` gives a zero-length
//! vector with no direction at all. Both are refused.

use ifc_model::{Entity, EntityId, Model, Transaction, Value};

use crate::authoring::invalid;
use crate::GeorefResult;

/// What to stage for an `IfcGeographicCRS`.
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct GeographicCrsDraft<'a> {
    /// `Name`: the CRS identifier, e.g. `EPSG:4326`.
    pub name: Option<&'a str>,
    /// `Description`.
    pub description: Option<&'a str>,
    /// `GeodeticDatum`.
    pub geodetic_datum: Option<&'a str>,
    /// `PrimeMeridian`.
    pub prime_meridian: Option<&'a str>,
    /// `AngleUnit`: an `IfcNamedUnit` for latitude and longitude.
    pub angle_unit: Option<EntityId>,
    /// `HeightUnit`: an `IfcNamedUnit` for ellipsoidal height.
    pub height_unit: Option<EntityId>,
}

impl<'a> GeographicCrsDraft<'a> {
    /// Starts an empty draft with every attribute unset.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets `Name`, the CRS identifier.
    #[must_use]
    pub const fn name(mut self, value: &'a str) -> Self {
        self.name = Some(value);
        self
    }

    /// Sets `Description`.
    #[must_use]
    pub const fn description(mut self, value: &'a str) -> Self {
        self.description = Some(value);
        self
    }

    /// Sets `GeodeticDatum`.
    #[must_use]
    pub const fn geodetic_datum(mut self, value: &'a str) -> Self {
        self.geodetic_datum = Some(value);
        self
    }

    /// Sets `PrimeMeridian`.
    #[must_use]
    pub const fn prime_meridian(mut self, value: &'a str) -> Self {
        self.prime_meridian = Some(value);
        self
    }

    /// Sets `AngleUnit`, an `IfcNamedUnit` reference.
    #[must_use]
    pub const fn angle_unit(mut self, value: EntityId) -> Self {
        self.angle_unit = Some(value);
        self
    }

    /// Sets `HeightUnit`, an `IfcNamedUnit` reference.
    #[must_use]
    pub const fn height_unit(mut self, value: EntityId) -> Self {
        self.height_unit = Some(value);
        self
    }
}

/// Stage an `IfcGeographicCRS`.
///
/// Every attribute is optional in the schema, but a CRS with no name
/// identifies nothing, so a blank name is refused rather than written
/// as `$`.
///
/// # Errors
///
/// Refuses a name that is present but blank.
pub fn create_geographic_crs(
    tx: &mut Transaction,
    draft: GeographicCrsDraft<'_>,
) -> GeorefResult<EntityId> {
    if let Some(name) = draft.name {
        if name.trim().is_empty() {
            return Err(invalid("IFCGEOGRAPHICCRS", "Name", name));
        }
    }

    Ok(tx.create(Entity::new(
        "IFCGEOGRAPHICCRS",
        vec![
            draft.name.map_or(Value::Null, |v| Value::Text(v.into())),
            draft
                .description
                .map_or(Value::Null, |v| Value::Text(v.into())),
            draft
                .geodetic_datum
                .map_or(Value::Null, |v| Value::Text(v.into())),
            draft
                .prime_meridian
                .map_or(Value::Null, |v| Value::Text(v.into())),
            draft.angle_unit.map_or(Value::Null, Value::Ref),
            draft.height_unit.map_or(Value::Null, Value::Ref),
        ],
    )))
}

/// The offset and rotation placing a local grid on a projection.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct MapConversionDraft {
    /// `SourceCRS`: usually the model's engineering context.
    pub source_crs: EntityId,
    /// `TargetCRS`: must be an `IfcProjectedCRS`.
    pub target_crs: EntityId,
    /// `Eastings`.
    pub eastings: f64,
    /// `Northings`.
    pub northings: f64,
    /// `OrthogonalHeight`.
    pub orthogonal_height: f64,
    /// `XAxisAbscissa` and `XAxisOrdinate`, the rotation vector.
    ///
    /// Both components or neither: a single one underdetermines the
    /// rotation.
    pub x_axis: Option<(f64, f64)>,
    /// `Scale`.
    pub scale: Option<f64>,
}

impl MapConversionDraft {
    /// Starts a draft placing `source_crs` on `target_crs` at the given
    /// offset, with no rotation or scale.
    #[must_use]
    pub const fn new(
        source_crs: EntityId,
        target_crs: EntityId,
        eastings: f64,
        northings: f64,
        orthogonal_height: f64,
    ) -> Self {
        Self {
            source_crs,
            target_crs,
            eastings,
            northings,
            orthogonal_height,
            x_axis: None,
            scale: None,
        }
    }

    /// Sets `XAxisAbscissa` and `XAxisOrdinate`, the rotation vector, as
    /// `(abscissa, ordinate)`.
    #[must_use]
    pub const fn x_axis(mut self, value: (f64, f64)) -> Self {
        self.x_axis = Some(value);
        self
    }

    /// Sets `Scale`.
    #[must_use]
    pub const fn scale(mut self, value: f64) -> Self {
        self.scale = Some(value);
        self
    }
}

fn resolved_type(tx: &Transaction, model: &Model, target: EntityId) -> Option<String> {
    tx.edits()
        .iter()
        .rev()
        .find_map(|edit| match edit {
            ifc_model::Edit::Create { id, entity } if *id == target => {
                Some(entity.type_name.as_ref().to_owned())
            }
            _ => None,
        })
        .or_else(|| model.get(target).map(|e| e.type_name.as_ref().to_owned()))
}

fn check_conversion(
    entity: &'static str,
    tx: &Transaction,
    model: &Model,
    draft: &MapConversionDraft,
) -> GeorefResult<()> {
    let finite = [
        ("Eastings", draft.eastings),
        ("Northings", draft.northings),
        ("OrthogonalHeight", draft.orthogonal_height),
    ];
    for (attribute, value) in finite {
        if !value.is_finite() {
            return Err(invalid(entity, attribute, value.to_string()));
        }
    }

    match resolved_type(tx, model, draft.target_crs).as_deref() {
        Some("IFCPROJECTEDCRS") => {}
        Some(other) => {
            return Err(invalid(
                entity,
                "TargetCRS",
                format!("expected IFCPROJECTEDCRS, found {other}"),
            ))
        }
        None => {
            return Err(invalid(
                entity,
                "TargetCRS",
                "target does not resolve to a staged entity",
            ))
        }
    }

    if let Some((abscissa, ordinate)) = draft.x_axis {
        if !abscissa.is_finite() || !ordinate.is_finite() {
            return Err(invalid(
                entity,
                "XAxisAbscissa",
                format!("expected a finite direction, got ({abscissa}, {ordinate})"),
            ));
        }
        if abscissa == 0.0 && ordinate == 0.0 {
            return Err(invalid(
                entity,
                "XAxisAbscissa",
                "a zero-length vector gives no rotation",
            ));
        }
    }

    // The reader refuses a `Scale` that is zero, negative or non-finite
    // (`GeorefError::InvalidScale`), so writing one would stage a record
    // this crate cannot read back (#254). The schema puts no WHERE rule on
    // `Scale`; the refusal is the reader's, mirrored here.
    if let Some(scale) = draft.scale {
        if !positive_finite(scale) {
            return Err(invalid(
                entity,
                "Scale",
                format!("expected a positive finite scale, got {scale}"),
            ));
        }
    }

    Ok(())
}

/// What the reader accepts for `Scale` and the per-axis factors.
fn positive_finite(value: f64) -> bool {
    value.is_finite() && value > 0.0
}

fn conversion_attributes(draft: &MapConversionDraft) -> Vec<Value> {
    let (abscissa, ordinate) = match draft.x_axis {
        Some((a, o)) => (Value::Real(a), Value::Real(o)),
        None => (Value::Null, Value::Null),
    };
    vec![
        Value::Ref(draft.source_crs),
        Value::Ref(draft.target_crs),
        Value::Real(draft.eastings),
        Value::Real(draft.northings),
        Value::Real(draft.orthogonal_height),
        abscissa,
        ordinate,
        draft.scale.map_or(Value::Null, Value::Real),
    ]
}

/// Stage an `IfcMapConversion`.
///
/// # Errors
///
/// Refuses a non-finite coordinate, a `TargetCRS` that is not an
/// `IfcProjectedCRS`, a partially specified or zero-length X axis, and
/// a scale that is zero, negative or non-finite -- the values the reader
/// refuses, so nothing is staged that cannot be read back.
pub fn create_map_conversion(
    tx: &mut Transaction,
    model: &Model,
    draft: MapConversionDraft,
) -> GeorefResult<EntityId> {
    check_conversion("IFCMAPCONVERSION", tx, model, &draft)?;
    Ok(tx.create(Entity::new(
        "IFCMAPCONVERSION",
        conversion_attributes(&draft),
    )))
}

/// Stage an `IfcMapConversionScaled`: per-axis scale factors.
///
/// Used where the projection distorts differently along each axis, so
/// one uniform `Scale` cannot describe it.
///
/// # Errors
///
/// Everything [`create_map_conversion`] refuses, plus a factor on any
/// axis that is zero, negative or non-finite -- a zero factor collapses
/// that axis entirely, and the reader refuses all three.
pub fn create_map_conversion_scaled(
    tx: &mut Transaction,
    model: &Model,
    draft: MapConversionDraft,
    factors: (f64, f64, f64),
) -> GeorefResult<EntityId> {
    check_conversion("IFCMAPCONVERSIONSCALED", tx, model, &draft)?;

    // Mirrors the reader, which refuses a factor that is not positive and
    // finite (`GeorefError::InvalidAttribute`, #252); see #254.
    let (x, y, z) = factors;
    for (attribute, value) in [("FactorX", x), ("FactorY", y), ("FactorZ", z)] {
        if !positive_finite(value) {
            return Err(invalid(
                "IFCMAPCONVERSIONSCALED",
                attribute,
                format!("expected a positive finite factor, got {value}"),
            ));
        }
    }

    let mut attributes = conversion_attributes(&draft);
    attributes.extend([Value::Real(x), Value::Real(y), Value::Real(z)]);
    Ok(tx.create(Entity::new("IFCMAPCONVERSIONSCALED", attributes)))
}

/// Stage an IFC4X3 `IfcRigidOperation` with length coordinates: a
/// translation with no rotation or scale.
///
/// `FirstCoordinate` and `SecondCoordinate` are `IfcMeasureValue`, a
/// SELECT, and `SameCoordinateType` requires both to be
/// `IfcLengthMeasure` or both `IfcPlaneAngleMeasure`; an untyped REAL
/// satisfies neither. So they are written typed, as
/// `IFCLENGTHMEASURE(..)`. The reader lowers this form to a translation
/// when the target is an `IfcProjectedCRS`; for latitude/longitude offsets
/// on an `IfcGeographicCRS` use [`create_angular_rigid_operation`].
///
/// The target is not checked here: `IfcRigidOperation` carries no
/// `TargetCRSOnlyProjected` rule.
///
/// # Errors
///
/// Refuses a non-finite coordinate or height.
pub fn create_rigid_operation(
    tx: &mut Transaction,
    source_crs: EntityId,
    target_crs: EntityId,
    coordinates: (f64, f64),
    height: Option<f64>,
) -> GeorefResult<EntityId> {
    rigid_operation(
        tx,
        source_crs,
        target_crs,
        coordinates,
        height,
        "IFCLENGTHMEASURE",
    )
}

/// Stage an IFC4X3 `IfcRigidOperation` with plane-angle coordinates, an
/// offset of latitude and longitude on an `IfcGeographicCRS`.
///
/// Both coordinates are written as `IFCPLANEANGLEMEASURE(..)`, the other
/// `SameCoordinateType` branch; `height` stays an `IfcLengthMeasure`.
/// Read it back with [`crate::resolve_geographic_offset_in`].
///
/// # Errors
///
/// Refuses a non-finite coordinate or height.
pub fn create_angular_rigid_operation(
    tx: &mut Transaction,
    source_crs: EntityId,
    target_crs: EntityId,
    coordinates: (f64, f64),
    height: Option<f64>,
) -> GeorefResult<EntityId> {
    rigid_operation(
        tx,
        source_crs,
        target_crs,
        coordinates,
        height,
        "IFCPLANEANGLEMEASURE",
    )
}

fn rigid_operation(
    tx: &mut Transaction,
    source_crs: EntityId,
    target_crs: EntityId,
    coordinates: (f64, f64),
    height: Option<f64>,
    measure: &'static str,
) -> GeorefResult<EntityId> {
    let (first, second) = coordinates;
    for (attribute, value) in [("FirstCoordinate", first), ("SecondCoordinate", second)] {
        if !value.is_finite() {
            return Err(invalid("IFCRIGIDOPERATION", attribute, value.to_string()));
        }
    }
    if let Some(value) = height {
        if !value.is_finite() {
            return Err(invalid("IFCRIGIDOPERATION", "Height", value.to_string()));
        }
    }
    let typed = |value: f64| Value::Typed {
        type_name: measure.into(),
        value: Box::new(Value::Real(value)),
    };

    Ok(tx.create(Entity::new(
        "IFCRIGIDOPERATION",
        vec![
            Value::Ref(source_crs),
            Value::Ref(target_crs),
            typed(first),
            typed(second),
            height.map_or(Value::Null, Value::Real),
        ],
    )))
}

/// Stage an `IfcWellKnownText`: a CRS definition in OGC WKT.
///
/// The literal is written as given. WKT is its own grammar and this
/// crate does not parse it: reformatting or normalising the text here
/// would change a definition the authoring tool exported verbatim, and
/// the receiving system is the one that parses it.
///
/// # Errors
///
/// Refuses a blank literal and a `coordinate_reference_system` that is
/// not an `IfcCoordinateReferenceSystem` subtype.
pub fn create_well_known_text(
    tx: &mut Transaction,
    model: &Model,
    well_known_text: &str,
    coordinate_reference_system: EntityId,
) -> GeorefResult<EntityId> {
    const ENTITY: &str = "IFCWELLKNOWNTEXT";
    const CRS_TYPES: &[&str] = &[
        "IFCPROJECTEDCRS",
        "IFCGEOGRAPHICCRS",
        "IFCCOORDINATEREFERENCESYSTEM",
    ];
    if well_known_text.trim().is_empty() {
        return Err(invalid(ENTITY, "WellKnownText", well_known_text));
    }
    let actual = tx
        .edits()
        .iter()
        .rev()
        .find_map(|edit| match edit {
            ifc_model::Edit::Create { id, entity } if *id == coordinate_reference_system => {
                Some(entity.type_name.as_ref().to_owned())
            }
            _ => None,
        })
        .or_else(|| {
            model
                .get(coordinate_reference_system)
                .map(|entity| entity.type_name.as_ref().to_owned())
        })
        .unwrap_or_default();
    if !CRS_TYPES
        .iter()
        .any(|kind| actual.eq_ignore_ascii_case(kind))
    {
        return Err(invalid(ENTITY, "CoordinateReferenceSystem", actual));
    }
    Ok(tx.create(Entity::new(
        ENTITY,
        vec![
            Value::Text(well_known_text.into()),
            Value::Ref(coordinate_reference_system),
        ],
    )))
}