1use std::error::Error;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Projection {
18 Linear,
22 Gnomonic,
26 Orthographic,
29 Stereographic,
31 ZenithalEquidistant,
35 ZenithalEqualArea,
37 PlateCarree,
40 Mercator,
42 CylindricalEqualArea,
45 HammerAitoff,
48 Mollweide,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Default)]
56pub(crate) struct ProjectionParams {
57 pub cea_lambda: Option<f64>,
60}
61
62const DEGREES_PER_RADIAN: f64 = 180.0 / std::f64::consts::PI;
64
65impl Projection {
66 pub fn from_ctype(ctype: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
74 let ctype = ctype.trim();
75
76 let Some((_, code)) = ctype.rsplit_once('-') else {
79 return Ok(Projection::Linear);
80 };
81
82 match code {
83 "" | "LINEAR" | "PIXEL" => Ok(Projection::Linear),
84 "TAN" | "TPV" => Ok(Projection::Gnomonic),
85 "SIN" => Ok(Projection::Orthographic),
86 "STG" => Ok(Projection::Stereographic),
87 "ARC" => Ok(Projection::ZenithalEquidistant),
88 "ZEA" => Ok(Projection::ZenithalEqualArea),
89 "CAR" => Ok(Projection::PlateCarree),
90 "MER" => Ok(Projection::Mercator),
91 "CEA" => Ok(Projection::CylindricalEqualArea),
92 "AIT" => Ok(Projection::HammerAitoff),
93 "MOL" => Ok(Projection::Mollweide),
94 other => Err(From::from(format!(
95 "Unsupported WCS projection in CTYPE {:?}: {} is not implemented",
96 ctype, other
97 ))),
98 }
99 }
100
101 pub fn code(self) -> &'static str {
103 match self {
104 Projection::Linear => "LINEAR",
105 Projection::Gnomonic => "TAN",
106 Projection::Orthographic => "SIN",
107 Projection::Stereographic => "STG",
108 Projection::ZenithalEquidistant => "ARC",
109 Projection::ZenithalEqualArea => "ZEA",
110 Projection::PlateCarree => "CAR",
111 Projection::Mercator => "MER",
112 Projection::CylindricalEqualArea => "CEA",
113 Projection::HammerAitoff => "AIT",
114 Projection::Mollweide => "MOL",
115 }
116 }
117
118 pub(crate) fn fiducial(self) -> (f64, f64) {
125 if self.is_zenithal() {
126 (0.0, 90.0)
127 } else {
128 (0.0, 0.0)
129 }
130 }
131
132 fn is_zenithal(self) -> bool {
134 matches!(
135 self,
136 Projection::Gnomonic
137 | Projection::Orthographic
138 | Projection::Stereographic
139 | Projection::ZenithalEquidistant
140 | Projection::ZenithalEqualArea
141 )
142 }
143
144 pub(crate) fn to_native(self, x: f64, y: f64, params: &ProjectionParams) -> (f64, f64) {
149 if self.is_zenithal() {
150 let radius = x.hypot(y);
151 let phi = if radius == 0.0 {
154 0.0
155 } else {
156 x.atan2(-y).to_degrees()
157 };
158
159 return (phi, self.zenithal_theta(radius));
160 }
161
162 match self {
163 Projection::PlateCarree => (x, y),
164 Projection::Mercator => (
165 x,
166 2.0 * (y / DEGREES_PER_RADIAN).exp().atan().to_degrees() - 90.0,
167 ),
168 Projection::CylindricalEqualArea => {
169 let lambda = params.cea_lambda.unwrap_or(1.0);
170 let sine = lambda * y / DEGREES_PER_RADIAN;
171 (x, arcsine(sine))
172 }
173 Projection::HammerAitoff => {
174 let (x, y) = (x / DEGREES_PER_RADIAN, y / DEGREES_PER_RADIAN);
175 let inside = 1.0 - (x / 4.0).powi(2) - (y / 2.0).powi(2);
176
177 if inside < 0.0 {
178 return (f64::NAN, f64::NAN);
179 }
180
181 let z = inside.sqrt();
182 let phi = 2.0 * (z * x / 2.0).atan2(2.0 * z * z - 1.0);
183
184 (phi.to_degrees(), arcsine(y * z))
185 }
186 Projection::Mollweide => {
187 let (x, y) = (x / DEGREES_PER_RADIAN, y / DEGREES_PER_RADIAN);
188 let root_two = std::f64::consts::SQRT_2;
189
190 let sine = y / root_two;
191 if !(-1.0..=1.0).contains(&sine) {
192 return (f64::NAN, f64::NAN);
193 }
194
195 let gamma = sine.asin();
196 let cosine = gamma.cos();
197 if cosine == 0.0 {
198 return (0.0, 90.0_f64.copysign(y));
200 }
201
202 let phi = std::f64::consts::PI * x / (2.0 * root_two * cosine);
203 let theta = arcsine((2.0 * gamma + (2.0 * gamma).sin()) / std::f64::consts::PI);
204
205 (phi.to_degrees(), theta)
206 }
207 Projection::Linear
210 | Projection::Gnomonic
211 | Projection::Orthographic
212 | Projection::Stereographic
213 | Projection::ZenithalEquidistant
214 | Projection::ZenithalEqualArea => (x, y),
215 }
216 }
217
218 #[allow(clippy::wrong_self_convention)]
221 pub(crate) fn from_native(self, phi: f64, theta: f64, params: &ProjectionParams) -> (f64, f64) {
222 if self.is_zenithal() {
223 let radius = self.zenithal_radius(theta);
224 let (sin, cos) = phi.to_radians().sin_cos();
225
226 return (radius * sin, -radius * cos);
227 }
228
229 match self {
230 Projection::PlateCarree => (phi, theta),
231 Projection::Mercator => {
232 let half = (45.0 + theta / 2.0).to_radians();
233 (phi, DEGREES_PER_RADIAN * half.tan().ln())
234 }
235 Projection::CylindricalEqualArea => {
236 let lambda = params.cea_lambda.unwrap_or(1.0);
237 (phi, DEGREES_PER_RADIAN * theta.to_radians().sin() / lambda)
238 }
239 Projection::HammerAitoff => {
240 let (phi, theta) = (phi.to_radians(), theta.to_radians());
241 let denominator = 1.0 + theta.cos() * (phi / 2.0).cos();
242
243 if denominator <= 0.0 {
244 return (f64::NAN, f64::NAN);
245 }
246
247 let gamma = (2.0 / denominator).sqrt();
248
249 (
250 DEGREES_PER_RADIAN * 2.0 * gamma * theta.cos() * (phi / 2.0).sin(),
251 DEGREES_PER_RADIAN * gamma * theta.sin(),
252 )
253 }
254 Projection::Mollweide => {
255 let (phi, theta) = (phi.to_radians(), theta.to_radians());
256 let gamma = mollweide_parametric(theta);
257 let root_two = std::f64::consts::SQRT_2;
258
259 (
260 DEGREES_PER_RADIAN * 2.0 * root_two * phi * gamma.cos() / std::f64::consts::PI,
261 DEGREES_PER_RADIAN * root_two * gamma.sin(),
262 )
263 }
264 Projection::Linear
265 | Projection::Gnomonic
266 | Projection::Orthographic
267 | Projection::Stereographic
268 | Projection::ZenithalEquidistant
269 | Projection::ZenithalEqualArea => (phi, theta),
270 }
271 }
272
273 fn zenithal_theta(self, radius: f64) -> f64 {
276 match self {
277 Projection::Gnomonic => DEGREES_PER_RADIAN.atan2(radius).to_degrees(),
280 Projection::Orthographic => arccosine(radius / DEGREES_PER_RADIAN),
281 Projection::Stereographic => {
282 90.0 - 2.0 * (radius / (2.0 * DEGREES_PER_RADIAN)).atan().to_degrees()
283 }
284 Projection::ZenithalEquidistant => 90.0 - radius,
285 Projection::ZenithalEqualArea => {
286 90.0 - 2.0 * arcsine(radius / (2.0 * DEGREES_PER_RADIAN))
287 }
288 _ => f64::NAN,
289 }
290 }
291
292 fn zenithal_radius(self, theta: f64) -> f64 {
295 let theta = theta.to_radians();
296
297 match self {
298 Projection::Gnomonic => {
299 let tan = theta.tan();
300 if tan <= 0.0 {
303 f64::NAN
304 } else {
305 DEGREES_PER_RADIAN / tan
306 }
307 }
308 Projection::Orthographic => {
309 if theta < 0.0 {
310 f64::NAN
311 } else {
312 DEGREES_PER_RADIAN * theta.cos()
313 }
314 }
315 Projection::Stereographic => {
316 let half = (std::f64::consts::FRAC_PI_4 - theta / 2.0).tan();
317 2.0 * DEGREES_PER_RADIAN * half
318 }
319 Projection::ZenithalEquidistant => 90.0 - theta.to_degrees(),
320 Projection::ZenithalEqualArea => {
321 2.0 * DEGREES_PER_RADIAN * (std::f64::consts::FRAC_PI_4 - theta / 2.0).sin()
322 }
323 _ => f64::NAN,
324 }
325 }
326}
327
328fn arcsine(value: f64) -> f64 {
331 if !(-1.0..=1.0).contains(&value) {
332 f64::NAN
333 } else {
334 value.asin().to_degrees()
335 }
336}
337
338fn arccosine(value: f64) -> f64 {
340 if !(-1.0..=1.0).contains(&value) {
341 f64::NAN
342 } else {
343 value.acos().to_degrees()
344 }
345}
346
347fn mollweide_parametric(theta: f64) -> f64 {
353 let target = std::f64::consts::PI * theta.sin();
354
355 if (theta.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-12 {
358 return std::f64::consts::FRAC_PI_2.copysign(theta);
359 }
360
361 let mut gamma = theta;
362 for _ in 0..32 {
363 let residual = 2.0 * gamma + (2.0 * gamma).sin() - target;
364 let derivative = 2.0 + 2.0 * (2.0 * gamma).cos();
365
366 if derivative.abs() < 1e-12 {
367 break;
368 }
369
370 let step = residual / derivative;
371 gamma -= step;
372
373 if step.abs() < 1e-14 {
374 break;
375 }
376 }
377
378 gamma
379}
380
381#[cfg(test)]
382mod tests {
383 use super::{Projection, ProjectionParams};
384
385 const SPHERICAL: [Projection; 10] = [
388 Projection::Gnomonic,
389 Projection::Orthographic,
390 Projection::Stereographic,
391 Projection::ZenithalEquidistant,
392 Projection::ZenithalEqualArea,
393 Projection::PlateCarree,
394 Projection::Mercator,
395 Projection::CylindricalEqualArea,
396 Projection::HammerAitoff,
397 Projection::Mollweide,
398 ];
399
400 #[test]
401 fn a_ctype_names_its_projection_in_its_last_field() {
402 assert_eq!(
403 Projection::from_ctype("RA---TAN").unwrap(),
404 Projection::Gnomonic
405 );
406 assert_eq!(
407 Projection::from_ctype("DEC--TAN").unwrap(),
408 Projection::Gnomonic
409 );
410 assert_eq!(
411 Projection::from_ctype("LINEAR").unwrap(),
412 Projection::Linear
413 );
414 assert_eq!(
415 Projection::from_ctype("RA---SIN").unwrap(),
416 Projection::Orthographic
417 );
418 assert_eq!(
419 Projection::from_ctype("GLON-AIT").unwrap(),
420 Projection::HammerAitoff
421 );
422 }
423
424 #[test]
425 fn a_distorted_gnomonic_axis_is_still_gnomonic() {
426 assert_eq!(
429 Projection::from_ctype("RA---TPV").unwrap(),
430 Projection::Gnomonic
431 );
432 }
433
434 #[test]
435 fn an_unimplemented_projection_is_an_error_rather_than_a_wrong_answer() {
436 let error =
439 Projection::from_ctype("RA---COE").expect_err("the COE projection is not implemented");
440
441 assert!(error.to_string().contains("COE"), "got: {error}");
442 }
443
444 #[test]
445 fn every_projection_round_trips_between_the_plane_and_its_native_sphere() {
446 let params = ProjectionParams::default();
447
448 for projection in SPHERICAL {
449 for phi in [-150.0, -30.0, 0.0, 45.0, 170.0] {
450 for theta in [-60.0, -10.0, 0.0, 25.0, 80.0] {
451 let (x, y) = projection.from_native(phi, theta, ¶ms);
452
453 if x.is_nan() || y.is_nan() {
456 continue;
457 }
458
459 let (back_phi, back_theta) = projection.to_native(x, y, ¶ms);
460
461 assert!(
462 (back_phi - phi).abs() < 1e-8 && (back_theta - theta).abs() < 1e-8,
463 "{:?} took ({phi}, {theta}) to ({x}, {y}) and back to ({back_phi}, \
464 {back_theta})",
465 projection
466 );
467 }
468 }
469 }
470 }
471
472 #[test]
473 fn the_fiducial_point_is_at_the_origin_of_the_plane() {
474 let params = ProjectionParams::default();
475
476 for projection in SPHERICAL {
477 let (phi, theta) = projection.fiducial();
478 let (x, y) = projection.from_native(phi, theta, ¶ms);
479
480 assert!(
481 x.abs() < 1e-9 && y.abs() < 1e-9,
482 "{:?} puts its reference point at ({x}, {y})",
483 projection
484 );
485 }
486 }
487
488 #[test]
489 fn a_point_the_projection_cannot_draw_is_not_quietly_moved() {
490 let params = ProjectionParams::default();
491
492 let (x, y) = Projection::Gnomonic.from_native(0.0, -30.0, ¶ms);
495 assert!(x.is_nan() && y.is_nan(), "got ({x}, {y})");
496
497 let (x, y) = Projection::Orthographic.from_native(0.0, -1.0, ¶ms);
499 assert!(x.is_nan() && y.is_nan(), "got ({x}, {y})");
500
501 let (phi, theta) = Projection::HammerAitoff.to_native(180.0, 120.0, ¶ms);
503 assert!(phi.is_nan() && theta.is_nan(), "got ({phi}, {theta})");
504 }
505
506 #[test]
507 fn the_plate_carree_is_longitude_and_latitude_unchanged() {
508 let params = ProjectionParams::default();
509
510 assert_eq!(
511 Projection::PlateCarree.to_native(30.0, -20.0, ¶ms),
512 (30.0, -20.0)
513 );
514 }
515}