Skip to main content

axioval_engine/
coordinate_system.rs

1//! Source coordinate systems: where a source's coordinates sit.
2//!
3//! Every object frame and every mesh is stated in its source's own
4//! coordinates. Whether two sources, or two revisions of one source, share a
5//! coordinate system is a fact about the source as a whole, not about any
6//! object, so this seam answers per source.
7//!
8//! A source may state three things, each independently:
9//!
10//! - its **world frame**: the frame its model coordinates are stated in,
11//!   with the origin in canonical metres;
12//! - **true north**: the plan direction of geographic north in those
13//!   coordinates;
14//! - a **map conversion**: how its coordinates map onto a named map
15//!   coordinate reference system (offset, rotation, scale).
16//!
17//! What a source does not state is `None`, never a default: a missing map
18//! conversion means the source is not georeferenced, not that it sits at the
19//! map origin.
20
21use std::sync::Arc;
22
23use axioval_ir::{Evidence, SourceId};
24use thiserror::Error;
25
26use crate::services::reviewable_exact_evidence;
27use crate::{MetricDirection, SnapshotBoundService, SourceSnapshot};
28
29/// Failure to supply a source's coordinate system.
30#[derive(Clone, Debug, Error, PartialEq, Eq)]
31pub enum CoordinateSystemError {
32    /// The service does not cover the source.
33    #[error("coordinate-system service does not cover source `{0}`")]
34    UncoveredSource(SourceId),
35    /// The source states its coordinate system more than once, for example
36    /// two model contexts, and nothing says which one applies.
37    #[error("coordinate system is stated ambiguously: {0}")]
38    Ambiguous(String),
39    /// The source states it by a construct the service cannot resolve
40    /// exactly.
41    #[error("coordinate system unsupported: {0}")]
42    Unsupported(String),
43    /// A statement, or the unit it is stated in, is malformed or cannot be
44    /// read exactly.
45    #[error("coordinate system cannot be read exactly: {0}")]
46    Unreadable(String),
47    /// A value is non-finite, an axis triple is not right-handed and
48    /// orthonormal, or a scale is not positive.
49    #[error("coordinate system is invalid")]
50    InvalidMeasurement,
51    /// The evidence is not exact, not reviewable, or from another source.
52    #[error("coordinate-system evidence is not exact and reviewable")]
53    InexactEvidence,
54    /// The service answered for another source.
55    #[error("coordinate-system service answered for another source")]
56    ResponseRequestMismatch,
57}
58
59/// A right-handed orthonormal frame with its origin in canonical metres.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct CoordinateFrame {
62    origin_metres: [f64; 3],
63    axes: [MetricDirection; 3],
64}
65
66impl CoordinateFrame {
67    /// A frame; the origin must be finite and the axes a right-handed
68    /// orthonormal triple.
69    pub fn try_new(
70        origin_metres: [f64; 3],
71        x: MetricDirection,
72        y: MetricDirection,
73        z: MetricDirection,
74    ) -> Result<Self, CoordinateSystemError> {
75        const ORTHOGONAL_TOLERANCE: f64 = 1.0e-9;
76        if !origin_metres.iter().all(|value| value.is_finite()) {
77            return Err(CoordinateSystemError::InvalidMeasurement);
78        }
79        let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
80        let [first, second, third] = [x.components(), y.components(), z.components()];
81        let cross = [
82            first[1] * second[2] - first[2] * second[1],
83            first[2] * second[0] - first[0] * second[2],
84            first[0] * second[1] - first[1] * second[0],
85        ];
86        if dot(first, second).abs() > ORTHOGONAL_TOLERANCE
87            || dot(first, third).abs() > ORTHOGONAL_TOLERANCE
88            || dot(second, third).abs() > ORTHOGONAL_TOLERANCE
89            || dot(cross, third) < 1.0 - ORTHOGONAL_TOLERANCE
90        {
91            return Err(CoordinateSystemError::InvalidMeasurement);
92        }
93        Ok(Self {
94            origin_metres,
95            axes: [x, y, z],
96        })
97    }
98
99    /// The origin, in canonical metres.
100    #[must_use]
101    pub fn origin_metres(&self) -> [f64; 3] {
102        self.origin_metres
103    }
104
105    /// The X, Y and Z axes.
106    #[must_use]
107    pub fn axes(&self) -> [MetricDirection; 3] {
108        self.axes
109    }
110}
111
112/// How a source's coordinates map onto a map coordinate reference system.
113#[derive(Clone, Debug, PartialEq)]
114pub struct MapConversion {
115    target: Option<String>,
116    offset: [f64; 3],
117    x_axis: [f64; 2],
118    scale: f64,
119    metres_per_map_unit: Option<f64>,
120}
121
122impl MapConversion {
123    /// A map conversion.
124    ///
125    /// `offset` is easting, northing and orthogonal height in the map's unit;
126    /// `x_axis` is the direction of the source's X axis in the map's plan
127    /// (abscissa, ordinate) and is normalized; `scale` is the factor from
128    /// source to map lengths. `metres_per_map_unit` is `None` when the map's
129    /// unit is not stated or cannot be resolved exactly, never a guess.
130    pub fn try_new(
131        target: Option<String>,
132        offset: [f64; 3],
133        x_axis: [f64; 2],
134        scale: f64,
135        metres_per_map_unit: Option<f64>,
136    ) -> Result<Self, CoordinateSystemError> {
137        let norm = x_axis[0].hypot(x_axis[1]);
138        let valid = offset.iter().all(|value| value.is_finite())
139            && norm.is_finite()
140            && norm > f64::EPSILON
141            && scale.is_finite()
142            && scale > 0.0
143            && metres_per_map_unit.is_none_or(|unit| unit.is_finite() && unit > 0.0)
144            && target.as_deref().is_none_or(|name| !name.trim().is_empty());
145        if !valid {
146            return Err(CoordinateSystemError::InvalidMeasurement);
147        }
148        Ok(Self {
149            target,
150            offset,
151            x_axis: [x_axis[0] / norm, x_axis[1] / norm],
152            scale,
153            metres_per_map_unit,
154        })
155    }
156
157    /// The name of the target map coordinate reference system, when stated.
158    #[must_use]
159    pub fn target(&self) -> Option<&str> {
160        self.target.as_deref()
161    }
162
163    /// Easting, northing and orthogonal height, in the map's unit.
164    #[must_use]
165    pub fn offset(&self) -> [f64; 3] {
166        self.offset
167    }
168
169    /// Unit direction of the source's X axis in the map's plan.
170    #[must_use]
171    pub fn x_axis(&self) -> [f64; 2] {
172        self.x_axis
173    }
174
175    /// Scale from source lengths to map lengths.
176    #[must_use]
177    pub fn scale(&self) -> f64 {
178        self.scale
179    }
180
181    /// Metres per map unit, or `None` when the unit is not known exactly.
182    #[must_use]
183    pub fn metres_per_map_unit(&self) -> Option<f64> {
184        self.metres_per_map_unit
185    }
186
187    /// The offset in metres, when the map's unit is known.
188    #[must_use]
189    pub fn offset_metres(&self) -> Option<[f64; 3]> {
190        self.metres_per_map_unit
191            .map(|unit| self.offset.map(|value| value * unit))
192    }
193}
194
195/// One source's coordinate system, as far as the source states it.
196#[derive(Clone, Debug, PartialEq)]
197pub struct SourceCoordinateSystem {
198    source: SourceId,
199    world: Option<CoordinateFrame>,
200    true_north: Option<[f64; 2]>,
201    map: Option<MapConversion>,
202    evidence: Evidence,
203}
204
205impl SourceCoordinateSystem {
206    /// The coordinate system of `source`.
207    ///
208    /// `true_north` is a plan direction and is normalized. The evidence must
209    /// be exact, reviewable and from `source`: a coordinate system is stated,
210    /// never estimated.
211    pub fn try_new(
212        source: SourceId,
213        world: Option<CoordinateFrame>,
214        true_north: Option<[f64; 2]>,
215        map: Option<MapConversion>,
216        evidence: Evidence,
217    ) -> Result<Self, CoordinateSystemError> {
218        let true_north = match true_north {
219            Some([x, y]) => {
220                let norm = x.hypot(y);
221                if !norm.is_finite() || norm <= f64::EPSILON {
222                    return Err(CoordinateSystemError::InvalidMeasurement);
223                }
224                Some([x / norm, y / norm])
225            }
226            None => None,
227        };
228        if !reviewable_exact_evidence(&evidence) || evidence.source != source {
229            return Err(CoordinateSystemError::InexactEvidence);
230        }
231        Ok(Self {
232            source,
233            world,
234            true_north,
235            map,
236            evidence,
237        })
238    }
239
240    /// The source this coordinate system belongs to.
241    #[must_use]
242    pub fn source(&self) -> &SourceId {
243        &self.source
244    }
245
246    /// The frame the source's coordinates are stated in, when stated.
247    #[must_use]
248    pub fn world(&self) -> Option<&CoordinateFrame> {
249        self.world.as_ref()
250    }
251
252    /// Unit plan direction of true north, when stated.
253    #[must_use]
254    pub fn true_north(&self) -> Option<[f64; 2]> {
255        self.true_north
256    }
257
258    /// The map conversion, when the source is georeferenced.
259    #[must_use]
260    pub fn map(&self) -> Option<&MapConversion> {
261        self.map.as_ref()
262    }
263
264    /// Reviewable provenance of the statements.
265    #[must_use]
266    pub fn evidence(&self) -> &Evidence {
267        &self.evidence
268    }
269}
270
271/// Trusted adapter seam supplying sources' coordinate systems.
272pub trait CoordinateSystemService: Send + Sync + 'static {
273    /// Exact source snapshots this service answers for.
274    fn source_snapshots(&self) -> &[SourceSnapshot];
275    /// The coordinate system of `source`, or why it cannot be read.
276    fn coordinate_system(
277        &self,
278        source: &SourceId,
279    ) -> Result<SourceCoordinateSystem, CoordinateSystemError>;
280}
281
282/// Registry handle for a [`CoordinateSystemService`].
283#[derive(Clone)]
284pub struct CoordinateSystemServiceHandle(Arc<dyn CoordinateSystemService>);
285
286impl CoordinateSystemServiceHandle {
287    /// Wraps a trusted coordinate-system service.
288    #[must_use]
289    pub fn new(service: Arc<dyn CoordinateSystemService>) -> Self {
290        Self(service)
291    }
292
293    /// The coordinate system of `source`. An uncovered source is refused,
294    /// and so is an answer about another source.
295    pub fn coordinate_system(
296        &self,
297        source: &SourceId,
298    ) -> Result<SourceCoordinateSystem, CoordinateSystemError> {
299        if !self
300            .0
301            .source_snapshots()
302            .iter()
303            .any(|snapshot| snapshot.source() == source)
304        {
305            return Err(CoordinateSystemError::UncoveredSource(source.clone()));
306        }
307        let answer = self.0.coordinate_system(source)?;
308        if answer.source() != source {
309            return Err(CoordinateSystemError::ResponseRequestMismatch);
310        }
311        Ok(answer)
312    }
313}
314
315impl SnapshotBoundService for CoordinateSystemServiceHandle {
316    fn source_snapshots(&self) -> &[SourceSnapshot] {
317        self.0.source_snapshots()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn source(name: &str) -> SourceId {
326        SourceId::new("cad", name).unwrap()
327    }
328
329    fn direction(vector: [f64; 3]) -> MetricDirection {
330        MetricDirection::try_new(vector).unwrap()
331    }
332
333    fn identity() -> [MetricDirection; 3] {
334        [
335            direction([1.0, 0.0, 0.0]),
336            direction([0.0, 1.0, 0.0]),
337            direction([0.0, 0.0, 1.0]),
338        ]
339    }
340
341    #[test]
342    fn a_frame_must_be_right_handed_and_finite() {
343        let [x, y, z] = identity();
344        assert!(CoordinateFrame::try_new([0.0; 3], x, y, z).is_ok());
345        assert_eq!(
346            CoordinateFrame::try_new([0.0; 3], direction([-1.0, 0.0, 0.0]), y, z),
347            Err(CoordinateSystemError::InvalidMeasurement)
348        );
349        assert_eq!(
350            CoordinateFrame::try_new([f64::NAN, 0.0, 0.0], x, y, z),
351            Err(CoordinateSystemError::InvalidMeasurement)
352        );
353    }
354
355    #[test]
356    #[allow(clippy::float_cmp)] // Normalizing an axis-aligned vector is exact.
357    fn a_map_conversion_needs_a_positive_scale_and_a_direction() {
358        assert!(MapConversion::try_new(None, [1.0, 2.0, 3.0], [1.0, 0.0], 1.0, None).is_ok());
359        for (axis, scale, unit) in [
360            ([0.0, 0.0], 1.0, None),
361            ([1.0, 0.0], 0.0, None),
362            ([1.0, 0.0], 1.0, Some(-1.0)),
363        ] {
364            assert_eq!(
365                MapConversion::try_new(None, [0.0; 3], axis, scale, unit),
366                Err(CoordinateSystemError::InvalidMeasurement)
367            );
368        }
369        let map =
370            MapConversion::try_new(None, [1.0, 2.0, 3.0], [0.0, 2.0], 1.0, Some(0.001)).unwrap();
371        assert_eq!(map.x_axis(), [0.0, 1.0]);
372        assert_eq!(map.offset_metres(), Some([0.001, 0.002, 0.003]));
373    }
374
375    #[test]
376    fn evidence_must_be_exact_and_from_the_source() {
377        let foreign = Evidence::exact(source("b"), "crs");
378        assert_eq!(
379            SourceCoordinateSystem::try_new(source("a"), None, None, None, foreign),
380            Err(CoordinateSystemError::InexactEvidence)
381        );
382        let system = SourceCoordinateSystem::try_new(
383            source("a"),
384            None,
385            Some([0.0, 3.0]),
386            None,
387            Evidence::exact(source("a"), "crs"),
388        )
389        .unwrap();
390        assert_eq!(system.true_north(), Some([0.0, 1.0]));
391    }
392
393    struct Fixed(Vec<SourceSnapshot>, SourceCoordinateSystem);
394    impl CoordinateSystemService for Fixed {
395        fn source_snapshots(&self) -> &[SourceSnapshot] {
396            &self.0
397        }
398        fn coordinate_system(
399            &self,
400            _: &SourceId,
401        ) -> Result<SourceCoordinateSystem, CoordinateSystemError> {
402            Ok(self.1.clone())
403        }
404    }
405
406    #[test]
407    fn the_handle_binds_answers_to_the_request() {
408        let snapshots = vec![
409            SourceSnapshot::try_new(source("a"), "r", "sha256:a").unwrap(),
410            SourceSnapshot::try_new(source("b"), "r", "sha256:b").unwrap(),
411        ];
412        let answer = SourceCoordinateSystem::try_new(
413            source("a"),
414            None,
415            None,
416            None,
417            Evidence::exact(source("a"), "crs"),
418        )
419        .unwrap();
420        let handle = CoordinateSystemServiceHandle::new(Arc::new(Fixed(snapshots, answer)));
421        assert!(handle.coordinate_system(&source("a")).is_ok());
422        assert_eq!(
423            handle.coordinate_system(&source("b")),
424            Err(CoordinateSystemError::ResponseRequestMismatch)
425        );
426        assert_eq!(
427            handle.coordinate_system(&source("c")),
428            Err(CoordinateSystemError::UncoveredSource(source("c")))
429        );
430    }
431}