ifc-georef 0.3.0

Georeferencing: map conversion, coordinate reference systems, site placement.
Documentation
//! Explicit length-unit resolution for map coordinates.

use ifc_model::value::Value;
use ifc_model::{EntityId, Model};

use crate::error::{GeorefError, GeorefResult};

/// A length unit reduced to a metre factor.
#[derive(Debug, Clone, PartialEq)]
pub struct LengthUnit {
    /// Unit name, either an SI token such as `MILLIMETRE` or the authored
    /// `IfcConversionBasedUnit.Name` such as `FOOT`.
    pub name: String,
    /// Metres per one unit. Always finite and strictly positive.
    pub metres_per_unit: f64,
}

pub(crate) fn resolve_length_unit(model: &Model, id: EntityId) -> GeorefResult<LengthUnit> {
    resolve(model, id, &mut Vec::new())
}

fn resolve(model: &Model, id: EntityId, chain: &mut Vec<EntityId>) -> GeorefResult<LengthUnit> {
    if chain.len() >= 16 || chain.contains(&id) {
        return Err(GeorefError::UnitCycle { entity: id });
    }
    chain.push(id);
    let entity = model.get(id).ok_or(GeorefError::MissingEntity {
        referrer: id,
        missing: id,
    })?;
    let result = match entity.type_name.as_ref() {
        "IFCSIUNIT" => {
            require_enum(entity.attribute(1), id, "LENGTHUNIT")?;
            let prefix = optional_enum(entity.attribute(2), id, 2, "Prefix")?;
            require_enum(entity.attribute(3), id, "METRE")?;
            let factor = match prefix.as_deref() {
                None => 1.0,
                Some("EXA") => 1e18,
                Some("PETA") => 1e15,
                Some("TERA") => 1e12,
                Some("GIGA") => 1e9,
                Some("MEGA") => 1e6,
                Some("KILO") => 1e3,
                Some("HECTO") => 1e2,
                Some("DECA") => 1e1,
                Some("DECI") => 1e-1,
                Some("CENTI") => 1e-2,
                Some("MILLI") => 1e-3,
                Some("MICRO") => 1e-6,
                Some("NANO") => 1e-9,
                Some("PICO") => 1e-12,
                Some("FEMTO") => 1e-15,
                Some("ATTO") => 1e-18,
                Some(_) => {
                    return Err(GeorefError::InvalidUnit {
                        entity: id,
                        detail: "unknown SI prefix",
                    })
                }
            };
            LengthUnit {
                name: prefix.map_or_else(|| "METRE".into(), |p| format!("{p}METRE")),
                metres_per_unit: factor,
            }
        }
        "IFCCONVERSIONBASEDUNIT" => {
            require_enum(entity.attribute(1), id, "LENGTHUNIT")?;
            let name = entity
                .text(2)
                .ok_or(GeorefError::MissingAttribute {
                    entity: id,
                    index: 2,
                    name: "Name",
                })?
                .to_owned();
            let factor_ref = entity.reference(3).ok_or(GeorefError::InvalidAttribute {
                entity: id,
                index: 3,
                name: "ConversionFactor",
            })?;
            let factor_entity = model.get(factor_ref).ok_or(GeorefError::MissingEntity {
                referrer: id,
                missing: factor_ref,
            })?;
            if !factor_entity.is_type("IFCMEASUREWITHUNIT") {
                return Err(GeorefError::WrongType {
                    entity: factor_ref,
                    expected: "IFCMEASUREWITHUNIT",
                    actual: factor_entity.type_name.to_string(),
                });
            }
            let value = factor_entity
                .attribute(0)
                .and_then(|v| v.unwrap_typed().as_f64())
                .ok_or(GeorefError::InvalidAttribute {
                    entity: factor_ref,
                    index: 0,
                    name: "ValueComponent",
                })?;
            let base_ref = factor_entity
                .reference(1)
                .ok_or(GeorefError::InvalidAttribute {
                    entity: factor_ref,
                    index: 1,
                    name: "UnitComponent",
                })?;
            let base = resolve(model, base_ref, chain)?;
            LengthUnit {
                name,
                metres_per_unit: value * base.metres_per_unit,
            }
        }
        actual => {
            return Err(GeorefError::WrongType {
                entity: id,
                expected: "IFCSIUNIT or IFCCONVERSIONBASEDUNIT",
                actual: actual.to_owned(),
            })
        }
    };
    chain.pop();
    if !result.metres_per_unit.is_finite() || result.metres_per_unit <= 0.0 {
        return Err(GeorefError::InvalidUnit {
            entity: id,
            detail: "conversion factor must be finite and positive",
        });
    }
    Ok(result)
}

fn require_enum(value: Option<&Value>, id: EntityId, expected: &'static str) -> GeorefResult<()> {
    match value.map(Value::unwrap_typed) {
        Some(Value::Enum(actual)) if actual.eq_ignore_ascii_case(expected) => Ok(()),
        _ => Err(GeorefError::InvalidUnit {
            entity: id,
            detail: expected,
        }),
    }
}

fn optional_enum(
    value: Option<&Value>,
    id: EntityId,
    index: usize,
    name: &'static str,
) -> GeorefResult<Option<String>> {
    match value.map(Value::unwrap_typed) {
        None | Some(Value::Null) => Ok(None),
        Some(Value::Enum(value)) => Ok(Some(value.to_ascii_uppercase())),
        _ => Err(GeorefError::InvalidAttribute {
            entity: id,
            index,
            name,
        }),
    }
}