1use 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#[serde_as]
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
55pub struct Hyperellipsoid<const N: usize> {
56 #[serde_as(as = "[_; N]")]
58 semi_axes: [PositiveReal; N],
59
60 bounding_sphere_radius: PositiveReal,
62}
63
64impl<const N: usize> Hyperellipsoid<N> {
65 #[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 #[must_use]
89 #[inline]
90 pub fn semi_axes(&self) -> &[PositiveReal; N] {
91 &self.semi_axes
92 }
93}
94
95pub type Ellipse = Hyperellipsoid<2>;
144
145pub 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 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 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 let p0 = c0; let p1 = c3 + c2 + c1 + c0; if p0 * p1 <= 0.0 {
345 return false;
346 }
347
348 if (p0 < 0.0) && (p1 < 0.0) {
350 return false;
351 }
352
353 let b1 = c0 + c1 / 3.0;
356 let b2 = c0 + f64::mul_add(2.0, c1, c2) / 3.0;
357
358 if p0 > 0.0 {
362 if b1 > 0.0 && b2 > 0.0 {
364 return true;
365 }
366 } else {
367 if b1 < 0.0 && b2 < 0.0 {
369 return true;
370 }
371 }
372
373 if c3.abs() < 1e-15 {
377 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 }
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 #[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 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}