geo3d 0.3.0

3D positions on Earth, made simple. Convert GPS (geodetic) coordinates to ECEF and local ENU/NED frames, then find look angles, distances and line of sight. Exact, tested, no dependencies.
Documentation
//! # 🌍 geo3d
//!
//! **3D positions on Earth, made simple.**
//!
//! Give geo3d a GPS position, then ask everyday questions:
//!
//! - πŸ“ Where is that plane, seen from here?
//! - πŸ“‘ Which way should the antenna point?
//! - πŸ“ How far apart are they?
//! - πŸ‘€ Can they see each other?
//! - πŸ’₯ Are they going to collide?
//!
//! It's pure Rust, with no dependencies.
//!
//! ```
//! use geo3d::{Geodetic, LocalFrame};
//!
//! let me = Geodetic::new(51.5, -0.12, 30.0);          // latitude, longitude, height in metres
//! let plane = Geodetic::new(51.6, -0.12, 3_000.0);
//!
//! let look = LocalFrame::new(me).look_angles(plane);   // which way to point
//! // due north, 14.9Β° up, 11.5 km away
//! assert!(look.elevation_deg > 14.0 && look.range_m > 11_000.0);
//! ```
//!
//! # ✨ Why geo3d?
//!
//! - 🎯 **Accurate.** Conversions are exact, and distances along the ground are good to 15
//!   nanometres. Everything is tested against GeographicLib, the reference library.
//! - πŸ›‘οΈ **Reliable.** It handles the tricky places, like the poles and opposite sides of the Earth,
//!   without NaN or panics.
//! - 🧩 **Easy to use.** Degrees and metres everywhere, with named fields. Latitude always comes first.
//! - πŸͺΆ **Light and fast.** No dependencies, no `unsafe` code, and nothing allocates. Works on Rust
//!   1.85 and newer.
//!
//! # πŸ—ΊοΈ The basics
//!
//! Latitude and longitude are angles, so you can't subtract two of them to get a distance. geo3d
//! turns positions into frames where normal 3D maths works.
//!
//! | Type | What it holds | Units |
//! |---|---|---|
//! | [`Geodetic`] | latitude, longitude and height, like GPS gives you | degrees, metres |
//! | [`Ecef`] | x, y, z from the centre of the Earth | metres |
//! | [`Enu`] / [`Ned`] | how far east, north and up (or north, east and down) from you | metres |
//! | [`Aer`] | azimuth, elevation and range: which way to point | degrees, metres |
//! | [`Eci`] | x, y, z that don't spin with the Earth, for satellites | metres |
//! | [`Vec3`] | a plain 3D vector, like a velocity | metres, m/s |
//!
//! A few rules hold everywhere:
//!
//! - Latitude comes first: `Geodetic::new(lat, lon, height)`.
//! - Bearings go clockwise from north, from 0Β° to 360Β°.
//! - Heights are above the WGS84 ellipsoid, like GPS. They are not heights above sea level.
//! - [`WGS84`] is the default, but any [`Ellipsoid`] works: `GRS80.to_ecef(p)`.
//! - A few low-level helpers use radians, and their names say so (`lat_rad`, `gmst_rad`).
//!   [`Vec3::angle_to`] also returns radians.
//!
//! # 🧭 Find what you need
//!
//! | I want to… | Use |
//! |---|---|
//! | convert between latitude/longitude and x, y, z | [`geodetic_to_ecef`], [`ecef_to_geodetic`] |
//! | find where something is, relative to me | [`LocalFrame::enu_of`], [`LocalFrame::ned_of`] |
//! | work out which way to point, and back | [`LocalFrame::look_angles`], [`LocalFrame::destination`] |
//! | find where a line of sight hits the ground | [`ground_strike`], [`Ellipsoid::ray_intersect`] |
//! | measure a straight line through space | [`distance3d_m`] |
//! | measure distance along the ground | [`Ellipsoid::geodesic_inverse`] (exact), [`Ellipsoid::great_circle_distance_m`] (quick) |
//! | travel a distance on a bearing | [`Ellipsoid::geodesic_direct`], [`Ellipsoid::great_circle_destination`] |
//! | check if something is inside a zone | [`Sphere`], [`Cone`], [`Cylinder`], [`AltitudeBand`] (all are [`Volume`]s) |
//! | check if two objects will collide | [`Track::conflict`], [`closest_approach`] |
//! | turn a velocity into heading, speed and climb | [`Course`], [`LocalFrame::course_of_velocity`] |
//! | check line of sight, or how far I can see | [`Ellipsoid::line_of_sight`], [`Ellipsoid::horizon_distance_m`] |
//! | track satellites | the [`eci`] module |
//! | shift between datums | [`Helmert7`] |
//!
//! More worked examples, a comparison with other crates and an accuracy table are in the
//! [README](https://github.com/FusionVault/geo3d#readme).
//!
//! # 🧩 Optional features
//!
//! They're all off by default, so a plain `cargo add geo3d` pulls in nothing else.
//!
//! | Feature | What it adds |
//! |---|---|
//! | `serde` | saving and loading every type |
//! | `mint` | conversions to `mint` types, and through them to `cgmath` and others |
//! | `glam` | conversions to `glam::DVec3` |
//! | `nalgebra` | conversions to `nalgebra` vectors and points |
//! | `geo-types` | [`Geodetic`] ↔ `geo_types::Point` (x is longitude, y is latitude) |

#![forbid(unsafe_code)]
#![warn(missing_docs)]

/// Every Rust code block in the README is compiled and run as a doctest.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;

mod datum;
pub mod eci;
mod ellipsoid;
mod frame;
mod geodesic;
mod great_circle;
mod interop;
mod local;
mod motion;
mod position;
mod vector;
mod visibility;
mod volume;

pub use datum::Helmert7;
pub use ellipsoid::{Ellipsoid, AIRY_1830, BESSEL_1841, CLARKE_1866, GRS80, INTERNATIONAL_1924, PZ90, WGS72, WGS84};
pub use frame::LocalFrame;
pub use geodesic::Geodesic;
pub use local::{Aer, Enu, Ned};
pub use motion::{closest_approach, Course, Cpa, Track};
pub use position::{Ecef, Eci, Geodetic};
pub use vector::Vec3;
pub use volume::{AltitudeBand, Cone, Cylinder, Sphere, Volume};

/// Latitude/longitude/height β†’ x, y, z on [`WGS84`]. Shorthand for [`WGS84.to_ecef`](Ellipsoid::to_ecef).
#[doc(alias = "geodetic2ecef")]
#[doc(alias = "lla2ecef")]
#[inline]
pub fn geodetic_to_ecef(g: Geodetic) -> Ecef {
    WGS84.to_ecef(g)
}

/// x, y, z β†’ latitude/longitude/height on [`WGS84`]. Shorthand for
/// [`WGS84.to_geodetic`](Ellipsoid::to_geodetic).
#[doc(alias = "ecef2geodetic")]
#[doc(alias = "ecef2lla")]
#[inline]
pub fn ecef_to_geodetic(e: Ecef) -> Geodetic {
    WGS84.to_geodetic(e)
}

/// The straight-line distance between two positions through space, metres β€” the **slant range**
/// between things at height, not the distance along the surface. On [`WGS84`].
#[doc(alias = "slant_range")]
#[inline]
pub fn distance3d_m(a: Geodetic, b: Geodetic) -> f64 {
    WGS84.to_ecef(a).distance_to(WGS84.to_ecef(b))
}

/// Where the line of sight from `observer` along `azimuth_deg` (0 = north, clockwise) and
/// `elevation_deg` (0 = horizon, negative = down) first meets the ground, on [`WGS84`]. `None` if it
/// points into space or the observer is below the surface. Shorthand for
/// [`LocalFrame::ground_strike`].
#[inline]
pub fn ground_strike(observer: Geodetic, azimuth_deg: f64, elevation_deg: f64) -> Option<Geodetic> {
    LocalFrame::new(observer).ground_strike(azimuth_deg, elevation_deg)
}

#[cfg(test)]
mod test_util {
    /// `|a βˆ’ b| ≀ tol`.
    pub fn close(a: f64, b: f64, tol: f64) -> bool {
        (a - b).abs() <= tol
    }

    /// Angles compare on the circle: 359.99999999Β° and 0Β° are 1e-8Β° apart.
    pub fn angle_close(a: f64, b: f64, tol: f64) -> bool {
        let d = (a - b).rem_euclid(360.0);
        d.min(360.0 - d) <= tol
    }
}