Skip to main content

oxigdal_algorithms/vector/
transform.rs

1//! Coordinate transformation operations for vector geometries
2//!
3//! This module provides coordinate transformation capabilities for converting
4//! geometries between different coordinate reference systems (CRS).
5//!
6//! # Features
7//!
8//! - **Point Transformation**: Transform individual points between CRS
9//! - **Geometry Transformation**: Transform entire geometries (LineString, Polygon, etc.)
10//! - **Batch Transformation**: Efficiently transform multiple coordinates at once
11//! - **Projection Support**: Support for common projections (WGS84, Web Mercator, UTM, etc.)
12//!
13//! # Examples
14//!
15//! ```
16//! use oxigdal_algorithms::vector::{Point, Coordinate, transform_point};
17//!
18//! // Transform from WGS84 (EPSG:4326) to Web Mercator (EPSG:3857)
19//! let wgs84_point = Point::new(-122.4194, 37.7749); // San Francisco
20//! # // In real usage, you would transform like this:
21//! # // let web_mercator = transform_point(&wgs84_point, "EPSG:4326", "EPSG:3857").unwrap();
22//! ```
23
24use crate::error::{AlgorithmError, Result};
25use oxigdal_core::vector::{
26    Coordinate, Geometry, GeometryCollection, LineString, MultiLineString, MultiPoint,
27    MultiPolygon, Point, Polygon,
28};
29
30#[cfg(feature = "std")]
31use std::vec::Vec;
32
33/// Common coordinate reference systems
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum CommonCrs {
36    /// WGS84 geographic coordinates (latitude/longitude)
37    Wgs84,
38    /// Web Mercator (used by Google Maps, OpenStreetMap)
39    WebMercator,
40    /// UTM Zone (specify zone number and hemisphere)
41    Utm { zone: u8, north: bool },
42}
43
44impl CommonCrs {
45    /// Returns the EPSG code for this CRS
46    pub fn epsg_code(&self) -> String {
47        match self {
48            Self::Wgs84 => "EPSG:4326".to_string(),
49            Self::WebMercator => "EPSG:3857".to_string(),
50            Self::Utm { zone, north } => {
51                if *north {
52                    format!("EPSG:326{:02}", zone)
53                } else {
54                    format!("EPSG:327{:02}", zone)
55                }
56            }
57        }
58    }
59}
60
61/// Transformer for coordinate reference system conversions
62///
63/// When the `crs-transform` feature is enabled, this uses the `oxigdal-proj`
64/// backend (pure-Rust proj4rs) for arbitrary CRS pairs.  The two hardcoded
65/// paths (WGS84↔Web Mercator) are kept as a fallback so that the API behaves
66/// identically when the feature is disabled.
67pub struct CrsTransformer {
68    source_crs: String,
69    target_crs: String,
70    #[cfg(feature = "crs-transform")]
71    proj_transformer: Option<oxigdal_proj::Transformer>,
72}
73
74impl CrsTransformer {
75    /// Creates a new CRS transformer
76    ///
77    /// # Arguments
78    ///
79    /// * `source_crs` - Source CRS (e.g., "EPSG:4326")
80    /// * `target_crs` - Target CRS (e.g., "EPSG:3857")
81    ///
82    /// # Returns
83    ///
84    /// A new transformer
85    ///
86    /// # Errors
87    ///
88    /// Returns error if CRS definitions are invalid
89    pub fn new(source_crs: impl Into<String>, target_crs: impl Into<String>) -> Result<Self> {
90        let source = source_crs.into();
91        let target = target_crs.into();
92
93        // Validate CRS strings
94        if source.is_empty() || target.is_empty() {
95            return Err(AlgorithmError::InvalidParameter {
96                parameter: "crs",
97                message: "CRS definition cannot be empty".to_string(),
98            });
99        }
100
101        #[cfg(feature = "crs-transform")]
102        let proj_transformer = Self::build_proj_transformer(&source, &target);
103
104        Ok(Self {
105            source_crs: source,
106            target_crs: target,
107            #[cfg(feature = "crs-transform")]
108            proj_transformer,
109        })
110    }
111
112    /// Attempts to construct an `oxigdal_proj::Transformer` from CRS strings.
113    ///
114    /// The constructor is deliberately infallible from the caller's perspective:
115    /// any initialisation failure is logged at debug level and returns `None`,
116    /// which causes `transform_coordinate` to fall back to the hardcoded paths.
117    #[cfg(feature = "crs-transform")]
118    fn build_proj_transformer(source: &str, target: &str) -> Option<oxigdal_proj::Transformer> {
119        let src_crs = Self::parse_crs_string(source).ok()?;
120        let tgt_crs = Self::parse_crs_string(target).ok()?;
121        match oxigdal_proj::Transformer::new(src_crs, tgt_crs) {
122            Ok(t) => Some(t.with_strict(false)),
123            Err(e) => {
124                tracing::debug!(
125                    "oxigdal-proj: could not initialise transformer {} → {}: {}",
126                    source,
127                    target,
128                    e
129                );
130                None
131            }
132        }
133    }
134
135    /// Parses a CRS identifier string into an `oxigdal_proj::Crs`.
136    ///
137    /// Recognised formats:
138    /// - `EPSG:<code>` (case-insensitive)
139    /// - Any string starting with `+proj=` (PROJ string)
140    /// - Everything else is passed to `Crs::from_wkt`
141    #[cfg(feature = "crs-transform")]
142    fn parse_crs_string(s: &str) -> core::result::Result<oxigdal_proj::Crs, oxigdal_proj::Error> {
143        let upper = s.trim().to_uppercase();
144        if let Some(code_str) = upper.strip_prefix("EPSG:") {
145            let code = code_str
146                .trim()
147                .parse::<u32>()
148                .map_err(|_| oxigdal_proj::Error::invalid_epsg_code(0))?;
149            oxigdal_proj::Crs::from_epsg(code)
150        } else if upper.starts_with("+PROJ=") || upper.starts_with("+proj=") {
151            oxigdal_proj::Crs::from_proj(s)
152        } else {
153            oxigdal_proj::Crs::from_wkt(s)
154        }
155    }
156
157    /// Creates a transformer from common CRS types
158    pub fn from_common(source: CommonCrs, target: CommonCrs) -> Result<Self> {
159        Self::new(source.epsg_code(), target.epsg_code())
160    }
161
162    /// Transforms a single coordinate
163    ///
164    /// # Arguments
165    ///
166    /// * `coord` - Input coordinate in source CRS
167    ///
168    /// # Returns
169    ///
170    /// Transformed coordinate in target CRS
171    ///
172    /// # Errors
173    ///
174    /// Returns error if transformation fails
175    pub fn transform_coordinate(&self, coord: &Coordinate) -> Result<Coordinate> {
176        // Special case: Identity transformation — always fast-path, no proj needed.
177        if self.source_crs == self.target_crs {
178            return Ok(*coord);
179        }
180
181        // When the `crs-transform` feature is active and the proj backend was
182        // initialised successfully, delegate all non-identity transformations to
183        // proj4rs (via oxigdal-proj).  The hardcoded paths below are only reached
184        // when the feature is disabled or proj initialisation failed for this pair.
185        #[cfg(feature = "crs-transform")]
186        if let Some(ref t) = self.proj_transformer {
187            return Self::transform_via_proj(t, coord);
188        }
189
190        // Hardcoded fast paths (feature off, or proj backend unavailable for pair).
191
192        // WGS84 to Web Mercator (common transformation)
193        if self.source_crs == "EPSG:4326" && self.target_crs == "EPSG:3857" {
194            return self.wgs84_to_web_mercator(coord);
195        }
196
197        // Web Mercator to WGS84
198        if self.source_crs == "EPSG:3857" && self.target_crs == "EPSG:4326" {
199            return self.web_mercator_to_wgs84(coord);
200        }
201
202        // For other transformations, proj integration is required
203        Err(AlgorithmError::UnsupportedOperation {
204            operation: format!(
205                "Coordinate transformation from {} to {} (requires crs-transform feature)",
206                self.source_crs, self.target_crs
207            ),
208        })
209    }
210
211    /// Delegates a single coordinate transformation to `oxigdal_proj::Transformer`.
212    ///
213    /// Error mapping:
214    /// - `OutOfAreaOfUse` → coordinate returned unchanged (logged at debug level, not an error).
215    ///   This preserves backward-compatibility: callers that worked without the proj backend
216    ///   would have received no area-of-use validation at all.
217    /// - All other errors → `AlgorithmError::UnsupportedOperation`.
218    #[cfg(feature = "crs-transform")]
219    fn transform_via_proj(t: &oxigdal_proj::Transformer, coord: &Coordinate) -> Result<Coordinate> {
220        let proj_coord = oxigdal_proj::Coordinate::new(coord.x, coord.y);
221        match t.transform(&proj_coord) {
222            Ok(out) => Ok(Coordinate::new_2d(out.x, out.y)),
223            Err(oxigdal_proj::Error::OutOfAreaOfUse { lon, lat, .. }) => {
224                tracing::debug!(
225                    "oxigdal-proj: point ({}, {}) is outside area of use — returning unchanged",
226                    lon,
227                    lat
228                );
229                Ok(*coord)
230            }
231            Err(e) => Err(AlgorithmError::UnsupportedOperation {
232                operation: format!("Coordinate transformation failed: {}", e),
233            }),
234        }
235    }
236
237    /// Transforms multiple coordinates efficiently
238    pub fn transform_coordinates(&self, coords: &[Coordinate]) -> Result<Vec<Coordinate>> {
239        coords
240            .iter()
241            .map(|c| self.transform_coordinate(c))
242            .collect()
243    }
244
245    /// Transforms a point
246    pub fn transform_point(&self, point: &Point) -> Result<Point> {
247        let transformed = self.transform_coordinate(&point.coord)?;
248        Ok(Point::from_coord(transformed))
249    }
250
251    /// Transforms a linestring
252    pub fn transform_linestring(&self, linestring: &LineString) -> Result<LineString> {
253        let coords = self.transform_coordinates(&linestring.coords)?;
254        LineString::new(coords).map_err(|e| AlgorithmError::GeometryError {
255            message: format!("Failed to create transformed linestring: {}", e),
256        })
257    }
258
259    /// Transforms a polygon
260    pub fn transform_polygon(&self, polygon: &Polygon) -> Result<Polygon> {
261        let exterior_coords = self.transform_coordinates(&polygon.exterior.coords)?;
262        let exterior =
263            LineString::new(exterior_coords).map_err(|e| AlgorithmError::GeometryError {
264                message: format!("Failed to create transformed exterior ring: {}", e),
265            })?;
266
267        let mut interiors = Vec::new();
268        for hole in &polygon.interiors {
269            let hole_coords = self.transform_coordinates(&hole.coords)?;
270            let hole_ring =
271                LineString::new(hole_coords).map_err(|e| AlgorithmError::GeometryError {
272                    message: format!("Failed to create transformed interior ring: {}", e),
273                })?;
274            interiors.push(hole_ring);
275        }
276
277        Polygon::new(exterior, interiors).map_err(|e| AlgorithmError::GeometryError {
278            message: format!("Failed to create transformed polygon: {}", e),
279        })
280    }
281
282    /// Transforms a geometry
283    pub fn transform_geometry(&self, geometry: &Geometry) -> Result<Geometry> {
284        match geometry {
285            Geometry::Point(p) => Ok(Geometry::Point(self.transform_point(p)?)),
286            Geometry::LineString(ls) => Ok(Geometry::LineString(self.transform_linestring(ls)?)),
287            Geometry::Polygon(poly) => Ok(Geometry::Polygon(self.transform_polygon(poly)?)),
288            Geometry::MultiPoint(mp) => {
289                let mut points = Vec::new();
290                for point in &mp.points {
291                    points.push(self.transform_point(point)?);
292                }
293                Ok(Geometry::MultiPoint(MultiPoint { points }))
294            }
295            Geometry::MultiLineString(mls) => {
296                let mut line_strings = Vec::new();
297                for ls in &mls.line_strings {
298                    line_strings.push(self.transform_linestring(ls)?);
299                }
300                Ok(Geometry::MultiLineString(MultiLineString { line_strings }))
301            }
302            Geometry::MultiPolygon(mp) => {
303                let mut polygons = Vec::new();
304                for poly in &mp.polygons {
305                    polygons.push(self.transform_polygon(poly)?);
306                }
307                Ok(Geometry::MultiPolygon(MultiPolygon { polygons }))
308            }
309            Geometry::GeometryCollection(gc) => {
310                let mut geometries = Vec::new();
311                for geom in &gc.geometries {
312                    geometries.push(self.transform_geometry(geom)?);
313                }
314                Ok(Geometry::GeometryCollection(GeometryCollection {
315                    geometries,
316                }))
317            }
318        }
319    }
320
321    /// WGS84 (EPSG:4326) to Web Mercator (EPSG:3857) transformation
322    fn wgs84_to_web_mercator(&self, coord: &Coordinate) -> Result<Coordinate> {
323        const EARTH_RADIUS: f64 = 6_378_137.0;
324
325        // Validate latitude range
326        if !(-90.0..=90.0).contains(&coord.y) {
327            return Err(AlgorithmError::InvalidParameter {
328                parameter: "latitude",
329                message: format!("Latitude {} is out of range [-90, 90]", coord.y),
330            });
331        }
332
333        // Web Mercator doesn't work well near poles
334        if coord.y.abs() > 85.0511 {
335            return Err(AlgorithmError::InvalidParameter {
336                parameter: "latitude",
337                message: format!(
338                    "Latitude {} is too close to poles for Web Mercator (max ±85.0511°)",
339                    coord.y
340                ),
341            });
342        }
343
344        let lon_rad = coord.x.to_radians();
345        let lat_rad = coord.y.to_radians();
346
347        let x = EARTH_RADIUS * lon_rad;
348        let y = EARTH_RADIUS * ((std::f64::consts::PI / 4.0 + lat_rad / 2.0).tan().ln());
349
350        Ok(Coordinate::new_2d(x, y))
351    }
352
353    /// Web Mercator (EPSG:3857) to WGS84 (EPSG:4326) transformation
354    fn web_mercator_to_wgs84(&self, coord: &Coordinate) -> Result<Coordinate> {
355        const EARTH_RADIUS: f64 = 6_378_137.0;
356
357        let lon = (coord.x / EARTH_RADIUS).to_degrees();
358        let lat =
359            (2.0 * (coord.y / EARTH_RADIUS).exp().atan() - std::f64::consts::PI / 2.0).to_degrees();
360
361        // Clamp to valid ranges
362        let lon = lon.clamp(-180.0, 180.0);
363        let lat = lat.clamp(-90.0, 90.0);
364
365        Ok(Coordinate::new_2d(lon, lat))
366    }
367}
368
369/// Transforms a point between coordinate reference systems
370///
371/// # Arguments
372///
373/// * `point` - Input point
374/// * `source_crs` - Source CRS (e.g., "EPSG:4326")
375/// * `target_crs` - Target CRS (e.g., "EPSG:3857")
376///
377/// # Returns
378///
379/// Transformed point
380///
381/// # Errors
382///
383/// Returns error if transformation fails
384pub fn transform_point(point: &Point, source_crs: &str, target_crs: &str) -> Result<Point> {
385    let transformer = CrsTransformer::new(source_crs, target_crs)?;
386    transformer.transform_point(point)
387}
388
389/// Transforms a linestring between coordinate reference systems
390pub fn transform_linestring(
391    linestring: &LineString,
392    source_crs: &str,
393    target_crs: &str,
394) -> Result<LineString> {
395    let transformer = CrsTransformer::new(source_crs, target_crs)?;
396    transformer.transform_linestring(linestring)
397}
398
399/// Transforms a polygon between coordinate reference systems
400pub fn transform_polygon(polygon: &Polygon, source_crs: &str, target_crs: &str) -> Result<Polygon> {
401    let transformer = CrsTransformer::new(source_crs, target_crs)?;
402    transformer.transform_polygon(polygon)
403}
404
405/// Transforms a geometry between coordinate reference systems
406pub fn transform_geometry(
407    geometry: &Geometry,
408    source_crs: &str,
409    target_crs: &str,
410) -> Result<Geometry> {
411    let transformer = CrsTransformer::new(source_crs, target_crs)?;
412    transformer.transform_geometry(geometry)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_common_crs_epsg_codes() {
421        assert_eq!(CommonCrs::Wgs84.epsg_code(), "EPSG:4326");
422        assert_eq!(CommonCrs::WebMercator.epsg_code(), "EPSG:3857");
423        assert_eq!(
424            CommonCrs::Utm {
425                zone: 10,
426                north: true
427            }
428            .epsg_code(),
429            "EPSG:32610"
430        );
431        assert_eq!(
432            CommonCrs::Utm {
433                zone: 33,
434                north: false
435            }
436            .epsg_code(),
437            "EPSG:32733"
438        );
439    }
440
441    #[test]
442    fn test_transformer_creation() {
443        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
444        assert!(transformer.is_ok());
445
446        let empty = CrsTransformer::new("", "EPSG:3857");
447        assert!(empty.is_err());
448    }
449
450    #[test]
451    fn test_identity_transformation() {
452        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:4326");
453        assert!(transformer.is_ok());
454
455        if let Ok(t) = transformer {
456            let coord = Coordinate::new_2d(10.0, 20.0);
457            let result = t.transform_coordinate(&coord);
458            assert!(result.is_ok());
459
460            if let Ok(transformed) = result {
461                assert!((transformed.x - 10.0).abs() < f64::EPSILON);
462                assert!((transformed.y - 20.0).abs() < f64::EPSILON);
463            }
464        }
465    }
466
467    #[test]
468    fn test_wgs84_to_web_mercator() {
469        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
470        assert!(transformer.is_ok());
471
472        if let Ok(t) = transformer {
473            // Transform origin (0, 0)
474            let origin = Coordinate::new_2d(0.0, 0.0);
475            let result = t.transform_coordinate(&origin);
476            assert!(result.is_ok());
477
478            if let Ok(transformed) = result {
479                assert!(transformed.x.abs() < 1.0);
480                assert!(transformed.y.abs() < 1.0);
481            }
482
483            // Transform San Francisco
484            let sf = Coordinate::new_2d(-122.4194, 37.7749);
485            let result = t.transform_coordinate(&sf);
486            assert!(result.is_ok());
487
488            if let Ok(transformed) = result {
489                // Web Mercator x should be negative (west of prime meridian)
490                assert!(transformed.x < 0.0);
491                // y should be positive (north of equator)
492                assert!(transformed.y > 0.0);
493            }
494        }
495    }
496
497    #[test]
498    fn test_web_mercator_to_wgs84() {
499        let transformer = CrsTransformer::new("EPSG:3857", "EPSG:4326");
500        assert!(transformer.is_ok());
501
502        if let Ok(t) = transformer {
503            // Transform origin
504            let origin = Coordinate::new_2d(0.0, 0.0);
505            let result = t.transform_coordinate(&origin);
506            assert!(result.is_ok());
507
508            if let Ok(transformed) = result {
509                assert!(transformed.x.abs() < 1e-6);
510                assert!(transformed.y.abs() < 1e-6);
511            }
512        }
513    }
514
515    #[test]
516    fn test_round_trip_transformation() {
517        let to_merc = CrsTransformer::new("EPSG:4326", "EPSG:3857");
518        let to_wgs = CrsTransformer::new("EPSG:3857", "EPSG:4326");
519
520        assert!(to_merc.is_ok());
521        assert!(to_wgs.is_ok());
522
523        if let (Ok(t1), Ok(t2)) = (to_merc, to_wgs) {
524            let original = Coordinate::new_2d(-122.4194, 37.7749);
525
526            let merc = t1.transform_coordinate(&original);
527            assert!(merc.is_ok());
528
529            if let Ok(m) = merc {
530                let back = t2.transform_coordinate(&m);
531                assert!(back.is_ok());
532
533                if let Ok(b) = back {
534                    // Should be close to original (within tolerance)
535                    assert!((b.x - original.x).abs() < 1e-6);
536                    assert!((b.y - original.y).abs() < 1e-6);
537                }
538            }
539        }
540    }
541
542    #[test]
543    fn test_transform_point() {
544        let point = Point::new(-122.4194, 37.7749);
545        let result = transform_point(&point, "EPSG:4326", "EPSG:3857");
546        assert!(result.is_ok());
547
548        if let Ok(transformed) = result {
549            assert!(transformed.coord.x < 0.0);
550            assert!(transformed.coord.y > 0.0);
551        }
552    }
553
554    #[test]
555    fn test_transform_linestring() {
556        let coords = vec![
557            Coordinate::new_2d(0.0, 0.0),
558            Coordinate::new_2d(1.0, 1.0),
559            Coordinate::new_2d(2.0, 2.0),
560        ];
561        let linestring = LineString::new(coords);
562        assert!(linestring.is_ok());
563
564        if let Ok(ls) = linestring {
565            let result = transform_linestring(&ls, "EPSG:4326", "EPSG:3857");
566            assert!(result.is_ok());
567
568            if let Ok(transformed) = result {
569                assert_eq!(transformed.coords.len(), 3);
570            }
571        }
572    }
573
574    #[test]
575    fn test_transform_polygon() {
576        let coords = vec![
577            Coordinate::new_2d(0.0, 0.0),
578            Coordinate::new_2d(4.0, 0.0),
579            Coordinate::new_2d(4.0, 4.0),
580            Coordinate::new_2d(0.0, 4.0),
581            Coordinate::new_2d(0.0, 0.0),
582        ];
583        let exterior = LineString::new(coords);
584        assert!(exterior.is_ok());
585
586        if let Ok(ext) = exterior {
587            let polygon = Polygon::new(ext, vec![]);
588            assert!(polygon.is_ok());
589
590            if let Ok(poly) = polygon {
591                let result = transform_polygon(&poly, "EPSG:4326", "EPSG:3857");
592                assert!(result.is_ok());
593
594                if let Ok(transformed) = result {
595                    assert_eq!(transformed.exterior.coords.len(), 5);
596                }
597            }
598        }
599    }
600
601    #[test]
602    fn test_invalid_latitude() {
603        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
604        assert!(transformer.is_ok());
605
606        if let Ok(t) = transformer {
607            // Latitude truly out of range (>90°): the Mercator formula produces
608            // non-finite values, so this must error regardless of backend.
609            let invalid = Coordinate::new_2d(0.0, 95.0);
610            let result = t.transform_coordinate(&invalid);
611            assert!(result.is_err(), "lat=95 must be rejected");
612
613            // lat=89° is geometrically valid for WGS84 and within the domain of
614            // Web Mercator when using a proper proj backend (which can compute it
615            // accurately).  The hardcoded fallback imposes a tighter ±85.0511°
616            // limit for safety, so we only assert the stricter check when the
617            // proj backend is not active.
618            #[cfg(not(feature = "crs-transform"))]
619            {
620                let near_pole = Coordinate::new_2d(0.0, 89.0);
621                let result = t.transform_coordinate(&near_pole);
622                assert!(
623                    result.is_err(),
624                    "lat=89 must be rejected by hardcoded fallback"
625                );
626            }
627        }
628    }
629
630    #[test]
631    fn test_batch_transformation() {
632        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
633        assert!(transformer.is_ok());
634
635        if let Ok(t) = transformer {
636            let coords = vec![
637                Coordinate::new_2d(0.0, 0.0),
638                Coordinate::new_2d(1.0, 1.0),
639                Coordinate::new_2d(-1.0, -1.0),
640            ];
641
642            let result = t.transform_coordinates(&coords);
643            assert!(result.is_ok());
644
645            if let Ok(transformed) = result {
646                assert_eq!(transformed.len(), 3);
647            }
648        }
649    }
650
651    #[test]
652    fn test_from_common_crs() {
653        let transformer = CrsTransformer::from_common(CommonCrs::Wgs84, CommonCrs::WebMercator);
654        assert!(transformer.is_ok());
655
656        if let Ok(t) = transformer {
657            assert_eq!(t.source_crs, "EPSG:4326");
658            assert_eq!(t.target_crs, "EPSG:3857");
659        }
660    }
661
662    // -----------------------------------------------------------------------
663    // New tests for the crs-transform feature (proj backend integration)
664    // -----------------------------------------------------------------------
665
666    /// Identity transformation: source == target == EPSG:4326.
667    /// Output coordinate must be numerically identical to input.
668    #[test]
669    fn test_crs_transformer_wgs84_identity_passthrough() {
670        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:4326")
671            .expect("identity transformer must construct");
672
673        let input = Coordinate::new_2d(13.4050, 52.5200); // Berlin
674        let output = transformer
675            .transform_coordinate(&input)
676            .expect("identity transform must succeed");
677
678        assert!(
679            (output.x - input.x).abs() < f64::EPSILON,
680            "x must be unchanged: {} != {}",
681            output.x,
682            input.x
683        );
684        assert!(
685            (output.y - input.y).abs() < f64::EPSILON,
686            "y must be unchanged: {} != {}",
687            output.y,
688            input.y
689        );
690    }
691
692    /// A polygon with 5 vertices (closed ring) must produce exactly 5 output vertices
693    /// regardless of which CRS path is used.
694    #[test]
695    fn test_crs_transformer_polygon_preserves_vertex_count() {
696        let coords = vec![
697            Coordinate::new_2d(-10.0, -10.0),
698            Coordinate::new_2d(10.0, -10.0),
699            Coordinate::new_2d(10.0, 10.0),
700            Coordinate::new_2d(-10.0, 10.0),
701            Coordinate::new_2d(-10.0, -10.0), // closing vertex
702        ];
703        let exterior = LineString::new(coords).expect("linestring must construct");
704        let polygon = Polygon::new(exterior, vec![]).expect("polygon must construct");
705
706        // Use WGS84 → Web Mercator (always supported, with or without crs-transform feature)
707        let transformer =
708            CrsTransformer::new("EPSG:4326", "EPSG:3857").expect("transformer must construct");
709        let result = transformer
710            .transform_polygon(&polygon)
711            .expect("polygon transform must succeed");
712
713        assert_eq!(
714            result.exterior.coords.len(),
715            5,
716            "transformed polygon must retain 5 vertices"
717        );
718    }
719
720    /// When `crs-transform` feature is OFF (or proj fails for an exotic pair),
721    /// `CrsTransformer::new` must still succeed and `transform_coordinate` must
722    /// either return `UnsupportedOperation` or a valid result — never panic.
723    #[test]
724    fn test_crs_transformer_unknown_epsg_falls_back_gracefully() {
725        // EPSG:32637 is WGS 84 / UTM zone 37N.  Without the feature, the hardcoded
726        // paths don't cover it, so we expect either UnsupportedOperation or a
727        // successful transform (when proj backend is available).
728        let result = CrsTransformer::new("EPSG:4326", "EPSG:32637");
729        // Construction must always succeed
730        assert!(
731            result.is_ok(),
732            "CrsTransformer::new must not fail for any non-empty CRS string"
733        );
734
735        let transformer = result.expect("already asserted Ok above");
736        let coord = Coordinate::new_2d(37.0, 55.0);
737        let transform_result = transformer.transform_coordinate(&coord);
738
739        // Either UnsupportedOperation (no-feature) or a valid coordinate (with-feature).
740        // The invariant: it must NOT panic, and if Err it must be UnsupportedOperation.
741        if let Err(ref e) = transform_result {
742            assert!(
743                matches!(e, AlgorithmError::UnsupportedOperation { .. }),
744                "unexpected error variant: {:?}",
745                e
746            );
747        }
748        // If Ok, the coordinate must be finite
749        if let Ok(ref c) = transform_result {
750            assert!(c.x.is_finite() && c.y.is_finite(), "output must be finite");
751        }
752    }
753
754    /// WGS84 origin (0°, 0°) must map to Web Mercator origin (0, 0) — this is
755    /// a well-known property of the Mercator projection.
756    #[test]
757    fn test_crs_transformer_wgs84_to_webmercator_known_point() {
758        let transformer =
759            CrsTransformer::new("EPSG:4326", "EPSG:3857").expect("transformer must construct");
760
761        let origin = Coordinate::new_2d(0.0, 0.0);
762        let result = transformer
763            .transform_coordinate(&origin)
764            .expect("transform of origin must succeed");
765
766        assert!(
767            result.x.abs() < 1.0,
768            "Web Mercator X at lon=0 must be ~0, got {}",
769            result.x
770        );
771        assert!(
772            result.y.abs() < 1.0,
773            "Web Mercator Y at lat=0 must be ~0, got {}",
774            result.y
775        );
776    }
777}