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}