diff --git a/CHANGELOG.md b/CHANGELOG.md index d975d06..bc2897f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Versioning]. [Keep a Changelog]: https://keepachangelog.com/en/1.1.0 [Semantic Versioning]: https://semver.org/spec/v2.0.0.html +## [Unreleased] + +### Added + +- Iterate straight coordinate lines with `HexCoord::line_to`. + ## [0.2.0] - 2026-04-26 ### Added diff --git a/src/coord.rs b/src/coord.rs index dcc3fd7..8bfccd7 100644 --- a/src/coord.rs +++ b/src/coord.rs @@ -53,6 +53,25 @@ impl HexCoord { -self.q - self.r } + #[inline] + pub(crate) fn round_hexspace(q: f32, r: f32, s: f32) -> Self { + let rounded_q = q.round(); + let rounded_r = r.round(); + let rounded_s = s.round(); + + let diff_q = (rounded_q - q).abs(); + let diff_r = (rounded_r - r).abs(); + let diff_s = (rounded_s - s).abs(); + + if diff_q > diff_r && diff_q > diff_s { + Self::from_rs(rounded_r as i32, rounded_s as i32) + } else if diff_r > diff_s { + Self::from_qs(rounded_q as i32, rounded_s as i32) + } else { + Self::new(rounded_q as i32, rounded_r as i32) + } + } + #[inline(always)] const fn add(&self, rhs: Self) -> Self { Self::new(self.q + rhs.q, self.r + rhs.r) @@ -407,6 +426,14 @@ mod tests { ); } + #[test] + fn hexspace_rounding_preserves_exact_coords(coord in coord_strategy()) { + prop_assert_eq!( + HexCoord::round_hexspace(coord.q as f32, coord.r as f32, coord.s() as f32), + coord, + ); + } + #[test] fn steps_to_is_reflexive_and_symmetric(a in coord_strategy(), b in coord_strategy()) { prop_assert_eq!(a.steps_to(a), 0); diff --git a/src/iter.rs b/src/iter.rs index 9a4cdd9..255478f 100644 --- a/src/iter.rs +++ b/src/iter.rs @@ -83,6 +83,44 @@ impl Iterator for HexRange { } } +/// Iterator over the straight-line interpolation between two hex coordinates. +/// +/// The line includes both endpoints. Coordinates are interpolated in fractional +/// cube/hexspace and rounded back to the nearest canonical coordinate. +pub struct HexLine { + steps_total: usize, + steps_taken: usize, + start: HexCoord, + end: HexCoord, +} + +impl HexLine { + const EPSILON: f32 = 0.000006; +} + +impl Iterator for HexLine { + type Item = HexCoord; + + fn next(&mut self) -> Option { + if self.steps_total < self.steps_taken { + return None; + } + + let t = if self.steps_total == 0 { + 0.0 + } else { + self.steps_taken as f32 / self.steps_total as f32 + }; + + let q = self.start.q as f32 * (1.0 - t) + self.end.q as f32 * t + Self::EPSILON; + let r = self.start.r as f32 * (1.0 - t) + self.end.r as f32 * t + Self::EPSILON; + let s = -q - r; + + self.steps_taken += 1; + Some(HexCoord::round_hexspace(q, r, s)) + } +} + /// Iterator over the boundary edges of a coordinate range. /// /// This walks the perimeter of `center.range(distance)` in clockwise order. @@ -184,6 +222,20 @@ impl HexCoord { } } + /// Returns an iterator over the straight-line interpolation from this coord + /// to `other`. + /// + /// The line includes both endpoints, so `coord.line_to(coord)` yields + /// exactly `coord`. + pub fn line_to(&self, other: HexCoord) -> HexLine { + HexLine { + steps_total: self.steps_to(other), + steps_taken: 0, + start: *self, + end: other, + } + } + /// Returns the boundary edges of [`Self::range`] for distance `n`. /// /// Edges are yielded clockwise from the top-right side of the range. This @@ -320,6 +372,21 @@ mod tests { ); } + #[test] + fn zero_length_line_is_the_start_coord() { + let center = HexCoord::new(3, -2); + + assert_eq!(center.line_to(center).collect::>(), vec![center]); + } + + #[test] + fn unit_line_contains_both_neighbors() { + let start = HexCoord::ZERO; + let end = start.top_right(); + + assert_eq!(start.line_to(end).collect::>(), vec![start, end]); + } + proptest! { #[test] fn rings_have_expected_distance_count_and_uniqueness( @@ -396,5 +463,23 @@ mod tests { prop_assert!((1..=2).contains(&inside)); } } + + #[test] + fn lines_include_endpoints_and_have_shortest_path_length( + start in coord_strategy(), + end in coord_strategy(), + ) { + let line = start.line_to(end).collect::>(); + let unique = line.iter().copied().collect::>(); + + prop_assert_eq!(line.len(), start.steps_to(end) + 1); + prop_assert_eq!(line.first(), Some(&start)); + prop_assert_eq!(line.last(), Some(&end)); + prop_assert_eq!(unique.len(), line.len()); + + for pair in line.windows(2) { + prop_assert_eq!(pair[0].steps_to(pair[1]), 1); + } + } } } diff --git a/src/lib.rs b/src/lib.rs index e8a381c..5af4443 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,7 +61,7 @@ mod worldspace; pub use coord::HexCoord; pub use edge::{HexEdge, HexEdgeDiscriminator, HexEdgeImpl, HexEdgeSrc}; -pub use iter::{HexEdgeRing, HexRange, HexRing, HexVertRing}; +pub use iter::{HexEdgeRing, HexLine, HexRange, HexRing, HexVertRing}; pub use plugin::HexCoordsPlugin; pub use vert::{HexVert, HexVertDiscriminator, HexVertImpl, HexVertSrc}; pub use worldspace::{HexUnitSize, WorldspaceSrc}; @@ -70,7 +70,7 @@ pub use worldspace::{HexUnitSize, WorldspaceSrc}; pub mod prelude { pub use crate::{ HexCoord, HexCoordsPlugin, HexEdge, HexEdgeDiscriminator, HexEdgeImpl, HexEdgeRing, - HexEdgeSrc, HexRange, HexRing, HexUnitSize, HexVert, HexVertDiscriminator, HexVertImpl, - HexVertRing, HexVertSrc, WorldspaceSrc, + HexEdgeSrc, HexLine, HexRange, HexRing, HexUnitSize, HexVert, HexVertDiscriminator, + HexVertImpl, HexVertRing, HexVertSrc, WorldspaceSrc, }; } diff --git a/src/worldspace.rs b/src/worldspace.rs index 2bd790e..9ec939a 100644 --- a/src/worldspace.rs +++ b/src/worldspace.rs @@ -175,21 +175,7 @@ impl HexCoord { let exact_r = 2.0 * y / SQRT_3; let exact_s = -exact_q - exact_r; - let q = exact_q.round(); - let r = exact_r.round(); - let s = exact_s.round(); - - let diff_q = (q - exact_q).abs(); - let diff_r = (r - exact_r).abs(); - let diff_s = (s - exact_s).abs(); - - if diff_q > diff_r && diff_q > diff_s { - Self::from_rs(r as i32, s as i32) - } else if diff_r > diff_s { - Self::from_qs(q as i32, s as i32) - } else { - Self::new(q as i32, r as i32) - } + Self::round_hexspace(exact_q, exact_r, exact_s) } }