scirs2_spatial/transform/
spherical.rs

1//! Spherical coordinate transformations
2//!
3//! This module provides functions for transforming between Cartesian and
4//! spherical coordinate systems, as well as utilities for working with
5//! spherical coordinates.
6//!
7//! Spherical coordinates use the following convention:
8//! - r (radius): Distance from the origin to the point
9//! - theta (polar angle): Angle from the positive z-axis (0 to π)
10//! - phi (azimuthal angle): Angle from the positive x-axis in the xy-plane (0 to 2π)
11//!
12//! Note: This convention (r, theta, phi) is used in physics. In mathematics, the
13//! convention (r, phi, theta) is sometimes used. We follow the physics convention.
14
15use crate::error::{SpatialError, SpatialResult};
16use scirs2_core::ndarray::{array, Array1, Array2, ArrayView1, ArrayView2};
17use std::f64::consts::{PI, TAU};
18
19/// Converts Cartesian coordinates (x, y, z) to spherical coordinates (r, theta, phi)
20///
21/// The spherical coordinates use the following convention:
22/// - r: Distance from the origin to the point (0 to ∞)
23/// - theta: Angle from the positive z-axis (0 to π)
24/// - phi: Angle from the positive x-axis in the xy-plane (0 to 2π)
25///
26/// # Arguments
27///
28/// * `cart` - Cartesian coordinates as a 3-element array [x, y, z]
29///
30/// # Returns
31///
32/// A 3-element array containing [r, theta, phi]
33///
34/// # Example
35///
36/// ```
37/// use scirs2_core::ndarray::array;
38/// use scirs2_spatial::transform::spherical::cart_to_spherical;
39///
40/// let cart = array![1.0, 1.0, 1.0]; // Point (1, 1, 1)
41/// let spherical = cart_to_spherical(&cart.view()).expect("Operation failed");
42///
43/// // r = sqrt(3)
44/// // theta = arccos(1/sqrt(3)) = 0.9553 radians (≈54.7°)
45/// // phi = arctan(1/1) = 0.7854 radians (45°)
46/// ```
47///
48/// # Errors
49///
50/// Returns an error if the input array doesn't have exactly 3 elements
51#[allow(dead_code)]
52pub fn cart_to_spherical(cart: &ArrayView1<f64>) -> SpatialResult<Array1<f64>> {
53    if cart.len() != 3 {
54        return Err(SpatialError::DimensionError(format!(
55            "Cartesian coordinates must have 3 elements, got {}",
56            cart.len()
57        )));
58    }
59
60    let x = cart[0];
61    let y = cart[1];
62    let z = cart[2];
63
64    // Compute r (radius)
65    let r = (x * x + y * y + z * z).sqrt();
66
67    // Handle the case where r is close to zero
68    if r < 1e-10 {
69        // Return [0, 0, 0] for the origin
70        return Ok(Array1::zeros(3));
71    }
72
73    // Compute theta (polar angle)
74    let theta = if r < 1e-10 {
75        0.0 // Default to 0 when at origin
76    } else {
77        (z / r).acos()
78    };
79
80    // Compute phi (azimuthal angle)
81    let phi = if x.abs() < 1e-10 && y.abs() < 1e-10 {
82        0.0 // Default to 0 when on z-axis
83    } else {
84        let raw_phi = y.atan2(x);
85        // Ensure phi is in [0, 2π)
86        if raw_phi < 0.0 {
87            raw_phi + TAU
88        } else {
89            raw_phi
90        }
91    };
92
93    Ok(array![r, theta, phi])
94}
95
96/// Converts spherical coordinates (r, theta, phi) to Cartesian coordinates (x, y, z)
97///
98/// The spherical coordinates use the following convention:
99/// - r: Distance from the origin to the point (0 to ∞)
100/// - theta: Angle from the positive z-axis (0 to π)
101/// - phi: Angle from the positive x-axis in the xy-plane (0 to 2π)
102///
103/// # Arguments
104///
105/// * `spherical` - Spherical coordinates as a 3-element array [r, theta, phi]
106///
107/// # Returns
108///
109/// A 3-element array containing [x, y, z]
110///
111/// # Example
112///
113/// ```
114/// use scirs2_core::ndarray::array;
115/// use scirs2_spatial::transform::spherical::spherical_to_cart;
116/// use std::f64::consts::PI;
117///
118/// // Point at r=2, theta=π/4 (45°), phi=π/3 (60°)
119/// let spherical = array![2.0, PI/4.0, PI/3.0];
120/// let cart = spherical_to_cart(&spherical.view()).expect("Operation failed");
121/// ```
122///
123/// # Errors
124///
125/// Returns an error if the input array doesn't have exactly 3 elements
126#[allow(dead_code)]
127pub fn spherical_to_cart(spherical: &ArrayView1<f64>) -> SpatialResult<Array1<f64>> {
128    if spherical.len() != 3 {
129        return Err(SpatialError::DimensionError(format!(
130            "Spherical coordinates must have 3 elements, got {}",
131            spherical.len()
132        )));
133    }
134
135    let r = spherical[0];
136    let theta = spherical[1];
137    let phi = spherical[2];
138
139    // Check that r is non-negative
140    if r < 0.0 {
141        return Err(SpatialError::ValueError(format!(
142            "Radius r must be non-negative, got {r}"
143        )));
144    }
145
146    // Check that theta is within valid range
147    if !(0.0..=PI).contains(&theta) {
148        return Err(SpatialError::ValueError(format!(
149            "Polar angle theta must be in [0, π], got {theta}"
150        )));
151    }
152
153    // Convert to Cartesian coordinates
154    let x = r * theta.sin() * phi.cos();
155    let y = r * theta.sin() * phi.sin();
156    let z = r * theta.cos();
157
158    Ok(array![x, y, z])
159}
160
161/// Converts multiple Cartesian coordinates to spherical coordinates
162///
163/// # Arguments
164///
165/// * `cart` - A 2D array of Cartesian coordinates, each row is a 3D point [x, y, z]
166///
167/// # Returns
168///
169/// A 2D array of spherical coordinates, each row is [r, theta, phi]
170///
171/// # Example
172///
173/// ```
174/// use scirs2_core::ndarray::array;
175/// use scirs2_spatial::transform::spherical::cart_to_spherical_batch;
176///
177/// let cart = array![
178///     [1.0, 0.0, 0.0],  // Point on x-axis
179///     [0.0, 1.0, 0.0],  // Point on y-axis
180///     [0.0, 0.0, 1.0],  // Point on z-axis
181/// ];
182/// let spherical = cart_to_spherical_batch(&cart.view()).expect("Operation failed");
183/// ```
184///
185/// # Errors
186///
187/// Returns an error if any row of the input array doesn't have exactly 3 elements
188#[allow(dead_code)]
189pub fn cart_to_spherical_batch(cart: &ArrayView2<'_, f64>) -> SpatialResult<Array2<f64>> {
190    if cart.ncols() != 3 {
191        return Err(SpatialError::DimensionError(format!(
192            "Cartesian coordinates must have 3 columns, got {}",
193            cart.ncols()
194        )));
195    }
196
197    let n_points = cart.nrows();
198    let mut result = Array2::zeros((n_points, 3));
199
200    for (i, row) in cart.rows().into_iter().enumerate() {
201        let spherical = cart_to_spherical(&row)?;
202        result.row_mut(i).assign(&spherical);
203    }
204
205    Ok(result)
206}
207
208/// Converts multiple spherical coordinates to Cartesian coordinates
209///
210/// # Arguments
211///
212/// * `spherical` - A 2D array of spherical coordinates, each row is [r, theta, phi]
213///
214/// # Returns
215///
216/// A 2D array of Cartesian coordinates, each row is [x, y, z]
217///
218/// # Example
219///
220/// ```
221/// use scirs2_core::ndarray::array;
222/// use scirs2_spatial::transform::spherical::spherical_to_cart_batch;
223/// use std::f64::consts::PI;
224///
225/// let spherical = array![
226///     [1.0, PI/2.0, 0.0],      // Point on x-axis
227///     [1.0, PI/2.0, PI/2.0],   // Point on y-axis
228///     [1.0, 0.0, 0.0],         // Point on z-axis
229/// ];
230/// let cart = spherical_to_cart_batch(&spherical.view()).expect("Operation failed");
231/// ```
232///
233/// # Errors
234///
235/// Returns an error if any row of the input array doesn't have exactly 3 elements
236#[allow(dead_code)]
237pub fn spherical_to_cart_batch(spherical: &ArrayView2<'_, f64>) -> SpatialResult<Array2<f64>> {
238    if spherical.ncols() != 3 {
239        return Err(SpatialError::DimensionError(format!(
240            "Spherical coordinates must have 3 columns, got {}",
241            spherical.ncols()
242        )));
243    }
244
245    let n_points = spherical.nrows();
246    let mut result = Array2::zeros((n_points, 3));
247
248    for (i, row) in spherical.rows().into_iter().enumerate() {
249        let cart = spherical_to_cart(&row)?;
250        result.row_mut(i).assign(&cart);
251    }
252
253    Ok(result)
254}
255
256/// Calculates the geodesic distance between two points on a sphere
257///
258/// # Arguments
259///
260/// * `spherical1` - Spherical coordinates of the first point [r, theta, phi]
261/// * `spherical2` - Spherical coordinates of the second point [r, theta, phi]
262///
263/// # Returns
264///
265/// The geodesic distance
266///
267/// # Example
268///
269/// ```
270/// use scirs2_core::ndarray::array;
271/// use scirs2_spatial::transform::spherical::geodesic_distance;
272/// use std::f64::consts::PI;
273///
274/// // North pole and a point on the equator
275/// let point1 = array![1.0, 0.0, 0.0];         // North pole
276/// let point2 = array![1.0, PI/2.0, 0.0];      // Point on equator
277///
278/// // Distance should be π/2 radians (90°) * radius (1.0)
279/// let distance = geodesic_distance(&point1.view(), &point2.view()).expect("Operation failed");
280/// ```
281///
282/// # Errors
283///
284/// Returns an error if the input arrays don't have exactly 3 elements
285#[allow(dead_code)]
286pub fn geodesic_distance(
287    spherical1: &ArrayView1<f64>,
288    spherical2: &ArrayView1<f64>,
289) -> SpatialResult<f64> {
290    if spherical1.len() != 3 || spherical2.len() != 3 {
291        return Err(SpatialError::DimensionError(
292            "Spherical coordinates must have 3 elements".into(),
293        ));
294    }
295
296    let r1 = spherical1[0];
297    let theta1 = spherical1[1];
298    let phi1 = spherical1[2];
299
300    let r2 = spherical2[0];
301    let theta2 = spherical2[1];
302    let phi2 = spherical2[2];
303
304    // Check that radii are non-negative
305    if r1 < 0.0 || r2 < 0.0 {
306        return Err(SpatialError::ValueError(
307            "Radius must be non-negative".into(),
308        ));
309    }
310
311    // For points on spheres with different radii, error is returned
312    if (r1 - r2).abs() > 1e-10 {
313        return Err(SpatialError::ValueError(
314            "Geodesic distance is only defined for points on the same sphere".into(),
315        ));
316    }
317
318    // Calculate the central angle using the Vincenty formula
319    let cos_theta1 = theta1.cos();
320    let sin_theta1 = theta1.sin();
321    let cos_theta2 = theta2.cos();
322    let sin_theta2 = theta2.sin();
323    let cos_dphi = (phi1 - phi2).cos();
324
325    // Calculate the angular distance
326    let cos_angle = cos_theta1 * cos_theta2 + sin_theta1 * sin_theta2 * cos_dphi;
327    // Clamp to valid range for acos
328    let cos_angle = cos_angle.clamp(-1.0, 1.0);
329    let angle = cos_angle.acos();
330
331    // Calculate the geodesic distance
332    let distance = r1 * angle;
333
334    Ok(distance)
335}
336
337/// Calculates the area of a spherical triangle given by three points on a unit sphere
338///
339/// # Arguments
340///
341/// * `p1` - First point in spherical coordinates [r, theta, phi]
342/// * `p2` - Second point in spherical coordinates [r, theta, phi]
343/// * `p3` - Third point in spherical coordinates [r, theta, phi]
344///
345/// # Returns
346///
347/// The area of the spherical triangle
348///
349/// # Example
350///
351/// ```
352/// use scirs2_core::ndarray::array;
353/// use scirs2_spatial::transform::spherical::spherical_triangle_area;
354/// use std::f64::consts::PI;
355///
356/// // Three points on a unit sphere
357/// let p1 = array![1.0, 0.0, 0.0];         // North pole
358/// let p2 = array![1.0, PI/2.0, 0.0];      // Point on equator, phi=0
359/// let p3 = array![1.0, PI/2.0, PI/2.0];   // Point on equator, phi=π/2
360///
361/// // This forms a spherical triangle with area π/2 steradians
362/// let area = spherical_triangle_area(&p1.view(), &p2.view(), &p3.view()).expect("Operation failed");
363/// ```
364#[allow(dead_code)]
365pub fn spherical_triangle_area(
366    p1: &ArrayView1<f64>,
367    p2: &ArrayView1<f64>,
368    p3: &ArrayView1<f64>,
369) -> SpatialResult<f64> {
370    // Ensure we're working with unit vectors on the sphere
371    let r1 = p1[0];
372    let r2 = p2[0];
373    let r3 = p3[0];
374
375    // Convert spherical to Cartesian for easier calculations
376    let cart1 = spherical_to_cart(p1)?;
377    let cart2 = spherical_to_cart(p2)?;
378    let cart3 = spherical_to_cart(p3)?;
379
380    // Normalize to unit vectors
381    let v1 = &cart1 / r1;
382    let v2 = &cart2 / r2;
383    let v3 = &cart3 / r3;
384
385    // Calculate dot products
386    let dot12 = v1.dot(&v2);
387    let dot23 = v2.dot(&v3);
388    let dot31 = v3.dot(&v1);
389
390    // Clamp to valid range for acos
391    let dot12 = dot12.clamp(-1.0, 1.0);
392    let dot23 = dot23.clamp(-1.0, 1.0);
393    let dot31 = dot31.clamp(-1.0, 1.0);
394
395    // Calculate the angles of the geodesic triangle
396    let a12 = dot12.acos();
397    let a23 = dot23.acos();
398    let a31 = dot31.acos();
399
400    // Calculate the area using the spherical excess formula
401    // E = A + B + C - π
402    let excess = a12 + a23 + a31 - PI;
403
404    // Area = radius^2 * excess
405    let area = r1 * r1 * excess;
406
407    Ok(area)
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use approx::assert_relative_eq;
414
415    #[test]
416    fn test_cart_to_spherical() {
417        // Origin
418        let cart = array![0.0, 0.0, 0.0];
419        let spherical = cart_to_spherical(&cart.view()).expect("Operation failed");
420        assert_relative_eq!(spherical[0], 0.0);
421        assert_relative_eq!(spherical[1], 0.0);
422        assert_relative_eq!(spherical[2], 0.0);
423
424        // Point on positive x-axis
425        let cart = array![1.0, 0.0, 0.0];
426        let spherical = cart_to_spherical(&cart.view()).expect("Operation failed");
427        assert_relative_eq!(spherical[0], 1.0); // r
428        assert_relative_eq!(spherical[1], PI / 2.0); // theta
429        assert_relative_eq!(spherical[2], 0.0); // phi
430
431        // Point on positive y-axis
432        let cart = array![0.0, 1.0, 0.0];
433        let spherical = cart_to_spherical(&cart.view()).expect("Operation failed");
434        assert_relative_eq!(spherical[0], 1.0); // r
435        assert_relative_eq!(spherical[1], PI / 2.0); // theta
436        assert_relative_eq!(spherical[2], PI / 2.0); // phi
437
438        // Point on positive z-axis
439        let cart = array![0.0, 0.0, 1.0];
440        let spherical = cart_to_spherical(&cart.view()).expect("Operation failed");
441        assert_relative_eq!(spherical[0], 1.0); // r
442        assert_relative_eq!(spherical[1], 0.0); // theta
443        assert_relative_eq!(spherical[2], 0.0); // phi
444
445        // Point in octant with all positive coordinates
446        let cart = array![1.0, 1.0, 1.0];
447        let spherical = cart_to_spherical(&cart.view()).expect("Operation failed");
448        assert_relative_eq!(spherical[0], 3.0_f64.sqrt(), epsilon = 1e-6); // r = sqrt(3)
449        assert_relative_eq!(spherical[1], (1.0 / 3.0_f64.sqrt()).acos(), epsilon = 1e-6); // theta = acos(1/sqrt(3))
450        assert_relative_eq!(spherical[2], PI / 4.0, epsilon = 1e-6); // phi = π/4
451    }
452
453    #[test]
454    fn test_spherical_to_cart() {
455        // Origin
456        let spherical = array![0.0, 0.0, 0.0];
457        let cart = spherical_to_cart(&spherical.view()).expect("Operation failed");
458        assert_relative_eq!(cart[0], 0.0);
459        assert_relative_eq!(cart[1], 0.0);
460        assert_relative_eq!(cart[2], 0.0);
461
462        // Point on positive x-axis
463        let spherical = array![1.0, PI / 2.0, 0.0];
464        let cart = spherical_to_cart(&spherical.view()).expect("Operation failed");
465        assert_relative_eq!(cart[0], 1.0, epsilon = 1e-6);
466        assert_relative_eq!(cart[1], 0.0, epsilon = 1e-6);
467        assert_relative_eq!(cart[2], 0.0, epsilon = 1e-6);
468
469        // Point on positive y-axis
470        let spherical = array![1.0, PI / 2.0, PI / 2.0];
471        let cart = spherical_to_cart(&spherical.view()).expect("Operation failed");
472        assert_relative_eq!(cart[0], 0.0, epsilon = 1e-6);
473        assert_relative_eq!(cart[1], 1.0, epsilon = 1e-6);
474        assert_relative_eq!(cart[2], 0.0, epsilon = 1e-6);
475
476        // Point on positive z-axis
477        let spherical = array![1.0, 0.0, 0.0];
478        let cart = spherical_to_cart(&spherical.view()).expect("Operation failed");
479        assert_relative_eq!(cart[0], 0.0, epsilon = 1e-6);
480        assert_relative_eq!(cart[1], 0.0, epsilon = 1e-6);
481        assert_relative_eq!(cart[2], 1.0, epsilon = 1e-6);
482
483        // Point with r=2, theta=π/4, phi=π/3
484        let spherical = array![2.0, PI / 4.0, PI / 3.0];
485        let cart = spherical_to_cart(&spherical.view()).expect("Operation failed");
486        assert_relative_eq!(
487            cart[0],
488            2.0 * (PI / 4.0).sin() * (PI / 3.0).cos(),
489            epsilon = 1e-6
490        );
491        assert_relative_eq!(
492            cart[1],
493            2.0 * (PI / 4.0).sin() * (PI / 3.0).sin(),
494            epsilon = 1e-6
495        );
496        assert_relative_eq!(cart[2], 2.0 * (PI / 4.0).cos(), epsilon = 1e-6);
497    }
498
499    #[test]
500    fn test_roundtrip() {
501        // Define a set of points to test
502        let cart_points = array![
503            [1.0, 0.0, 0.0],  // x-axis
504            [0.0, 1.0, 0.0],  // y-axis
505            [0.0, 0.0, 1.0],  // z-axis
506            [1.0, 1.0, 1.0],  // General point
507            [3.0, -2.0, 4.0], // Another general point
508        ];
509
510        for row in cart_points.rows() {
511            let cart_original = row.to_owned();
512
513            // Convert to spherical
514            let spherical = cart_to_spherical(&cart_original.view()).expect("Operation failed");
515
516            // Convert back to Cartesian
517            let cart_roundtrip = spherical_to_cart(&spherical.view()).expect("Operation failed");
518
519            // Check that we get the original point back
520            for i in 0..3 {
521                assert_relative_eq!(cart_original[i], cart_roundtrip[i], epsilon = 1e-6);
522            }
523        }
524    }
525
526    #[test]
527    fn test_batch_conversions() {
528        // Test batch conversion from Cartesian to spherical
529        let cart = array![
530            [1.0, 0.0, 0.0], // x-axis
531            [0.0, 1.0, 0.0], // y-axis
532            [0.0, 0.0, 1.0], // z-axis
533        ];
534
535        let spherical = cart_to_spherical_batch(&cart.view()).expect("Operation failed");
536
537        // Check dimensions
538        assert_eq!(spherical.shape(), &[3, 3]);
539
540        // Check individual values for x-axis point
541        assert_relative_eq!(spherical[[0, 0]], 1.0); // r
542        assert_relative_eq!(spherical[[0, 1]], PI / 2.0); // theta
543        assert_relative_eq!(spherical[[0, 2]], 0.0); // phi
544
545        // Test batch conversion from spherical to Cartesian
546        let cart_roundtrip = spherical_to_cart_batch(&spherical.view()).expect("Operation failed");
547
548        // Check that we get the original points back
549        assert_eq!(cart_roundtrip.shape(), &[3, 3]);
550
551        for i in 0..3 {
552            for j in 0..3 {
553                assert_relative_eq!(cart[[i, j]], cart_roundtrip[[i, j]], epsilon = 1e-6);
554            }
555        }
556    }
557
558    #[test]
559    fn test_geodesic_distance() {
560        // North pole to point on equator
561        let north_pole = array![1.0, 0.0, 0.0];
562        let equator_point = array![1.0, PI / 2.0, 0.0];
563
564        let distance =
565            geodesic_distance(&north_pole.view(), &equator_point.view()).expect("Operation failed");
566        assert_relative_eq!(distance, PI / 2.0, epsilon = 1e-6); // 90° arc = π/2 radians
567
568        // Two antipodal points on the equator
569        let point1 = array![1.0, PI / 2.0, 0.0];
570        let point2 = array![1.0, PI / 2.0, PI];
571
572        let distance = geodesic_distance(&point1.view(), &point2.view()).expect("Operation failed");
573        assert_relative_eq!(distance, PI, epsilon = 1e-6); // 180° arc = π radians
574
575        // Two points 60° apart on a sphere of radius 2
576        let point1 = array![2.0, PI / 2.0, 0.0];
577        let point2 = array![2.0, PI / 2.0, PI / 3.0];
578
579        let distance = geodesic_distance(&point1.view(), &point2.view()).expect("Operation failed");
580        assert_relative_eq!(distance, 2.0 * PI / 3.0, epsilon = 1e-6); // 60° arc * radius 2 = 2π/3
581    }
582
583    #[test]
584    fn test_spherical_triangle_area() {
585        // Octant triangle (1/8 of the sphere)
586        let p1 = array![1.0, 0.0, 0.0]; // North pole
587        let p2 = array![1.0, PI / 2.0, 0.0]; // Point on equator at phi=0
588        let p3 = array![1.0, PI / 2.0, PI / 2.0]; // Point on equator at phi=π/2
589
590        let area =
591            spherical_triangle_area(&p1.view(), &p2.view(), &p3.view()).expect("Operation failed");
592        assert_relative_eq!(area, PI / 2.0, epsilon = 1e-6); // Area = π/2 steradians
593
594        // 90° wedge on the sphere
595        let p1 = array![1.0, 0.0, 0.0]; // North pole
596        let p2 = array![1.0, PI, 0.0]; // South pole
597        let p3 = array![1.0, PI / 2.0, 0.0]; // Point on equator at phi=0
598
599        let area =
600            spherical_triangle_area(&p1.view(), &p2.view(), &p3.view()).expect("Operation failed");
601        assert_relative_eq!(area, PI, epsilon = 1e-6); // Area = π steradians (1/4 of sphere)
602    }
603
604    #[test]
605    fn test_error_cases() {
606        // Test error for wrong dimension in Cartesian to spherical
607        let bad_cart = array![1.0, 2.0];
608        assert!(cart_to_spherical(&bad_cart.view()).is_err());
609
610        // Test error for wrong dimension in spherical to Cartesian
611        let bad_spherical = array![1.0, 2.0];
612        assert!(spherical_to_cart(&bad_spherical.view()).is_err());
613
614        // Test error for negative radius
615        let neg_radius = array![-1.0, PI / 2.0, 0.0];
616        assert!(spherical_to_cart(&neg_radius.view()).is_err());
617
618        // Test error for invalid theta (polar angle)
619        let bad_theta = array![1.0, -0.1, 0.0];
620        assert!(spherical_to_cart(&bad_theta.view()).is_err());
621
622        let bad_theta = array![1.0, PI + 0.1, 0.0];
623        assert!(spherical_to_cart(&bad_theta.view()).is_err());
624
625        // Test error for geodesic distance with different radii
626        let p1 = array![1.0, 0.0, 0.0];
627        let p2 = array![2.0, 0.0, 0.0];
628        assert!(geodesic_distance(&p1.view(), &p2.view()).is_err());
629    }
630}