1use crate::coord::{Bounds, Coord, Coord3D, Transformable, Transformable3D};
2use crate::crs::CrsDef;
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};
13use crate::projection::{make_projection, Projection};
14use crate::registry;
15use crate::selector;
16use crate::{ellipsoid, geocentric};
17use smallvec::SmallVec;
18
19#[cfg(feature = "rayon")]
20const PARALLEL_MIN_TOTAL_ITEMS: usize = 16_384;
21#[cfg(feature = "rayon")]
22const PARALLEL_MIN_ITEMS_PER_THREAD: usize = 4_096;
23#[cfg(feature = "rayon")]
24const PARALLEL_CHUNKS_PER_THREAD: usize = 4;
25#[cfg(feature = "rayon")]
26const PARALLEL_MIN_CHUNK_SIZE: usize = 1_024;
27
28pub struct Transform {
30 source: CrsDef,
31 target: CrsDef,
32 selected_operation_definition: CoordinateOperation,
33 selected_direction: OperationStepDirection,
34 selected_operation: CoordinateOperationMetadata,
35 diagnostics: OperationSelectionDiagnostics,
36 selection_options: SelectionOptions,
37 pipeline: CompiledOperationPipeline,
38 fallback_pipelines: Vec<CompiledOperationFallback>,
39}
40
41struct CompiledOperationPipeline {
42 steps: SmallVec<[CompiledStep; 8]>,
43}
44
45struct CompiledOperationFallback {
46 metadata: CoordinateOperationMetadata,
47 pipeline: CompiledOperationPipeline,
48}
49
50enum CompiledStep {
51 ProjectionForward {
52 projection: Projection,
53 },
54 ProjectionInverse {
55 projection: Projection,
56 },
57 Helmert {
58 params: HelmertParams,
59 inverse: bool,
60 },
61 GridShift {
62 handle: GridHandle,
63 direction: GridShiftDirection,
64 },
65 GridShiftList {
66 handles: Box<[GridHandle]>,
67 allow_null: bool,
68 direction: GridShiftDirection,
69 },
70 GeodeticToGeocentric {
71 ellipsoid: Ellipsoid,
72 },
73 GeocentricToGeodetic {
74 ellipsoid: Ellipsoid,
75 },
76}
77
78impl Transform {
79 pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
81 Self::with_selection_options(from_crs, to_crs, SelectionOptions::default())
82 }
83
84 pub fn with_selection_options(
86 from_crs: &str,
87 to_crs: &str,
88 options: SelectionOptions,
89 ) -> Result<Self> {
90 let source = registry::lookup_authority_code(from_crs)?;
91 let target = registry::lookup_authority_code(to_crs)?;
92 Self::from_crs_defs_with_selection_options(&source, &target, options)
93 }
94
95 pub fn from_operation(
97 operation_id: CoordinateOperationId,
98 from_crs: &str,
99 to_crs: &str,
100 ) -> Result<Self> {
101 Self::with_selection_options(
102 from_crs,
103 to_crs,
104 SelectionOptions {
105 policy: SelectionPolicy::Operation(operation_id),
106 ..SelectionOptions::default()
107 },
108 )
109 }
110
111 pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
113 let source = registry::lookup_epsg(from)
114 .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
115 let target = registry::lookup_epsg(to)
116 .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
117 Self::from_crs_defs(&source, &target)
118 }
119
120 pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
122 Self::from_crs_defs_with_selection_options(from, to, SelectionOptions::default())
123 }
124
125 pub fn from_crs_defs_with_selection_options(
130 from: &CrsDef,
131 to: &CrsDef,
132 options: SelectionOptions,
133 ) -> Result<Self> {
134 let grid_runtime = GridRuntime::new(options.grid_provider.clone());
135 let candidate_set = selector::rank_operation_candidates(from, to, &options)?;
136 if candidate_set.ranked.is_empty() {
137 return Err(match options.policy {
138 SelectionPolicy::Operation(id) => match registry::lookup_operation(id) {
139 Some(_) => Error::OperationSelection(format!(
140 "operation id {} is not compatible with source EPSG:{} target EPSG:{}",
141 id.0,
142 from.epsg(),
143 to.epsg()
144 )),
145 None => Error::UnknownOperation(format!("unknown operation id {}", id.0)),
146 },
147 _ => Error::OperationSelection(format!(
148 "no compatible operation found for source EPSG:{} target EPSG:{}",
149 from.epsg(),
150 to.epsg()
151 )),
152 });
153 }
154
155 let mut skipped_operations = candidate_set.skipped;
156 let mut missing_required_grid = None;
157 let mut selected: Option<(
158 usize,
159 &selector::RankedOperationCandidate,
160 CoordinateOperationMetadata,
161 CompiledOperationPipeline,
162 )> = None;
163 let mut fallback_pipelines = Vec::new();
164
165 for (index, candidate) in candidate_set.ranked.iter().enumerate() {
166 if let Some((_, selected_candidate, ..)) = &selected {
167 if !selected_candidate.operation.uses_grids() {
168 skipped_operations.push(skipped_for_unselected_candidate(
169 candidate,
170 !selected_candidate.operation.deprecated,
171 ));
172 continue;
173 }
174 }
175
176 match compile_pipeline(
177 from,
178 to,
179 candidate.operation.as_ref(),
180 candidate.direction,
181 &grid_runtime,
182 ) {
183 Ok(pipeline) => {
184 let metadata = selected_metadata(
185 candidate.operation.as_ref(),
186 candidate.direction,
187 candidate.matched_area_of_use.clone(),
188 );
189 if let Some((_, selected_candidate, ..)) = &selected {
190 skipped_operations.push(skipped_for_unselected_candidate(
191 candidate,
192 !selected_candidate.operation.deprecated,
193 ));
194 fallback_pipelines.push(CompiledOperationFallback { metadata, pipeline });
195 } else {
196 selected = Some((index, candidate, metadata, pipeline));
197 }
198 }
199 Err(Error::Grid(error)) => {
200 if selected.is_none() && missing_required_grid.is_none() {
201 missing_required_grid = Some(error.to_string());
202 }
203 skipped_operations.push(SkippedOperation {
204 metadata: selected_metadata(
205 candidate.operation.as_ref(),
206 candidate.direction,
207 candidate.matched_area_of_use.clone(),
208 ),
209 reason: match error {
210 crate::grid::GridError::UnsupportedFormat(_) => {
211 SkippedOperationReason::UnsupportedGridFormat
212 }
213 _ => SkippedOperationReason::MissingGrid,
214 },
215 detail: error.to_string(),
216 });
217 }
218 Err(error) => {
219 skipped_operations.push(SkippedOperation {
220 metadata: selected_metadata(
221 candidate.operation.as_ref(),
222 candidate.direction,
223 candidate.matched_area_of_use.clone(),
224 ),
225 reason: SkippedOperationReason::LessPreferred,
226 detail: error.to_string(),
227 });
228 }
229 }
230 }
231
232 if let Some((index, candidate, metadata, pipeline)) = selected {
233 let selected_reasons =
234 selected_reasons_for(candidate, &candidate_set.ranked[index + 1..]);
235 let diagnostics = OperationSelectionDiagnostics {
236 selected_operation: metadata.clone(),
237 selected_match_kind: candidate.match_kind,
238 selected_reasons,
239 fallback_operations: fallback_pipelines
240 .iter()
241 .map(|fallback| fallback.metadata.clone())
242 .collect(),
243 skipped_operations,
244 approximate: candidate.operation.approximate,
245 missing_required_grid,
246 };
247 return Ok(Self {
248 source: from.clone(),
249 target: to.clone(),
250 selected_operation_definition: candidate.operation.clone().into_owned(),
251 selected_direction: candidate.direction,
252 selected_operation: metadata,
253 diagnostics,
254 selection_options: options,
255 pipeline,
256 fallback_pipelines,
257 });
258 }
259
260 if let Some(message) = missing_required_grid {
261 return Err(Error::OperationSelection(format!(
262 "better operations were skipped because required grids were unavailable: {message}"
263 )));
264 }
265
266 Err(Error::OperationSelection(format!(
267 "unable to compile an operation for source EPSG:{} target EPSG:{}",
268 from.epsg(),
269 to.epsg()
270 )))
271 }
272
273 pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
275 let c = coord.into_coord();
276 let result = self.convert_coord(c)?;
277 Ok(T::from_coord(result))
278 }
279
280 pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
282 let c = coord.into_coord3d();
283 let result = self.convert_coord3d(c)?;
284 Ok(T::from_coord3d(result))
285 }
286
287 pub fn convert_with_diagnostics<T: Transformable>(
293 &self,
294 coord: T,
295 ) -> Result<TransformOutcome<T>> {
296 let c = coord.into_coord();
297 let outcome = self.convert_coord_with_diagnostics(c)?;
298 Ok(TransformOutcome {
299 coord: T::from_coord(outcome.coord),
300 operation: outcome.operation,
301 grid_coverage_misses: outcome.grid_coverage_misses,
302 })
303 }
304
305 pub fn convert_3d_with_diagnostics<T: Transformable3D>(
311 &self,
312 coord: T,
313 ) -> Result<TransformOutcome<T>> {
314 let c = coord.into_coord3d();
315 let outcome = self.convert_coord3d_with_diagnostics(c)?;
316 Ok(TransformOutcome {
317 coord: T::from_coord3d(outcome.coord),
318 operation: outcome.operation,
319 grid_coverage_misses: outcome.grid_coverage_misses,
320 })
321 }
322
323 pub fn source_crs(&self) -> &CrsDef {
325 &self.source
326 }
327
328 pub fn target_crs(&self) -> &CrsDef {
330 &self.target
331 }
332
333 pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
335 &self.selected_operation
336 }
337
338 pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
340 &self.diagnostics
341 }
342
343 pub fn inverse(&self) -> Result<Self> {
345 let grid_runtime = GridRuntime::new(self.selection_options.grid_provider.clone());
346 let selected_direction = self.selected_direction.inverse();
347 let pipeline = compile_pipeline(
348 &self.target,
349 &self.source,
350 &self.selected_operation_definition,
351 selected_direction,
352 &grid_runtime,
353 )?;
354 let selected_operation = selected_metadata(
355 &self.selected_operation_definition,
356 selected_direction,
357 self.selected_operation.area_of_use.clone(),
358 );
359 let diagnostics = OperationSelectionDiagnostics {
360 selected_operation: selected_operation.clone(),
361 selected_match_kind: self.diagnostics.selected_match_kind,
362 selected_reasons: self.diagnostics.selected_reasons.clone(),
363 fallback_operations: Vec::new(),
364 skipped_operations: Vec::new(),
365 approximate: self.diagnostics.approximate,
366 missing_required_grid: self.diagnostics.missing_required_grid.clone(),
367 };
368 Ok(Self {
369 source: self.target.clone(),
370 target: self.source.clone(),
371 selected_operation_definition: self.selected_operation_definition.clone(),
372 selected_direction,
373 selected_operation,
374 diagnostics,
375 selection_options: self.selection_options.inverse(),
376 pipeline,
377 fallback_pipelines: Vec::new(),
378 })
379 }
380
381 pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
383 if !bounds.is_valid() {
384 return Err(Error::OutOfRange(
385 "bounds must be finite and satisfy min <= max".into(),
386 ));
387 }
388
389 let segments = densify_points
390 .checked_add(1)
391 .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))?;
392
393 let mut transformed: Option<Bounds> = None;
394 for i in 0..=segments {
395 let t = i as f64 / segments as f64;
396 let x = bounds.min_x + bounds.width() * t;
397 let y = bounds.min_y + bounds.height() * t;
398
399 for sample in [
400 Coord::new(x, bounds.min_y),
401 Coord::new(x, bounds.max_y),
402 Coord::new(bounds.min_x, y),
403 Coord::new(bounds.max_x, y),
404 ] {
405 let coord = self.convert_coord(sample)?;
406 if let Some(accum) = &mut transformed {
407 accum.expand_to_include(coord);
408 } else {
409 transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
410 }
411 }
412 }
413
414 transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
415 }
416
417 fn convert_coord(&self, c: Coord) -> Result<Coord> {
418 let result = self.convert_coord3d(Coord3D::new(c.x, c.y, 0.0))?;
419 Ok(Coord::new(result.x, result.y))
420 }
421
422 fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
423 Ok(self.convert_coord3d_with_diagnostics(c)?.coord)
424 }
425
426 fn convert_coord_with_diagnostics(&self, c: Coord) -> Result<TransformOutcome<Coord>> {
427 let outcome = self.convert_coord3d_with_diagnostics(Coord3D::new(c.x, c.y, 0.0))?;
428 Ok(TransformOutcome {
429 coord: Coord::new(outcome.coord.x, outcome.coord.y),
430 operation: outcome.operation,
431 grid_coverage_misses: outcome.grid_coverage_misses,
432 })
433 }
434
435 fn convert_coord3d_with_diagnostics(&self, c: Coord3D) -> Result<TransformOutcome<Coord3D>> {
436 let mut grid_coverage_misses = Vec::new();
437 match self.execute_pipeline(&self.pipeline, c) {
438 Ok(coord) => {
439 return Ok(TransformOutcome {
440 coord,
441 operation: self.selected_operation.clone(),
442 grid_coverage_misses,
443 });
444 }
445 Err(error) => {
446 if let Some(detail) = grid_coverage_miss_detail(&error) {
447 grid_coverage_misses.push(GridCoverageMiss {
448 operation: self.selected_operation.clone(),
449 detail,
450 });
451 } else {
452 return Err(error);
453 }
454 }
455 }
456
457 for fallback in &self.fallback_pipelines {
458 match self.execute_pipeline(&fallback.pipeline, c) {
459 Ok(coord) => {
460 return Ok(TransformOutcome {
461 coord,
462 operation: fallback.metadata.clone(),
463 grid_coverage_misses,
464 });
465 }
466 Err(error) => {
467 if let Some(detail) = grid_coverage_miss_detail(&error) {
468 grid_coverage_misses.push(GridCoverageMiss {
469 operation: fallback.metadata.clone(),
470 detail,
471 });
472 } else {
473 return Err(error);
474 }
475 }
476 }
477 }
478
479 Err(Error::Grid(GridError::OutsideCoverage(
480 grid_coverage_misses
481 .last()
482 .map(|miss| miss.detail.clone())
483 .unwrap_or_else(|| "grid coverage miss".into()),
484 )))
485 }
486
487 fn execute_pipeline(
488 &self,
489 pipeline: &CompiledOperationPipeline,
490 c: Coord3D,
491 ) -> Result<Coord3D> {
492 if pipeline.steps.is_empty() {
493 return Ok(c);
494 }
495 let preserved_z = c.z;
496 let mut state = if self.source.is_projected() {
497 let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
498 Coord3D::new(x_m, y_m, 0.0)
499 } else {
500 Coord3D::new(c.x.to_radians(), c.y.to_radians(), 0.0)
501 };
502
503 for step in &pipeline.steps {
504 state = execute_step(step, state)?;
505 }
506
507 let (x, y) = if self.target.is_projected() {
508 self.projected_meters_to_target_native(state.x, state.y)
509 } else {
510 (state.x.to_degrees(), state.y.to_degrees())
511 };
512
513 Ok(Coord3D::new(x, y, preserved_z))
514 }
515
516 fn source_projected_native_to_meters(&self, x: f64, y: f64) -> (f64, f64) {
517 match &self.source {
518 CrsDef::Projected(projected) => (
519 projected.linear_unit().to_meters(x),
520 projected.linear_unit().to_meters(y),
521 ),
522 CrsDef::Geographic(_) => (x, y),
523 }
524 }
525
526 fn projected_meters_to_target_native(&self, x: f64, y: f64) -> (f64, f64) {
527 match &self.target {
528 CrsDef::Projected(projected) => (
529 projected.linear_unit().from_meters(x),
530 projected.linear_unit().from_meters(y),
531 ),
532 CrsDef::Geographic(_) => (x, y),
533 }
534 }
535
536 pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
538 coords.iter().map(|c| self.convert(c.clone())).collect()
539 }
540
541 pub fn convert_batch_3d<T: Transformable3D + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
543 coords.iter().map(|c| self.convert_3d(c.clone())).collect()
544 }
545
546 pub fn convert_coords_in_place(&self, coords: &mut [Coord]) -> Result<()> {
551 for coord in coords {
552 *coord = self.convert_coord(*coord)?;
553 }
554 Ok(())
555 }
556
557 pub fn convert_coords_3d_in_place(&self, coords: &mut [Coord3D]) -> Result<()> {
562 for coord in coords {
563 *coord = self.convert_coord3d(*coord)?;
564 }
565 Ok(())
566 }
567
568 pub fn convert_coords_into(&self, input: &[Coord], output: &mut [Coord]) -> Result<()> {
573 validate_output_len(input.len(), output.len())?;
574 for (source, target) in input.iter().zip(output.iter_mut()) {
575 *target = self.convert_coord(*source)?;
576 }
577 Ok(())
578 }
579
580 pub fn convert_coords_3d_into(&self, input: &[Coord3D], output: &mut [Coord3D]) -> Result<()> {
585 validate_output_len(input.len(), output.len())?;
586 for (source, target) in input.iter().zip(output.iter_mut()) {
587 *target = self.convert_coord3d(*source)?;
588 }
589 Ok(())
590 }
591
592 #[cfg(feature = "rayon")]
594 pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
595 &self,
596 coords: &[T],
597 ) -> Result<Vec<T>> {
598 self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch(chunk))
599 }
600
601 #[cfg(feature = "rayon")]
603 pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync + Clone>(
604 &self,
605 coords: &[T],
606 ) -> Result<Vec<T>> {
607 self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch_3d(chunk))
608 }
609
610 #[cfg(feature = "rayon")]
611 fn convert_batch_parallel_adaptive<T, F>(&self, coords: &[T], convert: F) -> Result<Vec<T>>
612 where
613 T: Send + Sync + Clone,
614 F: Fn(&Self, &[T]) -> Result<Vec<T>> + Sync,
615 {
616 if !should_parallelize(coords.len()) {
617 return convert(self, coords);
618 }
619
620 use rayon::prelude::*;
621
622 let chunk_size = parallel_chunk_size(coords.len());
623 let chunk_results: Vec<Result<Vec<T>>> = coords
624 .par_chunks(chunk_size)
625 .map(|chunk| convert(self, chunk))
626 .collect();
627
628 let mut results = Vec::with_capacity(coords.len());
629 for chunk in chunk_results {
630 results.extend(chunk?);
631 }
632 Ok(results)
633 }
634}
635
636fn selected_metadata(
637 operation: &CoordinateOperation,
638 direction: OperationStepDirection,
639 matched_area_of_use: Option<crate::operation::AreaOfUse>,
640) -> CoordinateOperationMetadata {
641 let mut metadata = operation.metadata_for_direction(direction);
642 metadata.area_of_use = matched_area_of_use.or_else(|| operation.areas_of_use.first().cloned());
643 metadata
644}
645
646fn grid_coverage_miss_detail(error: &Error) -> Option<String> {
647 match error {
648 Error::Grid(GridError::OutsideCoverage(detail)) => Some(detail.clone()),
649 _ => None,
650 }
651}
652
653fn validate_output_len(input_len: usize, output_len: usize) -> Result<()> {
654 if input_len != output_len {
655 return Err(Error::OutOfRange(format!(
656 "output coordinate slice length {output_len} does not match input length {input_len}"
657 )));
658 }
659 Ok(())
660}
661
662fn selected_reasons_for(
663 selected: &selector::RankedOperationCandidate,
664 alternatives: &[selector::RankedOperationCandidate],
665) -> SmallVec<[crate::operation::SelectionReason; 4]> {
666 let mut reasons = selected.reasons.clone();
667 if selected_accuracy_preferred(selected, alternatives)
668 && !reasons.contains(&crate::operation::SelectionReason::AccuracyPreferred)
669 {
670 reasons.push(crate::operation::SelectionReason::AccuracyPreferred);
671 }
672 reasons
673}
674
675fn selected_accuracy_preferred(
676 selected: &selector::RankedOperationCandidate,
677 alternatives: &[selector::RankedOperationCandidate],
678) -> bool {
679 let Some(selected_accuracy) = selected.operation.accuracy.map(|value| value.meters) else {
680 return false;
681 };
682
683 alternatives.iter().any(|alternative| {
684 same_pre_accuracy_priority(selected, alternative)
685 && alternative
686 .operation
687 .accuracy
688 .map(|value| selected_accuracy < value.meters)
689 .unwrap_or(false)
690 })
691}
692
693fn same_pre_accuracy_priority(
694 left: &selector::RankedOperationCandidate,
695 right: &selector::RankedOperationCandidate,
696) -> bool {
697 match_kind_priority(left.match_kind) == match_kind_priority(right.match_kind)
698 && left.matched_area_of_use.is_some() == right.matched_area_of_use.is_some()
699}
700
701fn match_kind_priority(kind: crate::operation::OperationMatchKind) -> u8 {
702 match kind {
703 crate::operation::OperationMatchKind::Explicit => 4,
704 crate::operation::OperationMatchKind::ExactSourceTarget => 3,
705 crate::operation::OperationMatchKind::DerivedGeographic => 2,
706 crate::operation::OperationMatchKind::DatumCompatible => 1,
707 crate::operation::OperationMatchKind::ApproximateFallback => 0,
708 }
709}
710
711fn skipped_for_unselected_candidate(
712 candidate: &selector::RankedOperationCandidate,
713 prefer_non_deprecated: bool,
714) -> SkippedOperation {
715 let reason = if prefer_non_deprecated && candidate.operation.deprecated {
716 SkippedOperationReason::Deprecated
717 } else {
718 SkippedOperationReason::LessPreferred
719 };
720 let detail = match reason {
721 SkippedOperationReason::Deprecated => {
722 "not selected because a non-deprecated higher-ranked operation compiled successfully"
723 .into()
724 }
725 _ => "not selected because a higher-ranked operation compiled successfully".into(),
726 };
727 SkippedOperation {
728 metadata: selected_metadata(
729 candidate.operation.as_ref(),
730 candidate.direction,
731 candidate.matched_area_of_use.clone(),
732 ),
733 reason,
734 detail,
735 }
736}
737
738fn execute_step(step: &CompiledStep, coord: Coord3D) -> Result<Coord3D> {
739 match step {
740 CompiledStep::ProjectionForward { projection } => {
741 let (x, y) = projection.forward(coord.x, coord.y)?;
742 Ok(Coord3D::new(x, y, coord.z))
743 }
744 CompiledStep::ProjectionInverse { projection } => {
745 let (lon, lat) = projection.inverse(coord.x, coord.y)?;
746 Ok(Coord3D::new(lon, lat, coord.z))
747 }
748 CompiledStep::Helmert { params, inverse } => {
749 let (x, y, z) = if *inverse {
750 helmert::helmert_inverse(params, coord.x, coord.y, coord.z)
751 } else {
752 helmert::helmert_forward(params, coord.x, coord.y, coord.z)
753 };
754 Ok(Coord3D::new(x, y, z))
755 }
756 CompiledStep::GridShift { handle, direction } => {
757 let (lon, lat) = handle.apply(coord.x, coord.y, *direction)?;
758 Ok(Coord3D::new(lon, lat, coord.z))
759 }
760 CompiledStep::GridShiftList {
761 handles,
762 allow_null,
763 direction,
764 } => {
765 let mut last_coverage_miss = None;
766 for handle in handles.iter() {
767 match handle.apply(coord.x, coord.y, *direction) {
768 Ok((lon, lat)) => return Ok(Coord3D::new(lon, lat, coord.z)),
769 Err(GridError::OutsideCoverage(detail)) => {
770 last_coverage_miss = Some(detail);
771 }
772 Err(error) => return Err(Error::Grid(error)),
773 }
774 }
775
776 if *allow_null {
777 return Ok(coord);
778 }
779
780 Err(Error::Grid(GridError::OutsideCoverage(
781 last_coverage_miss.unwrap_or_else(|| "no datum grid covered coordinate".into()),
782 )))
783 }
784 CompiledStep::GeodeticToGeocentric { ellipsoid } => {
785 let (x, y, z) =
786 geocentric::geodetic_to_geocentric(ellipsoid, coord.x, coord.y, coord.z);
787 Ok(Coord3D::new(x, y, z))
788 }
789 CompiledStep::GeocentricToGeodetic { ellipsoid } => {
790 let (lon, lat, h) =
791 geocentric::geocentric_to_geodetic(ellipsoid, coord.x, coord.y, coord.z);
792 Ok(Coord3D::new(lon, lat, h))
793 }
794 }
795}
796
797fn compile_pipeline(
798 source: &CrsDef,
799 target: &CrsDef,
800 operation: &CoordinateOperation,
801 direction: OperationStepDirection,
802 grid_runtime: &GridRuntime,
803) -> Result<CompiledOperationPipeline> {
804 let mut steps = SmallVec::<[CompiledStep; 8]>::new();
805
806 if let CrsDef::Projected(projected) = source {
807 steps.push(CompiledStep::ProjectionInverse {
808 projection: make_projection(&projected.method(), projected.datum())?,
809 });
810 }
811
812 if operation.id.is_none() && matches!(operation.method, OperationMethod::Identity) {
813 } else {
815 compile_operation(
816 operation,
817 direction,
818 Some((source, target)),
819 grid_runtime,
820 &mut steps,
821 )?;
822 }
823
824 if let CrsDef::Projected(projected) = target {
825 steps.push(CompiledStep::ProjectionForward {
826 projection: make_projection(&projected.method(), projected.datum())?,
827 });
828 }
829
830 Ok(CompiledOperationPipeline { steps })
831}
832
833fn compile_operation(
834 operation: &CoordinateOperation,
835 direction: OperationStepDirection,
836 requested_pair: Option<(&CrsDef, &CrsDef)>,
837 grid_runtime: &GridRuntime,
838 steps: &mut SmallVec<[CompiledStep; 8]>,
839) -> Result<()> {
840 let (source_geo, target_geo) =
841 resolve_operation_geographic_pair(operation, direction, requested_pair)?;
842 match (&operation.method, direction) {
843 (OperationMethod::Identity, _) => {}
844 (OperationMethod::Helmert { params }, OperationStepDirection::Forward) => {
845 steps.push(CompiledStep::GeodeticToGeocentric {
846 ellipsoid: source_geo.datum().ellipsoid,
847 });
848 steps.push(CompiledStep::Helmert {
849 params: *params,
850 inverse: false,
851 });
852 steps.push(CompiledStep::GeocentricToGeodetic {
853 ellipsoid: target_geo.datum().ellipsoid,
854 });
855 }
856 (OperationMethod::Helmert { params }, OperationStepDirection::Reverse) => {
857 steps.push(CompiledStep::GeodeticToGeocentric {
858 ellipsoid: source_geo.datum().ellipsoid,
859 });
860 steps.push(CompiledStep::Helmert {
861 params: *params,
862 inverse: true,
863 });
864 steps.push(CompiledStep::GeocentricToGeodetic {
865 ellipsoid: target_geo.datum().ellipsoid,
866 });
867 }
868 (
869 OperationMethod::DatumShift {
870 source_to_wgs84,
871 target_to_wgs84,
872 },
873 OperationStepDirection::Forward,
874 ) => {
875 compile_to_wgs84(
876 source_to_wgs84,
877 source_geo.datum().ellipsoid,
878 grid_runtime,
879 steps,
880 )?;
881 compile_from_wgs84(
882 target_to_wgs84,
883 target_geo.datum().ellipsoid,
884 grid_runtime,
885 steps,
886 )?;
887 }
888 (
889 OperationMethod::DatumShift {
890 source_to_wgs84,
891 target_to_wgs84,
892 },
893 OperationStepDirection::Reverse,
894 ) => {
895 compile_to_wgs84(
896 target_to_wgs84,
897 source_geo.datum().ellipsoid,
898 grid_runtime,
899 steps,
900 )?;
901 compile_from_wgs84(
902 source_to_wgs84,
903 target_geo.datum().ellipsoid,
904 grid_runtime,
905 steps,
906 )?;
907 }
908 (
909 OperationMethod::GridShift {
910 grid_id,
911 direction: grid_direction,
912 ..
913 },
914 step_direction,
915 ) => {
916 let grid = registry::lookup_grid_definition(grid_id.0).ok_or_else(|| {
917 Error::Grid(crate::grid::GridError::NotFound(format!(
918 "grid id {}",
919 grid_id.0
920 )))
921 })?;
922 if grid.format == crate::grid::GridFormat::Unsupported {
923 return Err(Error::Grid(crate::grid::GridError::UnsupportedFormat(
924 grid.name,
925 )));
926 }
927 let handle = grid_runtime.resolve_handle(&grid)?;
928 let direction = match step_direction {
929 OperationStepDirection::Forward => *grid_direction,
930 OperationStepDirection::Reverse => grid_direction.inverse(),
931 };
932 steps.push(CompiledStep::GridShift { handle, direction });
933 }
934 (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Forward) => {
935 for step in child_steps {
936 let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
937 Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
938 })?;
939 compile_operation(&child, step.direction, None, grid_runtime, steps)?;
940 }
941 }
942 (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Reverse) => {
943 for step in child_steps.iter().rev() {
944 let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
945 Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
946 })?;
947 compile_operation(&child, step.direction.inverse(), None, grid_runtime, steps)?;
948 }
949 }
950 (OperationMethod::Projection { .. }, _) | (OperationMethod::AxisUnitNormalize, _) => {
951 return Err(Error::UnsupportedProjection(
952 "direct projection operations are not emitted by the embedded selector".into(),
953 ));
954 }
955 }
956 Ok(())
957}
958
959fn compile_to_wgs84(
960 transform: &DatumToWgs84,
961 source_ellipsoid: Ellipsoid,
962 grid_runtime: &GridRuntime,
963 steps: &mut SmallVec<[CompiledStep; 8]>,
964) -> Result<()> {
965 match transform {
966 DatumToWgs84::Identity => Ok(()),
967 DatumToWgs84::Helmert(params) => {
968 steps.push(CompiledStep::GeodeticToGeocentric {
969 ellipsoid: source_ellipsoid,
970 });
971 steps.push(CompiledStep::Helmert {
972 params: *params,
973 inverse: false,
974 });
975 steps.push(CompiledStep::GeocentricToGeodetic {
976 ellipsoid: ellipsoid::WGS84,
977 });
978 Ok(())
979 }
980 DatumToWgs84::GridShift(grids) => {
981 compile_grid_shift_list(grids, GridShiftDirection::Forward, grid_runtime, steps)
982 }
983 DatumToWgs84::Unknown => Err(Error::OperationSelection(
984 "datum has no known path to WGS84".into(),
985 )),
986 }
987}
988
989fn compile_from_wgs84(
990 transform: &DatumToWgs84,
991 target_ellipsoid: Ellipsoid,
992 grid_runtime: &GridRuntime,
993 steps: &mut SmallVec<[CompiledStep; 8]>,
994) -> Result<()> {
995 match transform {
996 DatumToWgs84::Identity => Ok(()),
997 DatumToWgs84::Helmert(params) => {
998 steps.push(CompiledStep::GeodeticToGeocentric {
999 ellipsoid: ellipsoid::WGS84,
1000 });
1001 steps.push(CompiledStep::Helmert {
1002 params: *params,
1003 inverse: true,
1004 });
1005 steps.push(CompiledStep::GeocentricToGeodetic {
1006 ellipsoid: target_ellipsoid,
1007 });
1008 Ok(())
1009 }
1010 DatumToWgs84::GridShift(grids) => {
1011 compile_grid_shift_list(grids, GridShiftDirection::Reverse, grid_runtime, steps)
1012 }
1013 DatumToWgs84::Unknown => Err(Error::OperationSelection(
1014 "datum has no known path from WGS84".into(),
1015 )),
1016 }
1017}
1018
1019fn compile_grid_shift_list(
1020 grids: &DatumGridShift,
1021 direction: GridShiftDirection,
1022 grid_runtime: &GridRuntime,
1023 steps: &mut SmallVec<[CompiledStep; 8]>,
1024) -> Result<()> {
1025 let mut handles = Vec::<GridHandle>::new();
1026 let mut allow_null = false;
1027 let mut required_grid_seen = false;
1028
1029 for entry in grids.entries() {
1030 match entry {
1031 DatumGridShiftEntry::Null => {
1032 allow_null = true;
1033 break;
1034 }
1035 DatumGridShiftEntry::Grid {
1036 definition,
1037 optional,
1038 } => {
1039 if !optional {
1040 required_grid_seen = true;
1041 }
1042 match grid_runtime.resolve_handle(definition) {
1043 Ok(handle) => handles.push(handle),
1044 Err(GridError::Unavailable(_) | GridError::NotFound(_)) if *optional => {}
1045 Err(error) => return Err(Error::Grid(error)),
1046 }
1047 }
1048 }
1049 }
1050
1051 if handles.is_empty() && !allow_null {
1052 if required_grid_seen {
1053 return Err(Error::Grid(GridError::Unavailable(
1054 "no required datum grid could be loaded".into(),
1055 )));
1056 }
1057 return Err(Error::Grid(GridError::Unavailable(
1058 "no optional datum grid could be loaded".into(),
1059 )));
1060 }
1061
1062 steps.push(CompiledStep::GridShiftList {
1063 handles: handles.into_boxed_slice(),
1064 allow_null,
1065 direction,
1066 });
1067 Ok(())
1068}
1069
1070fn resolve_operation_geographic_pair(
1071 operation: &CoordinateOperation,
1072 direction: OperationStepDirection,
1073 requested_pair: Option<(&CrsDef, &CrsDef)>,
1074) -> Result<(CrsDef, CrsDef)> {
1075 if let (Some(source_code), Some(target_code)) =
1076 (operation.source_crs_epsg, operation.target_crs_epsg)
1077 {
1078 let source = registry::lookup_epsg(match direction {
1079 OperationStepDirection::Forward => source_code,
1080 OperationStepDirection::Reverse => target_code,
1081 })
1082 .ok_or_else(|| {
1083 Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
1084 })?;
1085 let target = registry::lookup_epsg(match direction {
1086 OperationStepDirection::Forward => target_code,
1087 OperationStepDirection::Reverse => source_code,
1088 })
1089 .ok_or_else(|| {
1090 Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
1091 })?;
1092 return Ok((source, target));
1093 }
1094
1095 if let Some((source, target)) = requested_pair {
1096 return Ok((source.clone(), target.clone()));
1097 }
1098
1099 Err(Error::OperationSelection(format!(
1100 "operation {} is missing source/target CRS metadata",
1101 operation.name
1102 )))
1103}
1104
1105#[cfg(feature = "rayon")]
1106fn should_parallelize(len: usize) -> bool {
1107 if len == 0 {
1108 return false;
1109 }
1110
1111 let threads = rayon::current_num_threads().max(1);
1112 len >= PARALLEL_MIN_TOTAL_ITEMS.max(threads.saturating_mul(PARALLEL_MIN_ITEMS_PER_THREAD))
1113}
1114
1115#[cfg(feature = "rayon")]
1116fn parallel_chunk_size(len: usize) -> usize {
1117 let threads = rayon::current_num_threads().max(1);
1118 let target_chunks = threads.saturating_mul(PARALLEL_CHUNKS_PER_THREAD).max(1);
1119 let chunk_size = len.div_ceil(target_chunks);
1120 chunk_size.max(PARALLEL_MIN_CHUNK_SIZE)
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use crate::crs::{CrsDef, GeographicCrsDef, LinearUnit, ProjectedCrsDef, ProjectionMethod};
1127 use crate::datum::{self, DatumToWgs84};
1128 use crate::operation::{
1129 AreaOfInterest, OperationMatchKind, SelectionPolicy, SelectionReason,
1130 SkippedOperationReason,
1131 };
1132
1133 const US_FOOT_TO_METER: f64 = 0.3048006096012192;
1134
1135 fn expect_transform_error(result: Result<Transform>) -> Error {
1136 match result {
1137 Ok(_) => panic!("expected transform construction to fail"),
1138 Err(err) => err,
1139 }
1140 }
1141
1142 #[test]
1143 fn identity_same_crs() {
1144 let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
1145 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1146 assert_eq!(x, -74.006);
1147 assert_eq!(y, 40.7128);
1148 }
1149
1150 #[test]
1151 fn wgs84_to_web_mercator() {
1152 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1153 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1154 assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
1155 assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
1156 }
1157
1158 #[test]
1159 fn web_mercator_to_wgs84() {
1160 let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
1161 let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
1162 assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
1163 assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
1164 }
1165
1166 #[test]
1167 fn roundtrip_4326_3857() {
1168 let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1169 let inv = fwd.inverse().unwrap();
1170
1171 let original = (-74.0445, 40.6892);
1172 let projected = fwd.convert(original).unwrap();
1173 let back = inv.convert(projected).unwrap();
1174
1175 assert!((back.0 - original.0).abs() < 1e-8);
1176 assert!((back.1 - original.1).abs() < 1e-8);
1177 }
1178
1179 #[test]
1180 fn wgs84_to_utm_18n() {
1181 let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
1182 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
1183 assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
1184 assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
1185 }
1186
1187 #[test]
1188 fn equivalent_meter_and_foot_state_plane_crs_match_after_unit_conversion() {
1189 let coord = (-80.8431, 35.2271);
1190 let meter_tx = Transform::new("EPSG:4326", "EPSG:32119").unwrap();
1191 let foot_tx = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
1192
1193 let (mx, my) = meter_tx.convert(coord).unwrap();
1194 let (fx, fy) = foot_tx.convert(coord).unwrap();
1195
1196 assert!((fx * US_FOOT_TO_METER - mx).abs() < 0.02);
1197 assert!((fy * US_FOOT_TO_METER - my).abs() < 0.02);
1198 }
1199
1200 #[test]
1201 fn inverse_transform_accepts_native_projected_units_for_foot_crs() {
1202 let coord = (-80.8431, 35.2271);
1203 let forward = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
1204 let inverse = Transform::new("EPSG:2264", "EPSG:4326").unwrap();
1205
1206 let projected = forward.convert(coord).unwrap();
1207 let roundtrip = inverse.convert(projected).unwrap();
1208
1209 assert!((roundtrip.0 - coord.0).abs() < 1e-8);
1210 assert!((roundtrip.1 - coord.1).abs() < 1e-8);
1211 }
1212
1213 #[test]
1214 fn utm_to_web_mercator() {
1215 let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
1216 let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
1217 assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
1218 }
1219
1220 #[test]
1221 fn wgs84_to_polar_stereo_3413() {
1222 let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
1223 let (x, y) = t.convert((-45.0, 90.0)).unwrap();
1224 assert!(x.abs() < 1.0, "x = {x}");
1225 assert!(y.abs() < 1.0, "y = {y}");
1226 }
1227
1228 #[test]
1229 fn roundtrip_4326_3413() {
1230 let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
1231 let inv = fwd.inverse().unwrap();
1232
1233 let original = (-45.0, 75.0);
1234 let projected = fwd.convert(original).unwrap();
1235 let back = inv.convert(projected).unwrap();
1236
1237 assert!((back.0 - original.0).abs() < 1e-6);
1238 assert!((back.1 - original.1).abs() < 1e-6);
1239 }
1240
1241 #[test]
1242 fn geographic_to_geographic_same_datum_is_identity() {
1243 let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
1244 let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
1245 assert_eq!(lon, -74.006);
1246 assert_eq!(lat, 40.7128);
1247 assert_eq!(t.selected_operation().name, "Identity");
1248 }
1249
1250 #[test]
1251 fn unknown_crs_error() {
1252 let result = Transform::new("EPSG:99999", "EPSG:4326");
1253 assert!(result.is_err());
1254 }
1255
1256 #[test]
1257 fn cross_datum_nad27_to_wgs84() {
1258 let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1259 let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
1260 assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
1261 assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
1262 assert!(!t.selected_operation().approximate);
1263 }
1264
1265 #[test]
1266 fn explicit_grid_operation_compiles() {
1267 let t = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1268 .unwrap();
1269 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1693)));
1270 }
1271
1272 #[test]
1273 fn explicit_operation_rejects_incompatible_crs_pair() {
1274 let err = match Transform::from_operation(
1275 CoordinateOperationId(1693),
1276 "EPSG:4326",
1277 "EPSG:3857",
1278 ) {
1279 Ok(_) => panic!("incompatible operation should be rejected"),
1280 Err(err) => err,
1281 };
1282 assert!(matches!(err, Error::OperationSelection(_)));
1283 assert!(err.to_string().contains("not compatible"));
1284 }
1285
1286 #[test]
1287 fn explicit_selection_options_choose_grid_operation() {
1288 let t = Transform::with_selection_options(
1289 "EPSG:4267",
1290 "EPSG:4269",
1291 SelectionOptions {
1292 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1293 -80.5041667,
1294 44.5458333,
1295 ))),
1296 ..SelectionOptions::default()
1297 },
1298 )
1299 .unwrap();
1300 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
1301 assert!(!t.selection_diagnostics().approximate);
1302 }
1303
1304 #[test]
1305 fn source_crs_area_of_interest_is_normalized_before_selection() {
1306 let to_projected = Transform::new("EPSG:4267", "EPSG:26717").unwrap();
1307 let projected = to_projected.convert((-80.5041667, 44.5458333)).unwrap();
1308
1309 let t = Transform::with_selection_options(
1310 "EPSG:26717",
1311 "EPSG:4269",
1312 SelectionOptions {
1313 area_of_interest: Some(AreaOfInterest::source_crs_point(Coord::new(
1314 projected.0,
1315 projected.1,
1316 ))),
1317 ..SelectionOptions::default()
1318 },
1319 )
1320 .unwrap();
1321
1322 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
1323 assert_eq!(
1324 t.selection_diagnostics().selected_match_kind,
1325 OperationMatchKind::DerivedGeographic
1326 );
1327 }
1328
1329 #[test]
1330 fn area_of_interest_rejects_invalid_geographic_bounds() {
1331 for bounds in [
1332 Bounds::new(10.0, 5.0, -10.0, 20.0),
1333 Bounds::new(f64::NAN, 5.0, 10.0, 20.0),
1334 Bounds::new(-181.0, 5.0, -170.0, 20.0),
1335 Bounds::new(-80.0, -91.0, -70.0, -80.0),
1336 ] {
1337 let err = expect_transform_error(Transform::with_selection_options(
1338 "EPSG:4267",
1339 "EPSG:4269",
1340 SelectionOptions {
1341 area_of_interest: Some(AreaOfInterest::geographic_bounds(bounds)),
1342 ..SelectionOptions::default()
1343 },
1344 ));
1345
1346 assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
1347 }
1348 }
1349
1350 #[test]
1351 fn area_of_interest_validates_geographic_source_and_target_bounds() {
1352 for area_of_interest in [
1353 AreaOfInterest::source_crs_bounds(Bounds::new(-181.0, 40.0, -170.0, 45.0)),
1354 AreaOfInterest::target_crs_bounds(Bounds::new(-80.0, 40.0, -70.0, 91.0)),
1355 ] {
1356 let err = expect_transform_error(Transform::with_selection_options(
1357 "EPSG:4267",
1358 "EPSG:4269",
1359 SelectionOptions {
1360 area_of_interest: Some(area_of_interest),
1361 ..SelectionOptions::default()
1362 },
1363 ));
1364
1365 assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
1366 }
1367 }
1368
1369 #[test]
1370 fn area_of_interest_rejects_invalid_projected_bounds_before_sampling() {
1371 let err = expect_transform_error(Transform::with_selection_options(
1372 "EPSG:3857",
1373 "EPSG:4326",
1374 SelectionOptions {
1375 area_of_interest: Some(AreaOfInterest::source_crs_bounds(Bounds::new(
1376 10.0, 0.0, -10.0, 10.0,
1377 ))),
1378 ..SelectionOptions::default()
1379 },
1380 ));
1381
1382 assert!(matches!(err, Error::OutOfRange(_)), "got {err}");
1383 }
1384
1385 #[test]
1386 fn selection_diagnostics_capture_accuracy_preference() {
1387 let t = Transform::with_selection_options(
1388 "EPSG:4267",
1389 "EPSG:4269",
1390 SelectionOptions {
1391 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1392 -80.5041667,
1393 44.5458333,
1394 ))),
1395 ..SelectionOptions::default()
1396 },
1397 )
1398 .unwrap();
1399
1400 assert!(t
1401 .selection_diagnostics()
1402 .selected_reasons
1403 .contains(&SelectionReason::AccuracyPreferred));
1404 }
1405
1406 #[test]
1407 fn selection_diagnostics_capture_policy_filtered_candidates() {
1408 let t = Transform::with_selection_options(
1409 "EPSG:4267",
1410 "EPSG:4326",
1411 SelectionOptions {
1412 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1413 -80.5041667,
1414 44.5458333,
1415 ))),
1416 policy: SelectionPolicy::RequireGrids,
1417 ..SelectionOptions::default()
1418 },
1419 )
1420 .unwrap();
1421
1422 assert!(t
1423 .selection_diagnostics()
1424 .skipped_operations
1425 .iter()
1426 .any(|skipped| { matches!(skipped.reason, SkippedOperationReason::PolicyFiltered) }));
1427 }
1428
1429 #[test]
1430 fn selection_diagnostics_capture_area_mismatch_candidates() {
1431 let t = Transform::with_selection_options(
1432 "EPSG:4267",
1433 "EPSG:4269",
1434 SelectionOptions {
1435 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1436 -80.5041667,
1437 44.5458333,
1438 ))),
1439 policy: SelectionPolicy::RequireExactAreaMatch,
1440 ..SelectionOptions::default()
1441 },
1442 )
1443 .unwrap();
1444
1445 assert!(t
1446 .selection_diagnostics()
1447 .skipped_operations
1448 .iter()
1449 .any(|skipped| {
1450 matches!(skipped.reason, SkippedOperationReason::AreaOfUseMismatch)
1451 }));
1452 }
1453
1454 #[test]
1455 fn grid_coverage_miss_falls_back_under_best_available_policy() {
1456 let t = Transform::with_selection_options(
1457 "EPSG:4267",
1458 "EPSG:4269",
1459 SelectionOptions {
1460 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1461 -80.5041667,
1462 44.5458333,
1463 ))),
1464 ..SelectionOptions::default()
1465 },
1466 )
1467 .unwrap();
1468
1469 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
1470 assert!(!t.selection_diagnostics().fallback_operations.is_empty());
1471
1472 let outcome = t.convert_with_diagnostics((0.0, 0.0)).unwrap();
1473 let plain = t.convert((0.0, 0.0)).unwrap();
1474
1475 assert_eq!(plain, outcome.coord);
1476 assert_ne!(outcome.operation.id, Some(CoordinateOperationId(1313)));
1477 assert!(outcome
1478 .grid_coverage_misses
1479 .iter()
1480 .any(|miss| miss.operation.id == Some(CoordinateOperationId(1313))));
1481 }
1482
1483 #[test]
1484 fn grid_coverage_miss_does_not_use_non_grid_fallback_when_grids_are_required() {
1485 let t = Transform::with_selection_options(
1486 "EPSG:4267",
1487 "EPSG:4269",
1488 SelectionOptions {
1489 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
1490 -80.5041667,
1491 44.5458333,
1492 ))),
1493 policy: SelectionPolicy::RequireGrids,
1494 ..SelectionOptions::default()
1495 },
1496 )
1497 .unwrap();
1498
1499 assert!(t
1500 .selection_diagnostics()
1501 .fallback_operations
1502 .iter()
1503 .all(|operation| operation.uses_grids));
1504
1505 let err = t.convert((0.0, 0.0)).unwrap_err();
1506
1507 assert!(matches!(err, Error::Grid(GridError::OutsideCoverage(_))));
1508 }
1509
1510 #[test]
1511 fn cross_datum_roundtrip_nad27() {
1512 let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1513 let inv = fwd.inverse().unwrap();
1514 let original = (-90.0, 45.0);
1515 let shifted = fwd.convert(original).unwrap();
1516 let back = inv.convert(shifted).unwrap();
1517 assert!((back.0 - original.0).abs() < 1e-6);
1518 assert!((back.1 - original.1).abs() < 1e-6);
1519 }
1520
1521 #[test]
1522 fn inverse_preserves_explicit_operation_selection() {
1523 let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1524 .unwrap();
1525 let inv = fwd.inverse().unwrap();
1526
1527 assert_eq!(
1528 fwd.selected_operation().id,
1529 Some(CoordinateOperationId(1693))
1530 );
1531 assert_eq!(
1532 inv.selected_operation().id,
1533 Some(CoordinateOperationId(1693))
1534 );
1535 }
1536
1537 #[test]
1538 fn inverse_reorients_selected_metadata_and_diagnostics() {
1539 let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1540 .unwrap();
1541 let inv = fwd.inverse().unwrap();
1542
1543 assert_eq!(inv.source_crs().epsg(), 4326);
1544 assert_eq!(inv.target_crs().epsg(), 4267);
1545 assert_eq!(
1546 inv.selected_operation().direction,
1547 OperationStepDirection::Reverse
1548 );
1549 assert_eq!(inv.selected_operation().source_crs_epsg, Some(4326));
1550 assert_eq!(inv.selected_operation().target_crs_epsg, Some(4267));
1551 assert_eq!(
1552 inv.selection_diagnostics()
1553 .selected_operation
1554 .source_crs_epsg,
1555 Some(4326)
1556 );
1557 assert_eq!(
1558 inv.selection_diagnostics()
1559 .selected_operation
1560 .target_crs_epsg,
1561 Some(4267)
1562 );
1563 }
1564
1565 #[test]
1566 fn cross_datum_osgb36_to_wgs84() {
1567 let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
1568 let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap();
1569 assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
1570 assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
1571 }
1572
1573 #[test]
1574 fn wgs84_to_web_mercator_3d_preserves_height() {
1575 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1576 let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
1577 assert!((x - (-8238310.0)).abs() < 100.0);
1578 assert!((y - 4970072.0).abs() < 100.0);
1579 assert!((z - 123.45).abs() < 1e-12);
1580 }
1581
1582 #[test]
1583 fn cross_datum_roundtrip_nad27_3d() {
1584 let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1585 let inv = fwd.inverse().unwrap();
1586 let original = (-90.0, 45.0, 250.0);
1587 let shifted = fwd.convert_3d(original).unwrap();
1588 let back = inv.convert_3d(shifted).unwrap();
1589 assert!((back.0 - original.0).abs() < 1e-6);
1590 assert!((back.1 - original.1).abs() < 1e-6);
1591 assert!((back.2 - original.2).abs() < 1e-12);
1592 }
1593
1594 #[test]
1595 fn identical_custom_projected_crs_is_identity() {
1596 let from = CrsDef::Projected(ProjectedCrsDef::new(
1597 0,
1598 datum::WGS84,
1599 ProjectionMethod::WebMercator,
1600 LinearUnit::metre(),
1601 "Custom Web Mercator A",
1602 ));
1603 let to = CrsDef::Projected(ProjectedCrsDef::new(
1604 0,
1605 datum::WGS84,
1606 ProjectionMethod::WebMercator,
1607 LinearUnit::metre(),
1608 "Custom Web Mercator B",
1609 ));
1610
1611 let t = Transform::from_crs_defs(&from, &to).unwrap();
1612 assert_eq!(t.selected_operation().name, "Identity");
1613 }
1614
1615 #[test]
1616 fn unknown_custom_datums_do_not_collapse_to_identity() {
1617 let unknown = datum::Datum {
1618 ellipsoid: datum::WGS84.ellipsoid,
1619 to_wgs84: DatumToWgs84::Unknown,
1620 };
1621 let from = CrsDef::Projected(ProjectedCrsDef::new(
1622 0,
1623 unknown.clone(),
1624 ProjectionMethod::WebMercator,
1625 LinearUnit::metre(),
1626 "Unknown A",
1627 ));
1628 let to = CrsDef::Projected(ProjectedCrsDef::new(
1629 0,
1630 unknown,
1631 ProjectionMethod::WebMercator,
1632 LinearUnit::metre(),
1633 "Unknown B",
1634 ));
1635
1636 let err = match Transform::from_crs_defs(&from, &to) {
1637 Ok(_) => panic!("unknown custom datums should not build a transform"),
1638 Err(err) => err,
1639 };
1640 assert!(
1641 err.to_string().contains("no compatible operation found")
1642 || err
1643 .to_string()
1644 .contains("legacy Helmert fallback unavailable")
1645 );
1646 }
1647
1648 #[test]
1649 fn approximate_fallback_is_modeled_as_helmert_operation() {
1650 let from = CrsDef::Geographic(GeographicCrsDef::new(0, datum::NAD27, "Custom NAD27"));
1651 let to = CrsDef::Geographic(GeographicCrsDef::new(0, datum::OSGB36, "Custom OSGB36"));
1652
1653 let t = Transform::from_crs_defs(&from, &to).unwrap();
1654
1655 assert!(t.selected_operation().approximate);
1656 assert!(matches!(
1657 t.selected_operation_definition.method,
1658 OperationMethod::Helmert { .. }
1659 ));
1660 }
1661
1662 #[test]
1663 fn inverse_exposes_swapped_crs() {
1664 let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1665 let inv = fwd.inverse().unwrap();
1666
1667 assert_eq!(fwd.source_crs().epsg(), 4326);
1668 assert_eq!(fwd.target_crs().epsg(), 3857);
1669 assert_eq!(inv.source_crs().epsg(), 3857);
1670 assert_eq!(inv.target_crs().epsg(), 4326);
1671 }
1672
1673 #[test]
1674 fn transform_bounds_web_mercator() {
1675 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1676 let bounds = Bounds::new(-74.3, 40.45, -73.65, 40.95);
1677
1678 let result = t.transform_bounds(bounds, 8).unwrap();
1679
1680 assert!(result.min_x < -8_200_000.0);
1681 assert!(result.max_x < -8_100_000.0);
1682 assert!(result.min_y > 4_900_000.0);
1683 assert!(result.max_y > result.min_y);
1684 }
1685
1686 #[test]
1687 fn transform_bounds_rejects_invalid_input() {
1688 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1689 let err = t
1690 .transform_bounds(Bounds::new(10.0, 5.0, -10.0, 20.0), 0)
1691 .unwrap_err();
1692
1693 assert!(matches!(err, Error::OutOfRange(_)));
1694 }
1695
1696 #[test]
1697 fn batch_transform() {
1698 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1699 let coords: Vec<(f64, f64)> = (0..10)
1700 .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1701 .collect();
1702
1703 let results = t.convert_batch(&coords).unwrap();
1704 assert_eq!(results.len(), 10);
1705 for (x, _y) in &results {
1706 assert!(*x < 0.0);
1707 }
1708 }
1709
1710 #[test]
1711 fn batch_transform_3d() {
1712 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1713 let coords: Vec<(f64, f64, f64)> = (0..10)
1714 .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
1715 .collect();
1716
1717 let results = t.convert_batch_3d(&coords).unwrap();
1718 assert_eq!(results.len(), 10);
1719 for (index, (x, _y, z)) in results.iter().enumerate() {
1720 assert!(*x < 0.0);
1721 assert!((*z - index as f64).abs() < 1e-12);
1722 }
1723 }
1724
1725 #[test]
1726 fn batch_transform_coords_in_place_matches_vec_batch() {
1727 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1728 let input: Vec<Coord> = (0..10)
1729 .map(|i| Coord::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1730 .collect();
1731 let expected = t.convert_batch(&input).unwrap();
1732 let mut actual = input.clone();
1733
1734 t.convert_coords_in_place(&mut actual).unwrap();
1735
1736 assert_eq!(actual, expected);
1737 }
1738
1739 #[test]
1740 fn batch_transform_coords_into_reuses_output_slice() {
1741 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1742 let input: Vec<Coord> = (0..10)
1743 .map(|i| Coord::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1744 .collect();
1745 let expected = t.convert_batch(&input).unwrap();
1746 let mut actual = vec![Coord::new(0.0, 0.0); input.len()];
1747
1748 t.convert_coords_into(&input, &mut actual).unwrap();
1749
1750 assert_eq!(actual, expected);
1751 }
1752
1753 #[test]
1754 fn batch_transform_coords_into_rejects_mismatched_output_len() {
1755 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1756 let input = [Coord::new(-74.0, 40.0), Coord::new(-73.0, 41.0)];
1757 let mut output = [Coord::new(0.0, 0.0)];
1758
1759 let err = t.convert_coords_into(&input, &mut output).unwrap_err();
1760
1761 assert!(matches!(err, Error::OutOfRange(_)));
1762 }
1763
1764 #[test]
1765 fn batch_transform_coords_3d_in_place_and_into_preserve_height() {
1766 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1767 let input: Vec<Coord3D> = (0..10)
1768 .map(|i| Coord3D::new(-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
1769 .collect();
1770 let expected = t.convert_batch_3d(&input).unwrap();
1771
1772 let mut in_place = input.clone();
1773 t.convert_coords_3d_in_place(&mut in_place).unwrap();
1774 assert_eq!(in_place, expected);
1775
1776 let mut output = vec![Coord3D::new(0.0, 0.0, 0.0); input.len()];
1777 t.convert_coords_3d_into(&input, &mut output).unwrap();
1778 assert_eq!(output, expected);
1779 }
1780
1781 #[test]
1782 fn coord_type() {
1783 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1784 let c = Coord::new(-74.006, 40.7128);
1785 let result = t.convert(c).unwrap();
1786 assert!((result.x - (-8238310.0)).abs() < 100.0);
1787 }
1788
1789 #[cfg(feature = "geo-types")]
1790 #[test]
1791 fn geo_types_coord() {
1792 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1793 let c = geo_types::Coord {
1794 x: -74.006,
1795 y: 40.7128,
1796 };
1797 let result: geo_types::Coord<f64> = t.convert(c).unwrap();
1798 assert!((result.x - (-8238310.0)).abs() < 100.0);
1799 }
1800
1801 #[cfg(feature = "rayon")]
1802 #[test]
1803 fn parallel_batch_transform() {
1804 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1805 let coords: Vec<(f64, f64)> = (0..100)
1806 .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
1807 .collect();
1808
1809 let results = t.convert_batch_parallel(&coords).unwrap();
1810 assert_eq!(results.len(), 100);
1811 }
1812
1813 #[cfg(feature = "rayon")]
1814 #[test]
1815 fn parallel_batch_transform_matches_sequential_on_large_input() {
1816 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1817 let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
1818 let coords: Vec<(f64, f64)> = (0..len)
1819 .map(|i| (-179.0 + i as f64 * 0.0001, -80.0 + i as f64 * 0.00005))
1820 .collect();
1821
1822 let sequential = t.convert_batch(&coords).unwrap();
1823 let parallel = t.convert_batch_parallel(&coords).unwrap();
1824
1825 assert_eq!(parallel, sequential);
1826 }
1827
1828 #[cfg(feature = "rayon")]
1829 #[test]
1830 fn parallel_batch_transform_3d() {
1831 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1832 let coords: Vec<(f64, f64, f64)> = (0..100)
1833 .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01, i as f64))
1834 .collect();
1835
1836 let results = t.convert_batch_parallel_3d(&coords).unwrap();
1837 assert_eq!(results.len(), 100);
1838 }
1839
1840 #[cfg(feature = "rayon")]
1841 #[test]
1842 fn parallel_batch_transform_3d_matches_sequential_on_large_input() {
1843 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1844 let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
1845 let coords: Vec<(f64, f64, f64)> = (0..len)
1846 .map(|i| {
1847 (
1848 -179.0 + i as f64 * 0.0001,
1849 -80.0 + i as f64 * 0.00005,
1850 i as f64,
1851 )
1852 })
1853 .collect();
1854
1855 let sequential = t.convert_batch_3d(&coords).unwrap();
1856 let parallel = t.convert_batch_parallel_3d(&coords).unwrap();
1857
1858 assert_eq!(parallel, sequential);
1859 }
1860}