Skip to main content

proj_core/
operation.rs

1use crate::coord::{Bounds, Coord};
2use crate::crs::{LinearUnit, ProjectionMethod};
3use crate::datum::{DatumToWgs84, HelmertParams};
4use crate::error::{Error, Result};
5use smallvec::SmallVec;
6use std::collections::HashSet;
7use std::sync::Arc;
8
9const DEFAULT_AREA_BOUNDS_DENSIFY_POINTS: usize = 21;
10
11/// Stable identifier for a registry-backed coordinate operation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct CoordinateOperationId(pub u32);
15
16/// Stable identifier for a grid resource referenced by an operation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct GridId(pub u32);
20
21/// Ranked area-of-use metadata for an operation or grid.
22#[derive(Debug, Clone, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub struct AreaOfUse {
25    pub west: f64,
26    pub south: f64,
27    pub east: f64,
28    pub north: f64,
29    pub name: String,
30}
31
32impl AreaOfUse {
33    pub fn contains_point(&self, point: Coord) -> bool {
34        longitude_range_contains_point(self.west, self.east, point.x)
35            && point.y >= self.south
36            && point.y <= self.north
37    }
38
39    pub fn contains_bounds(&self, bounds: Bounds) -> bool {
40        longitude_range_contains_range(self.west, self.east, bounds.min_x, bounds.max_x)
41            && bounds.min_y >= self.south
42            && bounds.max_y <= self.north
43    }
44}
45
46fn longitude_range_contains_point(west: f64, east: f64, longitude: f64) -> bool {
47    longitude_delta(west, longitude) <= longitude_span(west, east)
48}
49
50fn longitude_range_contains_range(
51    outer_west: f64,
52    outer_east: f64,
53    inner_west: f64,
54    inner_east: f64,
55) -> bool {
56    let outer_span = longitude_span(outer_west, outer_east);
57    if outer_span >= 360.0 {
58        return true;
59    }
60    let inner_start = longitude_delta(outer_west, inner_west);
61    let inner_span = longitude_span(inner_west, inner_east);
62    inner_start + inner_span <= outer_span
63}
64
65fn longitude_span(west: f64, east: f64) -> f64 {
66    if east >= west {
67        east - west
68    } else {
69        east + 360.0 - west
70    }
71}
72
73fn longitude_delta(west: f64, east: f64) -> f64 {
74    (east - west).rem_euclid(360.0)
75}
76
77/// Nominal operation accuracy in meters.
78#[derive(Debug, Clone, Copy, PartialEq)]
79#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
80pub struct OperationAccuracy {
81    pub meters: f64,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub enum OperationStepDirection {
87    Forward,
88    Reverse,
89}
90
91impl OperationStepDirection {
92    pub fn inverse(self) -> Self {
93        match self {
94            Self::Forward => Self::Reverse,
95            Self::Reverse => Self::Forward,
96        }
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum AreaOfInterestCrs {
102    /// Geographic degrees with conventional west <= east bounds.
103    GeographicDegrees,
104    /// Geographic degrees with bounds crossing the antimeridian, represented
105    /// by west > east.
106    GeographicDegreesWrapped,
107    SourceCrs,
108    TargetCrs,
109}
110
111impl AreaOfInterestCrs {
112    pub fn inverse(self) -> Self {
113        match self {
114            Self::GeographicDegrees => Self::GeographicDegrees,
115            Self::GeographicDegreesWrapped => Self::GeographicDegreesWrapped,
116            Self::SourceCrs => Self::TargetCrs,
117            Self::TargetCrs => Self::SourceCrs,
118        }
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct AreaOfInterest {
124    pub crs: AreaOfInterestCrs,
125    pub point: Option<Coord>,
126    pub bounds: Option<Bounds>,
127}
128
129impl AreaOfInterest {
130    pub fn geographic_point(point: Coord) -> Self {
131        Self {
132            crs: AreaOfInterestCrs::GeographicDegrees,
133            point: Some(point),
134            bounds: None,
135        }
136    }
137
138    pub fn geographic_bounds(bounds: Bounds) -> Self {
139        Self {
140            crs: AreaOfInterestCrs::GeographicDegrees,
141            point: None,
142            bounds: Some(bounds),
143        }
144    }
145
146    /// Construct a geographic area of interest that crosses the antimeridian.
147    ///
148    /// The bounds are interpreted as west/south/east/north in degrees and must
149    /// satisfy `west > east`; use [`Self::geographic_bounds`] for normal
150    /// non-wrapped geographic bounds.
151    pub fn geographic_wrapped_bounds(bounds: Bounds) -> Self {
152        Self {
153            crs: AreaOfInterestCrs::GeographicDegreesWrapped,
154            point: None,
155            bounds: Some(bounds),
156        }
157    }
158
159    pub fn source_crs_point(point: Coord) -> Self {
160        Self {
161            crs: AreaOfInterestCrs::SourceCrs,
162            point: Some(point),
163            bounds: None,
164        }
165    }
166
167    pub fn source_crs_bounds(bounds: Bounds) -> Self {
168        Self {
169            crs: AreaOfInterestCrs::SourceCrs,
170            point: None,
171            bounds: Some(bounds),
172        }
173    }
174
175    pub fn target_crs_point(point: Coord) -> Self {
176        Self {
177            crs: AreaOfInterestCrs::TargetCrs,
178            point: Some(point),
179            bounds: None,
180        }
181    }
182
183    pub fn target_crs_bounds(bounds: Bounds) -> Self {
184        Self {
185            crs: AreaOfInterestCrs::TargetCrs,
186            point: None,
187            bounds: Some(bounds),
188        }
189    }
190
191    pub fn inverse(self) -> Self {
192        Self {
193            crs: self.crs.inverse(),
194            point: self.point,
195            bounds: self.bounds,
196        }
197    }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201pub enum GridInterpolation {
202    Bilinear,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum GridShiftDirection {
207    Forward,
208    Reverse,
209}
210
211impl GridShiftDirection {
212    pub fn inverse(self) -> Self {
213        match self {
214            Self::Forward => Self::Reverse,
215            Self::Reverse => Self::Forward,
216        }
217    }
218}
219
220#[derive(Debug, Clone, PartialEq)]
221pub struct OperationStep {
222    pub operation_id: CoordinateOperationId,
223    pub direction: OperationStepDirection,
224}
225
226/// A general affine map in geocentric metres.
227///
228/// The matrix is row-major and includes any scale factor. Unlike
229/// [`HelmertParams`], this representation can exactly carry EPSG full-matrix
230/// rotation methods without reducing them to a small-angle approximation.
231#[derive(Debug, Clone, Copy, PartialEq)]
232pub struct GeocentricAffineParams {
233    translation: [f64; 3],
234    matrix: [f64; 9],
235}
236
237impl GeocentricAffineParams {
238    pub fn new(translation: [f64; 3], matrix: [f64; 9]) -> Result<Self> {
239        let params = Self {
240            translation,
241            matrix,
242        };
243        params.validate()?;
244        Ok(params)
245    }
246
247    pub const fn translation(&self) -> [f64; 3] {
248        self.translation
249    }
250
251    pub const fn matrix(&self) -> [f64; 9] {
252        self.matrix
253    }
254
255    pub fn validate(&self) -> Result<()> {
256        if !self.translation.iter().all(|value| value.is_finite())
257            || !self.matrix.iter().all(|value| value.is_finite())
258        {
259            return Err(Error::InvalidDefinition(
260                "geocentric affine parameters must be finite".into(),
261            ));
262        }
263        if self.determinant().abs() <= 1e-24 {
264            return Err(Error::InvalidDefinition(
265                "geocentric affine matrix must be invertible".into(),
266            ));
267        }
268        Ok(())
269    }
270
271    pub(crate) fn forward(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
272        let m = self.matrix;
273        (
274            self.translation[0] + m[0] * x + m[1] * y + m[2] * z,
275            self.translation[1] + m[3] * x + m[4] * y + m[5] * z,
276            self.translation[2] + m[6] * x + m[7] * y + m[8] * z,
277        )
278    }
279
280    pub(crate) fn inverse(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
281        let m = self.matrix;
282        let ux = x - self.translation[0];
283        let uy = y - self.translation[1];
284        let uz = z - self.translation[2];
285        let det = self.determinant();
286        (
287            ((m[4] * m[8] - m[5] * m[7]) * ux
288                + (m[2] * m[7] - m[1] * m[8]) * uy
289                + (m[1] * m[5] - m[2] * m[4]) * uz)
290                / det,
291            ((m[5] * m[6] - m[3] * m[8]) * ux
292                + (m[0] * m[8] - m[2] * m[6]) * uy
293                + (m[2] * m[3] - m[0] * m[5]) * uz)
294                / det,
295            ((m[3] * m[7] - m[4] * m[6]) * ux
296                + (m[1] * m[6] - m[0] * m[7]) * uy
297                + (m[0] * m[4] - m[1] * m[3]) * uz)
298                / det,
299        )
300    }
301
302    fn determinant(&self) -> f64 {
303        let m = self.matrix;
304        m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
305            + m[2] * (m[3] * m[7] - m[4] * m[6])
306    }
307}
308
309/// Enum-backed operation method model used by selection and compilation.
310#[derive(Debug, Clone, PartialEq)]
311pub enum OperationMethod {
312    Identity,
313    Helmert {
314        params: HelmertParams,
315    },
316    GeocentricAffine {
317        params: GeocentricAffineParams,
318    },
319    GridShift {
320        grid_id: GridId,
321        interpolation: GridInterpolation,
322        direction: GridShiftDirection,
323    },
324    DatumShift {
325        source_to_wgs84: DatumToWgs84,
326        target_to_wgs84: DatumToWgs84,
327    },
328    Projection {
329        forward: bool,
330        method: ProjectionMethod,
331        linear_unit: LinearUnit,
332    },
333    AxisUnitNormalize,
334    Concatenated {
335        steps: SmallVec<[OperationStep; 4]>,
336    },
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum OperationMatchKind {
341    Custom,
342    ExactSourceTarget,
343    DerivedGeographic,
344    DatumCompatible,
345    Explicit,
346}
347
348#[derive(Debug, Clone, PartialEq)]
349pub struct CoordinateOperation {
350    pub id: Option<CoordinateOperationId>,
351    pub name: String,
352    pub source_crs_epsg: Option<u32>,
353    pub target_crs_epsg: Option<u32>,
354    pub source_datum_epsg: Option<u32>,
355    pub target_datum_epsg: Option<u32>,
356    pub accuracy: Option<OperationAccuracy>,
357    pub areas_of_use: SmallVec<[AreaOfUse; 1]>,
358    pub deprecated: bool,
359    pub preferred: bool,
360    pub approximate: bool,
361    /// EPSG records a same-CRS-pair replacement for this operation; ranking
362    /// prefers the replacement, matching C PROJ.
363    pub superseded: bool,
364    pub method: OperationMethod,
365}
366
367impl CoordinateOperation {
368    pub fn metadata(&self) -> CoordinateOperationMetadata {
369        CoordinateOperationMetadata {
370            id: self.id,
371            name: self.name.clone(),
372            direction: OperationStepDirection::Forward,
373            source_crs_epsg: self.source_crs_epsg,
374            target_crs_epsg: self.target_crs_epsg,
375            source_datum_epsg: self.source_datum_epsg,
376            target_datum_epsg: self.target_datum_epsg,
377            accuracy: self.accuracy,
378            area_of_use: self.areas_of_use.first().cloned(),
379            deprecated: self.deprecated,
380            preferred: self.preferred,
381            approximate: self.approximate,
382            uses_grids: self.uses_grids(),
383        }
384    }
385
386    pub fn metadata_for_direction(
387        &self,
388        direction: OperationStepDirection,
389    ) -> CoordinateOperationMetadata {
390        let mut metadata = self.metadata();
391        metadata.direction = direction;
392        if matches!(direction, OperationStepDirection::Reverse) {
393            std::mem::swap(&mut metadata.source_crs_epsg, &mut metadata.target_crs_epsg);
394            std::mem::swap(
395                &mut metadata.source_datum_epsg,
396                &mut metadata.target_datum_epsg,
397            );
398        }
399        metadata
400    }
401
402    pub fn uses_grids(&self) -> bool {
403        let mut visited = HashSet::new();
404        self.uses_grids_with_visited(&mut visited)
405    }
406
407    fn uses_grids_with_visited(&self, visited: &mut HashSet<CoordinateOperationId>) -> bool {
408        match &self.method {
409            OperationMethod::GridShift { .. } => true,
410            OperationMethod::DatumShift {
411                source_to_wgs84,
412                target_to_wgs84,
413            } => source_to_wgs84.uses_grid_shift() || target_to_wgs84.uses_grid_shift(),
414            OperationMethod::Concatenated { steps } => steps.iter().any(|step| {
415                if !visited.insert(step.operation_id) {
416                    return false;
417                }
418                let uses_grids = crate::registry::lookup_operation(step.operation_id)
419                    .map(|operation| operation.uses_grids_with_visited(visited))
420                    .unwrap_or(false);
421                visited.remove(&step.operation_id);
422                uses_grids
423            }),
424            _ => false,
425        }
426    }
427}
428
429#[derive(Debug, Clone, PartialEq)]
430#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
431pub struct CoordinateOperationMetadata {
432    pub id: Option<CoordinateOperationId>,
433    pub name: String,
434    pub direction: OperationStepDirection,
435    pub source_crs_epsg: Option<u32>,
436    pub target_crs_epsg: Option<u32>,
437    pub source_datum_epsg: Option<u32>,
438    pub target_datum_epsg: Option<u32>,
439    pub accuracy: Option<OperationAccuracy>,
440    pub area_of_use: Option<AreaOfUse>,
441    pub deprecated: bool,
442    pub preferred: bool,
443    pub approximate: bool,
444    pub uses_grids: bool,
445}
446
447#[derive(Debug, Clone)]
448pub enum SelectionPolicy {
449    /// Select the best supported registry/generated-registry operation,
450    /// explicit custom operation, or internal identity behavior.
451    BestAvailable,
452    /// Require a grid-backed datum operation whenever a datum shift is needed.
453    RequireGrids,
454    /// Require selected registry operations to match the configured area of interest.
455    RequireExactAreaMatch,
456    /// Select one explicit registry operation by id.
457    Operation(CoordinateOperationId),
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum VerticalGridOffsetConvention {
462    /// Grid values are geoid heights in meters (`N`), applied as
463    /// gravity height `H = h - N` and ellipsoidal height `h = H + N`.
464    GeoidHeightMeters,
465}
466
467#[derive(Debug, Clone, PartialEq)]
468pub struct VerticalGridOperation {
469    /// Human-readable operation name used in diagnostics.
470    pub name: String,
471    /// Grid resource definition resolved through the configured grid provider.
472    pub grid: crate::grid::GridDefinition,
473    /// Horizontal CRS EPSG code in which the grid is sampled, when known.
474    pub grid_horizontal_crs_epsg: Option<u32>,
475    /// Optional source vertical CRS EPSG filter.
476    pub source_vertical_crs_epsg: Option<u32>,
477    /// Optional target vertical CRS EPSG filter.
478    pub target_vertical_crs_epsg: Option<u32>,
479    /// Optional source gravity-related vertical datum EPSG filter.
480    pub source_vertical_datum_epsg: Option<u32>,
481    /// Optional target gravity-related vertical datum EPSG filter.
482    pub target_vertical_datum_epsg: Option<u32>,
483    /// Expected operation accuracy in meters, when known.
484    pub accuracy: Option<OperationAccuracy>,
485    /// Operation area of use, when distinct from the grid's area.
486    pub area_of_use: Option<AreaOfUse>,
487    pub offset_convention: VerticalGridOffsetConvention,
488}
489
490impl VerticalGridOperation {
491    pub fn inverse(&self) -> Self {
492        let mut inverse = self.clone();
493        std::mem::swap(
494            &mut inverse.source_vertical_crs_epsg,
495            &mut inverse.target_vertical_crs_epsg,
496        );
497        std::mem::swap(
498            &mut inverse.source_vertical_datum_epsg,
499            &mut inverse.target_vertical_datum_epsg,
500        );
501        inverse
502    }
503}
504
505#[derive(Clone)]
506pub struct SelectionOptions {
507    pub area_of_interest: Option<AreaOfInterest>,
508    /// Intermediate points sampled per edge when source/target CRS AOI bounds
509    /// are normalized to geographic degrees for operation selection.
510    ///
511    /// Values above [`crate::MAX_BOUNDS_DENSIFY_POINTS`] are rejected during
512    /// transform construction.
513    pub area_bounds_densify_points: usize,
514    pub policy: SelectionPolicy,
515    pub grid_provider: Option<Arc<dyn crate::grid::GridProvider>>,
516    pub coordinate_operations: Vec<CoordinateOperation>,
517    pub vertical_grid_operations: Vec<VerticalGridOperation>,
518}
519
520impl Default for SelectionOptions {
521    fn default() -> Self {
522        Self {
523            area_of_interest: None,
524            area_bounds_densify_points: DEFAULT_AREA_BOUNDS_DENSIFY_POINTS,
525            policy: SelectionPolicy::BestAvailable,
526            grid_provider: None,
527            coordinate_operations: Vec::new(),
528            vertical_grid_operations: Vec::new(),
529        }
530    }
531}
532
533impl SelectionOptions {
534    /// Create default selection options.
535    pub fn new() -> Self {
536        Self::default()
537    }
538
539    /// Set the area of interest used for operation ranking and filtering.
540    pub fn with_area_of_interest(mut self, area_of_interest: AreaOfInterest) -> Self {
541        self.area_of_interest = Some(area_of_interest);
542        self
543    }
544
545    /// Set how many intermediate points are sampled on each AOI bounds edge
546    /// when source/target CRS bounds are converted to geographic degrees.
547    ///
548    /// Values above [`crate::MAX_BOUNDS_DENSIFY_POINTS`] are rejected during
549    /// transform construction.
550    pub fn with_area_bounds_densify_points(mut self, densify_points: usize) -> Self {
551        self.area_bounds_densify_points = densify_points;
552        self
553    }
554
555    /// Set the operation selection policy.
556    pub fn with_policy(mut self, policy: SelectionPolicy) -> Self {
557        self.policy = policy;
558        self
559    }
560
561    /// Select the best supported registry/generated-registry operation,
562    /// explicit custom operation, or internal identity behavior.
563    ///
564    /// This is the default policy.
565    pub fn best_available(self) -> Self {
566        self.with_policy(SelectionPolicy::BestAvailable)
567    }
568
569    /// Require a grid-backed datum operation when a datum operation is needed.
570    pub fn require_grids(self) -> Self {
571        self.with_policy(SelectionPolicy::RequireGrids)
572    }
573
574    /// Require selected operations to match the configured area of interest.
575    pub fn require_exact_area_match(self) -> Self {
576        self.with_policy(SelectionPolicy::RequireExactAreaMatch)
577    }
578
579    /// Select a specific registry operation by id.
580    pub fn with_operation(self, operation_id: CoordinateOperationId) -> Self {
581        self.with_policy(SelectionPolicy::Operation(operation_id))
582    }
583
584    /// Set the grid provider used to resolve grid-backed horizontal and vertical operations.
585    pub fn with_grid_provider(mut self, provider: Arc<dyn crate::grid::GridProvider>) -> Self {
586        self.grid_provider = Some(provider);
587        self
588    }
589
590    /// Add one explicit horizontal coordinate operation candidate.
591    pub fn with_coordinate_operation(mut self, operation: CoordinateOperation) -> Self {
592        self.coordinate_operations.push(operation);
593        self
594    }
595
596    /// Add explicit horizontal coordinate operation candidates.
597    pub fn with_coordinate_operations(
598        mut self,
599        operations: impl IntoIterator<Item = CoordinateOperation>,
600    ) -> Self {
601        self.coordinate_operations.extend(operations);
602        self
603    }
604
605    /// Add one explicit vertical grid operation candidate.
606    pub fn with_vertical_grid_operation(mut self, operation: VerticalGridOperation) -> Self {
607        self.vertical_grid_operations.push(operation);
608        self
609    }
610
611    /// Add explicit vertical grid operation candidates.
612    pub fn with_vertical_grid_operations(
613        mut self,
614        operations: impl IntoIterator<Item = VerticalGridOperation>,
615    ) -> Self {
616        self.vertical_grid_operations.extend(operations);
617        self
618    }
619
620    pub fn inverse(&self) -> Self {
621        Self {
622            area_of_interest: self.area_of_interest.map(AreaOfInterest::inverse),
623            area_bounds_densify_points: self.area_bounds_densify_points,
624            policy: self.policy.clone(),
625            grid_provider: self.grid_provider.clone(),
626            coordinate_operations: self.coordinate_operations.clone(),
627            vertical_grid_operations: self
628                .vertical_grid_operations
629                .iter()
630                .map(VerticalGridOperation::inverse)
631                .collect(),
632        }
633    }
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637pub enum SelectionReason {
638    CustomOperation,
639    ExplicitOperation,
640    ExactSourceTarget,
641    AreaOfUseMatch,
642    AccuracyPreferred,
643    NonDeprecated,
644    PreferredOperation,
645}
646
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub enum SkippedOperationReason {
649    AreaOfUseMismatch,
650    MissingGrid,
651    UnsupportedGridFormat,
652    PolicyFiltered,
653    LessPreferred,
654    Deprecated,
655}
656
657#[derive(Debug, Clone, PartialEq)]
658pub struct SkippedOperation {
659    pub metadata: CoordinateOperationMetadata,
660    pub reason: SkippedOperationReason,
661    pub detail: String,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub enum VerticalTransformAction {
666    /// No explicit vertical CRS participates in the transform.
667    None,
668    /// `z` is preserved because the vertical CRS semantics and units match.
669    Preserved,
670    /// `z` is converted between units of the same vertical reference frame.
671    UnitConverted,
672    /// `z` is transformed by an explicit vertical operation.
673    Transformed,
674}
675
676#[derive(Debug, Clone, PartialEq)]
677pub struct VerticalGridProvenance {
678    pub name: String,
679    /// Content checksum of the resolved grid resource, formatted as `sha256:<hex>`.
680    pub checksum: Option<String>,
681    pub accuracy: Option<OperationAccuracy>,
682    pub area_of_use: Option<AreaOfUse>,
683    pub area_of_use_match: Option<bool>,
684}
685
686#[derive(Debug, Clone, PartialEq)]
687pub struct VerticalTransformDiagnostics {
688    pub action: VerticalTransformAction,
689    pub operation_name: Option<String>,
690    pub source_vertical_crs_epsg: Option<u32>,
691    pub target_vertical_crs_epsg: Option<u32>,
692    pub source_vertical_datum_epsg: Option<u32>,
693    pub target_vertical_datum_epsg: Option<u32>,
694    pub source_unit_to_meter: Option<f64>,
695    pub target_unit_to_meter: Option<f64>,
696    pub accuracy: Option<OperationAccuracy>,
697    pub area_of_use: Option<AreaOfUse>,
698    pub area_of_use_match: Option<bool>,
699    pub grids: Vec<VerticalGridProvenance>,
700}
701
702#[derive(Debug, Clone, PartialEq)]
703pub struct OperationSelectionDiagnostics {
704    pub selected_operation: CoordinateOperationMetadata,
705    pub selected_match_kind: OperationMatchKind,
706    pub selected_reasons: SmallVec<[SelectionReason; 4]>,
707    pub fallback_operations: Vec<CoordinateOperationMetadata>,
708    pub skipped_operations: Vec<SkippedOperation>,
709    pub approximate: bool,
710    pub missing_required_grid: Option<String>,
711}
712
713#[derive(Debug, Clone, PartialEq)]
714pub struct GridCoverageMiss {
715    /// Shared with the transform's compiled metadata; cloning an outcome
716    /// bumps a reference count instead of copying strings.
717    pub operation: std::sync::Arc<CoordinateOperationMetadata>,
718    pub detail: String,
719}
720
721#[derive(Debug, Clone, PartialEq)]
722pub struct TransformOutcome<T> {
723    pub coord: T,
724    /// Shared with the transform's compiled metadata; cloning an outcome
725    /// bumps a reference count instead of copying strings.
726    pub operation: std::sync::Arc<CoordinateOperationMetadata>,
727    pub vertical: VerticalTransformDiagnostics,
728    pub grid_coverage_misses: Vec<GridCoverageMiss>,
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use crate::grid::{EmbeddedGridProvider, GridDefinition, GridFormat};
735
736    #[test]
737    fn geocentric_affine_roundtrips_a_general_matrix() {
738        let params = GeocentricAffineParams::new(
739            [1.0, -2.0, 3.0],
740            [1.1, 0.2, -0.1, -0.3, 0.9, 0.4, 0.05, -0.2, 1.2],
741        )
742        .unwrap();
743        let point = (4_000_000.0, 1_000_000.0, 5_000_000.0);
744        let transformed = params.forward(point.0, point.1, point.2);
745        let roundtrip = params.inverse(transformed.0, transformed.1, transformed.2);
746
747        assert!((roundtrip.0 - point.0).abs() < 1e-9);
748        assert!((roundtrip.1 - point.1).abs() < 1e-9);
749        assert!((roundtrip.2 - point.2).abs() < 1e-9);
750    }
751
752    #[test]
753    fn geocentric_affine_rejects_non_finite_and_singular_parameters() {
754        assert!(GeocentricAffineParams::new(
755            [f64::NAN, 0.0, 0.0],
756            [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
757        )
758        .is_err());
759        assert!(GeocentricAffineParams::new(
760            [0.0; 3],
761            [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]
762        )
763        .is_err());
764    }
765
766    fn vertical_grid_operation(
767        name: &str,
768        source_vertical_crs_epsg: Option<u32>,
769        target_vertical_crs_epsg: Option<u32>,
770    ) -> VerticalGridOperation {
771        VerticalGridOperation {
772            name: name.into(),
773            grid: GridDefinition {
774                id: GridId(1),
775                name: format!("{name}.gtx"),
776                format: GridFormat::Gtx,
777                interpolation: GridInterpolation::Bilinear,
778                area_of_use: None,
779                resource_names: smallvec::SmallVec::from_vec(vec![format!("{name}.gtx")]),
780            },
781            grid_horizontal_crs_epsg: Some(4326),
782            source_vertical_crs_epsg,
783            target_vertical_crs_epsg,
784            source_vertical_datum_epsg: Some(1),
785            target_vertical_datum_epsg: Some(2),
786            accuracy: Some(OperationAccuracy { meters: 0.1 }),
787            area_of_use: None,
788            offset_convention: VerticalGridOffsetConvention::GeoidHeightMeters,
789        }
790    }
791
792    fn coordinate_operation(name: &str) -> CoordinateOperation {
793        CoordinateOperation {
794            id: None,
795            name: name.into(),
796            source_crs_epsg: None,
797            target_crs_epsg: None,
798            source_datum_epsg: None,
799            target_datum_epsg: None,
800            accuracy: Some(OperationAccuracy { meters: 0.0 }),
801            areas_of_use: smallvec::SmallVec::new(),
802            deprecated: false,
803            preferred: true,
804            approximate: false,
805            superseded: false,
806            method: OperationMethod::Identity,
807        }
808    }
809
810    #[test]
811    fn selection_options_builders_chain_advanced_options() {
812        let area = AreaOfInterest::geographic_point(Coord::new(-74.0, 40.0));
813        let provider: Arc<dyn crate::grid::GridProvider> = Arc::new(EmbeddedGridProvider);
814        let first_operation = coordinate_operation("first operation");
815        let second_operation = coordinate_operation("second operation");
816        let first = vertical_grid_operation("first", Some(4979), Some(5703));
817        let second = vertical_grid_operation("second", Some(4979), Some(5703));
818
819        let options = SelectionOptions::new()
820            .with_area_of_interest(area)
821            .with_area_bounds_densify_points(32)
822            .require_grids()
823            .with_grid_provider(provider.clone())
824            .with_coordinate_operation(first_operation.clone())
825            .with_coordinate_operations([second_operation.clone()])
826            .with_vertical_grid_operation(first.clone())
827            .with_vertical_grid_operations([second.clone()]);
828
829        assert_eq!(options.area_of_interest, Some(area));
830        assert_eq!(options.area_bounds_densify_points, 32);
831        assert!(matches!(options.policy, SelectionPolicy::RequireGrids));
832        assert!(Arc::ptr_eq(
833            options.grid_provider.as_ref().unwrap(),
834            &provider
835        ));
836        assert_eq!(
837            options.coordinate_operations,
838            vec![first_operation, second_operation]
839        );
840        assert_eq!(options.vertical_grid_operations, vec![first, second]);
841    }
842
843    #[test]
844    fn geographic_wrapped_bounds_constructor_marks_antimeridian_aoi() {
845        let bounds = Bounds::new(170.0, -20.0, -170.0, -10.0);
846        let area = AreaOfInterest::geographic_wrapped_bounds(bounds);
847
848        assert_eq!(area.crs, AreaOfInterestCrs::GeographicDegreesWrapped);
849        assert_eq!(area.bounds, Some(bounds));
850        assert_eq!(area.point, None);
851        assert_eq!(area.inverse(), area);
852    }
853
854    #[test]
855    fn area_of_use_contains_antimeridian_points_and_bounds() {
856        let area = AreaOfUse {
857            west: 160.0,
858            south: -25.0,
859            east: -160.0,
860            north: -5.0,
861            name: "Pacific antimeridian test area".into(),
862        };
863
864        assert!(area.contains_point(Coord::new(170.0, -15.0)));
865        assert!(area.contains_point(Coord::new(-170.0, -15.0)));
866        assert!(!area.contains_point(Coord::new(0.0, -15.0)));
867        assert!(area.contains_bounds(Bounds::new(170.0, -20.0, -170.0, -10.0)));
868        assert!(!area.contains_bounds(Bounds::new(150.0, -20.0, -170.0, -10.0)));
869
870        let world = AreaOfUse {
871            west: -180.0,
872            south: -90.0,
873            east: 180.0,
874            north: 90.0,
875            name: "World".into(),
876        };
877        assert!(world.contains_bounds(Bounds::new(170.0, -20.0, -170.0, -10.0)));
878    }
879
880    #[test]
881    fn selection_options_policy_builders_cover_all_modes() {
882        assert!(matches!(
883            SelectionOptions::new().best_available().policy,
884            SelectionPolicy::BestAvailable
885        ));
886        assert!(matches!(
887            SelectionOptions::new().require_exact_area_match().policy,
888            SelectionPolicy::RequireExactAreaMatch
889        ));
890        assert!(matches!(
891            SelectionOptions::new()
892                .with_operation(CoordinateOperationId(1234))
893                .policy,
894            SelectionPolicy::Operation(CoordinateOperationId(1234))
895        ));
896    }
897
898    #[test]
899    fn selection_options_inverse_preserves_builder_values() {
900        let options = SelectionOptions::new()
901            .with_area_of_interest(AreaOfInterest::source_crs_point(Coord::new(1.0, 2.0)))
902            .with_area_bounds_densify_points(32)
903            .with_policy(SelectionPolicy::RequireExactAreaMatch)
904            .with_coordinate_operation(coordinate_operation("operation"))
905            .with_vertical_grid_operation(vertical_grid_operation("grid", Some(4979), Some(5703)));
906
907        let inverse = options.inverse();
908
909        assert!(matches!(
910            inverse.area_of_interest,
911            Some(AreaOfInterest {
912                crs: AreaOfInterestCrs::TargetCrs,
913                point: Some(Coord { x: 1.0, y: 2.0 }),
914                bounds: None,
915            })
916        ));
917        assert!(matches!(
918            inverse.policy,
919            SelectionPolicy::RequireExactAreaMatch
920        ));
921        assert_eq!(inverse.area_bounds_densify_points, 32);
922        assert_eq!(inverse.coordinate_operations, options.coordinate_operations);
923        assert_eq!(
924            inverse.vertical_grid_operations[0].source_vertical_crs_epsg,
925            Some(5703)
926        );
927        assert_eq!(
928            inverse.vertical_grid_operations[0].target_vertical_crs_epsg,
929            Some(4979)
930        );
931    }
932}