Repository: ManevilleF/hexx (rust)

Issue:
Add function to compute (rounded) center of group of hexagons
This is useful when working with e.g. copy-paste functionality.

The code for this not trivial, especially if you want to avoid accidental overflows.

Pull request description:
Iterator tools
> Closes #17 

# Work done

- [x] `Hex` implements `Sum`
- [x] center of `Hex` iterator
- [x] mean of `Hex` iterator

## Extra

- [x] Adds ring pre-computation

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3789fa9..4cf7a2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,12 @@
 * Added `lerp` method to `Hex`
 * Added `rounding_div` method to `Hex` to perform floating point division rounded to the closest coordinate
 * Added `rounding_mul` method to `Hex` to perform floating point multiplication rounded to the closest coordinate
+* Added `cached_rings` and `cached_custom_rings` to `Hex` for rings pre-computation
+
+### Iterator extensions
+
+* Added `average` method for `Hex` iterators to compute their mean value
+* Added `center` method for `Hex` iterators to compute their centroid value
 
 ### Conversions
 
@@ -43,7 +49,10 @@
 * Implemented `RemAssign<Hex>` for `Hex`
 * Implemented `Rem<i32>` for `Hex`
 * Implemented `RemAssign<i32>` for `Hex`
+* Implemented `Sum` for `Hex`
 * `Hex::neighbor` is now `const`
+* `Hex::distance_to` is now `const`
+* `Hex::unsigned_distance_to` is now `const`
 
 ## 0.2.0
 
diff --git a/src/hex/convert.rs b/src/hex/convert.rs
new file mode 100644
index 0000000..a96f20c
--- /dev/null
+++ b/src/hex/convert.rs
@@ -0,0 +1,58 @@
+use super::Hex;
+use glam::{IVec2, IVec3, Vec2};
+
+impl From<(i32, i32)> for Hex {
+    #[inline]
+    fn from((x, y): (i32, i32)) -> Self {
+        Self { x, y }
+    }
+}
+
+impl From<[i32; 2]> for Hex {
+    #[inline]
+    fn from([x, y]: [i32; 2]) -> Self {
+        Self { x, y }
+    }
+}
+
+impl From<(f32, f32)> for Hex {
+    #[inline]
+    fn from(v: (f32, f32)) -> Self {
+        Self::round(v)
+    }
+}
+
+impl From<[f32; 2]> for Hex {
+    #[inline]
+    fn from([x, y]: [f32; 2]) -> Self {
+        Self::round((x, y))
+    }
+}
+
+impl From<Hex> for IVec2 {
+    #[inline]
+    fn from(hex: Hex) -> Self {
+        hex.as_ivec2()
+    }
+}
+
+impl From<Vec2> for Hex {
+    #[inline]
+    fn from(value: Vec2) -> Self {
+        Self::round((value.x, value.y))
+    }
+}
+
+impl From<Hex> for IVec3 {
+    #[inline]
+    fn from(hex: Hex) -> Self {
+        hex.as_ivec3()
+    }
+}
+
+impl From<IVec2> for Hex {
+    #[inline]
+    fn from(v: IVec2) -> Self {
+        Self::new(v.x, v.y)
+    }
+}
diff --git a/src/hex/impls.rs b/src/hex/impls.rs
index e5d5483..012ced7 100644
--- a/src/hex/impls.rs
+++ b/src/hex/impls.rs
@@ -1,7 +1,7 @@
 use super::Hex;
-use glam::{IVec2, IVec3, Vec2};
-use std::ops::{
-    Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign,
+use std::{
+    iter::{Product, Sum},
+    ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign},
 };
 
 impl Add<Self> for Hex {
@@ -39,6 +39,18 @@ impl AddAssign<i32> for Hex {
     }
 }
 
+impl Sum for Hex {
+    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
+        iter.fold(Self::ZERO, |a, b| a + b)
+    }
+}
+
+impl<'a> Sum<&'a Self> for Hex {
+    fn sum<I: Iterator<Item = &'a Self>>(iter: I) -> Self {
+        iter.copied().sum()
+    }
+}
+
 impl Sub<Self> for Hex {
     type Output = Self;
 
@@ -128,6 +140,18 @@ impl MulAssign<f32> for Hex {
     }
 }
 
+impl Product for Hex {
+    fn product<I: Iterator<Item = Self>>(iter: I) -> Self {
+        iter.fold(Self::ZERO, |a, b| a * b)
+    }
+}
+
+impl<'a> Product<&'a Self> for Hex {
+    fn product<I: Iterator<Item = &'a Self>>(iter: I) -> Self {
+        iter.copied().product()
+    }
+}
+
 impl Div<Self> for Hex {
     type Output = Self;
 
@@ -229,58 +253,70 @@ impl Neg for Hex {
     }
 }
 
-impl From<(i32, i32)> for Hex {
-    #[inline]
-    fn from((x, y): (i32, i32)) -> Self {
-        Self { x, y }
-    }
-}
-
-impl From<[i32; 2]> for Hex {
-    #[inline]
-    fn from([x, y]: [i32; 2]) -> Self {
-        Self { x, y }
-    }
-}
-
-impl From<(f32, f32)> for Hex {
-    #[inline]
-    fn from(v: (f32, f32)) -> Self {
-        Self::round(v)
-    }
-}
-
-impl From<[f32; 2]> for Hex {
-    #[inline]
-    fn from([x, y]: [f32; 2]) -> Self {
-        Self::round((x, y))
+/// Extension trait for iterators of [`Hex`] to compute a mean (average) value
+pub trait MeanExt: Iterator {
+    /// Method which takes an iterator and generates `Self` from the elements by finding the mean
+    /// (average) value.
+    ///
+    /// This method will return [`Hex::ZERO`] on an empty iterator
+    ///
+    /// # Example
+    ///
+    /// ```rust
+    /// # use hexx::*;
+    /// let mean = Hex::ZERO.range(10).average();
+    /// ```
+    #[doc(alias = "mean")]
+    fn average(self) -> Hex;
+}
+
+/// Extension trait for iterators to [`Hex`] to compute a center value
+pub trait CenterExt: Iterator {
+    /// Method which takes an iterator and generates `Self` from the elements by finding the center
+    /// value.
+    ///
+    /// This method will return [`Hex::ZERO`] on an empty iterator
+    ///
+    /// # Example
+    ///
+    /// ```rust
+    /// # use hexx::*;
+    /// let center = Hex::ZERO.range(10).center();
+    /// ```
+    fn center(self) -> Hex;
+}
+
+impl<I: Iterator<Item = Hex>> MeanExt for I {
+    fn average(self) -> Hex {
+        let mut sum = Hex::ZERO;
+        let mut count = 0;
+
+        for hex in self {
+            count += 1;
+            sum += hex;
+        }
+        // Avoid division by zero
+        sum / count.max(1)
     }
 }
 
-impl From<Hex> for IVec2 {
-    #[inline]
-    fn from(hex: Hex) -> Self {
-        hex.as_ivec2()
-    }
-}
+impl<I: Iterator<Item = Hex>> CenterExt for I {
+    fn center(self) -> Hex {
+        let mut x_min = 0;
+        let mut x_max = 0;
+        let mut y_min = 0;
+        let mut y_max = 0;
 
-impl From<Vec2> for Hex {
-    #[inline]
-    fn from(value: Vec2) -> Self {
-        Self::round((value.x, value.y))
-    }
-}
+        for hex in self {
+            x_min = x_min.min(hex.x);
+            x_max = x_max.max(hex.x);
+            y_min = y_min.min(hex.y);
+            y_max = y_max.max(hex.y);
+        }
 
-impl From<Hex> for IVec3 {
-    #[inline]
-    fn from(hex: Hex) -> Self {
-        hex.as_ivec3()
-    }
-}
+        let x = (x_min + x_max) / 2;
+        let y = (y_min + y_max) / 2;
 
-impl From<IVec2> for Hex {
-    #[inline]
-    fn from(v: IVec2) -> Self {
-        Self::new(v.x, v.y)
+        Hex::new(x, y)
     }
 }
diff --git a/src/hex/mod.rs b/src/hex/mod.rs
index 041ce73..330215b 100644
--- a/src/hex/mod.rs
+++ b/src/hex/mod.rs
@@ -1,7 +1,12 @@
+/// Type conversions
+mod convert;
+/// Traits implementations
 mod impls;
 #[cfg(test)]
 mod tests;
 
+pub use impls::{CenterExt, MeanExt};
+
 use crate::Direction;
 use glam::{IVec2, IVec3, Vec2};
 use itertools::Itertools;
@@ -39,8 +44,6 @@ use std::cmp::{max, min};
 /// - [`Self::rounded_div`]
 /// - [`Self::rounded_mul`]
 ///
-/// or the [`Div<f32>`] and [`Mul<f32>`] for operations using the [`Hex::round`] method.
-///
 /// [comparison]: https://www.redblobgames.com/grids/hexagons/#coordinates-comparison
 /// [axial]: https://www.redblobgames.com/grids/hexagons/#coordinates-axial
 ///
@@ -54,6 +57,8 @@ pub struct Hex {
 }
 
 impl Hex {
+    /// (0, 0)
+    pub const ORIGIN: Self = Self::ZERO;
     /// (0, 0)
     pub const ZERO: Self = Self::new(0, 0);
     /// (1, 1)
@@ -213,6 +218,8 @@ impl Hex {
     /// Negates the coordinate, giving its reflection (symmetry) around the origin.
     ///
     /// [`Hex`] implements [`Neg`] (`-` operator) but this method is `const`.
+    ///
+    /// [`Neg`]: std::ops::Neg
     pub const fn const_neg(self) -> Self {
         Self {
             x: -self.x,
@@ -225,6 +232,8 @@ impl Hex {
     /// adds `self` and `other`.
     ///
     /// [`Hex`] implements [`Add`] (`+` operator) but this method is `const`.
+    ///
+    /// [`Add`]: std::ops::Add
     pub const fn const_add(self, other: Self) -> Self {
         Self {
             x: self.x + other.x,
@@ -234,13 +243,15 @@ impl Hex {
 
     #[inline]
     #[must_use]
-    /// substracts `self` and `other`.
+    /// substracts `self` and `rhs`.
     ///
     /// [`Hex`] implements [`Sub`] (`-` operator) but this method is `const`.
-    pub const fn const_sub(self, other: Self) -> Self {
+    ///
+    /// [`Sub`]: std::ops::Sub
+    pub const fn const_sub(self, rhs: Self) -> Self {
         Self {
-            x: self.x - other.x,
-            y: self.y - other.y,
+            x: self.x - rhs.x,
+            y: self.y - rhs.y,
         }
     }
 
@@ -298,20 +309,20 @@ impl Hex {
 
     #[inline]
     #[must_use]
-    /// Computes the distance from `self` to `other` in hexagonal space as a signed integer
+    /// Computes the distance from `self` to `rhs` in hexagonal space as a signed integer
     ///
     /// See [`Self::unsigned_distance_to`] for the unsigned version
-    pub fn distance_to(self, other: Self) -> i32 {
-        (self - other).length()
+    pub const fn distance_to(self, rhs: Self) -> i32 {
+        self.const_sub(rhs).length()
     }
 
     #[inline]
     #[must_use]
-    /// Computes the distance from `self` to `other` in hexagonal space as an unsigned integer
+    /// Computes the distance from `self` to `rhs` in hexagonal space as an unsigned integer
     ///
     /// See [`Self::distance_to`] for the signed version
-    pub fn unsigned_distance_to(self, other: Self) -> u32 {
-        (self - other).ulength()
+    pub const fn unsigned_distance_to(self, rhs: Self) -> u32 {
+        self.const_sub(rhs).ulength()
     }
 
     #[inline]
@@ -493,7 +504,6 @@ impl Hex {
     }
 
     #[must_use]
-    #[allow(clippy::cast_possible_wrap)]
     /// Retrieves one [`Hex`] ring around `self` in a given `range`.
     /// The returned coordinates start from [`Direction::TopRight`] and loop around `self` counter clockwise.
     ///
@@ -505,8 +515,77 @@ impl Hex {
         self.custom_ring(range, Direction::TopRight, false)
     }
 
+    #[allow(clippy::cast_possible_truncation)]
+    #[must_use]
+    /// Retrieves all successive [`Hex`] rings around `self` in a given `RANGE` as an array of
+    /// rings.
+    /// The returned rings start from [`Direction::TopRight`] and loop around `self` counter clockwise.
+    ///
+    /// If you only need the coordinates see [`Self::range`] or [`Self::spiral_range`].
+    ///
+    /// # Usage
+    ///
+    /// This function's objective is to pre-compute rings around a coordinate, the returned array
+    /// can be used as a cache to avoid extra computation.
+    ///
+    /// ## Example
+    ///
+    /// ```rust
+    /// # use hexx::*;
+    ///
+    /// // We cache 10 rings around the origin
+    /// let cache = Hex::ORIGIN.cached_rings::<10>();
+    /// // We have our target center
+    /// let target = Hex::new(11, 24);
+    /// // We retrieve the ring of range 5 and offset it to match the target
+    /// let five_ring = cache[5].iter().map(|h| *h + target);
+    /// ```
+    ///
+    /// See this [article](https://www.redblobgames.com/grids/hexagons/#rings-spiral) for more
+    /// information
+    pub fn cached_rings<const RANGE: usize>(self) -> [Vec<Self>; RANGE] {
+        std::array::from_fn(|r| self.ring(r as u32))
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    #[must_use]
+    /// Retrieves all successive [`Hex`] rings around `self` in a given `RANGE` as an array of
+    /// rings.
+    /// The returned rings start from `start_dir`] and loop around `self` counter clockwise unless
+    /// `clockwise` is set to `true`.
+    ///
+    /// See also [`Self::cached_rings`]
+    /// If you only need the coordinates see [`Self::range`] or [`Self::custom_spiral_range`].
+    ///
+    /// # Usage
+    ///
+    /// This function's objective is to pre-compute rings around a coordinate, the returned array
+    /// can be used as a cache to avoid extra computation.
+    ///
+    /// ## Example
+    ///
+    /// ```rust
+    /// # use hexx::*;
+    ///
+    /// // We cache 10 rings around the origin
+    /// let cache = Hex::ORIGIN.cached_custom_rings::<10>(Direction::Top, true);
+    /// // We have our target center
+    /// let target = Hex::new(11, 24);
+    /// // We retrieve the ring of range 5 and offset it to match the target
+    /// let five_ring = cache[5].iter().map(|h| *h + target);
+    /// ```
+    ///
+    /// See this [article](https://www.redblobgames.com/grids/hexagons/#rings-spiral) for more
+    /// information
+    pub fn cached_custom_rings<const RANGE: usize>(
+        self,
+        start_dir: Direction,
+        clockwise: bool,
+    ) -> [Vec<Self>; RANGE] {
+        std::array::from_fn(|r| self.custom_ring(r as u32, start_dir, clockwise))
+    }
+
     #[must_use]
-    #[allow(clippy::cast_sign_loss)]
     /// Retrieves all [`Hex`] around `self` in a given `range` but ordered as successive rings,
     /// starting from `start_dir` and looping counter clockwise unless `clockwise` is set to `true`, forming a spiral
     ///


Test patch:
diff --git a/src/hex/tests.rs b/src/hex/tests.rs
index f90ca30..0a98260 100644
--- a/src/hex/tests.rs
+++ b/src/hex/tests.rs
@@ -14,6 +14,44 @@ fn int_addition() {
     assert_eq!(Hex::ONE + 1, Hex::new(2, 2));
 }
 
+#[test]
+fn hex_sum() {
+    // zero sum
+    assert_eq!(Hex::ZERO.line_to(Hex::ZERO).sum::<Hex>(), Hex::ZERO);
+    // correct sum
+    assert_eq!(Hex::ZERO.line_to(Hex::X).sum::<Hex>(), Hex::X);
+    assert_eq!(Hex::ZERO.line_to(Hex::Y).sum::<Hex>(), Hex::Y);
+    assert_eq!(Hex::ZERO.line_to(Hex::ONE).sum::<Hex>(), Hex::new(1, 2));
+    assert_eq!(
+        Hex::ZERO.line_to(Hex::new(5, 0)).sum::<Hex>(),
+        Hex::new(15, 0)
+    );
+}
+
+#[test]
+#[allow(clippy::cast_possible_truncation)]
+#[allow(clippy::cast_possible_wrap)]
+fn hex_avg_center() {
+    let hexes = [
+        Hex::ONE,
+        Hex::new(5, -12),
+        Hex::new(15, 2),
+        Hex::new(-5, 24),
+        Hex::new(-1, 17),
+    ];
+    let mean = hexes.iter().sum::<Hex>() / hexes.len() as i32;
+    let center = Hex::new(10, 12) / 2;
+
+    assert_eq!(hexes.into_iter().average(), mean);
+    assert_eq!(hexes.into_iter().center(), center);
+    assert_ne!(center, mean);
+
+    for r in 0..30 {
+        assert_eq!(Hex::ZERO.range(r).average(), Hex::ZERO);
+        assert_eq!(Hex::ZERO.range(r).center(), Hex::ZERO);
+    }
+}
+
 #[test]
 fn hex_subtraction() {
     assert_eq!(Hex::ZERO - Hex::ZERO, Hex::ZERO);
@@ -299,6 +337,16 @@ fn ring() {
     }
 }
 
+#[test]
+#[allow(clippy::cast_possible_truncation)]
+fn cached_rings() {
+    let hex = Hex::ZERO;
+    let cache = hex.cached_rings::<10>();
+    for (r, ring) in cache.into_iter().enumerate() {
+        assert_eq!(ring, hex.ring(r as u32));
+    }
+}
+
 #[test]
 fn ring_offset() {
     let zero = Hex::ZERO;