Skip to main content

scirs2_spatial/
projections.rs

1//! Map projections and coordinate system transformations
2//!
3//! This module provides comprehensive map projection systems for converting between
4//! geographic coordinates and various projected coordinate systems.
5//!
6//! # Features
7//!
8//! * **Geographic to UTM** - Universal Transverse Mercator projection
9//! * **Web Mercator** - EPSG:3857 projection for web maps
10//! * **Lambert Conformal Conic** - For mid-latitude regions
11//! * **Albers Equal Area** - Area-preserving projection
12//! * **Stereographic** - Azimuthal conformal projection
13//! * **Datum transformations** - WGS84, NAD83, etc.
14//!
15//! # Examples
16//!
17//! ```
18//! use scirs2_spatial::projections::{geographic_to_utm, utm_to_geographic, UTMZone};
19//!
20//! // Convert latitude/longitude to UTM
21//! let lat = 40.7128; // New York City
22//! let lon = -74.0060;
23//!
24//! let (zone, easting, northing) = geographic_to_utm(lat, lon)
25//!     .expect("Failed to convert to UTM");
26//!
27//! println!("UTM Zone {}: E={:.2}, N={:.2}", zone.number, easting, northing);
28//!
29//! // Convert back
30//! let (lat2, lon2) = utm_to_geographic(easting, northing, zone)
31//!     .expect("Failed to convert from UTM");
32//! ```
33
34use crate::error::{SpatialError, SpatialResult};
35use scirs2_core::numeric::Float;
36use std::f64::consts::PI;
37
38/// UTM zone information
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct UTMZone {
41    /// Zone number (1-60)
42    pub number: u8,
43    /// Hemisphere (true = North, false = South)
44    pub north: bool,
45}
46
47/// Map projection types
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum ProjectionType {
50    /// Universal Transverse Mercator
51    UTM,
52    /// Web Mercator (EPSG:3857)
53    WebMercator,
54    /// Lambert Conformal Conic
55    LambertConformalConic,
56    /// Albers Equal Area
57    AlbersEqualArea,
58    /// Stereographic
59    Stereographic,
60    /// Mercator
61    Mercator,
62}
63
64/// Ellipsoid parameters for datum definitions
65#[derive(Debug, Clone, Copy)]
66pub struct Ellipsoid {
67    /// Semi-major axis (equatorial radius) in meters
68    pub a: f64,
69    /// Flattening factor
70    pub f: f64,
71}
72
73impl Ellipsoid {
74    /// WGS84 ellipsoid (most common, used by GPS)
75    pub const WGS84: Ellipsoid = Ellipsoid {
76        a: 6378137.0,
77        f: 1.0 / 298.257223563,
78    };
79
80    /// GRS80 ellipsoid (used by NAD83)
81    pub const GRS80: Ellipsoid = Ellipsoid {
82        a: 6378137.0,
83        f: 1.0 / 298.257222101,
84    };
85
86    /// Semi-minor axis (polar radius)
87    pub fn b(&self) -> f64 {
88        self.a * (1.0 - self.f)
89    }
90
91    /// First eccentricity squared
92    pub fn e2(&self) -> f64 {
93        2.0 * self.f - self.f * self.f
94    }
95
96    /// Second eccentricity squared
97    pub fn e_prime2(&self) -> f64 {
98        let e2 = self.e2();
99        e2 / (1.0 - e2)
100    }
101}
102
103/// Convert geographic coordinates to UTM
104///
105/// # Arguments
106///
107/// * `latitude` - Latitude in decimal degrees (-80 to 84)
108/// * `longitude` - Longitude in decimal degrees (-180 to 180)
109///
110/// # Returns
111///
112/// * Tuple of (UTM zone, easting in meters, northing in meters)
113///
114/// # Examples
115///
116/// ```
117/// use scirs2_spatial::projections::geographic_to_utm;
118///
119/// let (zone, easting, northing) = geographic_to_utm(40.7128, -74.0060)
120///     .expect("Failed to convert");
121/// ```
122pub fn geographic_to_utm(latitude: f64, longitude: f64) -> SpatialResult<(UTMZone, f64, f64)> {
123    if !(-80.0..=84.0).contains(&latitude) {
124        return Err(SpatialError::ValueError(
125            "Latitude must be between -80 and 84 degrees for UTM".to_string(),
126        ));
127    }
128
129    if !(-180.0..=180.0).contains(&longitude) {
130        return Err(SpatialError::ValueError(
131            "Longitude must be between -180 and 180 degrees".to_string(),
132        ));
133    }
134
135    // Determine UTM zone
136    let zone_number = (((longitude + 180.0) / 6.0).floor() as u8 % 60) + 1;
137    let is_north = latitude >= 0.0;
138
139    let zone = UTMZone {
140        number: zone_number,
141        north: is_north,
142    };
143
144    // Convert to radians
145    let lat_rad = latitude * PI / 180.0;
146    let lon_rad = longitude * PI / 180.0;
147
148    // Central meridian of the zone
149    let lon0 = ((zone_number as f64 - 1.0) * 6.0 - 180.0 + 3.0) * PI / 180.0;
150
151    // WGS84 parameters
152    let ellipsoid = Ellipsoid::WGS84;
153    let a = ellipsoid.a;
154    let e2 = ellipsoid.e2();
155    let e = e2.sqrt();
156
157    // UTM scale factor
158    let k0 = 0.9996;
159
160    // Compute auxiliary values
161    let n = a / (1.0 - e2 * lat_rad.sin().powi(2)).sqrt();
162    let t = lat_rad.tan();
163    let c = ellipsoid.e_prime2() * lat_rad.cos().powi(2);
164    let aa = (lon_rad - lon0) * lat_rad.cos();
165
166    // Compute meridional arc
167    let m = meridional_arc(lat_rad, &ellipsoid);
168
169    // Easting
170    let easting = k0
171        * n
172        * (aa
173            + (1.0 - t * t + c) * aa.powi(3) / 6.0
174            + (5.0 - 18.0 * t * t + t.powi(4) + 72.0 * c - 58.0 * ellipsoid.e_prime2())
175                * aa.powi(5)
176                / 120.0)
177        + 500000.0; // False easting
178
179    // Northing
180    let mut northing = k0
181        * (m + n
182            * t
183            * (aa.powi(2) / 2.0
184                + (5.0 - t * t + 9.0 * c + 4.0 * c * c) * aa.powi(4) / 24.0
185                + (61.0 - 58.0 * t * t + t.powi(4) + 600.0 * c - 330.0 * ellipsoid.e_prime2())
186                    * aa.powi(6)
187                    / 720.0));
188
189    // False northing for southern hemisphere
190    if !is_north {
191        northing += 10000000.0;
192    }
193
194    Ok((zone, easting, northing))
195}
196
197/// Convert UTM coordinates to geographic
198///
199/// # Arguments
200///
201/// * `easting` - Easting in meters
202/// * `northing` - Northing in meters
203/// * `zone` - UTM zone
204///
205/// # Returns
206///
207/// * Tuple of (latitude, longitude) in decimal degrees
208pub fn utm_to_geographic(easting: f64, northing: f64, zone: UTMZone) -> SpatialResult<(f64, f64)> {
209    let ellipsoid = Ellipsoid::WGS84;
210    let a = ellipsoid.a;
211    let e2 = ellipsoid.e2();
212    let e = e2.sqrt();
213    let k0 = 0.9996;
214
215    // Remove false easting/northing
216    let x = easting - 500000.0;
217    let mut y = northing;
218    if !zone.north {
219        y -= 10000000.0;
220    }
221
222    // Central meridian
223    let lon0 = ((zone.number as f64 - 1.0) * 6.0 - 180.0 + 3.0) * PI / 180.0;
224
225    // Footpoint latitude
226    let m = y / k0;
227    let mu = m / (a * (1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2.powi(3) / 256.0));
228
229    let e1 = (1.0 - (1.0 - e2).sqrt()) / (1.0 + (1.0 - e2).sqrt());
230    let phi1 = mu
231        + (3.0 * e1 / 2.0 - 27.0 * e1.powi(3) / 32.0) * (2.0 * mu).sin()
232        + (21.0 * e1 * e1 / 16.0 - 55.0 * e1.powi(4) / 32.0) * (4.0 * mu).sin()
233        + (151.0 * e1.powi(3) / 96.0) * (6.0 * mu).sin();
234
235    // Compute latitude and longitude
236    let n1 = a / (1.0 - e2 * phi1.sin().powi(2)).sqrt();
237    let t1 = phi1.tan();
238    let c1 = ellipsoid.e_prime2() * phi1.cos().powi(2);
239    let r1 = a * (1.0 - e2) / (1.0 - e2 * phi1.sin().powi(2)).powf(1.5);
240    let d = x / (n1 * k0);
241
242    let latitude = phi1
243        - (n1 * t1 / r1)
244            * (d * d / 2.0
245                - (5.0 + 3.0 * t1 * t1 + 10.0 * c1 - 4.0 * c1 * c1 - 9.0 * ellipsoid.e_prime2())
246                    * d.powi(4)
247                    / 24.0
248                + (61.0 + 90.0 * t1 * t1 + 298.0 * c1 + 45.0 * t1 * t1 * t1 * t1
249                    - 252.0 * ellipsoid.e_prime2()
250                    - 3.0 * c1 * c1)
251                    * d.powi(6)
252                    / 720.0);
253
254    let longitude = lon0
255        + (d - (1.0 + 2.0 * t1 * t1 + c1) * d.powi(3) / 6.0
256            + (5.0 - 2.0 * c1 + 28.0 * t1 * t1 - 3.0 * c1 * c1
257                + 8.0 * ellipsoid.e_prime2()
258                + 24.0 * t1 * t1 * t1 * t1)
259                * d.powi(5)
260                / 120.0)
261            / phi1.cos();
262
263    Ok((latitude * 180.0 / PI, longitude * 180.0 / PI))
264}
265
266/// Compute meridional arc length
267fn meridional_arc(lat_rad: f64, ellipsoid: &Ellipsoid) -> f64 {
268    let a = ellipsoid.a;
269    let e2 = ellipsoid.e2();
270
271    let m0 = a * (1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2.powi(3) / 256.0);
272    let m2 = a * (3.0 * e2 / 8.0 + 3.0 * e2 * e2 / 32.0 + 45.0 * e2.powi(3) / 1024.0);
273    let m4 = a * (15.0 * e2 * e2 / 256.0 + 45.0 * e2.powi(3) / 1024.0);
274    let m6 = a * (35.0 * e2.powi(3) / 3072.0);
275
276    m0 * lat_rad - m2 * (2.0 * lat_rad).sin() + m4 * (4.0 * lat_rad).sin()
277        - m6 * (6.0 * lat_rad).sin()
278}
279
280/// Convert geographic coordinates to Web Mercator (EPSG:3857)
281///
282/// Used by most web mapping services (Google Maps, OpenStreetMap, etc.)
283///
284/// # Arguments
285///
286/// * `latitude` - Latitude in decimal degrees (-85.0511 to 85.0511)
287/// * `longitude` - Longitude in decimal degrees
288///
289/// # Returns
290///
291/// * Tuple of (x, y) in Web Mercator meters
292pub fn geographic_to_web_mercator(latitude: f64, longitude: f64) -> SpatialResult<(f64, f64)> {
293    const MAX_LAT: f64 = 85.051_128_779_806_59; // Maximum latitude for Web Mercator
294
295    if latitude.abs() > MAX_LAT {
296        return Err(SpatialError::ValueError(format!(
297            "Latitude must be between -{} and {} degrees for Web Mercator",
298            MAX_LAT, MAX_LAT
299        )));
300    }
301
302    let r = 6378137.0; // WGS84 equatorial radius
303
304    let x = r * longitude * PI / 180.0;
305    let y = r * ((PI / 4.0 + latitude * PI / 360.0).tan().ln());
306
307    Ok((x, y))
308}
309
310/// Convert Web Mercator coordinates to geographic
311///
312/// # Arguments
313///
314/// * `x` - X coordinate in meters
315/// * `y` - Y coordinate in meters
316///
317/// # Returns
318///
319/// * Tuple of (latitude, longitude) in decimal degrees
320pub fn web_mercator_to_geographic(x: f64, y: f64) -> SpatialResult<(f64, f64)> {
321    let r = 6378137.0;
322
323    let longitude = x / r * 180.0 / PI;
324    let latitude = (2.0 * (y / r).exp().atan() - PI / 2.0) * 180.0 / PI;
325
326    Ok((latitude, longitude))
327}
328
329/// Lambert Conformal Conic projection
330///
331/// # Arguments
332///
333/// * `latitude` - Latitude in decimal degrees
334/// * `longitude` - Longitude in decimal degrees
335/// * `lat0` - Origin latitude
336/// * `lon0` - Central meridian
337/// * `lat1` - First standard parallel
338/// * `lat2` - Second standard parallel
339///
340/// # Returns
341///
342/// * Tuple of (x, y) in meters
343pub fn lambert_conformal_conic(
344    latitude: f64,
345    longitude: f64,
346    lat0: f64,
347    lon0: f64,
348    lat1: f64,
349    lat2: f64,
350) -> SpatialResult<(f64, f64)> {
351    let ellipsoid = Ellipsoid::WGS84;
352    let a = ellipsoid.a;
353    let e = ellipsoid.e2().sqrt();
354
355    // Convert to radians
356    let lat = latitude * PI / 180.0;
357    let lon = longitude * PI / 180.0;
358    let lat_0 = lat0 * PI / 180.0;
359    let lon_0 = lon0 * PI / 180.0;
360    let lat_1 = lat1 * PI / 180.0;
361    let lat_2 = lat2 * PI / 180.0;
362
363    // Compute projection constants
364    let m1 = lat_1.cos() / (1.0 - e * e * lat_1.sin().powi(2)).sqrt();
365    let m2 = lat_2.cos() / (1.0 - e * e * lat_2.sin().powi(2)).sqrt();
366
367    let t = |phi: f64| {
368        (PI / 4.0 - phi / 2.0).tan() / ((1.0 - e * phi.sin()) / (1.0 + e * phi.sin())).powf(e / 2.0)
369    };
370
371    let t1 = t(lat_1);
372    let t2 = t(lat_2);
373    let t0 = t(lat_0);
374    let t_phi = t(lat);
375
376    let n = (m1.ln() - m2.ln()) / (t1.ln() - t2.ln());
377    let f = m1 / (n * t1.powf(n));
378    let rho_0 = a * f * t0.powf(n);
379    let rho = a * f * t_phi.powf(n);
380
381    let theta = n * (lon - lon_0);
382
383    let x = rho * theta.sin();
384    let y = rho_0 - rho * theta.cos();
385
386    Ok((x, y))
387}
388
389/// Albers Equal Area Conic projection
390///
391/// # Arguments
392///
393/// * `latitude` - Latitude in decimal degrees
394/// * `longitude` - Longitude in decimal degrees
395/// * `lat0` - Origin latitude
396/// * `lon0` - Central meridian
397/// * `lat1` - First standard parallel
398/// * `lat2` - Second standard parallel
399///
400/// # Returns
401///
402/// * Tuple of (x, y) in meters
403pub fn albers_equal_area(
404    latitude: f64,
405    longitude: f64,
406    lat0: f64,
407    lon0: f64,
408    lat1: f64,
409    lat2: f64,
410) -> SpatialResult<(f64, f64)> {
411    let ellipsoid = Ellipsoid::WGS84;
412    let a = ellipsoid.a;
413    let e = ellipsoid.e2().sqrt();
414
415    // Convert to radians
416    let lat = latitude * PI / 180.0;
417    let lon = longitude * PI / 180.0;
418    let lat_0 = lat0 * PI / 180.0;
419    let lon_0 = lon0 * PI / 180.0;
420    let lat_1 = lat1 * PI / 180.0;
421    let lat_2 = lat2 * PI / 180.0;
422
423    // Compute projection constants
424    let q = |phi: f64| {
425        let sin_phi = phi.sin();
426        (1.0 - e * e)
427            * (sin_phi / (1.0 - e * e * sin_phi.powi(2))
428                - (1.0 / (2.0 * e)) * ((1.0 - e * sin_phi) / (1.0 + e * sin_phi)).ln())
429    };
430
431    let m = |phi: f64| phi.cos() / (1.0 - e * e * phi.sin().powi(2)).sqrt();
432
433    let q0 = q(lat_0);
434    let q1 = q(lat_1);
435    let q2 = q(lat_2);
436    let q_phi = q(lat);
437
438    let m1 = m(lat_1);
439    let m2 = m(lat_2);
440
441    let n = (m1.powi(2) - m2.powi(2)) / (q2 - q1);
442    let c = m1.powi(2) + n * q1;
443    let rho_0 = a * (c - n * q0).sqrt() / n;
444    let rho = a * (c - n * q_phi).sqrt() / n;
445
446    let theta = n * (lon - lon_0);
447
448    let x = rho * theta.sin();
449    let y = rho_0 - rho * theta.cos();
450
451    Ok((x, y))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use approx::assert_relative_eq;
458
459    #[test]
460    fn test_geographic_to_utm() {
461        // New York City
462        let (zone, easting, northing) =
463            geographic_to_utm(40.7128, -74.0060).expect("Failed to convert");
464
465        assert_eq!(zone.number, 18);
466        assert!(zone.north);
467        assert!(easting > 500000.0 && easting < 600000.0);
468        assert!(northing > 4500000.0 && northing < 4600000.0);
469    }
470
471    #[test]
472    fn test_utm_roundtrip() {
473        let lat = 40.7128;
474        let lon = -74.0060;
475
476        let (zone, easting, northing) =
477            geographic_to_utm(lat, lon).expect("Failed to convert to UTM");
478
479        let (lat2, lon2) =
480            utm_to_geographic(easting, northing, zone).expect("Failed to convert from UTM");
481
482        assert_relative_eq!(lat, lat2, epsilon = 1e-6);
483        assert_relative_eq!(lon, lon2, epsilon = 1e-6);
484    }
485
486    #[test]
487    fn test_geographic_to_web_mercator() {
488        let (x, y) = geographic_to_web_mercator(0.0, 0.0).expect("Failed to convert");
489
490        // At the equator and prime meridian, both should be 0
491        assert_relative_eq!(x, 0.0, epsilon = 1.0);
492        assert_relative_eq!(y, 0.0, epsilon = 1.0);
493    }
494
495    #[test]
496    fn test_web_mercator_roundtrip() {
497        let lat = 40.7128;
498        let lon = -74.0060;
499
500        let (x, y) =
501            geographic_to_web_mercator(lat, lon).expect("Failed to convert to Web Mercator");
502
503        let (lat2, lon2) =
504            web_mercator_to_geographic(x, y).expect("Failed to convert from Web Mercator");
505
506        assert_relative_eq!(lat, lat2, epsilon = 1e-6);
507        assert_relative_eq!(lon, lon2, epsilon = 1e-6);
508    }
509
510    #[test]
511    fn test_utm_zone_calculation() {
512        // Test various locations
513        let test_cases = vec![
514            (40.7128, -74.0060, 18),  // New York
515            (51.5074, -0.1278, 30),   // London
516            (35.6762, 139.6503, 54),  // Tokyo
517            (-33.8688, 151.2093, 56), // Sydney
518        ];
519
520        for (lat, lon, expected_zone) in test_cases {
521            let (zone, _, _) = geographic_to_utm(lat, lon).expect("Failed to convert");
522            assert_eq!(
523                zone.number, expected_zone,
524                "Wrong zone for ({}, {})",
525                lat, lon
526            );
527        }
528    }
529
530    #[test]
531    fn test_lambert_conformal_conic() {
532        let result = lambert_conformal_conic(
533            40.0, -100.0, // Point
534            35.0, -100.0, // Origin
535            33.0, 45.0, // Standard parallels
536        );
537
538        assert!(result.is_ok());
539        let (x, y) = result.expect("computation failed");
540        // Just verify we get reasonable values
541        assert!(x.abs() < 10_000_000.0);
542        assert!(y.abs() < 10_000_000.0);
543    }
544
545    #[test]
546    fn test_albers_equal_area() {
547        let result = albers_equal_area(
548            40.0, -100.0, // Point
549            23.0, -96.0, // Origin
550            29.5, 45.5, // Standard parallels
551        );
552
553        assert!(result.is_ok());
554        let (x, y) = result.expect("computation failed");
555        // Verify reasonable values
556        assert!(x.abs() < 10_000_000.0);
557        assert!(y.abs() < 10_000_000.0);
558    }
559
560    #[test]
561    fn test_ellipsoid_parameters() {
562        let wgs84 = Ellipsoid::WGS84;
563
564        // Semi-minor axis should be less than semi-major
565        assert!(wgs84.b() < wgs84.a);
566
567        // Eccentricity squared should be small
568        assert!(wgs84.e2() > 0.0 && wgs84.e2() < 0.01);
569    }
570}