Skip to main content

kernel/
geomath.rs

1//! 2i: PostGIS-geography math, ported from e1 (already calibrated equal to
2//! PostGIS to float precision there). Distances = Vincenty's inverse
3//! formula on the WGS84 ellipsoid, in METRES; areas = spherical excess on
4//! the authalic sphere, in SQUARE METRES; point-in-polygon = planar
5//! even-odd crossing in coordinate space (the named subset deviation:
6//! correct away from poles/antimeridian; geography-PostGIS itself offers
7//! ST_Covers for the sphere-true predicate). Functions take (lat, lon) --
8//! PostGIS textual order; GeoJSON stores [lon, lat] and converters own
9//! the flip. Rings use the internal [[lat, lon], ...] layout.
10
11
12/// WGS84 defining parameters.
13const WGS84_A: f64 = 6_378_137.0;                 // semi-major axis (m)
14const WGS84_F: f64 = 1.0 / 298.257_223_563;       // flattening
15/// WGS84 first eccentricity squared, e² = f(2−f).
16const WGS84_E2: f64 = WGS84_F * (2.0 - WGS84_F);
17/// WGS84 authalic (equal-area) sphere radius (m) — the sphere with the same
18/// surface area as the ellipsoid; used for geodesic polygon area.
19const WGS84_AUTHALIC_R: f64 = 6_371_007.180_918_47;
20/// Great-circle (sphere) distance in KILOMETRES -- the cheap pre-filter:
21/// diverges from Vincenty/WGS84 by < 0.56%, so any comparison further
22/// than that band from a threshold can be decided here without the
23/// iterative formula.
24pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
25    const R: f64 = 6371.0;
26    let d_lat = (lat2 - lat1).to_radians();
27    let d_lon = (lon2 - lon1).to_radians();
28    let a = (d_lat / 2.0).sin().powi(2)
29        + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lon / 2.0).sin().powi(2);
30    R * 2.0 * a.sqrt().asin()
31}
32
33
34/// Authalic latitude (radians) for a geodetic latitude — the equal-area mapping
35/// onto the authalic sphere. Computing the spherical excess in authalic latitude
36/// (not geodetic) is what makes the sphere-based area equal the ellipsoid's, so it
37/// matches PostGIS `ST_Area(::geography)` rather than running ~0.12% low.
38fn authalic_lat(phi: f64) -> f64 {
39    let e2 = WGS84_E2;
40    let e = e2.sqrt();
41    let s = phi.sin();
42    // q(φ) = (1−e²)[ sinφ/(1−e²sin²φ) − 1/(2e)·ln((1−e·sinφ)/(1+e·sinφ)) ]
43    let q = |s: f64| {
44        (1.0 - e2) * (s / (1.0 - e2 * s * s) - (1.0 / (2.0 * e)) * ((1.0 - e * s) / (1.0 + e * s)).ln())
45    };
46    let qp = q(1.0); // q at the pole (sinφ = 1)
47    (q(s) / qp).clamp(-1.0, 1.0).asin()
48}
49
50/// Geodesic distance between two points in METRES on the WGS84 ellipsoid
51/// (Vincenty inverse). Matches PostGIS `ST_Distance(a::geography, b::geography)`.
52pub fn geodesic_distance_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
53    let a = WGS84_A;
54    let f = WGS84_F;
55    let b = a * (1.0 - f);
56
57    let l = (lon2 - lon1).to_radians();
58    let u1 = ((1.0 - f) * lat1.to_radians().tan()).atan();
59    let u2 = ((1.0 - f) * lat2.to_radians().tan()).atan();
60    let (sin_u1, cos_u1) = (u1.sin(), u1.cos());
61    let (sin_u2, cos_u2) = (u2.sin(), u2.cos());
62
63    let mut lambda = l;
64    let mut sin_sigma = 0.0;
65    let mut cos_sigma = 0.0;
66    let mut sigma = 0.0;
67    let mut cos_sq_alpha = 0.0;
68    let mut cos2_sigma_m = 0.0;
69
70    for _ in 0..200 {
71        let (sin_lambda, cos_lambda) = (lambda.sin(), lambda.cos());
72        sin_sigma = ((cos_u2 * sin_lambda).powi(2)
73            + (cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda).powi(2))
74        .sqrt();
75        if sin_sigma == 0.0 {
76            return 0.0; // coincident points
77        }
78        cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lambda;
79        sigma = sin_sigma.atan2(cos_sigma);
80        let sin_alpha = cos_u1 * cos_u2 * sin_lambda / sin_sigma;
81        cos_sq_alpha = 1.0 - sin_alpha * sin_alpha;
82        cos2_sigma_m = if cos_sq_alpha != 0.0 {
83            cos_sigma - 2.0 * sin_u1 * sin_u2 / cos_sq_alpha
84        } else {
85            0.0 // equatorial line
86        };
87        let c = f / 16.0 * cos_sq_alpha * (4.0 + f * (4.0 - 3.0 * cos_sq_alpha));
88        let lambda_prev = lambda;
89        lambda = l
90            + (1.0 - c)
91                * f
92                * sin_alpha
93                * (sigma
94                    + c * sin_sigma
95                        * (cos2_sigma_m + c * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m * cos2_sigma_m)));
96        if (lambda - lambda_prev).abs() < 1e-12 {
97            break;
98        }
99    }
100
101    let u_sq = cos_sq_alpha * (a * a - b * b) / (b * b);
102    let cap_a = 1.0 + u_sq / 16384.0 * (4096.0 + u_sq * (-768.0 + u_sq * (320.0 - 175.0 * u_sq)));
103    let cap_b = u_sq / 1024.0 * (256.0 + u_sq * (-128.0 + u_sq * (74.0 - 47.0 * u_sq)));
104    let delta_sigma = cap_b
105        * sin_sigma
106        * (cos2_sigma_m
107            + cap_b / 4.0
108                * (cos_sigma * (-1.0 + 2.0 * cos2_sigma_m * cos2_sigma_m)
109                    - cap_b / 6.0
110                        * cos2_sigma_m
111                        * (-3.0 + 4.0 * sin_sigma * sin_sigma)
112                        * (-3.0 + 4.0 * cos2_sigma_m * cos2_sigma_m)));
113    b * cap_a * (sigma - delta_sigma)
114}
115
116/// Geodesic length of a `[lat, lon]` vertex path in METRES (sum of Vincenty edges).
117/// For a closed ring this is the perimeter. Matches PostGIS `ST_Perimeter`/`ST_Length`.
118pub fn geodesic_path_length_m(coords: &[[f64; 2]]) -> f64 {
119    coords
120        .windows(2)
121        .map(|w| geodesic_distance_m(w[0][0], w[0][1], w[1][0], w[1][1]))
122        .sum()
123}
124
125/// Geodesic area of a polygon ring (`[lat, lon]`) in SQUARE METRES, via the
126/// spherical excess on the WGS84 authalic sphere. Matches PostGIS
127/// `ST_Area(::geography)` to ~1e-5 relative for city-scale polygons. Sign is
128/// dropped (absolute area); the ring need not be explicitly closed.
129pub fn geodesic_ring_area_m2(ring: &[[f64; 2]]) -> f64 {
130    let n = ring.len();
131    if n < 3 {
132        return 0.0;
133    }
134    // L'Huilier / line-integral form of the spherical excess:
135    //   E = Σ 2·atan2( tan(Δλ/2)·(tan(φ1/2)+tan(φ2/2)), 1 + tan(φ1/2)·tan(φ2/2) )
136    let mut excess = 0.0;
137    for i in 0..n {
138        // Longitude stays geodetic; latitude → authalic so the excess yields the
139        // ellipsoid's area (matches PostGIS geography) not the sphere's.
140        let (lat1, lon1) = (authalic_lat(ring[i][0].to_radians()), ring[i][1].to_radians());
141        let j = (i + 1) % n;
142        let (lat2, lon2) = (authalic_lat(ring[j][0].to_radians()), ring[j][1].to_radians());
143        let d_lon = lon2 - lon1;
144        let t1 = (lat1 / 2.0).tan();
145        let t2 = (lat2 / 2.0).tan();
146        excess += 2.0 * ((d_lon / 2.0).tan() * (t1 + t2)).atan2(1.0 + t1 * t2);
147    }
148    (excess.abs()) * WGS84_AUTHALIC_R * WGS84_AUTHALIC_R
149}
150
151// ── Point-in-polygon (ray casting) ───────────────────────────────────────────
152
153/// Test whether a point is inside a polygon ring using the ray-casting algorithm.
154///
155/// Ring format: `[[lat, lon], ...]` (internal format, NOT GeoJSON `[lon, lat]`).
156pub fn point_in_polygon(lat: f64, lon: f64, ring: &[[f64; 2]]) -> bool {
157    let n = ring.len();
158    if n < 3 {
159        return false;
160    }
161    let mut inside = false;
162    let mut j = n - 1;
163    for i in 0..n {
164        let (yi, xi) = (ring[i][0], ring[i][1]);
165        let (yj, xj) = (ring[j][0], ring[j][1]);
166        if ((yi > lat) != (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
167            inside = !inside;
168        }
169        j = i;
170    }
171    inside
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// Reference values captured from LIVE PostGIS `ST_*(::geography)`
179    /// (WGS84) -- carried over from the e1 calibration suite. These pin
180    /// the port bit-for-bit against the already-verified math.
181    #[test]
182    fn vincenty_matches_postgis_geography() {
183        // Vincenty's own published test pair (Flinders Peak -> Buninyong).
184        let d = geodesic_distance_m(-37.95103, 144.42487, -37.65282, 143.92650);
185        assert!((d - 54972.0).abs() < 2.0, "got {d}");
186        // Live-PostGIS point pair at NYC.
187        let d = geodesic_distance_m(40.70, -74.00, 40.75, -73.95);
188        assert!((d - 6976.62506433).abs() < 0.001, "dist {d}");
189        assert_eq!(geodesic_distance_m(40.0, -73.0, 40.0, -73.0), 0.0);
190    }
191
192    #[test]
193    fn ring_area_and_perimeter_match_postgis() {
194        // A 0.01x0.01 degree cell at NYC, rings as [lat, lon].
195        let ring = vec![
196            [40.70, -74.00], [40.70, -73.99], [40.71, -73.99],
197            [40.71, -74.00], [40.70, -74.00],
198        ];
199        let a = geodesic_ring_area_m2(&ring);
200        assert!((a - 938459.4059114456).abs() / 938459.406 < 1e-6, "area {a}");
201        let p = geodesic_path_length_m(&ring);
202        assert!((p - 3911.147957345263).abs() / 3911.148 < 1e-6, "perimeter {p}");
203    }
204
205    #[test]
206    fn point_in_polygon_in_and_out() {
207        let ring = [
208            [-37.80, 144.95], [-37.80, 144.98],
209            [-37.83, 144.98], [-37.83, 144.95],
210        ];
211        assert!(point_in_polygon(-37.81, 144.96, &ring));
212        assert!(!point_in_polygon(-38.15, 144.36, &ring));
213    }
214}