perfect-postcode/server-rs/src/parsing/bounds.rs
2026-05-14 22:07:14 +01:00

323 lines
9.9 KiB
Rust

use axum::http::StatusCode;
/// Check if two bounding boxes intersect.
/// Both boxes are (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon).
#[inline]
#[allow(clippy::too_many_arguments)]
pub fn bounds_intersect(
a_south: f64,
a_west: f64,
a_north: f64,
a_east: f64,
b_south: f64,
b_west: f64,
b_north: f64,
b_east: f64,
) -> bool {
a_west <= b_east && a_east >= b_west && a_south <= b_north && a_north >= b_south
}
/// Compute the lat/lon bounding box of an H3 cell in degrees, with a configurable buffer in degrees.
/// Returns (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon).
pub fn h3_cell_bounds(cell: h3o::CellIndex, buffer: f64) -> (f64, f64, f64, f64) {
let boundary = cell.boundary();
let (mut min_lat, mut max_lat) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut min_lon, mut max_lon) = (f64::INFINITY, f64::NEG_INFINITY);
for vertex in boundary.iter() {
// h3o LatLng::lat()/lng() return degrees
let lat = vertex.lat();
let lon = vertex.lng();
if lat < min_lat {
min_lat = lat;
}
if lat > max_lat {
max_lat = lat;
}
if lon < min_lon {
min_lon = lon;
}
if lon > max_lon {
max_lon = lon;
}
}
(
min_lat - buffer,
min_lon - buffer,
max_lat + buffer,
max_lon + buffer,
)
}
/// Require a bounds parameter, returning an error if absent, then parse it.
pub fn require_bounds(
bounds: Option<String>,
) -> Result<(f64, f64, f64, f64), (StatusCode, String)> {
let bounds_str = bounds.ok_or((
StatusCode::BAD_REQUEST,
"bounds parameter is required".into(),
))?;
parse_bounds(&bounds_str)
}
pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCode, String)> {
let parts: Vec<f64> = bounds_str
.split(',')
.map(|part| part.trim().parse::<f64>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid bounds format. Use: south,west,north,east".into(),
)
})?;
if parts.len() != 4 {
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds format. Use: south,west,north,east".into(),
));
}
let (south, west, north, east) = (parts[0], parts[1], parts[2], parts[3]);
if ![south, west, north, east]
.iter()
.all(|value| value.is_finite())
{
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds: values must be finite numbers".into(),
));
}
if !(-90.0..=90.0).contains(&south) || !(-90.0..=90.0).contains(&north) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds: latitude must be between -90 and 90".into(),
));
}
if !(-180.0..=180.0).contains(&west) || !(-180.0..=180.0).contains(&east) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds: longitude must be between -180 and 180".into(),
));
}
// Validate that bounds are not inverted
if south > north {
return Err((
StatusCode::BAD_REQUEST,
format!(
"Invalid bounds: south ({}) must be <= north ({})",
south, north
),
));
}
if west > east {
return Err((
StatusCode::BAD_REQUEST,
format!("Invalid bounds: west ({}) must be <= east ({})", west, east),
));
}
Ok((south, west, north, east))
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn parse_bounds_valid() {
assert_eq!(
parse_bounds("1.0,2.0,3.0,4.0").unwrap(),
(1.0, 2.0, 3.0, 4.0)
);
assert_eq!(
parse_bounds("51.5, -0.1, 51.6, 0.2").unwrap(),
(51.5, -0.1, 51.6, 0.2)
);
}
#[test]
fn parse_bounds_invalid() {
assert!(parse_bounds("1.0,2.0,3.0").is_err());
assert!(parse_bounds("1.0,2.0,3.0,4.0,5.0").is_err());
assert!(parse_bounds("a,b,c,d").is_err());
assert!(parse_bounds("").is_err());
}
#[test]
fn parse_bounds_inverted_rejected() {
// south > north is rejected
assert!(parse_bounds("52.0,-0.5,51.0,0.5").is_err());
// west > east is rejected
assert!(parse_bounds("51.0,0.5,52.0,-0.5").is_err());
}
#[test]
fn parse_bounds_rejects_non_finite_values() {
assert!(parse_bounds("NaN,0,1,1").is_err());
assert!(parse_bounds("0,0,inf,1").is_err());
}
#[test]
fn parse_bounds_accepts_world_sized_bounds() {
assert!(parse_bounds("-90,-180,90,180").is_ok());
assert!(parse_bounds("35.8,-45.0,67.2,45.0").is_ok());
}
#[test]
fn h3_cell_bounds_applies_buffer() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0);
let (buf_min_lat, buf_min_lon, buf_max_lat, buf_max_lon) = h3_cell_bounds(cell, 0.1);
assert!((min_lat - buf_min_lat - 0.1).abs() < 1e-10);
assert!((min_lon - buf_min_lon - 0.1).abs() < 1e-10);
assert!((buf_max_lat - max_lat - 0.1).abs() < 1e-10);
assert!((buf_max_lon - max_lon - 0.1).abs() < 1e-10);
}
#[test]
fn h3_cell_bounds_returns_degrees_not_radians() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0);
assert!(
min_lat > 30.0 && min_lat < 45.0,
"min_lat {} should be ~37° (degrees)",
min_lat
);
assert!(
max_lat > 30.0 && max_lat < 45.0,
"max_lat {} should be ~37° (degrees)",
max_lat
);
assert!(
min_lon < -100.0,
"min_lon {} should be ~-122° (degrees)",
min_lon
);
assert!(
max_lon < -100.0,
"max_lon {} should be ~-122° (degrees)",
max_lon
);
}
#[test]
fn bounds_intersect_overlapping() {
assert!(bounds_intersect(0.0, 0.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0));
assert!(bounds_intersect(0.0, 0.0, 10.0, 10.0, 2.0, 2.0, 5.0, 5.0));
assert!(bounds_intersect(2.0, 2.0, 5.0, 5.0, 0.0, 0.0, 10.0, 10.0));
assert!(bounds_intersect(0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 2.0, 1.0));
}
#[test]
fn bounds_intersect_non_overlapping() {
assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 0.0, 2.0, 1.0, 3.0));
assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 2.0, 0.0, 3.0, 1.0));
assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 5.0, 5.0, 6.0, 6.0));
}
#[test]
fn parse_bounds_with_spaces() {
let (south, west, _north, _east) = parse_bounds("51.0, -0.5, 52.0, 0.5").unwrap();
assert_eq!(south, 51.0);
assert_eq!(west, -0.5);
}
#[test]
fn parse_bounds_negative_values() {
let (south, _west, north, _east) = parse_bounds("-51.5,-0.5,-50.0,0.5").unwrap();
assert_eq!(south, -51.5);
assert_eq!(north, -50.0);
}
#[test]
fn touching_at_corner_intersects() {
assert!(bounds_intersect(
0.0, 0.0, 1.0, 1.0, // Box A
1.0, 1.0, 2.0, 2.0 // Box B touches at (1,1)
));
}
#[test]
fn touching_at_edge_intersects() {
assert!(bounds_intersect(
0.0, 0.0, 1.0, 1.0, // Box A
1.0, 0.0, 2.0, 1.0 // Box B touches along right edge
));
}
#[test]
fn disjoint_diagonally_no_intersect() {
assert!(!bounds_intersect(
0.0, 0.0, 1.0, 1.0, // Box A
2.0, 2.0, 3.0, 3.0 // Box B diagonally away
));
}
#[test]
fn negative_coordinates_intersect() {
assert!(bounds_intersect(
-2.0, -2.0, -1.0, -1.0, // Box A (negative coords)
-1.5, -1.5, -0.5, -0.5 // Box B overlaps
));
}
#[test]
fn h3_cell_bounds_zero_buffer() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let (south, west, north, east) = h3_cell_bounds(cell, 0.0);
assert!(south < north, "south {} should be < north {}", south, north);
assert!(west < east, "west {} should be < east {}", west, east);
assert!(south > 30.0 && south < 45.0);
assert!(west < -100.0);
}
#[test]
fn h3_cell_bounds_different_resolutions() {
let cell_high = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let res5 = h3o::Resolution::try_from(5).unwrap();
let cell_low = cell_high.parent(res5).unwrap();
let (s_low, w_low, n_low, e_low) = h3_cell_bounds(cell_low, 0.0);
let (s_high, w_high, n_high, e_high) = h3_cell_bounds(cell_high, 0.0);
let area_low = (n_low - s_low) * (e_low - w_low);
let area_high = (n_high - s_high) * (e_high - w_high);
assert!(area_low > area_high, "Lower res should have larger area");
}
#[test]
fn parent_cell_at_lower_resolution() {
let child = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let parent_res = h3o::Resolution::try_from(7).unwrap();
let parent = child.parent(parent_res).unwrap();
assert_eq!(parent.resolution(), parent_res);
assert!(parent.children(child.resolution()).any(|c| c == child));
}
#[test]
fn same_resolution_returns_self() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let res = cell.resolution();
let parent = cell.parent(res);
assert_eq!(parent, Some(cell));
}
#[test]
fn higher_resolution_parent_fails() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let higher_res = h3o::Resolution::try_from(10).unwrap();
let parent = cell.parent(higher_res);
assert!(parent.is_none());
}
}