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}