Skip to main content

proj_core/
transform.rs

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
28/// A reusable coordinate transformation between two CRS.
29pub 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    /// Create a transform from authority code strings (e.g., `"EPSG:4326"`).
69    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    /// Create a transform with explicit selection options.
74    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    /// Create a transform from an explicit registry operation id.
85    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    /// Create a transform from EPSG codes directly.
101    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    /// Create a transform from explicit CRS definitions.
110    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    /// Transform a single coordinate.
229    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    /// Transform a single 3D coordinate.
236    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    /// Return the source CRS definition for this transform.
243    pub fn source_crs(&self) -> &CrsDef {
244        &self.source
245    }
246
247    /// Return the target CRS definition for this transform.
248    pub fn target_crs(&self) -> &CrsDef {
249        &self.target
250    }
251
252    /// Return metadata for the selected coordinate operation.
253    pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
254        &self.selected_operation
255    }
256
257    /// Return selection diagnostics for this transform.
258    pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
259        &self.diagnostics
260    }
261
262    /// Build the inverse transform by swapping the source and target CRS.
263    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    /// Reproject a 2D bounding box by sampling its perimeter.
299    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    /// Batch transform (sequential).
386    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    /// Batch transform of 3D coordinates (sequential).
391    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    /// Batch transform with Rayon parallelism.
396    #[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    /// Batch transform of 3D coordinates with adaptive Rayon parallelism.
405    #[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        // Synthetic identity between semantically equivalent CRS.
577    } 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}