hoomd_manifold/
sphere.rs

1// Copyright (c) 2024-2026 The Regents of the University of Michigan.
2// Part of hoomd-rs, released under the BSD 3-Clause License.
3
4//! Implement vector and curved manifold types on a sphere.
5
6use approxim::{approx_derive::RelativeEq, assert_relative_eq};
7use rand::{
8    Rng,
9    distr::{Distribution, Uniform},
10};
11use serde::{Deserialize, Serialize};
12use std::f64::consts::PI;
13
14use hoomd_utility::valid::PositiveReal;
15use hoomd_vector::{Cartesian, InnerProduct, Metric};
16
17/// Point on the surface of a sphere.
18///
19/// [`Spherical`] is a point on a unit N-sphere embedded in (N+1)-dimensional
20/// euclidean space. Explicitly, the N-sphere is defined by the set of
21/// (N+1)-dimensional points whose components satisfy
22/// ```math
23/// x_1^2 + x_2^2 + \cdots + x_{N+1}^1 = 1.0
24/// ```
25/// Note that the radius is fixed to be 1.0.
26#[derive(Clone, Copy, Debug, PartialEq, RelativeEq, Serialize, Deserialize)]
27pub struct Spherical<const N: usize> {
28    /// a cartesian point living on the surface of an N-sphere
29    point: Cartesian<N>,
30}
31impl<const N: usize> Spherical<N> {
32    /// Get the coordinates of the point
33    #[inline]
34    #[must_use]
35    pub fn coordinates(&self) -> &[f64; N] {
36        &self.point.coordinates
37    }
38    /// Get the point of the sphere
39    #[inline]
40    #[must_use]
41    pub fn point(&self) -> &Cartesian<N> {
42        &self.point
43    }
44    /// Construct a Sphere given a Cartesian vector and a radius.
45    ///
46    /// # Panics
47    ///
48    /// Panics when the point is not sufficiently close to the sphere's surface.
49    #[inline]
50    #[must_use]
51    pub fn from_cartesian_coordinates(point: Cartesian<N>) -> Spherical<N> {
52        let rad = point.norm();
53        assert_relative_eq!(rad, 1.0_f64, epsilon = 1e-6);
54        Spherical { point }
55    }
56
57    /// Implements a stereographic projection from the N-sphere to an N-dimensional plane.
58    ///
59    /// # Example
60    /// ```
61    /// use hoomd_manifold::Spherical;
62    /// use hoomd_vector::Cartesian;
63    ///
64    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
65    /// let x = Cartesian::from([0.5_f64.sqrt(), 0.0, -(0.5_f64.sqrt())]);
66    /// let projection =
67    ///     Spherical::from_cartesian_coordinates(x).stereographic_projection();
68    /// assert_eq!(
69    ///     [1.0 / (2.0_f64.sqrt() + 1.0), 0.0],
70    ///     [projection[0], projection[1]]
71    /// );
72    /// # Ok(())
73    /// # }
74    /// ```
75    #[inline]
76    #[must_use]
77    pub fn stereographic_projection(&self) -> Vec<f64> {
78        (0..N - 1)
79            .collect::<Vec<usize>>()
80            .iter()
81            .map(|i| self.point.coordinates[*i] / (1.0 - self.point.coordinates[N - 1]))
82            .collect::<Vec<f64>>()
83    }
84}
85
86impl Spherical<3> {
87    /// Create a 2-sphere from spherical coordinates
88    #[inline]
89    #[must_use]
90    pub fn from_polar_coordinates(theta: f64, phi: f64) -> Spherical<3> {
91        let theta_mod = theta.rem_euclid(PI);
92        let phi_mod = phi.rem_euclid(2.0 * PI);
93        let point = Cartesian::from([
94            (theta_mod.sin()) * (phi_mod.cos()),
95            (theta_mod.sin()) * (phi_mod.sin()),
96            (theta_mod.cos()),
97        ]);
98        Spherical::from_cartesian_coordinates(point)
99    }
100}
101
102impl Spherical<4> {
103    /// Create a 3-sphere from spherical coordinates
104    #[inline]
105    #[must_use]
106    pub fn from_polar_coordinates(theta: f64, phi_1: f64, phi_2: f64) -> Spherical<4> {
107        let theta_mod = theta.rem_euclid(PI);
108        let phi_1_mod = phi_1.rem_euclid(PI);
109        let phi_2_mod = phi_2.rem_euclid(2.0 * PI);
110        let point = Cartesian::from([
111            (theta_mod.sin()) * (phi_1_mod.cos()),
112            (theta_mod.sin()) * (phi_1_mod.sin()) * (phi_2_mod.cos()),
113            (theta_mod.sin()) * (phi_1_mod.sin()) * (phi_2_mod.sin()),
114            theta_mod.cos(),
115        ]);
116        Spherical::from_cartesian_coordinates(point)
117    }
118}
119
120impl Metric for Spherical<3> {
121    /// The distance between two [`Spherical<3>`] points.
122    ///
123    /// Explicitly, the metric for two points $`\vec{u}`$ and $`\vec{v}`$ on a
124    /// 2-sphere with radius $`R`$ is given by
125    ///
126    /// ```math
127    /// d_{S_2}(\vec{u}, \vec{v}) = R \arccos\left[\frac{1}{R^2}(u_1v_1 + u_2v_2 + u_3v_3)\right]
128    /// ```
129    /// This choice of metric furnishes a representation of 2-dimensional spherical
130    /// space with Gaussian curvature $`K = 1/R^2`$.
131    #[inline]
132    fn distance(&self, other: &Self) -> f64 {
133        let arg = Cartesian::dot(&self.point, &other.point);
134        arg.acos()
135    }
136    #[inline]
137    fn distance_squared(&self, other: &Self) -> f64 {
138        (self.distance(other)).powi(2)
139    }
140    #[inline]
141    fn n_dimensions(&self) -> usize {
142        2_usize
143    }
144}
145
146impl Metric for Spherical<4> {
147    /// The distance between two [`Spherical<4>`] points.
148    ///
149    /// Explicitly, the
150    /// metric for two points $`\vec{u}`$ and $`\vec{v}`$ on a 3-sphere with
151    /// radius  $`R`$ is given by
152    ///
153    /// ```math
154    /// d_{S_3}(\vec{u}, \vec{v}) = R \arccos\left[\frac{1}{R^2}(u_1v_1 + u_2v_2 + u_3v_3 + u_4v_4)\right]
155    /// ```
156    /// This choice of metric furnishes a representation of 3-dimensional spherical
157    /// space with Gaussian curvature $`K = 1/R^2`$.
158    #[inline]
159    fn distance(&self, other: &Self) -> f64 {
160        let arg = Cartesian::dot(&self.point, &other.point);
161        arg.acos()
162    }
163    #[inline]
164    fn distance_squared(&self, other: &Self) -> f64 {
165        (self.distance(other)).powi(2)
166    }
167    #[inline]
168    fn n_dimensions(&self) -> usize {
169        3_usize
170    }
171}
172
173/// Randomly distribute points locally on a sphere.
174///
175/// [`SphericalDisk`] is a uniform distribution of points within distance `r` of
176/// a point on the 2-sphere with a given radius.
177///
178/// # Example
179///
180/// ```
181/// use hoomd_manifold::{Spherical, SphericalDisk};
182/// use hoomd_vector::{Cartesian, Metric};
183/// use rand::{Rng, SeedableRng, distr::Distribution, rngs::StdRng};
184///
185/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
186/// let mut rng = StdRng::seed_from_u64(12);
187///
188/// let sample_disk =
189///     SphericalDisk {
190///         disk_radius: 0.5_f64.try_into()?,
191///         point: Spherical::<3>::from_cartesian_coordinates(Cartesian::from(
192///             [0.01, 0.01, -(1.0 - 2.0 * (0.01_f64).powi(2)).sqrt()],
193///         )),
194///     };
195/// let random_point: Spherical<3> = sample_disk.sample(&mut rng);
196///
197/// let disk = SphericalDisk {
198///     disk_radius: 0.1_f64.try_into()?,
199///     point: random_point,
200/// };
201/// let transformed_random_point: Spherical<3> = disk.sample(&mut rng);
202///
203/// assert!(0.1 > random_point.distance(&transformed_random_point));
204///
205/// # Ok(())
206/// # }
207/// ```
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209pub struct SphericalDisk {
210    /// Max distance away from point.
211    pub disk_radius: PositiveReal,
212    /// The center of the disk.
213    pub point: Spherical<3>,
214}
215
216impl<const N: usize> Default for Spherical<N> {
217    #[inline]
218    fn default() -> Self {
219        let mut zero = Cartesian::<N>::default();
220        zero.coordinates[N - 1] = 1.0;
221        Spherical { point: zero }
222    }
223}
224
225impl Distribution<Spherical<3>> for SphericalDisk {
226    #[inline]
227    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Spherical<3> {
228        let max_angle = self.disk_radius.get();
229        let p = self.point.point.coordinates;
230        let p_norm = self.point.point.norm();
231        let p_hat = [p[0] / p_norm, p[1] / p_norm, p[2] / p_norm];
232        // Build an ON tangent frame (e1, e2) not parallel to p_hat
233        let tmp = if p_hat[2].abs() < 0.9 {
234            [0.0, 0.0, 1.0]
235        } else {
236            [1.0, 0.0, 0.0]
237        };
238
239        // e1 = normalized cross product of tmp and p_hat
240        let e1 = [
241            tmp[1] * p_hat[2] - tmp[2] * p_hat[1],
242            tmp[2] * p_hat[0] - tmp[0] * p_hat[2],
243            tmp[0] * p_hat[1] - tmp[1] * p_hat[0],
244        ];
245        let e1_norm = (e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]).sqrt();
246        let e1 = [e1[0] / e1_norm, e1[1] / e1_norm, e1[2] / e1_norm];
247        // e2 = cross product of p_hat and e1
248        let e2 = [
249            p_hat[1] * e1[2] - p_hat[2] * e1[1],
250            p_hat[2] * e1[0] - p_hat[0] * e1[2],
251            p_hat[0] * e1[1] - p_hat[1] * e1[0],
252        ];
253        // sample direction in tangent plane: dir = cos(phi) * e1 + sin(phi) * e2
254        let phi = Uniform::new(0.0, 2.0 * PI)
255            .expect("u is positive")
256            .sample(rng);
257        let dir = [
258            phi.cos() * e1[0] + phi.sin() * e2[0],
259            phi.cos() * e1[1] + phi.sin() * e2[1],
260            phi.cos() * e1[2] + phi.sin() * e2[2],
261        ];
262        // sample geodesic distance with area weighting
263        let u: f64 = Uniform::new(0.0, 1.0).expect("u is positive").sample(rng);
264        let cos_theta = 1.0 - u * (1.0 - max_angle.cos());
265        let sin_theta = (cos_theta.acos()).sin();
266        // exponential map exp_p(v) = R\cos(v)*p_hat + R\sin(v)*v_hat
267        let new_point = [
268            cos_theta * p_hat[0] + sin_theta * dir[0],
269            cos_theta * p_hat[1] + sin_theta * dir[1],
270            cos_theta * p_hat[2] + sin_theta * dir[2],
271        ];
272
273        Spherical::from_cartesian_coordinates(Cartesian::from(new_point))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use approxim::assert_relative_eq;
281    use rand::{SeedableRng, rngs::StdRng};
282
283    /// Generate a pair of points on the surface of a 2-sphere
284    fn generate_s2_pair() -> (Spherical<3>, Spherical<3>) {
285        (
286            Spherical::<3>::from_polar_coordinates(0.1, 0.3),
287            Spherical::<3>::from_polar_coordinates(1.1, 0.5),
288        )
289    }
290    /// Generate a pair of points on the surface of a 3-sphere
291    fn generate_s3_pair() -> (Spherical<4>, Spherical<4>) {
292        (
293            Spherical::<4>::from_polar_coordinates(0.2, 0.3, 0.5),
294            Spherical::<4>::from_polar_coordinates(2.3, 1.1, 0.4),
295        )
296    }
297
298    #[test]
299    fn spherical_distance() {
300        let (a, b) = generate_s2_pair();
301        let ab_distance = a.distance(&b);
302        let ab_distance_numeric = 1.002_106_222_125_083;
303        assert_relative_eq!(ab_distance, ab_distance_numeric, epsilon = 1e-12);
304
305        let (c, d) = generate_s3_pair();
306        let cd_distance = c.distance(&d);
307        let cd_distance_numeric = 2.153_128_900_772_028;
308        assert_relative_eq!(cd_distance, cd_distance_numeric, epsilon = 1e-12);
309    }
310
311    #[test]
312    fn stereographic() {
313        let a = Spherical::<3>::from_polar_coordinates(2.1, 1.5);
314        let a_projection = a.stereographic_projection();
315        let a_projection_numeric = [0.040_576_252_191_799_88, 0.572_182_772_038_917_1];
316        assert_relative_eq![a_projection[0], a_projection_numeric[0], epsilon = 1e-12];
317        assert_relative_eq![a_projection[1], a_projection_numeric[1], epsilon = 1e-12];
318
319        let b = Spherical::<4>::from_polar_coordinates(2.1, 1.5, 0.5);
320        let b_projection = b.stereographic_projection();
321        let b_projection_numeric = [
322            0.040_576_252_191_799_88,
323            0.502_137_622_955_448_1,
324            0.274_319_033_664_803_76,
325        ];
326        assert_relative_eq![b_projection[0], b_projection_numeric[0], epsilon = 1e-12];
327        assert_relative_eq![b_projection[1], b_projection_numeric[1], epsilon = 1e-12];
328        assert_relative_eq![b_projection[2], b_projection_numeric[2], epsilon = 1e-12];
329    }
330
331    #[test]
332    fn random_sphere() {
333        // Generate ten random points on the Hyperbolic
334        let mut rng = StdRng::seed_from_u64(42);
335        let d = 0.1;
336        let n_pole = Cartesian::from([0.0, 0.0, 1.0]);
337        for _n in 0..10 {
338            let disk = SphericalDisk {
339                disk_radius: d.try_into().expect("hard-coded positive number"),
340                point: Spherical::<3>::from_cartesian_coordinates(n_pole),
341            };
342            let random_point: Spherical<3> = disk.sample(&mut rng);
343
344            // check that points remain on Sphere
345            let rho = random_point.point.norm_squared();
346            assert_relative_eq!(rho, 1.0, epsilon = 1e-12);
347
348            // check that points are within distance d of north pole
349            let distance = (random_point.point[2].acos()) * (rho.sqrt());
350            assert!(d > distance);
351        }
352    }
353}