Skip to main content

proj_core/
transform.rs

1use crate::coord::{Bounds, Coord, Coord3D, Transformable, Transformable3D};
2use crate::crs::CrsDef;
3use crate::datum::{DatumGridShift, DatumGridShiftEntry, DatumToWgs84, HelmertParams};
4use crate::ellipsoid::Ellipsoid;
5use crate::error::{Error, Result};
6use crate::grid::{GridError, GridHandle, GridRuntime};
7use crate::helmert;
8use crate::operation::{
9    CoordinateOperation, CoordinateOperationId, CoordinateOperationMetadata, GridCoverageMiss,
10    GridShiftDirection, OperationMethod, OperationSelectionDiagnostics, OperationStepDirection,
11    SelectionOptions, SelectionPolicy, SkippedOperation, SkippedOperationReason, TransformOutcome,
12};
13use crate::projection::{make_projection, Projection};
14use crate::registry;
15use crate::selector;
16use crate::{ellipsoid, geocentric};
17use smallvec::SmallVec;
18
19#[cfg(feature = "rayon")]
20const PARALLEL_MIN_TOTAL_ITEMS: usize = 16_384;
21#[cfg(feature = "rayon")]
22const PARALLEL_MIN_ITEMS_PER_THREAD: usize = 4_096;
23#[cfg(feature = "rayon")]
24const PARALLEL_CHUNKS_PER_THREAD: usize = 4;
25#[cfg(feature = "rayon")]
26const PARALLEL_MIN_CHUNK_SIZE: usize = 1_024;
27
28/// A reusable coordinate transformation between two CRS.
29pub struct Transform {
30    source: CrsDef,
31    target: CrsDef,
32    selected_operation_definition: CoordinateOperation,
33    selected_direction: OperationStepDirection,
34    selected_operation: CoordinateOperationMetadata,
35    diagnostics: OperationSelectionDiagnostics,
36    selection_options: SelectionOptions,
37    pipeline: CompiledOperationPipeline,
38    fallback_pipelines: Vec<CompiledOperationFallback>,
39}
40
41struct CompiledOperationPipeline {
42    steps: SmallVec<[CompiledStep; 8]>,
43}
44
45struct CompiledOperationFallback {
46    metadata: CoordinateOperationMetadata,
47    pipeline: CompiledOperationPipeline,
48}
49
50enum CompiledStep {
51    ProjectionForward {
52        projection: Projection,
53    },
54    ProjectionInverse {
55        projection: Projection,
56    },
57    Helmert {
58        params: HelmertParams,
59        inverse: bool,
60    },
61    GridShift {
62        handle: GridHandle,
63        direction: GridShiftDirection,
64    },
65    GridShiftList {
66        handles: Box<[GridHandle]>,
67        allow_null: bool,
68        direction: GridShiftDirection,
69    },
70    GeodeticToGeocentric {
71        ellipsoid: Ellipsoid,
72    },
73    GeocentricToGeodetic {
74        ellipsoid: Ellipsoid,
75    },
76}
77
78impl Transform {
79    /// Create a transform from authority code strings (e.g., `"EPSG:4326"`).
80    pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
81        Self::with_selection_options(from_crs, to_crs, SelectionOptions::default())
82    }
83
84    /// Create a transform with explicit selection options.
85    pub fn with_selection_options(
86        from_crs: &str,
87        to_crs: &str,
88        options: SelectionOptions,
89    ) -> Result<Self> {
90        let source = registry::lookup_authority_code(from_crs)?;
91        let target = registry::lookup_authority_code(to_crs)?;
92        Self::from_crs_defs_with_selection_options(&source, &target, options)
93    }
94
95    /// Create a transform from an explicit registry operation id.
96    pub fn from_operation(
97        operation_id: CoordinateOperationId,
98        from_crs: &str,
99        to_crs: &str,
100    ) -> Result<Self> {
101        Self::with_selection_options(
102            from_crs,
103            to_crs,
104            SelectionOptions {
105                policy: SelectionPolicy::Operation(operation_id),
106                ..SelectionOptions::default()
107            },
108        )
109    }
110
111    /// Create a transform from EPSG codes directly.
112    pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
113        let source = registry::lookup_epsg(from)
114            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
115        let target = registry::lookup_epsg(to)
116            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
117        Self::from_crs_defs(&source, &target)
118    }
119
120    /// Create a transform from explicit CRS definitions.
121    pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
122        Self::from_crs_defs_with_selection_options(from, to, SelectionOptions::default())
123    }
124
125    /// Create a transform from explicit CRS definitions with operation-selection options.
126    ///
127    /// Use this when a custom CRS references grid resources and the transform
128    /// needs an application-supplied [`crate::grid::GridProvider`].
129    pub fn from_crs_defs_with_selection_options(
130        from: &CrsDef,
131        to: &CrsDef,
132        options: SelectionOptions,
133    ) -> Result<Self> {
134        let grid_runtime = GridRuntime::new(options.grid_provider.clone());
135        let candidate_set = selector::rank_operation_candidates(from, to, &options)?;
136        if candidate_set.ranked.is_empty() {
137            return Err(match options.policy {
138                SelectionPolicy::Operation(id) => match registry::lookup_operation(id) {
139                    Some(_) => Error::OperationSelection(format!(
140                        "operation id {} is not compatible with source EPSG:{} target EPSG:{}",
141                        id.0,
142                        from.epsg(),
143                        to.epsg()
144                    )),
145                    None => Error::UnknownOperation(format!("unknown operation id {}", id.0)),
146                },
147                _ => Error::OperationSelection(format!(
148                    "no compatible operation found for source EPSG:{} target EPSG:{}",
149                    from.epsg(),
150                    to.epsg()
151                )),
152            });
153        }
154
155        let mut skipped_operations = candidate_set.skipped;
156        let mut missing_required_grid = None;
157        let mut selected: Option<(
158            usize,
159            &selector::RankedOperationCandidate,
160            CoordinateOperationMetadata,
161            CompiledOperationPipeline,
162        )> = None;
163        let mut fallback_pipelines = Vec::new();
164
165        for (index, candidate) in candidate_set.ranked.iter().enumerate() {
166            if let Some((_, selected_candidate, ..)) = &selected {
167                if !selected_candidate.operation.uses_grids() {
168                    skipped_operations.push(skipped_for_unselected_candidate(
169                        candidate,
170                        !selected_candidate.operation.deprecated,
171                    ));
172                    continue;
173                }
174            }
175
176            match compile_pipeline(
177                from,
178                to,
179                candidate.operation.as_ref(),
180                candidate.direction,
181                &grid_runtime,
182            ) {
183                Ok(pipeline) => {
184                    let metadata = selected_metadata(
185                        candidate.operation.as_ref(),
186                        candidate.direction,
187                        candidate.matched_area_of_use.clone(),
188                    );
189                    if let Some((_, selected_candidate, ..)) = &selected {
190                        skipped_operations.push(skipped_for_unselected_candidate(
191                            candidate,
192                            !selected_candidate.operation.deprecated,
193                        ));
194                        fallback_pipelines.push(CompiledOperationFallback { metadata, pipeline });
195                    } else {
196                        selected = Some((index, candidate, metadata, pipeline));
197                    }
198                }
199                Err(Error::Grid(error)) => {
200                    if selected.is_none() && missing_required_grid.is_none() {
201                        missing_required_grid = Some(error.to_string());
202                    }
203                    skipped_operations.push(SkippedOperation {
204                        metadata: selected_metadata(
205                            candidate.operation.as_ref(),
206                            candidate.direction,
207                            candidate.matched_area_of_use.clone(),
208                        ),
209                        reason: match error {
210                            crate::grid::GridError::UnsupportedFormat(_) => {
211                                SkippedOperationReason::UnsupportedGridFormat
212                            }
213                            _ => SkippedOperationReason::MissingGrid,
214                        },
215                        detail: error.to_string(),
216                    });
217                }
218                Err(error) => {
219                    skipped_operations.push(SkippedOperation {
220                        metadata: selected_metadata(
221                            candidate.operation.as_ref(),
222                            candidate.direction,
223                            candidate.matched_area_of_use.clone(),
224                        ),
225                        reason: SkippedOperationReason::LessPreferred,
226                        detail: error.to_string(),
227                    });
228                }
229            }
230        }
231
232        if let Some((index, candidate, metadata, pipeline)) = selected {
233            let selected_reasons =
234                selected_reasons_for(candidate, &candidate_set.ranked[index + 1..]);
235            let diagnostics = OperationSelectionDiagnostics {
236                selected_operation: metadata.clone(),
237                selected_match_kind: candidate.match_kind,
238                selected_reasons,
239                fallback_operations: fallback_pipelines
240                    .iter()
241                    .map(|fallback| fallback.metadata.clone())
242                    .collect(),
243                skipped_operations,
244                approximate: candidate.operation.approximate,
245                missing_required_grid,
246            };
247            return Ok(Self {
248                source: from.clone(),
249                target: to.clone(),
250                selected_operation_definition: candidate.operation.clone().into_owned(),
251                selected_direction: candidate.direction,
252                selected_operation: metadata,
253                diagnostics,
254                selection_options: options,
255                pipeline,
256                fallback_pipelines,
257            });
258        }
259
260        if let Some(message) = missing_required_grid {
261            return Err(Error::OperationSelection(format!(
262                "better operations were skipped because required grids were unavailable: {message}"
263            )));
264        }
265
266        Err(Error::OperationSelection(format!(
267            "unable to compile an operation for source EPSG:{} target EPSG:{}",
268            from.epsg(),
269            to.epsg()
270        )))
271    }
272
273    /// Transform a single coordinate.
274    pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
275        let c = coord.into_coord();
276        let result = self.convert_coord(c)?;
277        Ok(T::from_coord(result))
278    }
279
280    /// Transform a single 3D coordinate.
281    pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
282        let c = coord.into_coord3d();
283        let result = self.convert_coord3d(c)?;
284        Ok(T::from_coord3d(result))
285    }
286
287    /// Transform a single coordinate and report the operation actually used.
288    ///
289    /// When the selected grid-backed operation misses grid coverage, this
290    /// reports the coverage misses and the lower-ranked fallback operation that
291    /// produced the result.
292    pub fn convert_with_diagnostics<T: Transformable>(
293        &self,
294        coord: T,
295    ) -> Result<TransformOutcome<T>> {
296        let c = coord.into_coord();
297        let outcome = self.convert_coord_with_diagnostics(c)?;
298        Ok(TransformOutcome {
299            coord: T::from_coord(outcome.coord),
300            operation: outcome.operation,
301            grid_coverage_misses: outcome.grid_coverage_misses,
302        })
303    }
304
305    /// Transform a single 3D coordinate and report the operation actually used.
306    ///
307    /// When the selected grid-backed operation misses grid coverage, this
308    /// reports the coverage misses and the lower-ranked fallback operation that
309    /// produced the result.
310    pub fn convert_3d_with_diagnostics<T: Transformable3D>(
311        &self,
312        coord: T,
313    ) -> Result<TransformOutcome<T>> {
314        let c = coord.into_coord3d();
315        let outcome = self.convert_coord3d_with_diagnostics(c)?;
316        Ok(TransformOutcome {
317            coord: T::from_coord3d(outcome.coord),
318            operation: outcome.operation,
319            grid_coverage_misses: outcome.grid_coverage_misses,
320        })
321    }
322
323    /// Return the source CRS definition for this transform.
324    pub fn source_crs(&self) -> &CrsDef {
325        &self.source
326    }
327
328    /// Return the target CRS definition for this transform.
329    pub fn target_crs(&self) -> &CrsDef {
330        &self.target
331    }
332
333    /// Return metadata for the selected coordinate operation.
334    pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
335        &self.selected_operation
336    }
337
338    /// Return selection diagnostics for this transform.
339    pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
340        &self.diagnostics
341    }
342
343    /// Build the inverse transform by swapping the source and target CRS.
344    pub fn inverse(&self) -> Result<Self> {
345        let grid_runtime = GridRuntime::new(self.selection_options.grid_provider.clone());
346        let selected_direction = self.selected_direction.inverse();
347        let pipeline = compile_pipeline(
348            &self.target,
349            &self.source,
350            &self.selected_operation_definition,
351            selected_direction,
352            &grid_runtime,
353        )?;
354        let selected_operation = selected_metadata(
355            &self.selected_operation_definition,
356            selected_direction,
357            self.selected_operation.area_of_use.clone(),
358        );
359        let diagnostics = OperationSelectionDiagnostics {
360            selected_operation: selected_operation.clone(),
361            selected_match_kind: self.diagnostics.selected_match_kind,
362            selected_reasons: self.diagnostics.selected_reasons.clone(),
363            fallback_operations: Vec::new(),
364            skipped_operations: Vec::new(),
365            approximate: self.diagnostics.approximate,
366            missing_required_grid: self.diagnostics.missing_required_grid.clone(),
367        };
368        Ok(Self {
369            source: self.target.clone(),
370            target: self.source.clone(),
371            selected_operation_definition: self.selected_operation_definition.clone(),
372            selected_direction,
373            selected_operation,
374            diagnostics,
375            selection_options: self.selection_options.inverse(),
376            pipeline,
377            fallback_pipelines: Vec::new(),
378        })
379    }
380
381    /// Reproject a 2D bounding box by sampling its perimeter.
382    pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
383        if !bounds.is_valid() {
384            return Err(Error::OutOfRange(
385                "bounds must be finite and satisfy min <= max".into(),
386            ));
387        }
388
389        let segments = densify_points
390            .checked_add(1)
391            .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))?;
392
393        let mut transformed: Option<Bounds> = None;
394        for i in 0..=segments {
395            let t = i as f64 / segments as f64;
396            let x = bounds.min_x + bounds.width() * t;
397            let y = bounds.min_y + bounds.height() * t;
398
399            for sample in [
400                Coord::new(x, bounds.min_y),
401                Coord::new(x, bounds.max_y),
402                Coord::new(bounds.min_x, y),
403                Coord::new(bounds.max_x, y),
404            ] {
405                let coord = self.convert_coord(sample)?;
406                if let Some(accum) = &mut transformed {
407                    accum.expand_to_include(coord);
408                } else {
409                    transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
410                }
411            }
412        }
413
414        transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
415    }
416
417    fn convert_coord(&self, c: Coord) -> Result<Coord> {
418        let result = self.convert_coord3d(Coord3D::new(c.x, c.y, 0.0))?;
419        Ok(Coord::new(result.x, result.y))
420    }
421
422    fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
423        Ok(self.convert_coord3d_with_diagnostics(c)?.coord)
424    }
425
426    fn convert_coord_with_diagnostics(&self, c: Coord) -> Result<TransformOutcome<Coord>> {
427        let outcome = self.convert_coord3d_with_diagnostics(Coord3D::new(c.x, c.y, 0.0))?;
428        Ok(TransformOutcome {
429            coord: Coord::new(outcome.coord.x, outcome.coord.y),
430            operation: outcome.operation,
431            grid_coverage_misses: outcome.grid_coverage_misses,
432        })
433    }
434
435    fn convert_coord3d_with_diagnostics(&self, c: Coord3D) -> Result<TransformOutcome<Coord3D>> {
436        let mut grid_coverage_misses = Vec::new();
437        match self.execute_pipeline(&self.pipeline, c) {
438            Ok(coord) => {
439                return Ok(TransformOutcome {
440                    coord,
441                    operation: self.selected_operation.clone(),
442                    grid_coverage_misses,
443                });
444            }
445            Err(error) => {
446                if let Some(detail) = grid_coverage_miss_detail(&error) {
447                    grid_coverage_misses.push(GridCoverageMiss {
448                        operation: self.selected_operation.clone(),
449                        detail,
450                    });
451                } else {
452                    return Err(error);
453                }
454            }
455        }
456
457        for fallback in &self.fallback_pipelines {
458            match self.execute_pipeline(&fallback.pipeline, c) {
459                Ok(coord) => {
460                    return Ok(TransformOutcome {
461                        coord,
462                        operation: fallback.metadata.clone(),
463                        grid_coverage_misses,
464                    });
465                }
466                Err(error) => {
467                    if let Some(detail) = grid_coverage_miss_detail(&error) {
468                        grid_coverage_misses.push(GridCoverageMiss {
469                            operation: fallback.metadata.clone(),
470                            detail,
471                        });
472                    } else {
473                        return Err(error);
474                    }
475                }
476            }
477        }
478
479        Err(Error::Grid(GridError::OutsideCoverage(
480            grid_coverage_misses
481                .last()
482                .map(|miss| miss.detail.clone())
483                .unwrap_or_else(|| "grid coverage miss".into()),
484        )))
485    }
486
487    fn execute_pipeline(
488        &self,
489        pipeline: &CompiledOperationPipeline,
490        c: Coord3D,
491    ) -> Result<Coord3D> {
492        if pipeline.steps.is_empty() {
493            return Ok(c);
494        }
495        let preserved_z = c.z;
496        let mut state = if self.source.is_projected() {
497            let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
498            Coord3D::new(x_m, y_m, 0.0)
499        } else {
500            Coord3D::new(c.x.to_radians(), c.y.to_radians(), 0.0)
501        };
502
503        for step in &pipeline.steps {
504            state = execute_step(step, state)?;
505        }
506
507        let (x, y) = if self.target.is_projected() {
508            self.projected_meters_to_target_native(state.x, state.y)
509        } else {
510            (state.x.to_degrees(), state.y.to_degrees())
511        };
512
513        Ok(Coord3D::new(x, y, preserved_z))
514    }
515
516    fn source_projected_native_to_meters(&self, x: f64, y: f64) -> (f64, f64) {
517        match &self.source {
518            CrsDef::Projected(projected) => (
519                projected.linear_unit().to_meters(x),
520                projected.linear_unit().to_meters(y),
521            ),
522            CrsDef::Geographic(_) => (x, y),
523        }
524    }
525
526    fn projected_meters_to_target_native(&self, x: f64, y: f64) -> (f64, f64) {
527        match &self.target {
528            CrsDef::Projected(projected) => (
529                projected.linear_unit().from_meters(x),
530                projected.linear_unit().from_meters(y),
531            ),
532            CrsDef::Geographic(_) => (x, y),
533        }
534    }
535
536    /// Batch transform (sequential).
537    pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
538        coords.iter().map(|c| self.convert(c.clone())).collect()
539    }
540
541    /// Batch transform of 3D coordinates (sequential).
542    pub fn convert_batch_3d<T: Transformable3D + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
543        coords.iter().map(|c| self.convert_3d(c.clone())).collect()
544    }
545
546    /// Transform 2D coordinates in place without allocating.
547    ///
548    /// Coordinates before a failing coordinate are left converted; the failing
549    /// coordinate and subsequent coordinates are left unchanged.
550    pub fn convert_coords_in_place(&self, coords: &mut [Coord]) -> Result<()> {
551        for coord in coords {
552            *coord = self.convert_coord(*coord)?;
553        }
554        Ok(())
555    }
556
557    /// Transform 3D coordinates in place without allocating.
558    ///
559    /// Coordinates before a failing coordinate are left converted; the failing
560    /// coordinate and subsequent coordinates are left unchanged.
561    pub fn convert_coords_3d_in_place(&self, coords: &mut [Coord3D]) -> Result<()> {
562        for coord in coords {
563            *coord = self.convert_coord3d(*coord)?;
564        }
565        Ok(())
566    }
567
568    /// Transform 2D coordinates from `input` into an existing `output` slice.
569    ///
570    /// `output` must have exactly the same length as `input`. This API performs
571    /// no allocation and does not require cloning input coordinates.
572    pub fn convert_coords_into(&self, input: &[Coord], output: &mut [Coord]) -> Result<()> {
573        validate_output_len(input.len(), output.len())?;
574        for (source, target) in input.iter().zip(output.iter_mut()) {
575            *target = self.convert_coord(*source)?;
576        }
577        Ok(())
578    }
579
580    /// Transform 3D coordinates from `input` into an existing `output` slice.
581    ///
582    /// `output` must have exactly the same length as `input`. This API performs
583    /// no allocation and does not require cloning input coordinates.
584    pub fn convert_coords_3d_into(&self, input: &[Coord3D], output: &mut [Coord3D]) -> Result<()> {
585        validate_output_len(input.len(), output.len())?;
586        for (source, target) in input.iter().zip(output.iter_mut()) {
587            *target = self.convert_coord3d(*source)?;
588        }
589        Ok(())
590    }
591
592    /// Batch transform with Rayon parallelism.
593    #[cfg(feature = "rayon")]
594    pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
595        &self,
596        coords: &[T],
597    ) -> Result<Vec<T>> {
598        self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch(chunk))
599    }
600
601    /// Batch transform of 3D coordinates with adaptive Rayon parallelism.
602    #[cfg(feature = "rayon")]
603    pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync + Clone>(
604        &self,
605        coords: &[T],
606    ) -> Result<Vec<T>> {
607        self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch_3d(chunk))
608    }
609
610    #[cfg(feature = "rayon")]
611    fn convert_batch_parallel_adaptive<T, F>(&self, coords: &[T], convert: F) -> Result<Vec<T>>
612    where
613        T: Send + Sync + Clone,
614        F: Fn(&Self, &[T]) -> Result<Vec<T>> + Sync,
615    {
616        if !should_parallelize(coords.len()) {
617            return convert(self, coords);
618        }
619
620        use rayon::prelude::*;
621
622        let chunk_size = parallel_chunk_size(coords.len());
623        let chunk_results: Vec<Result<Vec<T>>> = coords
624            .par_chunks(chunk_size)
625            .map(|chunk| convert(self, chunk))
626            .collect();
627
628        let mut results = Vec::with_capacity(coords.len());
629        for chunk in chunk_results {
630            results.extend(chunk?);
631        }
632        Ok(results)
633    }
634}
635
636fn selected_metadata(
637    operation: &CoordinateOperation,
638    direction: OperationStepDirection,
639    matched_area_of_use: Option<crate::operation::AreaOfUse>,
640) -> CoordinateOperationMetadata {
641    let mut metadata = operation.metadata_for_direction(direction);
642    metadata.area_of_use = matched_area_of_use.or_else(|| operation.areas_of_use.first().cloned());
643    metadata
644}
645
646fn grid_coverage_miss_detail(error: &Error) -> Option<String> {
647    match error {
648        Error::Grid(GridError::OutsideCoverage(detail)) => Some(detail.clone()),
649        _ => None,
650    }
651}
652
653fn validate_output_len(input_len: usize, output_len: usize) -> Result<()> {
654    if input_len != output_len {
655        return Err(Error::OutOfRange(format!(
656            "output coordinate slice length {output_len} does not match input length {input_len}"
657        )));
658    }
659    Ok(())
660}
661
662fn selected_reasons_for(
663    selected: &selector::RankedOperationCandidate,
664    alternatives: &[selector::RankedOperationCandidate],
665) -> SmallVec<[crate::operation::SelectionReason; 4]> {
666    let mut reasons = selected.reasons.clone();
667    if selected_accuracy_preferred(selected, alternatives)
668        && !reasons.contains(&crate::operation::SelectionReason::AccuracyPreferred)
669    {
670        reasons.push(crate::operation::SelectionReason::AccuracyPreferred);
671    }
672    reasons
673}
674
675fn selected_accuracy_preferred(
676    selected: &selector::RankedOperationCandidate,
677    alternatives: &[selector::RankedOperationCandidate],
678) -> bool {
679    let Some(selected_accuracy) = selected.operation.accuracy.map(|value| value.meters) else {
680        return false;
681    };
682
683    alternatives.iter().any(|alternative| {
684        same_pre_accuracy_priority(selected, alternative)
685            && alternative
686                .operation
687                .accuracy
688                .map(|value| selected_accuracy < value.meters)
689                .unwrap_or(false)
690    })
691}
692
693fn same_pre_accuracy_priority(
694    left: &selector::RankedOperationCandidate,
695    right: &selector::RankedOperationCandidate,
696) -> bool {
697    match_kind_priority(left.match_kind) == match_kind_priority(right.match_kind)
698        && left.matched_area_of_use.is_some() == right.matched_area_of_use.is_some()
699}
700
701fn match_kind_priority(kind: crate::operation::OperationMatchKind) -> u8 {
702    match kind {
703        crate::operation::OperationMatchKind::Explicit => 4,
704        crate::operation::OperationMatchKind::ExactSourceTarget => 3,
705        crate::operation::OperationMatchKind::DerivedGeographic => 2,
706        crate::operation::OperationMatchKind::DatumCompatible => 1,
707        crate::operation::OperationMatchKind::ApproximateFallback => 0,
708    }
709}
710
711fn skipped_for_unselected_candidate(
712    candidate: &selector::RankedOperationCandidate,
713    prefer_non_deprecated: bool,
714) -> SkippedOperation {
715    let reason = if prefer_non_deprecated && candidate.operation.deprecated {
716        SkippedOperationReason::Deprecated
717    } else {
718        SkippedOperationReason::LessPreferred
719    };
720    let detail = match reason {
721        SkippedOperationReason::Deprecated => {
722            "not selected because a non-deprecated higher-ranked operation compiled successfully"
723                .into()
724        }
725        _ => "not selected because a higher-ranked operation compiled successfully".into(),
726    };
727    SkippedOperation {
728        metadata: selected_metadata(
729            candidate.operation.as_ref(),
730            candidate.direction,
731            candidate.matched_area_of_use.clone(),
732        ),
733        reason,
734        detail,
735    }
736}
737
738fn execute_step(step: &CompiledStep, coord: Coord3D) -> Result<Coord3D> {
739    match step {
740        CompiledStep::ProjectionForward { projection } => {
741            let (x, y) = projection.forward(coord.x, coord.y)?;
742            Ok(Coord3D::new(x, y, coord.z))
743        }
744        CompiledStep::ProjectionInverse { projection } => {
745            let (lon, lat) = projection.inverse(coord.x, coord.y)?;
746            Ok(Coord3D::new(lon, lat, coord.z))
747        }
748        CompiledStep::Helmert { params, inverse } => {
749            let (x, y, z) = if *inverse {
750                helmert::helmert_inverse(params, coord.x, coord.y, coord.z)
751            } else {
752                helmert::helmert_forward(params, coord.x, coord.y, coord.z)
753            };
754            Ok(Coord3D::new(x, y, z))
755        }
756        CompiledStep::GridShift { handle, direction } => {
757            let (lon, lat) = handle.apply(coord.x, coord.y, *direction)?;
758            Ok(Coord3D::new(lon, lat, coord.z))
759        }
760        CompiledStep::GridShiftList {
761            handles,
762            allow_null,
763            direction,
764        } => {
765            let mut last_coverage_miss = None;
766            for handle in handles.iter() {
767                match handle.apply(coord.x, coord.y, *direction) {
768                    Ok((lon, lat)) => return Ok(Coord3D::new(lon, lat, coord.z)),
769                    Err(GridError::OutsideCoverage(detail)) => {
770                        last_coverage_miss = Some(detail);
771                    }
772                    Err(error) => return Err(Error::Grid(error)),
773                }
774            }
775
776            if *allow_null {
777                return Ok(coord);
778            }
779
780            Err(Error::Grid(GridError::OutsideCoverage(
781                last_coverage_miss.unwrap_or_else(|| "no datum grid covered coordinate".into()),
782            )))
783        }
784        CompiledStep::GeodeticToGeocentric { ellipsoid } => {
785            let (x, y, z) =
786                geocentric::geodetic_to_geocentric(ellipsoid, coord.x, coord.y, coord.z);
787            Ok(Coord3D::new(x, y, z))
788        }
789        CompiledStep::GeocentricToGeodetic { ellipsoid } => {
790            let (lon, lat, h) =
791                geocentric::geocentric_to_geodetic(ellipsoid, coord.x, coord.y, coord.z);
792            Ok(Coord3D::new(lon, lat, h))
793        }
794    }
795}
796
797fn compile_pipeline(
798    source: &CrsDef,
799    target: &CrsDef,
800    operation: &CoordinateOperation,
801    direction: OperationStepDirection,
802    grid_runtime: &GridRuntime,
803) -> Result<CompiledOperationPipeline> {
804    let mut steps = SmallVec::<[CompiledStep; 8]>::new();
805
806    if let CrsDef::Projected(projected) = source {
807        steps.push(CompiledStep::ProjectionInverse {
808            projection: make_projection(&projected.method(), projected.datum())?,
809        });
810    }
811
812    if operation.id.is_none() && matches!(operation.method, OperationMethod::Identity) {
813        // Synthetic identity between semantically equivalent CRS.
814    } else {
815        compile_operation(
816            operation,
817            direction,
818            Some((source, target)),
819            grid_runtime,
820            &mut steps,
821        )?;
822    }
823
824    if let CrsDef::Projected(projected) = target {
825        steps.push(CompiledStep::ProjectionForward {
826            projection: make_projection(&projected.method(), projected.datum())?,
827        });
828    }
829
830    Ok(CompiledOperationPipeline { steps })
831}
832
833fn compile_operation(
834    operation: &CoordinateOperation,
835    direction: OperationStepDirection,
836    requested_pair: Option<(&CrsDef, &CrsDef)>,
837    grid_runtime: &GridRuntime,
838    steps: &mut SmallVec<[CompiledStep; 8]>,
839) -> Result<()> {
840    let (source_geo, target_geo) =
841        resolve_operation_geographic_pair(operation, direction, requested_pair)?;
842    match (&operation.method, direction) {
843        (OperationMethod::Identity, _) => {}
844        (OperationMethod::Helmert { params }, OperationStepDirection::Forward) => {
845            steps.push(CompiledStep::GeodeticToGeocentric {
846                ellipsoid: source_geo.datum().ellipsoid,
847            });
848            steps.push(CompiledStep::Helmert {
849                params: *params,
850                inverse: false,
851            });
852            steps.push(CompiledStep::GeocentricToGeodetic {
853                ellipsoid: target_geo.datum().ellipsoid,
854            });
855        }
856        (OperationMethod::Helmert { params }, OperationStepDirection::Reverse) => {
857            steps.push(CompiledStep::GeodeticToGeocentric {
858                ellipsoid: source_geo.datum().ellipsoid,
859            });
860            steps.push(CompiledStep::Helmert {
861                params: *params,
862                inverse: true,
863            });
864            steps.push(CompiledStep::GeocentricToGeodetic {
865                ellipsoid: target_geo.datum().ellipsoid,
866            });
867        }
868        (
869            OperationMethod::DatumShift {
870                source_to_wgs84,
871                target_to_wgs84,
872            },
873            OperationStepDirection::Forward,
874        ) => {
875            compile_to_wgs84(
876                source_to_wgs84,
877                source_geo.datum().ellipsoid,
878                grid_runtime,
879                steps,
880            )?;
881            compile_from_wgs84(
882                target_to_wgs84,
883                target_geo.datum().ellipsoid,
884                grid_runtime,
885                steps,
886            )?;
887        }
888        (
889            OperationMethod::DatumShift {
890                source_to_wgs84,
891                target_to_wgs84,
892            },
893            OperationStepDirection::Reverse,
894        ) => {
895            compile_to_wgs84(
896                target_to_wgs84,
897                source_geo.datum().ellipsoid,
898                grid_runtime,
899                steps,
900            )?;
901            compile_from_wgs84(
902                source_to_wgs84,
903                target_geo.datum().ellipsoid,
904                grid_runtime,
905                steps,
906            )?;
907        }
908        (
909            OperationMethod::GridShift {
910                grid_id,
911                direction: grid_direction,
912                ..
913            },
914            step_direction,
915        ) => {
916            let grid = registry::lookup_grid_definition(grid_id.0).ok_or_else(|| {
917                Error::Grid(crate::grid::GridError::NotFound(format!(
918                    "grid id {}",
919                    grid_id.0
920                )))
921            })?;
922            if grid.format == crate::grid::GridFormat::Unsupported {
923                return Err(Error::Grid(crate::grid::GridError::UnsupportedFormat(
924                    grid.name,
925                )));
926            }
927            let handle = grid_runtime.resolve_handle(&grid)?;
928            let direction = match step_direction {
929                OperationStepDirection::Forward => *grid_direction,
930                OperationStepDirection::Reverse => grid_direction.inverse(),
931            };
932            steps.push(CompiledStep::GridShift { handle, direction });
933        }
934        (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Forward) => {
935            for step in child_steps {
936                let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
937                    Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
938                })?;
939                compile_operation(&child, step.direction, None, grid_runtime, steps)?;
940            }
941        }
942        (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Reverse) => {
943            for step in child_steps.iter().rev() {
944                let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
945                    Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
946                })?;
947                compile_operation(&child, step.direction.inverse(), None, grid_runtime, steps)?;
948            }
949        }
950        (OperationMethod::Projection { .. }, _) | (OperationMethod::AxisUnitNormalize, _) => {
951            return Err(Error::UnsupportedProjection(
952                "direct projection operations are not emitted by the embedded selector".into(),
953            ));
954        }
955    }
956    Ok(())
957}
958
959fn compile_to_wgs84(
960    transform: &DatumToWgs84,
961    source_ellipsoid: Ellipsoid,
962    grid_runtime: &GridRuntime,
963    steps: &mut SmallVec<[CompiledStep; 8]>,
964) -> Result<()> {
965    match transform {
966        DatumToWgs84::Identity => Ok(()),
967        DatumToWgs84::Helmert(params) => {
968            steps.push(CompiledStep::GeodeticToGeocentric {
969                ellipsoid: source_ellipsoid,
970            });
971            steps.push(CompiledStep::Helmert {
972                params: *params,
973                inverse: false,
974            });
975            steps.push(CompiledStep::GeocentricToGeodetic {
976                ellipsoid: ellipsoid::WGS84,
977            });
978            Ok(())
979        }
980        DatumToWgs84::GridShift(grids) => {
981            compile_grid_shift_list(grids, GridShiftDirection::Forward, grid_runtime, steps)
982        }
983        DatumToWgs84::Unknown => Err(Error::OperationSelection(
984            "datum has no known path to WGS84".into(),
985        )),
986    }
987}
988
989fn compile_from_wgs84(
990    transform: &DatumToWgs84,
991    target_ellipsoid: Ellipsoid,
992    grid_runtime: &GridRuntime,
993    steps: &mut SmallVec<[CompiledStep; 8]>,
994) -> Result<()> {
995    match transform {
996        DatumToWgs84::Identity => Ok(()),
997        DatumToWgs84::Helmert(params) => {
998            steps.push(CompiledStep::GeodeticToGeocentric {
999                ellipsoid: ellipsoid::WGS84,
1000            });
1001            steps.push(CompiledStep::Helmert {
1002                params: *params,
1003                inverse: true,
1004            });
1005            steps.push(CompiledStep::GeocentricToGeodetic {
1006                ellipsoid: target_ellipsoid,
1007            });
1008            Ok(())
1009        }
1010        DatumToWgs84::GridShift(grids) => {
1011            compile_grid_shift_list(grids, GridShiftDirection::Reverse, grid_runtime, steps)
1012        }
1013        DatumToWgs84::Unknown => Err(Error::OperationSelection(
1014            "datum has no known path from WGS84".into(),
1015        )),
1016    }
1017}
1018
1019fn compile_grid_shift_list(
1020    grids: &DatumGridShift,
1021    direction: GridShiftDirection,
1022    grid_runtime: &GridRuntime,
1023    steps: &mut SmallVec<[CompiledStep; 8]>,
1024) -> Result<()> {
1025    let mut handles = Vec::<GridHandle>::new();
1026    let mut allow_null = false;
1027    let mut required_grid_seen = false;
1028
1029    for entry in grids.entries() {
1030        match entry {
1031            DatumGridShiftEntry::Null => {
1032                allow_null = true;
1033                break;
1034            }
1035            DatumGridShiftEntry::Grid {
1036                definition,
1037                optional,
1038            } => {
1039                if !optional {
1040                    required_grid_seen = true;
1041                }
1042                match grid_runtime.resolve_handle(definition) {
1043                    Ok(handle) => handles.push(handle),
1044                    Err(GridError::Unavailable(_) | GridError::NotFound(_)) if *optional => {}
1045                    Err(error) => return Err(Error::Grid(error)),
1046                }
1047            }
1048        }
1049    }
1050
1051    if handles.is_empty() && !allow_null {
1052        if required_grid_seen {
1053            return Err(Error::Grid(GridError::Unavailable(
1054                "no required datum grid could be loaded".into(),
1055            )));
1056        }
1057        return Err(Error::Grid(GridError::Unavailable(
1058            "no optional datum grid could be loaded".into(),
1059        )));
1060    }
1061
1062    steps.push(CompiledStep::GridShiftList {
1063        handles: handles.into_boxed_slice(),
1064        allow_null,
1065        direction,
1066    });
1067    Ok(())
1068}
1069
1070fn resolve_operation_geographic_pair(
1071    operation: &CoordinateOperation,
1072    direction: OperationStepDirection,
1073    requested_pair: Option<(&CrsDef, &CrsDef)>,
1074) -> Result<(CrsDef, CrsDef)> {
1075    if let (Some(source_code), Some(target_code)) =
1076        (operation.source_crs_epsg, operation.target_crs_epsg)
1077    {
1078        let source = registry::lookup_epsg(match direction {
1079            OperationStepDirection::Forward => source_code,
1080            OperationStepDirection::Reverse => target_code,
1081        })
1082        .ok_or_else(|| {
1083            Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
1084        })?;
1085        let target = registry::lookup_epsg(match direction {
1086            OperationStepDirection::Forward => target_code,
1087            OperationStepDirection::Reverse => source_code,
1088        })
1089        .ok_or_else(|| {
1090            Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
1091        })?;
1092        return Ok((source, target));
1093    }
1094
1095    if let Some((source, target)) = requested_pair {
1096        return Ok((source.clone(), target.clone()));
1097    }
1098
1099    Err(Error::OperationSelection(format!(
1100        "operation {} is missing source/target CRS metadata",
1101        operation.name
1102    )))
1103}
1104
1105#[cfg(feature = "rayon")]
1106fn should_parallelize(len: usize) -> bool {
1107    if len == 0 {
1108        return false;
1109    }
1110
1111    let threads = rayon::current_num_threads().max(1);
1112    len >= PARALLEL_MIN_TOTAL_ITEMS.max(threads.saturating_mul(PARALLEL_MIN_ITEMS_PER_THREAD))
1113}
1114
1115#[cfg(feature = "rayon")]
1116fn parallel_chunk_size(len: usize) -> usize {
1117    let threads = rayon::current_num_threads().max(1);
1118    let target_chunks = threads.saturating_mul(PARALLEL_CHUNKS_PER_THREAD).max(1);
1119    let chunk_size = len.div_ceil(target_chunks);
1120    chunk_size.max(PARALLEL_MIN_CHUNK_SIZE)
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use crate::crs::{CrsDef, GeographicCrsDef, LinearUnit, ProjectedCrsDef, ProjectionMethod};
1127    use crate::datum::{self, DatumToWgs84};
1128    use crate::operation::{
1129        AreaOfInterest, OperationMatchKind, SelectionPolicy, SelectionReason,
1130        SkippedOperationReason,
1131    };
1132
1133    const US_FOOT_TO_METER: f64 = 0.3048006096012192;
1134
1135    fn expect_transform_error(result: Result<Transform>) -> Error {
1136        match result {
1137            Ok(_) => panic!("expected transform construction to fail"),
1138            Err(err) => err,
1139        }
1140    }
1141
1142    #[test]
1143    fn identity_same_crs() {
1144        let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
1145        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1146        assert_eq!(x, -74.006);
1147        assert_eq!(y, 40.7128);
1148    }
1149
1150    #[test]
1151    fn wgs84_to_web_mercator() {
1152        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1153        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1154        assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
1155        assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
1156    }
1157
1158    #[test]
1159    fn web_mercator_to_wgs84() {
1160        let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
1161        let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
1162        assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
1163        assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
1164    }
1165
1166    #[test]
1167    fn roundtrip_4326_3857() {
1168        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1169        let inv = fwd.inverse().unwrap();
1170
1171        let original = (-74.0445, 40.6892);
1172        let projected = fwd.convert(original).unwrap();
1173        let back = inv.convert(projected).unwrap();
1174
1175        assert!((back.0 - original.0).abs() < 1e-8);
1176        assert!((back.1 - original.1).abs() < 1e-8);
1177    }
1178
1179    #[test]
1180    fn wgs84_to_utm_18n() {
1181        let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
1182        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1183        assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
1184        assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
1185    }
1186
1187    #[test]
1188    fn equivalent_meter_and_foot_state_plane_crs_match_after_unit_conversion() {
1189        let coord = (-80.8431, 35.2271);
1190        let meter_tx = Transform::new("EPSG:4326", "EPSG:32119").unwrap();
1191        let foot_tx = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
1192
1193        let (mx, my) = meter_tx.convert(coord).unwrap();
1194        let (fx, fy) = foot_tx.convert(coord).unwrap();
1195
1196        assert!((fx * US_FOOT_TO_METER - mx).abs() < 0.02);
1197        assert!((fy * US_FOOT_TO_METER - my).abs() < 0.02);
1198    }
1199
1200    #[test]
1201    fn inverse_transform_accepts_native_projected_units_for_foot_crs() {
1202        let coord = (-80.8431, 35.2271);
1203        let forward = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
1204        let inverse = Transform::new("EPSG:2264", "EPSG:4326").unwrap();
1205
1206        let projected = forward.convert(coord).unwrap();
1207        let roundtrip = inverse.convert(projected).unwrap();
1208
1209        assert!((roundtrip.0 - coord.0).abs() < 1e-8);
1210        assert!((roundtrip.1 - coord.1).abs() < 1e-8);
1211    }
1212
1213    #[test]
1214    fn utm_to_web_mercator() {
1215        let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
1216        let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
1217        assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
1218    }
1219
1220    #[test]
1221    fn wgs84_to_polar_stereo_3413() {
1222        let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
1223        let (x, y) = t.convert((-45.0, 90.0)).unwrap();
1224        assert!(x.abs() < 1.0, "x = {x}");
1225        assert!(y.abs() < 1.0, "y = {y}");
1226    }
1227
1228    #[test]
1229    fn roundtrip_4326_3413() {
1230        let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
1231        let inv = fwd.inverse().unwrap();
1232
1233        let original = (-45.0, 75.0);
1234        let projected = fwd.convert(original).unwrap();
1235        let back = inv.convert(projected).unwrap();
1236
1237        assert!((back.0 - original.0).abs() < 1e-6);
1238        assert!((back.1 - original.1).abs() < 1e-6);
1239    }
1240
1241    #[test]
1242    fn geographic_to_geographic_same_datum_is_identity() {
1243        let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
1244        let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
1245        assert_eq!(lon, -74.006);
1246        assert_eq!(lat, 40.7128);
1247        assert_eq!(t.selected_operation().name, "Identity");
1248    }
1249
1250    #[test]
1251    fn unknown_crs_error() {
1252        let result = Transform::new("EPSG:99999", "EPSG:4326");
1253        assert!(result.is_err());
1254    }
1255
1256    #[test]
1257    fn cross_datum_nad27_to_wgs84() {
1258        let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1259        let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
1260        assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
1261        assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
1262        assert!(!t.selected_operation().approximate);
1263    }
1264
1265    #[test]
1266    fn explicit_grid_operation_compiles() {
1267        let t = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1268            .unwrap();
1269        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1693)));
1270    }
1271
1272    #[test]
1273    fn explicit_operation_rejects_incompatible_crs_pair() {
1274        let err = match Transform::from_operation(
1275            CoordinateOperationId(1693),
1276            "EPSG:4326",
1277            "EPSG:3857",
1278        ) {
1279            Ok(_) => panic!("incompatible operation should be rejected"),
1280            Err(err) => err,
1281        };
1282        assert!(matches!(err, Error::OperationSelection(_)));
1283        assert!(err.to_string().contains("not compatible"));
1284    }
1285
1286    #[test]
1287    fn explicit_selection_options_choose_grid_operation() {
1288        let t = Transform::with_selection_options(
1289            "EPSG:4267",
1290            "EPSG:4269",
1291            SelectionOptions {
1292                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1293                    -80.5041667,
1294                    44.5458333,
1295                ))),
1296                ..SelectionOptions::default()
1297            },
1298        )
1299        .unwrap();
1300        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
1301        assert!(!t.selection_diagnostics().approximate);
1302    }
1303
1304    #[test]
1305    fn source_crs_area_of_interest_is_normalized_before_selection() {
1306        let to_projected = Transform::new("EPSG:4267", "EPSG:26717").unwrap();
1307        let projected = to_projected.convert((-80.5041667, 44.5458333)).unwrap();
1308
1309        let t = Transform::with_selection_options(
1310            "EPSG:26717",
1311            "EPSG:4269",
1312            SelectionOptions {
1313                area_of_interest: Some(AreaOfInterest::source_crs_point(Coord::new(
1314                    projected.0,
1315                    projected.1,
1316                ))),
1317                ..SelectionOptions::default()
1318            },
1319        )
1320        .unwrap();
1321
1322        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
1323        assert_eq!(
1324            t.selection_diagnostics().selected_match_kind,
1325            OperationMatchKind::DerivedGeographic
1326        );
1327    }
1328
1329    #[test]
1330    fn area_of_interest_rejects_invalid_geographic_bounds() {
1331        for bounds in [
1332            Bounds::new(10.0, 5.0, -10.0, 20.0),
1333            Bounds::new(f64::NAN, 5.0, 10.0, 20.0),
1334            Bounds::new(-181.0, 5.0, -170.0, 20.0),
1335            Bounds::new(-80.0, -91.0, -70.0, -80.0),
1336        ] {
1337            let err = expect_transform_error(Transform::with_selection_options(
1338                "EPSG:4267",
1339                "EPSG:4269",
1340                SelectionOptions {
1341                    area_of_interest: Some(AreaOfInterest::geographic_bounds(bounds)),
1342                    ..SelectionOptions::default()
1343                },
1344            ));
1345
1346            assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
1347        }
1348    }
1349
1350    #[test]
1351    fn area_of_interest_validates_geographic_source_and_target_bounds() {
1352        for area_of_interest in [
1353            AreaOfInterest::source_crs_bounds(Bounds::new(-181.0, 40.0, -170.0, 45.0)),
1354            AreaOfInterest::target_crs_bounds(Bounds::new(-80.0, 40.0, -70.0, 91.0)),
1355        ] {
1356            let err = expect_transform_error(Transform::with_selection_options(
1357                "EPSG:4267",
1358                "EPSG:4269",
1359                SelectionOptions {
1360                    area_of_interest: Some(area_of_interest),
1361                    ..SelectionOptions::default()
1362                },
1363            ));
1364
1365            assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
1366        }
1367    }
1368
1369    #[test]
1370    fn area_of_interest_rejects_invalid_projected_bounds_before_sampling() {
1371        let err = expect_transform_error(Transform::with_selection_options(
1372            "EPSG:3857",
1373            "EPSG:4326",
1374            SelectionOptions {
1375                area_of_interest: Some(AreaOfInterest::source_crs_bounds(Bounds::new(
1376                    10.0, 0.0, -10.0, 10.0,
1377                ))),
1378                ..SelectionOptions::default()
1379            },
1380        ));
1381
1382        assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
1383    }
1384
1385    #[test]
1386    fn selection_diagnostics_capture_accuracy_preference() {
1387        let t = Transform::with_selection_options(
1388            "EPSG:4267",
1389            "EPSG:4269",
1390            SelectionOptions {
1391                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1392                    -80.5041667,
1393                    44.5458333,
1394                ))),
1395                ..SelectionOptions::default()
1396            },
1397        )
1398        .unwrap();
1399
1400        assert!(t
1401            .selection_diagnostics()
1402            .selected_reasons
1403            .contains(&SelectionReason::AccuracyPreferred));
1404    }
1405
1406    #[test]
1407    fn selection_diagnostics_capture_policy_filtered_candidates() {
1408        let t = Transform::with_selection_options(
1409            "EPSG:4267",
1410            "EPSG:4326",
1411            SelectionOptions {
1412                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1413                    -80.5041667,
1414                    44.5458333,
1415                ))),
1416                policy: SelectionPolicy::RequireGrids,
1417                ..SelectionOptions::default()
1418            },
1419        )
1420        .unwrap();
1421
1422        assert!(t
1423            .selection_diagnostics()
1424            .skipped_operations
1425            .iter()
1426            .any(|skipped| { matches!(skipped.reason, SkippedOperationReason::PolicyFiltered) }));
1427    }
1428
1429    #[test]
1430    fn selection_diagnostics_capture_area_mismatch_candidates() {
1431        let t = Transform::with_selection_options(
1432            "EPSG:4267",
1433            "EPSG:4269",
1434            SelectionOptions {
1435                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1436                    -80.5041667,
1437                    44.5458333,
1438                ))),
1439                policy: SelectionPolicy::RequireExactAreaMatch,
1440                ..SelectionOptions::default()
1441            },
1442        )
1443        .unwrap();
1444
1445        assert!(t
1446            .selection_diagnostics()
1447            .skipped_operations
1448            .iter()
1449            .any(|skipped| {
1450                matches!(skipped.reason, SkippedOperationReason::AreaOfUseMismatch)
1451            }));
1452    }
1453
1454    #[test]
1455    fn grid_coverage_miss_falls_back_under_best_available_policy() {
1456        let t = Transform::with_selection_options(
1457            "EPSG:4267",
1458            "EPSG:4269",
1459            SelectionOptions {
1460                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1461                    -80.5041667,
1462                    44.5458333,
1463                ))),
1464                ..SelectionOptions::default()
1465            },
1466        )
1467        .unwrap();
1468
1469        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
1470        assert!(!t.selection_diagnostics().fallback_operations.is_empty());
1471
1472        let outcome = t.convert_with_diagnostics((0.0, 0.0)).unwrap();
1473        let plain = t.convert((0.0, 0.0)).unwrap();
1474
1475        assert_eq!(plain, outcome.coord);
1476        assert_ne!(outcome.operation.id, Some(CoordinateOperationId(1313)));
1477        assert!(outcome
1478            .grid_coverage_misses
1479            .iter()
1480            .any(|miss| miss.operation.id == Some(CoordinateOperationId(1313))));
1481    }
1482
1483    #[test]
1484    fn grid_coverage_miss_does_not_use_non_grid_fallback_when_grids_are_required() {
1485        let t = Transform::with_selection_options(
1486            "EPSG:4267",
1487            "EPSG:4269",
1488            SelectionOptions {
1489                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1490                    -80.5041667,
1491                    44.5458333,
1492                ))),
1493                policy: SelectionPolicy::RequireGrids,
1494                ..SelectionOptions::default()
1495            },
1496        )
1497        .unwrap();
1498
1499        assert!(t
1500            .selection_diagnostics()
1501            .fallback_operations
1502            .iter()
1503            .all(|operation| operation.uses_grids));
1504
1505        let err = t.convert((0.0, 0.0)).unwrap_err();
1506
1507        assert!(matches!(err, Error::Grid(GridError::OutsideCoverage(_))));
1508    }
1509
1510    #[test]
1511    fn cross_datum_roundtrip_nad27() {
1512        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1513        let inv = fwd.inverse().unwrap();
1514        let original = (-90.0, 45.0);
1515        let shifted = fwd.convert(original).unwrap();
1516        let back = inv.convert(shifted).unwrap();
1517        assert!((back.0 - original.0).abs() < 1e-6);
1518        assert!((back.1 - original.1).abs() < 1e-6);
1519    }
1520
1521    #[test]
1522    fn inverse_preserves_explicit_operation_selection() {
1523        let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1524            .unwrap();
1525        let inv = fwd.inverse().unwrap();
1526
1527        assert_eq!(
1528            fwd.selected_operation().id,
1529            Some(CoordinateOperationId(1693))
1530        );
1531        assert_eq!(
1532            inv.selected_operation().id,
1533            Some(CoordinateOperationId(1693))
1534        );
1535    }
1536
1537    #[test]
1538    fn inverse_reorients_selected_metadata_and_diagnostics() {
1539        let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1540            .unwrap();
1541        let inv = fwd.inverse().unwrap();
1542
1543        assert_eq!(inv.source_crs().epsg(), 4326);
1544        assert_eq!(inv.target_crs().epsg(), 4267);
1545        assert_eq!(
1546            inv.selected_operation().direction,
1547            OperationStepDirection::Reverse
1548        );
1549        assert_eq!(inv.selected_operation().source_crs_epsg, Some(4326));
1550        assert_eq!(inv.selected_operation().target_crs_epsg, Some(4267));
1551        assert_eq!(
1552            inv.selection_diagnostics()
1553                .selected_operation
1554                .source_crs_epsg,
1555            Some(4326)
1556        );
1557        assert_eq!(
1558            inv.selection_diagnostics()
1559                .selected_operation
1560                .target_crs_epsg,
1561            Some(4267)
1562        );
1563    }
1564
1565    #[test]
1566    fn cross_datum_osgb36_to_wgs84() {
1567        let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
1568        let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap();
1569        assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
1570        assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
1571    }
1572
1573    #[test]
1574    fn wgs84_to_web_mercator_3d_preserves_height() {
1575        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1576        let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
1577        assert!((x - (-8238310.0)).abs() < 100.0);
1578        assert!((y - 4970072.0).abs() < 100.0);
1579        assert!((z - 123.45).abs() < 1e-12);
1580    }
1581
1582    #[test]
1583    fn cross_datum_roundtrip_nad27_3d() {
1584        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1585        let inv = fwd.inverse().unwrap();
1586        let original = (-90.0, 45.0, 250.0);
1587        let shifted = fwd.convert_3d(original).unwrap();
1588        let back = inv.convert_3d(shifted).unwrap();
1589        assert!((back.0 - original.0).abs() < 1e-6);
1590        assert!((back.1 - original.1).abs() < 1e-6);
1591        assert!((back.2 - original.2).abs() < 1e-12);
1592    }
1593
1594    #[test]
1595    fn identical_custom_projected_crs_is_identity() {
1596        let from = CrsDef::Projected(ProjectedCrsDef::new(
1597            0,
1598            datum::WGS84,
1599            ProjectionMethod::WebMercator,
1600            LinearUnit::metre(),
1601            "Custom Web Mercator A",
1602        ));
1603        let to = CrsDef::Projected(ProjectedCrsDef::new(
1604            0,
1605            datum::WGS84,
1606            ProjectionMethod::WebMercator,
1607            LinearUnit::metre(),
1608            "Custom Web Mercator B",
1609        ));
1610
1611        let t = Transform::from_crs_defs(&from, &to).unwrap();
1612        assert_eq!(t.selected_operation().name, "Identity");
1613    }
1614
1615    #[test]
1616    fn unknown_custom_datums_do_not_collapse_to_identity() {
1617        let unknown = datum::Datum {
1618            ellipsoid: datum::WGS84.ellipsoid,
1619            to_wgs84: DatumToWgs84::Unknown,
1620        };
1621        let from = CrsDef::Projected(ProjectedCrsDef::new(
1622            0,
1623            unknown.clone(),
1624            ProjectionMethod::WebMercator,
1625            LinearUnit::metre(),
1626            "Unknown A",
1627        ));
1628        let to = CrsDef::Projected(ProjectedCrsDef::new(
1629            0,
1630            unknown,
1631            ProjectionMethod::WebMercator,
1632            LinearUnit::metre(),
1633            "Unknown B",
1634        ));
1635
1636        let err = match Transform::from_crs_defs(&from, &to) {
1637            Ok(_) => panic!("unknown custom datums should not build a transform"),
1638            Err(err) => err,
1639        };
1640        assert!(
1641            err.to_string().contains("no compatible operation found")
1642                || err
1643                    .to_string()
1644                    .contains("legacy Helmert fallback unavailable")
1645        );
1646    }
1647
1648    #[test]
1649    fn approximate_fallback_is_modeled_as_helmert_operation() {
1650        let from = CrsDef::Geographic(GeographicCrsDef::new(0, datum::NAD27, "Custom NAD27"));
1651        let to = CrsDef::Geographic(GeographicCrsDef::new(0, datum::OSGB36, "Custom OSGB36"));
1652
1653        let t = Transform::from_crs_defs(&from, &to).unwrap();
1654
1655        assert!(t.selected_operation().approximate);
1656        assert!(matches!(
1657            t.selected_operation_definition.method,
1658            OperationMethod::Helmert { .. }
1659        ));
1660    }
1661
1662    #[test]
1663    fn inverse_exposes_swapped_crs() {
1664        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1665        let inv = fwd.inverse().unwrap();
1666
1667        assert_eq!(fwd.source_crs().epsg(), 4326);
1668        assert_eq!(fwd.target_crs().epsg(), 3857);
1669        assert_eq!(inv.source_crs().epsg(), 3857);
1670        assert_eq!(inv.target_crs().epsg(), 4326);
1671    }
1672
1673    #[test]
1674    fn transform_bounds_web_mercator() {
1675        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1676        let bounds = Bounds::new(-74.3, 40.45, -73.65, 40.95);
1677
1678        let result = t.transform_bounds(bounds, 8).unwrap();
1679
1680        assert!(result.min_x < -8_200_000.0);
1681        assert!(result.max_x < -8_100_000.0);
1682        assert!(result.min_y > 4_900_000.0);
1683        assert!(result.max_y > result.min_y);
1684    }
1685
1686    #[test]
1687    fn transform_bounds_rejects_invalid_input() {
1688        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1689        let err = t
1690            .transform_bounds(Bounds::new(10.0, 5.0, -10.0, 20.0), 0)
1691            .unwrap_err();
1692
1693        assert!(matches!(err, Error::OutOfRange(_)));
1694    }
1695
1696    #[test]
1697    fn batch_transform() {
1698        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1699        let coords: Vec<(f64, f64)> = (0..10)
1700            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1701            .collect();
1702
1703        let results = t.convert_batch(&coords).unwrap();
1704        assert_eq!(results.len(), 10);
1705        for (x, _y) in &results {
1706            assert!(*x < 0.0);
1707        }
1708    }
1709
1710    #[test]
1711    fn batch_transform_3d() {
1712        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1713        let coords: Vec<(f64, f64, f64)> = (0..10)
1714            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
1715            .collect();
1716
1717        let results = t.convert_batch_3d(&coords).unwrap();
1718        assert_eq!(results.len(), 10);
1719        for (index, (x, _y, z)) in results.iter().enumerate() {
1720            assert!(*x < 0.0);
1721            assert!((*z - index as f64).abs() < 1e-12);
1722        }
1723    }
1724
1725    #[test]
1726    fn batch_transform_coords_in_place_matches_vec_batch() {
1727        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1728        let input: Vec<Coord> = (0..10)
1729            .map(|i| Coord::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1730            .collect();
1731        let expected = t.convert_batch(&input).unwrap();
1732        let mut actual = input.clone();
1733
1734        t.convert_coords_in_place(&mut actual).unwrap();
1735
1736        assert_eq!(actual, expected);
1737    }
1738
1739    #[test]
1740    fn batch_transform_coords_into_reuses_output_slice() {
1741        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1742        let input: Vec<Coord> = (0..10)
1743            .map(|i| Coord::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1744            .collect();
1745        let expected = t.convert_batch(&input).unwrap();
1746        let mut actual = vec![Coord::new(0.0, 0.0); input.len()];
1747
1748        t.convert_coords_into(&input, &mut actual).unwrap();
1749
1750        assert_eq!(actual, expected);
1751    }
1752
1753    #[test]
1754    fn batch_transform_coords_into_rejects_mismatched_output_len() {
1755        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1756        let input = [Coord::new(-74.0, 40.0), Coord::new(-73.0, 41.0)];
1757        let mut output = [Coord::new(0.0, 0.0)];
1758
1759        let err = t.convert_coords_into(&input, &mut output).unwrap_err();
1760
1761        assert!(matches!(err, Error::OutOfRange(_)));
1762    }
1763
1764    #[test]
1765    fn batch_transform_coords_3d_in_place_and_into_preserve_height() {
1766        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1767        let input: Vec<Coord3D> = (0..10)
1768            .map(|i| Coord3D::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
1769            .collect();
1770        let expected = t.convert_batch_3d(&input).unwrap();
1771
1772        let mut in_place = input.clone();
1773        t.convert_coords_3d_in_place(&mut in_place).unwrap();
1774        assert_eq!(in_place, expected);
1775
1776        let mut output = vec![Coord3D::new(0.0, 0.0, 0.0); input.len()];
1777        t.convert_coords_3d_into(&input, &mut output).unwrap();
1778        assert_eq!(output, expected);
1779    }
1780
1781    #[test]
1782    fn coord_type() {
1783        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1784        let c = Coord::new(-74.006, 40.7128);
1785        let result = t.convert(c).unwrap();
1786        assert!((result.x - (-8238310.0)).abs() < 100.0);
1787    }
1788
1789    #[cfg(feature = "geo-types")]
1790    #[test]
1791    fn geo_types_coord() {
1792        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1793        let c = geo_types::Coord {
1794            x: -74.006,
1795            y: 40.7128,
1796        };
1797        let result: geo_types::Coord<f64> = t.convert(c).unwrap();
1798        assert!((result.x - (-8238310.0)).abs() < 100.0);
1799    }
1800
1801    #[cfg(feature = "rayon")]
1802    #[test]
1803    fn parallel_batch_transform() {
1804        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1805        let coords: Vec<(f64, f64)> = (0..100)
1806            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
1807            .collect();
1808
1809        let results = t.convert_batch_parallel(&coords).unwrap();
1810        assert_eq!(results.len(), 100);
1811    }
1812
1813    #[cfg(feature = "rayon")]
1814    #[test]
1815    fn parallel_batch_transform_matches_sequential_on_large_input() {
1816        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1817        let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
1818        let coords: Vec<(f64, f64)> = (0..len)
1819            .map(|i| (-179.0 + i as f64 * 0.0001, -80.0 + i as f64 * 0.00005))
1820            .collect();
1821
1822        let sequential = t.convert_batch(&coords).unwrap();
1823        let parallel = t.convert_batch_parallel(&coords).unwrap();
1824
1825        assert_eq!(parallel, sequential);
1826    }
1827
1828    #[cfg(feature = "rayon")]
1829    #[test]
1830    fn parallel_batch_transform_3d() {
1831        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1832        let coords: Vec<(f64, f64, f64)> = (0..100)
1833            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01, i as f64))
1834            .collect();
1835
1836        let results = t.convert_batch_parallel_3d(&coords).unwrap();
1837        assert_eq!(results.len(), 100);
1838    }
1839
1840    #[cfg(feature = "rayon")]
1841    #[test]
1842    fn parallel_batch_transform_3d_matches_sequential_on_large_input() {
1843        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1844        let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
1845        let coords: Vec<(f64, f64, f64)> = (0..len)
1846            .map(|i| {
1847                (
1848                    -179.0 + i as f64 * 0.0001,
1849                    -80.0 + i as f64 * 0.00005,
1850                    i as f64,
1851                )
1852            })
1853            .collect();
1854
1855        let sequential = t.convert_batch_3d(&coords).unwrap();
1856        let parallel = t.convert_batch_parallel_3d(&coords).unwrap();
1857
1858        assert_eq!(parallel, sequential);
1859    }
1860}