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