use ifc_model::value::Value;
use ifc_model::EntityId;
use ifc_schema::SchemaVersion;
use crate::error::{GeorefError, GeorefResult};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompoundPlaneAngle {
pub degrees: i64,
pub minutes: i64,
pub seconds: i64,
pub millionths: Option<i64>,
}
impl CompoundPlaneAngle {
#[must_use]
pub fn decimal_degrees(&self) -> f64 {
self.degrees as f64
+ self.minutes as f64 / 60.0
+ self.seconds as f64 / 3600.0
+ self.millionths.unwrap_or(0) as f64 / 3_600_000_000.0
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum Axis {
Latitude,
Longitude,
}
impl Axis {
const fn name(self) -> &'static str {
match self {
Self::Latitude => "RefLatitude",
Self::Longitude => "RefLongitude",
}
}
}
pub(crate) fn read_angle(
value: Option<&Value>,
entity: EntityId,
index: usize,
axis: Axis,
version: SchemaVersion,
) -> GeorefResult<Option<CompoundPlaneAngle>> {
let name = axis.name();
let invalid = GeorefError::InvalidAttribute {
entity,
index,
name,
};
let items = match value.map(Value::unwrap_typed) {
None | Some(Value::Null) => return Ok(None),
Some(Value::List(items)) => items,
Some(_) => return Err(invalid),
};
if !(3..=4).contains(&items.len()) {
return Err(invalid);
}
let mut parts = [0_i64; 4];
for (slot, item) in parts.iter_mut().zip(items) {
*slot = item.unwrap_typed().as_i64().ok_or(invalid.clone())?;
}
let angle = CompoundPlaneAngle {
degrees: parts[0],
minutes: parts[1],
seconds: parts[2],
millionths: (items.len() == 4).then_some(parts[3]),
};
let violation = |rule| GeorefError::InvalidCompoundAngle {
entity,
index,
name,
rule,
};
if let Some(rule) = broken_rule(&angle, version) {
return Err(violation(rule));
}
let degrees = angle.decimal_degrees();
let (limit, rule) = match axis {
Axis::Latitude => (90.0, "WGS84 latitude range [-90, 90]"),
Axis::Longitude => (180.0, "WGS84 longitude range [-180, 180]"),
};
if degrees.abs() > limit {
return Err(violation(rule));
}
Ok(Some(angle))
}
fn broken_rule(angle: &CompoundPlaneAngle, version: SchemaVersion) -> Option<&'static str> {
let CompoundPlaneAngle {
degrees: d,
minutes: m,
seconds: s,
millionths: u,
} = *angle;
if version == SchemaVersion::Ifc2x3 {
let same_sign = (d >= 0 && m >= 0 && s >= 0) || (d <= 0 && m <= 0 && s <= 0);
return if !(-360..360).contains(&d) {
Some("WR1")
} else if !(-60..60).contains(&m) {
Some("WR2")
} else if !(-60..60).contains(&s) {
Some("WR3")
} else if !same_sign {
Some("WR4")
} else {
None
};
}
let u_all = u.unwrap_or(0);
let same_sign =
(d >= 0 && m >= 0 && s >= 0 && u_all >= 0) || (d <= 0 && m <= 0 && s <= 0 && u_all <= 0);
if m.unsigned_abs() >= 60 {
Some("MinutesInRange")
} else if s.unsigned_abs() >= 60 {
Some("SecondsInRange")
} else if u.is_some_and(|u| u.unsigned_abs() >= 1_000_000) {
Some("MicrosecondsInRange")
} else if !same_sign {
Some("ConsistentSign")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(parts: &[i64]) -> Value {
Value::List(parts.iter().map(|p| Value::Integer(*p)).collect())
}
fn read(parts: &[i64], axis: Axis, version: SchemaVersion) -> GeorefResult<f64> {
read_angle(Some(&list(parts)), EntityId(1), 9, axis, version)
.map(|a| a.expect("present").decimal_degrees())
}
fn rule_of(result: GeorefResult<f64>) -> &'static str {
match result {
Err(GeorefError::InvalidCompoundAngle { rule, .. }) => rule,
other => panic!("expected a compound-angle violation, got {other:?}"),
}
}
#[test]
fn converts_degrees_minutes_seconds_and_millionths() {
let v = read(&[41, 53, 30, 500_000], Axis::Latitude, SchemaVersion::Ifc4).unwrap();
assert!((v - (41.0 + 53.0 / 60.0 + 30.5 / 3600.0)).abs() < 1e-12);
let w = read(&[-87, -35, -40], Axis::Longitude, SchemaVersion::Ifc4x3).unwrap();
assert!((w + (87.0 + 35.0 / 60.0 + 40.0 / 3600.0)).abs() < 1e-12);
}
#[test]
fn ifc4_and_ifc4x3_rules_are_named() {
for version in [SchemaVersion::Ifc4, SchemaVersion::Ifc4x3] {
let lat = Axis::Latitude;
assert_eq!(rule_of(read(&[1, 60, 0], lat, version)), "MinutesInRange");
assert_eq!(rule_of(read(&[1, 0, -60], lat, version)), "SecondsInRange");
assert_eq!(
rule_of(read(&[1, 0, 0, 1_000_000], lat, version)),
"MicrosecondsInRange"
);
assert_eq!(rule_of(read(&[1, -2, 0], lat, version)), "ConsistentSign");
assert_eq!(
rule_of(read(&[-1, 0, 0, 5], lat, version)),
"ConsistentSign"
);
}
}
#[test]
fn ifc2x3_rules_are_its_own() {
let v = SchemaVersion::Ifc2x3;
let lon = Axis::Longitude;
assert_eq!(rule_of(read(&[360, 0, 0], lon, v)), "WR1");
assert_eq!(rule_of(read(&[1, 60, 0], lon, v)), "WR2");
assert_eq!(rule_of(read(&[1, 0, 60], lon, v)), "WR3");
assert_eq!(rule_of(read(&[1, -1, 0], lon, v)), "WR4");
assert!(read(&[-1, 0, 0, 5], lon, v).is_ok());
assert!(read(&[1, 0, 0, 2_000_000], lon, v).is_ok());
assert_eq!(
rule_of(read(&[1, 0, 0, 2_000_000], lon, SchemaVersion::Ifc4)),
"MicrosecondsInRange"
);
}
#[test]
fn wgs84_ranges_are_enforced_per_axis() {
let v = SchemaVersion::Ifc4;
assert!(read(&[90, 0, 0], Axis::Latitude, v).is_ok());
assert_eq!(
rule_of(read(&[90, 0, 1], Axis::Latitude, v)),
"WGS84 latitude range [-90, 90]"
);
assert!(read(&[-180, 0, 0], Axis::Longitude, v).is_ok());
assert_eq!(
rule_of(read(&[181, 0, 0], Axis::Longitude, v)),
"WGS84 longitude range [-180, 180]"
);
}
#[test]
fn malformed_lists_are_invalid_attributes() {
for value in [
list(&[1, 2]),
list(&[1, 2, 3, 4, 5]),
Value::List(vec![Value::Integer(1), Value::Real(2.5), Value::Integer(0)]),
Value::Real(41.5),
] {
assert!(matches!(
read_angle(
Some(&value),
EntityId(1),
9,
Axis::Latitude,
SchemaVersion::Ifc4
),
Err(GeorefError::InvalidAttribute {
index: 9,
name: "RefLatitude",
..
})
));
}
assert_eq!(
read_angle(
Some(&Value::Null),
EntityId(1),
9,
Axis::Latitude,
SchemaVersion::Ifc4
),
Ok(None)
);
}
}