Skip to main content

hoomd_geometry/shape/
hyperellipsoid.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 [`Hyperellipsoid`].
5
6use serde::{Deserialize, Serialize};
7use serde_with::serde_as;
8use std::ops::Mul;
9
10use super::sphere::sphere_volume_prefactor;
11use crate::{BoundingSphereRadius, IntersectsAt, IntersectsAtGlobal, SupportMapping, Volume};
12use hoomd_linear_algebra::{
13    QuadraticForm,
14    matrix::{DiagonalMatrix, Matrix22},
15};
16use hoomd_utility::valid::PositiveReal;
17use hoomd_vector::{Angle, Cartesian, InnerProduct, Metric, Rotate, Rotation, RotationMatrix};
18
19/// The geometry resulting from an Hypersphere that is scaled along the Cartesian axes.
20///
21/// See [`Ellipse`] and [`Ellipsoid`] for special cases in 2 and 3 dimensions.
22///
23/// # Examples
24///
25/// Basic construction and methods:
26/// ```
27/// use approxim::assert_relative_eq;
28/// use hoomd_geometry::{BoundingSphereRadius, Volume, shape::Hyperellipsoid};
29/// use std::f64::consts::PI;
30///
31/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
32/// let ellipse =
33///     Hyperellipsoid::with_semi_axes([1.0.try_into()?, 2.0.try_into()?]);
34/// let bounding_radius = ellipse.bounding_sphere_radius();
35/// let volume = ellipse.volume();
36///
37/// assert_eq!(bounding_radius.get(), 2.0);
38/// assert_relative_eq!(volume, PI * 1.0 * 2.0);
39///
40/// let sphere = Hyperellipsoid::with_semi_axes([
41///     2.0.try_into()?,
42///     2.0.try_into()?,
43///     2.0.try_into()?,
44/// ]);
45/// let bounding_radius = sphere.bounding_sphere_radius();
46/// let volume = sphere.volume();
47///
48/// assert_eq!(bounding_radius.get(), 2.0);
49/// assert_eq!(volume, 4.0 / 3.0 * PI * 2.0_f64.powi(3));
50/// # Ok(())
51/// # }
52/// ```
53#[serde_as]
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
55pub struct Hyperellipsoid<const N: usize> {
56    /// The principle semi-axes of the [`Hyperellipsoid`] along each Cartesian direction.
57    #[serde_as(as = "[_; N]")]
58    semi_axes: [PositiveReal; N],
59
60    /// The bounding sphere radius.
61    bounding_sphere_radius: PositiveReal,
62}
63
64impl<const N: usize> Hyperellipsoid<N> {
65    /// Construct a new Hyperellipsoid with the given semi-axes along each Cartesian direction.
66    #[expect(
67        clippy::missing_panics_doc,
68        reason = "Panic would occur due to a bug in hoomd-rs."
69    )]
70    #[must_use]
71    #[inline]
72    pub fn with_semi_axes(semi_axes: [PositiveReal; N]) -> Self {
73        let bounding_sphere_radius = semi_axes
74            .iter()
75            .map(PositiveReal::get)
76            .reduce(f64::max)
77            .expect("N must be greater than or equal to 1")
78            .try_into()
79            .expect("expression evaluates to a positive real");
80
81        Self {
82            semi_axes,
83            bounding_sphere_radius,
84        }
85    }
86
87    /// Get the semi axes.
88    #[must_use]
89    #[inline]
90    pub fn semi_axes(&self) -> &[PositiveReal; N] {
91        &self.semi_axes
92    }
93}
94
95/// A circle scaled along the x and y axes.
96///
97/// # Examples
98///
99/// Basic construction and methods:
100/// ```
101/// use approxim::assert_relative_eq;
102/// use hoomd_geometry::{BoundingSphereRadius, Volume, shape::Ellipse};
103/// use std::f64::consts::PI;
104///
105/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
106/// let ellipse = Ellipse::with_semi_axes([1.0.try_into()?, 2.0.try_into()?]);
107/// let bounding_radius = ellipse.bounding_sphere_radius();
108/// let volume = ellipse.volume();
109///
110/// assert_eq!(bounding_radius.get(), 2.0);
111/// assert_relative_eq!(volume, PI * 1.0 * 2.0);
112/// # Ok(())
113/// # }
114/// ```
115///
116/// Rapid ellipse-ellipse intersection testing is possible with hoomd-geometry. This check
117/// is based on a result from algebraic geometry, with the precise approach documented
118/// within the code:
119/// ```
120/// use hoomd_geometry::{IntersectsAt, shape::Ellipse};
121/// use hoomd_vector::Angle;
122///
123/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
124/// let long_ellipse =
125///     Ellipse::with_semi_axes([0.5.try_into()?, 3.0.try_into()?]);
126/// let round_ellipse =
127///     Ellipse::with_semi_axes([1.0.try_into()?, 2.0.try_into()?]);
128///
129/// let v_ij = [
130///     0.0,
131///     long_ellipse.semi_axes()[1].get() + round_ellipse.semi_axes()[1].get()
132///         - 0.1,
133/// ]
134/// .into();
135///
136/// assert_eq!(
137///     long_ellipse.intersects_at(&round_ellipse, &v_ij, &Angle::from(0.0)),
138///     true
139/// );
140/// # Ok(())
141/// # }
142/// ```
143pub type Ellipse = Hyperellipsoid<2>;
144
145/// A sphere scaled along the x, y, and z axes.
146///
147/// # Examples
148///
149/// Basic construction and methods:
150/// ```
151/// use approxim::assert_relative_eq;
152/// use hoomd_geometry::{BoundingSphereRadius, Volume, shape::Ellipsoid};
153/// use std::f64::consts::PI;
154///
155/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
156/// let sphere = Ellipsoid::with_semi_axes([
157///     2.0.try_into()?,
158///     2.0.try_into()?,
159///     2.0.try_into()?,
160/// ]);
161/// let bounding_radius = sphere.bounding_sphere_radius();
162/// let volume = sphere.volume();
163///
164/// assert_eq!(bounding_radius.get(), 2.0);
165/// assert_eq!(volume, 4.0 / 3.0 * PI * 2.0_f64.powi(3));
166/// # Ok(())
167/// # }
168/// ```
169///
170/// Test for intersections using [`Convex`](crate::Convex):
171/// ```
172/// use hoomd_geometry::{Convex, IntersectsAt, shape::Ellipsoid};
173/// use hoomd_vector::Versor;
174///
175/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
176/// let ellipsoid = Convex(Ellipsoid::with_semi_axes([
177///     1.0.try_into()?,
178///     2.0.try_into()?,
179///     3.0.try_into()?,
180/// ]));
181/// let q = Versor::default();
182///
183/// assert_eq!(
184///     ellipsoid.intersects_at(&ellipsoid, &[0.9, 0.0, 0.0].into(), &q),
185///     true
186/// );
187/// assert_eq!(
188///     ellipsoid.intersects_at(&ellipsoid, &[1.1, 0.0, 0.0].into(), &q),
189///     true
190/// );
191/// assert_eq!(
192///     ellipsoid.intersects_at(&ellipsoid, &[0.0, 1.9, 0.0].into(), &q),
193///     true
194/// );
195/// assert_eq!(
196///     ellipsoid.intersects_at(&ellipsoid, &[0.0, 2.1, 0.0].into(), &q),
197///     true
198/// );
199/// # Ok(())
200/// # }
201/// ```
202pub type Ellipsoid = Hyperellipsoid<3>;
203
204impl<const N: usize> SupportMapping<Cartesian<N>> for Hyperellipsoid<N> {
205    #[inline]
206    fn support_mapping(&self, n: &Cartesian<N>) -> Cartesian<N> {
207        let denominator =
208            Cartesian::<N>::from(std::array::from_fn(|i| self.semi_axes[i].get() * n[i])).norm();
209        std::array::from_fn(|i| n[i] * self.semi_axes[i].get().powi(2) / denominator).into()
210    }
211}
212
213impl<const N: usize> BoundingSphereRadius for Hyperellipsoid<N> {
214    #[inline]
215    fn bounding_sphere_radius(&self) -> PositiveReal {
216        self.bounding_sphere_radius
217    }
218}
219impl<const N: usize> Volume for Hyperellipsoid<N> {
220    #[inline]
221    fn volume(&self) -> f64 {
222        self.semi_axes
223            .iter()
224            .map(PositiveReal::get)
225            .fold(sphere_volume_prefactor(N), f64::mul)
226    }
227}
228
229impl<R> IntersectsAt<Hyperellipsoid<2>, Cartesian<2>, R> for Hyperellipsoid<2>
230where
231    R: Rotation + Rotate<Cartesian<2>>,
232    Angle: From<R>,
233    RotationMatrix<2>: From<R>,
234{
235    #[inline]
236    fn intersects_at(&self, other: &Hyperellipsoid<2>, v_ij: &Cartesian<2>, o_ij: &R) -> bool {
237        // This approach is derived from "A Robust Computational Test for Overlap of Two
238        // Arbitrary-dimensional Ellipsoids in Fault-Detection of Kalman Filters".
239        // Rather than generalize over dimension (which results in significant performance
240        // losses, even with efficient linear algebra libraries), we choose to implement
241        // the special case of ellipses in 2d. This derivation is far from rigorous, but
242        // aims to inform how the method actually works.
243        //
244        // We begin with Remark 1 from the above paper. In essence, we are defininig a
245        // convex, one dimensional function K(λ) that represents the intersection of our
246        // two ellipsoids, which is also a conic section. If this intersection function has
247        // any real roots (in the domain (0, 1)), our ellipsoids must intersect. To compute
248        // this function, we represent our ellipsoids as matrixes $A$ and $B$. $K(λ)$ is...
249        //
250        // $$
251        // K(λ) = 1 - v^T @ (1/(1-λ) B^-1 + 1/λ A^-1)^-1
252        // $$
253        //
254        // The "natural" matrix form of an ellipse is $diag(1/axes_i**2)$. However, we must
255        // transform one of our matrixes for the intersection calculation. We choose A, as
256        // it simplifies our future calculations. With R as the rotation matrix of o_ij:
257        //
258        // $$
259        // A = (R^-1).T @ diag(1/axes_A**2) @ R^-1
260        // $$
261        // The inverse of a rotation matrix is its transpose, so this simplifies. However,
262        // because we actually desire A_inverse and B_inverse (and A and B are diagonal),
263        // we can simplify further:
264        // $$
265        // A^-1 = R @ diag(axes_A**2) @ R^T
266        // B^-1 = diag(axes_B**2)
267        // $$
268        //
269        // Both these results can be cached and reused for evaluation of [`k_lambda`].
270        // Note that our equation is really of the quadratic form $K(λ)=1 - v.T @ M @ v$,
271        // with $M = (1/(1-λ) B^-1 + 1/λ A^-1)^-1$. This final inversion makes evaluating
272        // K(λ) in this form undesirable in the general case, but in 2D we have a simple
273        // closed form for the inverse.
274        //
275        // Recall that our ellipsoids do not overlap if there is a real root of K(λ) on
276        // (0, 1). Rather than searching for such a root, we can instead query for its
277        // existence analytically. We can show that K(λ) <= 0 if and only if its numerator
278        // polynomial P(λ) <= 0. P(λ) is a $n+1$-degree (cubic) polynomial:
279        //
280        // P(λ) = c3 λ^3 + c2 λ^2 + c1 λ + c0
281        //
282        // Where the coefficients are derived from the determinants and adjoints of A^-1
283        // and B^-1. Since P(0) > 0 and P(1) > 0, the ellipsoids are separated if and only
284        // if P(λ) has a local minimum in (0, 1) where P(λ) <= 0.
285
286        // b^-1 = R @ (B^-1) @ R.T, where B^-1 is in the local frame and ∴ diagonal
287        // Simplified, we get the following (provided B^-1=diag([a_sq, b_sq]))
288        // {{Cos[θ]^2 a_sq + b_sq Sin[θ]^2, Cos[θ] (a_sq - b_sq) Sin[θ]},
289        //  {Cos[θ] (a_sq - b_sq) Sin[θ], Cos[θ]^2 b_sq + a_sq Sin[θ]^2}}
290        let theta = Angle::from(*o_ij).theta;
291        let (sin, cos) = theta.sin_cos();
292        let (s_sq, c_sq) = (sin.powi(2), cos.powi(2));
293        let a = other.semi_axes[0].get();
294        let b = other.semi_axes[1].get();
295
296        let a_inv = DiagonalMatrix {
297            elements: self.semi_axes.map(|x| x.get().powi(2)),
298        };
299        let diagonal = cos * (a.powi(2) - b.powi(2)) * sin;
300        let b_inv_m_a_inv = Matrix22 {
301            rows: [
302                [
303                    c_sq * a.powi(2) + b.powi(2) * s_sq - a_inv[(0, 0)],
304                    diagonal,
305                ],
306                [
307                    diagonal,
308                    c_sq * b.powi(2) + a.powi(2) * s_sq - a_inv[(1, 1)],
309                ],
310            ],
311        };
312
313        let det_a_inv = a_inv[(0, 0)] * a_inv[(1, 1)];
314        let det_b_inv_m_a_inv = b_inv_m_a_inv.determinant();
315
316        let adj_a_inv = Matrix22 {
317            rows: [[a_inv[(1, 1)], 0.0], [0.0, a_inv[(0, 0)]]],
318        };
319
320        let adj_b_inv_m_a_inv = Matrix22 {
321            rows: [
322                [b_inv_m_a_inv.rows[1][1], -b_inv_m_a_inv.rows[0][1]],
323                [-b_inv_m_a_inv.rows[1][0], b_inv_m_a_inv.rows[0][0]],
324            ],
325        };
326        let q0 = adj_a_inv.compute_quadratic_form(&v_ij.coordinates);
327        let q1 = adj_b_inv_m_a_inv.compute_quadratic_form(&v_ij.coordinates);
328
329        // d1 = tr(adj(A_inv) @ d).
330        // Note the off diagonal terms would be 0 and are excluded
331        let d1 = f64::mul_add(
332            adj_a_inv[(0, 0)],
333            b_inv_m_a_inv[(0, 0)],
334            adj_a_inv[(1, 1)] * b_inv_m_a_inv[(1, 1)],
335        );
336
337        let (c3, c2, c1, c0) = (q1, det_b_inv_m_a_inv - q1 + q0, d1 - q0, det_a_inv);
338
339        // Early exit with an IVT check
340        let p0 = c0; // P(0) = c0
341        let p1 = c3 + c2 + c1 + c0; // P(1) = c3 + c2 + c1 + c0
342
343        // If endpoints have different signs we definitely have a root
344        if p0 * p1 <= 0.0 {
345            return false;
346        }
347
348        // Both endpoints are negative -- no overlap is possible
349        if (p0 < 0.0) && (p1 < 0.0) {
350            return false;
351        }
352
353        // Bézier Clipping check
354        // Control points b0 = p0, b3 = p1, so all we need are b1 and b2
355        let b1 = c0 + c1 / 3.0;
356        let b2 = c0 + f64::mul_add(2.0, c1, c2) / 3.0;
357
358        // Check if the hull is strictly separated from 0.
359        // Since we know p0 and p1 have the same sign (from step 1),
360        // we just need to check if b1 and b2 share that sign.
361        if p0 > 0.0 {
362            // Entire hull is positive -> No root possible.
363            if b1 > 0.0 && b2 > 0.0 {
364                return true;
365            }
366        } else {
367            // Entire hull is negative -> No root possible.
368            if b1 < 0.0 && b2 < 0.0 {
369                return true;
370            }
371        }
372
373        // Find local extrema of P(λ) = c3*λ^3 + c2*λ^2 + c1*λ + c0
374        // P'(lambda) = 3*c3*λ^2 + 2*c2*λ + c1
375        // Polynomial is effectively quadratic
376        if c3.abs() < 1e-15 {
377            // Polynomial is *not* effectively linear
378            if c2.abs() > 1e-15 {
379                let l_star = -c1 / (2.0 * c2);
380                if (0.0..1.0).contains(&l_star) {
381                    let p_star = (c2 * l_star + c1) * l_star + c0;
382                    if p0 > 0.0 && p_star <= 0.0 {
383                        return false;
384                    }
385                    if p0 < 0.0 && p_star >= 0.0 {
386                        return false;
387                    }
388                }
389            }
390        } else {
391            let delta = f64::mul_add(c2, c2, (-3.0 * c3) * c1);
392            if delta >= 0.0 {
393                let sqrt_delta = delta.sqrt();
394                let l1 = (-c2 - sqrt_delta) / (3.0 * c3);
395                let l2 = (-c2 + sqrt_delta) / (3.0 * c3);
396
397                for l in [l1, l2] {
398                    if (0.0..1.0).contains(&l) {
399                        let p = ((c3 * l + c2) * l + c1) * l + c0;
400                        if p0 > 0.0 && p <= 0.0 {
401                            return false;
402                        }
403                        if p0 < 0.0 && p >= 0.0 {
404                            return false;
405                        }
406                    }
407                }
408            }
409        }
410        true // If we did not detect a non-positive value of P(λ), the shapes overlap
411    }
412}
413impl<R> IntersectsAtGlobal<Hyperellipsoid<2>, Cartesian<2>, R> for Hyperellipsoid<2>
414where
415    R: Rotation + Rotate<Cartesian<2>>,
416    Angle: From<R>,
417    RotationMatrix<2>: From<R>,
418{
419    #[inline]
420    fn intersects_at_global(
421        &self,
422        other: &Hyperellipsoid<2>,
423        r_self: &Cartesian<2>,
424        o_self: &R,
425        r_other: &Cartesian<2>,
426        o_other: &R,
427    ) -> bool {
428        let max_separation =
429            self.bounding_sphere_radius().get() + other.bounding_sphere_radius().get();
430        if r_self.distance_squared(r_other) >= max_separation.powi(2) {
431            return false;
432        }
433
434        let (v_ij, o_ij) = hoomd_vector::pair_system_to_local(r_self, o_self, r_other, o_other);
435
436        self.intersects_at(other, &v_ij, &o_ij)
437    }
438}
439
440#[expect(
441    clippy::used_underscore_binding,
442    reason = "Used for const parameterization."
443)]
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::{
448        Convex,
449        shape::{Circle, Hypersphere},
450    };
451    use approxim::assert_relative_eq;
452    use hoomd_vector::Angle;
453    use rand::{RngExt, SeedableRng, rngs::StdRng};
454    use rstest::*;
455    use std::marker::PhantomData;
456
457    #[rstest]
458    #[case(PhantomData::<Hypersphere<1>>)]
459    #[case(PhantomData::<Hypersphere<2>>)]
460    #[case(PhantomData::<Hypersphere<3>>)]
461    #[case(PhantomData::<Hypersphere<4>>)]
462    #[case(PhantomData::<Hypersphere<5>>)]
463    fn test_support_hyperellipsoid<const N: usize>(
464        #[case] _n: PhantomData<Hypersphere<N>>,
465        #[values(0.1, 1.0, 33.3)] radius: f64,
466    ) {
467        let s = Hypersphere::<N> {
468            radius: radius.try_into().expect("test value is a positive real"),
469        };
470        let he = Hyperellipsoid::with_semi_axes(
471            [radius.try_into().expect("test value is a positive real"); N],
472        );
473        let v = [1.0; N].into();
474        assert_relative_eq!(he.support_mapping(&v), s.support_mapping(&v));
475    }
476    #[rstest]
477    #[case(PhantomData::<Hypersphere<1>>)]
478    #[case(PhantomData::<Hypersphere<2>>)]
479    #[case(PhantomData::<Hypersphere<3>>)]
480    #[case(PhantomData::<Hypersphere<4>>)]
481    #[case(PhantomData::<Hypersphere<5>>)]
482    fn test_volume_hyperellipsoid<const N: usize>(
483        #[case] _n: PhantomData<Hypersphere<N>>,
484        #[values(0.1, 1.0, 33.3)] radius: f64,
485    ) {
486        let s = Hypersphere::<N> {
487            radius: radius.try_into().expect("test value is a positive real"),
488        };
489        let he = Hyperellipsoid::with_semi_axes(
490            [radius.try_into().expect("test value is a positive real"); N],
491        );
492        assert_relative_eq!(he.volume(), s.volume());
493    }
494
495    #[rstest]
496    fn test_ellipse_overlap_along_axis(
497        #[values([0.0, 0.0], [1.0,0.0], [1.999_999, 0.0], [2.000_001, 0.0], [2.1, 0.0])]
498        v_ij: [f64; 2],
499        #[values(0.0, 2.0 * std::f64::consts::PI)] o_ij: f64,
500    ) {
501        let el0 = Ellipse::with_semi_axes([
502            1.0.try_into().expect("test value is a positive real"),
503            4.0.try_into().expect("test value is a positive real"),
504        ]);
505        let el1 = Ellipse::with_semi_axes([
506            1.0.try_into().expect("test value is a positive real"),
507            4.0.try_into().expect("test value is a positive real"),
508        ]);
509
510        assert_eq!(
511            el0.intersects_at(&el1, &v_ij.into(), &Angle::from(o_ij)),
512            Convex(el0).intersects_at(&Convex(el1), &v_ij.into(), &Angle::from(o_ij))
513        );
514    }
515    // This data was generated by root finding in mathematica, and cases have been
516    // visually verified. I tried to annotate why the cases are named as they are, but
517    // to be totally honest they don't fully describe the system. "large" gaps indicate
518    // spacing of ~1e-1 and "small" gaps indicate <1e-3. Much smaller than this and my
519    // mathematica script starts to disagree with rust, but I will note that xenocollide
520    // and our custom implementation DO agree in all tested cases.
521    #[rstest]
522    #[case::small_gap_nearly_axis_aligned([1.0595, 1.9480], [0.8127, 1.8536], [1.8784, 0.0441], 3.1083, false)]
523    #[case::large_gap_near_45_degrees([1.1006, 1.7573], [1.1109, 0.5128], [0.5199, -2.8111], 0.8937, false)]
524    #[case::oblate([1.6074, 0.8916], [1.7323, 1.4077], [2.3685, 1.5793], 2.0076, false)]
525    #[case::oblate_near_spherical_modest_gap([1.1498, 0.6598], [1.4868, 1.4980], [2.4987, 0.9798], 3.0069, false)]
526    #[case::oblate_near_spherical_overlap([1.1498, 0.6598], [1.4868, 1.4980], [2.3453, 0.9196], 3.0069, true)]
527    #[case::very_oblate_modest_gap([1.9115, 0.5322], [1.8442, 1.3827], [-0.5431, -2.2782], 2.6297, false)]
528    #[case::very_oblate_modest_overlap([1.9115, 0.5322], [1.8442, 1.3827], [-0.4628, -1.9416], 2.6297, true)]
529    #[case::nearly_orthogonal_large_gap([0.7574, 1.5755], [0.6234, 1.5001], [2.0806, -1.1887], 1.6139, false)]
530    #[case::nearly_orthogonal_overlap([0.7574, 1.5755], [0.6234, 1.5001], [1.9758, -1.1289], 1.6139, true)]
531    #[case::nothing_special([0.6577, 1.0500], [1.4852, 1.0473], [-0.3930, -2.2628], 0.5831, false)]
532    #[case::nothing_special_overlaps([0.6577, 1.0500], [1.4852, 1.0473], [-0.3812, -2.1947], 0.5831, true)]
533    #[case::quite_close([0.6166, 1.4486], [1.4183, 0.9930], [-1.7161, -1.0606], 2.6675, false)]
534    #[case::quite_close_overlaps([0.6166, 1.4486], [1.4183, 0.9930], [-1.6661, -1.0297], 2.6675, true)]
535    #[case::near_sphere_very_close([1.1526, 1.9197], [1.8130, 1.7356], [2.8098, -0.8908], 1.4848, false)]
536    #[case::near_sphere_overlaps_very_close([1.1526, 1.9197], [1.8130, 1.7356], [2.7958, -0.8864], 1.4848, true)]
537    #[case::near_matching_oblate_small_gap([1.4806, 0.6951], [0.6211, 1.8280], [2.5104, -0.5142], 0.6163, false)]
538    #[case::near_matching_oblate_overlaps([1.4806, 0.6951], [0.6211, 1.8280], [2.4964, -0.5114], 0.6163, true)]
539    #[case::size_disparity_very_small_gap([1.0641, 0.7902], [1.7608, 0.6606], [0.4238, 1.5632], 0.2742, false)]
540    #[case::size_disparity_overlaps([1.0641, 0.7902], [1.7608, 0.6606], [0.4206, 1.5515], 0.2742, true)]
541    #[case::very_skinny_nearly_orthogonal_very_close([0.2442, 7.1313], [9.5758, 0.9664], [-6.7470, 7.7818], 0.0056, false)]
542    #[case::very_skinny_nearly_orthogonal_very_close([0.2442, 7.1313], [9.5758, 0.9664], [-6.7367, 7.7700], 0.0056, true)]
543    #[case::very_skinny_very_close([6.7374, 0.2257], [6.6506, 1.9512], [-9.0568, -1.4038], -0.2483, false)]
544    #[case::very_skinny_overlap([6.7374, 0.2257], [6.6506, 1.9512], [-8.9931, -1.3939], -0.2483, false)]
545    #[case::nearly_orthogonal([4.1355, 7.7023], [1.0113, 7.0499], [-5.0217, 3.9708], -0.0012, false)]
546    #[case::nearly_orthogonal_overlaps([4.1355, 7.7023], [1.0113, 7.0499], [-5.0070, 3.9591], -0.0012, true)]
547    #[case::big_sphere_very_close([0.7540, 2.0375], [6.5678, 6.8224], [4.9170, 6.6840], 0.1307, false)]
548    #[case::big_sphere_overlap([0.7540, 2.0375], [6.5678, 6.8224], [4.9041, 6.6665], 0.1307, true)]
549    fn test_ellipse_overlap_known_cases(
550        #[case] e0: [f64; 2],
551        #[case] e1: [f64; 2],
552        #[case] v_ij: [f64; 2],
553        #[case] o_ij: f64,
554        #[case] does_overlap: bool,
555    ) {
556        let el0 = Ellipse::with_semi_axes([
557            e0[0].try_into().expect("test value is a positive real"),
558            e0[1].try_into().expect("test value is a positive real"),
559        ]);
560        let el1 = Ellipse::with_semi_axes([
561            e1[0].try_into().expect("test value is a positive real"),
562            e1[1].try_into().expect("test value is a positive real"),
563        ]);
564
565        assert_eq!(
566            el0.intersects_at(&el1, &v_ij.into(), &Angle::from(o_ij)),
567            does_overlap
568        );
569        assert_eq!(
570            Convex(el0).intersects_at(&Convex(el1), &v_ij.into(), &Angle::from(o_ij)),
571            does_overlap
572        );
573    }
574
575    #[rstest]
576    #[case::six_one_aligned([4.2952, -2.1351], -0.0880, false)]
577    #[case::six_one_aligned([4.2597, -2.1174], -0.0880, false)]
578    #[case::six_one_aligned([-1.9937, -2.1883], -0.2352, false)]
579    #[case::six_one_aligned([-1.9787, -2.1719], -0.2352, true)]
580    #[case::six_one_aligned([-0.7759, 2.0001], 0.0095, false)]
581    #[case::six_one_aligned([-0.7688, 1.9816], 0.0095, true)]
582    #[case::six_one_aligned([2.1407, 1.9930], -0.1539, true)]
583    #[case::six_one_aligned([6.4962, -1.7588], 0.0426, false)]
584    #[case::six_one_aligned([6.2927, -1.7037], 0.0426, false)]
585    #[case::six_one_aligned([2.0187, -3.0287], -0.3221, false)]
586    #[case::six_one_aligned([-5.9183, -1.3618], -0.2930, false)]
587    #[case::six_one_aligned([1.0047, -2.0353], 0.0426, false)]
588    #[case::six_one_aligned([0.9783, -1.9817], 0.0426, true)]
589    #[case::six_one_aligned([11.2492, 0.8228], 0.0199, false)]
590    #[case::six_one_aligned([11.0, 0.8208], 0.0199, true)]
591    #[case::tip_to_tail([-11.9985, -0.0165], 0.0085, false)]
592    #[case::tip_to_tail([-11.9864, -0.0165], 0.0085, true)]
593    fn test_ellipse_overlap_likely_cases(
594        #[case] v_ij: [f64; 2],
595        #[case] o_ij: f64,
596        #[case] does_overlap: bool,
597    ) {
598        let el0 = Ellipse::with_semi_axes([
599            6.0.try_into().expect("test value is a positive real"),
600            1.0.try_into().expect("test value is a positive real"),
601        ]);
602        let el1 = Ellipse::with_semi_axes([
603            6.0.try_into().expect("test value is a positive real"),
604            1.0.try_into().expect("test value is a positive real"),
605        ]);
606
607        assert_eq!(
608            el0.intersects_at(&el1, &Cartesian::from(v_ij), &Angle::from(o_ij)),
609            does_overlap
610        );
611        assert_eq!(
612            Convex(el0).intersects_at(&Convex(el1), &v_ij.into(), &Angle::from(o_ij)),
613            does_overlap
614        );
615    }
616
617    #[rstest]
618    fn test_random_sphere_ellipse_overlap() {
619        let mut rng = StdRng::seed_from_u64(2);
620        for _ in 0..100_000 {
621            let (a, c): (f64, f64) = StdRng::random(&mut rng);
622            let a = (a * 5.0).try_into().expect("test value is a positive real");
623            let c = (c * 5.0).try_into().expect("test value is a positive real");
624            let el0 = Ellipse::with_semi_axes([a, a]);
625            let el1 = Ellipse::with_semi_axes([c, c]);
626
627            let v_ij = StdRng::random::<Cartesian<2>>(&mut rng) * 10.0;
628            let angle = Angle::from(
629                rng.random_range((-2.0 * std::f64::consts::PI)..(2.0 * std::f64::consts::PI)),
630            );
631            assert_eq!(
632                el0.intersects_at(&el1, &v_ij, &angle),
633                Circle { radius: a }.intersects_at(&Circle { radius: c }, &v_ij, &angle),
634            );
635        }
636    }
637
638    #[rstest]
639    fn test_random_ellipsoids_overlap() {
640        // Xenocollide precision becomes an issue! So only a few tests are possible
641        // Inspecting failing cases in Ovito & HOOMD shows we are correct
642        let mut rng = StdRng::seed_from_u64(2);
643        for _ in 0..10 {
644            let (a, b, c, d): (f64, f64, f64, f64) = StdRng::random(&mut rng);
645            let a = a.try_into().expect("test value is a positive real");
646            let b = b.try_into().expect("test value is a positive real");
647            let c = c.try_into().expect("test value is a positive real");
648            let d = d.try_into().expect("test value is a positive real");
649
650            let el0 = Ellipse::with_semi_axes([a, b]);
651            let el1 = Ellipse::with_semi_axes([c, d]);
652
653            let v_ij = StdRng::random::<Cartesian<2>>(&mut rng) * 10.0;
654            let angle = Angle::from(
655                rng.random_range((-2.0 * std::f64::consts::PI)..(2.0 * std::f64::consts::PI)),
656            );
657            assert_eq!(
658                el0.intersects_at(&el1, &v_ij, &angle),
659                Convex(el0).intersects_at(&Convex(el1), &v_ij, &angle),
660                "(a,b,c,d)= ({}, {}, {}, {})\nangle= {angle}\nv_ij= {v_ij}",
661                a.get(),
662                b.get(),
663                c.get(),
664                d.get()
665            );
666        }
667    }
668}