Skip to main content

hoomd_vector/
angle.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 [`Angle`]
5
6use serde::{Deserialize, Serialize};
7use std::{f64::consts::PI, fmt};
8
9use approxim::approx_derive::RelativeEq;
10use rand::{
11    Rng,
12    distr::{Distribution, StandardUniform, Uniform},
13};
14
15use crate::{Cartesian, Rotate, Rotation, RotationMatrix};
16
17/// Rotation in the plane.
18///
19/// The rotation is represented by an angle `theta` in radians. Positive values rotate
20/// counter-clockwise.
21///
22/// ## Constructing [`Angle`]
23///
24/// The default Angle rotates by 0 radians:
25///
26/// ```
27/// use hoomd_vector::Angle;
28///
29/// let a = Angle::default();
30/// assert_eq!(a.theta, 0.0)
31/// ```
32///
33/// Create an [`Angle`] with a given value:
34/// ```
35/// use hoomd_vector::Angle;
36/// use std::f64::consts::PI;
37///
38/// let a = Angle::from(PI / 2.0);
39/// assert_eq!(a.theta, PI / 2.0);
40/// ```
41///
42/// Create a random [`Angle`] from the uniform distribution over all rotations:
43/// ```
44/// use hoomd_vector::Angle;
45/// use rand::{RngExt, SeedableRng, rngs::StdRng};
46///
47/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
48/// let mut rng = StdRng::seed_from_u64(1);
49/// let a: Angle = rng.random();
50/// # Ok(())
51/// # }
52/// ```
53///
54/// ## Operations using [`Angle`]
55///
56/// Rotate a [`Cartesian<2>`] vector by an [`Angle`]:
57/// ```
58/// use approxim::assert_relative_eq;
59/// use hoomd_vector::{Angle, Cartesian, Rotate, Rotation};
60/// use std::f64::consts::PI;
61///
62/// let v = Cartesian::from([-1.0, 0.0]);
63/// let a = Angle::from(PI / 2.0);
64/// let rotated = a.rotate(&v);
65/// assert_relative_eq!(rotated, [0.0, -1.0].into())
66/// ```
67///
68/// Combine two rotations together:
69/// ```
70/// use hoomd_vector::{Angle, Rotation};
71/// use std::f64::consts::PI;
72///
73/// let a = Angle::from(PI / 2.0);
74/// let b = Angle::from(-PI / 4.0);
75/// let c = a.combine(&b);
76/// assert_eq!(c.theta, PI / 4.0);
77/// ```
78#[derive(Clone, Copy, Debug, Default, PartialEq, RelativeEq, Serialize, Deserialize)]
79pub struct Angle {
80    /// Rotation angle (radians).
81    pub theta: f64,
82}
83
84impl Angle {
85    /// Reduce the rotation.
86    ///
87    /// [`Angle`] rotations are well-defined for any value of `theta`. However, combining small
88    /// rotations with large ones will introduce floating point round-off error. Reducing an [`Angle`]
89    /// creates an equivalent rotation with `theta` in the range from 0 to 2 pi.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use hoomd_vector::Angle;
95    /// use std::f64::consts::PI;
96    ///
97    /// let a = Angle::from(20.0 * PI);
98    /// let b = a.to_reduced();
99    /// assert_eq!(b.theta, 0.0)
100    /// ```
101    #[inline]
102    #[must_use]
103    pub fn to_reduced(self) -> Self {
104        Self {
105            theta: self.theta.rem_euclid(2.0 * PI),
106        }
107    }
108}
109
110impl From<Angle> for RotationMatrix<2> {
111    /// Construct a rotation matrix equivalent to this angle's rotation.
112    ///
113    /// When rotating many vectors by the same [`Angle`], improve performance
114    /// by converting to a matrix first and applying that matrix to the vectors.
115    ///
116    /// # Example
117    /// ```
118    /// use approxim::assert_relative_eq;
119    /// use hoomd_vector::{Angle, Cartesian, Rotate, RotationMatrix};
120    /// use std::f64::consts::PI;
121    ///
122    /// let v = Cartesian::from([-1.0, 0.0]);
123    /// let a = Angle::from(PI / 2.0);
124    ///
125    /// let matrix = RotationMatrix::from(a);
126    /// let rotated = matrix.rotate(&v);
127    /// assert_relative_eq!(rotated, [0.0, -1.0].into());
128    /// ```
129    #[inline]
130    fn from(angle: Angle) -> RotationMatrix<2> {
131        let sin_theta = angle.theta.sin();
132        let cos_theta = angle.theta.cos();
133        RotationMatrix {
134            rows: [
135                [cos_theta, -sin_theta].into(),
136                [sin_theta, cos_theta].into(),
137            ],
138        }
139    }
140}
141
142impl From<f64> for Angle {
143    /// Create a rotation by `theta` radians.
144    ///
145    /// # Example
146    /// ```
147    /// use hoomd_vector::Angle;
148    ///
149    /// let a = Angle::from(1.5);
150    /// assert_eq!(a.theta, 1.5);
151    /// ```
152    #[inline]
153    fn from(theta: f64) -> Self {
154        Self { theta }
155    }
156}
157
158impl Rotate<Cartesian<2>> for Angle {
159    type Matrix = RotationMatrix<2>;
160
161    #[inline]
162    /// Rotate a [`Cartesian<2>`] in the plane by an [`Angle`]
163    ///
164    /// # Example
165    /// ```
166    /// use approxim::assert_relative_eq;
167    /// use hoomd_vector::{Angle, Cartesian, Rotate, Rotation};
168    /// use std::f64::consts::PI;
169    ///
170    /// let v = Cartesian::from([-1.0, 0.0]);
171    /// let a = Angle::from(PI / 2.0);
172    /// let rotated = a.rotate(&v);
173    /// assert_relative_eq!(rotated, [0.0, -1.0].into());
174    /// ```
175    fn rotate(&self, vector: &Cartesian<2>) -> Cartesian<2> {
176        let sin_theta = self.theta.sin();
177        let cos_theta = self.theta.cos();
178        Cartesian::from([
179            vector.coordinates[0] * cos_theta - vector.coordinates[1] * sin_theta,
180            vector.coordinates[0] * sin_theta + vector.coordinates[1] * cos_theta,
181        ])
182    }
183}
184
185impl Rotation for Angle {
186    #[inline]
187    /// Create an [`Angle`] that rotates by 0 radians.
188    ///
189    /// # Example
190    /// ```
191    /// use hoomd_vector::{Angle, Rotation};
192    ///
193    /// let a = Angle::default();
194    /// assert_eq!(a.theta, 0.0);
195    /// ```
196    fn identity() -> Self {
197        Self::default()
198    }
199
200    #[inline]
201    /// Create an [`Angle`] that rotates by the same amount in the opposite direction.
202    ///
203    /// # Example
204    /// ```
205    /// use hoomd_vector::{Angle, Rotation};
206    /// use std::f64::consts::PI;
207    ///
208    /// let a = Angle::from(PI / 3.0);
209    /// let b = a.inverted();
210    /// assert_eq!(b.theta, -PI / 3.0);
211    /// ```
212    fn inverted(self) -> Self {
213        Self::from(-self.theta)
214    }
215
216    #[inline]
217    /// Create an [`Angle`] that rotates by the sum of the two angles.
218    ///
219    /// # Example
220    /// ```
221    /// use hoomd_vector::{Angle, Rotation};
222    /// use std::f64::consts::PI;
223    ///
224    /// let a = Angle::from(PI / 2.0);
225    /// let b = Angle::from(-PI / 4.0);
226    /// let c = a.combine(&b);
227    /// assert_eq!(c.theta, PI / 4.0);
228    /// ```
229    fn combine(&self, other: &Self) -> Self {
230        Self::from(self.theta + other.theta)
231    }
232}
233
234impl fmt::Display for Angle {
235    /// Format an Angle as `<{theta}>`.
236    #[inline]
237    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
238        write!(f, "<{}>", self.theta)
239    }
240}
241
242impl Distribution<Angle> for StandardUniform {
243    /// Sample a random angle from the uniform distribution over all rotations.
244    ///
245    /// # Example
246    ///
247    /// ```
248    /// use hoomd_vector::Angle;
249    /// use rand::{RngExt, SeedableRng, rngs::StdRng};
250    ///
251    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
252    /// let mut rng = StdRng::seed_from_u64(1);
253    /// let v: Angle = rng.random();
254    /// # Ok(())
255    /// # }
256    /// ```
257    #[inline]
258    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Angle {
259        let uniform = Uniform::new(0.0, 2.0 * PI).expect("hard-coded distribution should be valid");
260        Angle::from(uniform.sample(rng))
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use approxim::assert_relative_eq;
268    use rand::{RngExt, SeedableRng, rngs::StdRng};
269    use rstest::*;
270    use std::f64::consts::PI;
271
272    // Test named cases of the three input values (angle, vector input, and answer)
273    #[rstest]
274    #[case::pi_halves(PI / 2.0, (1.0, -0.5), (0.5, 1.0))]
275    #[case::negative_pi_thirds(-PI / 3.0, (1.0, 0.0), (0.5, -f64::sqrt(3.0) / 2.0))]
276    #[case::negative_pi(-PI, (3.1, -0.2), (-3.1, 0.2))]
277    #[case::two_pi(PI*2.0, (3.1, -0.2), (3.1, -0.2))]
278    #[case::zero(0.0, (3.1, -0.2), (3.1, -0.2))]
279    #[case::negative_zero(-0.0, (3.1, -0.2), (3.1, -0.2))]
280    fn rotate_2d(#[case] angle: f64, #[case] vec: (f64, f64), #[case] ans: (f64, f64)) {
281        let angle = Angle::from(angle);
282        let vec = Cartesian::from(vec);
283        let ans = Cartesian::from(ans);
284
285        assert_relative_eq!(angle.rotate(&vec), ans, epsilon = 4.0 * f64::EPSILON);
286        assert_relative_eq!(
287            RotationMatrix::from(angle).rotate(&vec),
288            ans,
289            epsilon = 4.0 * f64::EPSILON
290        );
291    }
292
293    // Test with Cartesian product of the input arrays
294    #[rstest(
295        ang1 => [0.0, PI / 2.0, 1e-12 * PI, -3.0, 12345.6],
296        ang2 => [-0.0, -PI / 3.0, PI, 2.0 * PI]
297    )]
298    fn combine_2d(ang1: f64, ang2: f64) {
299        let (angle1, angle2) = (Angle::from(ang1), Angle::from(ang2));
300        assert_relative_eq!(angle1.combine(&angle2).theta, ang1 + ang2);
301    }
302
303    #[test]
304    fn default() {
305        let a = Angle::default();
306        assert!(a.theta == 0.0);
307    }
308
309    #[test]
310    fn identity() {
311        let a = Angle::identity();
312        assert!(a.theta == 0.0);
313    }
314
315    #[rstest(theta => [0.0, 1.0, 2.125, 14.875, -4.5])]
316    fn inverted(theta: f64) {
317        let angle1 = Angle::from(theta);
318        let angle2 = angle1.inverted();
319        assert!(angle2.theta == -theta);
320        assert_relative_eq!(angle1.combine(&angle2), Angle::identity());
321    }
322
323    #[test]
324    fn display() {
325        let a = Angle::from(1.5);
326        let s = format!("{a}");
327        assert_eq!(s, "<1.5>");
328    }
329
330    #[test]
331    fn reduced() {
332        let two_pi = 2.0 * PI;
333
334        assert_relative_eq!(Angle::from(0.125).to_reduced(), (0.125).into());
335        assert_relative_eq!(Angle::from(2.0 * PI + 0.125).to_reduced(), (0.125).into());
336        assert_relative_eq!(Angle::from(2.0 * 2.0 * PI + 0.5).to_reduced(), (0.5).into());
337        assert_relative_eq!(Angle::from(3.0 * 2.0 * PI + 3.0).to_reduced(), (3.0).into());
338        assert_relative_eq!(
339            Angle::from(2.0 * PI - 0.125).to_reduced(),
340            (2.0 * PI - 0.125).into()
341        );
342
343        assert_relative_eq!(Angle::from(two_pi).to_reduced(), (0.0).into());
344        assert_relative_eq!(Angle::from(-0.125).to_reduced(), (2.0 * PI - 0.125).into());
345        assert_relative_eq!(Angle::from(-3.0).to_reduced(), (2.0 * PI - 3.0).into());
346        assert_relative_eq!(
347            Angle::from(-2.0 * PI - 0.125).to_reduced(),
348            (2.0 * PI - 0.125).into()
349        );
350        assert_relative_eq!(
351            Angle::from(10.0 * -2.0 * PI - 0.125).to_reduced(),
352            (2.0 * PI - 0.125).into()
353        );
354    }
355
356    #[test]
357    fn random() {
358        let mut rng = StdRng::seed_from_u64(1);
359
360        for _ in 0..10000 {
361            let a: Angle = rng.random();
362            assert!(a.theta >= 0.0 && a.theta < 2.0 * PI);
363        }
364    }
365}