Project

geodetic

0.0
The project is in a healthy, maintained state
A Ruby gem for converting between 19 geodetic coordinate systems (LLA, ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH, HAM, OLC, GEOREF, GARS, H3, S2) with Vincenty great-circle and ECEF Euclidean distance calculations, unit-aware Distance and Bearing classes, geoid height support, and geographic area operations.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

Geodetic

[!INFO] See the CHANGELOG for the latest changes. The examples directory has runnable demo apps that show-off the various capabilities of the Geodetic library.


Geodetic
"Convert coordinates. Map the world."
Key Features
  • 19 Coordinate Systems - LLA, ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH, HAM, OLC, GEOREF, GARS, H3, S2
  • Full Bidirectional Conversions - Every system converts to and from every other system
  • Distance Calculations - Vincenty great-circle and straight-line with unit tracking
  • Bearing Calculations - Forward azimuth, back azimuth, compass directions, elevation angles
  • Geoid Height Support - EGM96, EGM2008, GEOID18, GEOID12B models
  • Geographic Areas - Circle, Polygon, BoundingBox, Triangle, Rectangle, Pentagon, Hexagon, Octagon
  • Segments - Directed two-point line segments with projection, intersection, and interpolation
  • Paths - Directed coordinate sequences with navigation, interpolation, closest approach, intersection, and area conversion
  • Features - Named geometry wrapper with metadata and delegated distance/bearing
  • Vectors - Geodetic displacement (distance + bearing) with full arithmetic and Vincenty direct
  • Geodetic Arithmetic - Compose geometry with operators: P1 + P2 → Segment, + P3 → Path, + Distance → Circle, * Vector → translate
  • GeoJSON Export - Build FeatureCollections from any mix of objects and save to file
  • WKT Serialization - Well-Known Text export/import with SRID/EWKT and Z-dimension support
  • WKB Serialization - Well-Known Binary export/import with EWKB, SRID, hex encoding, and file I/O
  • GEOS Acceleration - Optional native acceleration for polygon validation, point-in-polygon, path intersection, and boolean operations
  • Validated Setters - Type coercion and range validation on all coordinate attributes
  • Serialization - to_s(precision), to_a, from_string, from_array, DMS format
  • Multiple Datums - WGS84, Clarke 1866, GRS 1980, Airy 1830, and more
  • Immutable Value Types - Distance and Bearing with arithmetic and comparison

Geodetic enables precise conversion between geodetic coordinate systems in Ruby. All 19 coordinate systems support complete bidirectional conversions with high precision. Review the full documentation website and explore the runnable examples.

Installation

Add to your Gemfile:

gem "geodetic"

Or install directly:

gem install geodetic

Optional: H3 Hexagonal Index

The H3 coordinate system requires Uber's H3 C library installed on your system. Without it, all other 17 coordinate systems work normally; H3 operations will raise a helpful error.

# macOS
brew install h3

# Linux (build from source)
# See https://h3geo.org/docs/installation

You can also set the LIBH3_PATH environment variable to point to a custom libh3 location.

Optional: GEOS Spatial Acceleration

The GEOS library accelerates polygon validation, point-in-polygon tests, path intersection, and adds boolean geometry operations (intersection, difference, convex hull, etc.). Without it, all operations use pure Ruby implementations. Geodetic automatically uses GEOS when available and falls back to Ruby when it is not.

# macOS
brew install geos

# Linux (Debian/Ubuntu)
sudo apt-get install libgeos-dev

Geodetic uses GEOS selectively — only where it provides a measurable speedup. Single segment intersections stay in Ruby (FFI overhead exceeds the computation cost). For polygons with fewer than 15 vertices, point-in-polygon tests also stay in Ruby.

Set GEODETIC_GEOS_DISABLE=1 to force pure Ruby for all operations, even when GEOS is installed. See GEOS Acceleration for detailed performance analysis and example 12 for a runnable benchmark.

Geodetic::Geos.available?  # => true when libgeos_c is found and not disabled

Optional: S2 Spherical Geometry Index

The S2 coordinate system requires Google's S2 Geometry library installed on your system. S2 projects a cube onto the sphere and subdivides it into quadrilateral cells via a Hilbert space-filling curve, providing very low distortion (~0.56%) across the entire Earth surface. Cell IDs are 64-bit integers that enable efficient spatial database queries on standard B-tree indexes.

# macOS
brew install s2geometry

# Linux (build from source)
# See https://github.com/google/s2geometry

You can set the LIBS2_PATH environment variable to point to a custom libs2 location. See S2 documentation for detailed API reference and example 15 for a comprehensive demo.

Geodetic::Coordinate::S2.available?  # => true when libs2 is found

Usage

Basic Coordinate Creation

All constructors use keyword arguments:

require "geodetic"

include Geodetic

# lng:, lon:, and long: are all accepted for longitude
lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
lla = Coordinate::LLA.new(lat: 47.6205, lon: -122.3493, alt: 184.0)
lla = Coordinate::LLA.new(lat: 47.6205, long: -122.3493, alt: 184.0)
# Readers: lla.lng, lla.lon, lla.long, lla.longitude all return the same value
ecef = Coordinate::ECEF.new(x: -2304643.57, y: -3638650.07, z: 4688674.43)
utm = Coordinate::UTM.new(easting: 548894.0, northing: 5272748.0, altitude: 184.0, zone: 10, hemisphere: "N")
enu = Coordinate::ENU.new(e: 100.0, n: 200.0, u: 50.0)
ned = Coordinate::NED.new(n: 200.0, e: 100.0, d: -50.0)

GCS Shorthand

For convenience, you can define a short alias in your application:

require "geodetic"

GCS = Geodetic::Coordinate

seattle = Geodetic::Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
ecef = Geodetic::Coordinate::ECEF.new(x: -2304643.57, y: -3638650.07, z: 4688674.43)

Discovering Coordinate Systems

List all available coordinate systems at runtime:

Geodetic::Coordinate.systems
# => [Geodetic::Coordinate::LLA, Geodetic::Coordinate::ECEF, Geodetic::Coordinate::UTM, ...]

# Get short names
Geodetic::Coordinate.systems.map { |c| c.name.split('::').last }
# => ["LLA", "ECEF", "UTM", "ENU", "NED", "MGRS", "USNG", "WebMercator",
#     "UPS", "StatePlane", "BNG", "GH36", "GH", "HAM", "OLC", "GEOREF", "GARS", "H3", "S2"]

Coordinate Conversions

Every coordinate system can convert to and from every other system:

lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)

# LLA to other systems
ecef = lla.to_ecef
utm  = lla.to_utm
wm   = Coordinate::WebMercator.from_lla(lla)
mgrs = Coordinate::MGRS.from_lla(lla)

# Convert back
lla_roundtrip = ecef.to_lla

# Local coordinate systems require a reference point
reference = Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 0.0)
enu = lla.to_enu(reference)
ned = lla.to_ned(reference)

Serialization

All coordinate classes support to_s, to_a, from_string, and from_array. The to_s method accepts an optional precision parameter controlling the number of decimal places:

lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)

lla.to_s                            # => "47.620500, -122.349300, 184.00"
lla.to_s(3)                         # => "47.620, -122.349, 184.00"
lla.to_s(0)                         # => "48, -122, 184"
lla.to_a                            # => [47.6205, -122.3493, 184.0]

Coordinate::LLA.from_string("47.6205, -122.3493, 184.0")
Coordinate::LLA.from_array([47.6205, -122.3493, 184.0])

Default precisions by class: LLA=6, Bearing=4, all others=2. Passing 0 returns integers.

Validated Setters

All coordinate classes provide setter methods with type coercion and validation:

lla = Coordinate::LLA.new(lat: 47.0, lng: -122.0, alt: 100.0)
lla.lat = 48.0                     # validates -90..90
lla.lng = -121.0                   # validates -180..180
lla.alt = 200.0                    # no range constraint
lla.lat = 91.0                     # => ArgumentError

utm = Coordinate::UTM.new(easting: 500000.0, northing: 5000000.0, zone: 10, hemisphere: 'N')
utm.zone = 15                      # validates 1..60
utm.hemisphere = 'S'               # validates 'N' or 'S'
utm.easting = -1.0                 # => ArgumentError

# UPS cross-validates hemisphere/zone combinations
ups = Coordinate::UPS.new(hemisphere: 'N', zone: 'Y')
ups.zone = 'Z'                     # valid for hemisphere 'N'
ups.zone = 'A'                     # => ArgumentError (rolls back)

# BNG auto-updates grid_ref when easting/northing change
bng = Coordinate::BNG.new(easting: 530000, northing: 180000)
bng.easting = 430000               # grid_ref automatically recalculated

ECEF, ENU, NED, and WebMercator setters coerce to float with no range constraints. MGRS, USNG, GH36, GH, HAM, OLC, Distance, and Bearing are immutable.

DMS (Degrees, Minutes, Seconds)

lla = Coordinate::LLA.new(lat: 37.7749, lng: -122.4192, alt: 15.0)
lla.to_dms    # => "37° 46' 29.64\" N, 122° 25' 9.12\" W, 15.00 m"

Coordinate::LLA.from_dms("37° 46' 29.64\" N, 122° 25' 9.12\" W, 15.00 m")

String-Based Coordinate Systems

MGRS and USNG use string representations:

mgrs = Coordinate::MGRS.new(mgrs_string: "18SUJ2337006519")
mgrs = Coordinate::MGRS.from_string("18SUJ2337006519")
mgrs.to_s    # => "18SUJ2337006519"

usng = Coordinate::USNG.new(usng_string: "18T WL 12345 67890")
usng = Coordinate::USNG.from_string("18T WL 12345 67890")
usng.to_s    # => "18T WL 12345 67890"

Distance Calculations

Universal distance methods work across all coordinate types and return Distance objects with unit tracking and conversion.

Instance method distance_to — Vincenty great-circle distance:

seattle = Geodetic::Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
portland = Geodetic::Coordinate::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
sf = Geodetic::Coordinate::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)

d = seattle.distance_to(portland)         # => Distance (meters)
d.meters                                  # => 235385.71
d.to_km.to_f                             # => 235.39
d.to_mi.to_f                             # => 146.26

seattle.distance_to(portland, sf)         # => [Distance, Distance] (radial)
seattle.distance_to([portland, sf])       # => [Distance, Distance] (radial)

Class method distance_between — consecutive chain distances:

Geodetic::Coordinate.distance_between(seattle, portland)        # => Distance
Geodetic::Coordinate.distance_between(seattle, portland, sf)    # => [Distance, Distance] (chain)
Geodetic::Coordinate.distance_between([seattle, portland, sf])  # => [Distance, Distance] (chain)

Straight-line (ECEF Euclidean) versions:

seattle.straight_line_distance_to(portland)              # => Distance
Geodetic::Coordinate.straight_line_distance_between(seattle, portland)    # => Distance

Cross-system distances — works between any coordinate types:

utm = seattle.to_utm
mgrs = Geodetic::Coordinate::MGRS.from_lla(portland)
utm.distance_to(mgrs)    # => Distance

Note: ENU and NED are relative coordinate systems and must be converted to an absolute system before distance and bearing calculations. They retain local_bearing_to, horizontal_distance_to, and other local methods for tangent-plane operations.

Bearing Calculations

Universal bearing methods work across all coordinate types and return Bearing objects.

Instance method bearing_to — great-circle forward azimuth:

seattle = Geodetic::Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
portland = Geodetic::Coordinate::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)

b = seattle.bearing_to(portland)   # => Bearing
b.degrees                          # => 186.25
b.to_radians                      # => 3.25...
b.to_compass                      # => "S"
b.to_compass(points: 8)           # => "S"
b.reverse                         # => Bearing (back azimuth)
b.to_s                            # => "186.2539°"

Instance method elevation_to — vertical look angle:

a = Geodetic::Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 0.0)
b = Geodetic::Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 5000.0)

a.elevation_to(b)   # => 89.9... (degrees, nearly straight up)

Class method bearing_between — consecutive chain bearings:

Geodetic::Coordinate.bearing_between(seattle, portland)        # => Bearing
Geodetic::Coordinate.bearing_between(seattle, portland, sf)    # => [Bearing, Bearing] (chain)

Cross-system bearings — works between any coordinate types:

utm = seattle.to_utm
mgrs = Geodetic::Coordinate::MGRS.from_lla(portland)
utm.bearing_to(mgrs)    # => Bearing

Bearing Class

Bearing wraps an azimuth angle (0-360°) with compass and radian conversions.

b = Geodetic::Bearing.new(225)
b.degrees                   # => 225.0
b.to_radians                # => 3.926...
b.reverse                   # => Bearing (45°)
b.to_compass(points: 4)     # => "W"
b.to_compass(points: 8)     # => "SW"
b.to_compass(points: 16)    # => "SW"
b.to_s                      # => "225.0000°"
b.to_s(1)                   # => "225.0°"
b.to_s(0)                   # => "225°"

# Arithmetic
b + 10                       # => Bearing (235°)
b - 10                       # => Bearing (215°)
Bearing.new(90) - Bearing.new(45)  # => 45.0 (Float, angular difference)

Distance Class

Distance tracks values internally in meters with a configurable display unit. All distance methods return Distance objects.

Construction:

d = Geodetic::Distance.new(1000)          # 1000 meters
d = Geodetic::Distance.km(5)             # 5 kilometers
d = Geodetic::Distance.mi(3)             # 3 miles
d = Geodetic::Distance.ft(5280)          # 5280 feet
d = Geodetic::Distance.nmi(1)            # 1 nautical mile

Unit conversions — return a new Distance with the same meters, different display unit:

d = Geodetic::Distance.new(1609.344)
d.to_km.to_f     # => 1.609344
d.to_mi.to_f     # => 1.0
d.to_ft.to_f     # => 5280.0
d.to_nmi.to_f    # => 0.869...
d.meters          # => 1609.344 (always available)

Display and formatting:

d = Geodetic::Distance.new(5000).to_km
d.to_f     # => 5.0 (in display unit)
d.to_i     # => 5
d.to_s     # => "5.00 km"
d.to_s(1)  # => "5.0 km"
d.to_s(0)  # => "5 km"
d.inspect  # => "#<Geodetic::Distance 5.00 km (5000.0 m)>"

Arithmetic — results always in meters:

d1 = Geodetic::Distance.km(5)
d2 = Geodetic::Distance.mi(3)

(d1 + d2).meters      # => 9828.032 (5km + 3mi in meters)
(d1 - d2).meters      # => 171.968
(d1 * 2).meters       # => 10000.0
(d1 / 2).meters       # => 2500.0
d1 / d2               # => 1.034... (Float ratio)

# Numeric constants use the display unit
d = Geodetic::Distance.new(5000).to_km   # 5 km
(d + 3).meters        # => 8000.0 (3 km added)

Comparison:

Geodetic::Distance.km(1) == Geodetic::Distance.new(1000)  # => true
Geodetic::Distance.km(5) > Geodetic::Distance.mi(2)       # => true

Supported units: meters (m), kilometers (km), centimeters (cm), millimeters (mm), miles (mi), yards (yd), feet (ft), inches (in), nautical_miles (nmi)

Datums

wgs84 = Datum.new(name: "WGS84")
wgs84.a     # => 6378137.0 (semi-major axis)
wgs84.e2    # => 0.00669437999014132 (eccentricity squared)

# Use a different datum for conversions
nad27 = Datum.new(name: "CLARKE_1866")
ecef = lla.to_ecef(nad27)

# List available datums
Datum.list

Geoid Height

geoid = GeoidHeight.new(geoid_model: "EGM2008")

# Get geoid height at a location
geoid.geoid_height_at(47.6205, -122.3493)

# Convert between height datums
geoid.ellipsoidal_to_orthometric(47.6205, -122.3493, 184.0)
geoid.orthometric_to_ellipsoidal(47.6205, -122.3493, 150.0)

# Convert between vertical datums
geoid.convert_vertical_datum(47.6205, -122.3493, 184.0, "HAE", "NAVD88")

The GeoidHeightSupport module is mixed into LLA for convenience:

lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
lla.geoid_height              # => geoid undulation in meters
lla.orthometric_height        # => height above mean sea level

Geohash-36 (GH36)

A spatial hashing coordinate that encodes lat/lng into a compact, URL-friendly string:

# From a geohash string
gh36 = Coordinate::GH36.new("bdrdC26BqH")

# From any coordinate
gh36 = Coordinate::GH36.new(lla)
gh36 = lla.to_gh36(precision: 8)

# Decode back to LLA
lla = gh36.to_lla

# Neighbor cells
gh36.neighbors  # => { N: GH36, S: GH36, E: GH36, W: GH36, NE: ..., NW: ..., SE: ..., SW: ... }

# Bounding rectangle of the geohash cell
area = gh36.to_area    # => Areas::BoundingBox
area.includes?(gh36.to_lla)  # => true

# Precision info
gh36.precision              # => 10
gh36.precision_in_meters    # => { lat: 0.31, lng: 0.62 }

Geohash (GH)

The standard Geohash (base-32) algorithm by Gustavo Niemeyer, widely supported by Elasticsearch, Redis, PostGIS, and geocoding services:

# From a geohash string
gh = Coordinate::GH.new("dr5ru7")

# From any coordinate
gh = Coordinate::GH.new(lla)
gh = lla.to_gh(precision: 8)

# Decode back to LLA
lla = gh.to_lla

# Neighbor cells
gh.neighbors  # => { N: GH, S: GH, E: GH, W: GH, NE: ..., NW: ..., SE: ..., SW: ... }

# Bounding rectangle of the geohash cell
area = gh.to_area    # => Areas::BoundingBox
area.includes?(gh.to_lla)  # => true

# Precision info
gh.precision              # => 6
gh.precision_in_meters    # => { lat: 610.98, lng: 1221.97 }

Maidenhead Locator (HAM)

The Maidenhead Locator System used worldwide in amateur radio for grid square identification:

# From a Maidenhead locator string
ham = Coordinate::HAM.new("FN31pr")

# From any coordinate
ham = Coordinate::HAM.new(lla)
ham = lla.to_ham(precision: 8)

# Decode back to LLA
lla = ham.to_lla

# Neighbor cells
ham.neighbors  # => { N: HAM, S: HAM, E: HAM, W: HAM, NE: ..., NW: ..., SE: ..., SW: ... }

# Bounding rectangle of the grid square
area = ham.to_area    # => Areas::BoundingBox
area.includes?(ham.to_lla)  # => true

# Precision info
ham.precision              # => 6
ham.precision_in_meters    # => { lat: 4631.0, lng: 9260.0 }

Open Location Code / Plus Codes (OLC)

Google's open system for encoding locations into short, URL-friendly codes:

# From a plus code string
olc = Coordinate::OLC.new("849VCWC8+R9")

# From any coordinate
olc = Coordinate::OLC.new(lla)
olc = lla.to_olc(precision: 11)

# Decode back to LLA
lla = olc.to_lla

# Neighbor cells
olc.neighbors  # => { N: OLC, S: OLC, E: OLC, W: OLC, NE: ..., NW: ..., SE: ..., SW: ... }

# Bounding rectangle of the plus code cell
area = olc.to_area    # => Areas::BoundingBox
area.includes?(olc.to_lla)  # => true

# Precision info
olc.precision              # => 10
olc.precision_in_meters    # => { lat: 13.9, lng: 13.9 }

S2 Spherical Geometry Index

Google's hierarchical spatial index using a cube-on-sphere projection with Hilbert curve ordering. Cell IDs are 64-bit integers displayed as hex tokens. See S2 documentation for the full API.

# From a token string or integer
s2 = Coordinate::S2.new("54906ab14")
s2 = Coordinate::S2.new(6093487605347778560)

# From any coordinate
s2 = Coordinate::S2.new(lla)
s2 = lla.to_s2(precision: 20)     # level 20

# Decode back to LLA
lla = s2.to_lla

# Properties
s2.level               # => 15
s2.face                # => 2 (cube face 0-5)
s2.cell_id             # => 6093487605347778560 (for database storage)
s2.to_s                # => "54906ab14" (token for display)

# Hierarchy
s2.parent(10)          # => S2 at level 10
s2.children            # => [S2, S2, S2, S2] at level 16

# Neighbors (4 edge-adjacent cells)
s2.neighbors           # => [S2, S2, S2, S2]

# Containment
parent.contains?(child)   # => true
parent.intersects?(child) # => true

# Cell area (exact spherical surface area)
s2.cell_area           # => 77544.2 (square meters)

# Cell boundary as polygon
polygon = s2.to_area   # => Areas::Polygon with 4 vertices
polygon.includes?(s2.to_lla)  # => true

# Database range scans (spatial queries on B-tree indexes)
s2.range_min           # => start of cell ID range
s2.range_max           # => end of cell ID range

Geographic Areas

# Circle area
center = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
circle = Areas::Circle.new(centroid: center, radius: 1000.0)  # 1km radius

# Polygon area
points = [
  Coordinate::LLA.new(lat: 47.60, lng: -122.35, alt: 0.0),
  Coordinate::LLA.new(lat: 47.63, lng: -122.35, alt: 0.0),
  Coordinate::LLA.new(lat: 47.63, lng: -122.33, alt: 0.0),
  Coordinate::LLA.new(lat: 47.60, lng: -122.33, alt: 0.0),
]
polygon = Areas::Polygon.new(boundary: points)
polygon.centroid    # => computed centroid as LLA

# BoundingBox area (accepts any coordinate type)
nw = Coordinate::LLA.new(lat: 41.0, lng: -75.0)
se = Coordinate::LLA.new(lat: 40.0, lng: -74.0)
rect = Areas::BoundingBox.new(nw: nw, se: se)
rect.centroid       # => LLA at center
rect.ne             # => computed NE corner
rect.sw             # => computed SW corner
rect.includes?(point)  # => true/false

Segments

Segment represents a directed line segment between two points. It provides the geometric primitives that Path and Polygon build on.

a = Coordinate::LLA.new(lat: 40.7484, lng: -73.9857, alt: 0)
b = Coordinate::LLA.new(lat: 40.7580, lng: -73.9855, alt: 0)

seg = Segment.new(a, b)

# Properties (lazily computed, cached)
seg.length           # => Distance
seg.distance         # => Distance (alias for length)
seg.bearing          # => Bearing
seg.midpoint         # => LLA at halfway point

# Projection — closest point on segment to a target
foot, dist_m = seg.project(target_point)

# Interpolation — point at fraction along segment
seg.interpolate(0.25)  # => LLA at quarter-way

# Membership
seg.includes?(a)              # => true  (vertex check only)
seg.includes?(seg.midpoint)   # => false
seg.contains?(seg.midpoint)   # => true  (on-segment check)

# Intersection
seg.intersects?(other_seg)  # => true/false

# Conversion
seg.reverse    # => Segment with swapped endpoints
seg.to_path    # => two-point Path
seg.to_a       # => [start_point, end_point]

Paths

Path is a directed, ordered sequence of unique coordinates representing routes, trails, or boundaries.

route = Path.new(coordinates: [battery_park, wall_street, brooklyn_bridge, city_hall])

# Navigation
route.first                      # => starting waypoint
route.next(wall_street)          # => brooklyn_bridge
route.total_distance.to_km       # => "3.42 km"

# Build incrementally
trail = Path.new
trail << start << middle << finish
trail >> new_start               # prepend

# Combine paths
combined = downtown + uptown     # concatenate
trimmed  = combined - detour     # remove coordinates

# Closest approach (geometric projection, not just waypoints)
route.closest_coordinate_to(off_path_point)
route.distance_to(target)
route.closest_points_to(other_path)  # path-to-path

# Spatial operations
sub = route.between(a, b)        # extract subpath
left, right = route.split_at(c)  # split at waypoint
route.at_distance(Distance.km(2)) # interpolate along path
route.bounds                     # => Areas::BoundingBox
route.to_polygon                 # close into polygon
route.intersects?(other_path)    # crossing detection
route.contains?(point)           # on-segment check

# Enumerable
route.map { |c| c.lat }
route.select { |c| c.lat > 40.72 }

Features

Feature wraps a geometry (any coordinate, area, or path) with a label and a metadata hash. It delegates distance_to and bearing_to to its geometry, using the centroid for area geometries.

liberty = Feature.new(
  label:    "Statue of Liberty",
  geometry: Coordinate::LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
  metadata: { category: "monument", year: 1886 }
)

empire = Feature.new(
  label:    "Empire State Building",
  geometry: Coordinate::LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
  metadata: { category: "building", floors: 102 }
)

liberty.distance_to(empire).to_km   # => "8.24 km"
liberty.bearing_to(empire).degrees  # => 36.95

# Area geometries use the centroid for distance/bearing
park = Feature.new(
  label:    "Central Park",
  geometry: Areas::Polygon.new(boundary: [...])
)
park.distance_to(liberty).to_km     # => "12.47 km"

All three attributes (label, geometry, metadata) are mutable.

Vectors

Vector pairs a Distance (magnitude) with a Bearing (direction) to represent a geodetic displacement. It solves the Vincenty direct problem to compute destination points.

v = Geodetic::Vector.new(distance: 10_000, bearing: 90.0)
v = Geodetic::Vector.new(distance: Distance.km(10), bearing: Bearing.new(90))

v.north          # => north component in meters
v.east           # => east component in meters
v.magnitude      # => distance in meters
v.reverse        # => same distance, opposite bearing
v.normalize      # => unit vector (1 meter)

Vector arithmetic:

v1 + v2          # => Vector (component-wise addition)
v1 - v2          # => Vector (component-wise subtraction)
v * 3            # => Vector (scale distance)
v / 2            # => Vector (scale distance)
-v               # => Vector (reverse bearing)
v.dot(v2)        # => Float (dot product)
v.cross(v2)      # => Float (2D cross product)

Factory methods:

Vector.from_components(north: 1000, east: 500)
Vector.from_segment(segment)
segment.to_vector

Geodetic Arithmetic

Operators build geometry from coordinates, vectors, and distances:

# Building geometry with +
p1 + p2                    # => Segment
p1 + p2 + p3              # => Path
p1 + segment              # => Path
segment + p3              # => Path
segment + segment          # => Path
p1 + distance              # => Circle
p1 + vector                # => Segment (to destination)
segment + vector           # => Path (extend from endpoint)
vector + segment           # => Path (prepend via reverse)
path + vector              # => Path (extend from last point)
vector + coordinate        # => Segment
distance + coordinate      # => Circle

# Translation with * or .translate
p1 * vector                # => Coordinate (translated point)
segment * vector           # => Segment (translated endpoints)
path * vector              # => Path (translated waypoints)
circle * vector            # => Circle (translated centroid)
polygon * vector           # => Polygon (translated vertices)

Corridors

Convert a path into a polygon corridor of a given width:

route = seattle + portland + sf
corridor = route.to_corridor(width: 1000)        # 1km wide polygon
corridor = route.to_corridor(width: Distance.km(1))

GeoJSON Export

GeoJSON builds a GeoJSON FeatureCollection from any mix of Geodetic objects and writes it to a file.

gj = Geodetic::GeoJSON.new
gj << seattle
gj << [portland, sf, la]
gj << Feature.new(label: "Route", geometry: route, metadata: { mode: "driving" })
gj << Areas::Circle.new(centroid: seattle, radius: 10_000)

gj.size        # => 6
gj.to_h        # => {"type" => "FeatureCollection", "features" => [...]}
gj.to_json     # => compact JSON string
gj.save("map.geojson", pretty: true)

Every geometry type has a to_geojson method returning a GeoJSON-compatible Hash:

seattle.to_geojson                        # => {"type" => "Point", ...}
Segment.new(seattle, portland).to_geojson # => {"type" => "LineString", ...}
route.to_geojson                          # => {"type" => "LineString", ...}
route.to_geojson(as: :polygon)            # => {"type" => "Polygon", ...}
polygon.to_geojson                        # => {"type" => "Polygon", ...}
circle.to_geojson(segments: 64)           # => {"type" => "Polygon", ...} (64-gon)
bbox.to_geojson                           # => {"type" => "Polygon", ...}
feature.to_geojson                        # => {"type" => "Feature", ...}

Features carry their label as "name" and metadata as properties in the GeoJSON output. Non-Feature objects added to the collection are auto-wrapped as Features with empty properties.

Loading GeoJSON files:

objects = Geodetic::GeoJSON.load("map.geojson")
# => [Feature("Seattle", LLA), Segment, Path, Polygon, LLA, ...]

load returns an Array of Geodetic objects. Features with a "name" or non-empty properties round-trip as Feature objects; bare geometries with empty properties return as raw coordinates, segments, paths, or polygons. GeoJSON.parse(hash) does the same from an already-parsed Hash.

WKT Serialization

WKT provides Well-Known Text export and import for all geometry types. WKT is the standard format used by PostGIS, RGeo, Shapely, JTS, and most GIS tools.

seattle.to_wkt                        # => "POINT(-122.3493 47.6205)"
seattle.to_wkt(srid: 4326)           # => "SRID=4326;POINT(-122.3493 47.6205)"
seattle.to_wkt(precision: 2)         # => "POINT(-122.35 47.62)"

Segment.new(seattle, portland).to_wkt # => "LINESTRING(-122.3493 47.6205, -122.6784 45.5152)"
route.to_wkt                          # => "LINESTRING(...)"
polygon.to_wkt                        # => "POLYGON((...))
circle.to_wkt(segments: 64)          # => "POLYGON((...))  (64-gon)"
feature.to_wkt                        # => delegates to geometry (WKT has no properties)

Altitude triggers the Z suffix. When any point has altitude, all points in that geometry get Z:

Geodetic::Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 184.0).to_wkt
# => "POINT Z(-122.35 47.62 184.0)"

File I/O and parsing:

# Save to file (one WKT per line)
Geodetic::WKT.save!("shapes.wkt", seattle, segment, polygon, srid: 4326)

# Load from file
objects = Geodetic::WKT.load("shapes.wkt")
# => [Coordinate::LLA, Segment, Areas::Polygon]

# Parse a single WKT string
obj = Geodetic::WKT.parse("POINT(-122.3493 47.6205)")
obj, srid = Geodetic::WKT.parse_with_srid("SRID=4326;POLYGON((-122 47, -121 46, -123 46, -122 47))")

WKB Serialization

WKB provides Well-Known Binary export and import — the binary counterpart to WKT used by PostGIS, GEOS, RGeo, and Shapely for efficient geometry storage. Output is always little-endian (NDR).

seattle.to_wkb                        # => 21-byte binary string
seattle.to_wkb_hex                    # => "01010000008a1f63ee5a965ec0..."
seattle.to_wkb_hex(srid: 4326)       # => EWKB with embedded SRID

Segment.new(seattle, portland).to_wkb_hex  # => LINESTRING hex
polygon.to_wkb_hex                         # => POLYGON hex

File I/O (binary and hex):

# Binary format (framed: count + size-prefixed WKB)
Geodetic::WKB.save!("shapes.wkb", seattle, segment, polygon)
objects = Geodetic::WKB.load("shapes.wkb")

# Hex format (one hex string per line, supports comments)
Geodetic::WKB.save_hex!("shapes.wkb.hex", seattle, segment, polygon)
objects = Geodetic::WKB.load_hex("shapes.wkb.hex")

# Parse a single hex or binary string
obj = Geodetic::WKB.parse("01010000008a1f63ee5a965ec08195438b6ccf4740")
obj, srid = Geodetic::WKB.parse_with_srid(ewkb_hex)

Web Mercator Tile Coordinates

wm = Coordinate::WebMercator.from_lla(lla)
wm.to_tile_coordinates(15)     # => [x_tile, y_tile, zoom]
wm.to_pixel_coordinates(15)    # => [x_pixel, y_pixel, zoom]

Coordinate::WebMercator.from_tile_coordinates(5241, 11438, 15)

Available Datums

Airy 1830, Modified Airy, Australian National, Bessel 1841, Clarke 1866, Clarke 1880, Everest (India 1830), Everest (Brunei & E.Malaysia), Everest (W.Malaysia & Singapore), GRS 1980, Helmert 1906, Hough 1960, International 1924, South American 1969, WGS72, WGS84

Documentation

Full documentation is available at madbomber.github.io/geodetic.

Examples

The examples/ directory contains runnable demo scripts showing progressive usage:

Script Description
01_basic_conversions.rb LLA, ECEF, UTM, ENU, NED conversions and roundtrips
02_all_coordinate_systems.rb All 18 coordinate systems, cross-system chains, and areas
03_distance_calculations.rb Distance class features, unit conversions, and arithmetic
04_bearing_calculations.rb Bearing class, compass directions, elevation angles, and chain bearings
05_map_rendering/ Render landmarks on a raster map with Feature objects, polygon areas, bearing arrows, and icons using libgd-gis
06_path_operations.rb Path class: construction, navigation, mutation, path arithmetic, closest approach, containment, Enumerable, equality, subpaths, split, interpolation, bounding boxes, polygon conversion, intersection, path-to-path/area closest points, and Feature integration
07_segments_and_shapes.rb Segment and polygon subclasses: Triangle, Rectangle, Pentagon, Hexagon, Octagon with containment, edges, and bounding boxes
08_geodetic_arithmetic.rb Geodetic arithmetic: building geometry with + (Segments, Paths, Circles), Vector class (Vincenty direct, components, arithmetic, dot/cross products), translation with * (Coordinates, Segments, Paths, Circles, Polygons), and corridors
09_geojson_export.rb GeoJSON export: to_geojson on all geometry types, GeoJSON class for building FeatureCollections with <<, delete/clear, Enumerable, and save to file
10_wkt_serialization.rb WKT serialization: to_wkt on all geometry types, SRID/EWKT, Z-dimension handling, parsing, and roundtrip verification
11_wkb_serialization.rb WKB serialization: to_wkb/to_wkb_hex on all geometry types, EWKB/SRID, Z-dimension, parsing, roundtrip, and binary/hex file I/O
12_geos_benchmark.rb GEOS performance benchmark: polygon validation, point-in-polygon, path intersection, PreparedGeometry batch containment, and GEOS-only boolean operations
13_geos_operations.rb GEOS-only operations: boolean overlay (intersection, difference, symmetric difference, union), buffering, convex hull, simplification, validity checking, geometry repair, planar measurements, nearest points, PreparedGeometry, and operation chaining
14_geos_map_rendering.rb GEOS map rendering: visualizes 8 GEOS operation categories on a single raster map with distinct colors and an embedded legend
15_s2_geometry.rb S2 Geometry: construction, round-trip conversion, cell hierarchy (parent/children), edge neighbors, containment/intersection, cell area calculations, cell polygons, database range scans, cross-hash conversions, distance/bearing, arithmetic, serialization (WKT/WKB/GeoJSON), six cube faces, and performance benchmarks

Run any example with:

ruby -Ilib examples/01_basic_conversions.rb

Development

For comprehensive guides and API documentation, visit https://madbomber.github.io/geodetic

bin/setup          # Install dependencies
rake test          # Run tests
bin/console        # Interactive console

License

Available as open source under the MIT License.