1use crate::coord::{Bounds, Coord, Coord3D, Transformable, Transformable3D};
2use crate::crs::{CrsDef, ProjectionMethod};
3use crate::datum::Datum;
4use crate::error::{Error, Result};
5use crate::geocentric;
6use crate::helmert;
7use crate::projection::{make_projection, ProjectionImpl};
8use crate::registry;
9
10#[cfg(feature = "rayon")]
11const PARALLEL_MIN_ITEMS_PER_THREAD: usize = 2_048;
12#[cfg(feature = "rayon")]
13const PARALLEL_CHUNKS_PER_THREAD: usize = 4;
14#[cfg(feature = "rayon")]
15const PARALLEL_MIN_CHUNK_SIZE: usize = 1_024;
16
17pub struct Transform {
36 source: CrsDef,
37 target: CrsDef,
38 pipeline: TransformPipeline,
39}
40
41enum TransformPipeline {
42 Identity,
44 SameDatumForward { forward: Box<dyn ProjectionImpl> },
46 SameDatumInverse { inverse: Box<dyn ProjectionImpl> },
48 SameDatumBoth {
50 inverse: Box<dyn ProjectionImpl>,
51 forward: Box<dyn ProjectionImpl>,
52 },
53 DatumShift {
55 inverse: Option<Box<dyn ProjectionImpl>>,
56 source_datum: Datum,
57 target_datum: Datum,
58 forward: Option<Box<dyn ProjectionImpl>>,
59 },
60}
61
62impl Transform {
63 pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
68 let source = registry::lookup_authority_code(from_crs)?;
69 let target = registry::lookup_authority_code(to_crs)?;
70 Self::from_crs_defs(&source, &target)
71 }
72
73 pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
75 let source = registry::lookup_epsg(from)
76 .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
77 let target = registry::lookup_epsg(to)
78 .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
79 Self::from_crs_defs(&source, &target)
80 }
81
82 pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
86 if (from.epsg() != 0 && from.epsg() == to.epsg()) || same_crs_definition(from, to) {
88 return Ok(Self {
89 source: *from,
90 target: *to,
91 pipeline: TransformPipeline::Identity,
92 });
93 }
94
95 let source_datum = from.datum();
96 let target_datum = to.datum();
97
98 let same_datum = source_datum.same_datum(target_datum)
99 || (source_datum.is_wgs84_compatible() && target_datum.is_wgs84_compatible());
100
101 let pipeline = if same_datum {
102 match (from, to) {
104 (CrsDef::Geographic(_), CrsDef::Geographic(_)) => TransformPipeline::Identity,
105 (CrsDef::Geographic(_), CrsDef::Projected(p)) => {
106 let forward = make_projection(&p.method(), p.datum())?;
107 TransformPipeline::SameDatumForward { forward }
108 }
109 (CrsDef::Projected(p), CrsDef::Geographic(_)) => {
110 let inverse = make_projection(&p.method(), p.datum())?;
111 TransformPipeline::SameDatumInverse { inverse }
112 }
113 (CrsDef::Projected(p_from), CrsDef::Projected(p_to)) => {
114 let inverse = make_projection(&p_from.method(), p_from.datum())?;
115 let forward = make_projection(&p_to.method(), p_to.datum())?;
116 TransformPipeline::SameDatumBoth { inverse, forward }
117 }
118 }
119 } else {
120 if !source_datum.is_wgs84_compatible() && source_datum.to_wgs84.is_none() {
122 return Err(Error::UnsupportedProjection(format!(
123 "source CRS EPSG:{} has no known datum shift to WGS84",
124 from.epsg()
125 )));
126 }
127 if !target_datum.is_wgs84_compatible() && target_datum.to_wgs84.is_none() {
128 return Err(Error::UnsupportedProjection(format!(
129 "target CRS EPSG:{} has no known datum shift to WGS84",
130 to.epsg()
131 )));
132 }
133
134 let inverse = match from {
135 CrsDef::Projected(p) => Some(make_projection(&p.method(), p.datum())?),
136 CrsDef::Geographic(_) => None,
137 };
138 let forward = match to {
139 CrsDef::Projected(p) => Some(make_projection(&p.method(), p.datum())?),
140 CrsDef::Geographic(_) => None,
141 };
142
143 TransformPipeline::DatumShift {
144 inverse,
145 source_datum: *source_datum,
146 target_datum: *target_datum,
147 forward,
148 }
149 };
150
151 Ok(Self {
152 source: *from,
153 target: *to,
154 pipeline,
155 })
156 }
157
158 pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
169 let c = coord.into_coord();
170 let result = self.convert_coord(c)?;
171 Ok(T::from_coord(result))
172 }
173
174 pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
186 let c = coord.into_coord3d();
187 let result = self.convert_coord3d(c)?;
188 Ok(T::from_coord3d(result))
189 }
190
191 pub fn source_crs(&self) -> &CrsDef {
193 &self.source
194 }
195
196 pub fn target_crs(&self) -> &CrsDef {
198 &self.target
199 }
200
201 pub fn inverse(&self) -> Result<Self> {
203 Self::from_crs_defs(&self.target, &self.source)
204 }
205
206 pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
214 if !bounds.is_valid() {
215 return Err(Error::OutOfRange(
216 "bounds must be finite and satisfy min <= max".into(),
217 ));
218 }
219
220 let segments = densify_points
221 .checked_add(1)
222 .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))?;
223
224 let mut transformed: Option<Bounds> = None;
225 for i in 0..=segments {
226 let t = i as f64 / segments as f64;
227 let x = bounds.min_x + bounds.width() * t;
228 let y = bounds.min_y + bounds.height() * t;
229
230 for sample in [
231 Coord::new(x, bounds.min_y),
232 Coord::new(x, bounds.max_y),
233 Coord::new(bounds.min_x, y),
234 Coord::new(bounds.max_x, y),
235 ] {
236 let coord = self.convert_coord(sample)?;
237 if let Some(accum) = &mut transformed {
238 accum.expand_to_include(coord);
239 } else {
240 transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
241 }
242 }
243 }
244
245 transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
246 }
247
248 fn convert_coord(&self, c: Coord) -> Result<Coord> {
250 let result = self.convert_coord3d(Coord3D::new(c.x, c.y, 0.0))?;
251 Ok(Coord {
252 x: result.x,
253 y: result.y,
254 })
255 }
256
257 fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
259 match &self.pipeline {
260 TransformPipeline::Identity => Ok(c),
261
262 TransformPipeline::SameDatumForward { forward } => {
263 let lon_rad = c.x.to_radians();
266 let lat_rad = c.y.to_radians();
267 let (x_m, y_m) = forward.forward(lon_rad, lat_rad)?;
268 let (x, y) = self.projected_meters_to_target_native(x_m, y_m);
269 Ok(Coord3D { x, y, z: c.z })
270 }
271
272 TransformPipeline::SameDatumInverse { inverse } => {
273 let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
276 let (lon_rad, lat_rad) = inverse.inverse(x_m, y_m)?;
277 Ok(Coord3D {
278 x: lon_rad.to_degrees(),
279 y: lat_rad.to_degrees(),
280 z: c.z,
281 })
282 }
283
284 TransformPipeline::SameDatumBoth { inverse, forward } => {
285 let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
288 let (lon_rad, lat_rad) = inverse.inverse(x_m, y_m)?;
289 let (x_m, y_m) = forward.forward(lon_rad, lat_rad)?;
290 let (x, y) = self.projected_meters_to_target_native(x_m, y_m);
291 Ok(Coord3D { x, y, z: c.z })
292 }
293
294 TransformPipeline::DatumShift {
295 inverse,
296 source_datum,
297 target_datum,
298 forward,
299 } => {
300 let (lon_rad, lat_rad) = if let Some(inv) = inverse {
302 let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
303 inv.inverse(x_m, y_m)?
304 } else {
305 (c.x.to_radians(), c.y.to_radians())
306 };
307
308 let (x, y, z) = geocentric::geodetic_to_geocentric(
310 &source_datum.ellipsoid,
311 lon_rad,
312 lat_rad,
313 0.0,
314 );
315
316 let (x2, y2, z2) = if let Some(params) = &source_datum.to_wgs84 {
318 helmert::helmert_forward(params, x, y, z)
319 } else {
320 (x, y, z) };
322
323 let (x3, y3, z3) = if let Some(params) = &target_datum.to_wgs84 {
325 helmert::helmert_inverse(params, x2, y2, z2)
326 } else {
327 (x2, y2, z2) };
329
330 let (lon_out, lat_out, _h_out) =
332 geocentric::geocentric_to_geodetic(&target_datum.ellipsoid, x3, y3, z3);
333
334 if let Some(fwd) = forward {
336 let (x_m, y_m) = fwd.forward(lon_out, lat_out)?;
337 let (x, y) = self.projected_meters_to_target_native(x_m, y_m);
338 Ok(Coord3D { x, y, z: c.z })
339 } else {
340 Ok(Coord3D {
341 x: lon_out.to_degrees(),
342 y: lat_out.to_degrees(),
343 z: c.z,
344 })
345 }
346 }
347 }
348 }
349
350 fn source_projected_native_to_meters(&self, x: f64, y: f64) -> (f64, f64) {
351 match self.source {
352 CrsDef::Projected(p) => (p.linear_unit().to_meters(x), p.linear_unit().to_meters(y)),
353 CrsDef::Geographic(_) => (x, y),
354 }
355 }
356
357 fn projected_meters_to_target_native(&self, x: f64, y: f64) -> (f64, f64) {
358 match self.target {
359 CrsDef::Projected(p) => (
360 p.linear_unit().from_meters(x),
361 p.linear_unit().from_meters(y),
362 ),
363 CrsDef::Geographic(_) => (x, y),
364 }
365 }
366
367 pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
369 coords.iter().map(|c| self.convert(c.clone())).collect()
370 }
371
372 pub fn convert_batch_3d<T: Transformable3D + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
374 coords.iter().map(|c| self.convert_3d(c.clone())).collect()
375 }
376
377 #[cfg(feature = "rayon")]
382 pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
383 &self,
384 coords: &[T],
385 ) -> Result<Vec<T>> {
386 self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch(chunk))
387 }
388
389 #[cfg(feature = "rayon")]
391 pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync + Clone>(
392 &self,
393 coords: &[T],
394 ) -> Result<Vec<T>> {
395 self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch_3d(chunk))
396 }
397
398 #[cfg(feature = "rayon")]
399 fn convert_batch_parallel_adaptive<T, F>(&self, coords: &[T], convert: F) -> Result<Vec<T>>
400 where
401 T: Send + Sync + Clone,
402 F: Fn(&Self, &[T]) -> Result<Vec<T>> + Sync,
403 {
404 if !should_parallelize(coords.len()) {
405 return convert(self, coords);
406 }
407
408 use rayon::prelude::*;
409
410 let chunk_size = parallel_chunk_size(coords.len());
411 let chunk_results: Vec<Result<Vec<T>>> = coords
412 .par_chunks(chunk_size)
413 .map(|chunk| convert(self, chunk))
414 .collect();
415
416 let mut results = Vec::with_capacity(coords.len());
417 for chunk in chunk_results {
418 results.extend(chunk?);
419 }
420 Ok(results)
421 }
422}
423
424#[cfg(feature = "rayon")]
425fn should_parallelize(len: usize) -> bool {
426 if len == 0 {
427 return false;
428 }
429
430 let threads = rayon::current_num_threads().max(1);
431 len >= threads.saturating_mul(PARALLEL_MIN_ITEMS_PER_THREAD)
432}
433
434#[cfg(feature = "rayon")]
435fn parallel_chunk_size(len: usize) -> usize {
436 let threads = rayon::current_num_threads().max(1);
437 let target_chunks = threads.saturating_mul(PARALLEL_CHUNKS_PER_THREAD).max(1);
438 let chunk_size = len.div_ceil(target_chunks);
439 chunk_size.max(PARALLEL_MIN_CHUNK_SIZE)
440}
441
442fn same_crs_definition(from: &CrsDef, to: &CrsDef) -> bool {
443 match (from, to) {
444 (CrsDef::Geographic(a), CrsDef::Geographic(b)) => a.datum().same_datum(b.datum()),
445 (CrsDef::Projected(a), CrsDef::Projected(b)) => {
446 a.datum().same_datum(b.datum())
447 && approx_eq(a.linear_unit_to_meter(), b.linear_unit_to_meter())
448 && projection_methods_equivalent(&a.method(), &b.method())
449 }
450 _ => false,
451 }
452}
453
454fn projection_methods_equivalent(a: &ProjectionMethod, b: &ProjectionMethod) -> bool {
455 match (a, b) {
456 (ProjectionMethod::WebMercator, ProjectionMethod::WebMercator) => true,
457 (
458 ProjectionMethod::TransverseMercator {
459 lon0: a_lon0,
460 lat0: a_lat0,
461 k0: a_k0,
462 false_easting: a_false_easting,
463 false_northing: a_false_northing,
464 },
465 ProjectionMethod::TransverseMercator {
466 lon0: b_lon0,
467 lat0: b_lat0,
468 k0: b_k0,
469 false_easting: b_false_easting,
470 false_northing: b_false_northing,
471 },
472 ) => {
473 approx_eq(*a_lon0, *b_lon0)
474 && approx_eq(*a_lat0, *b_lat0)
475 && approx_eq(*a_k0, *b_k0)
476 && approx_eq(*a_false_easting, *b_false_easting)
477 && approx_eq(*a_false_northing, *b_false_northing)
478 }
479 (
480 ProjectionMethod::PolarStereographic {
481 lon0: a_lon0,
482 lat_ts: a_lat_ts,
483 k0: a_k0,
484 false_easting: a_false_easting,
485 false_northing: a_false_northing,
486 },
487 ProjectionMethod::PolarStereographic {
488 lon0: b_lon0,
489 lat_ts: b_lat_ts,
490 k0: b_k0,
491 false_easting: b_false_easting,
492 false_northing: b_false_northing,
493 },
494 ) => {
495 approx_eq(*a_lon0, *b_lon0)
496 && approx_eq(*a_lat_ts, *b_lat_ts)
497 && approx_eq(*a_k0, *b_k0)
498 && approx_eq(*a_false_easting, *b_false_easting)
499 && approx_eq(*a_false_northing, *b_false_northing)
500 }
501 (
502 ProjectionMethod::LambertConformalConic {
503 lon0: a_lon0,
504 lat0: a_lat0,
505 lat1: a_lat1,
506 lat2: a_lat2,
507 false_easting: a_false_easting,
508 false_northing: a_false_northing,
509 },
510 ProjectionMethod::LambertConformalConic {
511 lon0: b_lon0,
512 lat0: b_lat0,
513 lat1: b_lat1,
514 lat2: b_lat2,
515 false_easting: b_false_easting,
516 false_northing: b_false_northing,
517 },
518 ) => {
519 approx_eq(*a_lon0, *b_lon0)
520 && approx_eq(*a_lat0, *b_lat0)
521 && approx_eq(*a_lat1, *b_lat1)
522 && approx_eq(*a_lat2, *b_lat2)
523 && approx_eq(*a_false_easting, *b_false_easting)
524 && approx_eq(*a_false_northing, *b_false_northing)
525 }
526 (
527 ProjectionMethod::AlbersEqualArea {
528 lon0: a_lon0,
529 lat0: a_lat0,
530 lat1: a_lat1,
531 lat2: a_lat2,
532 false_easting: a_false_easting,
533 false_northing: a_false_northing,
534 },
535 ProjectionMethod::AlbersEqualArea {
536 lon0: b_lon0,
537 lat0: b_lat0,
538 lat1: b_lat1,
539 lat2: b_lat2,
540 false_easting: b_false_easting,
541 false_northing: b_false_northing,
542 },
543 ) => {
544 approx_eq(*a_lon0, *b_lon0)
545 && approx_eq(*a_lat0, *b_lat0)
546 && approx_eq(*a_lat1, *b_lat1)
547 && approx_eq(*a_lat2, *b_lat2)
548 && approx_eq(*a_false_easting, *b_false_easting)
549 && approx_eq(*a_false_northing, *b_false_northing)
550 }
551 (
552 ProjectionMethod::Mercator {
553 lon0: a_lon0,
554 lat_ts: a_lat_ts,
555 k0: a_k0,
556 false_easting: a_false_easting,
557 false_northing: a_false_northing,
558 },
559 ProjectionMethod::Mercator {
560 lon0: b_lon0,
561 lat_ts: b_lat_ts,
562 k0: b_k0,
563 false_easting: b_false_easting,
564 false_northing: b_false_northing,
565 },
566 ) => {
567 approx_eq(*a_lon0, *b_lon0)
568 && approx_eq(*a_lat_ts, *b_lat_ts)
569 && approx_eq(*a_k0, *b_k0)
570 && approx_eq(*a_false_easting, *b_false_easting)
571 && approx_eq(*a_false_northing, *b_false_northing)
572 }
573 (
574 ProjectionMethod::EquidistantCylindrical {
575 lon0: a_lon0,
576 lat_ts: a_lat_ts,
577 false_easting: a_false_easting,
578 false_northing: a_false_northing,
579 },
580 ProjectionMethod::EquidistantCylindrical {
581 lon0: b_lon0,
582 lat_ts: b_lat_ts,
583 false_easting: b_false_easting,
584 false_northing: b_false_northing,
585 },
586 ) => {
587 approx_eq(*a_lon0, *b_lon0)
588 && approx_eq(*a_lat_ts, *b_lat_ts)
589 && approx_eq(*a_false_easting, *b_false_easting)
590 && approx_eq(*a_false_northing, *b_false_northing)
591 }
592 _ => false,
593 }
594}
595
596fn approx_eq(a: f64, b: f64) -> bool {
597 (a - b).abs() < 1e-12
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use crate::crs::{CrsDef, LinearUnit, ProjectedCrsDef, ProjectionMethod};
604 use crate::datum;
605
606 const US_FOOT_TO_METER: f64 = 0.3048006096012192;
607
608 #[test]
609 fn identity_same_crs() {
610 let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
611 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
612 assert_eq!(x, -74.006);
613 assert_eq!(y, 40.7128);
614 }
615
616 #[test]
617 fn wgs84_to_web_mercator() {
618 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
619 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
620 assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
622 assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
623 }
624
625 #[test]
626 fn web_mercator_to_wgs84() {
627 let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
628 let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
629 assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
630 assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
631 }
632
633 #[test]
634 fn roundtrip_4326_3857() {
635 let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
636 let inv = fwd.inverse().unwrap();
637
638 let original = (-74.0445, 40.6892);
639 let projected = fwd.convert(original).unwrap();
640 let back = inv.convert(projected).unwrap();
641
642 assert!(
643 (back.0 - original.0).abs() < 1e-8,
644 "lon: {} vs {}",
645 back.0,
646 original.0
647 );
648 assert!(
649 (back.1 - original.1).abs() < 1e-8,
650 "lat: {} vs {}",
651 back.1,
652 original.1
653 );
654 }
655
656 #[test]
657 fn wgs84_to_utm_18n() {
658 let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
659 let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
660 assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
661 assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
662 }
663
664 #[test]
665 fn equivalent_meter_and_foot_state_plane_crs_match_after_unit_conversion() {
666 let coord = (-80.8431, 35.2271); let meter_tx = Transform::new("EPSG:4326", "EPSG:32119").unwrap();
668 let foot_tx = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
669
670 let (mx, my) = meter_tx.convert(coord).unwrap();
671 let (fx, fy) = foot_tx.convert(coord).unwrap();
672
673 assert!(
674 (fx * US_FOOT_TO_METER - mx).abs() < 0.02,
675 "x mismatch: {fx} ft vs {mx} m"
676 );
677 assert!(
678 (fy * US_FOOT_TO_METER - my).abs() < 0.02,
679 "y mismatch: {fy} ft vs {my} m"
680 );
681 }
682
683 #[test]
684 fn inverse_transform_accepts_native_projected_units_for_foot_crs() {
685 let coord = (-80.8431, 35.2271); let forward = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
687 let inverse = Transform::new("EPSG:2264", "EPSG:4326").unwrap();
688
689 let projected = forward.convert(coord).unwrap();
690 let roundtrip = inverse.convert(projected).unwrap();
691
692 assert!((roundtrip.0 - coord.0).abs() < 1e-8, "lon: {}", roundtrip.0);
693 assert!((roundtrip.1 - coord.1).abs() < 1e-8, "lat: {}", roundtrip.1);
694 }
695
696 #[test]
697 fn utm_to_web_mercator() {
698 let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
700 let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
702 assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
704 }
705
706 #[test]
707 fn wgs84_to_polar_stereo_3413() {
708 let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
709 let (x, y) = t.convert((-45.0, 90.0)).unwrap();
710 assert!(x.abs() < 1.0, "x = {x}");
712 assert!(y.abs() < 1.0, "y = {y}");
713 }
714
715 #[test]
716 fn roundtrip_4326_3413() {
717 let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
718 let inv = fwd.inverse().unwrap();
719
720 let original = (-45.0, 75.0);
721 let projected = fwd.convert(original).unwrap();
722 let back = inv.convert(projected).unwrap();
723
724 assert!(
725 (back.0 - original.0).abs() < 1e-6,
726 "lon: {} vs {}",
727 back.0,
728 original.0
729 );
730 assert!(
731 (back.1 - original.1).abs() < 1e-6,
732 "lat: {} vs {}",
733 back.1,
734 original.1
735 );
736 }
737
738 #[test]
739 fn geographic_to_geographic_same_datum_is_identity() {
740 let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
742 let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
743 assert_eq!(lon, -74.006);
744 assert_eq!(lat, 40.7128);
745 }
746
747 #[test]
748 fn unknown_crs_error() {
749 let result = Transform::new("EPSG:99999", "EPSG:4326");
750 assert!(result.is_err());
751 }
752
753 #[test]
754 fn cross_datum_nad27_to_wgs84() {
755 let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
756 let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
757 assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
759 assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
760 }
761
762 #[test]
763 fn cross_datum_roundtrip_nad27() {
764 let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
765 let inv = fwd.inverse().unwrap();
766 let original = (-90.0, 45.0);
767 let shifted = fwd.convert(original).unwrap();
768 let back = inv.convert(shifted).unwrap();
769 assert!(
770 (back.0 - original.0).abs() < 1e-6,
771 "lon: {} vs {}",
772 back.0,
773 original.0
774 );
775 assert!(
776 (back.1 - original.1).abs() < 1e-6,
777 "lat: {} vs {}",
778 back.1,
779 original.1
780 );
781 }
782
783 #[test]
784 fn cross_datum_osgb36_to_wgs84() {
785 let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
786 let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap(); assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
789 assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
790 }
791
792 #[test]
793 fn wgs84_to_web_mercator_3d_preserves_height() {
794 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
795 let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
796 assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
797 assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
798 assert!((z - 123.45).abs() < 1e-12, "z = {z}");
799 }
800
801 #[test]
802 fn cross_datum_roundtrip_nad27_3d() {
803 let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
804 let inv = fwd.inverse().unwrap();
805 let original = (-90.0, 45.0, 250.0);
806 let shifted = fwd.convert_3d(original).unwrap();
807 let back = inv.convert_3d(shifted).unwrap();
808 assert!(
809 (back.0 - original.0).abs() < 1e-6,
810 "lon: {} vs {}",
811 back.0,
812 original.0
813 );
814 assert!(
815 (back.1 - original.1).abs() < 1e-6,
816 "lat: {} vs {}",
817 back.1,
818 original.1
819 );
820 assert!(
821 (back.2 - original.2).abs() < 1e-12,
822 "h: {} vs {}",
823 back.2,
824 original.2
825 );
826 }
827
828 #[test]
829 fn identical_custom_projected_crs_is_identity() {
830 let from = CrsDef::Projected(ProjectedCrsDef::new(
831 0,
832 datum::WGS84,
833 ProjectionMethod::WebMercator,
834 LinearUnit::metre(),
835 "Custom Web Mercator A",
836 ));
837 let to = CrsDef::Projected(ProjectedCrsDef::new(
838 0,
839 datum::WGS84,
840 ProjectionMethod::WebMercator,
841 LinearUnit::metre(),
842 "Custom Web Mercator B",
843 ));
844
845 let t = Transform::from_crs_defs(&from, &to).unwrap();
846 assert!(matches!(t.pipeline, TransformPipeline::Identity));
847 }
848
849 #[test]
850 fn inverse_exposes_swapped_crs() {
851 let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
852 let inv = fwd.inverse().unwrap();
853
854 assert_eq!(fwd.source_crs().epsg(), 4326);
855 assert_eq!(fwd.target_crs().epsg(), 3857);
856 assert_eq!(inv.source_crs().epsg(), 3857);
857 assert_eq!(inv.target_crs().epsg(), 4326);
858 }
859
860 #[test]
861 fn transform_bounds_web_mercator() {
862 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
863 let bounds = Bounds::new(-74.3, 40.45, -73.65, 40.95);
864
865 let result = t.transform_bounds(bounds, 8).unwrap();
866
867 assert!(result.min_x < -8_200_000.0);
868 assert!(result.max_x < -8_100_000.0);
869 assert!(result.min_y > 4_900_000.0);
870 assert!(result.max_y > result.min_y);
871 }
872
873 #[test]
874 fn transform_bounds_rejects_invalid_input() {
875 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
876 let err = t
877 .transform_bounds(Bounds::new(10.0, 5.0, -10.0, 20.0), 0)
878 .unwrap_err();
879
880 assert!(matches!(err, Error::OutOfRange(_)));
881 }
882
883 #[test]
884 fn batch_transform() {
885 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
886 let coords: Vec<(f64, f64)> = (0..10)
887 .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
888 .collect();
889
890 let results = t.convert_batch(&coords).unwrap();
891 assert_eq!(results.len(), 10);
892 for (x, _y) in &results {
893 assert!(*x < 0.0); }
895 }
896
897 #[test]
898 fn batch_transform_3d() {
899 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
900 let coords: Vec<(f64, f64, f64)> = (0..10)
901 .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
902 .collect();
903
904 let results = t.convert_batch_3d(&coords).unwrap();
905 assert_eq!(results.len(), 10);
906 for (index, (x, _y, z)) in results.iter().enumerate() {
907 assert!(*x < 0.0);
908 assert!((*z - index as f64).abs() < 1e-12);
909 }
910 }
911
912 #[test]
913 fn coord_type() {
914 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
915 let c = Coord::new(-74.006, 40.7128);
916 let result = t.convert(c).unwrap();
917 assert!((result.x - (-8238310.0)).abs() < 100.0);
918 }
919
920 #[cfg(feature = "geo-types")]
921 #[test]
922 fn geo_types_coord() {
923 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
924 let c = geo_types::Coord {
925 x: -74.006,
926 y: 40.7128,
927 };
928 let result: geo_types::Coord<f64> = t.convert(c).unwrap();
929 assert!((result.x - (-8238310.0)).abs() < 100.0);
930 }
931
932 #[cfg(feature = "rayon")]
933 #[test]
934 fn parallel_batch_transform() {
935 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
936 let coords: Vec<(f64, f64)> = (0..100)
937 .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
938 .collect();
939
940 let results = t.convert_batch_parallel(&coords).unwrap();
941 assert_eq!(results.len(), 100);
942 }
943
944 #[cfg(feature = "rayon")]
945 #[test]
946 fn parallel_batch_transform_matches_sequential_on_large_input() {
947 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
948 let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
949 let coords: Vec<(f64, f64)> = (0..len)
950 .map(|i| (-179.0 + i as f64 * 0.0001, -80.0 + i as f64 * 0.00005))
951 .collect();
952
953 let sequential = t.convert_batch(&coords).unwrap();
954 let parallel = t.convert_batch_parallel(&coords).unwrap();
955
956 assert_eq!(parallel, sequential);
957 }
958
959 #[cfg(feature = "rayon")]
960 #[test]
961 fn parallel_batch_transform_3d() {
962 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
963 let coords: Vec<(f64, f64, f64)> = (0..100)
964 .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01, i as f64))
965 .collect();
966
967 let results = t.convert_batch_parallel_3d(&coords).unwrap();
968 assert_eq!(results.len(), 100);
969 }
970
971 #[cfg(feature = "rayon")]
972 #[test]
973 fn parallel_batch_transform_3d_matches_sequential_on_large_input() {
974 let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
975 let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
976 let coords: Vec<(f64, f64, f64)> = (0..len)
977 .map(|i| {
978 (
979 -179.0 + i as f64 * 0.0001,
980 -80.0 + i as f64 * 0.00005,
981 i as f64,
982 )
983 })
984 .collect();
985
986 let sequential = t.convert_batch_3d(&coords).unwrap();
987 let parallel = t.convert_batch_parallel_3d(&coords).unwrap();
988
989 assert_eq!(parallel, sequential);
990 }
991}