From dbfd44a014c9f5bc8380e774473d711124d01b78 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:04:44 -0600 Subject: [PATCH] feat(core): generic bounds-safe Grid Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/grid.rs | 303 ++++++++++++++++++++++++++++++++++++++ reikhelm-core/src/lib.rs | 1 + 2 files changed, 304 insertions(+) create mode 100644 reikhelm-core/src/grid.rs diff --git a/reikhelm-core/src/grid.rs b/reikhelm-core/src/grid.rs new file mode 100644 index 0000000..6eb0352 --- /dev/null +++ b/reikhelm-core/src/grid.rs @@ -0,0 +1,303 @@ +//! A generic, bounds-safe 2D grid (spec §4.2). +//! +//! [`Grid`] is the workhorse storage type for the generator. It is the map +//! itself (`Grid`) and also the scratch buffers used during generation +//! (`Grid` region-id maps, `Grid` distance fields / noise). Cells are +//! stored in a flat [`Vec`] in row-major order (row 0 first, then row 1, …), +//! so a point `(x, y)` lives at index `y * width + x`. +//! +//! Every access is bounds-checked and total: out-of-bounds reads return +//! [`None`], out-of-bounds writes are silently ignored, and neighbor queries +//! yield only in-bounds points. No method panics on valid input (spec §8). + +use serde::{Deserialize, Serialize}; + +use crate::geometry::{Point, Rect}; + +/// A row-major 2D grid of `T`, addressed by integer [`Point`]s. +/// +/// The grid covers the half-open cell range `0..width` × `0..height`. Because +/// [`Point`] coordinates are signed, callers can compute candidate points that +/// fall outside the grid; the grid's accessors clip such points rather than +/// panicking. +/// +/// `Serialize`/`Deserialize` are available whenever `T` itself supports them, +/// so a `Grid` can round-trip through serde while a `Grid` +/// simply does not gain those impls. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Grid { + width: u32, + height: u32, + cells: Vec, +} + +impl Grid { + /// Creates a `width` × `height` grid with every cell initialized to `fill`. + /// + /// `fill` is cloned into each cell, so `T` must be [`Clone`]. A zero extent + /// in either dimension yields an empty grid (no cells). + pub fn new(width: u32, height: u32, fill: T) -> Self + where + T: Clone, + { + let len = width as usize * height as usize; + Grid { + width, + height, + cells: vec![fill; len], + } + } + + /// Creates a `width` × `height` grid, computing each cell from its point. + /// + /// `f` is called once per cell in row-major order with that cell's + /// coordinate. This is the natural constructor for derived grids (e.g. a + /// distance field or a checkerboard) where each value depends on position. + pub fn from_fn(width: u32, height: u32, f: impl Fn(Point) -> T) -> Self { + let mut cells = Vec::with_capacity(width as usize * height as usize); + for y in 0..height as i32 { + for x in 0..width as i32 { + cells.push(f(Point::new(x, y))); + } + } + Grid { + width, + height, + cells, + } + } + + /// Returns the grid width in cells. + pub fn width(&self) -> u32 { + self.width + } + + /// Returns the grid height in cells. + pub fn height(&self) -> u32 { + self.height + } + + /// Returns `true` if `p` lies within the grid. + /// + /// A point is in bounds when `0 <= x < width` and `0 <= y < height`. + pub fn in_bounds(&self, p: Point) -> bool { + p.x >= 0 && (p.x as u32) < self.width && p.y >= 0 && (p.y as u32) < self.height + } + + /// Maps an in-bounds point to its flat `cells` index, or [`None`] if OOB. + /// + /// Internal helper: every public accessor routes through this so the + /// row-major layout (`y * width + x`) lives in exactly one place. + fn index(&self, p: Point) -> Option { + if self.in_bounds(p) { + Some(p.y as usize * self.width as usize + p.x as usize) + } else { + None + } + } + + /// Returns a shared reference to the cell at `p`, or [`None`] if OOB. + pub fn get(&self, p: Point) -> Option<&T> { + self.index(p).map(|i| &self.cells[i]) + } + + /// Returns a mutable reference to the cell at `p`, or [`None`] if OOB. + pub fn get_mut(&mut self, p: Point) -> Option<&mut T> { + match self.index(p) { + Some(i) => Some(&mut self.cells[i]), + None => None, + } + } + + /// Writes `value` into the cell at `p`. + /// + /// Out-of-bounds writes are a no-op (never a panic), so passes can carve + /// freely without first re-checking bounds (spec §8). + pub fn set(&mut self, p: Point, value: T) { + if let Some(i) = self.index(p) { + self.cells[i] = value; + } + } + + /// Returns the in-bounds orthogonal neighbors (N, S, W, E) of `p`. + /// + /// Out-of-bounds neighbors are filtered out, so a corner cell yields two + /// neighbors and an edge cell three. Order follows [`Point::OFFSETS4`]. + pub fn neighbors4(&self, p: Point) -> impl Iterator + '_ { + p.neighbors4().into_iter().filter(|&n| self.in_bounds(n)) + } + + /// Returns the in-bounds 8-connected neighbors of `p`. + /// + /// Out-of-bounds neighbors are filtered out, so a corner cell yields three + /// neighbors. Order follows [`Point::OFFSETS8`] (orthogonal then diagonal). + pub fn neighbors8(&self, p: Point) -> impl Iterator + '_ { + p.neighbors8().into_iter().filter(|&n| self.in_bounds(n)) + } + + /// Iterates over every cell as `(point, &value)` in row-major order. + /// + /// Visits exactly `width * height` cells, each once. + pub fn iter(&self) -> impl Iterator + '_ { + let width = self.width; + self.cells.iter().enumerate().map(move |(i, value)| { + let x = (i % width as usize) as i32; + let y = (i / width as usize) as i32; + (Point::new(x, y), value) + }) + } + + /// Iterates over the cells inside `rect` as `(point, &value)`. + /// + /// The rectangle is clipped to the grid, so points outside the grid are + /// never yielded even when `rect` extends past an edge. Iteration is + /// row-major within the (clipped) rectangle. An empty or fully-outside + /// rectangle yields nothing. + pub fn iter_rect(&self, rect: Rect) -> impl Iterator + '_ { + // Build the point range from `rect`'s (copied) fields rather than + // `rect.iter()`, whose returned iterator borrows the local `rect` and + // would dangle once this function returns. + let (left, right) = (rect.x, rect.right()); + let (top, bottom) = (rect.y, rect.bottom()); + (top..bottom) + .flat_map(move |y| (left..right).map(move |x| Point::new(x, y))) + .filter_map(move |p| self.get(p).map(|value| (p, value))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_set_round_trip_in_bounds() { + let mut g = Grid::new(4, 3, 0u32); + let p = Point::new(2, 1); + assert_eq!(g.get(p), Some(&0)); + g.set(p, 42); + assert_eq!(g.get(p), Some(&42)); + + // get_mut mutates in place. + *g.get_mut(p).unwrap() = 7; + assert_eq!(g.get(p), Some(&7)); + } + + #[test] + fn set_out_of_bounds_is_a_no_op_and_get_returns_none() { + let mut g = Grid::new(4, 3, 0u32); + // Each side, including negative coords (signed points can go OOB). + let oob = [ + Point::new(-1, 0), + Point::new(0, -1), + Point::new(4, 0), // x == width (exclusive) + Point::new(0, 3), // y == height (exclusive) + ]; + for &p in &oob { + assert!(!g.in_bounds(p)); + assert_eq!(g.get(p), None); + assert!(g.get_mut(p).is_none()); + g.set(p, 999); // must not panic and must not touch any cell + } + // No in-bounds cell was disturbed by the OOB writes. + assert!(g.iter().all(|(_, &v)| v == 0)); + } + + #[test] + fn neighbors_at_corner_are_in_bounds_only() { + let g = Grid::new(5, 5, 0u8); + let corner = Point::new(0, 0); + + let n4: Vec = g.neighbors4(corner).collect(); + // Only (1,0) and (0,1) are in bounds at the top-left corner. + assert_eq!(n4.len(), 2); + assert!(n4.contains(&Point::new(1, 0))); + assert!(n4.contains(&Point::new(0, 1))); + + let n8: Vec = g.neighbors8(corner).collect(); + // A corner has exactly 3 in-bounds 8-connected neighbors. + assert_eq!(n8.len(), 3); + assert!(n8.contains(&Point::new(1, 0))); + assert!(n8.contains(&Point::new(0, 1))); + assert!(n8.contains(&Point::new(1, 1))); + // All neighbors are genuinely in bounds. + assert!(n8.iter().all(|&p| g.in_bounds(p))); + } + + #[test] + fn neighbors_in_interior_are_complete() { + let g = Grid::new(5, 5, 0u8); + let center = Point::new(2, 2); + assert_eq!(g.neighbors4(center).count(), 4); + assert_eq!(g.neighbors8(center).count(), 8); + } + + #[test] + fn iter_visits_every_cell_exactly_once() { + let g = Grid::new(4, 3, 0u32); + let visited: Vec = g.iter().map(|(p, _)| p).collect(); + + // Exactly width * height cells. + assert_eq!(visited.len(), 4 * 3); + + // Each in-bounds point appears exactly once. + for y in 0..3 { + for x in 0..4 { + let p = Point::new(x, y); + assert_eq!(visited.iter().filter(|&&q| q == p).count(), 1); + } + } + } + + #[test] + fn iter_is_row_major() { + let g = Grid::new(3, 2, 0u32); + let points: Vec = g.iter().map(|(p, _)| p).collect(); + assert_eq!( + points, + vec![ + Point::new(0, 0), + Point::new(1, 0), + Point::new(2, 0), + Point::new(0, 1), + Point::new(1, 1), + Point::new(2, 1), + ] + ); + } + + #[test] + fn iter_rect_is_clipped_to_grid_bounds() { + let g = Grid::new(4, 4, 0u32); + // Rect spills off the bottom-right edge: x:2..6, y:2..6. + let rect = Rect::new(2, 2, 4, 4); + let points: Vec = g.iter_rect(rect).map(|(p, _)| p).collect(); + + // Only the in-grid portion (x:2..4, y:2..4) = 4 cells survives. + assert_eq!(points.len(), 4); + assert!(points.iter().all(|&p| g.in_bounds(p))); + assert!(points.contains(&Point::new(2, 2))); + assert!(points.contains(&Point::new(3, 3))); + // Out-of-bounds cells are never yielded. + assert!(!points.contains(&Point::new(4, 4))); + } + + #[test] + fn iter_rect_fully_outside_yields_nothing() { + let g = Grid::new(4, 4, 0u32); + let rect = Rect::new(10, 10, 3, 3); + assert_eq!(g.iter_rect(rect).count(), 0); + } + + #[test] + fn from_fn_matches_closure_at_sampled_points() { + // Encode the coordinate so each cell has a distinct, checkable value. + let g = Grid::from_fn(5, 4, |p| (p.y * 100 + p.x) as u32); + assert_eq!(g.width(), 5); + assert_eq!(g.height(), 4); + + for &p in &[Point::new(0, 0), Point::new(4, 3), Point::new(2, 1)] { + let expected = (p.y * 100 + p.x) as u32; + assert_eq!(g.get(p), Some(&expected)); + } + } +} diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index af07bcf..a4dfaae 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -4,3 +4,4 @@ //! dependencies. Modules are added by later tasks. pub mod geometry; +pub mod grid;