geo3d
3D geodesy in pure f64, zero dependencies.
Latitude, longitude and height are not a Euclidean space, so you cannot subtract two of them and
get a distance, an angle or a direction. geo3d gives you the metric frames that make 3D geometry
work — Earth-centred (ECEF) and local East-North-Up — and the everyday calculations on top of them:
slant range, look angles, where a line of sight meets the ground, whether a point is inside a
volume, when two moving objects get closest, geodesic distance along the ellipsoid.
Everything is typed (Geodetic, Ecef, Enu, Aer, …), Copy, allocation-free and
#![forbid(unsafe_code)]. Angles in the API are degrees, lengths are metres. ECEF is the frame
CesiumJS renders in, so a (lat, lon, height) track maps straight onto a globe.
Install
Optional: cargo add geo3d --features serde derives Serialize/Deserialize on every coordinate type.
Quick start
use ;
let site = new; // latitude°, longitude°, height m
let aircraft = new;
let slant = distance3d_m; // straight-line metres through space
let aer = new.look_angles; // azimuth°, elevation°, range m
let near = new.contains;
assert!;
assert!;
Every example in this README is compiled and run as a doctest by CI, so they are safe to copy.
Guide
Coordinates and ellipsoids
Geodetic is latitude/longitude in degrees and ellipsoidal height in metres. Ecef is metres from
the Earth's centre. An Ellipsoid is a value — WGS84 is the default and has shorthand free
functions; GRS80, WGS72, AIRY_1830, BESSEL_1841, CLARKE_1866, INTERNATIONAL_1924 and
PZ90 are there too, or build your own with Ellipsoid::new(a, f).
use ;
let sydney = new;
let e: Ecef = geodetic_to_ecef; // WGS84 shorthand
let e2 = WGS84.to_ecef; // the same, spelled out
let back = ecef_to_geodetic;
assert_eq!;
assert!;
let on_grs80 = GRS80.to_ecef; // any ellipsoid works the same way
assert!;
Local frames: ENU and NED
A LocalFrame is a tangent plane anchored at a reference point. It precomputes its rotation once,
so converting many targets against one observer is cheap. Every conversion exists in both the
East-North-Up and North-East-Down conventions.
use ;
let radar = new;
let target = new; // ~11 km due north, 3 km up
let enu = radar.enu_of; // east, north, up in metres
let ned = radar.ned_of; // north, east, down
assert!;
// ...and back: an offset in the frame as a geodetic position.
let there = radar.geodetic_of;
assert!;
Use to_enu / to_ecef when you already have ECEF positions, and dir_to_enu / dir_to_ecef
for directions (rotation only, no translation).
Look angles, pointing and ground strike
Aer is a pointing from an observer: azimuth (degrees clockwise from north), elevation (degrees
above the horizon) and range. look_angles gives it, destination inverts it, and ground_strike
finds where a pointing meets the ellipsoid surface.
use ;
let station = new;
let aircraft = new;
let aer = station.look_angles; // ≈ az 35.6°, el 7.3°, range 19 254 m
assert!;
let again = station.destination; // the aircraft, from the pointing
assert!;
// A camera looking 2° below the horizon along that bearing: where does it hit the ground?
let ground = station.ground_strike;
assert!;
assert!; // above the horizon: never meets the surface
// Pointings convert to local vectors and back.
let enu = new.to_enu; // 1 km due east
assert!;
Distances: straight line, great circle, geodesic
Three different questions, three functions. Pick by what you mean:
distance3d_m— the straight line through space (the ECEF chord). Right for anything at height: aircraft separation, sensor range, satellite slant range.great_circle_distance_m— along the surface on a mean-radius sphere. Fast, within about 0.3 % of the true value; fine for sorting, thresholds and display.geodesic_inverse— along the surface of the ellipsoid (Vincenty), accurate to about 0.5 mm. ReturnsNoneonly for nearly antipodal points.
use ;
let lax = new;
let jfk = new;
let chord = distance3d_m;
let spherical = WGS84.great_circle_distance_m;
let geodesic = WGS84.geodesic_inverse.unwrap;
assert!;
assert!;
// The direct problem: go that far on that bearing and you arrive at JFK.
let = WGS84.geodesic_direct;
assert!;
// Spherical helpers for bearing, destination and midpoint are there too.
let bearing = WGS84.initial_bearing_deg;
let halfway = WGS84.great_circle_midpoint;
assert!;
Volumes
Everything that can answer "is this point inside?" implements Volume. Volumes precompute their
frame at construction, so testing many points against one volume is cheap.
AltitudeBand— a height range with optional floor and ceiling. Compose it with any 2D footprint test (fromgeo, geodatafusion, PostGIS, …) viawithin_prismfor an extruded airspace or geofence.Sphere— a range or proximity bubble.Cone— a directional volume: a camera field of view, an antenna beam, a scan sector.Cylinder— a circular footprint through a height band.
use ;
let site = new;
let band = new;
let inside_footprint = true; // from your 2D geometry library
assert!;
assert!;
let bubble = new;
let camera = new; // due east, 20° half-angle, 5 km
let zone = new;
let p = new; // ~2.2 km east, 500 m up
assert!;
assert!; // north: outside the beam
Tracks, courses and conflicts
Track is a position and velocity in ECEF. Course is what receivers report — course over ground,
ground speed, climb rate — and a LocalFrame converts between the two at the object's position.
use ;
let a_pos = new;
let b_pos = new;
// Course over ground → ECEF velocity, each at its own position.
let a = new;
let b = new;
let cpa = a.closest_approach; // cpa.time_s (seconds from now), cpa.distance_m
let alert = a.conflict; // Some(cpa) only if it is in the future and within 1 km
let in_a_minute = WGS84.to_geodetic; // dead reckoning
assert!;
assert!; // heading south-west
// Back from a velocity to a course, e.g. from two fixes ten seconds apart.
let fixes = from_fixes;
let course = new.course_of_velocity;
assert!;
Line of sight and horizon
Geometric visibility over a smooth ellipsoid — no terrain, no refraction.
use ;
let observer = WGS84.to_ecef; // 10 km up
let near = WGS84.to_ecef; // ~111 km away
let far = WGS84.to_ecef; // ~1 100 km away
assert!;
assert!; // the Earth is in the way
let horizon = WGS84.horizon_distance_m; // ≈ 357 km
assert!;
Inertial frame
The eci module rotates between Earth-fixed and Earth-centred inertial coordinates by Greenwich
mean sidereal time (IAU 1982). It is the plain rotation satellite-tracking code uses; precession,
nutation and polar motion are out of scope.
use ;
let gmst = gmst_from_unix; // Unix seconds → sidereal angle in radians
let fixed = new;
let inertial = fixed.to_eci;
let back = inertial.to_ecef;
assert!;
Datum shifts
Helmert7 applies a 7-parameter transformation between ECEF frames. Build it from parameters as
EPSG publishes them, in either rotation convention.
use ;
// Position-vector convention: tx ty tz in metres, rx ry rz in arc-seconds, scale in ppm.
let h = position_vector;
// Coordinate-frame convention (flipped rotation signs): Helmert7::coordinate_frame(...)
let p = new;
let shifted = h.apply;
let back = h.apply_inverse; // exact; `h.inverse()` is the first-order approximation
assert!;
Serde
With the serde feature every coordinate type serialises as plain named fields:
// cargo add geo3d --features serde
let json = to_string?;
// {"lat_deg":34.05,"lon_deg":-118.24,"height_m":100.0}
Interoperability
The coordinate types are deliberately small and Copy, but you will want to move them into the math
and GIS crates you already use. Optional, default-off features add From/Into conversions, so
the default build stays dependency-free:
mint—mint::Vector3<f64>(every vector type) andmint::Point3<f64>(the position types).mintis the common interop layer, so this one bridge also reachesglam,nalgebraandcgmath.glam—glam::DVec3.nalgebra—nalgebra::Vector3<f64>/Point3<f64>.geo-types—Geodetic↔geo_types::Point<f64>/Coord<f64>, for piping into the 2Dgeoecosystem (polygon tests, PostGIS, shapefiles). Longitude isx, latitude isy(thegeoconvention); the ellipsoidal height is dropped.
#
Precision
Each function states its accuracy class, and the tests hold it:
- Exact closed forms — geodetic → ECEF, the local frames, look angles, ray–ellipsoid, volumes,
closest approach, Helmert
apply/apply_inverse. Round trips are held to 1e-11° and 1e-6 m over a global grid from 11 km below the surface to geostationary height, and the conversions are cross-validated against an independent implementation (map_3d, a dev-only dependency). - ECEF → geodetic — Bowring's method iterated to machine precision, so the inverse holds at orbital heights as well as on the ground.
- Geodesics (Vincenty) — about 0.5 mm. Pinned to GeographicLib reference values: the classic
Flinders Peak–Buninyong line on two ellipsoids, direct problems from 1 m to 9 000 km, and the exact
quarter-equator and quarter-meridian lengths. Nearly antipodal pairs return
Nonerather than a wrong number. - Spherical approximations —
great_circle_*andhorizon_distance_muse the mean radius and say so; within 0.5 % of the geodesic. - Linearised —
Helmert7::inverse(useapply_inversefor exact),interpolate(an ECEF chord),Track(constant velocity) andeci(GMST only) are documented as such on each item.
Performance
The geometry is closed-form and precomputes per reference, so per-call cost is dominated by the
hardware sin/cos/atan2/sqrt. Two things make that measurable and a little faster:
-
Benchmarks.
cargo benchruns a criterion suite over the hot paths (conversions, look angles, geodesics, Helmert, ray intersection, vector ops), so a regression shows up as a number. -
FMA, where it pays. The dot/cross products, the geodetic ↔ ECEF conversions, the Vincenty series and the Helmert transform are written as fused multiply-adds — but the fused form is taken only when the target has an FMA instruction, because
f64::mul_addlowers to a slow software libcall otherwise. On a stock target the code is the plain multiply-add it always was (identical speed and results); build with FMA enabled to get the fused path — one instruction, one rounding (marginally more accurate), and a few percent faster on the geodesy operations:RUSTFLAGS="-C target-cpu=native"
Design
- Zero required dependencies, pure
f64, every typeCopy, nothing allocates,#![forbid(unsafe_code)]. Optional features addserdeand the interop bridges, nothing else. - Precompute per reference, not per point:
LocalFrame,Sphere,ConeandCylinderdo their trigonometry once at construction. - Closed forms wherever one exists; iteration only where it must be, and then to convergence.
- Ellipsoid-generic: everything takes an
Ellipsoid, WGS84 is the default. - Degrees and metres at the API; radians never leak out.
Not in scope, by design: planar 2D geometry (use the geo
ecosystem), map projections (proj, geodesy), geodesics to the millimetre over long lines
(geographiclib), terrain and meshes (parry3d), and precession, nutation or polar motion.
Develop
CI runs all of the above plus a build on the declared minimum Rust version (1.85).
Versioning and license
Plain semver; tags vX.Y.Z at the published commit; see CHANGELOG.md.
Apache-2.0, see LICENSE.