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
31pub 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn source_crs(&self) -> &CrsDef {
574 &self.source
575 }
576
577 pub fn target_crs(&self) -> &CrsDef {
579 &self.target
580 }
581
582 pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
584 &self.selected_operation
585 }
586
587 pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
589 &self.diagnostics
590 }
591
592 pub fn vertical_diagnostics(&self) -> &VerticalTransformDiagnostics {
594 self.vertical_transform.diagnostics()
595 }
596
597 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 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 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 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 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 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 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 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 #[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 #[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 } 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}