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/// Policy for coordinates that fall outside the source CRS's declared area of use.
62///
63/// Some CRS pairs (via the `crs-transform` proj backend) can report that a point
64/// lies outside the region for which the transformation is defined. This policy
65/// controls what happens then.
66///
67/// The default is [`OutOfAreaPolicy::PassThrough`], preserving historical
68/// behaviour (the coordinate is returned unchanged, in the *source* CRS, with a
69/// debug-level log). Use [`OutOfAreaPolicy::Error`] to have such points fail
70/// loudly instead of silently mixing coordinate systems within one geometry, or
71/// [`CrsTransformer::transform_coordinates_reporting`] to detect which indices
72/// fell out of area without aborting.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum OutOfAreaPolicy {
75    /// Return the untransformed source coordinate (logged at debug level).
76    ///
77    /// Backward-compatible default. Note this can leave a multi-vertex geometry
78    /// with some vertices in the target CRS and some in the source CRS.
79    #[default]
80    PassThrough,
81    /// Return an error for any point outside the source CRS's area of use.
82    Error,
83}
84
85/// Transformer for coordinate reference system conversions
86///
87/// When the `crs-transform` feature is enabled, this uses the `oxigdal-proj`
88/// backend (pure-Rust proj4rs) for arbitrary CRS pairs.  The two hardcoded
89/// paths (WGS84↔Web Mercator) are kept as a fallback so that the API behaves
90/// identically when the feature is disabled.
91///
92/// # Area-of-use handling
93///
94/// A point that lies outside the source CRS's declared area of use is handled
95/// according to [`CrsTransformer`]'s `OutOfAreaPolicy` (default
96/// `OutOfAreaPolicy::PassThrough`). See [`CrsTransformer::with_out_of_area_policy`]
97/// and [`CrsTransformer::transform_coordinates_reporting`].
98pub struct CrsTransformer {
99    source_crs: String,
100    target_crs: String,
101    out_of_area_policy: OutOfAreaPolicy,
102    #[cfg(feature = "crs-transform")]
103    proj_transformer: Option<oxigdal_proj::Transformer>,
104}
105
106impl CrsTransformer {
107    /// Creates a new CRS transformer
108    ///
109    /// # Arguments
110    ///
111    /// * `source_crs` - Source CRS (e.g., "EPSG:4326")
112    /// * `target_crs` - Target CRS (e.g., "EPSG:3857")
113    ///
114    /// # Returns
115    ///
116    /// A new transformer
117    ///
118    /// # Errors
119    ///
120    /// Returns error if CRS definitions are invalid
121    pub fn new(source_crs: impl Into<String>, target_crs: impl Into<String>) -> Result<Self> {
122        let source = source_crs.into();
123        let target = target_crs.into();
124
125        // Validate CRS strings
126        if source.is_empty() || target.is_empty() {
127            return Err(AlgorithmError::InvalidParameter {
128                parameter: "crs",
129                message: "CRS definition cannot be empty".to_string(),
130            });
131        }
132
133        // Default policy is PassThrough, which corresponds to non-strict
134        // area-of-use validation in the proj backend.
135        #[cfg(feature = "crs-transform")]
136        let proj_transformer = Self::build_proj_transformer(&source, &target, false);
137
138        Ok(Self {
139            source_crs: source,
140            target_crs: target,
141            out_of_area_policy: OutOfAreaPolicy::default(),
142            #[cfg(feature = "crs-transform")]
143            proj_transformer,
144        })
145    }
146
147    /// Sets the policy for coordinates outside the source CRS's area of use.
148    ///
149    /// Returns `self` for builder-style chaining. Under
150    /// `OutOfAreaPolicy::Error`, the proj backend (when the `crs-transform`
151    /// feature is enabled) is rebuilt with strict area-of-use validation so that
152    /// out-of-area points are actually detected and surfaced as errors rather
153    /// than silently transformed or passed through.
154    pub fn with_out_of_area_policy(mut self, policy: OutOfAreaPolicy) -> Self {
155        self.out_of_area_policy = policy;
156        #[cfg(feature = "crs-transform")]
157        {
158            let strict = matches!(policy, OutOfAreaPolicy::Error);
159            self.proj_transformer =
160                Self::build_proj_transformer(&self.source_crs, &self.target_crs, strict);
161        }
162        self
163    }
164
165    /// Returns the currently configured `OutOfAreaPolicy`.
166    pub fn out_of_area_policy(&self) -> OutOfAreaPolicy {
167        self.out_of_area_policy
168    }
169
170    /// Attempts to construct an `oxigdal_proj::Transformer` from CRS strings.
171    ///
172    /// The constructor is deliberately infallible from the caller's perspective:
173    /// any initialisation failure is logged at debug level and returns `None`,
174    /// which causes `transform_coordinate` to fall back to the hardcoded paths.
175    #[cfg(feature = "crs-transform")]
176    fn build_proj_transformer(
177        source: &str,
178        target: &str,
179        strict: bool,
180    ) -> Option<oxigdal_proj::Transformer> {
181        let src_crs = Self::parse_crs_string(source).ok()?;
182        let tgt_crs = Self::parse_crs_string(target).ok()?;
183        match oxigdal_proj::Transformer::new(src_crs, tgt_crs) {
184            Ok(t) => Some(t.with_strict(strict)),
185            Err(e) => {
186                tracing::debug!(
187                    "oxigdal-proj: could not initialise transformer {} → {}: {}",
188                    source,
189                    target,
190                    e
191                );
192                None
193            }
194        }
195    }
196
197    /// Parses a CRS identifier string into an `oxigdal_proj::Crs`.
198    ///
199    /// Recognised formats:
200    /// - `EPSG:<code>` (case-insensitive)
201    /// - Any string starting with `+proj=` (PROJ string)
202    /// - Everything else is passed to `Crs::from_wkt`
203    #[cfg(feature = "crs-transform")]
204    fn parse_crs_string(s: &str) -> core::result::Result<oxigdal_proj::Crs, oxigdal_proj::Error> {
205        let upper = s.trim().to_uppercase();
206        if let Some(code_str) = upper.strip_prefix("EPSG:") {
207            let code = code_str
208                .trim()
209                .parse::<u32>()
210                .map_err(|_| oxigdal_proj::Error::invalid_epsg_code(0))?;
211            oxigdal_proj::Crs::from_epsg(code)
212        } else if upper.starts_with("+PROJ=") || upper.starts_with("+proj=") {
213            oxigdal_proj::Crs::from_proj(s)
214        } else {
215            oxigdal_proj::Crs::from_wkt(s)
216        }
217    }
218
219    /// Creates a transformer from common CRS types
220    pub fn from_common(source: CommonCrs, target: CommonCrs) -> Result<Self> {
221        Self::new(source.epsg_code(), target.epsg_code())
222    }
223
224    /// Transforms a single coordinate
225    ///
226    /// # Arguments
227    ///
228    /// * `coord` - Input coordinate in source CRS
229    ///
230    /// # Returns
231    ///
232    /// Transformed coordinate in target CRS
233    ///
234    /// # Area of use
235    ///
236    /// When the `crs-transform` feature is enabled, a point that lies outside
237    /// the source CRS's declared area of use is handled per the configured
238    /// `OutOfAreaPolicy` (see [`Self::with_out_of_area_policy`]). Under the
239    /// default `OutOfAreaPolicy::PassThrough` such a point is returned
240    /// **unchanged in the source CRS** (with a debug-level log); under
241    /// `OutOfAreaPolicy::Error` it produces an error. To transform a whole
242    /// geometry and learn which vertices fell out of area without aborting, use
243    /// [`Self::transform_coordinates_reporting`].
244    ///
245    /// # Errors
246    ///
247    /// Returns error if transformation fails, or if a coordinate is outside the
248    /// area of use and the policy is `OutOfAreaPolicy::Error`.
249    pub fn transform_coordinate(&self, coord: &Coordinate) -> Result<Coordinate> {
250        self.transform_coordinate_checked(coord).map(|(c, _)| c)
251    }
252
253    /// Transforms a single coordinate, also reporting whether it fell outside
254    /// the source CRS's area of use (and was therefore passed through).
255    ///
256    /// The boolean is always `false` unless the `crs-transform` feature is
257    /// enabled, the proj backend is active, and the point is out of area under
258    /// [`OutOfAreaPolicy::PassThrough`]. Under [`OutOfAreaPolicy::Error`] an
259    /// out-of-area point returns `Err` instead.
260    fn transform_coordinate_checked(&self, coord: &Coordinate) -> Result<(Coordinate, bool)> {
261        // Special case: Identity transformation — always fast-path, no proj needed.
262        if self.source_crs == self.target_crs {
263            return Ok((*coord, false));
264        }
265
266        // When the `crs-transform` feature is active and the proj backend was
267        // initialised successfully, delegate all non-identity transformations to
268        // proj4rs (via oxigdal-proj).  The hardcoded paths below are only reached
269        // when the feature is disabled or proj initialisation failed for this pair.
270        #[cfg(feature = "crs-transform")]
271        if let Some(ref t) = self.proj_transformer {
272            return self.transform_via_proj(t, coord);
273        }
274
275        // Hardcoded fast paths (feature off, or proj backend unavailable for pair).
276
277        // WGS84 to Web Mercator (common transformation)
278        if self.source_crs == "EPSG:4326" && self.target_crs == "EPSG:3857" {
279            return self.wgs84_to_web_mercator(coord).map(|c| (c, false));
280        }
281
282        // Web Mercator to WGS84
283        if self.source_crs == "EPSG:3857" && self.target_crs == "EPSG:4326" {
284            return self.web_mercator_to_wgs84(coord).map(|c| (c, false));
285        }
286
287        // For other transformations, proj integration is required
288        Err(AlgorithmError::UnsupportedOperation {
289            operation: format!(
290                "Coordinate transformation from {} to {} (requires crs-transform feature)",
291                self.source_crs, self.target_crs
292            ),
293        })
294    }
295
296    /// Delegates a single coordinate transformation to `oxigdal_proj::Transformer`.
297    ///
298    /// Area-of-use handling depends on [`Self::out_of_area_policy`]:
299    /// - [`OutOfAreaPolicy::PassThrough`] → the source coordinate is returned
300    ///   unchanged (logged at debug level) and flagged `true`.
301    /// - [`OutOfAreaPolicy::Error`] → an `AlgorithmError` is returned.
302    ///
303    /// All other proj errors map to `AlgorithmError::UnsupportedOperation`.
304    #[cfg(feature = "crs-transform")]
305    fn transform_via_proj(
306        &self,
307        t: &oxigdal_proj::Transformer,
308        coord: &Coordinate,
309    ) -> Result<(Coordinate, bool)> {
310        let proj_coord = oxigdal_proj::Coordinate::new(coord.x, coord.y);
311        match t.transform(&proj_coord) {
312            Ok(out) => Ok((Coordinate::new_2d(out.x, out.y), false)),
313            Err(oxigdal_proj::Error::OutOfAreaOfUse { lon, lat, .. }) => {
314                match self.out_of_area_policy {
315                    OutOfAreaPolicy::Error => Err(AlgorithmError::ComputationError(format!(
316                        "coordinate ({lon}, {lat}) is outside the source CRS's area of use"
317                    ))),
318                    OutOfAreaPolicy::PassThrough => {
319                        tracing::debug!(
320                            "oxigdal-proj: point ({}, {}) is outside area of use — returning unchanged",
321                            lon,
322                            lat
323                        );
324                        Ok((*coord, true))
325                    }
326                }
327            }
328            Err(e) => Err(AlgorithmError::UnsupportedOperation {
329                operation: format!("Coordinate transformation failed: {}", e),
330            }),
331        }
332    }
333
334    /// Transforms multiple coordinates efficiently
335    pub fn transform_coordinates(&self, coords: &[Coordinate]) -> Result<Vec<Coordinate>> {
336        coords
337            .iter()
338            .map(|c| self.transform_coordinate(c))
339            .collect()
340    }
341
342    /// Transforms multiple coordinates, reporting the indices of any that fell
343    /// outside the source CRS's area of use (and were passed through unchanged).
344    ///
345    /// This lets callers detect and reject/filter a geometry that would
346    /// otherwise silently mix coordinate systems, without switching to the
347    /// hard-error `OutOfAreaPolicy::Error`. The returned index list is always
348    /// empty unless the `crs-transform` feature is active and the current policy
349    /// is `OutOfAreaPolicy::PassThrough`.
350    ///
351    /// # Errors
352    ///
353    /// Propagates any transformation error (including out-of-area errors when
354    /// the policy is `OutOfAreaPolicy::Error`).
355    pub fn transform_coordinates_reporting(
356        &self,
357        coords: &[Coordinate],
358    ) -> Result<(Vec<Coordinate>, Vec<usize>)> {
359        let mut out = Vec::with_capacity(coords.len());
360        let mut out_of_area = Vec::new();
361        for (i, c) in coords.iter().enumerate() {
362            let (transformed, was_out_of_area) = self.transform_coordinate_checked(c)?;
363            out.push(transformed);
364            if was_out_of_area {
365                out_of_area.push(i);
366            }
367        }
368        Ok((out, out_of_area))
369    }
370
371    /// Transforms a point
372    pub fn transform_point(&self, point: &Point) -> Result<Point> {
373        let transformed = self.transform_coordinate(&point.coord)?;
374        Ok(Point::from_coord(transformed))
375    }
376
377    /// Transforms a linestring
378    pub fn transform_linestring(&self, linestring: &LineString) -> Result<LineString> {
379        let coords = self.transform_coordinates(&linestring.coords)?;
380        LineString::new(coords).map_err(|e| AlgorithmError::GeometryError {
381            message: format!("Failed to create transformed linestring: {}", e),
382        })
383    }
384
385    /// Transforms a polygon
386    pub fn transform_polygon(&self, polygon: &Polygon) -> Result<Polygon> {
387        let exterior_coords = self.transform_coordinates(&polygon.exterior.coords)?;
388        let exterior =
389            LineString::new(exterior_coords).map_err(|e| AlgorithmError::GeometryError {
390                message: format!("Failed to create transformed exterior ring: {}", e),
391            })?;
392
393        let mut interiors = Vec::new();
394        for hole in &polygon.interiors {
395            let hole_coords = self.transform_coordinates(&hole.coords)?;
396            let hole_ring =
397                LineString::new(hole_coords).map_err(|e| AlgorithmError::GeometryError {
398                    message: format!("Failed to create transformed interior ring: {}", e),
399                })?;
400            interiors.push(hole_ring);
401        }
402
403        Polygon::new(exterior, interiors).map_err(|e| AlgorithmError::GeometryError {
404            message: format!("Failed to create transformed polygon: {}", e),
405        })
406    }
407
408    /// Transforms a geometry
409    pub fn transform_geometry(&self, geometry: &Geometry) -> Result<Geometry> {
410        match geometry {
411            Geometry::Point(p) => Ok(Geometry::Point(self.transform_point(p)?)),
412            Geometry::LineString(ls) => Ok(Geometry::LineString(self.transform_linestring(ls)?)),
413            Geometry::Polygon(poly) => Ok(Geometry::Polygon(self.transform_polygon(poly)?)),
414            Geometry::MultiPoint(mp) => {
415                let mut points = Vec::new();
416                for point in &mp.points {
417                    points.push(self.transform_point(point)?);
418                }
419                Ok(Geometry::MultiPoint(MultiPoint { points }))
420            }
421            Geometry::MultiLineString(mls) => {
422                let mut line_strings = Vec::new();
423                for ls in &mls.line_strings {
424                    line_strings.push(self.transform_linestring(ls)?);
425                }
426                Ok(Geometry::MultiLineString(MultiLineString { line_strings }))
427            }
428            Geometry::MultiPolygon(mp) => {
429                let mut polygons = Vec::new();
430                for poly in &mp.polygons {
431                    polygons.push(self.transform_polygon(poly)?);
432                }
433                Ok(Geometry::MultiPolygon(MultiPolygon { polygons }))
434            }
435            Geometry::GeometryCollection(gc) => {
436                let mut geometries = Vec::new();
437                for geom in &gc.geometries {
438                    geometries.push(self.transform_geometry(geom)?);
439                }
440                Ok(Geometry::GeometryCollection(GeometryCollection {
441                    geometries,
442                }))
443            }
444        }
445    }
446
447    /// WGS84 (EPSG:4326) to Web Mercator (EPSG:3857) transformation
448    fn wgs84_to_web_mercator(&self, coord: &Coordinate) -> Result<Coordinate> {
449        const EARTH_RADIUS: f64 = 6_378_137.0;
450
451        // Validate latitude range
452        if !(-90.0..=90.0).contains(&coord.y) {
453            return Err(AlgorithmError::InvalidParameter {
454                parameter: "latitude",
455                message: format!("Latitude {} is out of range [-90, 90]", coord.y),
456            });
457        }
458
459        // Web Mercator doesn't work well near poles
460        if coord.y.abs() > 85.0511 {
461            return Err(AlgorithmError::InvalidParameter {
462                parameter: "latitude",
463                message: format!(
464                    "Latitude {} is too close to poles for Web Mercator (max ±85.0511°)",
465                    coord.y
466                ),
467            });
468        }
469
470        let lon_rad = coord.x.to_radians();
471        let lat_rad = coord.y.to_radians();
472
473        let x = EARTH_RADIUS * lon_rad;
474        let y = EARTH_RADIUS * ((std::f64::consts::PI / 4.0 + lat_rad / 2.0).tan().ln());
475
476        Ok(Coordinate::new_2d(x, y))
477    }
478
479    /// Web Mercator (EPSG:3857) to WGS84 (EPSG:4326) transformation
480    fn web_mercator_to_wgs84(&self, coord: &Coordinate) -> Result<Coordinate> {
481        const EARTH_RADIUS: f64 = 6_378_137.0;
482
483        let lon = (coord.x / EARTH_RADIUS).to_degrees();
484        let lat =
485            (2.0 * (coord.y / EARTH_RADIUS).exp().atan() - std::f64::consts::PI / 2.0).to_degrees();
486
487        // Clamp to valid ranges
488        let lon = lon.clamp(-180.0, 180.0);
489        let lat = lat.clamp(-90.0, 90.0);
490
491        Ok(Coordinate::new_2d(lon, lat))
492    }
493}
494
495/// Transforms a point between coordinate reference systems
496///
497/// # Arguments
498///
499/// * `point` - Input point
500/// * `source_crs` - Source CRS (e.g., "EPSG:4326")
501/// * `target_crs` - Target CRS (e.g., "EPSG:3857")
502///
503/// # Returns
504///
505/// Transformed point
506///
507/// # Errors
508///
509/// Returns error if transformation fails
510pub fn transform_point(point: &Point, source_crs: &str, target_crs: &str) -> Result<Point> {
511    let transformer = CrsTransformer::new(source_crs, target_crs)?;
512    transformer.transform_point(point)
513}
514
515/// Transforms a linestring between coordinate reference systems
516pub fn transform_linestring(
517    linestring: &LineString,
518    source_crs: &str,
519    target_crs: &str,
520) -> Result<LineString> {
521    let transformer = CrsTransformer::new(source_crs, target_crs)?;
522    transformer.transform_linestring(linestring)
523}
524
525/// Transforms a polygon between coordinate reference systems
526pub fn transform_polygon(polygon: &Polygon, source_crs: &str, target_crs: &str) -> Result<Polygon> {
527    let transformer = CrsTransformer::new(source_crs, target_crs)?;
528    transformer.transform_polygon(polygon)
529}
530
531/// Transforms a geometry between coordinate reference systems
532pub fn transform_geometry(
533    geometry: &Geometry,
534    source_crs: &str,
535    target_crs: &str,
536) -> Result<Geometry> {
537    let transformer = CrsTransformer::new(source_crs, target_crs)?;
538    transformer.transform_geometry(geometry)
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_common_crs_epsg_codes() {
547        assert_eq!(CommonCrs::Wgs84.epsg_code(), "EPSG:4326");
548        assert_eq!(CommonCrs::WebMercator.epsg_code(), "EPSG:3857");
549        assert_eq!(
550            CommonCrs::Utm {
551                zone: 10,
552                north: true
553            }
554            .epsg_code(),
555            "EPSG:32610"
556        );
557        assert_eq!(
558            CommonCrs::Utm {
559                zone: 33,
560                north: false
561            }
562            .epsg_code(),
563            "EPSG:32733"
564        );
565    }
566
567    #[test]
568    fn test_transformer_creation() {
569        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
570        assert!(transformer.is_ok());
571
572        let empty = CrsTransformer::new("", "EPSG:3857");
573        assert!(empty.is_err());
574    }
575
576    #[test]
577    fn test_identity_transformation() {
578        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:4326");
579        assert!(transformer.is_ok());
580
581        if let Ok(t) = transformer {
582            let coord = Coordinate::new_2d(10.0, 20.0);
583            let result = t.transform_coordinate(&coord);
584            assert!(result.is_ok());
585
586            if let Ok(transformed) = result {
587                assert!((transformed.x - 10.0).abs() < f64::EPSILON);
588                assert!((transformed.y - 20.0).abs() < f64::EPSILON);
589            }
590        }
591    }
592
593    #[test]
594    fn test_wgs84_to_web_mercator() {
595        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
596        assert!(transformer.is_ok());
597
598        if let Ok(t) = transformer {
599            // Transform origin (0, 0)
600            let origin = Coordinate::new_2d(0.0, 0.0);
601            let result = t.transform_coordinate(&origin);
602            assert!(result.is_ok());
603
604            if let Ok(transformed) = result {
605                assert!(transformed.x.abs() < 1.0);
606                assert!(transformed.y.abs() < 1.0);
607            }
608
609            // Transform San Francisco
610            let sf = Coordinate::new_2d(-122.4194, 37.7749);
611            let result = t.transform_coordinate(&sf);
612            assert!(result.is_ok());
613
614            if let Ok(transformed) = result {
615                // Web Mercator x should be negative (west of prime meridian)
616                assert!(transformed.x < 0.0);
617                // y should be positive (north of equator)
618                assert!(transformed.y > 0.0);
619            }
620        }
621    }
622
623    #[test]
624    fn test_web_mercator_to_wgs84() {
625        let transformer = CrsTransformer::new("EPSG:3857", "EPSG:4326");
626        assert!(transformer.is_ok());
627
628        if let Ok(t) = transformer {
629            // Transform origin
630            let origin = Coordinate::new_2d(0.0, 0.0);
631            let result = t.transform_coordinate(&origin);
632            assert!(result.is_ok());
633
634            if let Ok(transformed) = result {
635                assert!(transformed.x.abs() < 1e-6);
636                assert!(transformed.y.abs() < 1e-6);
637            }
638        }
639    }
640
641    #[test]
642    fn test_round_trip_transformation() {
643        let to_merc = CrsTransformer::new("EPSG:4326", "EPSG:3857");
644        let to_wgs = CrsTransformer::new("EPSG:3857", "EPSG:4326");
645
646        assert!(to_merc.is_ok());
647        assert!(to_wgs.is_ok());
648
649        if let (Ok(t1), Ok(t2)) = (to_merc, to_wgs) {
650            let original = Coordinate::new_2d(-122.4194, 37.7749);
651
652            let merc = t1.transform_coordinate(&original);
653            assert!(merc.is_ok());
654
655            if let Ok(m) = merc {
656                let back = t2.transform_coordinate(&m);
657                assert!(back.is_ok());
658
659                if let Ok(b) = back {
660                    // Should be close to original (within tolerance)
661                    assert!((b.x - original.x).abs() < 1e-6);
662                    assert!((b.y - original.y).abs() < 1e-6);
663                }
664            }
665        }
666    }
667
668    #[test]
669    fn test_transform_point() {
670        let point = Point::new(-122.4194, 37.7749);
671        let result = transform_point(&point, "EPSG:4326", "EPSG:3857");
672        assert!(result.is_ok());
673
674        if let Ok(transformed) = result {
675            assert!(transformed.coord.x < 0.0);
676            assert!(transformed.coord.y > 0.0);
677        }
678    }
679
680    #[test]
681    fn test_transform_linestring() {
682        let coords = vec![
683            Coordinate::new_2d(0.0, 0.0),
684            Coordinate::new_2d(1.0, 1.0),
685            Coordinate::new_2d(2.0, 2.0),
686        ];
687        let linestring = LineString::new(coords);
688        assert!(linestring.is_ok());
689
690        if let Ok(ls) = linestring {
691            let result = transform_linestring(&ls, "EPSG:4326", "EPSG:3857");
692            assert!(result.is_ok());
693
694            if let Ok(transformed) = result {
695                assert_eq!(transformed.coords.len(), 3);
696            }
697        }
698    }
699
700    #[test]
701    fn test_transform_polygon() {
702        let coords = vec![
703            Coordinate::new_2d(0.0, 0.0),
704            Coordinate::new_2d(4.0, 0.0),
705            Coordinate::new_2d(4.0, 4.0),
706            Coordinate::new_2d(0.0, 4.0),
707            Coordinate::new_2d(0.0, 0.0),
708        ];
709        let exterior = LineString::new(coords);
710        assert!(exterior.is_ok());
711
712        if let Ok(ext) = exterior {
713            let polygon = Polygon::new(ext, vec![]);
714            assert!(polygon.is_ok());
715
716            if let Ok(poly) = polygon {
717                let result = transform_polygon(&poly, "EPSG:4326", "EPSG:3857");
718                assert!(result.is_ok());
719
720                if let Ok(transformed) = result {
721                    assert_eq!(transformed.exterior.coords.len(), 5);
722                }
723            }
724        }
725    }
726
727    #[test]
728    fn test_invalid_latitude() {
729        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
730        assert!(transformer.is_ok());
731
732        if let Ok(t) = transformer {
733            // Latitude truly out of range (>90°): the Mercator formula produces
734            // non-finite values, so this must error regardless of backend.
735            let invalid = Coordinate::new_2d(0.0, 95.0);
736            let result = t.transform_coordinate(&invalid);
737            assert!(result.is_err(), "lat=95 must be rejected");
738
739            // lat=89° is geometrically valid for WGS84 and within the domain of
740            // Web Mercator when using a proper proj backend (which can compute it
741            // accurately).  The hardcoded fallback imposes a tighter ±85.0511°
742            // limit for safety, so we only assert the stricter check when the
743            // proj backend is not active.
744            #[cfg(not(feature = "crs-transform"))]
745            {
746                let near_pole = Coordinate::new_2d(0.0, 89.0);
747                let result = t.transform_coordinate(&near_pole);
748                assert!(
749                    result.is_err(),
750                    "lat=89 must be rejected by hardcoded fallback"
751                );
752            }
753        }
754    }
755
756    #[test]
757    fn test_batch_transformation() {
758        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:3857");
759        assert!(transformer.is_ok());
760
761        if let Ok(t) = transformer {
762            let coords = vec![
763                Coordinate::new_2d(0.0, 0.0),
764                Coordinate::new_2d(1.0, 1.0),
765                Coordinate::new_2d(-1.0, -1.0),
766            ];
767
768            let result = t.transform_coordinates(&coords);
769            assert!(result.is_ok());
770
771            if let Ok(transformed) = result {
772                assert_eq!(transformed.len(), 3);
773            }
774        }
775    }
776
777    #[test]
778    fn test_from_common_crs() {
779        let transformer = CrsTransformer::from_common(CommonCrs::Wgs84, CommonCrs::WebMercator);
780        assert!(transformer.is_ok());
781
782        if let Ok(t) = transformer {
783            assert_eq!(t.source_crs, "EPSG:4326");
784            assert_eq!(t.target_crs, "EPSG:3857");
785        }
786    }
787
788    // -----------------------------------------------------------------------
789    // New tests for the crs-transform feature (proj backend integration)
790    // -----------------------------------------------------------------------
791
792    /// Identity transformation: source == target == EPSG:4326.
793    /// Output coordinate must be numerically identical to input.
794    #[test]
795    fn test_crs_transformer_wgs84_identity_passthrough() {
796        let transformer = CrsTransformer::new("EPSG:4326", "EPSG:4326")
797            .expect("identity transformer must construct");
798
799        let input = Coordinate::new_2d(13.4050, 52.5200); // Berlin
800        let output = transformer
801            .transform_coordinate(&input)
802            .expect("identity transform must succeed");
803
804        assert!(
805            (output.x - input.x).abs() < f64::EPSILON,
806            "x must be unchanged: {} != {}",
807            output.x,
808            input.x
809        );
810        assert!(
811            (output.y - input.y).abs() < f64::EPSILON,
812            "y must be unchanged: {} != {}",
813            output.y,
814            input.y
815        );
816    }
817
818    /// A polygon with 5 vertices (closed ring) must produce exactly 5 output vertices
819    /// regardless of which CRS path is used.
820    #[test]
821    fn test_crs_transformer_polygon_preserves_vertex_count() {
822        let coords = vec![
823            Coordinate::new_2d(-10.0, -10.0),
824            Coordinate::new_2d(10.0, -10.0),
825            Coordinate::new_2d(10.0, 10.0),
826            Coordinate::new_2d(-10.0, 10.0),
827            Coordinate::new_2d(-10.0, -10.0), // closing vertex
828        ];
829        let exterior = LineString::new(coords).expect("linestring must construct");
830        let polygon = Polygon::new(exterior, vec![]).expect("polygon must construct");
831
832        // Use WGS84 → Web Mercator (always supported, with or without crs-transform feature)
833        let transformer =
834            CrsTransformer::new("EPSG:4326", "EPSG:3857").expect("transformer must construct");
835        let result = transformer
836            .transform_polygon(&polygon)
837            .expect("polygon transform must succeed");
838
839        assert_eq!(
840            result.exterior.coords.len(),
841            5,
842            "transformed polygon must retain 5 vertices"
843        );
844    }
845
846    /// When `crs-transform` feature is OFF (or proj fails for an exotic pair),
847    /// `CrsTransformer::new` must still succeed and `transform_coordinate` must
848    /// either return `UnsupportedOperation` or a valid result — never panic.
849    #[test]
850    fn test_crs_transformer_unknown_epsg_falls_back_gracefully() {
851        // EPSG:32637 is WGS 84 / UTM zone 37N.  Without the feature, the hardcoded
852        // paths don't cover it, so we expect either UnsupportedOperation or a
853        // successful transform (when proj backend is available).
854        let result = CrsTransformer::new("EPSG:4326", "EPSG:32637");
855        // Construction must always succeed
856        assert!(
857            result.is_ok(),
858            "CrsTransformer::new must not fail for any non-empty CRS string"
859        );
860
861        let transformer = result.expect("already asserted Ok above");
862        let coord = Coordinate::new_2d(37.0, 55.0);
863        let transform_result = transformer.transform_coordinate(&coord);
864
865        // Either UnsupportedOperation (no-feature) or a valid coordinate (with-feature).
866        // The invariant: it must NOT panic, and if Err it must be UnsupportedOperation.
867        if let Err(ref e) = transform_result {
868            assert!(
869                matches!(e, AlgorithmError::UnsupportedOperation { .. }),
870                "unexpected error variant: {:?}",
871                e
872            );
873        }
874        // If Ok, the coordinate must be finite
875        if let Ok(ref c) = transform_result {
876            assert!(c.x.is_finite() && c.y.is_finite(), "output must be finite");
877        }
878    }
879
880    /// WGS84 origin (0°, 0°) must map to Web Mercator origin (0, 0) — this is
881    /// a well-known property of the Mercator projection.
882    #[test]
883    fn test_crs_transformer_wgs84_to_webmercator_known_point() {
884        let transformer =
885            CrsTransformer::new("EPSG:4326", "EPSG:3857").expect("transformer must construct");
886
887        let origin = Coordinate::new_2d(0.0, 0.0);
888        let result = transformer
889            .transform_coordinate(&origin)
890            .expect("transform of origin must succeed");
891
892        assert!(
893            result.x.abs() < 1.0,
894            "Web Mercator X at lon=0 must be ~0, got {}",
895            result.x
896        );
897        assert!(
898            result.y.abs() < 1.0,
899            "Web Mercator Y at lat=0 must be ~0, got {}",
900            result.y
901        );
902    }
903
904    #[test]
905    fn test_out_of_area_policy_default_and_builder() {
906        let t = CrsTransformer::new("EPSG:4326", "EPSG:3857").expect("must construct");
907        assert_eq!(
908            t.out_of_area_policy(),
909            OutOfAreaPolicy::PassThrough,
910            "default policy must be PassThrough for backward compatibility"
911        );
912
913        let t = t.with_out_of_area_policy(OutOfAreaPolicy::Error);
914        assert_eq!(t.out_of_area_policy(), OutOfAreaPolicy::Error);
915
916        // In-area transforms must still succeed under the Error policy.
917        let origin = Coordinate::new_2d(0.0, 0.0);
918        let out = t
919            .transform_coordinate(&origin)
920            .expect("origin is in area of use for WGS84→WebMercator");
921        assert!(out.x.abs() < 1.0 && out.y.abs() < 1.0);
922    }
923
924    #[test]
925    fn test_transform_coordinates_reporting_in_area() {
926        let t = CrsTransformer::new("EPSG:4326", "EPSG:3857").expect("must construct");
927        let coords = vec![
928            Coordinate::new_2d(0.0, 0.0),
929            Coordinate::new_2d(10.0, 10.0),
930            Coordinate::new_2d(-20.0, 30.0),
931        ];
932        let (out, out_of_area) = t
933            .transform_coordinates_reporting(&coords)
934            .expect("reporting transform must succeed");
935
936        assert_eq!(out.len(), 3, "all coordinates must be transformed");
937        for c in &out {
938            assert!(c.x.is_finite() && c.y.is_finite());
939        }
940        // These well-known in-area points must not be flagged out of area.
941        assert!(
942            out_of_area.is_empty(),
943            "no in-area points should be reported, got {out_of_area:?}"
944        );
945    }
946
947    #[test]
948    fn test_transform_coordinates_reporting_identity() {
949        // Identity transform never reports out-of-area regardless of backend.
950        let t = CrsTransformer::new("EPSG:4326", "EPSG:4326").expect("must construct");
951        let coords = vec![Coordinate::new_2d(200.0, 95.0)]; // even a wild point
952        let (out, out_of_area) = t
953            .transform_coordinates_reporting(&coords)
954            .expect("identity reporting must succeed");
955        assert_eq!(out.len(), 1);
956        assert!(out_of_area.is_empty());
957        assert!((out[0].x - 200.0).abs() < f64::EPSILON);
958    }
959}