diff --git a/Cargo.lock b/Cargo.lock index 3deb45c..ce887e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -372,6 +372,7 @@ version = "0.1.0" dependencies = [ "criterion", "serde", + "serde_json", "thiserror", "tracing", ] diff --git a/crates/roadrunner-core/Cargo.toml b/crates/roadrunner-core/Cargo.toml index 8862c0e..c73839f 100644 --- a/crates/roadrunner-core/Cargo.toml +++ b/crates/roadrunner-core/Cargo.toml @@ -16,6 +16,7 @@ tracing.workspace = true [dev-dependencies] criterion = "0.7.0" +serde_json = "1" [[bench]] name = "graph_traversal" diff --git a/crates/roadrunner-core/benches/graph_traversal.rs b/crates/roadrunner-core/benches/graph_traversal.rs index 572b882..5934c41 100644 --- a/crates/roadrunner-core/benches/graph_traversal.rs +++ b/crates/roadrunner-core/benches/graph_traversal.rs @@ -4,6 +4,7 @@ use std::collections::{HashSet, VecDeque}; use std::hint::black_box; use criterion::{BenchmarkId, Criterion, Throughput}; +use roadrunner_core::geo::Coordinate; use roadrunner_core::graph::{Edge, EdgeId, Graph, Node, NodeId}; const FAN_OUT: u32 = 3; @@ -14,7 +15,7 @@ fn generated_graph(node_count: u32) -> Graph { let mut graph = Graph::new(); for value in 0..node_count { - let result = graph.add_node(Node::new(NodeId::new(value))); + let result = graph.add_node(Node::new(NodeId::new(value), Coordinate::ORIGIN)); assert!( result.is_ok(), "generated node insertion failed: {result:?}" diff --git a/crates/roadrunner-core/src/geo/bounding_box.rs b/crates/roadrunner-core/src/geo/bounding_box.rs new file mode 100644 index 0000000..39d9906 --- /dev/null +++ b/crates/roadrunner-core/src/geo/bounding_box.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::Coordinate; + +/// An inclusive, axis-aligned geographic bounding box. +/// +/// Antimeridian-crossing boxes are deferred. Consequently, the western longitude +/// must be less than or equal to the eastern longitude. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "BoundingBoxRepr", into = "BoundingBoxRepr")] +pub struct BoundingBox { + south_west: Coordinate, + north_east: Coordinate, +} + +impl BoundingBox { + /// Creates a box from its south-west and north-east corners. + /// + /// # Errors + /// + /// Returns [`BoundingBoxError`] when latitude or longitude bounds are inverted. + pub fn new(south_west: Coordinate, north_east: Coordinate) -> Result { + if south_west.latitude() > north_east.latitude() { + return Err(BoundingBoxError::InvertedLatitude { + south: south_west.latitude(), + north: north_east.latitude(), + }); + } + if south_west.longitude() > north_east.longitude() { + return Err(BoundingBoxError::InvertedLongitude { + west: south_west.longitude(), + east: north_east.longitude(), + }); + } + + Ok(Self { + south_west, + north_east, + }) + } + + /// Returns the south-west corner. + #[must_use] + pub const fn south_west(self) -> Coordinate { + self.south_west + } + + /// Returns the north-east corner. + #[must_use] + pub const fn north_east(self) -> Coordinate { + self.north_east + } + + /// Returns whether a coordinate lies inside or on the box boundary. + #[must_use] + pub fn contains(self, coordinate: Coordinate) -> bool { + (self.south_west.latitude()..=self.north_east.latitude()).contains(&coordinate.latitude()) + && (self.south_west.longitude()..=self.north_east.longitude()) + .contains(&coordinate.longitude()) + } +} + +#[derive(Serialize, Deserialize)] +struct BoundingBoxRepr { + south_west: Coordinate, + north_east: Coordinate, +} + +impl TryFrom for BoundingBox { + type Error = BoundingBoxError; + + fn try_from(value: BoundingBoxRepr) -> Result { + Self::new(value.south_west, value.north_east) + } +} + +impl From for BoundingBoxRepr { + fn from(value: BoundingBox) -> Self { + Self { + south_west: value.south_west(), + north_east: value.north_east(), + } + } +} + +/// Errors produced when constructing a bounding box. +#[derive(Debug, Clone, Copy, PartialEq, Error)] +pub enum BoundingBoxError { + /// The southern latitude is north of the northern latitude. + #[error("south latitude {south} exceeds north latitude {north}")] + InvertedLatitude { + /// The rejected southern latitude. + south: f64, + /// The rejected northern latitude. + north: f64, + }, + + /// The western longitude is east of the eastern longitude. + #[error("west longitude {west} exceeds east longitude {east}")] + InvertedLongitude { + /// The rejected western longitude. + west: f64, + /// The rejected eastern longitude. + east: f64, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coordinate(latitude: f64, longitude: f64) -> Coordinate { + let result = Coordinate::new(latitude, longitude); + let Ok(coordinate) = result else { + panic!("expected test coordinate to be valid: {result:?}"); + }; + coordinate + } + + #[test] + fn contains_interior_and_boundary_coordinates() { + let bounds = BoundingBox::new(coordinate(6.0, 3.0), coordinate(7.0, 4.0)); + let Ok(bounds) = bounds else { + panic!("expected test bounds to be valid"); + }; + + assert!(bounds.contains(coordinate(6.5, 3.5))); + assert!(bounds.contains(coordinate(6.0, 3.0))); + assert!(bounds.contains(coordinate(7.0, 4.0))); + assert!(!bounds.contains(coordinate(7.1, 3.5))); + } + + #[test] + fn rejects_inverted_latitude_bounds() { + assert_eq!( + BoundingBox::new(coordinate(7.0, 3.0), coordinate(6.0, 4.0)), + Err(BoundingBoxError::InvertedLatitude { + south: 7.0, + north: 6.0, + }) + ); + } + + #[test] + fn rejects_inverted_longitudes_and_antimeridian_crossing_boxes() { + assert_eq!( + BoundingBox::new(coordinate(-10.0, 170.0), coordinate(10.0, -170.0)), + Err(BoundingBoxError::InvertedLongitude { + west: 170.0, + east: -170.0, + }) + ); + } + + #[test] + fn deserialization_preserves_box_invariants() { + let serialized = r#"{ + "south_west":{"latitude":7,"longitude":3}, + "north_east":{"latitude":6,"longitude":4} + }"#; + + assert!(serde_json::from_str::(serialized).is_err()); + } +} diff --git a/crates/roadrunner-core/src/geo/coordinate.rs b/crates/roadrunner-core/src/geo/coordinate.rs new file mode 100644 index 0000000..2e3e14e --- /dev/null +++ b/crates/roadrunner-core/src/geo/coordinate.rs @@ -0,0 +1,162 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// A validated WGS 84 latitude and longitude pair in decimal degrees. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "CoordinateRepr", into = "CoordinateRepr")] +pub struct Coordinate { + latitude: f64, + longitude: f64, +} + +impl Coordinate { + /// The intersection of the equator and prime meridian. + pub const ORIGIN: Self = Self { + latitude: 0.0, + longitude: 0.0, + }; + + /// Creates a validated coordinate in decimal degrees. + /// + /// # Errors + /// + /// Returns [`CoordinateError`] when either component is not finite, latitude + /// falls outside `[-90, 90]`, or longitude falls outside `[-180, 180]`. + pub fn new(latitude: f64, longitude: f64) -> Result { + if !latitude.is_finite() { + return Err(CoordinateError::LatitudeNotFinite { latitude }); + } + if !longitude.is_finite() { + return Err(CoordinateError::LongitudeNotFinite { longitude }); + } + if !(-90.0..=90.0).contains(&latitude) { + return Err(CoordinateError::LatitudeOutOfRange { latitude }); + } + if !(-180.0..=180.0).contains(&longitude) { + return Err(CoordinateError::LongitudeOutOfRange { longitude }); + } + + Ok(Self { + latitude, + longitude, + }) + } + + /// Returns latitude in decimal degrees. + #[must_use] + pub const fn latitude(self) -> f64 { + self.latitude + } + + /// Returns longitude in decimal degrees. + #[must_use] + pub const fn longitude(self) -> f64 { + self.longitude + } +} + +#[derive(Serialize, Deserialize)] +struct CoordinateRepr { + latitude: f64, + longitude: f64, +} + +impl TryFrom for Coordinate { + type Error = CoordinateError; + + fn try_from(value: CoordinateRepr) -> Result { + Self::new(value.latitude, value.longitude) + } +} + +impl From for CoordinateRepr { + fn from(value: Coordinate) -> Self { + Self { + latitude: value.latitude(), + longitude: value.longitude(), + } + } +} + +/// Errors produced when constructing a coordinate. +#[derive(Debug, Clone, Copy, PartialEq, Error)] +pub enum CoordinateError { + /// Latitude is NaN or infinite. + #[error("latitude must be finite, got {latitude}")] + LatitudeNotFinite { + /// The rejected latitude. + latitude: f64, + }, + + /// Longitude is NaN or infinite. + #[error("longitude must be finite, got {longitude}")] + LongitudeNotFinite { + /// The rejected longitude. + longitude: f64, + }, + + /// Latitude is outside the inclusive WGS 84 range. + #[error("latitude must be between -90 and 90 degrees, got {latitude}")] + LatitudeOutOfRange { + /// The rejected latitude. + latitude: f64, + }, + + /// Longitude is outside the inclusive WGS 84 range. + #[error("longitude must be between -180 and 180 degrees, got {longitude}")] + LongitudeOutOfRange { + /// The rejected longitude. + longitude: f64, + }, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn accepts_boundary_coordinates() { + assert!(Coordinate::new(-90.0, -180.0).is_ok()); + assert!(Coordinate::new(90.0, 180.0).is_ok()); + } + + #[test] + fn rejects_out_of_range_components() { + assert_eq!( + Coordinate::new(90.1, 0.0), + Err(CoordinateError::LatitudeOutOfRange { latitude: 90.1 }) + ); + assert_eq!( + Coordinate::new(0.0, -180.1), + Err(CoordinateError::LongitudeOutOfRange { longitude: -180.1 }) + ); + } + + #[test] + fn rejects_non_finite_components() { + assert!(matches!( + Coordinate::new(f64::NAN, 0.0), + Err(CoordinateError::LatitudeNotFinite { .. }) + )); + assert!(matches!( + Coordinate::new(0.0, f64::INFINITY), + Err(CoordinateError::LongitudeNotFinite { .. }) + )); + } + + #[test] + fn serde_round_trip_preserves_fields_and_validation() { + let coordinate = Coordinate::new(6.5244, 3.3792); + let Ok(coordinate) = coordinate else { + panic!("expected the Lagos coordinate to be valid"); + }; + + assert!(matches!( + serde_json::to_value(coordinate), + Ok(value) if value == json!({ "latitude": 6.5244, "longitude": 3.3792 }) + )); + assert!(serde_json::from_str::(r#"{"latitude":91,"longitude":0}"#).is_err()); + } +} diff --git a/crates/roadrunner-core/src/geo/haversine.rs b/crates/roadrunner-core/src/geo/haversine.rs new file mode 100644 index 0000000..0304bf3 --- /dev/null +++ b/crates/roadrunner-core/src/geo/haversine.rs @@ -0,0 +1,85 @@ +use super::{Coordinate, Meters}; + +const MEAN_EARTH_RADIUS_METERS: f64 = 6_371_008.8; + +/// Calculates the great-circle distance between two WGS 84 coordinates. +/// +/// The result uses the Haversine formula and the mean Earth radius. It represents +/// straight-line surface distance, not road-network travel distance. +#[must_use] +pub fn haversine_distance(a: Coordinate, b: Coordinate) -> Meters { + let latitude_a = a.latitude().to_radians(); + let latitude_b = b.latitude().to_radians(); + let latitude_delta = (b.latitude() - a.latitude()).to_radians(); + let longitude_delta = (b.longitude() - a.longitude()).to_radians(); + + let half_latitude_sine = (latitude_delta / 2.0).sin(); + let half_longitude_sine = (longitude_delta / 2.0).sin(); + let haversine = half_latitude_sine.mul_add( + half_latitude_sine, + latitude_a.cos() * latitude_b.cos() * half_longitude_sine.powi(2), + ); + let bounded_haversine = haversine.clamp(0.0, 1.0); + let central_angle = 2.0 + * bounded_haversine + .sqrt() + .atan2((1.0 - bounded_haversine).sqrt()); + + Meters::from_calculation(MEAN_EARTH_RADIUS_METERS * central_angle) +} + +#[cfg(test)] +mod tests { + use std::f64::consts::FRAC_PI_2; + + use super::*; + + fn coordinate(latitude: f64, longitude: f64) -> Coordinate { + let result = Coordinate::new(latitude, longitude); + let Ok(coordinate) = result else { + panic!("expected test coordinate to be valid: {result:?}"); + }; + coordinate + } + + fn assert_distance_close(actual: Meters, expected: f64, tolerance: f64) { + let difference = (actual.value() - expected).abs(); + assert!( + difference <= tolerance, + "expected {expected} ± {tolerance} meters, got {actual}" + ); + } + + #[test] + fn identical_coordinates_have_zero_distance() { + let lagos = coordinate(6.5244, 3.3792); + + assert_eq!(haversine_distance(lagos, lagos), Meters::ZERO); + } + + #[test] + fn equatorial_quarter_circumference_matches_the_mean_radius() { + let distance = haversine_distance(Coordinate::ORIGIN, coordinate(0.0, 90.0)); + + assert_distance_close(distance, MEAN_EARTH_RADIUS_METERS * FRAC_PI_2, 0.001); + } + + #[test] + fn london_to_paris_matches_a_known_distance() { + let london = coordinate(51.5074, -0.1278); + let paris = coordinate(48.8566, 2.3522); + + assert_distance_close(haversine_distance(london, paris), 343_556.0, 100.0); + } + + #[test] + fn distance_is_symmetric() { + let lagos = coordinate(6.5244, 3.3792); + let abuja = coordinate(9.0765, 7.3986); + + assert_eq!( + haversine_distance(lagos, abuja), + haversine_distance(abuja, lagos) + ); + } +} diff --git a/crates/roadrunner-core/src/geo/mod.rs b/crates/roadrunner-core/src/geo/mod.rs new file mode 100644 index 0000000..3ab1a68 --- /dev/null +++ b/crates/roadrunner-core/src/geo/mod.rs @@ -0,0 +1,11 @@ +//! Validated geographic values, physical units, and distance calculations. + +mod bounding_box; +mod coordinate; +mod haversine; +mod units; + +pub use bounding_box::{BoundingBox, BoundingBoxError}; +pub use coordinate::{Coordinate, CoordinateError}; +pub use haversine::haversine_distance; +pub use units::{Distance, KilometersPerHour, MeasurementUnit, Meters, Seconds, UnitError}; diff --git a/crates/roadrunner-core/src/geo/units.rs b/crates/roadrunner-core/src/geo/units.rs new file mode 100644 index 0000000..aeed5a2 --- /dev/null +++ b/crates/roadrunner-core/src/geo/units.rs @@ -0,0 +1,199 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Identifies a physical unit when reporting validation errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeasurementUnit { + /// Distance measured in meters. + Meters, + /// Duration measured in seconds. + Seconds, + /// Speed measured in kilometers per hour. + KilometersPerHour, +} + +impl fmt::Display for MeasurementUnit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Meters => formatter.write_str("meters"), + Self::Seconds => formatter.write_str("seconds"), + Self::KilometersPerHour => formatter.write_str("kilometers per hour"), + } + } +} + +/// Errors produced when constructing a physical unit value. +#[derive(Debug, Clone, Copy, PartialEq, Error)] +pub enum UnitError { + /// A unit value is NaN or infinite. + #[error("{unit} must be finite, got {value}")] + NotFinite { + /// The unit being constructed. + unit: MeasurementUnit, + /// The rejected numeric value. + value: f64, + }, + + /// A unit value is less than zero. + #[error("{unit} must be non-negative, got {value}")] + Negative { + /// The unit being constructed. + unit: MeasurementUnit, + /// The rejected numeric value. + value: f64, + }, +} + +fn validate(value: f64, unit: MeasurementUnit) -> Result { + if !value.is_finite() { + return Err(UnitError::NotFinite { unit, value }); + } + if value < 0.0 { + return Err(UnitError::Negative { unit, value }); + } + Ok(value) +} + +macro_rules! non_negative_unit { + ($(#[$meta:meta])* $name:ident, $unit:expr, $suffix:literal) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] + #[serde(try_from = "f64", into = "f64")] + #[repr(transparent)] + pub struct $name(f64); + + impl $name { + /// A zero-valued measurement. + pub const ZERO: Self = Self(0.0); + + /// Creates a validated measurement. + /// + /// # Errors + /// + /// Returns [`UnitError`] when `value` is negative or not finite. + pub fn new(value: f64) -> Result { + validate(value, $unit).map(Self) + } + + /// Returns the numeric value in this type's named unit. + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } + } + + impl TryFrom for $name { + type Error = UnitError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } + } + + impl From<$name> for f64 { + fn from(value: $name) -> Self { + value.value() + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} {}", self.0, $suffix) + } + } + }; +} + +non_negative_unit!( + /// A non-negative, finite distance measured in meters. + Meters, + MeasurementUnit::Meters, + "m" +); + +non_negative_unit!( + /// A non-negative, finite duration measured in seconds. + Seconds, + MeasurementUnit::Seconds, + "s" +); + +non_negative_unit!( + /// A non-negative, finite speed measured in kilometers per hour. + KilometersPerHour, + MeasurementUnit::KilometersPerHour, + "km/h" +); + +/// Roadrunner's canonical distance type. +pub type Distance = Meters; + +impl Meters { + pub(crate) fn from_calculation(value: f64) -> Self { + debug_assert!(value.is_finite()); + debug_assert!(value >= 0.0); + Self(value) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn accepts_zero_and_positive_measurements() { + assert_eq!(Meters::new(0.0), Ok(Meters::ZERO)); + assert_eq!(Seconds::new(12.5).map(Seconds::value), Ok(12.5)); + assert_eq!( + KilometersPerHour::new(50.0).map(KilometersPerHour::value), + Ok(50.0) + ); + } + + #[test] + fn rejects_negative_measurements() { + assert_eq!( + Meters::new(-1.0), + Err(UnitError::Negative { + unit: MeasurementUnit::Meters, + value: -1.0, + }) + ); + } + + #[test] + fn rejects_non_finite_measurements() { + assert!(matches!( + Seconds::new(f64::INFINITY), + Err(UnitError::NotFinite { + unit: MeasurementUnit::Seconds, + .. + }) + )); + assert!(matches!( + KilometersPerHour::new(f64::NAN), + Err(UnitError::NotFinite { + unit: MeasurementUnit::KilometersPerHour, + .. + }) + )); + } + + #[test] + fn serializes_as_a_number_and_validates_deserialization() { + let meters = Meters::new(42.5); + let Ok(meters) = meters else { + panic!("expected a valid meter value"); + }; + + assert!(matches!( + serde_json::to_value(meters), + Ok(value) if value == json!(42.5) + )); + assert!(serde_json::from_str::("-1").is_err()); + } +} diff --git a/crates/roadrunner-core/src/graph/network.rs b/crates/roadrunner-core/src/graph/network.rs index ab5ed5d..8a27c03 100644 --- a/crates/roadrunner-core/src/graph/network.rs +++ b/crates/roadrunner-core/src/graph/network.rs @@ -154,15 +154,20 @@ impl Graph { #[cfg(test)] mod tests { use super::*; + use crate::geo::Coordinate; const A: NodeId = NodeId::new(1); const B: NodeId = NodeId::new(2); const C: NodeId = NodeId::new(3); const D: NodeId = NodeId::new(4); + fn node(id: NodeId) -> Node { + Node::new(id, Coordinate::ORIGIN) + } + fn add_nodes(graph: &mut Graph, ids: &[NodeId]) { for id in ids { - assert_eq!(graph.add_node(Node::new(*id)), Ok(())); + assert_eq!(graph.add_node(node(*id)), Ok(())); } } @@ -188,12 +193,12 @@ mod tests { fn single_node_has_an_empty_adjacency_list() { let mut graph = Graph::new(); - assert_eq!(graph.add_node(Node::new(A)), Ok(())); + assert_eq!(graph.add_node(node(A)), Ok(())); assert_eq!(graph.node_count(), 1); assert_eq!(graph.edge_count(), 0); assert!(graph.contains_node(A)); - assert_eq!(graph.node(A), Some(&Node::new(A))); + assert_eq!(graph.node(A), Some(&node(A))); assert_eq!(graph.neighbors(A), Ok([].as_slice())); } @@ -261,7 +266,7 @@ mod tests { #[test] fn edge_insertion_rejects_missing_endpoints_without_mutation() { let mut graph = Graph::new(); - assert_eq!(graph.add_node(Node::new(A)), Ok(())); + assert_eq!(graph.add_node(node(A)), Ok(())); let missing_source = Edge::new(EdgeId::new(10), B, A); let missing_destination = Edge::new(EdgeId::new(11), A, B); @@ -291,7 +296,7 @@ mod tests { let parallel = Edge::new(EdgeId::new(11), A, B); assert_eq!( - graph.add_node(Node::new(A)), + graph.add_node(node(A)), Err(GraphError::DuplicateNode { id: A }) ); assert_eq!(graph.add_edge(first), Ok(())); diff --git a/crates/roadrunner-core/src/graph/node.rs b/crates/roadrunner-core/src/graph/node.rs index 446b97a..a594d92 100644 --- a/crates/roadrunner-core/src/graph/node.rs +++ b/crates/roadrunner-core/src/graph/node.rs @@ -1,21 +1,24 @@ use serde::{Deserialize, Serialize}; +use crate::geo::Coordinate; + use super::NodeId; /// A vertex in Roadrunner's internal road-network graph. /// -/// Geographic coordinates are added in Phase 3. Keeping this Phase 2 type -/// topology-only prevents raw coordinate values from leaking into the graph API. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// Coordinates use Roadrunner's validated geographic domain type rather than raw +/// latitude and longitude values. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Node { id: NodeId, + coordinate: Coordinate, } impl Node { - /// Creates a graph node with a stable identity. + /// Creates a graph node with a stable identity and coordinate. #[must_use] - pub const fn new(id: NodeId) -> Self { - Self { id } + pub const fn new(id: NodeId, coordinate: Coordinate) -> Self { + Self { id, coordinate } } /// Returns the node's identity. @@ -23,4 +26,27 @@ impl Node { pub const fn id(self) -> NodeId { self.id } + + /// Returns the node's geographic coordinate. + #[must_use] + pub const fn coordinate(self) -> Coordinate { + self.coordinate + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retains_its_validated_coordinate() { + let coordinate = Coordinate::new(6.5244, 3.3792); + let Ok(coordinate) = coordinate else { + panic!("expected the Lagos coordinate to be valid"); + }; + let node = Node::new(NodeId::new(1), coordinate); + + assert_eq!(node.id(), NodeId::new(1)); + assert_eq!(node.coordinate(), coordinate); + } } diff --git a/crates/roadrunner-core/src/lib.rs b/crates/roadrunner-core/src/lib.rs index 838d7c0..19f095b 100644 --- a/crates/roadrunner-core/src/lib.rs +++ b/crates/roadrunner-core/src/lib.rs @@ -4,6 +4,7 @@ //! dedicated implementation phases. This crate currently establishes the stable //! library boundary shared by Roadrunner's adapters. +pub mod geo; pub mod graph; /// Returns the version of the Roadrunner core crate.