Skip to main content

proj_core/
operation.rs

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