1use crate::coord::{Bounds, Coord, Coord3D, Transformable, Transformable3D};
2use crate::crs::CrsDef;
3use crate::datum::HelmertParams;
4use crate::ellipsoid::Ellipsoid;
5use crate::error::{Error, Result};
6use crate::geocentric;
7use crate::grid::{GridHandle, GridRuntime};
8use crate::helmert;
9use crate::operation::{
10 CoordinateOperation, CoordinateOperationId, CoordinateOperationMetadata, GridShiftDirection,
11 OperationMethod, OperationSelectionDiagnostics, OperationStepDirection, SelectionOptions,
12 SelectionPolicy, SkippedOperation, SkippedOperationReason,
13};
14use crate::projection::{make_projection, Projection};
15use crate::registry;
16use crate::selector;
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}
39
40struct CompiledOperationPipeline {
41 steps: SmallVec<[CompiledStep; 8]>,
42}
43
44enum CompiledStep {
45 ProjectionForward {
46 projection: Projection,
47 },
48 ProjectionInverse {
49 projection: Projection,
50 },
51 Helmert {
52 params: HelmertParams,
53 inverse: bool,
54 },
55 GridShift {
56 handle: GridHandle,
57 direction: GridShiftDirection,
58 },
59 GeodeticToGeocentric {
60 ellipsoid: Ellipsoid,
61 },
62 GeocentricToGeodetic {
63 ellipsoid: Ellipsoid,
64 },
65}
66
67impl Transform {
68 pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
70 Self::with_selection_options(from_crs, to_crs, SelectionOptions::default())
71 }
72
73 pub fn with_selection_options(
75 from_crs: &str,
76 to_crs: &str,
77 options: SelectionOptions,
78 ) -> Result<Self> {
79 let source = registry::lookup_authority_code(from_crs)?;
80 let target = registry::lookup_authority_code(to_crs)?;
81 Self::from_crs_defs_with_selection_options(&source, &target, options)
82 }
83
84 pub fn from_operation(
86 operation_id: CoordinateOperationId,
87 from_crs: &str,
88 to_crs: &str,
89 ) -> Result<Self> {
90 Self::with_selection_options(
91 from_crs,
92 to_crs,
93 SelectionOptions {
94 policy: SelectionPolicy::Operation(operation_id),
95 ..SelectionOptions::default()
96 },
97 )
98 }
99
100 pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
102 let source = registry::lookup_epsg(from)
103 .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
104 let target = registry::lookup_epsg(to)
105 .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
106 Self::from_crs_defs(&source, &target)
107 }
108
109 pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
111 Self::from_crs_defs_with_selection_options(from, to, SelectionOptions::default())
112 }
113
114 fn from_crs_defs_with_selection_options(
115 from: &CrsDef,
116 to: &CrsDef,
117 options: SelectionOptions,
118 ) -> Result<Self> {
119 let grid_runtime = GridRuntime::new(options.grid_provider.clone());
120 let candidate_set = selector::rank_operation_candidates(from, to, &options)?;
121 if candidate_set.ranked.is_empty() {
122 return Err(match options.policy {
123 SelectionPolicy::Operation(id) => match registry::lookup_operation(id) {
124 Some(_) => Error::OperationSelection(format!(
125 "operation id {} is not compatible with source EPSG:{} target EPSG:{}",
126 id.0,
127 from.epsg(),
128 to.epsg()
129 )),
130 None => Error::UnknownOperation(format!("unknown operation id {}", id.0)),
131 },
132 _ => Error::OperationSelection(format!(
133 "no compatible operation found for source EPSG:{} target EPSG:{}",
134 from.epsg(),
135 to.epsg()
136 )),
137 });
138 }
139
140 let mut skipped_operations = candidate_set.skipped;
141 let mut missing_required_grid = None;
142 for (index, candidate) in candidate_set.ranked.iter().enumerate() {
143 match compile_pipeline(
144 from,
145 to,
146 candidate.operation.as_ref(),
147 candidate.direction,
148 &grid_runtime,
149 ) {
150 Ok(pipeline) => {
151 let metadata = selected_metadata(
152 candidate.operation.as_ref(),
153 candidate.direction,
154 candidate.matched_area_of_use.clone(),
155 );
156 let selected_reasons =
157 selected_reasons_for(candidate, &candidate_set.ranked[index + 1..]);
158 skipped_operations.extend(candidate_set.ranked[index + 1..].iter().map(
159 |other| {
160 skipped_for_unselected_candidate(other, !candidate.operation.deprecated)
161 },
162 ));
163 let diagnostics = OperationSelectionDiagnostics {
164 selected_operation: metadata.clone(),
165 selected_match_kind: candidate.match_kind,
166 selected_reasons,
167 skipped_operations,
168 approximate: candidate.operation.approximate,
169 missing_required_grid,
170 };
171 return Ok(Self {
172 source: *from,
173 target: *to,
174 selected_operation_definition: candidate.operation.clone().into_owned(),
175 selected_direction: candidate.direction,
176 selected_operation: metadata,
177 diagnostics,
178 selection_options: options,
179 pipeline,
180 });
181 }
182 Err(Error::Grid(error)) => {
183 if missing_required_grid.is_none() {
184 missing_required_grid = Some(error.to_string());
185 }
186 skipped_operations.push(SkippedOperation {
187 metadata: selected_metadata(
188 candidate.operation.as_ref(),
189 candidate.direction,
190 candidate.matched_area_of_use.clone(),
191 ),
192 reason: match error {
193 crate::grid::GridError::UnsupportedFormat(_) => {
194 SkippedOperationReason::UnsupportedGridFormat
195 }
196 _ => SkippedOperationReason::MissingGrid,
197 },
198 detail: error.to_string(),
199 });
200 }
201 Err(error) => {
202 skipped_operations.push(SkippedOperation {
203 metadata: selected_metadata(
204 candidate.operation.as_ref(),
205 candidate.direction,
206 candidate.matched_area_of_use.clone(),
207 ),
208 reason: SkippedOperationReason::LessPreferred,
209 detail: error.to_string(),
210 });
211 }
212 }
213 }
214
215 if let Some(message) = missing_required_grid {
216 return Err(Error::OperationSelection(format!(
217 "better operations were skipped because required grids were unavailable: {message}"
218 )));
219 }
220
221 Err(Error::OperationSelection(format!(
222 "unable to compile an operation for source EPSG:{} target EPSG:{}",
223 from.epsg(),
224 to.epsg()
225 )))
226 }
227
228 pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
230 let c = coord.into_coord();
231 let result = self.convert_coord(c)?;
232 Ok(T::from_coord(result))
233 }
234
235 pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
237 let c = coord.into_coord3d();
238 let result = self.convert_coord3d(c)?;
239 Ok(T::from_coord3d(result))
240 }
241
242 pub fn source_crs(&self) -> &CrsDef {
244 &self.source
245 }
246
247 pub fn target_crs(&self) -> &CrsDef {
249 &self.target
250 }
251
252 pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
254 &self.selected_operation
255 }
256
257 pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
259 &self.diagnostics
260 }
261
262 pub fn inverse(&self) -> Result<Self> {
264 let grid_runtime = GridRuntime::new(self.selection_options.grid_provider.clone());
265 let selected_direction = self.selected_direction.inverse();
266 let pipeline = compile_pipeline(
267 &self.target,
268 &self.source,
269 &self.selected_operation_definition,
270 selected_direction,
271 &grid_runtime,
272 )?;
273 let selected_operation = selected_metadata(
274 &self.selected_operation_definition,
275 selected_direction,
276 self.selected_operation.area_of_use.clone(),
277 );
278 let diagnostics = OperationSelectionDiagnostics {
279 selected_operation: selected_operation.clone(),
280 selected_match_kind: self.diagnostics.selected_match_kind,
281 selected_reasons: self.diagnostics.selected_reasons.clone(),
282 skipped_operations: Vec::new(),
283 approximate: self.diagnostics.approximate,
284 missing_required_grid: self.diagnostics.missing_required_grid.clone(),
285 };
286 Ok(Self {
287 source: self.target,
288 target: self.source,
289 selected_operation_definition: self.selected_operation_definition.clone(),
290 selected_direction,
291 selected_operation,
292 diagnostics,
293 selection_options: self.selection_options.inverse(),
294 pipeline,
295 })
296 }
297
298 pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
300 if !bounds.is_valid() {
301 return Err(Error::OutOfRange(
302 "bounds must be finite and satisfy min <= max".into(),
303 ));
304 }
305
306 let segments = densify_points
307 .checked_add(1)
308 .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))?;
309
310 let mut transformed: Option<Bounds> = None;
311 for i in 0..=segments {
312 let t = i as f64 / segments as f64;
313 let x = bounds.min_x + bounds.width() * t;
314 let y = bounds.min_y + bounds.height() * t;
315
316 for sample in [
317 Coord::new(x, bounds.min_y),
318 Coord::new(x, bounds.max_y),
319 Coord::new(bounds.min_x, y),
320 Coord::new(bounds.max_x, y),
321 ] {
322 let coord = self.convert_coord(sample)?;
323 if let Some(accum) = &mut transformed {
324 accum.expand_to_include(coord);
325 } else {
326 transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
327 }
328 }
329 }
330
331 transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
332 }
333
334 fn convert_coord(&self, c: Coord) -> Result<Coord> {
335 let result = self.convert_coord3d(Coord3D::new(c.x, c.y, 0.0))?;
336 Ok(Coord::new(result.x, result.y))
337 }
338
339 fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
340 if self.pipeline.steps.is_empty() {
341 return Ok(c);
342 }
343
344 let preserved_z = c.z;
345 let mut state = if self.source.is_projected() {
346 let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
347 Coord3D::new(x_m, y_m, 0.0)
348 } else {
349 Coord3D::new(c.x.to_radians(), c.y.to_radians(), 0.0)
350 };
351
352 for step in &self.pipeline.steps {
353 state = execute_step(step, state)?;
354 }
355
356 let (x, y) = if self.target.is_projected() {
357 self.projected_meters_to_target_native(state.x, state.y)
358 } else {
359 (state.x.to_degrees(), state.y.to_degrees())
360 };
361
362 Ok(Coord3D::new(x, y, preserved_z))
363 }
364
365 fn source_projected_native_to_meters(&self, x: f64, y: f64) -> (f64, f64) {
366 match self.source {
367 CrsDef::Projected(projected) => (
368 projected.linear_unit().to_meters(x),
369 projected.linear_unit().to_meters(y),
370 ),
371 CrsDef::Geographic(_) => (x, y),
372 }
373 }
374
375 fn projected_meters_to_target_native(&self, x: f64, y: f64) -> (f64, f64) {
376 match self.target {
377 CrsDef::Projected(projected) => (
378 projected.linear_unit().from_meters(x),
379 projected.linear_unit().from_meters(y),
380 ),
381 CrsDef::Geographic(_) => (x, y),
382 }
383 }
384
385 pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
387 coords.iter().map(|c| self.convert(c.clone())).collect()
388 }
389
390 pub fn convert_batch_3d<T: Transformable3D + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
392 coords.iter().map(|c| self.convert_3d(c.clone())).collect()
393 }
394
395 #[cfg(feature = "rayon")]
397 pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
398 &self,
399 coords: &[T],
400 ) -> Result<Vec<T>> {
401 self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch(chunk))
402 }
403
404 #[cfg(feature = "rayon")]
406 pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync + Clone>(
407 &self,
408 coords: &[T],
409 ) -> Result<Vec<T>> {
410 self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch_3d(chunk))
411 }
412
413 #[cfg(feature = "rayon")]
414 fn convert_batch_parallel_adaptive<T, F>(&self, coords: &[T], convert: F) -> Result<Vec<T>>
415 where
416 T: Send + Sync + Clone,
417 F: Fn(&Self, &[T]) -> Result<Vec<T>> + Sync,
418 {
419 if !should_parallelize(coords.len()) {
420 return convert(self, coords);
421 }
422
423 use rayon::prelude::*;
424
425 let chunk_size = parallel_chunk_size(coords.len());
426 let chunk_results: Vec<Result<Vec<T>>> = coords
427 .par_chunks(chunk_size)
428 .map(|chunk| convert(self, chunk))
429 .collect();
430
431 let mut results = Vec::with_capacity(coords.len());
432 for chunk in chunk_results {
433 results.extend(chunk?);
434 }
435 Ok(results)
436 }
437}
438
439fn selected_metadata(
440 operation: &CoordinateOperation,
441 direction: OperationStepDirection,
442 matched_area_of_use: Option<crate::operation::AreaOfUse>,
443) -> CoordinateOperationMetadata {
444 let mut metadata = operation.metadata_for_direction(direction);
445 metadata.area_of_use = matched_area_of_use.or_else(|| operation.areas_of_use.first().cloned());
446 metadata
447}
448
449fn selected_reasons_for(
450 selected: &selector::RankedOperationCandidate,
451 alternatives: &[selector::RankedOperationCandidate],
452) -> SmallVec<[crate::operation::SelectionReason; 4]> {
453 let mut reasons = selected.reasons.clone();
454 if selected_accuracy_preferred(selected, alternatives)
455 && !reasons.contains(&crate::operation::SelectionReason::AccuracyPreferred)
456 {
457 reasons.push(crate::operation::SelectionReason::AccuracyPreferred);
458 }
459 reasons
460}
461
462fn selected_accuracy_preferred(
463 selected: &selector::RankedOperationCandidate,
464 alternatives: &[selector::RankedOperationCandidate],
465) -> bool {
466 let Some(selected_accuracy) = selected.operation.accuracy.map(|value| value.meters) else {
467 return false;
468 };
469
470 alternatives.iter().any(|alternative| {
471 same_pre_accuracy_priority(selected, alternative)
472 && alternative
473 .operation
474 .accuracy
475 .map(|value| selected_accuracy < value.meters)
476 .unwrap_or(false)
477 })
478}
479
480fn same_pre_accuracy_priority(
481 left: &selector::RankedOperationCandidate,
482 right: &selector::RankedOperationCandidate,
483) -> bool {
484 match_kind_priority(left.match_kind) == match_kind_priority(right.match_kind)
485 && left.matched_area_of_use.is_some() == right.matched_area_of_use.is_some()
486}
487
488fn match_kind_priority(kind: crate::operation::OperationMatchKind) -> u8 {
489 match kind {
490 crate::operation::OperationMatchKind::Explicit => 4,
491 crate::operation::OperationMatchKind::ExactSourceTarget => 3,
492 crate::operation::OperationMatchKind::DerivedGeographic => 2,
493 crate::operation::OperationMatchKind::DatumCompatible => 1,
494 crate::operation::OperationMatchKind::ApproximateFallback => 0,
495 }
496}
497
498fn skipped_for_unselected_candidate(
499 candidate: &selector::RankedOperationCandidate,
500 prefer_non_deprecated: bool,
501) -> SkippedOperation {
502 let reason = if prefer_non_deprecated && candidate.operation.deprecated {
503 SkippedOperationReason::Deprecated
504 } else {
505 SkippedOperationReason::LessPreferred
506 };
507 let detail = match reason {
508 SkippedOperationReason::Deprecated => {
509 "not selected because a non-deprecated higher-ranked operation compiled successfully"
510 .into()
511 }
512 _ => "not selected because a higher-ranked operation compiled successfully".into(),
513 };
514 SkippedOperation {
515 metadata: selected_metadata(
516 candidate.operation.as_ref(),
517 candidate.direction,
518 candidate.matched_area_of_use.clone(),
519 ),
520 reason,
521 detail,
522 }
523}
524
525fn execute_step(step: &CompiledStep, coord: Coord3D) -> Result<Coord3D> {
526 match step {
527 CompiledStep::ProjectionForward { projection } => {
528 let (x, y) = projection.forward(coord.x, coord.y)?;
529 Ok(Coord3D::new(x, y, coord.z))
530 }
531 CompiledStep::ProjectionInverse { projection } => {
532 let (lon, lat) = projection.inverse(coord.x, coord.y)?;
533 Ok(Coord3D::new(lon, lat, coord.z))
534 }
535 CompiledStep::Helmert { params, inverse } => {
536 let (x, y, z) = if *inverse {
537 helmert::helmert_inverse(params, coord.x, coord.y, coord.z)
538 } else {
539 helmert::helmert_forward(params, coord.x, coord.y, coord.z)
540 };
541 Ok(Coord3D::new(x, y, z))
542 }
543 CompiledStep::GridShift { handle, direction } => {
544 let (lon, lat) = handle.apply(coord.x, coord.y, *direction)?;
545 Ok(Coord3D::new(lon, lat, coord.z))
546 }
547 CompiledStep::GeodeticToGeocentric { ellipsoid } => {
548 let (x, y, z) =
549 geocentric::geodetic_to_geocentric(ellipsoid, coord.x, coord.y, coord.z);
550 Ok(Coord3D::new(x, y, z))
551 }
552 CompiledStep::GeocentricToGeodetic { ellipsoid } => {
553 let (lon, lat, h) =
554 geocentric::geocentric_to_geodetic(ellipsoid, coord.x, coord.y, coord.z);
555 Ok(Coord3D::new(lon, lat, h))
556 }
557 }
558}
559
560fn compile_pipeline(
561 source: &CrsDef,
562 target: &CrsDef,
563 operation: &CoordinateOperation,
564 direction: OperationStepDirection,
565 grid_runtime: &GridRuntime,
566) -> Result<CompiledOperationPipeline> {
567 let mut steps = SmallVec::<[CompiledStep; 8]>::new();
568
569 if let CrsDef::Projected(projected) = source {
570 steps.push(CompiledStep::ProjectionInverse {
571 projection: make_projection(&projected.method(), projected.datum())?,
572 });
573 }
574
575 if operation.id.is_none() && matches!(operation.method, OperationMethod::Identity) {
576 } else {
578 compile_operation(
579 operation,
580 direction,
581 Some((source, target)),
582 grid_runtime,
583 &mut steps,
584 )?;
585 }
586
587 if let CrsDef::Projected(projected) = target {
588 steps.push(CompiledStep::ProjectionForward {
589 projection: make_projection(&projected.method(), projected.datum())?,
590 });
591 }
592
593 Ok(CompiledOperationPipeline { steps })
594}
595
596fn compile_operation(
597 operation: &CoordinateOperation,
598 direction: OperationStepDirection,
599 requested_pair: Option<(&CrsDef, &CrsDef)>,
600 grid_runtime: &GridRuntime,
601 steps: &mut SmallVec<[CompiledStep; 8]>,
602) -> Result<()> {
603 let (source_geo, target_geo) =
604 resolve_operation_geographic_pair(operation, direction, requested_pair)?;
605 match (&operation.method, direction) {
606 (OperationMethod::Identity, _) => {}
607 (OperationMethod::Helmert { params }, OperationStepDirection::Forward) => {
608 steps.push(CompiledStep::GeodeticToGeocentric {
609 ellipsoid: source_geo.datum().ellipsoid,
610 });
611 steps.push(CompiledStep::Helmert {
612 params: *params,
613 inverse: false,
614 });
615 steps.push(CompiledStep::GeocentricToGeodetic {
616 ellipsoid: target_geo.datum().ellipsoid,
617 });
618 }
619 (OperationMethod::Helmert { params }, OperationStepDirection::Reverse) => {
620 steps.push(CompiledStep::GeodeticToGeocentric {
621 ellipsoid: source_geo.datum().ellipsoid,
622 });
623 steps.push(CompiledStep::Helmert {
624 params: *params,
625 inverse: true,
626 });
627 steps.push(CompiledStep::GeocentricToGeodetic {
628 ellipsoid: target_geo.datum().ellipsoid,
629 });
630 }
631 (
632 OperationMethod::GridShift {
633 grid_id,
634 direction: grid_direction,
635 ..
636 },
637 step_direction,
638 ) => {
639 let grid = registry::lookup_grid_definition(grid_id.0).ok_or_else(|| {
640 Error::Grid(crate::grid::GridError::NotFound(format!(
641 "grid id {}",
642 grid_id.0
643 )))
644 })?;
645 if grid.format == crate::grid::GridFormat::Unsupported {
646 return Err(Error::Grid(crate::grid::GridError::UnsupportedFormat(
647 grid.name,
648 )));
649 }
650 let handle = grid_runtime.resolve_handle(&grid)?;
651 let direction = match step_direction {
652 OperationStepDirection::Forward => *grid_direction,
653 OperationStepDirection::Reverse => grid_direction.inverse(),
654 };
655 steps.push(CompiledStep::GridShift { handle, direction });
656 }
657 (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Forward) => {
658 for step in child_steps {
659 let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
660 Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
661 })?;
662 compile_operation(&child, step.direction, None, grid_runtime, steps)?;
663 }
664 }
665 (OperationMethod::Concatenated { steps: child_steps }, OperationStepDirection::Reverse) => {
666 for step in child_steps.iter().rev() {
667 let child = registry::lookup_operation(step.operation_id).ok_or_else(|| {
668 Error::UnknownOperation(format!("unknown operation id {}", step.operation_id.0))
669 })?;
670 compile_operation(&child, step.direction.inverse(), None, grid_runtime, steps)?;
671 }
672 }
673 (OperationMethod::Projection { .. }, _) | (OperationMethod::AxisUnitNormalize, _) => {
674 return Err(Error::UnsupportedProjection(
675 "direct projection operations are not emitted by the embedded selector".into(),
676 ));
677 }
678 }
679 Ok(())
680}
681
682fn resolve_operation_geographic_pair(
683 operation: &CoordinateOperation,
684 direction: OperationStepDirection,
685 requested_pair: Option<(&CrsDef, &CrsDef)>,
686) -> Result<(CrsDef, CrsDef)> {
687 if let (Some(source_code), Some(target_code)) =
688 (operation.source_crs_epsg, operation.target_crs_epsg)
689 {
690 let source = registry::lookup_epsg(match direction {
691 OperationStepDirection::Forward => source_code,
692 OperationStepDirection::Reverse => target_code,
693 })
694 .ok_or_else(|| {
695 Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
696 })?;
697 let target = registry::lookup_epsg(match direction {
698 OperationStepDirection::Forward => target_code,
699 OperationStepDirection::Reverse => source_code,
700 })
701 .ok_or_else(|| {
702 Error::UnknownCrs(format!("unknown EPSG code in operation {}", operation.name))
703 })?;
704 return Ok((source, target));
705 }
706
707 if let Some((source, target)) = requested_pair {
708 return Ok(match direction {
709 OperationStepDirection::Forward => (*source, *target),
710 OperationStepDirection::Reverse => (*target, *source),
711 });
712 }
713
714 Err(Error::OperationSelection(format!(
715 "operation {} is missing source/target CRS metadata",
716 operation.name
717 )))
718}
719
720#[cfg(feature = "rayon")]
721fn should_parallelize(len: usize) -> bool {
722 if len == 0 {
723 return false;
724 }
725
726 let threads = rayon::current_num_threads().max(1);
727 len >= PARALLEL_MIN_TOTAL_ITEMS.max(threads.saturating_mul(PARALLEL_MIN_ITEMS_PER_THREAD))
728}
729
730#[cfg(feature = "rayon")]
731fn parallel_chunk_size(len: usize) -> usize {
732 let threads = rayon::current_num_threads().max(1);
733 let target_chunks = threads.saturating_mul(PARALLEL_CHUNKS_PER_THREAD).max(1);
734 let chunk_size = len.div_ceil(target_chunks);
735 chunk_size.max(PARALLEL_MIN_CHUNK_SIZE)
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::crs::{CrsDef, GeographicCrsDef, LinearUnit, ProjectedCrsDef, ProjectionMethod};
742 use crate::datum::{self, DatumToWgs84};
743 use crate::operation::{
744 AreaOfInterest, OperationMatchKind, SelectionPolicy, SelectionReason,
745 SkippedOperationReason,
746 };
747
748 const US_FOOT_TO_METER: f64 = 0.3048006096012192;
749
750 #[test]
751 fn identity_same_crs() {
752 let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
753 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
754 assert_eq!(x, -74.006);
755 assert_eq!(y, 40.7128);
756 }
757
758 #[test]
759 fn wgs84_to_web_mercator() {
760 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
761 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
762 assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
763 assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
764 }
765
766 #[test]
767 fn web_mercator_to_wgs84() {
768 let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
769 let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
770 assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
771 assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
772 }
773
774 #[test]
775 fn roundtrip_4326_3857() {
776 let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
777 let inv = fwd.inverse().unwrap();
778
779 let original = (-74.0445, 40.6892);
780 let projected = fwd.convert(original).unwrap();
781 let back = inv.convert(projected).unwrap();
782
783 assert!((back.0 - original.0).abs() < 1e-8);
784 assert!((back.1 - original.1).abs() < 1e-8);
785 }
786
787 #[test]
788 fn wgs84_to_utm_18n() {
789 let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
790 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
791 assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
792 assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
793 }
794
795 #[test]
796 fn equivalent_meter_and_foot_state_plane_crs_match_after_unit_conversion() {
797 let coord = (-80.8431, 35.2271);
798 let meter_tx = Transform::new("EPSG:4326", "EPSG:32119").unwrap();
799 let foot_tx = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
800
801 let (mx, my) = meter_tx.convert(coord).unwrap();
802 let (fx, fy) = foot_tx.convert(coord).unwrap();
803
804 assert!((fx * US_FOOT_TO_METER - mx).abs() < 0.02);
805 assert!((fy * US_FOOT_TO_METER - my).abs() < 0.02);
806 }
807
808 #[test]
809 fn inverse_transform_accepts_native_projected_units_for_foot_crs() {
810 let coord = (-80.8431, 35.2271);
811 let forward = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
812 let inverse = Transform::new("EPSG:2264", "EPSG:4326").unwrap();
813
814 let projected = forward.convert(coord).unwrap();
815 let roundtrip = inverse.convert(projected).unwrap();
816
817 assert!((roundtrip.0 - coord.0).abs() < 1e-8);
818 assert!((roundtrip.1 - coord.1).abs() < 1e-8);
819 }
820
821 #[test]
822 fn utm_to_web_mercator() {
823 let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
824 let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
825 assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
826 }
827
828 #[test]
829 fn wgs84_to_polar_stereo_3413() {
830 let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
831 let (x, y) = t.convert((-45.0, 90.0)).unwrap();
832 assert!(x.abs() < 1.0, "x = {x}");
833 assert!(y.abs() < 1.0, "y = {y}");
834 }
835
836 #[test]
837 fn roundtrip_4326_3413() {
838 let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
839 let inv = fwd.inverse().unwrap();
840
841 let original = (-45.0, 75.0);
842 let projected = fwd.convert(original).unwrap();
843 let back = inv.convert(projected).unwrap();
844
845 assert!((back.0 - original.0).abs() < 1e-6);
846 assert!((back.1 - original.1).abs() < 1e-6);
847 }
848
849 #[test]
850 fn geographic_to_geographic_same_datum_is_identity() {
851 let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
852 let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
853 assert_eq!(lon, -74.006);
854 assert_eq!(lat, 40.7128);
855 assert_eq!(t.selected_operation().name, "Identity");
856 }
857
858 #[test]
859 fn unknown_crs_error() {
860 let result = Transform::new("EPSG:99999", "EPSG:4326");
861 assert!(result.is_err());
862 }
863
864 #[test]
865 fn cross_datum_nad27_to_wgs84() {
866 let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
867 let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
868 assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
869 assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
870 assert!(!t.selected_operation().approximate);
871 }
872
873 #[test]
874 fn explicit_grid_operation_compiles() {
875 let t = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
876 .unwrap();
877 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1693)));
878 }
879
880 #[test]
881 fn explicit_operation_rejects_incompatible_crs_pair() {
882 let err = match Transform::from_operation(
883 CoordinateOperationId(1693),
884 "EPSG:4326",
885 "EPSG:3857",
886 ) {
887 Ok(_) => panic!("incompatible operation should be rejected"),
888 Err(err) => err,
889 };
890 assert!(matches!(err, Error::OperationSelection(_)));
891 assert!(err.to_string().contains("not compatible"));
892 }
893
894 #[test]
895 fn explicit_selection_options_choose_grid_operation() {
896 let t = Transform::with_selection_options(
897 "EPSG:4267",
898 "EPSG:4269",
899 SelectionOptions {
900 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
901 -80.5041667,
902 44.5458333,
903 ))),
904 ..SelectionOptions::default()
905 },
906 )
907 .unwrap();
908 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
909 assert!(!t.selection_diagnostics().approximate);
910 }
911
912 #[test]
913 fn source_crs_area_of_interest_is_normalized_before_selection() {
914 let to_projected = Transform::new("EPSG:4267", "EPSG:26717").unwrap();
915 let projected = to_projected.convert((-80.5041667, 44.5458333)).unwrap();
916
917 let t = Transform::with_selection_options(
918 "EPSG:26717",
919 "EPSG:4269",
920 SelectionOptions {
921 area_of_interest: Some(AreaOfInterest::source_crs_point(Coord::new(
922 projected.0,
923 projected.1,
924 ))),
925 ..SelectionOptions::default()
926 },
927 )
928 .unwrap();
929
930 assert_eq!(t.selected_operation().id, Some(CoordinateOperationId(1313)));
931 assert_eq!(
932 t.selection_diagnostics().selected_match_kind,
933 OperationMatchKind::DerivedGeographic
934 );
935 }
936
937 #[test]
938 fn selection_diagnostics_capture_accuracy_preference() {
939 let t = Transform::with_selection_options(
940 "EPSG:4267",
941 "EPSG:4269",
942 SelectionOptions {
943 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
944 -80.5041667,
945 44.5458333,
946 ))),
947 ..SelectionOptions::default()
948 },
949 )
950 .unwrap();
951
952 assert!(t
953 .selection_diagnostics()
954 .selected_reasons
955 .contains(&SelectionReason::AccuracyPreferred));
956 }
957
958 #[test]
959 fn selection_diagnostics_capture_policy_filtered_candidates() {
960 let t = Transform::with_selection_options(
961 "EPSG:4267",
962 "EPSG:4326",
963 SelectionOptions {
964 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
965 -80.5041667,
966 44.5458333,
967 ))),
968 policy: SelectionPolicy::RequireGrids,
969 ..SelectionOptions::default()
970 },
971 )
972 .unwrap();
973
974 assert!(t
975 .selection_diagnostics()
976 .skipped_operations
977 .iter()
978 .any(|skipped| { matches!(skipped.reason, SkippedOperationReason::PolicyFiltered) }));
979 }
980
981 #[test]
982 fn selection_diagnostics_capture_area_mismatch_candidates() {
983 let t = Transform::with_selection_options(
984 "EPSG:4267",
985 "EPSG:4269",
986 SelectionOptions {
987 area_of_interest: Some(AreaOfInterest::geographic_point(Coord::new(
988 -80.5041667,
989 44.5458333,
990 ))),
991 policy: SelectionPolicy::RequireExactAreaMatch,
992 ..SelectionOptions::default()
993 },
994 )
995 .unwrap();
996
997 assert!(t
998 .selection_diagnostics()
999 .skipped_operations
1000 .iter()
1001 .any(|skipped| {
1002 matches!(skipped.reason, SkippedOperationReason::AreaOfUseMismatch)
1003 }));
1004 }
1005
1006 #[test]
1007 fn cross_datum_roundtrip_nad27() {
1008 let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1009 let inv = fwd.inverse().unwrap();
1010 let original = (-90.0, 45.0);
1011 let shifted = fwd.convert(original).unwrap();
1012 let back = inv.convert(shifted).unwrap();
1013 assert!((back.0 - original.0).abs() < 1e-6);
1014 assert!((back.1 - original.1).abs() < 1e-6);
1015 }
1016
1017 #[test]
1018 fn inverse_preserves_explicit_operation_selection() {
1019 let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1020 .unwrap();
1021 let inv = fwd.inverse().unwrap();
1022
1023 assert_eq!(
1024 fwd.selected_operation().id,
1025 Some(CoordinateOperationId(1693))
1026 );
1027 assert_eq!(
1028 inv.selected_operation().id,
1029 Some(CoordinateOperationId(1693))
1030 );
1031 }
1032
1033 #[test]
1034 fn inverse_reorients_selected_metadata_and_diagnostics() {
1035 let fwd = Transform::from_operation(CoordinateOperationId(1693), "EPSG:4267", "EPSG:4326")
1036 .unwrap();
1037 let inv = fwd.inverse().unwrap();
1038
1039 assert_eq!(inv.source_crs().epsg(), 4326);
1040 assert_eq!(inv.target_crs().epsg(), 4267);
1041 assert_eq!(inv.selected_operation().source_crs_epsg, Some(4326));
1042 assert_eq!(inv.selected_operation().target_crs_epsg, Some(4267));
1043 assert_eq!(
1044 inv.selection_diagnostics()
1045 .selected_operation
1046 .source_crs_epsg,
1047 Some(4326)
1048 );
1049 assert_eq!(
1050 inv.selection_diagnostics()
1051 .selected_operation
1052 .target_crs_epsg,
1053 Some(4267)
1054 );
1055 }
1056
1057 #[test]
1058 fn cross_datum_osgb36_to_wgs84() {
1059 let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
1060 let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap();
1061 assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
1062 assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
1063 }
1064
1065 #[test]
1066 fn wgs84_to_web_mercator_3d_preserves_height() {
1067 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1068 let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
1069 assert!((x - (-8238310.0)).abs() < 100.0);
1070 assert!((y - 4970072.0).abs() < 100.0);
1071 assert!((z - 123.45).abs() < 1e-12);
1072 }
1073
1074 #[test]
1075 fn cross_datum_roundtrip_nad27_3d() {
1076 let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
1077 let inv = fwd.inverse().unwrap();
1078 let original = (-90.0, 45.0, 250.0);
1079 let shifted = fwd.convert_3d(original).unwrap();
1080 let back = inv.convert_3d(shifted).unwrap();
1081 assert!((back.0 - original.0).abs() < 1e-6);
1082 assert!((back.1 - original.1).abs() < 1e-6);
1083 assert!((back.2 - original.2).abs() < 1e-12);
1084 }
1085
1086 #[test]
1087 fn identical_custom_projected_crs_is_identity() {
1088 let from = CrsDef::Projected(ProjectedCrsDef::new(
1089 0,
1090 datum::WGS84,
1091 ProjectionMethod::WebMercator,
1092 LinearUnit::metre(),
1093 "Custom Web Mercator A",
1094 ));
1095 let to = CrsDef::Projected(ProjectedCrsDef::new(
1096 0,
1097 datum::WGS84,
1098 ProjectionMethod::WebMercator,
1099 LinearUnit::metre(),
1100 "Custom Web Mercator B",
1101 ));
1102
1103 let t = Transform::from_crs_defs(&from, &to).unwrap();
1104 assert_eq!(t.selected_operation().name, "Identity");
1105 }
1106
1107 #[test]
1108 fn unknown_custom_datums_do_not_collapse_to_identity() {
1109 let unknown = datum::Datum {
1110 ellipsoid: datum::WGS84.ellipsoid,
1111 to_wgs84: DatumToWgs84::Unknown,
1112 };
1113 let from = CrsDef::Projected(ProjectedCrsDef::new(
1114 0,
1115 unknown,
1116 ProjectionMethod::WebMercator,
1117 LinearUnit::metre(),
1118 "Unknown A",
1119 ));
1120 let to = CrsDef::Projected(ProjectedCrsDef::new(
1121 0,
1122 unknown,
1123 ProjectionMethod::WebMercator,
1124 LinearUnit::metre(),
1125 "Unknown B",
1126 ));
1127
1128 let err = match Transform::from_crs_defs(&from, &to) {
1129 Ok(_) => panic!("unknown custom datums should not build a transform"),
1130 Err(err) => err,
1131 };
1132 assert!(
1133 err.to_string().contains("no compatible operation found")
1134 || err
1135 .to_string()
1136 .contains("legacy Helmert fallback unavailable")
1137 );
1138 }
1139
1140 #[test]
1141 fn approximate_fallback_is_modeled_as_helmert_operation() {
1142 let from = CrsDef::Geographic(GeographicCrsDef::new(0, datum::NAD27, "Custom NAD27"));
1143 let to = CrsDef::Geographic(GeographicCrsDef::new(0, datum::OSGB36, "Custom OSGB36"));
1144
1145 let t = Transform::from_crs_defs(&from, &to).unwrap();
1146
1147 assert!(t.selected_operation().approximate);
1148 assert!(matches!(
1149 t.selected_operation_definition.method,
1150 OperationMethod::Helmert { .. }
1151 ));
1152 }
1153
1154 #[test]
1155 fn inverse_exposes_swapped_crs() {
1156 let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1157 let inv = fwd.inverse().unwrap();
1158
1159 assert_eq!(fwd.source_crs().epsg(), 4326);
1160 assert_eq!(fwd.target_crs().epsg(), 3857);
1161 assert_eq!(inv.source_crs().epsg(), 3857);
1162 assert_eq!(inv.target_crs().epsg(), 4326);
1163 }
1164
1165 #[test]
1166 fn transform_bounds_web_mercator() {
1167 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1168 let bounds = Bounds::new(-74.3, 40.45, -73.65, 40.95);
1169
1170 let result = t.transform_bounds(bounds, 8).unwrap();
1171
1172 assert!(result.min_x < -8_200_000.0);
1173 assert!(result.max_x < -8_100_000.0);
1174 assert!(result.min_y > 4_900_000.0);
1175 assert!(result.max_y > result.min_y);
1176 }
1177
1178 #[test]
1179 fn transform_bounds_rejects_invalid_input() {
1180 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1181 let err = t
1182 .transform_bounds(Bounds::new(10.0, 5.0, -10.0, 20.0), 0)
1183 .unwrap_err();
1184
1185 assert!(matches!(err, Error::OutOfRange(_)));
1186 }
1187
1188 #[test]
1189 fn batch_transform() {
1190 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1191 let coords: Vec<(f64, f64)> = (0..10)
1192 .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
1193 .collect();
1194
1195 let results = t.convert_batch(&coords).unwrap();
1196 assert_eq!(results.len(), 10);
1197 for (x, _y) in &results {
1198 assert!(*x < 0.0);
1199 }
1200 }
1201
1202 #[test]
1203 fn batch_transform_3d() {
1204 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1205 let coords: Vec<(f64, f64, f64)> = (0..10)
1206 .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
1207 .collect();
1208
1209 let results = t.convert_batch_3d(&coords).unwrap();
1210 assert_eq!(results.len(), 10);
1211 for (index, (x, _y, z)) in results.iter().enumerate() {
1212 assert!(*x < 0.0);
1213 assert!((*z - index as f64).abs() < 1e-12);
1214 }
1215 }
1216
1217 #[test]
1218 fn coord_type() {
1219 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1220 let c = Coord::new(-74.006, 40.7128);
1221 let result = t.convert(c).unwrap();
1222 assert!((result.x - (-8238310.0)).abs() < 100.0);
1223 }
1224
1225 #[cfg(feature = "geo-types")]
1226 #[test]
1227 fn geo_types_coord() {
1228 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1229 let c = geo_types::Coord {
1230 x: -74.006,
1231 y: 40.7128,
1232 };
1233 let result: geo_types::Coord<f64> = t.convert(c).unwrap();
1234 assert!((result.x - (-8238310.0)).abs() < 100.0);
1235 }
1236
1237 #[cfg(feature = "rayon")]
1238 #[test]
1239 fn parallel_batch_transform() {
1240 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1241 let coords: Vec<(f64, f64)> = (0..100)
1242 .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
1243 .collect();
1244
1245 let results = t.convert_batch_parallel(&coords).unwrap();
1246 assert_eq!(results.len(), 100);
1247 }
1248
1249 #[cfg(feature = "rayon")]
1250 #[test]
1251 fn parallel_batch_transform_matches_sequential_on_large_input() {
1252 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1253 let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
1254 let coords: Vec<(f64, f64)> = (0..len)
1255 .map(|i| (-179.0 + i as f64 * 0.0001, -80.0 + i as f64 * 0.00005))
1256 .collect();
1257
1258 let sequential = t.convert_batch(&coords).unwrap();
1259 let parallel = t.convert_batch_parallel(&coords).unwrap();
1260
1261 assert_eq!(parallel, sequential);
1262 }
1263
1264 #[cfg(feature = "rayon")]
1265 #[test]
1266 fn parallel_batch_transform_3d() {
1267 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1268 let coords: Vec<(f64, f64, f64)> = (0..100)
1269 .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01, i as f64))
1270 .collect();
1271
1272 let results = t.convert_batch_parallel_3d(&coords).unwrap();
1273 assert_eq!(results.len(), 100);
1274 }
1275
1276 #[cfg(feature = "rayon")]
1277 #[test]
1278 fn parallel_batch_transform_3d_matches_sequential_on_large_input() {
1279 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
1280 let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
1281 let coords: Vec<(f64, f64, f64)> = (0..len)
1282 .map(|i| {
1283 (
1284 -179.0 + i as f64 * 0.0001,
1285 -80.0 + i as f64 * 0.00005,
1286 i as f64,
1287 )
1288 })
1289 .collect();
1290
1291 let sequential = t.convert_batch_3d(&coords).unwrap();
1292 let parallel = t.convert_batch_parallel_3d(&coords).unwrap();
1293
1294 assert_eq!(parallel, sequential);
1295 }
1296}