Skip to main content

proj_core/
transform.rs

1use crate::coord::{
2    Bounds, Coord, Coord3D, Transformable, Transformable3D, MAX_BOUNDS_DENSIFY_POINTS,
3};
4use crate::crs::CrsDef;
5use crate::error::{Error, Result};
6use crate::grid::{GridError, GridRuntime};
7use crate::operation::{
8    CoordinateOperationId, CoordinateOperationMetadata, GridCoverageMiss,
9    OperationSelectionDiagnostics, OperationStepDirection, SelectionOptions, TransformOutcome,
10    VerticalTransformAction, VerticalTransformDiagnostics,
11};
12use crate::registry;
13use crate::selector::SelectedOperationKind;
14
15#[cfg(feature = "geo-types")]
16mod geo_adapters;
17mod pipeline;
18mod selection;
19#[cfg(test)]
20mod tests;
21mod vertical;
22
23use pipeline::{
24    compile_pipeline, execute_pipeline_xy, execute_pipeline_xyz, validate_output_len,
25    validate_pipeline_coord3d, validate_vertical_ordinate, CompiledOperationFallback,
26    CompiledOperationPipeline, PipelineExecutionOutcome,
27};
28use selection::{
29    compile_selected_pipelines, grid_coverage_miss_detail, is_grid_coverage_miss, selected_metadata,
30};
31use vertical::{compile_vertical_transform, vertical_diagnostics, VerticalTransform};
32
33#[cfg(feature = "rayon")]
34use pipeline::should_parallelize;
35
36#[cfg(all(test, feature = "rayon"))]
37use pipeline::PARALLEL_MIN_ITEMS_PER_THREAD;
38#[cfg(test)]
39use pipeline::{PipelineSourceXyUnits, PipelineTargetXyUnits};
40
41/// Geoid-grid vertical transforms compose with the pre-datum-shift
42/// ellipsoidal height: applying one across a Helmert/geocentric horizontal
43/// pipeline would silently drop the datum shift's ellipsoidal-height change.
44/// Every supported geoid path rides an identity or grid-based horizontal
45/// operation today, so reject the unsupported composition at construction
46/// instead of producing wrong heights.
47fn validate_vertical_composition(
48    vertical: &VerticalTransform,
49    pipeline: &CompiledOperationPipeline,
50    fallbacks: &[CompiledOperationFallback],
51) -> Result<()> {
52    if !matches!(vertical, VerticalTransform::GridShiftList { .. }) {
53        return Ok(());
54    }
55    if pipeline.transforms_ellipsoidal_height
56        || fallbacks
57            .iter()
58            .any(|fallback| fallback.pipeline.transforms_ellipsoidal_height)
59    {
60        return Err(Error::OperationSelection(
61            "geoid-grid vertical transforms cannot yet be composed with a horizontal datum shift that changes ellipsoidal height; transform the horizontal datum and the vertical reference in separate steps"
62                .into(),
63        ));
64    }
65    Ok(())
66}
67
68/// A reusable coordinate transformation between two CRS.
69#[derive(Clone)]
70pub struct Transform {
71    source: CrsDef,
72    target: CrsDef,
73    selected_operation_kind: SelectedOperationKind,
74    selected_direction: OperationStepDirection,
75    selected_operation: std::sync::Arc<CoordinateOperationMetadata>,
76    diagnostics: OperationSelectionDiagnostics,
77    vertical_transform: VerticalTransform,
78    selection_options: SelectionOptions,
79    pipeline: CompiledOperationPipeline,
80    fallback_pipelines: Vec<CompiledOperationFallback>,
81}
82
83impl std::fmt::Debug for Transform {
84    /// Summary form: compiled pipelines and grid data are internals; the
85    /// CRS pair and the selected operation identify the transform.
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("Transform")
88            .field("source", &self.source)
89            .field("target", &self.target)
90            .field("selected_operation", &self.selected_operation)
91            .finish_non_exhaustive()
92    }
93}
94
95/// `Transform` must stay cheaply shareable across threads and duplicable;
96/// a field losing one of these auto traits is a semver break, so fail the
97/// build instead.
98fn assert_transform_auto_traits<T: Send + Sync + Clone + std::fmt::Debug>() {}
99const _: fn() = assert_transform_auto_traits::<Transform>;
100
101/// Trait for `geo-types` geometries that can be transformed as whole values.
102///
103/// Implementations transform coordinates in storage order and return the first
104/// coordinate error without producing a partially transformed geometry.
105#[cfg(feature = "geo-types")]
106pub trait TransformableGeometry: Sized {
107    fn transform_geometry(self, transform: &Transform) -> Result<Self>;
108}
109
110fn validate_wrapped_geographic_transform_bounds(bounds: Bounds) -> Result<()> {
111    if !bounds.min_x.is_finite()
112        || !bounds.min_y.is_finite()
113        || !bounds.max_x.is_finite()
114        || !bounds.max_y.is_finite()
115        || bounds.min_x <= bounds.max_x
116        || bounds.min_y > bounds.max_y
117    {
118        return Err(Error::OutOfRange(
119            "wrapped geographic bounds must be finite and satisfy west > east and south <= north"
120                .into(),
121        ));
122    }
123
124    for point in [
125        Coord::new(bounds.min_x, bounds.min_y),
126        Coord::new(bounds.min_x, bounds.max_y),
127        Coord::new(bounds.max_x, bounds.min_y),
128        Coord::new(bounds.max_x, bounds.max_y),
129    ] {
130        if !(-180.0..=180.0).contains(&point.x) {
131            return Err(Error::OutOfRange(format!(
132                "wrapped geographic bounds longitude {:.8} degrees is outside [-180, 180]",
133                point.x
134            )));
135        }
136        if !(-90.0..=90.0).contains(&point.y) {
137            return Err(Error::OutOfRange(format!(
138                "wrapped geographic bounds latitude {:.8} degrees is outside [-90, 90]",
139                point.y
140            )));
141        }
142    }
143
144    Ok(())
145}
146
147impl Transform {
148    /// Create a transform from authority code strings (e.g., `"EPSG:4326"`).
149    pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
150        Self::with_selection_options(from_crs, to_crs, SelectionOptions::default())
151    }
152
153    /// Create an explicitly horizontal-only transform from authority code strings.
154    ///
155    /// Compound CRS inputs are reduced to their horizontal components. Use this
156    /// for XY-only data; it deliberately does not transform an explicit vertical
157    /// ordinate from either CRS.
158    pub fn new_horizontal(from_crs: &str, to_crs: &str) -> Result<Self> {
159        Self::new_horizontal_with_selection_options(from_crs, to_crs, SelectionOptions::default())
160    }
161
162    /// Create a transform with explicit selection options.
163    pub fn with_selection_options(
164        from_crs: &str,
165        to_crs: &str,
166        options: SelectionOptions,
167    ) -> Result<Self> {
168        let source = registry::lookup_authority_code(from_crs)?;
169        let target = registry::lookup_authority_code(to_crs)?;
170        Self::from_crs_defs_with_selection_options(&source, &target, options)
171    }
172
173    /// Create an explicitly horizontal-only transform from authority code
174    /// strings with operation-selection options.
175    pub fn new_horizontal_with_selection_options(
176        from_crs: &str,
177        to_crs: &str,
178        options: SelectionOptions,
179    ) -> Result<Self> {
180        let source = registry::lookup_authority_code(from_crs)?;
181        let target = registry::lookup_authority_code(to_crs)?;
182        Self::from_horizontal_components_with_selection_options(&source, &target, options)
183    }
184
185    /// Create a transform from an explicit registry operation id.
186    pub fn from_operation(
187        operation_id: CoordinateOperationId,
188        from_crs: &str,
189        to_crs: &str,
190    ) -> Result<Self> {
191        Self::with_selection_options(
192            from_crs,
193            to_crs,
194            SelectionOptions::new().with_operation(operation_id),
195        )
196    }
197
198    /// Create a transform from EPSG codes directly.
199    pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
200        let source = registry::lookup_epsg(from)
201            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
202        let target = registry::lookup_epsg(to)
203            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
204        Self::from_crs_defs(&source, &target)
205    }
206
207    /// Create an explicitly horizontal-only transform from EPSG codes.
208    pub fn from_epsg_horizontal(from: u32, to: u32) -> Result<Self> {
209        let source = registry::lookup_epsg(from)
210            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
211        let target = registry::lookup_epsg(to)
212            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
213        Self::from_horizontal_components(&source, &target)
214    }
215
216    /// Create a transform from explicit CRS definitions.
217    pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
218        Self::from_crs_defs_with_selection_options(from, to, SelectionOptions::default())
219    }
220
221    /// Create a horizontal-only transform from explicit CRS definitions.
222    ///
223    /// Compound CRS inputs are reduced to their horizontal component before
224    /// operation selection. This is intended for XY-only workflows where
225    /// vertical transformation is deliberately out of scope.
226    pub fn from_horizontal_components(from: &CrsDef, to: &CrsDef) -> Result<Self> {
227        Self::from_horizontal_components_with_selection_options(
228            from,
229            to,
230            SelectionOptions::default(),
231        )
232    }
233
234    /// Create a horizontal-only transform from explicit CRS definitions with
235    /// operation-selection options.
236    pub fn from_horizontal_components_with_selection_options(
237        from: &CrsDef,
238        to: &CrsDef,
239        options: SelectionOptions,
240    ) -> Result<Self> {
241        let source = from.horizontal_crs().ok_or_else(|| {
242            Error::InvalidDefinition("source CRS does not contain a horizontal component".into())
243        })?;
244        let target = to.horizontal_crs().ok_or_else(|| {
245            Error::InvalidDefinition("target CRS does not contain a horizontal component".into())
246        })?;
247        Self::from_crs_defs_with_selection_options(&source, &target, options)
248    }
249
250    /// Create a transform from explicit CRS definitions with operation-selection options.
251    ///
252    /// Use this when a custom CRS references grid resources and the transform
253    /// needs an application-supplied [`crate::grid::GridProvider`].
254    pub fn from_crs_defs_with_selection_options(
255        from: &CrsDef,
256        to: &CrsDef,
257        options: SelectionOptions,
258    ) -> Result<Self> {
259        let grid_runtime = GridRuntime::new(options.grid_provider.clone());
260        let vertical_transform = compile_vertical_transform(from, to, &options, &grid_runtime)?;
261        let selected = compile_selected_pipelines(from, to, &options, &grid_runtime)?;
262        validate_vertical_composition(
263            &vertical_transform,
264            &selected.pipeline,
265            &selected.fallback_pipelines,
266        )?;
267        Ok(Self {
268            source: from.clone(),
269            target: to.clone(),
270            selected_operation_kind: selected.operation,
271            selected_direction: selected.direction,
272            selected_operation: std::sync::Arc::new(selected.metadata),
273            diagnostics: selected.diagnostics,
274            vertical_transform,
275            selection_options: options,
276            pipeline: selected.pipeline,
277            fallback_pipelines: selected.fallback_pipelines,
278        })
279    }
280
281    /// Transform a single coordinate.
282    pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
283        let c = coord.to_coord();
284        let result = self.convert_coord(c)?;
285        Ok(T::from_coord(result))
286    }
287
288    /// Transform a whole `geo-types` geometry.
289    ///
290    /// This method is available only with the `geo-types` feature. It
291    /// transforms coordinates in geometry storage order and returns the first
292    /// coordinate error without producing a partial result.
293    ///
294    /// `geo_types::Rect` is treated as a source bounds envelope and converted
295    /// to sampled axis-aligned target bounds with 21 intermediate points per
296    /// edge. This is an approximation for nonlinear projections: extrema can
297    /// occur between samples. Use [`Self::convert_rect`] when the rect sampling
298    /// density should be chosen by the caller.
299    #[cfg(feature = "geo-types")]
300    pub fn convert_geometry<T: TransformableGeometry>(&self, geometry: T) -> Result<T> {
301        geometry.transform_geometry(self)
302    }
303
304    /// Transform a `geo_types::Rect` to sampled axis-aligned target bounds.
305    ///
306    /// This method is available only with the `geo-types` feature. A rect
307    /// represents an envelope, not a true geometry, so nonlinear projections can
308    /// have edge extrema between samples. Increase `densify_points` to sample
309    /// edges more finely for higher-fidelity bounds, or use
310    /// [`Self::transform_bounds`] directly when working with [`Bounds`].
311    ///
312    /// `densify_points` is the number of intermediate samples added per edge
313    /// and must be no larger than [`MAX_BOUNDS_DENSIFY_POINTS`].
314    #[cfg(feature = "geo-types")]
315    pub fn convert_rect(
316        &self,
317        rect: geo_types::Rect<f64>,
318        densify_points: usize,
319    ) -> Result<geo_types::Rect<f64>> {
320        geo_adapters::transform_geo_rect_with_densification(self, rect, densify_points)
321    }
322
323    /// Transform a single 3D coordinate.
324    pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
325        let c = coord.to_coord3d();
326        let result = self.convert_coord3d(c)?;
327        Ok(T::from_coord3d(result))
328    }
329
330    /// Transform a single coordinate and report the operation actually used.
331    ///
332    /// This 2D API is XY-only: it does not apply or sample configured vertical
333    /// transforms.
334    ///
335    /// When the selected grid-backed operation misses grid coverage, this
336    /// reports the coverage misses and the lower-ranked fallback operation that
337    /// produced the result.
338    pub fn convert_with_diagnostics<T: Transformable>(
339        &self,
340        coord: T,
341    ) -> Result<TransformOutcome<T>> {
342        let c = coord.to_coord();
343        let outcome = self.convert_coord_with_diagnostics(c)?;
344        Ok(TransformOutcome {
345            coord: T::from_coord(outcome.coord),
346            operation: outcome.operation,
347            vertical: outcome.vertical,
348            grid_coverage_misses: outcome.grid_coverage_misses,
349        })
350    }
351
352    /// Transform a single 3D coordinate and report the operation actually used.
353    ///
354    /// When the selected grid-backed operation misses grid coverage, this
355    /// reports the coverage misses and the lower-ranked fallback operation that
356    /// produced the result.
357    pub fn convert_3d_with_diagnostics<T: Transformable3D>(
358        &self,
359        coord: T,
360    ) -> Result<TransformOutcome<T>> {
361        let c = coord.to_coord3d();
362        let outcome = self.convert_coord3d_with_diagnostics(c)?;
363        Ok(TransformOutcome {
364            coord: T::from_coord3d(outcome.coord),
365            operation: outcome.operation,
366            vertical: outcome.vertical,
367            grid_coverage_misses: outcome.grid_coverage_misses,
368        })
369    }
370
371    /// Return the source CRS definition for this transform.
372    pub fn source_crs(&self) -> &CrsDef {
373        &self.source
374    }
375
376    /// Return the target CRS definition for this transform.
377    pub fn target_crs(&self) -> &CrsDef {
378        &self.target
379    }
380
381    /// Return metadata for the selected coordinate operation.
382    pub fn selected_operation(&self) -> &CoordinateOperationMetadata {
383        &self.selected_operation
384    }
385
386    /// Return selection diagnostics for this transform.
387    pub fn selection_diagnostics(&self) -> &OperationSelectionDiagnostics {
388        &self.diagnostics
389    }
390
391    /// Return diagnostics for the vertical component of this transform.
392    pub fn vertical_diagnostics(&self) -> &VerticalTransformDiagnostics {
393        self.vertical_transform.diagnostics()
394    }
395
396    /// Build the inverse transform by swapping the source and target CRS.
397    pub fn inverse(&self) -> Result<Self> {
398        let grid_runtime = GridRuntime::new(self.selection_options.grid_provider.clone());
399        let inverse_options = self.selection_options.inverse();
400        let vertical_transform = compile_vertical_transform(
401            &self.target,
402            &self.source,
403            &inverse_options,
404            &grid_runtime,
405        )?;
406        let selected_direction = self.selected_direction.inverse();
407        let selected_operation_kind = self.selected_operation_kind.clone().into_owned();
408        let pipeline = compile_pipeline(
409            &self.target,
410            &self.source,
411            &selected_operation_kind,
412            selected_direction,
413            &grid_runtime,
414        )?;
415        let selected_operation = selected_metadata(
416            &selected_operation_kind,
417            &self.source,
418            &self.target,
419            selected_direction,
420            self.selected_operation.area_of_use.clone(),
421        );
422        let mut fallback_pipelines = Vec::with_capacity(self.fallback_pipelines.len());
423        for fallback in &self.fallback_pipelines {
424            let direction = fallback.direction.inverse();
425            let pipeline = compile_pipeline(
426                &self.target,
427                &self.source,
428                &fallback.operation,
429                direction,
430                &grid_runtime,
431            )?;
432            let metadata = selected_metadata(
433                &fallback.operation,
434                &self.source,
435                &self.target,
436                direction,
437                fallback.metadata.area_of_use.clone(),
438            );
439            fallback_pipelines.push(CompiledOperationFallback {
440                operation: fallback.operation.clone(),
441                direction,
442                metadata: std::sync::Arc::new(metadata),
443                pipeline,
444            });
445        }
446        let diagnostics = OperationSelectionDiagnostics {
447            selected_operation: selected_operation.clone(),
448            selected_match_kind: self.diagnostics.selected_match_kind,
449            selected_reasons: self.diagnostics.selected_reasons.clone(),
450            fallback_operations: fallback_pipelines
451                .iter()
452                .map(|fallback| (*fallback.metadata).clone())
453                .collect(),
454            skipped_operations: self
455                .diagnostics
456                .skipped_operations
457                .iter()
458                .cloned()
459                .map(|mut skipped| {
460                    skipped.metadata.direction = skipped.metadata.direction.inverse();
461                    std::mem::swap(
462                        &mut skipped.metadata.source_crs_epsg,
463                        &mut skipped.metadata.target_crs_epsg,
464                    );
465                    std::mem::swap(
466                        &mut skipped.metadata.source_datum_epsg,
467                        &mut skipped.metadata.target_datum_epsg,
468                    );
469                    skipped
470                })
471                .collect(),
472            approximate: self.diagnostics.approximate,
473            missing_required_grid: self.diagnostics.missing_required_grid.clone(),
474        };
475        validate_vertical_composition(&vertical_transform, &pipeline, &fallback_pipelines)?;
476        Ok(Self {
477            source: self.target.clone(),
478            target: self.source.clone(),
479            selected_operation_kind,
480            selected_direction,
481            selected_operation: std::sync::Arc::new(selected_operation),
482            diagnostics,
483            vertical_transform,
484            selection_options: inverse_options,
485            pipeline,
486            fallback_pipelines,
487        })
488    }
489
490    /// Reproject a 2D bounding box by sampling its perimeter.
491    ///
492    /// `densify_points` is the number of intermediate samples added per edge
493    /// and must be no larger than [`MAX_BOUNDS_DENSIFY_POINTS`].
494    pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
495        if !bounds.is_valid() {
496            return Err(Error::OutOfRange(
497                "bounds must be finite and satisfy min <= max".into(),
498            ));
499        }
500
501        self.transform_valid_bounds(bounds, densify_points)
502    }
503
504    /// Reproject a geographic bounding box that crosses the antimeridian.
505    ///
506    /// `bounds` is interpreted as west/south/east/north in source geographic
507    /// degrees and must satisfy `west > east`. Projected and normal
508    /// non-wrapped bounds should use [`Self::transform_bounds`].
509    ///
510    /// `densify_points` is the number of intermediate samples added per edge
511    /// and must be no larger than [`MAX_BOUNDS_DENSIFY_POINTS`].
512    pub fn transform_geographic_wrapped_bounds(
513        &self,
514        bounds: Bounds,
515        densify_points: usize,
516    ) -> Result<Bounds> {
517        if !self.source.is_geographic() {
518            return Err(Error::InvalidDefinition(
519                "wrapped geographic bounds require a geographic source CRS".into(),
520            ));
521        }
522        validate_wrapped_geographic_transform_bounds(bounds)?;
523
524        let west_segment = Bounds::new(bounds.min_x, bounds.min_y, 180.0, bounds.max_y);
525        let east_segment = Bounds::new(-180.0, bounds.min_y, bounds.max_x, bounds.max_y);
526        let mut transformed = self.transform_valid_bounds(west_segment, densify_points)?;
527        let east_transformed = self.transform_valid_bounds(east_segment, densify_points)?;
528        transformed.expand_to_include(Coord::new(east_transformed.min_x, east_transformed.min_y));
529        transformed.expand_to_include(Coord::new(east_transformed.max_x, east_transformed.max_y));
530        Ok(transformed)
531    }
532
533    fn transform_valid_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
534        let segments = bounds_densify_segments(densify_points)?;
535
536        let mut transformed: Option<Bounds> = None;
537        for i in 0..=segments {
538            let t = i as f64 / segments as f64;
539            let x = bounds.min_x + bounds.width() * t;
540            let y = bounds.min_y + bounds.height() * t;
541
542            for sample in [
543                Coord::new(x, bounds.min_y),
544                Coord::new(x, bounds.max_y),
545                Coord::new(bounds.min_x, y),
546                Coord::new(bounds.max_x, y),
547            ] {
548                let coord = self.convert_coord(sample)?;
549                if let Some(accum) = &mut transformed {
550                    accum.expand_to_include(coord);
551                } else {
552                    transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
553                }
554            }
555        }
556
557        transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
558    }
559
560    fn convert_coord(&self, c: Coord) -> Result<Coord> {
561        match execute_pipeline_xy(&self.pipeline, Coord3D::new(c.x, c.y, 0.0)) {
562            Ok(coord) => return Ok(coord),
563            Err(error) => {
564                if !is_grid_coverage_miss(&error) {
565                    return Err(error);
566                }
567            }
568        }
569
570        for fallback in &self.fallback_pipelines {
571            match execute_pipeline_xy(&fallback.pipeline, Coord3D::new(c.x, c.y, 0.0)) {
572                Ok(coord) => return Ok(coord),
573                Err(error) => {
574                    if !is_grid_coverage_miss(&error) {
575                        return Err(error);
576                    }
577                }
578            }
579        }
580
581        Err(Error::Grid(GridError::OutsideCoverage(
582            "grid coverage miss".into(),
583        )))
584    }
585
586    fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
587        match self.execute_pipeline_coord3d(&self.pipeline, c) {
588            Ok(coord) => return Ok(coord),
589            Err(error) => {
590                if !is_grid_coverage_miss(&error) {
591                    return Err(error);
592                }
593            }
594        }
595
596        for fallback in &self.fallback_pipelines {
597            match self.execute_pipeline_coord3d(&fallback.pipeline, c) {
598                Ok(coord) => return Ok(coord),
599                Err(error) => {
600                    if !is_grid_coverage_miss(&error) {
601                        return Err(error);
602                    }
603                }
604            }
605        }
606
607        Err(Error::Grid(GridError::OutsideCoverage(
608            "grid coverage miss".into(),
609        )))
610    }
611
612    fn convert_coord_with_diagnostics(&self, c: Coord) -> Result<TransformOutcome<Coord>> {
613        let mut grid_coverage_misses = Vec::new();
614        let c = Coord3D::new(c.x, c.y, 0.0);
615
616        match execute_pipeline_xy(&self.pipeline, c) {
617            Ok(coord) => {
618                return Ok(TransformOutcome {
619                    coord,
620                    operation: self.selected_operation.clone(),
621                    vertical: vertical_diagnostics(VerticalTransformAction::None, None, None, None),
622                    grid_coverage_misses,
623                });
624            }
625            Err(error) => {
626                if let Some(detail) = grid_coverage_miss_detail(&error) {
627                    grid_coverage_misses.push(GridCoverageMiss {
628                        operation: self.selected_operation.clone(),
629                        detail,
630                    });
631                } else {
632                    return Err(error);
633                }
634            }
635        }
636
637        for fallback in &self.fallback_pipelines {
638            match execute_pipeline_xy(&fallback.pipeline, c) {
639                Ok(coord) => {
640                    return Ok(TransformOutcome {
641                        coord,
642                        operation: fallback.metadata.clone(),
643                        vertical: vertical_diagnostics(
644                            VerticalTransformAction::None,
645                            None,
646                            None,
647                            None,
648                        ),
649                        grid_coverage_misses,
650                    });
651                }
652                Err(error) => {
653                    if let Some(detail) = grid_coverage_miss_detail(&error) {
654                        grid_coverage_misses.push(GridCoverageMiss {
655                            operation: fallback.metadata.clone(),
656                            detail,
657                        });
658                    } else {
659                        return Err(error);
660                    }
661                }
662            }
663        }
664
665        Err(Error::Grid(GridError::OutsideCoverage(
666            grid_coverage_misses
667                .last()
668                .map(|miss| miss.detail.clone())
669                .unwrap_or_else(|| "grid coverage miss".into()),
670        )))
671    }
672
673    fn convert_coord3d_with_diagnostics(&self, c: Coord3D) -> Result<TransformOutcome<Coord3D>> {
674        let mut grid_coverage_misses = Vec::new();
675        match self.execute_pipeline(&self.pipeline, c) {
676            Ok(outcome) => {
677                return Ok(TransformOutcome {
678                    coord: outcome.coord,
679                    operation: self.selected_operation.clone(),
680                    vertical: outcome.vertical,
681                    grid_coverage_misses,
682                });
683            }
684            Err(error) => {
685                if let Some(detail) = grid_coverage_miss_detail(&error) {
686                    grid_coverage_misses.push(GridCoverageMiss {
687                        operation: self.selected_operation.clone(),
688                        detail,
689                    });
690                } else {
691                    return Err(error);
692                }
693            }
694        }
695
696        for fallback in &self.fallback_pipelines {
697            match self.execute_pipeline(&fallback.pipeline, c) {
698                Ok(outcome) => {
699                    return Ok(TransformOutcome {
700                        coord: outcome.coord,
701                        operation: fallback.metadata.clone(),
702                        vertical: outcome.vertical,
703                        grid_coverage_misses,
704                    });
705                }
706                Err(error) => {
707                    if let Some(detail) = grid_coverage_miss_detail(&error) {
708                        grid_coverage_misses.push(GridCoverageMiss {
709                            operation: fallback.metadata.clone(),
710                            detail,
711                        });
712                    } else {
713                        return Err(error);
714                    }
715                }
716            }
717        }
718
719        Err(Error::Grid(GridError::OutsideCoverage(
720            grid_coverage_misses
721                .last()
722                .map(|miss| miss.detail.clone())
723                .unwrap_or_else(|| "grid coverage miss".into()),
724        )))
725    }
726
727    /// Without a vertical CRS on either side, `convert_3d` heights are
728    /// ellipsoidal, so datum-shift-induced height changes computed by the
729    /// horizontal pipeline must survive — the same semantics C PROJ applies
730    /// to the 3D promotions of the CRS pair. With vertical CRSs present, the
731    /// vertical transform owns `z` (gravity-related heights are unaffected
732    /// by ellipsoidal datum math).
733    fn pipeline_owns_height(&self, pipeline: &CompiledOperationPipeline) -> bool {
734        pipeline.transforms_ellipsoidal_height
735            && matches!(self.vertical_transform, VerticalTransform::None { .. })
736    }
737
738    fn execute_pipeline(
739        &self,
740        pipeline: &CompiledOperationPipeline,
741        c: Coord3D,
742    ) -> Result<PipelineExecutionOutcome> {
743        validate_vertical_ordinate(c.z)?;
744        if self.pipeline_owns_height(pipeline) {
745            let coord = execute_pipeline_xyz(pipeline, c)?;
746            return Ok(PipelineExecutionOutcome {
747                coord,
748                vertical: self.vertical_transform.diagnostics().clone(),
749            });
750        }
751        let xy = execute_pipeline_xy(pipeline, c)?;
752        let vertical = self.vertical_transform.apply(c)?;
753        let coord = Coord3D::new(xy.x, xy.y, vertical.z);
754        validate_pipeline_coord3d("pipeline final output", coord)?;
755        Ok(PipelineExecutionOutcome {
756            coord,
757            vertical: vertical.diagnostics,
758        })
759    }
760
761    fn execute_pipeline_coord3d(
762        &self,
763        pipeline: &CompiledOperationPipeline,
764        c: Coord3D,
765    ) -> Result<Coord3D> {
766        validate_vertical_ordinate(c.z)?;
767        if self.pipeline_owns_height(pipeline) {
768            return execute_pipeline_xyz(pipeline, c);
769        }
770        let xy = execute_pipeline_xy(pipeline, c)?;
771        let z = self.vertical_transform.apply_z(c)?;
772        let coord = Coord3D::new(xy.x, xy.y, z);
773        validate_pipeline_coord3d("pipeline final output", coord)?;
774        Ok(coord)
775    }
776
777    /// Batch transform (sequential).
778    pub fn convert_batch<T: Transformable>(&self, coords: &[T]) -> Result<Vec<T>> {
779        coords
780            .iter()
781            .map(|c| self.convert_coord(c.to_coord()).map(T::from_coord))
782            .collect()
783    }
784
785    /// Batch transform of 3D coordinates (sequential).
786    pub fn convert_batch_3d<T: Transformable3D>(&self, coords: &[T]) -> Result<Vec<T>> {
787        coords
788            .iter()
789            .map(|c| self.convert_coord3d(c.to_coord3d()).map(T::from_coord3d))
790            .collect()
791    }
792
793    /// Transform 2D coordinates in place without allocating.
794    ///
795    /// Coordinates before a failing coordinate are left converted; the failing
796    /// coordinate and subsequent coordinates are left unchanged.
797    pub fn convert_coords_in_place(&self, coords: &mut [Coord]) -> Result<()> {
798        for coord in coords {
799            *coord = self.convert_coord(*coord)?;
800        }
801        Ok(())
802    }
803
804    /// Transform 3D coordinates in place without allocating.
805    ///
806    /// Coordinates before a failing coordinate are left converted; the failing
807    /// coordinate and subsequent coordinates are left unchanged.
808    pub fn convert_coords_3d_in_place(&self, coords: &mut [Coord3D]) -> Result<()> {
809        for coord in coords {
810            *coord = self.convert_coord3d(*coord)?;
811        }
812        Ok(())
813    }
814
815    /// Transform 2D coordinates from `input` into an existing `output` slice.
816    ///
817    /// `output` must have exactly the same length as `input`. This API performs
818    /// no allocation and does not require cloning input coordinates. If a
819    /// coordinate fails, the preceding output elements have already changed.
820    pub fn convert_coords_into(&self, input: &[Coord], output: &mut [Coord]) -> Result<()> {
821        validate_output_len(input.len(), output.len())?;
822        for (source, target) in input.iter().zip(output.iter_mut()) {
823            *target = self.convert_coord(*source)?;
824        }
825        Ok(())
826    }
827
828    /// Transform 3D coordinates from `input` into an existing `output` slice.
829    ///
830    /// `output` must have exactly the same length as `input`. This API performs
831    /// no allocation and does not require cloning input coordinates. If a
832    /// coordinate fails, the preceding output elements have already changed.
833    pub fn convert_coords_3d_into(&self, input: &[Coord3D], output: &mut [Coord3D]) -> Result<()> {
834        validate_output_len(input.len(), output.len())?;
835        for (source, target) in input.iter().zip(output.iter_mut()) {
836            *target = self.convert_coord3d(*source)?;
837        }
838        Ok(())
839    }
840
841    /// Batch transform with Rayon parallelism.
842    #[cfg(feature = "rayon")]
843    pub fn convert_batch_parallel<T: Transformable + Send + Sync>(
844        &self,
845        coords: &[T],
846    ) -> Result<Vec<T>> {
847        if !should_parallelize(coords.len()) {
848            return self.convert_batch(coords);
849        }
850
851        use rayon::prelude::*;
852
853        coords
854            .par_iter()
855            .map(|coord| self.convert_coord(coord.to_coord()).map(T::from_coord))
856            .collect()
857    }
858
859    /// Batch transform of 3D coordinates with adaptive Rayon parallelism.
860    #[cfg(feature = "rayon")]
861    pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync>(
862        &self,
863        coords: &[T],
864    ) -> Result<Vec<T>> {
865        if !should_parallelize(coords.len()) {
866            return self.convert_batch_3d(coords);
867        }
868
869        use rayon::prelude::*;
870
871        coords
872            .par_iter()
873            .map(|coord| {
874                self.convert_coord3d(coord.to_coord3d())
875                    .map(T::from_coord3d)
876            })
877            .collect()
878    }
879}
880
881pub(crate) fn bounds_densify_segments(densify_points: usize) -> Result<usize> {
882    if densify_points > MAX_BOUNDS_DENSIFY_POINTS {
883        return Err(Error::OutOfRange(format!(
884            "densify point count {densify_points} exceeds maximum {MAX_BOUNDS_DENSIFY_POINTS}"
885        )));
886    }
887    densify_points
888        .checked_add(1)
889        .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))
890}