Skip to main content

proj_core/
transform.rs

1use crate::coord::{Bounds, Coord, Coord3D, Transformable, Transformable3D};
2use crate::crs::{CrsDef, LinearUnit, VerticalCrsDef};
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    VerticalGridOffsetConvention, VerticalGridOperation, VerticalGridProvenance,
13    VerticalTransformAction, VerticalTransformDiagnostics,
14};
15use crate::projection::{make_projection, Projection};
16use crate::registry;
17use crate::selector;
18use crate::{ellipsoid, geocentric};
19use smallvec::SmallVec;
20use std::sync::Arc;
21
22#[cfg(feature = "rayon")]
23const PARALLEL_MIN_TOTAL_ITEMS: usize = 16_384;
24#[cfg(feature = "rayon")]
25const PARALLEL_MIN_ITEMS_PER_THREAD: usize = 4_096;
26#[cfg(feature = "rayon")]
27const PARALLEL_CHUNKS_PER_THREAD: usize = 4;
28#[cfg(feature = "rayon")]
29const PARALLEL_MIN_CHUNK_SIZE: usize = 1_024;
30
31/// A reusable coordinate transformation between two CRS.
32pub struct Transform {
33    source: CrsDef,
34    target: CrsDef,
35    selected_operation_definition: CoordinateOperation,
36    selected_direction: OperationStepDirection,
37    selected_operation: CoordinateOperationMetadata,
38    diagnostics: OperationSelectionDiagnostics,
39    vertical_transform: VerticalTransform,
40    selection_options: SelectionOptions,
41    pipeline: CompiledOperationPipeline,
42    fallback_pipelines: Vec<CompiledOperationFallback>,
43}
44
45struct CompiledOperationPipeline {
46    steps: SmallVec<[CompiledStep; 8]>,
47}
48
49struct CompiledOperationFallback {
50    metadata: CoordinateOperationMetadata,
51    pipeline: CompiledOperationPipeline,
52}
53
54struct PipelineExecutionOutcome {
55    coord: Coord3D,
56    vertical: VerticalTransformDiagnostics,
57}
58
59enum CompiledStep {
60    ProjectionForward {
61        projection: Projection,
62    },
63    ProjectionInverse {
64        projection: Projection,
65    },
66    Helmert {
67        params: HelmertParams,
68        inverse: bool,
69    },
70    GridShift {
71        handle: GridHandle,
72        direction: GridShiftDirection,
73    },
74    GridShiftList {
75        handles: Box<[GridHandle]>,
76        allow_null: bool,
77        direction: GridShiftDirection,
78    },
79    GeodeticToGeocentric {
80        ellipsoid: Ellipsoid,
81    },
82    GeocentricToGeodetic {
83        ellipsoid: Ellipsoid,
84    },
85}
86
87enum VerticalTransform {
88    None {
89        diagnostics: VerticalTransformDiagnostics,
90    },
91    Preserve {
92        diagnostics: VerticalTransformDiagnostics,
93    },
94    UnitConvert {
95        source_unit: LinearUnit,
96        target_unit: LinearUnit,
97        diagnostics: VerticalTransformDiagnostics,
98    },
99    GridShiftList {
100        shifts: Box<[CompiledVerticalGridShift]>,
101        diagnostics: VerticalTransformDiagnostics,
102    },
103}
104
105struct CompiledVerticalGridShift {
106    handle: GridHandle,
107    direction: VerticalGridShiftDirection,
108    source_unit: LinearUnit,
109    target_unit: LinearUnit,
110    sample_horizontal: VerticalSampleHorizontal,
111    diagnostics: VerticalTransformDiagnostics,
112}
113
114#[derive(Clone, Copy)]
115enum VerticalGridShiftDirection {
116    EllipsoidToGravity,
117    GravityToEllipsoid,
118}
119
120enum VerticalSampleHorizontal {
121    Geographic,
122    Projected {
123        projection: Arc<Projection>,
124        linear_unit: LinearUnit,
125    },
126    Transform(Box<Transform>),
127}
128
129impl VerticalSampleHorizontal {
130    fn compile(
131        source: &CrsDef,
132        grid_horizontal_crs_epsg: Option<u32>,
133        options: &SelectionOptions,
134    ) -> Result<Self> {
135        if let Some(epsg) = grid_horizontal_crs_epsg {
136            return Self::compile_for_grid_crs(source, epsg, options);
137        }
138
139        if let Some(projected) = source.as_projected() {
140            return Ok(Self::Projected {
141                projection: Arc::new(make_projection(&projected.method(), projected.datum())?),
142                linear_unit: projected.linear_unit(),
143            });
144        }
145        if source.as_geographic().is_some() {
146            return Ok(Self::Geographic);
147        }
148        Err(Error::InvalidDefinition(
149            "vertical grid transforms require a horizontal CRS component".into(),
150        ))
151    }
152
153    fn compile_for_grid_crs(
154        source: &CrsDef,
155        grid_horizontal_crs_epsg: u32,
156        options: &SelectionOptions,
157    ) -> Result<Self> {
158        let source_base = source.base_geographic_crs_epsg();
159        if source_base == Some(grid_horizontal_crs_epsg) {
160            return Self::compile(source, None, options);
161        }
162
163        let grid_crs = registry::lookup_epsg(grid_horizontal_crs_epsg).ok_or_else(|| {
164            Error::UnknownCrs(format!(
165                "unknown vertical grid horizontal CRS EPSG:{grid_horizontal_crs_epsg}"
166            ))
167        })?;
168        if !grid_crs.is_geographic() {
169            return Err(Error::OperationSelection(format!(
170                "vertical grid horizontal CRS EPSG:{grid_horizontal_crs_epsg} is not a supported geographic sampling CRS"
171            )));
172        }
173
174        let mut horizontal_options = options.clone();
175        horizontal_options.vertical_grid_operations.clear();
176        let transform = Transform::from_horizontal_components_with_selection_options(
177            source,
178            &grid_crs,
179            horizontal_options,
180        )?;
181        Ok(Self::Transform(Box::new(transform)))
182    }
183
184    fn lon_lat_radians(&self, x: f64, y: f64) -> Result<(f64, f64)> {
185        match self {
186            Self::Geographic => Ok((x.to_radians(), y.to_radians())),
187            Self::Projected {
188                projection,
189                linear_unit,
190            } => projection.inverse(linear_unit.to_meters(x), linear_unit.to_meters(y)),
191            Self::Transform(transform) => {
192                let grid_coord = transform.convert((x, y))?;
193                Ok((grid_coord.0.to_radians(), grid_coord.1.to_radians()))
194            }
195        }
196    }
197}
198
199struct VerticalApplyOutcome {
200    z: f64,
201    diagnostics: VerticalTransformDiagnostics,
202}
203
204impl VerticalTransform {
205    fn apply_z(&self, coord: Coord3D) -> Result<f64> {
206        match self {
207            Self::None { .. } | Self::Preserve { .. } => Ok(coord.z),
208            Self::UnitConvert {
209                source_unit,
210                target_unit,
211                ..
212            } => Ok(target_unit.from_meters(source_unit.to_meters(coord.z))),
213            Self::GridShiftList { shifts, .. } => {
214                let mut last_coverage_error = None;
215                for shift in shifts {
216                    match apply_vertical_grid_shift(shift, coord) {
217                        Ok(z) => return Ok(z),
218                        Err(Error::Grid(crate::grid::GridError::OutsideCoverage(detail))) => {
219                            last_coverage_error = Some(detail);
220                        }
221                        Err(error) => return Err(error),
222                    }
223                }
224                Err(Error::Grid(crate::grid::GridError::OutsideCoverage(
225                    last_coverage_error.unwrap_or_else(|| "vertical grid coverage miss".into()),
226                )))
227            }
228        }
229    }
230
231    fn apply(&self, coord: Coord3D) -> Result<VerticalApplyOutcome> {
232        match self {
233            Self::None { diagnostics } | Self::Preserve { diagnostics } => {
234                Ok(VerticalApplyOutcome {
235                    z: coord.z,
236                    diagnostics: diagnostics.clone(),
237                })
238            }
239            Self::UnitConvert {
240                source_unit,
241                target_unit,
242                diagnostics,
243            } => Ok(VerticalApplyOutcome {
244                z: target_unit.from_meters(source_unit.to_meters(coord.z)),
245                diagnostics: diagnostics.clone(),
246            }),
247            Self::GridShiftList { shifts, .. } => {
248                let mut last_coverage_error = None;
249                for shift in shifts {
250                    match apply_vertical_grid_shift(shift, coord) {
251                        Ok(z) => {
252                            return Ok(VerticalApplyOutcome {
253                                z,
254                                diagnostics: shift.diagnostics.clone(),
255                            });
256                        }
257                        Err(Error::Grid(crate::grid::GridError::OutsideCoverage(detail))) => {
258                            last_coverage_error = Some(detail);
259                        }
260                        Err(error) => return Err(error),
261                    }
262                }
263                Err(Error::Grid(crate::grid::GridError::OutsideCoverage(
264                    last_coverage_error.unwrap_or_else(|| "vertical grid coverage miss".into()),
265                )))
266            }
267        }
268    }
269
270    fn diagnostics(&self) -> &VerticalTransformDiagnostics {
271        match self {
272            Self::None { diagnostics }
273            | Self::Preserve { diagnostics }
274            | Self::UnitConvert { diagnostics, .. }
275            | Self::GridShiftList { diagnostics, .. } => diagnostics,
276        }
277    }
278}
279
280fn apply_vertical_grid_shift(shift: &CompiledVerticalGridShift, coord: Coord3D) -> Result<f64> {
281    let (lon, lat) = shift.sample_horizontal.lon_lat_radians(coord.x, coord.y)?;
282    let offset_meters = shift
283        .handle
284        .sample_vertical_offset_meters(lon, lat)?
285        .offset_meters;
286    let source_meters = shift.source_unit.to_meters(coord.z);
287    let target_meters = match shift.direction {
288        VerticalGridShiftDirection::EllipsoidToGravity => source_meters - offset_meters,
289        VerticalGridShiftDirection::GravityToEllipsoid => source_meters + offset_meters,
290    };
291    Ok(shift.target_unit.from_meters(target_meters))
292}
293
294impl Transform {
295    /// Create a transform from authority code strings (e.g., `"EPSG:4326"`).
296    pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
297        Self::with_selection_options(from_crs, to_crs, SelectionOptions::default())
298    }
299
300    /// Create a transform with explicit selection options.
301    pub fn with_selection_options(
302        from_crs: &str,
303        to_crs: &str,
304        options: SelectionOptions,
305    ) -> Result<Self> {
306        let source = registry::lookup_authority_code(from_crs)?;
307        let target = registry::lookup_authority_code(to_crs)?;
308        Self::from_crs_defs_with_selection_options(&source, &target, options)
309    }
310
311    /// Create a transform from an explicit registry operation id.
312    pub fn from_operation(
313        operation_id: CoordinateOperationId,
314        from_crs: &str,
315        to_crs: &str,
316    ) -> Result<Self> {
317        Self::with_selection_options(
318            from_crs,
319            to_crs,
320            SelectionOptions {
321                policy: SelectionPolicy::Operation(operation_id),
322                ..SelectionOptions::default()
323            },
324        )
325    }
326
327    /// Create a transform from EPSG codes directly.
328    pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
329        let source = registry::lookup_epsg(from)
330            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
331        let target = registry::lookup_epsg(to)
332            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
333        Self::from_crs_defs(&source, &target)
334    }
335
336    /// Create a transform from explicit CRS definitions.
337    pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
338        Self::from_crs_defs_with_selection_options(from, to, SelectionOptions::default())
339    }
340
341    /// Create a horizontal-only transform from explicit CRS definitions.
342    ///
343    /// Compound CRS inputs are reduced to their horizontal component before
344    /// operation selection. This is intended for XY-only workflows where
345    /// vertical transformation is deliberately out of scope.
346    pub fn from_horizontal_components(from: &CrsDef, to: &CrsDef) -> Result<Self> {
347        Self::from_horizontal_components_with_selection_options(
348            from,
349            to,
350            SelectionOptions::default(),
351        )
352    }
353
354    /// Create a horizontal-only transform from explicit CRS definitions with
355    /// operation-selection options.
356    pub fn from_horizontal_components_with_selection_options(
357        from: &CrsDef,
358        to: &CrsDef,
359        options: SelectionOptions,
360    ) -> Result<Self> {
361        let source = from.horizontal_crs().ok_or_else(|| {
362            Error::InvalidDefinition("source CRS does not contain a horizontal component".into())
363        })?;
364        let target = to.horizontal_crs().ok_or_else(|| {
365            Error::InvalidDefinition("target CRS does not contain a horizontal component".into())
366        })?;
367        Self::from_crs_defs_with_selection_options(&source, &target, options)
368    }
369
370    /// Create a transform from explicit CRS definitions with operation-selection options.
371    ///
372    /// Use this when a custom CRS references grid resources and the transform
373    /// needs an application-supplied [`crate::grid::GridProvider`].
374    pub fn from_crs_defs_with_selection_options(
375        from: &CrsDef,
376        to: &CrsDef,
377        options: SelectionOptions,
378    ) -> Result<Self> {
379        let grid_runtime = GridRuntime::new(options.grid_provider.clone());
380        let vertical_transform = compile_vertical_transform(from, to, &options, &grid_runtime)?;
381        let candidate_set = selector::rank_operation_candidates(from, to, &options)?;
382        if candidate_set.ranked.is_empty() {
383            return Err(match options.policy {
384                SelectionPolicy::Operation(id) => match registry::lookup_operation(id) {
385                    Some(_) => Error::OperationSelection(format!(
386                        "operation id {} is not compatible with source EPSG:{} target EPSG:{}",
387                        id.0,
388                        from.epsg(),
389                        to.epsg()
390                    )),
391                    None => Error::UnknownOperation(format!("unknown operation id {}", id.0)),
392                },
393                _ => Error::OperationSelection(format!(
394                    "no compatible operation found for source EPSG:{} target EPSG:{}",
395                    from.epsg(),
396                    to.epsg()
397                )),
398            });
399        }
400
401        let mut skipped_operations = candidate_set.skipped;
402        let mut missing_required_grid = None;
403        let mut selected: Option<(
404            usize,
405            &selector::RankedOperationCandidate,
406            CoordinateOperationMetadata,
407            CompiledOperationPipeline,
408        )> = None;
409        let mut fallback_pipelines = Vec::new();
410
411        for (index, candidate) in candidate_set.ranked.iter().enumerate() {
412            if let Some((_, selected_candidate, ..)) = &selected {
413                if !selected_candidate.operation.uses_grids() {
414                    skipped_operations.push(skipped_for_unselected_candidate(
415                        candidate,
416                        !selected_candidate.operation.deprecated,
417                    ));
418                    continue;
419                }
420            }
421
422            match compile_pipeline(
423                from,
424                to,
425                candidate.operation.as_ref(),
426                candidate.direction,
427                &grid_runtime,
428            ) {
429                Ok(pipeline) => {
430                    let metadata = selected_metadata(
431                        candidate.operation.as_ref(),
432                        candidate.direction,
433                        candidate.matched_area_of_use.clone(),
434                    );
435                    if let Some((_, selected_candidate, ..)) = &selected {
436                        skipped_operations.push(skipped_for_unselected_candidate(
437                            candidate,
438                            !selected_candidate.operation.deprecated,
439                        ));
440                        fallback_pipelines.push(CompiledOperationFallback { metadata, pipeline });
441                    } else {
442                        selected = Some((index, candidate, metadata, pipeline));
443                    }
444                }
445                Err(Error::Grid(error)) => {
446                    if selected.is_none() && missing_required_grid.is_none() {
447                        missing_required_grid = Some(error.to_string());
448                    }
449                    skipped_operations.push(SkippedOperation {
450                        metadata: selected_metadata(
451                            candidate.operation.as_ref(),
452                            candidate.direction,
453                            candidate.matched_area_of_use.clone(),
454                        ),
455                        reason: match error {
456                            crate::grid::GridError::UnsupportedFormat(_) => {
457                                SkippedOperationReason::UnsupportedGridFormat
458                            }
459                            _ => SkippedOperationReason::MissingGrid,
460                        },
461                        detail: error.to_string(),
462                    });
463                }
464                Err(error) => {
465                    skipped_operations.push(SkippedOperation {
466                        metadata: selected_metadata(
467                            candidate.operation.as_ref(),
468                            candidate.direction,
469                            candidate.matched_area_of_use.clone(),
470                        ),
471                        reason: SkippedOperationReason::LessPreferred,
472                        detail: error.to_string(),
473                    });
474                }
475            }
476        }
477
478        if let Some((index, candidate, metadata, pipeline)) = selected {
479            let selected_reasons =
480                selected_reasons_for(candidate, &candidate_set.ranked[index + 1..]);
481            let diagnostics = OperationSelectionDiagnostics {
482                selected_operation: metadata.clone(),
483                selected_match_kind: candidate.match_kind,
484                selected_reasons,
485                fallback_operations: fallback_pipelines
486                    .iter()
487                    .map(|fallback| fallback.metadata.clone())
488                    .collect(),
489                skipped_operations,
490                approximate: candidate.operation.approximate,
491                missing_required_grid,
492            };
493            return Ok(Self {
494                source: from.clone(),
495                target: to.clone(),
496                selected_operation_definition: candidate.operation.clone().into_owned(),
497                selected_direction: candidate.direction,
498                selected_operation: metadata,
499                diagnostics,
500                vertical_transform,
501                selection_options: options,
502                pipeline,
503                fallback_pipelines,
504            });
505        }
506
507        if let Some(message) = missing_required_grid {
508            return Err(Error::OperationSelection(format!(
509                "better operations were skipped because required grids were unavailable: {message}"
510            )));
511        }
512
513        Err(Error::OperationSelection(format!(
514            "unable to compile an operation for source EPSG:{} target EPSG:{}",
515            from.epsg(),
516            to.epsg()
517        )))
518    }
519
520    /// Transform a single coordinate.
521    pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
522        let c = coord.into_coord();
523        let result = self.convert_coord(c)?;
524        Ok(T::from_coord(result))
525    }
526
527    /// Transform a single 3D coordinate.
528    pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
529        let c = coord.into_coord3d();
530        let result = self.convert_coord3d(c)?;
531        Ok(T::from_coord3d(result))
532    }
533
534    /// Transform a single coordinate and report the operation actually used.
535    ///
536    /// When the selected grid-backed operation misses grid coverage, this
537    /// reports the coverage misses and the lower-ranked fallback operation that
538    /// produced the result.
539    pub fn convert_with_diagnostics<T: Transformable>(
540        &self,
541        coord: T,
542    ) -> Result<TransformOutcome<T>> {
543        let c = coord.into_coord();
544        let outcome = self.convert_coord_with_diagnostics(c)?;
545        Ok(TransformOutcome {
546            coord: T::from_coord(outcome.coord),
547            operation: outcome.operation,
548            vertical: outcome.vertical,
549            grid_coverage_misses: outcome.grid_coverage_misses,
550        })
551    }
552
553    /// Transform a single 3D coordinate and report the operation actually used.
554    ///
555    /// When the selected grid-backed operation misses grid coverage, this
556    /// reports the coverage misses and the lower-ranked fallback operation that
557    /// produced the result.
558    pub fn convert_3d_with_diagnostics<T: Transformable3D>(
559        &self,
560        coord: T,
561    ) -> Result<TransformOutcome<T>> {
562        let c = coord.into_coord3d();
563        let outcome = self.convert_coord3d_with_diagnostics(c)?;
564        Ok(TransformOutcome {
565            coord: T::from_coord3d(outcome.coord),
566            operation: outcome.operation,
567            vertical: outcome.vertical,
568            grid_coverage_misses: outcome.grid_coverage_misses,
569        })
570    }
571
572    /// Return the source CRS definition for this transform.
573    pub fn source_crs(&self) -> &CrsDef {
574        &self.source
575    }
576
577    /// Return the target CRS definition for this transform.
578    pub fn target_crs(&self) -> &CrsDef {
579        &self.target
580    }
581
582    /// Return metadata for the selected coordinate operation.
583    pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
584        &self.selected_operation
585    }
586
587    /// Return selection diagnostics for this transform.
588    pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
589        &self.diagnostics
590    }
591
592    /// Return diagnostics for the vertical component of this transform.
593    pub fn vertical_diagnostics(&self) -> &VerticalTransformDiagnostics {
594        self.vertical_transform.diagnostics()
595    }
596
597    /// Build the inverse transform by swapping the source and target CRS.
598    pub fn inverse(&self) -> Result<Self> {
599        let grid_runtime = GridRuntime::new(self.selection_options.grid_provider.clone());
600        let inverse_options = self.selection_options.inverse();
601        let vertical_transform = compile_vertical_transform(
602            &self.target,
603            &self.source,
604            &inverse_options,
605            &grid_runtime,
606        )?;
607        let selected_direction = self.selected_direction.inverse();
608        let pipeline = compile_pipeline(
609            &self.target,
610            &self.source,
611            &self.selected_operation_definition,
612            selected_direction,
613            &grid_runtime,
614        )?;
615        let selected_operation = selected_metadata(
616            &self.selected_operation_definition,
617            selected_direction,
618            self.selected_operation.area_of_use.clone(),
619        );
620        let diagnostics = OperationSelectionDiagnostics {
621            selected_operation: selected_operation.clone(),
622            selected_match_kind: self.diagnostics.selected_match_kind,
623            selected_reasons: self.diagnostics.selected_reasons.clone(),
624            fallback_operations: Vec::new(),
625            skipped_operations: Vec::new(),
626            approximate: self.diagnostics.approximate,
627            missing_required_grid: self.diagnostics.missing_required_grid.clone(),
628        };
629        Ok(Self {
630            source: self.target.clone(),
631            target: self.source.clone(),
632            selected_operation_definition: self.selected_operation_definition.clone(),
633            selected_direction,
634            selected_operation,
635            diagnostics,
636            vertical_transform,
637            selection_options: inverse_options,
638            pipeline,
639            fallback_pipelines: Vec::new(),
640        })
641    }
642
643    /// Reproject a 2D bounding box by sampling its perimeter.
644    pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
645        if !bounds.is_valid() {
646            return Err(Error::OutOfRange(
647                "bounds must be finite and satisfy min <= max".into(),
648            ));
649        }
650
651        let segments = densify_points
652            .checked_add(1)
653            .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))?;
654
655        let mut transformed: Option<Bounds> = None;
656        for i in 0..=segments {
657            let t = i as f64 / segments as f64;
658            let x = bounds.min_x + bounds.width() * t;
659            let y = bounds.min_y + bounds.height() * t;
660
661            for sample in [
662                Coord::new(x, bounds.min_y),
663                Coord::new(x, bounds.max_y),
664                Coord::new(bounds.min_x, y),
665                Coord::new(bounds.max_x, y),
666            ] {
667                let coord = self.convert_coord(sample)?;
668                if let Some(accum) = &mut transformed {
669                    accum.expand_to_include(coord);
670                } else {
671                    transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
672                }
673            }
674        }
675
676        transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
677    }
678
679    fn convert_coord(&self, c: Coord) -> Result<Coord> {
680        match self.execute_pipeline_xy(&self.pipeline, Coord3D::new(c.x, c.y, 0.0)) {
681            Ok(coord) => return Ok(coord),
682            Err(error) => {
683                if !is_grid_coverage_miss(&error) {
684                    return Err(error);
685                }
686            }
687        }
688
689        for fallback in &self.fallback_pipelines {
690            match self.execute_pipeline_xy(&fallback.pipeline, Coord3D::new(c.x, c.y, 0.0)) {
691                Ok(coord) => return Ok(coord),
692                Err(error) => {
693                    if !is_grid_coverage_miss(&error) {
694                        return Err(error);
695                    }
696                }
697            }
698        }
699
700        Err(Error::Grid(GridError::OutsideCoverage(
701            "grid coverage miss".into(),
702        )))
703    }
704
705    fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
706        match self.execute_pipeline_coord3d(&self.pipeline, c) {
707            Ok(coord) => return Ok(coord),
708            Err(error) => {
709                if !is_grid_coverage_miss(&error) {
710                    return Err(error);
711                }
712            }
713        }
714
715        for fallback in &self.fallback_pipelines {
716            match self.execute_pipeline_coord3d(&fallback.pipeline, c) {
717                Ok(coord) => return Ok(coord),
718                Err(error) => {
719                    if !is_grid_coverage_miss(&error) {
720                        return Err(error);
721                    }
722                }
723            }
724        }
725
726        Err(Error::Grid(GridError::OutsideCoverage(
727            "grid coverage miss".into(),
728        )))
729    }
730
731    fn convert_coord_with_diagnostics(&self, c: Coord) -> Result<TransformOutcome<Coord>> {
732        let outcome = self.convert_coord3d_with_diagnostics(Coord3D::new(c.x, c.y, 0.0))?;
733        Ok(TransformOutcome {
734            coord: Coord::new(outcome.coord.x, outcome.coord.y),
735            operation: outcome.operation,
736            vertical: outcome.vertical,
737            grid_coverage_misses: outcome.grid_coverage_misses,
738        })
739    }
740
741    fn convert_coord3d_with_diagnostics(&self, c: Coord3D) -> Result<TransformOutcome<Coord3D>> {
742        let mut grid_coverage_misses = Vec::new();
743        match self.execute_pipeline(&self.pipeline, c) {
744            Ok(outcome) => {
745                return Ok(TransformOutcome {
746                    coord: outcome.coord,
747                    operation: self.selected_operation.clone(),
748                    vertical: outcome.vertical,
749                    grid_coverage_misses,
750                });
751            }
752            Err(error) => {
753                if let Some(detail) = grid_coverage_miss_detail(&error) {
754                    grid_coverage_misses.push(GridCoverageMiss {
755                        operation: self.selected_operation.clone(),
756                        detail,
757                    });
758                } else {
759                    return Err(error);
760                }
761            }
762        }
763
764        for fallback in &self.fallback_pipelines {
765            match self.execute_pipeline(&fallback.pipeline, c) {
766                Ok(outcome) => {
767                    return Ok(TransformOutcome {
768                        coord: outcome.coord,
769                        operation: fallback.metadata.clone(),
770                        vertical: outcome.vertical,
771                        grid_coverage_misses,
772                    });
773                }
774                Err(error) => {
775                    if let Some(detail) = grid_coverage_miss_detail(&error) {
776                        grid_coverage_misses.push(GridCoverageMiss {
777                            operation: fallback.metadata.clone(),
778                            detail,
779                        });
780                    } else {
781                        return Err(error);
782                    }
783                }
784            }
785        }
786
787        Err(Error::Grid(GridError::OutsideCoverage(
788            grid_coverage_misses
789                .last()
790                .map(|miss| miss.detail.clone())
791                .unwrap_or_else(|| "grid coverage miss".into()),
792        )))
793    }
794
795    fn execute_pipeline(
796        &self,
797        pipeline: &CompiledOperationPipeline,
798        c: Coord3D,
799    ) -> Result<PipelineExecutionOutcome> {
800        let xy = self.execute_pipeline_xy(pipeline, c)?;
801        let vertical = self.vertical_transform.apply(c)?;
802        Ok(PipelineExecutionOutcome {
803            coord: Coord3D::new(xy.x, xy.y, vertical.z),
804            vertical: vertical.diagnostics,
805        })
806    }
807
808    fn execute_pipeline_coord3d(
809        &self,
810        pipeline: &CompiledOperationPipeline,
811        c: Coord3D,
812    ) -> Result<Coord3D> {
813        let xy = self.execute_pipeline_xy(pipeline, c)?;
814        let z = self.vertical_transform.apply_z(c)?;
815        Ok(Coord3D::new(xy.x, xy.y, z))
816    }
817
818    fn execute_pipeline_xy(
819        &self,
820        pipeline: &CompiledOperationPipeline,
821        c: Coord3D,
822    ) -> Result<Coord> {
823        if pipeline.steps.is_empty() {
824            return Ok(Coord::new(c.x, c.y));
825        }
826        let mut state = if self.source.is_projected() {
827            let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
828            Coord3D::new(x_m, y_m, 0.0)
829        } else {
830            Coord3D::new(c.x.to_radians(), c.y.to_radians(), 0.0)
831        };
832
833        for step in &pipeline.steps {
834            state = execute_step(step, state)?;
835        }
836
837        let (x, y) = if self.target.is_projected() {
838            self.projected_meters_to_target_native(state.x, state.y)
839        } else {
840            (state.x.to_degrees(), state.y.to_degrees())
841        };
842
843        Ok(Coord::new(x, y))
844    }
845
846    fn source_projected_native_to_meters(&self, x: f64, y: f64) -> (f64, f64) {
847        match self.source.as_projected() {
848            Some(projected) => (
849                projected.linear_unit().to_meters(x),
850                projected.linear_unit().to_meters(y),
851            ),
852            None => (x, y),
853        }
854    }
855
856    fn projected_meters_to_target_native(&self, x: f64, y: f64) -> (f64, f64) {
857        match self.target.as_projected() {
858            Some(projected) => (
859                projected.linear_unit().from_meters(x),
860                projected.linear_unit().from_meters(y),
861            ),
862            None => (x, y),
863        }
864    }
865
866    /// Batch transform (sequential).
867    pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
868        coords.iter().map(|c| self.convert(c.clone())).collect()
869    }
870
871    /// Batch transform of 3D coordinates (sequential).
872    pub fn convert_batch_3d<T: Transformable3D + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
873        coords.iter().map(|c| self.convert_3d(c.clone())).collect()
874    }
875
876    /// Transform 2D coordinates in place without allocating.
877    ///
878    /// Coordinates before a failing coordinate are left converted; the failing
879    /// coordinate and subsequent coordinates are left unchanged.
880    pub fn convert_coords_in_place(&self, coords: &mut [Coord]) -> Result<()> {
881        for coord in coords {
882            *coord = self.convert_coord(*coord)?;
883        }
884        Ok(())
885    }
886
887    /// Transform 3D coordinates in place without allocating.
888    ///
889    /// Coordinates before a failing coordinate are left converted; the failing
890    /// coordinate and subsequent coordinates are left unchanged.
891    pub fn convert_coords_3d_in_place(&self, coords: &mut [Coord3D]) -> Result<()> {
892        for coord in coords {
893            *coord = self.convert_coord3d(*coord)?;
894        }
895        Ok(())
896    }
897
898    /// Transform 2D coordinates from `input` into an existing `output` slice.
899    ///
900    /// `output` must have exactly the same length as `input`. This API performs
901    /// no allocation and does not require cloning input coordinates.
902    pub fn convert_coords_into(&self, input: &[Coord], output: &mut [Coord]) -> Result<()> {
903        validate_output_len(input.len(), output.len())?;
904        for (source, target) in input.iter().zip(output.iter_mut()) {
905            *target = self.convert_coord(*source)?;
906        }
907        Ok(())
908    }
909
910    /// Transform 3D coordinates from `input` into an existing `output` slice.
911    ///
912    /// `output` must have exactly the same length as `input`. This API performs
913    /// no allocation and does not require cloning input coordinates.
914    pub fn convert_coords_3d_into(&self, input: &[Coord3D], output: &mut [Coord3D]) -> Result<()> {
915        validate_output_len(input.len(), output.len())?;
916        for (source, target) in input.iter().zip(output.iter_mut()) {
917            *target = self.convert_coord3d(*source)?;
918        }
919        Ok(())
920    }
921
922    /// Batch transform with Rayon parallelism.
923    #[cfg(feature = "rayon")]
924    pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
925        &self,
926        coords: &[T],
927    ) -> Result<Vec<T>> {
928        self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch(chunk))
929    }
930
931    /// Batch transform of 3D coordinates with adaptive Rayon parallelism.
932    #[cfg(feature = "rayon")]
933    pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync + Clone>(
934        &self,
935        coords: &[T],
936    ) -> Result<Vec<T>> {
937        self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch_3d(chunk))
938    }
939
940    #[cfg(feature = "rayon")]
941    fn convert_batch_parallel_adaptive<T, F>(&self, coords: &[T], convert: F) -> Result<Vec<T>>
942    where
943        T: Send + Sync + Clone,
944        F: Fn(&Self, &[T]) -> Result<Vec<T>> + Sync,
945    {
946        if !should_parallelize(coords.len()) {
947            return convert(self, coords);
948        }
949
950        use rayon::prelude::*;
951
952        let chunk_size = parallel_chunk_size(coords.len());
953        let chunk_results: Vec<Result<Vec<T>>> = coords
954            .par_chunks(chunk_size)
955            .map(|chunk| convert(self, chunk))
956            .collect();
957
958        let mut results = Vec::with_capacity(coords.len());
959        for chunk in chunk_results {
960            results.extend(chunk?);
961        }
962        Ok(results)
963    }
964}
965
966fn selected_metadata(
967    operation: &CoordinateOperation,
968    direction: OperationStepDirection,
969    matched_area_of_use: Option<crate::operation::AreaOfUse>,
970) -> CoordinateOperationMetadata {
971    let mut metadata = operation.metadata_for_direction(direction);
972    metadata.area_of_use = matched_area_of_use.or_else(|| operation.areas_of_use.first().cloned());
973    metadata
974}
975
976fn is_grid_coverage_miss(error: &Error) -> bool {
977    matches!(error, Error::Grid(GridError::OutsideCoverage(_)))
978}
979
980fn grid_coverage_miss_detail(error: &Error) -> Option<String> {
981    match error {
982        Error::Grid(GridError::OutsideCoverage(detail)) => Some(detail.clone()),
983        _ => None,
984    }
985}
986
987fn validate_output_len(input_len: usize, output_len: usize) -> Result<()> {
988    if input_len != output_len {
989        return Err(Error::OutOfRange(format!(
990            "output coordinate slice length {output_len} does not match input length {input_len}"
991        )));
992    }
993    Ok(())
994}
995
996fn compile_vertical_transform(
997    source: &CrsDef,
998    target: &CrsDef,
999    options: &SelectionOptions,
1000    grid_runtime: &GridRuntime,
1001) -> Result<VerticalTransform> {
1002    match (source.vertical_crs(), target.vertical_crs()) {
1003        (None, None) => Ok(VerticalTransform::None {
1004            diagnostics: vertical_diagnostics(
1005                VerticalTransformAction::None,
1006                None,
1007                None,
1008                None,
1009            ),
1010        }),
1011        (Some(source_vertical), Some(target_vertical))
1012            if source_vertical.same_vertical_reference(target_vertical) =>
1013        {
1014            if unit_factors_match(
1015                source_vertical.linear_unit_to_meter(),
1016                target_vertical.linear_unit_to_meter(),
1017            ) {
1018                Ok(VerticalTransform::Preserve {
1019                    diagnostics: vertical_diagnostics(
1020                        VerticalTransformAction::Preserved,
1021                        Some("Vertical ordinate preservation".into()),
1022                        Some(source_vertical),
1023                        Some(target_vertical),
1024                    ),
1025                })
1026            } else {
1027                Ok(VerticalTransform::UnitConvert {
1028                    source_unit: source_vertical.linear_unit(),
1029                    target_unit: target_vertical.linear_unit(),
1030                    diagnostics: vertical_diagnostics(
1031                        VerticalTransformAction::UnitConverted,
1032                        Some("Vertical unit conversion".into()),
1033                        Some(source_vertical),
1034                        Some(target_vertical),
1035                    ),
1036                })
1037            }
1038        }
1039        (Some(source_vertical), Some(target_vertical))
1040            if is_ellipsoidal_gravity_pair(source_vertical, target_vertical) =>
1041        {
1042            let shifts = compile_vertical_grid_shifts(
1043                source,
1044                target,
1045                source_vertical,
1046                target_vertical,
1047                options,
1048                grid_runtime,
1049            )?;
1050            if !shifts.is_empty() {
1051                let diagnostics = shifts[0].diagnostics.clone();
1052                return Ok(VerticalTransform::GridShiftList {
1053                    shifts: shifts.into_boxed_slice(),
1054                    diagnostics,
1055                });
1056            }
1057
1058            Err(Error::OperationSelection(format!(
1059                "vertical CRS transformations between ellipsoidal and gravity-related heights require a supported geoid grid operation; no supported vertical grid operation is available for source {} target {}",
1060                vertical_label(source_vertical),
1061                vertical_label(target_vertical)
1062            )))
1063        }
1064        (Some(source_vertical), Some(target_vertical)) => Err(Error::OperationSelection(format!(
1065            "vertical CRS transformations between different vertical reference frames require a supported vertical operation/grid; no supported operation is available for source {} target {}",
1066            vertical_label(source_vertical),
1067            vertical_label(target_vertical)
1068        ))),
1069        (Some(_), None) | (None, Some(_)) => Err(Error::OperationSelection(
1070            "cannot transform between an explicit vertical CRS and a horizontal-only CRS; vertical CRS transformations are not supported".into(),
1071        )),
1072    }
1073}
1074
1075fn compile_vertical_grid_shifts(
1076    source_crs: &CrsDef,
1077    target_crs: &CrsDef,
1078    source_vertical: &VerticalCrsDef,
1079    target_vertical: &VerticalCrsDef,
1080    options: &SelectionOptions,
1081    grid_runtime: &GridRuntime,
1082) -> Result<Vec<CompiledVerticalGridShift>> {
1083    let area = selector::resolve_area_of_interest(source_crs, target_crs, options)?;
1084    let direction = vertical_grid_shift_direction(source_vertical, target_vertical)?;
1085    let mut candidates =
1086        matching_vertical_grid_operations(source_vertical, target_vertical, options, area.as_ref());
1087
1088    if matches!(options.policy, SelectionPolicy::RequireExactAreaMatch) && area.is_some() {
1089        candidates.retain(|candidate| candidate.area_of_use_match == Some(true));
1090    }
1091    sort_vertical_grid_candidates(&mut candidates);
1092
1093    let mut first_error = None;
1094    let mut shifts = Vec::new();
1095    for candidate in candidates {
1096        match compile_vertical_grid_shift(
1097            candidate,
1098            source_crs,
1099            source_vertical,
1100            target_vertical,
1101            direction,
1102            options,
1103            grid_runtime,
1104        ) {
1105            Ok(shift) => shifts.push(shift),
1106            Err(error) => {
1107                if first_error.is_none() {
1108                    first_error = Some(error);
1109                }
1110            }
1111        }
1112    }
1113
1114    if shifts.is_empty() {
1115        if let Some(error) = first_error {
1116            return Err(error);
1117        }
1118    }
1119
1120    Ok(shifts)
1121}
1122
1123struct VerticalGridCandidate<'a> {
1124    operation: &'a VerticalGridOperation,
1125    area_of_use_match: Option<bool>,
1126    grid_area_of_use_match: Option<bool>,
1127}
1128
1129fn matching_vertical_grid_operations<'a>(
1130    source: &VerticalCrsDef,
1131    target: &VerticalCrsDef,
1132    options: &'a SelectionOptions,
1133    area: Option<&selector::ResolvedAreaOfInterest>,
1134) -> Vec<VerticalGridCandidate<'a>> {
1135    options
1136        .vertical_grid_operations
1137        .iter()
1138        .filter(|operation| vertical_grid_operation_matches(operation, source, target))
1139        .map(|operation| VerticalGridCandidate {
1140            operation,
1141            area_of_use_match: vertical_operation_area_match(operation, area),
1142            grid_area_of_use_match: area_of_use_match(operation.grid.area_of_use.as_ref(), area),
1143        })
1144        .collect()
1145}
1146
1147fn sort_vertical_grid_candidates(candidates: &mut [VerticalGridCandidate<'_>]) {
1148    candidates.sort_by(|left, right| {
1149        right
1150            .area_of_use_match
1151            .unwrap_or(false)
1152            .cmp(&left.area_of_use_match.unwrap_or(false))
1153            .then_with(|| {
1154                let left_accuracy = left
1155                    .operation
1156                    .accuracy
1157                    .map(|accuracy| accuracy.meters)
1158                    .unwrap_or(f64::MAX);
1159                let right_accuracy = right
1160                    .operation
1161                    .accuracy
1162                    .map(|accuracy| accuracy.meters)
1163                    .unwrap_or(f64::MAX);
1164                left_accuracy
1165                    .partial_cmp(&right_accuracy)
1166                    .unwrap_or(std::cmp::Ordering::Equal)
1167            })
1168    });
1169}
1170
1171fn compile_vertical_grid_shift(
1172    candidate: VerticalGridCandidate<'_>,
1173    source_crs: &CrsDef,
1174    source_vertical: &VerticalCrsDef,
1175    target_vertical: &VerticalCrsDef,
1176    direction: VerticalGridShiftDirection,
1177    options: &SelectionOptions,
1178    grid_runtime: &GridRuntime,
1179) -> Result<CompiledVerticalGridShift> {
1180    let operation = candidate.operation;
1181    match operation.offset_convention {
1182        VerticalGridOffsetConvention::GeoidHeightMeters => {}
1183    }
1184    let handle = grid_runtime.resolve_handle(&operation.grid)?;
1185    Ok(CompiledVerticalGridShift {
1186        direction,
1187        source_unit: source_vertical.linear_unit(),
1188        target_unit: target_vertical.linear_unit(),
1189        sample_horizontal: VerticalSampleHorizontal::compile(
1190            source_crs,
1191            operation.grid_horizontal_crs_epsg,
1192            options,
1193        )?,
1194        diagnostics: vertical_grid_diagnostics(
1195            operation,
1196            &handle,
1197            candidate.area_of_use_match,
1198            candidate.grid_area_of_use_match,
1199            source_vertical,
1200            target_vertical,
1201        ),
1202        handle,
1203    })
1204}
1205
1206fn vertical_grid_operation_matches(
1207    operation: &VerticalGridOperation,
1208    source: &VerticalCrsDef,
1209    target: &VerticalCrsDef,
1210) -> bool {
1211    vertical_crs_filter_matches(operation.source_vertical_crs_epsg, source)
1212        && vertical_crs_filter_matches(operation.target_vertical_crs_epsg, target)
1213        && vertical_datum_filter_matches(operation.source_vertical_datum_epsg, source)
1214        && vertical_datum_filter_matches(operation.target_vertical_datum_epsg, target)
1215}
1216
1217fn vertical_operation_area_match(
1218    operation: &VerticalGridOperation,
1219    area: Option<&selector::ResolvedAreaOfInterest>,
1220) -> Option<bool> {
1221    let area_of_use = operation
1222        .area_of_use
1223        .as_ref()
1224        .or(operation.grid.area_of_use.as_ref());
1225    area_of_use_match(area_of_use, area)
1226}
1227
1228fn area_of_use_match(
1229    area_of_use: Option<&crate::operation::AreaOfUse>,
1230    area: Option<&selector::ResolvedAreaOfInterest>,
1231) -> Option<bool> {
1232    let area = area?;
1233    let area_of_use = area_of_use?;
1234    Some(
1235        area.point
1236            .map(|value| area_of_use.contains_point(value))
1237            .unwrap_or(false)
1238            || area
1239                .bounds
1240                .map(|value| area_of_use.contains_bounds(value))
1241                .unwrap_or(false),
1242    )
1243}
1244
1245fn vertical_crs_filter_matches(filter: Option<u32>, vertical: &VerticalCrsDef) -> bool {
1246    filter.is_none_or(|epsg| nonzero_vertical_epsg(vertical) == Some(epsg))
1247}
1248
1249fn vertical_datum_filter_matches(filter: Option<u32>, vertical: &VerticalCrsDef) -> bool {
1250    filter.is_none_or(|epsg| vertical.vertical_datum_epsg() == Some(epsg))
1251}
1252
1253fn vertical_grid_shift_direction(
1254    source: &VerticalCrsDef,
1255    target: &VerticalCrsDef,
1256) -> Result<VerticalGridShiftDirection> {
1257    if source.kind().is_ellipsoidal_height() && target.kind().is_gravity_related_height() {
1258        return Ok(VerticalGridShiftDirection::EllipsoidToGravity);
1259    }
1260    if source.kind().is_gravity_related_height() && target.kind().is_ellipsoidal_height() {
1261        return Ok(VerticalGridShiftDirection::GravityToEllipsoid);
1262    }
1263    Err(Error::OperationSelection(
1264        "vertical grid operations currently support ellipsoidal height to/from gravity-related height".into(),
1265    ))
1266}
1267
1268fn vertical_grid_diagnostics(
1269    operation: &VerticalGridOperation,
1270    handle: &GridHandle,
1271    area_of_use_match: Option<bool>,
1272    grid_area_of_use_match: Option<bool>,
1273    source: &VerticalCrsDef,
1274    target: &VerticalCrsDef,
1275) -> VerticalTransformDiagnostics {
1276    let mut diagnostics = vertical_diagnostics(
1277        VerticalTransformAction::Transformed,
1278        Some(operation.name.clone()),
1279        Some(source),
1280        Some(target),
1281    );
1282    diagnostics.accuracy = operation.accuracy;
1283    diagnostics.area_of_use = operation
1284        .area_of_use
1285        .clone()
1286        .or_else(|| handle.definition().area_of_use.clone());
1287    diagnostics.area_of_use_match = area_of_use_match;
1288    diagnostics.grids.push(VerticalGridProvenance {
1289        name: handle.definition().name.clone(),
1290        checksum: Some(handle.checksum().to_string()),
1291        accuracy: operation.accuracy,
1292        area_of_use: handle.definition().area_of_use.clone(),
1293        area_of_use_match: grid_area_of_use_match,
1294    });
1295    diagnostics
1296}
1297
1298fn vertical_diagnostics(
1299    action: VerticalTransformAction,
1300    operation_name: Option<String>,
1301    source: Option<&VerticalCrsDef>,
1302    target: Option<&VerticalCrsDef>,
1303) -> VerticalTransformDiagnostics {
1304    VerticalTransformDiagnostics {
1305        action,
1306        operation_name,
1307        source_vertical_crs_epsg: source.and_then(nonzero_vertical_epsg),
1308        target_vertical_crs_epsg: target.and_then(nonzero_vertical_epsg),
1309        source_vertical_datum_epsg: source.and_then(VerticalCrsDef::vertical_datum_epsg),
1310        target_vertical_datum_epsg: target.and_then(VerticalCrsDef::vertical_datum_epsg),
1311        source_unit_to_meter: source.map(VerticalCrsDef::linear_unit_to_meter),
1312        target_unit_to_meter: target.map(VerticalCrsDef::linear_unit_to_meter),
1313        accuracy: None,
1314        area_of_use: None,
1315        area_of_use_match: None,
1316        grids: Vec::new(),
1317    }
1318}
1319
1320fn nonzero_vertical_epsg(vertical: &VerticalCrsDef) -> Option<u32> {
1321    match vertical.epsg() {
1322        0 => None,
1323        epsg => Some(epsg),
1324    }
1325}
1326
1327fn unit_factors_match(a: f64, b: f64) -> bool {
1328    (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0)
1329}
1330
1331fn is_ellipsoidal_gravity_pair(source: &VerticalCrsDef, target: &VerticalCrsDef) -> bool {
1332    (source.kind().is_ellipsoidal_height() && target.kind().is_gravity_related_height())
1333        || (source.kind().is_gravity_related_height() && target.kind().is_ellipsoidal_height())
1334}
1335
1336fn vertical_label(vertical: &VerticalCrsDef) -> String {
1337    match nonzero_vertical_epsg(vertical) {
1338        Some(epsg) => format!("EPSG:{epsg}"),
1339        None if vertical.name().is_empty() => "unnamed vertical CRS".into(),
1340        None => vertical.name().into(),
1341    }
1342}
1343
1344fn selected_reasons_for(
1345    selected: &selector::RankedOperationCandidate,
1346    alternatives: &[selector::RankedOperationCandidate],
1347) -> SmallVec<[crate::operation::SelectionReason; 4]> {
1348    let mut reasons = selected.reasons.clone();
1349    if selected_accuracy_preferred(selected, alternatives)
1350        && !reasons.contains(&crate::operation::SelectionReason::AccuracyPreferred)
1351    {
1352        reasons.push(crate::operation::SelectionReason::AccuracyPreferred);
1353    }
1354    reasons
1355}
1356
1357fn selected_accuracy_preferred(
1358    selected: &selector::RankedOperationCandidate,
1359    alternatives: &[selector::RankedOperationCandidate],
1360) -> bool {
1361    let Some(selected_accuracy) = selected.operation.accuracy.map(|value| value.meters) else {
1362        return false;
1363    };
1364
1365    alternatives.iter().any(|alternative| {
1366        same_pre_accuracy_priority(selected, alternative)
1367            && alternative
1368                .operation
1369                .accuracy
1370                .map(|value| selected_accuracy < value.meters)
1371                .unwrap_or(false)
1372    })
1373}
1374
1375fn same_pre_accuracy_priority(
1376    left: &selector::RankedOperationCandidate,
1377    right: &selector::RankedOperationCandidate,
1378) -> bool {
1379    match_kind_priority(left.match_kind) == match_kind_priority(right.match_kind)
1380        && left.matched_area_of_use.is_some() == right.matched_area_of_use.is_some()
1381}
1382
1383fn match_kind_priority(kind: crate::operation::OperationMatchKind) -> u8 {
1384    match kind {
1385        crate::operation::OperationMatchKind::Explicit => 4,
1386        crate::operation::OperationMatchKind::ExactSourceTarget => 3,
1387        crate::operation::OperationMatchKind::DerivedGeographic => 2,
1388        crate::operation::OperationMatchKind::DatumCompatible => 1,
1389        crate::operation::OperationMatchKind::ApproximateFallback => 0,
1390    }
1391}
1392
1393fn skipped_for_unselected_candidate(
1394    candidate: &selector::RankedOperationCandidate,
1395    prefer_non_deprecated: bool,
1396) -> SkippedOperation {
1397    let reason = if prefer_non_deprecated && candidate.operation.deprecated {
1398        SkippedOperationReason::Deprecated
1399    } else {
1400        SkippedOperationReason::LessPreferred
1401    };
1402    let detail = match reason {
1403        SkippedOperationReason::Deprecated => {
1404            "not selected because a non-deprecated higher-ranked operation compiled successfully"
1405                .into()
1406        }
1407        _ => "not selected because a higher-ranked operation compiled successfully".into(),
1408    };
1409    SkippedOperation {
1410        metadata: selected_metadata(
1411            candidate.operation.as_ref(),
1412            candidate.direction,
1413            candidate.matched_area_of_use.clone(),
1414        ),
1415        reason,
1416        detail,
1417    }
1418}
1419
1420fn execute_step(step: &CompiledStep, coord: Coord3D) -> Result<Coord3D> {
1421    match step {
1422        CompiledStep::ProjectionForward { projection } => {
1423            let (x, y) = projection.forward(coord.x, coord.y)?;
1424            Ok(Coord3D::new(x, y, coord.z))
1425        }
1426        CompiledStep::ProjectionInverse { projection } => {
1427            let (lon, lat) = projection.inverse(coord.x, coord.y)?;
1428            Ok(Coord3D::new(lon, lat, coord.z))
1429        }
1430        CompiledStep::Helmert { params, inverse } => {
1431            let (x, y, z) = if *inverse {
1432                helmert::helmert_inverse(params, coord.x, coord.y, coord.z)
1433            } else {
1434                helmert::helmert_forward(params, coord.x, coord.y, coord.z)
1435            };
1436            Ok(Coord3D::new(x, y, z))
1437        }
1438        CompiledStep::GridShift { handle, direction } => {
1439            let (lon, lat) = handle.apply(coord.x, coord.y, *direction)?;
1440            Ok(Coord3D::new(lon, lat, coord.z))
1441        }
1442        CompiledStep::GridShiftList {
1443            handles,
1444            allow_null,
1445            direction,
1446        } => {
1447            let mut last_coverage_miss = None;
1448            for handle in handles.iter() {
1449                match handle.apply(coord.x, coord.y, *direction) {
1450                    Ok((lon, lat)) => return Ok(Coord3D::new(lon, lat, coord.z)),
1451                    Err(GridError::OutsideCoverage(detail)) => {
1452                        last_coverage_miss = Some(detail);
1453                    }
1454                    Err(error) => return Err(Error::Grid(error)),
1455                }
1456            }
1457
1458            if *allow_null {
1459                return Ok(coord);
1460            }
1461
1462            Err(Error::Grid(GridError::OutsideCoverage(
1463                last_coverage_miss.unwrap_or_else(|| "no datum grid covered coordinate".into()),
1464            )))
1465        }
1466        CompiledStep::GeodeticToGeocentric { ellipsoid } => {
1467            let (x, y, z) =
1468                geocentric::geodetic_to_geocentric(ellipsoid, coord.x, coord.y, coord.z);
1469            Ok(Coord3D::new(x, y, z))
1470        }
1471        CompiledStep::GeocentricToGeodetic { ellipsoid } => {
1472            let (lon, lat, h) =
1473                geocentric::geocentric_to_geodetic(ellipsoid, coord.x, coord.y, coord.z);
1474            Ok(Coord3D::new(lon, lat, h))
1475        }
1476    }
1477}
1478
1479fn compile_pipeline(
1480    source: &CrsDef,
1481    target: &CrsDef,
1482    operation: &CoordinateOperation,
1483    direction: OperationStepDirection,
1484    grid_runtime: &GridRuntime,
1485) -> Result<CompiledOperationPipeline> {
1486    let mut steps = SmallVec::<[CompiledStep; 8]>::new();
1487
1488    if let Some(projected) = source.as_projected() {
1489        steps.push(CompiledStep::ProjectionInverse {
1490            projection: make_projection(&projected.method(), projected.datum())?,
1491        });
1492    }
1493
1494    if operation.id.is_none() && matches!(operation.method, OperationMethod::Identity) {
1495        // Synthetic identity between semantically equivalent CRS.
1496    } else {
1497        compile_operation(
1498            operation,
1499            direction,
1500            Some((source, target)),
1501            grid_runtime,
1502            &mut steps,
1503        )?;
1504    }
1505
1506    if let Some(projected) = target.as_projected() {
1507        steps.push(CompiledStep::ProjectionForward {
1508            projection: make_projection(&projected.method(), projected.datum())?,
1509        });
1510    }
1511
1512    Ok(CompiledOperationPipeline { steps })
1513}
1514
1515fn compile_operation(
1516    operation: &CoordinateOperation,
1517    direction: OperationStepDirection,
1518    requested_pair: Option<(&CrsDef, &CrsDef)>,
1519    grid_runtime: &GridRuntime,
1520    steps: &mut SmallVec<[CompiledStep; 8]>,
1521) -> Result<()> {
1522    let (source_geo, target_geo) =
1523        resolve_operation_geographic_pair(operation, direction, requested_pair)?;
1524    match (&operation.method, direction) {
1525        (OperationMethod::Identity, _) => {}
1526        (OperationMethod::Helmert { params }, OperationStepDirection::Forward) => {
1527            steps.push(CompiledStep::GeodeticToGeocentric {
1528                ellipsoid: source_geo.datum().ellipsoid,
1529            });
1530            steps.push(CompiledStep::Helmert {
1531                params: *params,
1532                inverse: false,
1533            });
1534            steps.push(CompiledStep::GeocentricToGeodetic {
1535                ellipsoid: target_geo.datum().ellipsoid,
1536            });
1537        }
1538        (OperationMethod::Helmert { params }, OperationStepDirection::Reverse) => {
1539            steps.push(CompiledStep::GeodeticToGeocentric {
1540                ellipsoid: source_geo.datum().ellipsoid,
1541            });
1542            steps.push(CompiledStep::Helmert {
1543                params: *params,
1544                inverse: true,
1545            });
1546            steps.push(CompiledStep::GeocentricToGeodetic {
1547                ellipsoid: target_geo.datum().ellipsoid,
1548            });
1549        }
1550        (
1551            OperationMethod::DatumShift {
1552                source_to_wgs84,
1553                target_to_wgs84,
1554            },
1555            OperationStepDirection::Forward,
1556        ) => {
1557            compile_to_wgs84(
1558                source_to_wgs84,
1559                source_geo.datum().ellipsoid,
1560                grid_runtime,
1561                steps,
1562            )?;
1563            compile_from_wgs84(
1564                target_to_wgs84,
1565                target_geo.datum().ellipsoid,
1566                grid_runtime,
1567                steps,
1568            )?;
1569        }
1570        (
1571            OperationMethod::DatumShift {
1572                source_to_wgs84,
1573                target_to_wgs84,
1574            },
1575            OperationStepDirection::Reverse,
1576        ) => {
1577            compile_to_wgs84(
1578                target_to_wgs84,
1579                source_geo.datum().ellipsoid,
1580                grid_runtime,
1581                steps,
1582            )?;
1583            compile_from_wgs84(
1584                source_to_wgs84,
1585                target_geo.datum().ellipsoid,
1586                grid_runtime,
1587                steps,
1588            )?;
1589        }
1590        (
1591            OperationMethod::GridShift {
1592                grid_id,
1593                direction: grid_direction,
1594                ..
1595            },
1596            step_direction,
1597        ) => {
1598            let grid = registry::lookup_grid_definition(grid_id.0).ok_or_else(|| {
1599                Error::Grid(crate::grid::GridError::NotFound(format!(
1600                    "grid id {}",
1601                    grid_id.0
1602                )))
1603            })?;
1604            if grid.format == crate::grid::GridFormat::Unsupported {
1605                return Err(Error::Grid(crate::grid::GridError::UnsupportedFormat(
1606                    grid.name,
1607                )));
1608            }
1609            let handle = grid_runtime.resolve_handle(&grid)?;
1610            let direction = match step_direction {
1611                OperationStepDirection::Forward => *grid_direction,
1612                OperationStepDirection::Reverse => grid_direction.inverse(),
1613            };
1614            steps.push(CompiledStep::GridShift { handle, direction });
1615        }
1616        (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Forward) => {
1617            for step in child_steps {
1618                let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
1619                    Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
1620                })?;
1621                compile_operation(&child, step.direction, None, grid_runtime, steps)?;
1622            }
1623        }
1624        (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Reverse) => {
1625            for step in child_steps.iter().rev() {
1626                let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
1627                    Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
1628                })?;
1629                compile_operation(&child, step.direction.inverse(), None, grid_runtime, steps)?;
1630            }
1631        }
1632        (OperationMethod::Projection { .. }, _) | (OperationMethod::AxisUnitNormalize, _) => {
1633            return Err(Error::UnsupportedProjection(
1634                "direct projection operations are not emitted by the embedded selector".into(),
1635            ));
1636        }
1637    }
1638    Ok(())
1639}
1640
1641fn compile_to_wgs84(
1642    transform: &DatumToWgs84,
1643    source_ellipsoid: Ellipsoid,
1644    grid_runtime: &GridRuntime,
1645    steps: &mut SmallVec<[CompiledStep; 8]>,
1646) -> Result<()> {
1647    match transform {
1648        DatumToWgs84::Identity => Ok(()),
1649        DatumToWgs84::Helmert(params) => {
1650            steps.push(CompiledStep::GeodeticToGeocentric {
1651                ellipsoid: source_ellipsoid,
1652            });
1653            steps.push(CompiledStep::Helmert {
1654                params: *params,
1655                inverse: false,
1656            });
1657            steps.push(CompiledStep::GeocentricToGeodetic {
1658                ellipsoid: ellipsoid::WGS84,
1659            });
1660            Ok(())
1661        }
1662        DatumToWgs84::GridShift(grids) => {
1663            compile_grid_shift_list(grids, GridShiftDirection::Forward, grid_runtime, steps)
1664        }
1665        DatumToWgs84::Unknown => Err(Error::OperationSelection(
1666            "datum has no known path to WGS84".into(),
1667        )),
1668    }
1669}
1670
1671fn compile_from_wgs84(
1672    transform: &DatumToWgs84,
1673    target_ellipsoid: Ellipsoid,
1674    grid_runtime: &GridRuntime,
1675    steps: &mut SmallVec<[CompiledStep; 8]>,
1676) -> Result<()> {
1677    match transform {
1678        DatumToWgs84::Identity => Ok(()),
1679        DatumToWgs84::Helmert(params) => {
1680            steps.push(CompiledStep::GeodeticToGeocentric {
1681                ellipsoid: ellipsoid::WGS84,
1682            });
1683            steps.push(CompiledStep::Helmert {
1684                params: *params,
1685                inverse: true,
1686            });
1687            steps.push(CompiledStep::GeocentricToGeodetic {
1688                ellipsoid: target_ellipsoid,
1689            });
1690            Ok(())
1691        }
1692        DatumToWgs84::GridShift(grids) => {
1693            compile_grid_shift_list(grids, GridShiftDirection::Reverse, grid_runtime, steps)
1694        }
1695        DatumToWgs84::Unknown => Err(Error::OperationSelection(
1696            "datum has no known path from WGS84".into(),
1697        )),
1698    }
1699}
1700
1701fn compile_grid_shift_list(
1702    grids: &DatumGridShift,
1703    direction: GridShiftDirection,
1704    grid_runtime: &GridRuntime,
1705    steps: &mut SmallVec<[CompiledStep; 8]>,
1706) -> Result<()> {
1707    let mut handles = Vec::<GridHandle>::new();
1708    let mut allow_null = false;
1709    let mut required_grid_seen = false;
1710
1711    for entry in grids.entries() {
1712        match entry {
1713            DatumGridShiftEntry::Null => {
1714                allow_null = true;
1715                break;
1716            }
1717            DatumGridShiftEntry::Grid {
1718                definition,
1719                optional,
1720            } => {
1721                if !optional {
1722                    required_grid_seen = true;
1723                }
1724                match grid_runtime.resolve_handle(definition) {
1725                    Ok(handle) => handles.push(handle),
1726                    Err(GridError::Unavailable(_) | GridError::NotFound(_)) if *optional => {}
1727                    Err(error) => return Err(Error::Grid(error)),
1728                }
1729            }
1730        }
1731    }
1732
1733    if handles.is_empty() && !allow_null {
1734        if required_grid_seen {
1735            return Err(Error::Grid(GridError::Unavailable(
1736                "no required datum grid could be loaded".into(),
1737            )));
1738        }
1739        return Err(Error::Grid(GridError::Unavailable(
1740            "no optional datum grid could be loaded".into(),
1741        )));
1742    }
1743
1744    steps.push(CompiledStep::GridShiftList {
1745        handles: handles.into_boxed_slice(),
1746        allow_null,
1747        direction,
1748    });
1749    Ok(())
1750}
1751
1752fn resolve_operation_geographic_pair(
1753    operation: &CoordinateOperation,
1754    direction: OperationStepDirection,
1755    requested_pair: Option<(&CrsDef, &CrsDef)>,
1756) -> Result<(CrsDef, CrsDef)> {
1757    if let (Some(source_code), Some(target_code)) =
1758        (operation.source_crs_epsg, operation.target_crs_epsg)
1759    {
1760        let source = registry::lookup_epsg(match direction {
1761            OperationStepDirection::Forward => source_code,
1762            OperationStepDirection::Reverse => target_code,
1763        })
1764        .ok_or_else(|| {
1765            Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
1766        })?;
1767        let target = registry::lookup_epsg(match direction {
1768            OperationStepDirection::Forward => target_code,
1769            OperationStepDirection::Reverse => source_code,
1770        })
1771        .ok_or_else(|| {
1772            Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
1773        })?;
1774        return Ok((source, target));
1775    }
1776
1777    if let Some((source, target)) = requested_pair {
1778        return Ok((source.clone(), target.clone()));
1779    }
1780
1781    Err(Error::OperationSelection(format!(
1782        "operation {} is missing source/target CRS metadata",
1783        operation.name
1784    )))
1785}
1786
1787#[cfg(feature = "rayon")]
1788fn should_parallelize(len: usize) -> bool {
1789    if len == 0 {
1790        return false;
1791    }
1792
1793    let threads = rayon::current_num_threads().max(1);
1794    len >= PARALLEL_MIN_TOTAL_ITEMS.max(threads.saturating_mul(PARALLEL_MIN_ITEMS_PER_THREAD))
1795}
1796
1797#[cfg(feature = "rayon")]
1798fn parallel_chunk_size(len: usize) -> usize {
1799    let threads = rayon::current_num_threads().max(1);
1800    let target_chunks = threads.saturating_mul(PARALLEL_CHUNKS_PER_THREAD).max(1);
1801    let chunk_size = len.div_ceil(target_chunks);
1802    chunk_size.max(PARALLEL_MIN_CHUNK_SIZE)
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807    use super::*;
1808    use crate::crs::{
1809        CompoundCrsDef, CrsDef, GeographicCrsDef, HorizontalCrsDef, LinearUnit, ProjectedCrsDef,
1810        ProjectionMethod, VerticalCrsDef,
1811    };
1812    use crate::datum::{self, DatumToWgs84};
1813    use crate::grid::{FilesystemGridProvider, GridDefinition, GridFormat};
1814    use crate::operation::{
1815        AreaOfInterest, GridId, GridInterpolation, OperationMatchKind, SelectionPolicy,
1816        SelectionReason, SkippedOperationReason, VerticalGridOffsetConvention,
1817        VerticalGridOperation, VerticalTransformAction,
1818    };
1819    use std::path::PathBuf;
1820    use std::sync::atomic::{AtomicUsize, Ordering};
1821    use std::sync::Arc;
1822
1823    const US_FOOT_TO_METER: f64 = 0.3048006096012192;
1824    static TEMP_GRID_COUNTER: AtomicUsize = AtomicUsize::new(0);
1825
1826    fn expect_transform_error(result: Result<Transform>) -> Error {
1827        match result {
1828            Ok(_) => panic!("expected transform construction to fail"),
1829            Err(err) => err,
1830        }
1831    }
1832
1833    fn write_test_gtx(values: &[f32]) -> PathBuf {
1834        write_test_gtx_files(&[("test.gtx", -75.0, 40.0, values)])
1835    }
1836
1837    fn write_test_gtx_files(files: &[(&str, f64, f64, &[f32])]) -> PathBuf {
1838        let dir = std::env::temp_dir().join(format!(
1839            "proj-core-vertical-grid-{}-{}",
1840            std::process::id(),
1841            TEMP_GRID_COUNTER.fetch_add(1, Ordering::SeqCst)
1842        ));
1843        std::fs::create_dir_all(&dir).unwrap();
1844        for (name, west, south, values) in files {
1845            let mut bytes = Vec::new();
1846            bytes.extend_from_slice(&(*south).to_be_bytes());
1847            bytes.extend_from_slice(&(*west).to_be_bytes());
1848            bytes.extend_from_slice(&1.0f64.to_be_bytes());
1849            bytes.extend_from_slice(&1.0f64.to_be_bytes());
1850            bytes.extend_from_slice(&2i32.to_be_bytes());
1851            bytes.extend_from_slice(&2i32.to_be_bytes());
1852            for value in *values {
1853                bytes.extend_from_slice(&value.to_be_bytes());
1854            }
1855            std::fs::write(dir.join(name), bytes).unwrap();
1856        }
1857        dir
1858    }
1859
1860    fn test_vertical_grid_operation() -> VerticalGridOperation {
1861        test_vertical_grid_operation_named("Test geoid height to NAVD88", "test.gtx")
1862    }
1863
1864    fn test_vertical_grid_operation_named(
1865        name: &str,
1866        resource_name: &str,
1867    ) -> VerticalGridOperation {
1868        VerticalGridOperation {
1869            name: name.into(),
1870            grid: GridDefinition {
1871                id: GridId(900_001),
1872                name: resource_name.into(),
1873                format: GridFormat::Gtx,
1874                interpolation: GridInterpolation::Bilinear,
1875                area_of_use: Some(crate::operation::AreaOfUse {
1876                    west: -75.0,
1877                    south: 40.0,
1878                    east: -74.0,
1879                    north: 41.0,
1880                    name: "test grid".into(),
1881                }),
1882                resource_names: SmallVec::from_vec(vec![resource_name.into()]),
1883            },
1884            grid_horizontal_crs_epsg: Some(4326),
1885            source_vertical_crs_epsg: None,
1886            target_vertical_crs_epsg: Some(5703),
1887            source_vertical_datum_epsg: None,
1888            target_vertical_datum_epsg: Some(5103),
1889            accuracy: Some(crate::operation::OperationAccuracy { meters: 0.01 }),
1890            area_of_use: None,
1891            offset_convention: VerticalGridOffsetConvention::GeoidHeightMeters,
1892        }
1893    }
1894
1895    #[test]
1896    fn identity_same_crs() {
1897        let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
1898        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1899        assert_eq!(x, -74.006);
1900        assert_eq!(y, 40.7128);
1901    }
1902
1903    #[test]
1904    fn wgs84_to_web_mercator() {
1905        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1906        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1907        assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
1908        assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
1909    }
1910
1911    #[test]
1912    fn web_mercator_to_wgs84() {
1913        let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
1914        let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
1915        assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
1916        assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
1917    }
1918
1919    #[test]
1920    fn roundtrip_4326_3857() {
1921        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1922        let inv = fwd.inverse().unwrap();
1923
1924        let original = (-74.0445, 40.6892);
1925        let projected = fwd.convert(original).unwrap();
1926        let back = inv.convert(projected).unwrap();
1927
1928        assert!((back.0 - original.0).abs() < 1e-8);
1929        assert!((back.1 - original.1).abs() < 1e-8);
1930    }
1931
1932    #[test]
1933    fn wgs84_to_utm_18n() {
1934        let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
1935        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1936        assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
1937        assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
1938    }
1939
1940    #[test]
1941    fn equivalent_meter_and_foot_state_plane_crs_match_after_unit_conversion() {
1942        let coord = (-80.8431, 35.2271);
1943        let meter_tx = Transform::new("EPSG:4326", "EPSG:32119").unwrap();
1944        let foot_tx = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
1945
1946        let (mx, my) = meter_tx.convert(coord).unwrap();
1947        let (fx, fy) = foot_tx.convert(coord).unwrap();
1948
1949        assert!((fx * US_FOOT_TO_METER - mx).abs() < 0.02);
1950        assert!((fy * US_FOOT_TO_METER - my).abs() < 0.02);
1951    }
1952
1953    #[test]
1954    fn inverse_transform_accepts_native_projected_units_for_foot_crs() {
1955        let coord = (-80.8431, 35.2271);
1956        let forward = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
1957        let inverse = Transform::new("EPSG:2264", "EPSG:4326").unwrap();
1958
1959        let projected = forward.convert(coord).unwrap();
1960        let roundtrip = inverse.convert(projected).unwrap();
1961
1962        assert!((roundtrip.0 - coord.0).abs() < 1e-8);
1963        assert!((roundtrip.1 - coord.1).abs() < 1e-8);
1964    }
1965
1966    #[test]
1967    fn utm_to_web_mercator() {
1968        let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
1969        let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
1970        assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
1971    }
1972
1973    #[test]
1974    fn wgs84_to_polar_stereo_3413() {
1975        let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
1976        let (x, y) = t.convert((-45.0, 90.0)).unwrap();
1977        assert!(x.abs() < 1.0, "x = {x}");
1978        assert!(y.abs() < 1.0, "y = {y}");
1979    }
1980
1981    #[test]
1982    fn roundtrip_4326_3413() {
1983        let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
1984        let inv = fwd.inverse().unwrap();
1985
1986        let original = (-45.0, 75.0);
1987        let projected = fwd.convert(original).unwrap();
1988        let back = inv.convert(projected).unwrap();
1989
1990        assert!((back.0 - original.0).abs() < 1e-6);
1991        assert!((back.1 - original.1).abs() < 1e-6);
1992    }
1993
1994    #[test]
1995    fn geographic_to_geographic_same_datum_is_identity() {
1996        let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
1997        let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
1998        assert_eq!(lon, -74.006);
1999        assert_eq!(lat, 40.7128);
2000        assert_eq!(t.selected_operation().name, "Identity");
2001    }
2002
2003    #[test]
2004    fn unknown_crs_error() {
2005        let result = Transform::new("EPSG:99999", "EPSG:4326");
2006        assert!(result.is_err());
2007    }
2008
2009    #[test]
2010    fn cross_datum_nad27_to_wgs84() {
2011        let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
2012        let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
2013        assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
2014        assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
2015        assert!(!t.selected_operation().approximate);
2016    }
2017
2018    #[test]
2019    fn explicit_grid_operation_compiles() {
2020        let t = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
2021            .unwrap();
2022        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1693)));
2023    }
2024
2025    #[test]
2026    fn explicit_operation_rejects_incompatible_crs_pair() {
2027        let err = match Transform::from_operation(
2028            CoordinateOperationId(1693),
2029            "EPSG:4326",
2030            "EPSG:3857",
2031        ) {
2032            Ok(_) => panic!("incompatible operation should be rejected"),
2033            Err(err) => err,
2034        };
2035        assert!(matches!(err, Error::OperationSelection(_)));
2036        assert!(err.to_string().contains("not compatible"));
2037    }
2038
2039    #[test]
2040    fn explicit_selection_options_choose_grid_operation() {
2041        let t = Transform::with_selection_options(
2042            "EPSG:4267",
2043            "EPSG:4269",
2044            SelectionOptions {
2045                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
2046                    -80.5041667,
2047                    44.5458333,
2048                ))),
2049                ..SelectionOptions::default()
2050            },
2051        )
2052        .unwrap();
2053        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
2054        assert!(!t.selection_diagnostics().approximate);
2055    }
2056
2057    #[test]
2058    fn source_crs_area_of_interest_is_normalized_before_selection() {
2059        let to_projected = Transform::new("EPSG:4267", "EPSG:26717").unwrap();
2060        let projected = to_projected.convert((-80.5041667, 44.5458333)).unwrap();
2061
2062        let t = Transform::with_selection_options(
2063            "EPSG:26717",
2064            "EPSG:4269",
2065            SelectionOptions {
2066                area_of_interest: Some(AreaOfInterest::source_crs_point(Coord::new(
2067                    projected.0,
2068                    projected.1,
2069                ))),
2070                ..SelectionOptions::default()
2071            },
2072        )
2073        .unwrap();
2074
2075        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
2076        assert_eq!(
2077            t.selection_diagnostics().selected_match_kind,
2078            OperationMatchKind::DerivedGeographic
2079        );
2080    }
2081
2082    #[test]
2083    fn area_of_interest_rejects_invalid_geographic_bounds() {
2084        for bounds in [
2085            Bounds::new(10.0, 5.0, -10.0, 20.0),
2086            Bounds::new(f64::NAN, 5.0, 10.0, 20.0),
2087            Bounds::new(-181.0, 5.0, -170.0, 20.0),
2088            Bounds::new(-80.0, -91.0, -70.0, -80.0),
2089        ] {
2090            let err = expect_transform_error(Transform::with_selection_options(
2091                "EPSG:4267",
2092                "EPSG:4269",
2093                SelectionOptions {
2094                    area_of_interest: Some(AreaOfInterest::geographic_bounds(bounds)),
2095                    ..SelectionOptions::default()
2096                },
2097            ));
2098
2099            assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
2100        }
2101    }
2102
2103    #[test]
2104    fn area_of_interest_validates_geographic_source_and_target_bounds() {
2105        for area_of_interest in [
2106            AreaOfInterest::source_crs_bounds(Bounds::new(-181.0, 40.0, -170.0, 45.0)),
2107            AreaOfInterest::target_crs_bounds(Bounds::new(-80.0, 40.0, -70.0, 91.0)),
2108        ] {
2109            let err = expect_transform_error(Transform::with_selection_options(
2110                "EPSG:4267",
2111                "EPSG:4269",
2112                SelectionOptions {
2113                    area_of_interest: Some(area_of_interest),
2114                    ..SelectionOptions::default()
2115                },
2116            ));
2117
2118            assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
2119        }
2120    }
2121
2122    #[test]
2123    fn area_of_interest_rejects_invalid_projected_bounds_before_sampling() {
2124        let err = expect_transform_error(Transform::with_selection_options(
2125            "EPSG:3857",
2126            "EPSG:4326",
2127            SelectionOptions {
2128                area_of_interest: Some(AreaOfInterest::source_crs_bounds(Bounds::new(
2129                    10.0, 0.0, -10.0, 10.0,
2130                ))),
2131                ..SelectionOptions::default()
2132            },
2133        ));
2134
2135        assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
2136    }
2137
2138    #[test]
2139    fn selection_diagnostics_capture_accuracy_preference() {
2140        let t = Transform::with_selection_options(
2141            "EPSG:4267",
2142            "EPSG:4269",
2143            SelectionOptions {
2144                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
2145                    -80.5041667,
2146                    44.5458333,
2147                ))),
2148                ..SelectionOptions::default()
2149            },
2150        )
2151        .unwrap();
2152
2153        assert!(t
2154            .selection_diagnostics()
2155            .selected_reasons
2156            .contains(&SelectionReason::AccuracyPreferred));
2157    }
2158
2159    #[test]
2160    fn selection_diagnostics_capture_policy_filtered_candidates() {
2161        let t = Transform::with_selection_options(
2162            "EPSG:4267",
2163            "EPSG:4326",
2164            SelectionOptions {
2165                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
2166                    -80.5041667,
2167                    44.5458333,
2168                ))),
2169                policy: SelectionPolicy::RequireGrids,
2170                ..SelectionOptions::default()
2171            },
2172        )
2173        .unwrap();
2174
2175        assert!(t
2176            .selection_diagnostics()
2177            .skipped_operations
2178            .iter()
2179            .any(|skipped| { matches!(skipped.reason, SkippedOperationReason::PolicyFiltered) }));
2180    }
2181
2182    #[test]
2183    fn selection_diagnostics_capture_area_mismatch_candidates() {
2184        let t = Transform::with_selection_options(
2185            "EPSG:4267",
2186            "EPSG:4269",
2187            SelectionOptions {
2188                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
2189                    -80.5041667,
2190                    44.5458333,
2191                ))),
2192                policy: SelectionPolicy::RequireExactAreaMatch,
2193                ..SelectionOptions::default()
2194            },
2195        )
2196        .unwrap();
2197
2198        assert!(t
2199            .selection_diagnostics()
2200            .skipped_operations
2201            .iter()
2202            .any(|skipped| {
2203                matches!(skipped.reason, SkippedOperationReason::AreaOfUseMismatch)
2204            }));
2205    }
2206
2207    #[test]
2208    fn grid_coverage_miss_falls_back_under_best_available_policy() {
2209        let t = Transform::with_selection_options(
2210            "EPSG:4267",
2211            "EPSG:4269",
2212            SelectionOptions {
2213                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
2214                    -80.5041667,
2215                    44.5458333,
2216                ))),
2217                ..SelectionOptions::default()
2218            },
2219        )
2220        .unwrap();
2221
2222        assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
2223        assert!(!t.selection_diagnostics().fallback_operations.is_empty());
2224
2225        let outcome = t.convert_with_diagnostics((0.0, 0.0)).unwrap();
2226        let plain = t.convert((0.0, 0.0)).unwrap();
2227
2228        assert_eq!(plain, outcome.coord);
2229        assert_ne!(outcome.operation.id, Some(CoordinateOperationId(1313)));
2230        assert!(outcome
2231            .grid_coverage_misses
2232            .iter()
2233            .any(|miss| miss.operation.id == Some(CoordinateOperationId(1313))));
2234    }
2235
2236    #[test]
2237    fn grid_coverage_miss_does_not_use_non_grid_fallback_when_grids_are_required() {
2238        let t = Transform::with_selection_options(
2239            "EPSG:4267",
2240            "EPSG:4269",
2241            SelectionOptions {
2242                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
2243                    -80.5041667,
2244                    44.5458333,
2245                ))),
2246                policy: SelectionPolicy::RequireGrids,
2247                ..SelectionOptions::default()
2248            },
2249        )
2250        .unwrap();
2251
2252        assert!(t
2253            .selection_diagnostics()
2254            .fallback_operations
2255            .iter()
2256            .all(|operation| operation.uses_grids));
2257
2258        let err = t.convert((0.0, 0.0)).unwrap_err();
2259
2260        assert!(matches!(err, Error::Grid(GridError::OutsideCoverage(_))));
2261    }
2262
2263    #[test]
2264    fn cross_datum_roundtrip_nad27() {
2265        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
2266        let inv = fwd.inverse().unwrap();
2267        let original = (-90.0, 45.0);
2268        let shifted = fwd.convert(original).unwrap();
2269        let back = inv.convert(shifted).unwrap();
2270        assert!((back.0 - original.0).abs() < 1e-6);
2271        assert!((back.1 - original.1).abs() < 1e-6);
2272    }
2273
2274    #[test]
2275    fn inverse_preserves_explicit_operation_selection() {
2276        let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
2277            .unwrap();
2278        let inv = fwd.inverse().unwrap();
2279
2280        assert_eq!(
2281            fwd.selected_operation().id,
2282            Some(CoordinateOperationId(1693))
2283        );
2284        assert_eq!(
2285            inv.selected_operation().id,
2286            Some(CoordinateOperationId(1693))
2287        );
2288    }
2289
2290    #[test]
2291    fn inverse_reorients_selected_metadata_and_diagnostics() {
2292        let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
2293            .unwrap();
2294        let inv = fwd.inverse().unwrap();
2295
2296        assert_eq!(inv.source_crs().epsg(), 4326);
2297        assert_eq!(inv.target_crs().epsg(), 4267);
2298        assert_eq!(
2299            inv.selected_operation().direction,
2300            OperationStepDirection::Reverse
2301        );
2302        assert_eq!(inv.selected_operation().source_crs_epsg, Some(4326));
2303        assert_eq!(inv.selected_operation().target_crs_epsg, Some(4267));
2304        assert_eq!(
2305            inv.selection_diagnostics()
2306                .selected_operation
2307                .source_crs_epsg,
2308            Some(4326)
2309        );
2310        assert_eq!(
2311            inv.selection_diagnostics()
2312                .selected_operation
2313                .target_crs_epsg,
2314            Some(4267)
2315        );
2316    }
2317
2318    #[test]
2319    fn cross_datum_osgb36_to_wgs84() {
2320        let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
2321        let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap();
2322        assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
2323        assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
2324    }
2325
2326    #[test]
2327    fn wgs84_to_web_mercator_3d_preserves_height() {
2328        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2329        let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
2330        assert!((x - (-8238310.0)).abs() < 100.0);
2331        assert!((y - 4970072.0).abs() < 100.0);
2332        assert!((z - 123.45).abs() < 1e-12);
2333    }
2334
2335    #[test]
2336    fn equal_compound_vertical_crs_preserves_height() {
2337        let source = registry::lookup_epsg(4979).unwrap();
2338        let target_horizontal = ProjectedCrsDef::new_with_base_geographic_crs(
2339            3857,
2340            4326,
2341            datum::WGS84,
2342            ProjectionMethod::WebMercator,
2343            LinearUnit::metre(),
2344            "WGS 84 / Pseudo-Mercator",
2345        );
2346        let target_vertical = VerticalCrsDef::ellipsoidal_height(
2347            0,
2348            datum::WGS84,
2349            LinearUnit::metre(),
2350            "WGS 84 ellipsoidal height",
2351        );
2352        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2353            0,
2354            HorizontalCrsDef::Projected(target_horizontal),
2355            target_vertical,
2356            "WGS 84 / Pseudo-Mercator + ellipsoidal height",
2357        )));
2358
2359        let t = Transform::from_crs_defs(&source, &target).unwrap();
2360        let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
2361        assert!((x - (-8238310.0)).abs() < 100.0);
2362        assert!((y - 4970072.0).abs() < 100.0);
2363        assert!((z - 123.45).abs() < 1e-12);
2364        assert_eq!(
2365            t.vertical_diagnostics().action,
2366            VerticalTransformAction::Preserved
2367        );
2368    }
2369
2370    #[test]
2371    fn horizontal_components_allow_compound_xy_preview() {
2372        let source = registry::lookup_epsg(4979).unwrap();
2373        let target = registry::lookup_epsg(3857).unwrap();
2374        let err = expect_transform_error(Transform::from_crs_defs(&source, &target));
2375        assert!(err.to_string().contains("explicit vertical CRS"));
2376
2377        let t = Transform::from_horizontal_components(&source, &target).unwrap();
2378        assert!(t.source_crs().vertical_crs().is_none());
2379        assert!(t.target_crs().vertical_crs().is_none());
2380        assert_eq!(
2381            t.vertical_diagnostics().action,
2382            VerticalTransformAction::None
2383        );
2384        let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
2385        assert!((x - (-8238310.0)).abs() < 100.0);
2386        assert!((y - 4970072.0).abs() < 100.0);
2387        assert!((z - 123.45).abs() < 1e-12);
2388    }
2389
2390    #[test]
2391    fn same_vertical_reference_converts_z_units() {
2392        let horizontal =
2393            HorizontalCrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
2394        let source_vertical =
2395            VerticalCrsDef::gravity_related_height(5703, 5103, LinearUnit::metre(), "NAVD88")
2396                .unwrap();
2397        let target_vertical =
2398            VerticalCrsDef::gravity_related_height(0, 5103, LinearUnit::foot(), "NAVD88 foot")
2399                .unwrap();
2400        let source = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2401            0,
2402            horizontal.clone(),
2403            source_vertical,
2404            "WGS 84 + NAVD88 height",
2405        )));
2406        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2407            0,
2408            horizontal,
2409            target_vertical,
2410            "WGS 84 + NAVD88 height (ft)",
2411        )));
2412
2413        let t = Transform::from_crs_defs(&source, &target).unwrap();
2414        let plain = t.convert_3d((-74.006, 40.7128, 1.0)).unwrap();
2415        assert!((plain.2 - 3.280839895013123).abs() < 1e-12);
2416
2417        let outcome = t
2418            .convert_3d_with_diagnostics((-74.006, 40.7128, 1.0))
2419            .unwrap();
2420        assert!((outcome.coord.2 - 3.280839895013123).abs() < 1e-12);
2421        assert_eq!(
2422            outcome.vertical.action,
2423            VerticalTransformAction::UnitConverted
2424        );
2425        assert_eq!(outcome.vertical.source_vertical_crs_epsg, Some(5703));
2426        assert_eq!(outcome.vertical.source_vertical_datum_epsg, Some(5103));
2427        assert_eq!(outcome.vertical.target_vertical_datum_epsg, Some(5103));
2428    }
2429
2430    #[test]
2431    fn vertical_geoid_grid_transforms_ellipsoidal_to_gravity_height() {
2432        let grid_root = write_test_gtx(&[-30.0, -30.0, -30.0, -30.0]);
2433        let source = registry::lookup_epsg(4979).unwrap();
2434        let target_horizontal = ProjectedCrsDef::new_with_base_geographic_crs(
2435            3857,
2436            4326,
2437            datum::WGS84,
2438            ProjectionMethod::WebMercator,
2439            LinearUnit::metre(),
2440            "WGS 84 / Pseudo-Mercator",
2441        );
2442        let target_vertical = registry::lookup_vertical_epsg(5703).unwrap();
2443        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2444            0,
2445            HorizontalCrsDef::Projected(target_horizontal),
2446            target_vertical,
2447            "WGS 84 / Pseudo-Mercator + NAVD88 height",
2448        )));
2449
2450        let t = Transform::from_crs_defs_with_selection_options(
2451            &source,
2452            &target,
2453            SelectionOptions {
2454                grid_provider: Some(Arc::new(FilesystemGridProvider::new(vec![grid_root]))),
2455                vertical_grid_operations: vec![test_vertical_grid_operation()],
2456                ..SelectionOptions::default()
2457            },
2458        )
2459        .unwrap();
2460
2461        let plain = t.convert_3d((-74.5, 40.5, 100.0)).unwrap();
2462        assert!((plain.2 - 130.0).abs() < 1e-9);
2463
2464        let outcome = t.convert_3d_with_diagnostics((-74.5, 40.5, 100.0)).unwrap();
2465        assert!((outcome.coord.2 - 130.0).abs() < 1e-9);
2466        assert_eq!(
2467            outcome.vertical.action,
2468            VerticalTransformAction::Transformed
2469        );
2470        assert_eq!(outcome.vertical.target_vertical_crs_epsg, Some(5703));
2471        assert_eq!(outcome.vertical.grids[0].name, "test.gtx");
2472        assert!(outcome.vertical.grids[0]
2473            .checksum
2474            .as_ref()
2475            .unwrap()
2476            .starts_with("sha256:"));
2477        assert_eq!(outcome.vertical.area_of_use_match, None);
2478
2479        let inverse = t.inverse().unwrap();
2480        let roundtrip = inverse.convert_3d(outcome.coord).unwrap();
2481        assert!((roundtrip.0 - -74.5).abs() < 1e-6);
2482        assert!((roundtrip.1 - 40.5).abs() < 1e-6);
2483        assert!((roundtrip.2 - 100.0).abs() < 1e-9);
2484    }
2485
2486    #[test]
2487    fn vertical_grid_selection_prefers_area_of_use_match() {
2488        let grid_root = write_test_gtx_files(&[
2489            ("outside.gtx", -75.0, 40.0, &[-10.0, -10.0, -10.0, -10.0]),
2490            ("inside.gtx", -75.0, 40.0, &[-30.0, -30.0, -30.0, -30.0]),
2491        ]);
2492        let source = registry::lookup_epsg(4979).unwrap();
2493        let horizontal =
2494            HorizontalCrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
2495        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2496            0,
2497            horizontal,
2498            registry::lookup_vertical_epsg(5703).unwrap(),
2499            "WGS 84 + NAVD88 height",
2500        )));
2501
2502        let mut outside =
2503            test_vertical_grid_operation_named("outside geoid operation", "outside.gtx");
2504        outside.grid.area_of_use = Some(crate::operation::AreaOfUse {
2505            west: 0.0,
2506            south: 0.0,
2507            east: 1.0,
2508            north: 1.0,
2509            name: "outside".into(),
2510        });
2511        let inside = test_vertical_grid_operation_named("inside geoid operation", "inside.gtx");
2512
2513        let t = Transform::from_crs_defs_with_selection_options(
2514            &source,
2515            &target,
2516            SelectionOptions {
2517                area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(-74.5, 40.5))),
2518                grid_provider: Some(Arc::new(FilesystemGridProvider::new(vec![grid_root]))),
2519                vertical_grid_operations: vec![outside, inside],
2520                ..SelectionOptions::default()
2521            },
2522        )
2523        .unwrap();
2524
2525        let outcome = t.convert_3d_with_diagnostics((-74.5, 40.5, 100.0)).unwrap();
2526        assert!((outcome.coord.2 - 130.0).abs() < 1e-9);
2527        assert_eq!(
2528            outcome.vertical.operation_name.as_deref(),
2529            Some("inside geoid operation")
2530        );
2531        assert_eq!(outcome.vertical.area_of_use_match, Some(true));
2532        assert_eq!(outcome.vertical.grids[0].area_of_use_match, Some(true));
2533    }
2534
2535    #[test]
2536    fn vertical_grid_runtime_falls_back_after_coverage_miss() {
2537        let grid_root = write_test_gtx_files(&[
2538            ("outside.gtx", 10.0, 10.0, &[-10.0, -10.0, -10.0, -10.0]),
2539            ("inside.gtx", -75.0, 40.0, &[-30.0, -30.0, -30.0, -30.0]),
2540        ]);
2541        let source = registry::lookup_epsg(4979).unwrap();
2542        let horizontal =
2543            HorizontalCrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
2544        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2545            0,
2546            horizontal,
2547            registry::lookup_vertical_epsg(5703).unwrap(),
2548            "WGS 84 + NAVD88 height",
2549        )));
2550
2551        let outside = test_vertical_grid_operation_named("outside geoid operation", "outside.gtx");
2552        let inside = test_vertical_grid_operation_named("inside geoid operation", "inside.gtx");
2553        let t = Transform::from_crs_defs_with_selection_options(
2554            &source,
2555            &target,
2556            SelectionOptions {
2557                grid_provider: Some(Arc::new(FilesystemGridProvider::new(vec![grid_root]))),
2558                vertical_grid_operations: vec![outside, inside],
2559                ..SelectionOptions::default()
2560            },
2561        )
2562        .unwrap();
2563
2564        let outcome = t.convert_3d_with_diagnostics((-74.5, 40.5, 100.0)).unwrap();
2565        assert!((outcome.coord.2 - 130.0).abs() < 1e-9);
2566        assert_eq!(
2567            outcome.vertical.operation_name.as_deref(),
2568            Some("inside geoid operation")
2569        );
2570    }
2571
2572    #[test]
2573    fn vertical_grid_rejects_unsupported_sampling_crs() {
2574        let grid_root = write_test_gtx(&[-30.0, -30.0, -30.0, -30.0]);
2575        let source = registry::lookup_epsg(4979).unwrap();
2576        let horizontal =
2577            HorizontalCrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
2578        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2579            0,
2580            horizontal,
2581            registry::lookup_vertical_epsg(5703).unwrap(),
2582            "WGS 84 + NAVD88 height",
2583        )));
2584        let mut operation = test_vertical_grid_operation();
2585        operation.grid_horizontal_crs_epsg = Some(3857);
2586
2587        let err = expect_transform_error(Transform::from_crs_defs_with_selection_options(
2588            &source,
2589            &target,
2590            SelectionOptions {
2591                grid_provider: Some(Arc::new(FilesystemGridProvider::new(vec![grid_root]))),
2592                vertical_grid_operations: vec![operation],
2593                ..SelectionOptions::default()
2594            },
2595        ));
2596
2597        assert!(err
2598            .to_string()
2599            .contains("not a supported geographic sampling CRS"));
2600    }
2601
2602    #[test]
2603    fn vertical_geoid_grid_rejects_outside_coverage() {
2604        let grid_root = write_test_gtx(&[-30.0, -30.0, -30.0, -30.0]);
2605        let horizontal =
2606            HorizontalCrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
2607        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2608            0,
2609            horizontal,
2610            registry::lookup_vertical_epsg(5703).unwrap(),
2611            "WGS 84 + NAVD88 height",
2612        )));
2613        let source = registry::lookup_epsg(4979).unwrap();
2614        let t = Transform::from_crs_defs_with_selection_options(
2615            &source,
2616            &target,
2617            SelectionOptions {
2618                grid_provider: Some(Arc::new(FilesystemGridProvider::new(vec![grid_root]))),
2619                vertical_grid_operations: vec![test_vertical_grid_operation()],
2620                ..SelectionOptions::default()
2621            },
2622        )
2623        .unwrap();
2624
2625        let err = t.convert_3d((-80.0, 40.5, 100.0)).unwrap_err();
2626        assert!(matches!(err, Error::Grid(GridError::OutsideCoverage(_))));
2627    }
2628
2629    #[test]
2630    fn two_dimensional_convert_does_not_sample_vertical_grids() {
2631        let grid_root = write_test_gtx(&[-30.0, -30.0, -30.0, -30.0]);
2632        let source = registry::lookup_epsg(4979).unwrap();
2633        let target_horizontal = ProjectedCrsDef::new_with_base_geographic_crs(
2634            3857,
2635            4326,
2636            datum::WGS84,
2637            ProjectionMethod::WebMercator,
2638            LinearUnit::metre(),
2639            "WGS 84 / Pseudo-Mercator",
2640        );
2641        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2642            0,
2643            HorizontalCrsDef::Projected(target_horizontal),
2644            registry::lookup_vertical_epsg(5703).unwrap(),
2645            "WGS 84 / Pseudo-Mercator + NAVD88 height",
2646        )));
2647        let t = Transform::from_crs_defs_with_selection_options(
2648            &source,
2649            &target,
2650            SelectionOptions {
2651                grid_provider: Some(Arc::new(FilesystemGridProvider::new(vec![grid_root]))),
2652                vertical_grid_operations: vec![test_vertical_grid_operation()],
2653                ..SelectionOptions::default()
2654            },
2655        )
2656        .unwrap();
2657
2658        let (x, y) = t.convert((-80.0, 40.5)).unwrap();
2659        assert!(x < -8_900_000.0 && x > -8_910_000.0, "x = {x}");
2660        assert!(y > 4_930_000.0 && y < 4_940_000.0, "y = {y}");
2661
2662        let err = t.convert_3d((-80.0, 40.5, 100.0)).unwrap_err();
2663        assert!(matches!(err, Error::Grid(GridError::OutsideCoverage(_))));
2664    }
2665
2666    #[test]
2667    fn explicit_vertical_to_horizontal_only_transform_is_rejected() {
2668        let source = registry::lookup_epsg(4979).unwrap();
2669        let target = registry::lookup_epsg(4326).unwrap();
2670        let err = expect_transform_error(Transform::from_crs_defs(&source, &target));
2671        assert!(err.to_string().contains("explicit vertical CRS"));
2672    }
2673
2674    #[test]
2675    fn mismatched_compound_vertical_crs_is_rejected() {
2676        let source = registry::lookup_epsg(4979).unwrap();
2677        let target_horizontal = GeographicCrsDef::new(4326, datum::WGS84, "WGS 84");
2678        let target_vertical =
2679            VerticalCrsDef::gravity_related_height(5703, 5103, LinearUnit::metre(), "NAVD88")
2680                .unwrap();
2681        let target = CrsDef::Compound(Box::new(CompoundCrsDef::new(
2682            0,
2683            HorizontalCrsDef::Geographic(target_horizontal),
2684            target_vertical,
2685            "WGS 84 + NAVD88 height",
2686        )));
2687
2688        let err = expect_transform_error(Transform::from_crs_defs(&source, &target));
2689        assert!(err.to_string().contains("vertical CRS transformations"));
2690        assert!(err.to_string().contains("geoid grid"));
2691    }
2692
2693    #[test]
2694    fn cross_datum_roundtrip_nad27_3d() {
2695        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
2696        let inv = fwd.inverse().unwrap();
2697        let original = (-90.0, 45.0, 250.0);
2698        let shifted = fwd.convert_3d(original).unwrap();
2699        let back = inv.convert_3d(shifted).unwrap();
2700        assert!((back.0 - original.0).abs() < 1e-6);
2701        assert!((back.1 - original.1).abs() < 1e-6);
2702        assert!((back.2 - original.2).abs() < 1e-12);
2703    }
2704
2705    #[test]
2706    fn identical_custom_projected_crs_is_identity() {
2707        let from = CrsDef::Projected(ProjectedCrsDef::new(
2708            0,
2709            datum::WGS84,
2710            ProjectionMethod::WebMercator,
2711            LinearUnit::metre(),
2712            "Custom Web Mercator A",
2713        ));
2714        let to = CrsDef::Projected(ProjectedCrsDef::new(
2715            0,
2716            datum::WGS84,
2717            ProjectionMethod::WebMercator,
2718            LinearUnit::metre(),
2719            "Custom Web Mercator B",
2720        ));
2721
2722        let t = Transform::from_crs_defs(&from, &to).unwrap();
2723        assert_eq!(t.selected_operation().name, "Identity");
2724    }
2725
2726    #[test]
2727    fn unknown_custom_datums_do_not_collapse_to_identity() {
2728        let unknown = datum::Datum {
2729            ellipsoid: datum::WGS84.ellipsoid,
2730            to_wgs84: DatumToWgs84::Unknown,
2731        };
2732        let from = CrsDef::Projected(ProjectedCrsDef::new(
2733            0,
2734            unknown.clone(),
2735            ProjectionMethod::WebMercator,
2736            LinearUnit::metre(),
2737            "Unknown A",
2738        ));
2739        let to = CrsDef::Projected(ProjectedCrsDef::new(
2740            0,
2741            unknown,
2742            ProjectionMethod::WebMercator,
2743            LinearUnit::metre(),
2744            "Unknown B",
2745        ));
2746
2747        let err = match Transform::from_crs_defs(&from, &to) {
2748            Ok(_) => panic!("unknown custom datums should not build a transform"),
2749            Err(err) => err,
2750        };
2751        assert!(
2752            err.to_string().contains("no compatible operation found")
2753                || err
2754                    .to_string()
2755                    .contains("legacy Helmert fallback unavailable")
2756        );
2757    }
2758
2759    #[test]
2760    fn approximate_fallback_is_modeled_as_helmert_operation() {
2761        let from = CrsDef::Geographic(GeographicCrsDef::new(0, datum::NAD27, "Custom NAD27"));
2762        let to = CrsDef::Geographic(GeographicCrsDef::new(0, datum::OSGB36, "Custom OSGB36"));
2763
2764        let t = Transform::from_crs_defs(&from, &to).unwrap();
2765
2766        assert!(t.selected_operation().approximate);
2767        assert!(matches!(
2768            t.selected_operation_definition.method,
2769            OperationMethod::Helmert { .. }
2770        ));
2771    }
2772
2773    #[test]
2774    fn inverse_exposes_swapped_crs() {
2775        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2776        let inv = fwd.inverse().unwrap();
2777
2778        assert_eq!(fwd.source_crs().epsg(), 4326);
2779        assert_eq!(fwd.target_crs().epsg(), 3857);
2780        assert_eq!(inv.source_crs().epsg(), 3857);
2781        assert_eq!(inv.target_crs().epsg(), 4326);
2782    }
2783
2784    #[test]
2785    fn transform_bounds_web_mercator() {
2786        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2787        let bounds = Bounds::new(-74.3, 40.45, -73.65, 40.95);
2788
2789        let result = t.transform_bounds(bounds, 8).unwrap();
2790
2791        assert!(result.min_x < -8_200_000.0);
2792        assert!(result.max_x < -8_100_000.0);
2793        assert!(result.min_y > 4_900_000.0);
2794        assert!(result.max_y > result.min_y);
2795    }
2796
2797    #[test]
2798    fn transform_bounds_rejects_invalid_input() {
2799        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2800        let err = t
2801            .transform_bounds(Bounds::new(10.0, 5.0, -10.0, 20.0), 0)
2802            .unwrap_err();
2803
2804        assert!(matches!(err, Error::OutOfRange(_)));
2805    }
2806
2807    #[test]
2808    fn batch_transform() {
2809        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2810        let coords: Vec<(f64, f64)> = (0..10)
2811            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
2812            .collect();
2813
2814        let results = t.convert_batch(&coords).unwrap();
2815        assert_eq!(results.len(), 10);
2816        for (x, _y) in &results {
2817            assert!(*x < 0.0);
2818        }
2819    }
2820
2821    #[test]
2822    fn batch_transform_3d() {
2823        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2824        let coords: Vec<(f64, f64, f64)> = (0..10)
2825            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
2826            .collect();
2827
2828        let results = t.convert_batch_3d(&coords).unwrap();
2829        assert_eq!(results.len(), 10);
2830        for (index, (x, _y, z)) in results.iter().enumerate() {
2831            assert!(*x < 0.0);
2832            assert!((*z - index as f64).abs() < 1e-12);
2833        }
2834    }
2835
2836    #[test]
2837    fn batch_transform_coords_in_place_matches_vec_batch() {
2838        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2839        let input: Vec<Coord> = (0..10)
2840            .map(|i| Coord::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
2841            .collect();
2842        let expected = t.convert_batch(&input).unwrap();
2843        let mut actual = input.clone();
2844
2845        t.convert_coords_in_place(&mut actual).unwrap();
2846
2847        assert_eq!(actual, expected);
2848    }
2849
2850    #[test]
2851    fn batch_transform_coords_into_reuses_output_slice() {
2852        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2853        let input: Vec<Coord> = (0..10)
2854            .map(|i| Coord::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
2855            .collect();
2856        let expected = t.convert_batch(&input).unwrap();
2857        let mut actual = vec![Coord::new(0.0, 0.0); input.len()];
2858
2859        t.convert_coords_into(&input, &mut actual).unwrap();
2860
2861        assert_eq!(actual, expected);
2862    }
2863
2864    #[test]
2865    fn batch_transform_coords_into_rejects_mismatched_output_len() {
2866        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2867        let input = [Coord::new(-74.0, 40.0), Coord::new(-73.0, 41.0)];
2868        let mut output = [Coord::new(0.0, 0.0)];
2869
2870        let err = t.convert_coords_into(&input, &mut output).unwrap_err();
2871
2872        assert!(matches!(err, Error::OutOfRange(_)));
2873    }
2874
2875    #[test]
2876    fn batch_transform_coords_3d_in_place_and_into_preserve_height() {
2877        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2878        let input: Vec<Coord3D> = (0..10)
2879            .map(|i| Coord3D::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
2880            .collect();
2881        let expected = t.convert_batch_3d(&input).unwrap();
2882
2883        let mut in_place = input.clone();
2884        t.convert_coords_3d_in_place(&mut in_place).unwrap();
2885        assert_eq!(in_place, expected);
2886
2887        let mut output = vec![Coord3D::new(0.0, 0.0, 0.0); input.len()];
2888        t.convert_coords_3d_into(&input, &mut output).unwrap();
2889        assert_eq!(output, expected);
2890    }
2891
2892    #[test]
2893    fn coord_type() {
2894        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2895        let c = Coord::new(-74.006, 40.7128);
2896        let result = t.convert(c).unwrap();
2897        assert!((result.x - (-8238310.0)).abs() < 100.0);
2898    }
2899
2900    #[cfg(feature = "geo-types")]
2901    #[test]
2902    fn geo_types_coord() {
2903        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2904        let c = geo_types::Coord {
2905            x: -74.006,
2906            y: 40.7128,
2907        };
2908        let result: geo_types::Coord<f64> = t.convert(c).unwrap();
2909        assert!((result.x - (-8238310.0)).abs() < 100.0);
2910    }
2911
2912    #[cfg(feature = "rayon")]
2913    #[test]
2914    fn parallel_batch_transform() {
2915        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2916        let coords: Vec<(f64, f64)> = (0..100)
2917            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
2918            .collect();
2919
2920        let results = t.convert_batch_parallel(&coords).unwrap();
2921        assert_eq!(results.len(), 100);
2922    }
2923
2924    #[cfg(feature = "rayon")]
2925    #[test]
2926    fn parallel_batch_transform_matches_sequential_on_large_input() {
2927        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2928        let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
2929        let coords: Vec<(f64, f64)> = (0..len)
2930            .map(|i| (-179.0 + i as f64 * 0.0001, -80.0 + i as f64 * 0.00005))
2931            .collect();
2932
2933        let sequential = t.convert_batch(&coords).unwrap();
2934        let parallel = t.convert_batch_parallel(&coords).unwrap();
2935
2936        assert_eq!(parallel, sequential);
2937    }
2938
2939    #[cfg(feature = "rayon")]
2940    #[test]
2941    fn parallel_batch_transform_3d() {
2942        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2943        let coords: Vec<(f64, f64, f64)> = (0..100)
2944            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01, i as f64))
2945            .collect();
2946
2947        let results = t.convert_batch_parallel_3d(&coords).unwrap();
2948        assert_eq!(results.len(), 100);
2949    }
2950
2951    #[cfg(feature = "rayon")]
2952    #[test]
2953    fn parallel_batch_transform_3d_matches_sequential_on_large_input() {
2954        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
2955        let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
2956        let coords: Vec<(f64, f64, f64)> = (0..len)
2957            .map(|i| {
2958                (
2959                    -179.0 + i as f64 * 0.0001,
2960                    -80.0 + i as f64 * 0.00005,
2961                    i as f64,
2962                )
2963            })
2964            .collect();
2965
2966        let sequential = t.convert_batch_3d(&coords).unwrap();
2967        let parallel = t.convert_batch_parallel_3d(&coords).unwrap();
2968
2969        assert_eq!(parallel, sequential);
2970    }
2971}