use nodedb_types::geometry::Geometry;
const EARTH_RADIUS_M: f64 = 6_371_000.0;
pub fn st_area(geom: &Geometry) -> f64 {
match geom {
Geometry::Polygon { coordinates } => polygon_area(coordinates),
Geometry::MultiPolygon { coordinates } => {
coordinates.iter().map(|rings| polygon_area(rings)).sum()
}
Geometry::GeometryCollection { geometries } => geometries.iter().map(st_area).sum(),
Geometry::Point { .. }
| Geometry::LineString { .. }
| Geometry::MultiPoint { .. }
| Geometry::MultiLineString { .. } => 0.0,
_ => 0.0,
}
}
fn polygon_area(rings: &[Vec<[f64; 2]>]) -> f64 {
let mut iter = rings.iter();
let Some(exterior) = iter.next() else {
return 0.0;
};
let holes: f64 = iter.map(|ring| ring_area(ring)).sum();
(ring_area(exterior) - holes).max(0.0)
}
fn ring_area(ring: &[[f64; 2]]) -> f64 {
if ring.len() < 3 {
return 0.0;
}
let mut total = 0.0;
for i in 0..ring.len() {
let a = ring[i];
let b = ring[(i + 1) % ring.len()];
let mut dlng = (b[0] - a[0]).to_radians();
if dlng > std::f64::consts::PI {
dlng -= std::f64::consts::TAU;
} else if dlng < -std::f64::consts::PI {
dlng += std::f64::consts::TAU;
}
total += dlng * (2.0 + a[1].to_radians().sin() + b[1].to_radians().sin());
}
(total * EARTH_RADIUS_M * EARTH_RADIUS_M / 2.0).abs()
}
pub fn st_centroid(geom: &Geometry) -> Option<Geometry> {
let [lng, lat] = centroid_coords(geom)?;
Some(Geometry::point(lng, lat))
}
#[derive(Default, Clone, Copy)]
struct Weighted {
lng: f64,
lat: f64,
weight: f64,
}
impl Weighted {
fn add(&mut self, point: [f64; 2], weight: f64) {
self.lng += point[0] * weight;
self.lat += point[1] * weight;
self.weight += weight;
}
fn merge(&mut self, other: Self) {
self.lng += other.lng;
self.lat += other.lat;
self.weight += other.weight;
}
fn resolve(self) -> Option<[f64; 2]> {
if self.weight > 0.0 {
Some([self.lng / self.weight, self.lat / self.weight])
} else {
None
}
}
}
fn centroid_coords(geom: &Geometry) -> Option<[f64; 2]> {
areal_centroid(geom)
.and_then(Weighted::resolve)
.or_else(|| linear_centroid(geom).and_then(Weighted::resolve))
.or_else(|| punctual_centroid(geom).and_then(Weighted::resolve))
}
fn areal_centroid(geom: &Geometry) -> Option<Weighted> {
match geom {
Geometry::Polygon { coordinates } => Some(polygon_centroid(coordinates)),
Geometry::MultiPolygon { coordinates } => {
let mut acc = Weighted::default();
for rings in coordinates {
acc.merge(polygon_centroid(rings));
}
Some(acc)
}
Geometry::GeometryCollection { geometries } => collect(geometries, areal_centroid),
Geometry::Point { .. }
| Geometry::LineString { .. }
| Geometry::MultiPoint { .. }
| Geometry::MultiLineString { .. } => None,
_ => None,
}
}
fn linear_centroid(geom: &Geometry) -> Option<Weighted> {
match geom {
Geometry::LineString { coordinates } => Some(line_centroid(coordinates)),
Geometry::MultiLineString { coordinates } => {
let mut acc = Weighted::default();
for line in coordinates {
acc.merge(line_centroid(line));
}
Some(acc)
}
Geometry::GeometryCollection { geometries } => collect(geometries, linear_centroid),
Geometry::Point { .. }
| Geometry::MultiPoint { .. }
| Geometry::Polygon { .. }
| Geometry::MultiPolygon { .. } => None,
_ => None,
}
}
fn punctual_centroid(geom: &Geometry) -> Option<Weighted> {
let mut acc = Weighted::default();
for point in vertices(geom) {
acc.add(point, 1.0);
}
Some(acc)
}
fn collect(geometries: &[Geometry], f: fn(&Geometry) -> Option<Weighted>) -> Option<Weighted> {
let mut acc = Weighted::default();
let mut any = false;
for member in geometries {
if let Some(part) = f(member) {
acc.merge(part);
any = true;
}
}
any.then_some(acc)
}
fn polygon_centroid(rings: &[Vec<[f64; 2]>]) -> Weighted {
let mut acc = Weighted::default();
let mut iter = rings.iter();
let Some(exterior) = iter.next() else {
return acc;
};
if let Some(point) = ring_centroid(exterior) {
acc.add(point, ring_area(exterior));
}
for hole in iter {
if let Some(point) = ring_centroid(hole) {
acc.add(point, -ring_area(hole));
}
}
if acc.weight <= 0.0 {
return Weighted::default();
}
acc
}
fn ring_centroid(ring: &[[f64; 2]]) -> Option<[f64; 2]> {
if ring.is_empty() {
return None;
}
let mut twice_area = 0.0;
let mut lng = 0.0;
let mut lat = 0.0;
for i in 0..ring.len() {
let a = ring[i];
let b = ring[(i + 1) % ring.len()];
let cross = a[0] * b[1] - b[0] * a[1];
twice_area += cross;
lng += (a[0] + b[0]) * cross;
lat += (a[1] + b[1]) * cross;
}
if twice_area.abs() < f64::EPSILON {
let n = ring.len() as f64;
return Some([
ring.iter().map(|c| c[0]).sum::<f64>() / n,
ring.iter().map(|c| c[1]).sum::<f64>() / n,
]);
}
Some([lng / (3.0 * twice_area), lat / (3.0 * twice_area)])
}
fn line_centroid(coords: &[[f64; 2]]) -> Weighted {
let mut acc = Weighted::default();
for pair in coords.windows(2) {
let (a, b) = (pair[0], pair[1]);
let length = nodedb_types::geometry::haversine_distance(a[0], a[1], b[0], b[1]);
acc.add([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0], length);
}
acc
}
fn vertices(geom: &Geometry) -> Vec<[f64; 2]> {
match geom {
Geometry::Point { coordinates } => vec![*coordinates],
Geometry::LineString { coordinates } | Geometry::MultiPoint { coordinates } => {
coordinates.clone()
}
Geometry::Polygon { coordinates } | Geometry::MultiLineString { coordinates } => {
coordinates.iter().flatten().copied().collect()
}
Geometry::MultiPolygon { coordinates } => {
coordinates.iter().flatten().flatten().copied().collect()
}
Geometry::GeometryCollection { geometries } => {
geometries.iter().flat_map(vertices).collect()
}
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn area_of_unit_degree_cell_at_equator() {
let poly = Geometry::polygon(vec![vec![
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
[0.0, 0.0],
]]);
let area = st_area(&poly);
let expected = 1.236e10;
assert!(
(area - expected).abs() / expected < 0.01,
"expected ~{expected} m², got {area}"
);
}
#[test]
fn area_is_orientation_independent() {
let ccw = Geometry::polygon(vec![vec![
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
[0.0, 0.0],
]]);
let cw = Geometry::polygon(vec![vec![
[0.0, 0.0],
[0.0, 1.0],
[1.0, 1.0],
[1.0, 0.0],
[0.0, 0.0],
]]);
assert!((st_area(&ccw) - st_area(&cw)).abs() < 1.0);
}
#[test]
fn area_subtracts_holes() {
let solid = Geometry::polygon(vec![vec![
[0.0, 0.0],
[4.0, 0.0],
[4.0, 4.0],
[0.0, 4.0],
[0.0, 0.0],
]]);
let with_hole = Geometry::polygon(vec![
vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]],
vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]],
]);
assert!(
st_area(&with_hole) < st_area(&solid),
"hole must reduce the measured area"
);
}
#[test]
fn area_of_non_areal_geometry_is_zero() {
let line = Geometry::line_string(vec![[0.0, 0.0], [1.0, 1.0]]);
assert_eq!(st_area(&line), 0.0);
assert_eq!(st_area(&Geometry::point(1.0, 2.0)), 0.0);
}
#[test]
fn centroid_of_point_is_itself() {
let p = Geometry::point(10.0, 20.0);
assert_eq!(st_centroid(&p), Some(Geometry::point(10.0, 20.0)));
}
#[test]
fn centroid_of_square_is_its_center() {
let square = Geometry::polygon(vec![vec![
[0.0, 0.0],
[2.0, 0.0],
[2.0, 2.0],
[0.0, 2.0],
[0.0, 0.0],
]]);
let Some(Geometry::Point { coordinates }) = st_centroid(&square) else {
panic!("expected a Point centroid");
};
assert!((coordinates[0] - 1.0).abs() < 1e-9);
assert!((coordinates[1] - 1.0).abs() < 1e-9);
}
#[test]
fn centroid_is_area_weighted_not_vertex_mean() {
let l_shape = Geometry::polygon(vec![vec![
[0.0, 0.0],
[4.0, 0.0],
[4.0, 1.0],
[1.0, 1.0],
[1.0, 4.0],
[0.0, 4.0],
[0.0, 0.0],
]]);
let Some(Geometry::Point { coordinates }) = st_centroid(&l_shape) else {
panic!("expected a Point centroid");
};
let expected = 9.5 / 7.0;
assert!(
(coordinates[0] - expected).abs() < 0.01 && (coordinates[1] - expected).abs() < 0.01,
"expected ~({expected}, {expected}), got {coordinates:?}"
);
}
#[test]
fn centroid_of_collection_prefers_areal_components() {
let collection = Geometry::GeometryCollection {
geometries: vec![
Geometry::polygon(vec![vec![
[0.0, 0.0],
[2.0, 0.0],
[2.0, 2.0],
[0.0, 2.0],
[0.0, 0.0],
]]),
Geometry::point(100.0, 100.0),
],
};
let Some(Geometry::Point { coordinates }) = st_centroid(&collection) else {
panic!("expected a Point centroid");
};
assert!(
(coordinates[0] - 1.0).abs() < 1e-6,
"stray point must not pull the centroid, got {coordinates:?}"
);
}
#[test]
fn centroid_of_degenerate_polygon_falls_back() {
let collapsed =
Geometry::polygon(vec![vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 0.0]]]);
let centroid = st_centroid(&collapsed);
assert!(
centroid.is_some(),
"a collinear ring must still yield a centroid"
);
}
#[test]
fn centroid_of_linestring_is_length_weighted() {
let line = Geometry::line_string(vec![[0.0, 0.0], [10.0, 0.0], [11.0, 0.0]]);
let Some(Geometry::Point { coordinates }) = st_centroid(&line) else {
panic!("expected a Point centroid");
};
assert!(
coordinates[0] < 6.0,
"length weighting must bias toward the long segment, got {coordinates:?}"
);
}
#[test]
fn centroid_of_empty_geometry_is_none() {
let empty = Geometry::GeometryCollection { geometries: vec![] };
assert_eq!(st_centroid(&empty), None);
}
}