Skip to main content

powerio_dist/glm/
coordinates.rs

1use std::collections::BTreeMap;
2
3use powerio_core::Error;
4
5use super::{GlmPropertyValue, ObjectIndex, ObjectKey, Statement, TokenKind};
6
7/// Source-declared object coordinates in decimal degrees. An absent axis is
8/// distinct from zero. These common GLM fields do not declare a datum or CRS.
9/// Powerflow initialization may subsequently replace a link's location.
10#[derive(Clone, Copy, Debug, Default, PartialEq)]
11pub struct GlmCoordinates {
12    pub latitude: Option<f64>,
13    pub longitude: Option<f64>,
14}
15
16impl ObjectIndex<'_> {
17    /// Evaluate coordinate assignments in expanded source order. Native
18    /// `(name.latitude)` / `(name.longitude)` references copy the value at the
19    /// assignment, not the referenced object's eventual value. Only already
20    /// named objects are visible; an absent referenced axis stays absent.
21    ///
22    /// The returned vector uses [`ObjectKey::ordinal`]. This is source state,
23    /// not initialized engine geometry. Malformed/nonfinite/out-of-range
24    /// coordinates fail explicitly instead of relying on native prefix parsing.
25    pub fn declared_coordinates(&self) -> Result<Vec<GlmCoordinates>, Error> {
26        let mut result = vec![GlmCoordinates::default(); self.objects().len()];
27        let mut events = Vec::new();
28        for object in self.objects() {
29            for statement in object.other_statements() {
30                if let Statement::Simple { tokens, span } = statement
31                    && tokens
32                        .first()
33                        .is_some_and(|token| matches!(token.text(), "latitude" | "longitude"))
34                {
35                    return Err(self.fail(span, "coordinate assignment is missing its value"));
36                }
37            }
38            for property in object.properties() {
39                if matches!(property.name, "name" | "latitude" | "longitude") {
40                    events.push((object, property));
41                }
42            }
43        }
44        // Expanded spans provide one ordering across included source files.
45        // This also handles parent properties written after nested objects.
46        events.sort_by_key(|(_, property)| property.span.byte_start());
47        let mut named: BTreeMap<&str, ObjectKey> = BTreeMap::new();
48        for (object, property) in events {
49            if property.name == "name" {
50                if let Some(name) = object.name() {
51                    named.insert(name, object.key());
52                }
53                continue;
54            }
55            let fail = |message| self.fail(property.span, message);
56            let GlmPropertyValue::Tokens(tokens) = &property.value else {
57                return Err(fail("coordinate cannot be an inline object"));
58            };
59            let [token] = *tokens else {
60                return Err(fail(
61                    "coordinate requires one literal or object-axis reference",
62                ));
63            };
64            let text = if token.kind() == TokenKind::Quoted {
65                token
66                    .text()
67                    .strip_prefix('"')
68                    .and_then(|s| s.strip_suffix('"'))
69                    .ok_or_else(|| fail("coordinate strings require double quotes"))?
70            } else {
71                token.text()
72            };
73            let latitude = property.name == "latitude";
74            let value = if let Some(reference) = text.strip_prefix('(') {
75                let (name, axis) = reference
76                    .strip_suffix(')')
77                    .and_then(|s| s.split_once('.'))
78                    .ok_or_else(|| fail("expected (object.latitude) or (object.longitude)"))?;
79                if axis != property.name {
80                    return Err(fail("coordinate reference must use the same axis"));
81                }
82                let key = named.get(name).ok_or_else(|| {
83                    fail("coordinate reference requires an object named before this assignment")
84                })?;
85                let point = result[key.ordinal()];
86                if latitude {
87                    point.latitude
88                } else {
89                    point.longitude
90                }
91            } else {
92                Some(parse_coordinate(text, latitude).map_err(fail)?)
93            };
94            let point = &mut result[object.key().ordinal()];
95            if latitude {
96                point.latitude = value;
97            } else {
98                point.longitude = value;
99            }
100        }
101        Ok(result)
102    }
103}
104
105fn parse_coordinate(text: &str, latitude: bool) -> Result<f64, &'static str> {
106    let hemispheres = if latitude { ['N', 'S'] } else { ['E', 'W'] };
107    let value = if let Some(position) = text.find(hemispheres) {
108        let (degrees, tail) = text.split_at(position);
109        if degrees.is_empty() || !degrees.bytes().all(|c| c.is_ascii_digit()) {
110            return Err("hemisphere coordinates require unsigned integer degrees");
111        }
112        let degrees = number(degrees)?;
113        let (minutes, seconds) = if let Some((minutes, seconds)) = tail[1..].split_once(':') {
114            if minutes.is_empty() || !minutes.bytes().all(|c| c.is_ascii_digit()) {
115                return Err("DMS coordinates require unsigned integer minutes");
116            }
117            (number(minutes)?, number(seconds)?)
118        } else {
119            (number(&tail[1..])?, 0.0)
120        };
121        if !(0.0..60.0).contains(&minutes) || !(0.0..60.0).contains(&seconds) {
122            return Err("coordinate minutes and seconds must be in [0, 60)");
123        }
124        let magnitude = degrees + minutes / 60.0 + seconds / 3600.0;
125        if tail.starts_with(hemispheres[1]) {
126            -magnitude
127        } else {
128            magnitude
129        }
130    } else {
131        number(text)?
132    };
133    let limit = if latitude { 90.0 } else { 180.0 };
134    if !(-limit..=limit).contains(&value) {
135        return Err("coordinate is outside the geographic axis range");
136    }
137    Ok(value)
138}
139
140fn number(text: &str) -> Result<f64, &'static str> {
141    let value = text
142        .parse::<f64>()
143        .map_err(|_| "invalid coordinate number")?;
144    if !value.is_finite() {
145        return Err("coordinate must be finite");
146    }
147    Ok(value)
148}