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, RngExt,
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, Quaternion, Rotate, Versor};
16
17/// Point on the surface of a sphere.
18///
19/// [`Spherical<N>`] is a point on a unit $`(N-1)`$-sphere embedded in
20/// $`N`$-dimensional Euclidean space. Explicitly, the $`N`$-sphere is defined
21/// by the set of $`(N+1)`$-dimensional points whose components satisfy
22/// ```math
23/// x_1^2 + x_2^2 + \cdots + x_{N+1}^2 = 1.0
24/// ```
25/// Note that the radius is fixed to be 1.0. The curvature of `Spherical`
26/// simulations can be tuned by changing the length scale of bodies and
27/// interactions. For example, rescaling all distances by
28/// $`r\rightarrow r/\ell`$ has the same effect as running simulations with
29/// radius $`R=1/\ell`$ or Gauss curvature $`K = \ell^2`$.
30#[derive(Clone, Copy, Debug, PartialEq, RelativeEq, Serialize, Deserialize)]
31pub struct Spherical<const N: usize> {
32    /// a cartesian point living on the surface of an N-sphere
33    point: Cartesian<N>,
34}
35impl<const N: usize> Spherical<N> {
36    /// Get the coordinates of the point
37    #[inline]
38    #[must_use]
39    pub fn coordinates(&self) -> &[f64; N] {
40        &self.point.coordinates
41    }
42    /// Get the point of the sphere
43    #[inline]
44    #[must_use]
45    pub fn point(&self) -> &Cartesian<N> {
46        &self.point
47    }
48    /// Construct a Sphere given a Cartesian vector and a radius.
49    ///
50    /// # Panics
51    ///
52    /// Panics when the point is not sufficiently close to the sphere's surface.
53    #[inline]
54    #[must_use]
55    pub fn from_cartesian_coordinates(point: Cartesian<N>) -> Spherical<N> {
56        let rad = point.norm();
57        assert_relative_eq!(rad, 1.0_f64, epsilon = 1e-6);
58        Spherical { point }
59    }
60    /// Implements a stereographic projection from the $`N`$-sphere to an
61    /// $`n`$-dimensional subspace by projecting through the
62    /// $`(0,\cdots, 0,1)`$ axis.
63    ///
64    /// # Example
65    /// ```
66    /// use hoomd_manifold::Spherical;
67    /// use hoomd_vector::Cartesian;
68    ///
69    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
70    /// let x = Cartesian::from([0.5_f64.sqrt(), 0.0, -(0.5_f64.sqrt())]);
71    /// let projection =
72    ///     Spherical::from_cartesian_coordinates(x).stereographic_projection();
73    /// assert_eq!(
74    ///     [1.0 / (2.0_f64.sqrt() + 1.0), 0.0],
75    ///     [projection[0], projection[1]]
76    /// );
77    /// # Ok(())
78    /// # }
79    /// ```
80    #[inline]
81    #[must_use]
82    pub fn stereographic_projection(&self) -> Vec<f64> {
83        (0..N - 1)
84            .collect::<Vec<usize>>()
85            .iter()
86            .map(|i| self.point.coordinates[*i] / (1.0 - self.point.coordinates[N - 1]))
87            .collect::<Vec<f64>>()
88    }
89}
90
91impl Spherical<3> {
92    /// Create a 2-sphere from spherical coordinates
93    #[inline]
94    #[must_use]
95    pub fn from_polar_coordinates(theta: f64, phi: f64) -> Spherical<3> {
96        let theta_mod = theta.rem_euclid(PI);
97        let phi_mod = phi.rem_euclid(2.0 * PI);
98        let point = Cartesian::from([
99            (theta_mod.sin()) * (phi_mod.cos()),
100            (theta_mod.sin()) * (phi_mod.sin()),
101            (theta_mod.cos()),
102        ]);
103        Spherical::from_cartesian_coordinates(point)
104    }
105}
106
107impl Spherical<4> {
108    /// Create a point on a 3-sphere from spherical coordinates. Note that this uses
109    /// the convention
110    /// ```math
111    /// \begin{pmatrix}
112    /// \sin\theta \cos\phi_1
113    /// \\ \sin\theta \sin\phi_1 \cos\phi_2
114    /// \\ \sin\theta \sin\phi_1 \sin\phi_2
115    /// \\ \cos\theta
116    /// \end{pmatrix}
117    /// ```
118    /// where $`\theta`$ and $`\phi_1`$ both run over the range $`0`$ to $`\pi`$ and $`\phi_2`$
119    /// runs from $`0`$ to $`2\pi`$.
120    #[inline]
121    #[must_use]
122    pub fn from_polar_coordinates(theta: f64, phi_1: f64, phi_2: f64) -> Spherical<4> {
123        let theta_mod = theta.rem_euclid(PI);
124        let phi_1_mod = phi_1.rem_euclid(PI);
125        let phi_2_mod = phi_2.rem_euclid(2.0 * PI);
126        let point = Cartesian::from([
127            (theta_mod.sin()) * (phi_1_mod.cos()),
128            (theta_mod.sin()) * (phi_1_mod.sin()) * (phi_2_mod.cos()),
129            (theta_mod.sin()) * (phi_1_mod.sin()) * (phi_2_mod.sin()),
130            theta_mod.cos(),
131        ]);
132        Spherical::from_cartesian_coordinates(point)
133    }
134    /// Create a point on a unit-radius 3-sphere from a unit quaternion.
135    #[inline]
136    #[must_use]
137    pub fn from_versor(versor: Versor) -> Spherical<4> {
138        let quat = versor.get();
139        let (a, b, c, d) = (quat.scalar, quat.vector[0], quat.vector[1], quat.vector[2]);
140        Spherical::<4>::from_cartesian_coordinates(Cartesian::from([a, b, c, d]))
141    }
142    /// Create a versor which maps $`(1,0,0,0)`$ to the target `Spherical<4>` point.
143    /// # Example
144    /// ```
145    /// use approxim::assert_relative_eq;
146    /// use hoomd_manifold::Spherical;
147    /// use hoomd_vector::{Cartesian, Quaternion, Versor};
148    /// use std::f64::consts::PI;
149    ///
150    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
151    /// let radius = 1.0;
152    /// let x = Spherical::<4>::from_polar_coordinates(
153    ///     PI / 4.0,
154    ///     PI / 8.0,
155    ///     5.0 * PI / 4.0,
156    /// );
157    /// let x_versor = x.to_versor();
158    /// let pole_versor = Quaternion::from([1.0, 0.0, 0.0, 0.0])
159    ///     .to_versor()
160    ///     .expect("not a null vector");
161    /// let transformation =
162    ///     (*x_versor.get() * *pole_versor.get() * *x_versor.get())
163    ///         .to_versor()
164    ///         .expect("Hard-coded example is valid");
165    /// let mapped_pole = Spherical::<4>::from_versor(transformation);
166    ///
167    /// assert_relative_eq!(
168    ///     mapped_pole.coordinates()[0],
169    ///     x.coordinates()[0],
170    ///     epsilon = 1e-12
171    /// );
172    /// assert_relative_eq!(
173    ///     mapped_pole.coordinates()[1],
174    ///     x.coordinates()[1],
175    ///     epsilon = 1e-12
176    /// );
177    /// assert_relative_eq!(
178    ///     mapped_pole.coordinates()[2],
179    ///     x.coordinates()[2],
180    ///     epsilon = 1e-12
181    /// );
182    /// assert_relative_eq!(
183    ///     mapped_pole.coordinates()[3],
184    ///     x.coordinates()[3],
185    ///     epsilon = 1e-12
186    /// );
187    /// # Ok(())
188    /// # }
189    /// ```
190    #[inline]
191    #[must_use]
192    pub fn to_versor(&self) -> Versor {
193        let phi = self.coordinates()[3].atan2(self.coordinates()[2]);
194        let theta = ((self.coordinates()[3].powi(2) + self.coordinates()[2].powi(2)).sqrt())
195            .atan2(self.coordinates()[1]);
196        let psi = ((self.coordinates()[3].powi(2)
197            + self.coordinates()[2].powi(2)
198            + self.coordinates()[1].powi(2))
199        .sqrt())
200        .atan2(self.coordinates()[0]);
201        let n_hat = Cartesian::from([
202            theta.cos(),
203            (theta.sin()) * (phi.cos()),
204            (theta.sin()) * (phi.sin()),
205        ])
206        .to_unit_unchecked();
207        Versor::from_axis_angle(n_hat.0, psi)
208    }
209}
210
211impl Metric for Spherical<3> {
212    /// The distance between two [`Spherical<3>`] points.
213    ///
214    /// Explicitly, the metric for two points $`\vec{u}`$ and $`\vec{v}`$ on a
215    /// 2-sphere with unit radius is given by
216    ///
217    /// ```math
218    /// d_{S_2}(\vec{u}, \vec{v}) = \arccos\left[u_1v_1 + u_2v_2 + u_3v_3\right]
219    /// ```
220    /// This choice of metric furnishes a representation of 2-dimensional spherical
221    /// space with Gaussian curvature $`K = 1`$.
222    #[inline]
223    fn distance(&self, other: &Self) -> f64 {
224        let arg = Cartesian::dot(&self.point, &other.point);
225        let arg_clipped = arg.clamp(-1.0, 1.0);
226        arg_clipped.acos()
227    }
228    #[inline]
229    fn distance_squared(&self, other: &Self) -> f64 {
230        (self.distance(other)).powi(2)
231    }
232    #[inline]
233    fn n_dimensions(&self) -> usize {
234        2_usize
235    }
236}
237
238impl Metric for Spherical<4> {
239    /// The distance between two [`Spherical<4>`] points.
240    ///
241    /// Explicitly, the
242    /// metric for two points $`\vec{u}`$ and $`\vec{v}`$ on a 3-sphere with
243    /// unit radius is given by
244    ///
245    /// ```math
246    /// d_{S_3}(\vec{u}, \vec{v}) = \arccos\left[u_1v_1 + u_2v_2 + u_3v_3 + u_4v_4\right]
247    /// ```
248    /// This choice of metric furnishes a representation of 3-dimensional spherical
249    /// space with Gaussian curvature $`K = 1`$.
250    #[inline]
251    fn distance(&self, other: &Self) -> f64 {
252        let arg = Cartesian::dot(&self.point, &other.point);
253        let arg_clipped = arg.clamp(-1.0, 1.0);
254        arg_clipped.acos()
255    }
256    #[inline]
257    fn distance_squared(&self, other: &Self) -> f64 {
258        (self.distance(other)).powi(2)
259    }
260    #[inline]
261    fn n_dimensions(&self) -> usize {
262        3_usize
263    }
264}
265
266/// Randomly distribute points locally on a sphere.
267///
268/// [`SphericalDisk`] is a uniform distribution of points within distance `r` of
269/// a point on the 2-sphere with a given radius.
270///
271/// # Example
272///
273/// ```
274/// use hoomd_manifold::{Spherical, SphericalDisk};
275/// use hoomd_vector::{Cartesian, Metric};
276/// use rand::{Rng, SeedableRng, distr::Distribution, rngs::StdRng};
277///
278/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
279/// let mut rng = StdRng::seed_from_u64(12);
280///
281/// let sample_disk =
282///     SphericalDisk {
283///         disk_radius: 0.5_f64.try_into()?,
284///         point: Spherical::<3>::from_cartesian_coordinates(Cartesian::from(
285///             [0.01, 0.01, -(1.0 - 2.0 * (0.01_f64).powi(2)).sqrt()],
286///         )),
287///     };
288/// let random_point: Spherical<3> = sample_disk.sample(&mut rng);
289///
290/// let disk = SphericalDisk {
291///     disk_radius: 0.1_f64.try_into()?,
292///     point: random_point,
293/// };
294/// let transformed_random_point: Spherical<3> = disk.sample(&mut rng);
295///
296/// assert!(0.1 > random_point.distance(&transformed_random_point));
297///
298/// # Ok(())
299/// # }
300/// ```
301#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
302pub struct SphericalDisk<const N: usize> {
303    /// Max distance away from point.
304    pub disk_radius: PositiveReal,
305    /// The center of the disk.
306    pub point: Spherical<N>,
307}
308
309impl<const N: usize> Default for Spherical<N> {
310    #[inline]
311    fn default() -> Self {
312        let mut zero = Cartesian::<N>::default();
313        zero.coordinates[N - 1] = 1.0;
314        Spherical { point: zero }
315    }
316}
317
318impl Distribution<Spherical<3>> for SphericalDisk<3> {
319    /// Translates 3-dimensional cartesian vector named "point" along the
320    /// surface of a sphere by maximum distance of r.
321    #[inline]
322    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Spherical<3> {
323        let max_angle = self.disk_radius.get();
324        let p = self.point.point.coordinates;
325        let p_norm = self.point.point.norm();
326        let p_hat = [p[0] / p_norm, p[1] / p_norm, p[2] / p_norm];
327        // Build an ON tangent frame (e1, e2) not parallel to p_hat
328        let tmp = if p_hat[2].abs() < 0.9 {
329            [0.0, 0.0, 1.0]
330        } else {
331            [1.0, 0.0, 0.0]
332        };
333
334        // e1 = normalized cross product of tmp and p_hat
335        let e1 = [
336            tmp[1] * p_hat[2] - tmp[2] * p_hat[1],
337            tmp[2] * p_hat[0] - tmp[0] * p_hat[2],
338            tmp[0] * p_hat[1] - tmp[1] * p_hat[0],
339        ];
340        let e1_norm = (e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]).sqrt();
341        let e1 = [e1[0] / e1_norm, e1[1] / e1_norm, e1[2] / e1_norm];
342        // e2 = cross product of p_hat and e1
343        let e2 = [
344            p_hat[1] * e1[2] - p_hat[2] * e1[1],
345            p_hat[2] * e1[0] - p_hat[0] * e1[2],
346            p_hat[0] * e1[1] - p_hat[1] * e1[0],
347        ];
348        // sample direction in tangent plane: dir = cos(phi) * e1 + sin(phi) * e2
349        let phi = Uniform::new(0.0, 2.0 * PI)
350            .expect("u is positive")
351            .sample(rng);
352        let dir = [
353            phi.cos() * e1[0] + phi.sin() * e2[0],
354            phi.cos() * e1[1] + phi.sin() * e2[1],
355            phi.cos() * e1[2] + phi.sin() * e2[2],
356        ];
357        // sample geodesic distance with area weighting
358        let u: f64 = Uniform::new(0.0, 1.0).expect("u is positive").sample(rng);
359        let cos_theta = 1.0 - u * (1.0 - max_angle.cos());
360        let sin_theta = (cos_theta.acos()).sin();
361        // exponential map exp_p(v) = R\cos(v)*p_hat + R\sin(v)*v_hat
362        let new_point = [
363            cos_theta * p_hat[0] + sin_theta * dir[0],
364            cos_theta * p_hat[1] + sin_theta * dir[1],
365            cos_theta * p_hat[2] + sin_theta * dir[2],
366        ];
367
368        Spherical::from_cartesian_coordinates(Cartesian::from(new_point))
369    }
370}
371
372impl Distribution<Spherical<4>> for SphericalDisk<4> {
373    /// Translates 3-dimensional cartesian vector named "point" along the
374    /// surface of a sphere by maximum distance of r.
375    #[inline]
376    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Spherical<4> {
377        let max_trans = self.disk_radius.get();
378        let point = self.point;
379        // generate random unit cartesian vector
380        let v: Versor = rng.random();
381        let b_hat = v
382            .rotate(&Cartesian::from([1.0, 0.0, 0.0]))
383            .to_unit()
384            .expect("hard coded non-null vector");
385        let eta = Uniform::new(0.0, max_trans).expect("hard coded non-negative");
386        let translation_versor = Versor::from_axis_angle(b_hat.0, eta.sample(rng));
387
388        let position_versor = Quaternion::from(*point.coordinates())
389            .to_versor()
390            .expect("spherical points cannot be null");
391        let transformation =
392            ((*translation_versor.get()) * (*position_versor.get()) * (*translation_versor.get()))
393                .to_versor()
394                .expect("spherical points cannot be null");
395        let sphere_point = Spherical::<4>::from_versor(transformation);
396        Spherical::<4>::from_cartesian_coordinates(*sphere_point.point())
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use approxim::assert_relative_eq;
404    use rand::{SeedableRng, rngs::StdRng};
405
406    /// Generate a pair of points on the surface of a 2-sphere
407    fn generate_s2_pair() -> (Spherical<3>, Spherical<3>) {
408        (
409            Spherical::<3>::from_polar_coordinates(0.1, 0.3),
410            Spherical::<3>::from_polar_coordinates(1.1, 0.5),
411        )
412    }
413    /// Generate a pair of points on the surface of a 3-sphere
414    fn generate_s3_pair() -> (Spherical<4>, Spherical<4>) {
415        (
416            Spherical::<4>::from_polar_coordinates(0.2, 0.3, 0.5),
417            Spherical::<4>::from_polar_coordinates(2.3, 1.1, 0.4),
418        )
419    }
420
421    #[test]
422    fn spherical_distance() {
423        let (a, b) = generate_s2_pair();
424        let ab_distance = a.distance(&b);
425        let ab_distance_numeric = 1.002_106_222_125_083;
426        assert_relative_eq!(ab_distance, ab_distance_numeric, epsilon = 1e-12);
427
428        let (c, d) = generate_s3_pair();
429        let cd_distance = c.distance(&d);
430        let cd_distance_numeric = 2.153_128_900_772_028;
431        assert_relative_eq!(cd_distance, cd_distance_numeric, epsilon = 1e-12);
432    }
433
434    #[test]
435    fn stereographic() {
436        let a = Spherical::<3>::from_polar_coordinates(2.1, 1.5);
437        let a_projection = a.stereographic_projection();
438        let a_projection_numeric = [0.040_576_252_191_799_88, 0.572_182_772_038_917_1];
439        assert_relative_eq![a_projection[0], a_projection_numeric[0], epsilon = 1e-12];
440        assert_relative_eq![a_projection[1], a_projection_numeric[1], epsilon = 1e-12];
441
442        let b = Spherical::<4>::from_polar_coordinates(2.1, 1.5, 0.5);
443        let b_projection = b.stereographic_projection();
444        let b_projection_numeric = [
445            0.040_576_252_191_799_88,
446            0.502_137_622_955_448_1,
447            0.274_319_033_664_803_76,
448        ];
449        assert_relative_eq![b_projection[0], b_projection_numeric[0], epsilon = 1e-12];
450        assert_relative_eq![b_projection[1], b_projection_numeric[1], epsilon = 1e-12];
451        assert_relative_eq![b_projection[2], b_projection_numeric[2], epsilon = 1e-12];
452    }
453
454    #[test]
455    fn random_two_sphere() {
456        // Generate ten random points on the Hyperbolic
457        let mut rng = StdRng::seed_from_u64(42);
458        let d = 0.1;
459        let n_pole = Cartesian::from([0.0, 0.0, 1.0]);
460        for _n in 0..10 {
461            let disk = SphericalDisk {
462                disk_radius: d.try_into().expect("hard-coded positive number"),
463                point: Spherical::<3>::from_cartesian_coordinates(n_pole),
464            };
465            let random_point: Spherical<3> = disk.sample(&mut rng);
466
467            // check that points remain on Sphere
468            let rho = random_point.point.norm_squared();
469            assert_relative_eq!(rho, 1.0, epsilon = 1e-12);
470
471            // check that points are within distance d of north pole
472            let distance = (random_point.point[2].acos()) * (rho.sqrt());
473            assert!(d > distance);
474        }
475    }
476    #[test]
477    fn random_three_sphere() {
478        // Generate ten random points on the Hyperbolic
479        let mut rng = StdRng::seed_from_u64(42);
480        let d = 0.1;
481        let n_pole = Spherical::from_cartesian_coordinates(Cartesian::from([1.0, 0.0, 0.0, 0.0]));
482        for _n in 0..10 {
483            let disk = SphericalDisk {
484                disk_radius: d.try_into().expect("hard-coded positive number"),
485                point: n_pole,
486            };
487            let random_point: Spherical<4> = disk.sample(&mut rng);
488
489            // check that points remain on Sphere
490            let rho = random_point.point.norm_squared();
491            assert_relative_eq!(rho, 1.0, epsilon = 1e-12);
492
493            // check that points are within distance d of north pole
494            let distance = random_point.point().distance(n_pole.point());
495            assert!(d > distance);
496        }
497    }
498    #[test]
499    fn from_versor() {
500        // generate a 3-sphere point from a versor
501        let mut rng = StdRng::seed_from_u64(358);
502        let v: Versor = rng.random();
503        let sphere_pt = Spherical::<4>::from_versor(v);
504        assert_eq!(sphere_pt.coordinates()[0], v.get().scalar);
505        assert_eq!(sphere_pt.coordinates()[1], v.get().vector.coordinates[0]);
506        assert_eq!(sphere_pt.coordinates()[2], v.get().vector.coordinates[1]);
507        assert_eq!(sphere_pt.coordinates()[3], v.get().vector.coordinates[2]);
508    }
509    #[test]
510    fn to_versor() {
511        // generate a 3-sphere point from a versor
512        let mut rng = StdRng::seed_from_u64(5121);
513        let v: Versor = rng.random();
514        let sphere_pt = Spherical::<4>::from_versor(v);
515        // get versor which maps identity versor to 3-sphere point
516        let versor_from_spherical = sphere_pt.to_versor();
517        let pole_versor = Quaternion::from([1.0, 0.0, 0.0, 0.0])
518            .to_versor()
519            .expect("not a null vector");
520        let transformation =
521            (*versor_from_spherical.get() * *pole_versor.get() * *versor_from_spherical.get())
522                .to_versor()
523                .expect("Hard-coded example is valid");
524        let q = transformation.get();
525        assert_relative_eq!(q.scalar, v.get().scalar, epsilon = 1e-12);
526        assert_relative_eq!(
527            q.vector.coordinates[0],
528            v.get().vector.coordinates[0],
529            epsilon = 1e-12
530        );
531        assert_relative_eq!(
532            q.vector.coordinates[1],
533            v.get().vector.coordinates[1],
534            epsilon = 1e-12
535        );
536        assert_relative_eq!(
537            q.vector.coordinates[2],
538            v.get().vector.coordinates[2],
539            epsilon = 1e-12
540        );
541    }
542}