Skip to main content

oxigdal_algorithms/vector/
geodesic.rs

1//! Karney's geodesic algorithms for accurate ellipsoidal computations
2//!
3//! Implements geodesic area and distance computation on the WGS84 ellipsoid
4//! using the algorithms from:
5//! Karney, C.F.F. (2013) "Algorithms for geodesics",
6//! Journal of Geodesy 87(1), pp. 43-55.
7//!
8//! Provides sub-millimeter accuracy (relative error < 1e-15 for full Earth)
9//! for geodesic area computation. Handles antimeridian-crossing and
10//! polar-enclosing polygons correctly.
11//!
12//! # Algorithm Overview
13//!
14//! Uses 6th-order Taylor expansion in the third flattening `n = (a-b)/(a+b)`.
15//! For each polygon edge, solves the inverse geodesic problem to obtain the S12
16//! area element. The polygon area is the sum of S12 values with winding
17//! correction.
18//!
19//! # Backend
20//!
21//! Powered by `geographiclib-rs`, a Pure Rust port of GeographicLib.
22//! This ensures correctness (tested against the full GeographicLib test suite)
23//! while maintaining the COOLJAPAN Pure Rust policy.
24
25use crate::error::{AlgorithmError, Result};
26use oxigdal_core::vector::{Coordinate, Polygon};
27
28// ────────────────────────────────────────────────────
29// Re-export of the underlying geodesic engine
30// ────────────────────────────────────────────────────
31
32use geographiclib_rs::{
33    Geodesic as GeodesicEngine, InverseGeodesic, PolygonArea as GeodesicPolygonArea, Winding,
34};
35
36// ────────────────────────────────────────────────────
37// Geodesic Struct — thin wrapper
38// ────────────────────────────────────────────────────
39
40/// Geodesic solver for an ellipsoid of revolution.
41///
42/// Wraps the `geographiclib-rs` implementation of Karney's algorithms.
43/// The default constructor uses WGS84 parameters.
44///
45/// # Examples
46///
47/// ```
48/// use oxigdal_algorithms::vector::geodesic::Geodesic;
49///
50/// let geod = Geodesic::wgs84();
51/// let result = geod.inverse(0.0, 0.0, 1.0, 0.0);
52/// assert!(result.is_ok());
53/// ```
54#[derive(Debug, Clone)]
55pub struct Geodesic {
56    engine: GeodesicEngine,
57}
58
59/// Result of the inverse geodesic problem between two points.
60#[derive(Debug, Clone, Copy)]
61pub struct InverseResult {
62    /// Geodesic distance in meters
63    pub s12: f64,
64    /// Forward azimuth at point 1 (degrees, clockwise from north)
65    pub azi1: f64,
66    /// Forward azimuth at point 2 (degrees, clockwise from north)
67    pub azi2: f64,
68    /// Area element between the geodesic and the equator (square meters)
69    pub s12_area: f64,
70}
71
72/// Result of polygon area computation.
73#[derive(Debug, Clone, Copy)]
74pub struct PolygonAreaResult {
75    /// Absolute area in square meters
76    pub area: f64,
77    /// Perimeter in meters
78    pub perimeter: f64,
79    /// Number of vertices
80    pub num_vertices: usize,
81}
82
83impl Geodesic {
84    /// Creates a new `Geodesic` for the WGS84 ellipsoid.
85    pub fn wgs84() -> Self {
86        Self {
87            engine: GeodesicEngine::wgs84(),
88        }
89    }
90
91    /// Creates a new `Geodesic` for an arbitrary ellipsoid.
92    ///
93    /// # Arguments
94    ///
95    /// * `a` - Semi-major axis (meters)
96    /// * `f` - Flattening (dimensionless)
97    pub fn new(a: f64, f: f64) -> Self {
98        Self {
99            engine: GeodesicEngine::new(a, f),
100        }
101    }
102
103    /// Returns the total surface area of the ellipsoid in square meters.
104    pub fn ellipsoid_area(&self) -> f64 {
105        self.engine.area()
106    }
107
108    /// Solve the inverse geodesic problem: given two points (lat1,lon1) and
109    /// (lat2,lon2) in degrees, compute distance, azimuths, and area element.
110    ///
111    /// # Arguments
112    ///
113    /// * `lat1` - Latitude of point 1 (degrees, -90..90)
114    /// * `lon1` - Longitude of point 1 (degrees)
115    /// * `lat2` - Latitude of point 2 (degrees, -90..90)
116    /// * `lon2` - Longitude of point 2 (degrees)
117    ///
118    /// # Returns
119    ///
120    /// `InverseResult` with distance, azimuths, and area element S12.
121    ///
122    /// # Errors
123    ///
124    /// Returns error if latitude is out of range [-90, 90].
125    pub fn inverse(&self, lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> Result<InverseResult> {
126        // Validate latitudes
127        if lat1.abs() > 90.0 + 1e-10 || lat2.abs() > 90.0 + 1e-10 {
128            return Err(AlgorithmError::InvalidParameter {
129                parameter: "latitude",
130                message: "latitude must be between -90 and 90 degrees".to_string(),
131            });
132        }
133
134        // Clamp latitudes to [-90, 90]
135        let lat1 = lat1.clamp(-90.0, 90.0);
136        let lat2 = lat2.clamp(-90.0, 90.0);
137
138        // Use the full _gen_inverse to get all outputs including S12
139        #[allow(non_snake_case)]
140        let (_a12, s12, azi1, _calp1, azi2, _calp2, _m12, _M12, _M21, S12) = self
141            .engine
142            ._gen_inverse(lat1, lon1, lat2, lon2, Self::full_mask());
143
144        Ok(InverseResult {
145            s12,
146            azi1: Self::atan2d(azi1, _calp1),
147            azi2: Self::atan2d(azi2, _calp2),
148            s12_area: S12,
149        })
150    }
151
152    /// Compute distance between two points in meters.
153    ///
154    /// Convenience method that only returns the distance.
155    ///
156    /// # Arguments
157    ///
158    /// * `lat1` - Latitude of point 1 (degrees)
159    /// * `lon1` - Longitude of point 1 (degrees)
160    /// * `lat2` - Latitude of point 2 (degrees)
161    /// * `lon2` - Longitude of point 2 (degrees)
162    ///
163    /// # Errors
164    ///
165    /// Returns error if latitude is out of range.
166    pub fn distance(&self, lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> Result<f64> {
167        if lat1.abs() > 90.0 + 1e-10 || lat2.abs() > 90.0 + 1e-10 {
168            return Err(AlgorithmError::InvalidParameter {
169                parameter: "latitude",
170                message: "latitude must be between -90 and 90 degrees".to_string(),
171            });
172        }
173
174        let lat1 = lat1.clamp(-90.0, 90.0);
175        let lat2 = lat2.clamp(-90.0, 90.0);
176
177        let s12: f64 = self.engine.inverse(lat1, lon1, lat2, lon2);
178        Ok(s12)
179    }
180
181    /// Compute the area of a polygon whose vertices are given in longitude/latitude degrees.
182    ///
183    /// The polygon should be given as a ring of coordinates. The ring may or may
184    /// not be closed (last == first). Uses Karney's geodesic area algorithm for
185    /// sub-millimeter accuracy on the WGS84 ellipsoid.
186    ///
187    /// # Arguments
188    ///
189    /// * `coords` - Ring of (longitude, latitude) coordinates in degrees
190    ///
191    /// # Returns
192    ///
193    /// `PolygonAreaResult` with absolute area (m^2), perimeter (m), and vertex count.
194    ///
195    /// # Errors
196    ///
197    /// Returns error if fewer than 3 distinct vertices or invalid latitudes.
198    pub fn polygon_area(&self, coords: &[Coordinate]) -> Result<PolygonAreaResult> {
199        if coords.len() < 3 {
200            return Err(AlgorithmError::InsufficientData {
201                operation: "polygon_area_karney",
202                message: "polygon must have at least 3 coordinates".to_string(),
203            });
204        }
205
206        // Validate all latitudes
207        for (i, coord) in coords.iter().enumerate() {
208            if coord.y.abs() > 90.0 + 1e-10 {
209                return Err(AlgorithmError::InvalidParameter {
210                    parameter: "latitude",
211                    message: format!(
212                        "coordinate {} has invalid latitude {} (must be -90..90)",
213                        i, coord.y
214                    ),
215                });
216            }
217        }
218
219        // Determine if the ring is closed
220        let n = coords.len();
221        let is_closed = n > 3
222            && (coords[0].x - coords[n - 1].x).abs() < 1e-12
223            && (coords[0].y - coords[n - 1].y).abs() < 1e-12;
224
225        // Number of distinct vertices
226        let num_verts = if is_closed { n - 1 } else { n };
227
228        // Use geographiclib-rs PolygonArea with CCW winding (standard GIS convention)
229        let mut pa = GeodesicPolygonArea::new(&self.engine, Winding::CounterClockwise);
230
231        for i in 0..num_verts {
232            let lat = coords[i].y.clamp(-90.0, 90.0);
233            let lon = coords[i].x;
234            // PolygonArea::add_point takes (lat, lon)
235            pa.add_point(lat, lon);
236        }
237
238        // compute(true) = signed area (positive for CCW, negative for CW)
239        let (perimeter, signed_area, count) = pa.compute(true);
240
241        // Take absolute value to get the polygon interior area.
242        // For CW-wound polygons the signed area is negative; abs() recovers the
243        // interior area. For very large polygons (> half Earth) this naturally
244        // gives the correct interior area since the signed area is already the
245        // smaller quantity.
246        let area = signed_area.abs();
247
248        Ok(PolygonAreaResult {
249            area,
250            perimeter,
251            num_vertices: count,
252        })
253    }
254
255    /// Compute the signed area of a polygon ring.
256    ///
257    /// Positive for counter-clockwise, negative for clockwise.
258    /// This is useful for determining polygon winding order.
259    ///
260    /// # Arguments
261    ///
262    /// * `coords` - Ring of (longitude, latitude) coordinates in degrees
263    ///
264    /// # Returns
265    ///
266    /// Signed area in square meters.
267    ///
268    /// # Errors
269    ///
270    /// Returns error if fewer than 3 distinct vertices or invalid latitudes.
271    pub fn polygon_area_signed(&self, coords: &[Coordinate]) -> Result<f64> {
272        if coords.len() < 3 {
273            return Err(AlgorithmError::InsufficientData {
274                operation: "polygon_area_signed_karney",
275                message: "polygon must have at least 3 coordinates".to_string(),
276            });
277        }
278
279        // Validate latitudes
280        for (i, coord) in coords.iter().enumerate() {
281            if coord.y.abs() > 90.0 + 1e-10 {
282                return Err(AlgorithmError::InvalidParameter {
283                    parameter: "latitude",
284                    message: format!(
285                        "coordinate {} has invalid latitude {} (must be -90..90)",
286                        i, coord.y
287                    ),
288                });
289            }
290        }
291
292        let n = coords.len();
293        let is_closed = n > 3
294            && (coords[0].x - coords[n - 1].x).abs() < 1e-12
295            && (coords[0].y - coords[n - 1].y).abs() < 1e-12;
296        let num_verts = if is_closed { n - 1 } else { n };
297
298        let mut pa = GeodesicPolygonArea::new(&self.engine, Winding::CounterClockwise);
299        for i in 0..num_verts {
300            let lat = coords[i].y.clamp(-90.0, 90.0);
301            let lon = coords[i].x;
302            pa.add_point(lat, lon);
303        }
304
305        // compute(true) = signed area
306        let (_perimeter, area, _count) = pa.compute(true);
307        Ok(area)
308    }
309
310    /// Compute the area of an `oxigdal_core::vector::Polygon`.
311    ///
312    /// Computes the area of the exterior ring minus the area of all interior
313    /// rings (holes). Coordinates are expected in (longitude, latitude) degrees.
314    ///
315    /// # Arguments
316    ///
317    /// * `polygon` - Input polygon
318    ///
319    /// # Returns
320    ///
321    /// Area in square meters (always non-negative)
322    ///
323    /// # Errors
324    ///
325    /// Returns error if polygon has fewer than 3 coordinates or invalid latitudes.
326    pub fn polygon_area_full(&self, polygon: &Polygon) -> Result<f64> {
327        let ext_result = self.polygon_area(&polygon.exterior.coords)?;
328        let mut area = ext_result.area;
329
330        for hole in &polygon.interiors {
331            let hole_result = self.polygon_area(&hole.coords)?;
332            area -= hole_result.area;
333        }
334
335        Ok(area.abs())
336    }
337
338    // ────────────────────────────────────────────────
339    // Internal helpers
340    // ────────────────────────────────────────────────
341
342    /// Capability mask for full inverse output including area (S12).
343    fn full_mask() -> u64 {
344        use geographiclib_rs::geodesic_capability as caps;
345        caps::LATITUDE
346            | caps::LONGITUDE
347            | caps::DISTANCE
348            | caps::AREA
349            | caps::LONG_UNROLL
350            | caps::AZIMUTH
351    }
352
353    /// Convert (sin, cos) to degrees
354    fn atan2d(sinx: f64, cosx: f64) -> f64 {
355        sinx.atan2(cosx).to_degrees()
356    }
357}
358
359// ────────────────────────────────────────────────────
360// Public Convenience Functions
361// ────────────────────────────────────────────────────
362
363/// Compute the geodesic area of a polygon ring using Karney's algorithm.
364///
365/// Convenience function that creates a WGS84 `Geodesic` and calls `polygon_area`.
366///
367/// # Arguments
368///
369/// * `coords` - Ring of (longitude, latitude) coordinates in degrees
370///
371/// # Returns
372///
373/// Area in square meters
374///
375/// # Errors
376///
377/// Returns error if fewer than 3 vertices or invalid latitudes.
378pub fn ring_area_karney(coords: &[Coordinate]) -> Result<f64> {
379    let geod = Geodesic::wgs84();
380    let result = geod.polygon_area(coords)?;
381    Ok(result.area)
382}
383
384/// Compute the geodesic area of a polygon (exterior minus holes) using Karney's algorithm.
385///
386/// Convenience function that creates a WGS84 `Geodesic` and calls `polygon_area_full`.
387///
388/// # Arguments
389///
390/// * `polygon` - Input polygon
391///
392/// # Returns
393///
394/// Area in square meters
395///
396/// # Errors
397///
398/// Returns error if polygon is invalid or latitudes out of range.
399pub fn area_polygon_karney(polygon: &Polygon) -> Result<f64> {
400    let geod = Geodesic::wgs84();
401    geod.polygon_area_full(polygon)
402}
403
404// ────────────────────────────────────────────────────
405// Tests
406// ────────────────────────────────────────────────────
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use oxigdal_core::vector::LineString;
412
413    /// Helper: create a polygon from a sequence of (lon, lat) pairs.
414    /// Automatically closes the ring.
415    fn make_polygon(vertices: &[(f64, f64)]) -> Result<Polygon> {
416        let mut coords: Vec<Coordinate> = vertices
417            .iter()
418            .map(|&(lon, lat)| Coordinate::new_2d(lon, lat))
419            .collect();
420        // Close the ring
421        if let Some(&first) = vertices.first() {
422            coords.push(Coordinate::new_2d(first.0, first.1));
423        }
424        let exterior = LineString::new(coords).map_err(AlgorithmError::Core)?;
425        Polygon::new(exterior, vec![]).map_err(AlgorithmError::Core)
426    }
427
428    #[test]
429    fn test_wgs84_construction() {
430        let geod = Geodesic::wgs84();
431        let area = geod.ellipsoid_area();
432        // Earth surface area ~ 5.1e14 m^2
433        assert!(area > 5.0e14);
434        assert!(area < 5.2e14);
435    }
436
437    #[test]
438    fn test_inverse_same_point() {
439        let geod = Geodesic::wgs84();
440        let result = geod.inverse(0.0, 0.0, 0.0, 0.0);
441        assert!(result.is_ok());
442        if let Ok(inv) = result {
443            assert!(inv.s12.abs() < 1e-6);
444        }
445    }
446
447    #[test]
448    fn test_inverse_equator_short() {
449        let geod = Geodesic::wgs84();
450        // 1 degree along equator ~ 111,319 m
451        let result = geod.inverse(0.0, 0.0, 0.0, 1.0);
452        assert!(result.is_ok());
453        if let Ok(inv) = result {
454            assert!(
455                inv.s12 > 111_000.0 && inv.s12 < 112_000.0,
456                "equatorial 1-degree distance = {}, expected ~111319",
457                inv.s12
458            );
459        }
460    }
461
462    #[test]
463    fn test_inverse_meridian() {
464        let geod = Geodesic::wgs84();
465        // 1 degree along meridian at equator ~ 110,574 m
466        let result = geod.inverse(0.0, 0.0, 1.0, 0.0);
467        assert!(result.is_ok());
468        if let Ok(inv) = result {
469            assert!(
470                inv.s12 > 110_000.0 && inv.s12 < 112_000.0,
471                "meridional 1-degree distance = {}, expected ~110574",
472                inv.s12
473            );
474        }
475    }
476
477    #[test]
478    fn test_inverse_symmetry() {
479        let geod = Geodesic::wgs84();
480        let r1 = geod.inverse(10.0, 20.0, 30.0, 40.0);
481        let r2 = geod.inverse(30.0, 40.0, 10.0, 20.0);
482
483        assert!(r1.is_ok());
484        assert!(r2.is_ok());
485
486        if let (Ok(inv1), Ok(inv2)) = (r1, r2) {
487            let rel_err = (inv1.s12 - inv2.s12).abs() / inv1.s12.max(1.0);
488            assert!(
489                rel_err < 1e-12,
490                "inverse distance not symmetric: {} vs {}",
491                inv1.s12,
492                inv2.s12
493            );
494        }
495    }
496
497    #[test]
498    fn test_inverse_invalid_latitude() {
499        let geod = Geodesic::wgs84();
500        // lat2 = 95 is invalid
501        let result = geod.inverse(0.0, 0.0, 95.0, 0.0);
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn test_distance_convenience() {
507        let geod = Geodesic::wgs84();
508        let d = geod.distance(0.0, 0.0, 0.0, 1.0);
509        assert!(d.is_ok());
510        if let Ok(dist) = d {
511            assert!(dist > 111_000.0 && dist < 112_000.0);
512        }
513    }
514
515    // ────────────────────────────────────────────────
516    // Polygon Area Tests
517    // ────────────────────────────────────────────────
518
519    #[test]
520    fn test_polygon_area_unit_square_equator() {
521        // 1 degree x 1 degree square at equator
522        // GeographicLib Planimeter reference: 12,308,778,361.469 m^2
523        let geod = Geodesic::wgs84();
524        let coords = vec![
525            Coordinate::new_2d(0.0, 0.0),
526            Coordinate::new_2d(1.0, 0.0),
527            Coordinate::new_2d(1.0, 1.0),
528            Coordinate::new_2d(0.0, 1.0),
529            Coordinate::new_2d(0.0, 0.0),
530        ];
531        let result = geod.polygon_area(&coords);
532        assert!(result.is_ok(), "polygon_area failed: {:?}", result.err());
533        if let Ok(pa) = result {
534            let reference = 12_308_778_361.469;
535            let rel_error = (pa.area - reference).abs() / reference;
536            assert!(
537                rel_error < 1e-6,
538                "Unit square area {:.3} m^2, expected ~{:.3} m^2, relative error: {:.10}",
539                pa.area,
540                reference,
541                rel_error
542            );
543        }
544    }
545
546    #[test]
547    fn test_polygon_area_full_earth() {
548        // Polygon enclosing the entire Earth (CCW rectangle around the globe)
549        // From geographiclib-rs: area of WGS84 ellipsoid
550        let geod = Geodesic::wgs84();
551        let total_area = geod.ellipsoid_area();
552
553        // A quadrilateral at 89N/89S that nearly covers the whole earth
554        let coords = vec![
555            Coordinate::new_2d(0.0, -89.0),
556            Coordinate::new_2d(90.0, -89.0),
557            Coordinate::new_2d(180.0, -89.0),
558            Coordinate::new_2d(-90.0, -89.0),
559            Coordinate::new_2d(0.0, -89.0),
560        ];
561        let result = geod.polygon_area(&coords);
562        assert!(result.is_ok(), "polygon_area failed: {:?}", result.err());
563        if let Ok(pa) = result {
564            // This should be a cap around the south pole
565            // Much smaller than the total earth area
566            assert!(pa.area > 0.0);
567            assert!(pa.area < total_area);
568        }
569    }
570
571    #[test]
572    fn test_polygon_area_ellipsoid_area() {
573        // Check that the ellipsoid area matches known value
574        let geod = Geodesic::wgs84();
575        let total_area = geod.ellipsoid_area();
576        let reference = 5.10065621724089e14;
577        let rel_error = (total_area - reference).abs() / reference;
578        assert!(
579            rel_error < 1e-8,
580            "Ellipsoid area {:.6e}, expected {:.6e}, rel err: {:.10}",
581            total_area,
582            reference,
583            rel_error
584        );
585    }
586
587    #[test]
588    fn test_polygon_area_high_latitude_triangle() {
589        // Triangle at 89N (near pole) — matches GeographicLib planimeter0 test
590        let geod = Geodesic::wgs84();
591        let coords = vec![
592            Coordinate::new_2d(0.0, 89.0),
593            Coordinate::new_2d(90.0, 89.0),
594            Coordinate::new_2d(180.0, 89.0),
595            Coordinate::new_2d(270.0, 89.0),
596            Coordinate::new_2d(0.0, 89.0),
597        ];
598        let result = geod.polygon_area(&coords);
599        assert!(result.is_ok(), "polygon_area failed: {:?}", result.err());
600        if let Ok(pa) = result {
601            // GeographicLib reference: 24,952,305,678 m^2
602            let reference = 24_952_305_678.0;
603            let rel_error = (pa.area - reference).abs() / reference;
604            assert!(
605                rel_error < 1e-6,
606                "High-lat area {:.0} m^2, expected {:.0} m^2, rel err: {:.10}",
607                pa.area,
608                reference,
609                rel_error
610            );
611        }
612    }
613
614    #[test]
615    fn test_polygon_area_antimeridian_crossing() {
616        // Rectangle crossing the antimeridian (179E to 179W = 2 degrees wide)
617        let geod = Geodesic::wgs84();
618        let coords = vec![
619            Coordinate::new_2d(179.0, 0.0),
620            Coordinate::new_2d(-179.0, 0.0),
621            Coordinate::new_2d(-179.0, 1.0),
622            Coordinate::new_2d(179.0, 1.0),
623            Coordinate::new_2d(179.0, 0.0),
624        ];
625        let result = geod.polygon_area(&coords);
626        assert!(result.is_ok(), "polygon_area failed: {:?}", result.err());
627        if let Ok(pa) = result {
628            // 2 degrees wide, 1 degree tall at equator
629            // Should be approximately 2 * 12,308,778,361 m^2
630            let reference_approx = 2.0 * 12_308_778_361.0;
631            let rel_error = (pa.area - reference_approx).abs() / reference_approx;
632            assert!(
633                rel_error < 0.01,
634                "Antimeridian area {:.3} m^2, expected ~{:.3} m^2, rel error: {:.6}",
635                pa.area,
636                reference_approx,
637                rel_error
638            );
639        }
640    }
641
642    #[test]
643    fn test_polygon_area_polar_enclosing() {
644        // Quadrilateral around the south pole at -89 latitude.
645        // At the south pole, CCW (as seen from outside the Earth) means
646        // longitudes decreasing: 0 -> 270 -> 180 -> 90.
647        // This matches the 89N CCW test by symmetry.
648        let geod = Geodesic::wgs84();
649        let coords = vec![
650            Coordinate::new_2d(0.0, -89.0),
651            Coordinate::new_2d(270.0, -89.0),
652            Coordinate::new_2d(180.0, -89.0),
653            Coordinate::new_2d(90.0, -89.0),
654            Coordinate::new_2d(0.0, -89.0),
655        ];
656        let result = geod.polygon_area(&coords);
657        assert!(result.is_ok(), "polygon_area failed: {:?}", result.err());
658        if let Ok(pa) = result {
659            assert!(pa.area > 0.0, "polar area should be positive");
660            // Same as the 89N test by symmetry
661            let reference = 24_952_305_678.0;
662            let rel_error = (pa.area - reference).abs() / reference;
663            assert!(
664                rel_error < 1e-4,
665                "Polar enclosing area {:.0}, expected ~{:.0}, rel err {:.8}",
666                pa.area,
667                reference,
668                rel_error
669            );
670        }
671    }
672
673    #[test]
674    fn test_polygon_area_cw_ccw_same_absolute() {
675        // CCW square
676        let geod = Geodesic::wgs84();
677        let ccw = vec![
678            Coordinate::new_2d(0.0, 0.0),
679            Coordinate::new_2d(1.0, 0.0),
680            Coordinate::new_2d(1.0, 1.0),
681            Coordinate::new_2d(0.0, 1.0),
682            Coordinate::new_2d(0.0, 0.0),
683        ];
684        // CW square (reversed)
685        let cw = vec![
686            Coordinate::new_2d(0.0, 0.0),
687            Coordinate::new_2d(0.0, 1.0),
688            Coordinate::new_2d(1.0, 1.0),
689            Coordinate::new_2d(1.0, 0.0),
690            Coordinate::new_2d(0.0, 0.0),
691        ];
692
693        let result_ccw = geod.polygon_area(&ccw);
694        let result_cw = geod.polygon_area(&cw);
695
696        assert!(result_ccw.is_ok());
697        assert!(result_cw.is_ok());
698
699        if let (Ok(pa_ccw), Ok(pa_cw)) = (result_ccw, result_cw) {
700            let diff = (pa_ccw.area - pa_cw.area).abs();
701            let mean = (pa_ccw.area + pa_cw.area) / 2.0;
702            assert!(
703                diff / mean < 1e-10,
704                "CW/CCW areas differ: {} vs {}",
705                pa_ccw.area,
706                pa_cw.area
707            );
708        }
709    }
710
711    #[test]
712    fn test_polygon_area_signed_winding() {
713        // Test that signed area differentiates CW from CCW
714        let geod = Geodesic::wgs84();
715
716        // CCW (standard GIS convention — positive signed area)
717        let ccw = vec![
718            Coordinate::new_2d(0.0, 0.0),
719            Coordinate::new_2d(1.0, 0.0),
720            Coordinate::new_2d(1.0, 1.0),
721            Coordinate::new_2d(0.0, 1.0),
722            Coordinate::new_2d(0.0, 0.0),
723        ];
724
725        // CW (reversed — negative signed area)
726        let cw = vec![
727            Coordinate::new_2d(0.0, 0.0),
728            Coordinate::new_2d(0.0, 1.0),
729            Coordinate::new_2d(1.0, 1.0),
730            Coordinate::new_2d(1.0, 0.0),
731            Coordinate::new_2d(0.0, 0.0),
732        ];
733
734        let signed_ccw = geod.polygon_area_signed(&ccw);
735        let signed_cw = geod.polygon_area_signed(&cw);
736
737        assert!(signed_ccw.is_ok());
738        assert!(signed_cw.is_ok());
739
740        if let (Ok(area_ccw), Ok(area_cw)) = (signed_ccw, signed_cw) {
741            assert!(
742                area_ccw > 0.0,
743                "CCW signed area should be positive: {}",
744                area_ccw
745            );
746            assert!(
747                area_cw < 0.0,
748                "CW signed area should be negative: {}",
749                area_cw
750            );
751            assert!(
752                (area_ccw.abs() - area_cw.abs()).abs() / area_ccw.abs() < 1e-10,
753                "absolute values should match"
754            );
755        }
756    }
757
758    #[test]
759    fn test_polygon_area_degenerate_collinear() {
760        // Degenerate polygon: all points on the equator
761        let geod = Geodesic::wgs84();
762        let coords = vec![
763            Coordinate::new_2d(0.0, 0.0),
764            Coordinate::new_2d(1.0, 0.0),
765            Coordinate::new_2d(2.0, 0.0),
766            Coordinate::new_2d(0.0, 0.0),
767        ];
768        let result = geod.polygon_area(&coords);
769        assert!(result.is_ok());
770        if let Ok(pa) = result {
771            assert!(
772                pa.area < 1.0,
773                "collinear polygon area should be ~0, got {}",
774                pa.area
775            );
776        }
777    }
778
779    #[test]
780    fn test_polygon_area_convenience_function() {
781        let coords = vec![
782            Coordinate::new_2d(0.0, 0.0),
783            Coordinate::new_2d(1.0, 0.0),
784            Coordinate::new_2d(1.0, 1.0),
785            Coordinate::new_2d(0.0, 1.0),
786            Coordinate::new_2d(0.0, 0.0),
787        ];
788        let result = ring_area_karney(&coords);
789        assert!(result.is_ok());
790        if let Ok(area) = result {
791            let reference = 12_308_778_361.469;
792            let rel_error = (area - reference).abs() / reference;
793            assert!(
794                rel_error < 1e-6,
795                "convenience area {:.3} m^2, expected ~{:.3} m^2",
796                area,
797                reference
798            );
799        }
800    }
801
802    #[test]
803    fn test_area_polygon_karney_with_hole() {
804        let outer = make_polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
805        let inner = make_polygon(&[(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)]);
806
807        assert!(outer.is_ok());
808        assert!(inner.is_ok());
809
810        if let (Ok(outer_p), Ok(inner_p)) = (outer, inner) {
811            let exterior = outer_p.exterior.clone();
812            let hole = inner_p.exterior.clone();
813            let poly_with_hole = Polygon::new(exterior, vec![hole]);
814            assert!(poly_with_hole.is_ok());
815
816            if let Ok(p) = poly_with_hole {
817                let result = area_polygon_karney(&p);
818                assert!(result.is_ok());
819                if let Ok(area) = result {
820                    // Area should be outer minus inner, both > 0
821                    assert!(area > 0.0);
822                    // Outer ~ 10*10 degree square, inner ~ 6*6 degree square
823                    // The difference should be substantial
824                    assert!(
825                        area > 1e11,
826                        "area with hole should be substantial: {}",
827                        area
828                    );
829                }
830            }
831        }
832    }
833
834    #[test]
835    fn test_polygon_area_insufficient_coords() {
836        let geod = Geodesic::wgs84();
837        let coords = vec![Coordinate::new_2d(0.0, 0.0), Coordinate::new_2d(1.0, 0.0)];
838        let result = geod.polygon_area(&coords);
839        assert!(result.is_err());
840    }
841
842    #[test]
843    fn test_polygon_area_invalid_latitude() {
844        let geod = Geodesic::wgs84();
845        let coords = vec![
846            Coordinate::new_2d(0.0, 0.0),
847            Coordinate::new_2d(1.0, 0.0),
848            Coordinate::new_2d(1.0, 95.0), // Invalid
849            Coordinate::new_2d(0.0, 0.0),
850        ];
851        let result = geod.polygon_area(&coords);
852        assert!(result.is_err());
853    }
854
855    #[test]
856    fn test_polygon_area_open_ring() {
857        // Test with an open ring (not closed)
858        let geod = Geodesic::wgs84();
859        let coords = vec![
860            Coordinate::new_2d(0.0, 0.0),
861            Coordinate::new_2d(1.0, 0.0),
862            Coordinate::new_2d(1.0, 1.0),
863            Coordinate::new_2d(0.0, 1.0),
864        ];
865        let result = geod.polygon_area(&coords);
866        assert!(result.is_ok());
867        if let Ok(pa) = result {
868            // Should give same result as closed ring
869            let reference = 12_308_778_361.469;
870            let rel_error = (pa.area - reference).abs() / reference;
871            assert!(
872                rel_error < 1e-6,
873                "open ring area {:.3}, expected ~{:.3}",
874                pa.area,
875                reference
876            );
877        }
878    }
879
880    #[test]
881    fn test_custom_ellipsoid() {
882        // Perfect sphere with a = b = 6371000
883        let r = 6_371_000.0;
884        let geod = Geodesic::new(r, 0.0);
885        let area = geod.ellipsoid_area();
886        use core::f64::consts::PI;
887        let expected = 4.0 * PI * r * r;
888        let rel_error = (area - expected).abs() / expected;
889        assert!(
890            rel_error < 1e-10,
891            "sphere area {:.6e}, expected {:.6e}",
892            area,
893            expected
894        );
895    }
896
897    #[test]
898    fn test_diamond_polygon() {
899        // GeographicLib planimeter0 test: diamond at equator
900        let geod = Geodesic::wgs84();
901        let coords = vec![
902            Coordinate::new_2d(-1.0, 0.0),
903            Coordinate::new_2d(0.0, -1.0),
904            Coordinate::new_2d(1.0, 0.0),
905            Coordinate::new_2d(0.0, 1.0),
906            Coordinate::new_2d(-1.0, 0.0),
907        ];
908        let result = geod.polygon_area(&coords);
909        assert!(result.is_ok());
910        if let Ok(pa) = result {
911            // GeographicLib reference: 24,619,419,146 m^2
912            let reference = 24_619_419_146.0;
913            let rel_error = (pa.area - reference).abs() / reference;
914            assert!(
915                rel_error < 1e-5,
916                "diamond area {:.0}, expected {:.0}, rel err {:.8}",
917                pa.area,
918                reference,
919                rel_error
920            );
921        }
922    }
923
924    #[test]
925    fn test_perimeter_unit_square() {
926        // Perimeter of 1-degree square at equator
927        // GeographicLib reference: 443,770.917 m
928        let geod = Geodesic::wgs84();
929        let coords = vec![
930            Coordinate::new_2d(0.0, 0.0),
931            Coordinate::new_2d(1.0, 0.0),
932            Coordinate::new_2d(1.0, 1.0),
933            Coordinate::new_2d(0.0, 1.0),
934            Coordinate::new_2d(0.0, 0.0),
935        ];
936        let result = geod.polygon_area(&coords);
937        assert!(result.is_ok());
938        if let Ok(pa) = result {
939            let reference = 443_770.917;
940            let rel_error = (pa.perimeter - reference).abs() / reference;
941            assert!(
942                rel_error < 1e-5,
943                "perimeter {:.3}, expected {:.3}",
944                pa.perimeter,
945                reference
946            );
947        }
948    }
949
950    #[test]
951    fn test_area_method_karney_via_area_module() {
952        // Test that AreaMethod::KarneyGeodesic works through the area module
953        let coords = vec![
954            Coordinate::new_2d(0.0, 0.0),
955            Coordinate::new_2d(1.0, 0.0),
956            Coordinate::new_2d(1.0, 1.0),
957            Coordinate::new_2d(0.0, 1.0),
958            Coordinate::new_2d(0.0, 0.0),
959        ];
960        let exterior = LineString::new(coords);
961        assert!(exterior.is_ok());
962
963        if let Ok(ext) = exterior {
964            let poly = Polygon::new(ext, vec![]);
965            assert!(poly.is_ok());
966
967            if let Ok(p) = poly {
968                let result = crate::vector::area::area_polygon(
969                    &p,
970                    crate::vector::area::AreaMethod::KarneyGeodesic,
971                );
972                assert!(result.is_ok());
973                if let Ok(area) = result {
974                    let reference = 12_308_778_361.469;
975                    let rel_error = (area - reference).abs() / reference;
976                    assert!(
977                        rel_error < 1e-6,
978                        "AreaMethod::KarneyGeodesic: area {:.3}, expected ~{:.3}",
979                        area,
980                        reference
981                    );
982                }
983            }
984        }
985    }
986}