From f707bd13be3d3d8ec941a1f558030c7a9b302236 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:02:36 -0600 Subject: [PATCH] feat(core): geometry primitives (Point, Rect, Line) Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/geometry.rs | 413 ++++++++++++++++++++++++++++++++++ reikhelm-core/src/lib.rs | 2 + 2 files changed, 415 insertions(+) create mode 100644 reikhelm-core/src/geometry.rs diff --git a/reikhelm-core/src/geometry.rs b/reikhelm-core/src/geometry.rs new file mode 100644 index 0000000..d250d68 --- /dev/null +++ b/reikhelm-core/src/geometry.rs @@ -0,0 +1,413 @@ +//! Integer grid-coordinate geometry (spec ยง4.1). +//! +//! These are the foundational value types reused by the grid, regions, and +//! every generation pass. They are deliberately small, `Copy`, and total: no +//! method panics on valid input. All types derive the standard comparison and +//! serialization traits so that a [`crate::map`]-level value can round-trip +//! through serde. + +use serde::{Deserialize, Serialize}; + +/// A single cell coordinate on the integer grid. +/// +/// Coordinates are signed so that intermediate arithmetic (offsets, inflation) +/// can produce out-of-bounds values without wrapping; bounds checking is the +/// grid's responsibility, not the point's. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Point { + /// Horizontal coordinate (column), increasing rightward. + pub x: i32, + /// Vertical coordinate (row), increasing downward. + pub y: i32, +} + +impl Point { + /// The four orthogonal neighbor offsets (N, S, W, E), in a stable order. + /// + /// Order is fixed for determinism: anything iterating neighbors visits them + /// the same way on every machine and every run. + pub const OFFSETS4: [Point; 4] = [ + Point { x: 0, y: -1 }, + Point { x: 0, y: 1 }, + Point { x: -1, y: 0 }, + Point { x: 1, y: 0 }, + ]; + + /// The eight neighbor offsets (orthogonal then diagonal), in a stable order. + pub const OFFSETS8: [Point; 8] = [ + Point { x: 0, y: -1 }, + Point { x: 0, y: 1 }, + Point { x: -1, y: 0 }, + Point { x: 1, y: 0 }, + Point { x: -1, y: -1 }, + Point { x: 1, y: -1 }, + Point { x: -1, y: 1 }, + Point { x: 1, y: 1 }, + ]; + + /// Creates a new point at `(x, y)`. + pub const fn new(x: i32, y: i32) -> Self { + Point { x, y } + } + + /// Returns the Manhattan (L1) distance between `self` and `other`. + /// + /// This is symmetric: `a.manhattan(b) == b.manhattan(a)`. + pub fn manhattan(&self, other: Point) -> i32 { + (self.x - other.x).abs() + (self.y - other.y).abs() + } + + /// Returns a copy of this point translated by `(dx, dy)`. + pub const fn offset(&self, dx: i32, dy: i32) -> Point { + Point { + x: self.x + dx, + y: self.y + dy, + } + } + + /// Returns the four orthogonally adjacent points (N, S, W, E). + /// + /// Bounds are not consulted here โ€” callers that need in-bounds neighbors + /// filter through the grid. Order matches [`Point::OFFSETS4`]. + pub fn neighbors4(&self) -> [Point; 4] { + Point::OFFSETS4.map(|o| self.offset(o.x, o.y)) + } + + /// Returns the eight surrounding points (orthogonal then diagonal). + /// + /// Order matches [`Point::OFFSETS8`]. + pub fn neighbors8(&self) -> [Point; 8] { + Point::OFFSETS8.map(|o| self.offset(o.x, o.y)) + } +} + +/// An axis-aligned rectangle of grid cells with integer origin and size. +/// +/// `(x, y)` is the top-left (minimum) corner; `w`/`h` are the extents in cells. +/// A rectangle with `w <= 0` or `h <= 0` is empty: it contains no cells and +/// intersects nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Rect { + /// Left edge (minimum x). + pub x: i32, + /// Top edge (minimum y). + pub y: i32, + /// Width in cells. + pub w: i32, + /// Height in cells. + pub h: i32, +} + +impl Rect { + /// Creates a rectangle with top-left corner `(x, y)` and size `w` x `h`. + pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Self { + Rect { x, y, w, h } + } + + /// The exclusive right edge (`x + w`): one past the last contained column. + pub const fn right(&self) -> i32 { + self.x + self.w + } + + /// The exclusive bottom edge (`y + h`): one past the last contained row. + pub const fn bottom(&self) -> i32 { + self.y + self.h + } + + /// Returns `true` if the rectangle contains no cells (non-positive extent). + pub const fn is_empty(&self) -> bool { + self.w <= 0 || self.h <= 0 + } + + /// Returns the center cell of the rectangle. + /// + /// Uses integer division, so for even extents the center is biased toward + /// the top-left. For any non-empty rect this is an interior (contained) + /// cell. + pub const fn center(&self) -> Point { + Point::new(self.x + self.w / 2, self.y + self.h / 2) + } + + /// Returns `true` if `p` lies within the rectangle (corners inclusive). + pub const fn contains(&self, p: Point) -> bool { + p.x >= self.x && p.x < self.right() && p.y >= self.y && p.y < self.bottom() + } + + /// Returns `true` if `self` and `other` share at least one cell. + /// + /// This is symmetric. Rectangles that merely touch edge-to-edge (one's + /// right edge equal to the other's left edge) do **not** intersect, since + /// they share no cell. Empty rectangles intersect nothing. + pub const fn intersects(&self, other: &Rect) -> bool { + if self.is_empty() || other.is_empty() { + return false; + } + self.x < other.right() + && other.x < self.right() + && self.y < other.bottom() + && other.y < self.bottom() + } + + /// Returns a copy grown by `by` cells on every side. + /// + /// A negative `by` shrinks (deflates) the rectangle; if it shrinks past + /// zero, the resulting extents may be non-positive (an empty rect). + pub const fn inflate(&self, by: i32) -> Rect { + Rect { + x: self.x - by, + y: self.y - by, + w: self.w + 2 * by, + h: self.h + 2 * by, + } + } + + /// Iterates over every contained cell in row-major order (rows top to + /// bottom, columns left to right within each row). + /// + /// Yields exactly `w * h` distinct points for a non-empty rect, and nothing + /// for an empty one. Row-major order is fixed for determinism. + pub fn iter(&self) -> impl Iterator + '_ { + let xs = self.x..self.right(); + (self.y..self.bottom()).flat_map(move |y| xs.clone().map(move |x| Point::new(x, y))) + } +} + +/// A segment between two grid cells, used for carving corridors. +/// +/// `cells()` walks the grid run from `from` to `to`. v1 only needs straight +/// orthogonal runs (horizontal or vertical), which is what corridors are built +/// from; the iterator documents its behavior for the diagonal/general case +/// below. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Line { + /// Start cell (inclusive). + pub from: Point, + /// End cell (inclusive). + pub to: Point, +} + +impl Line { + /// Creates a line segment from `from` to `to`. + pub const fn new(from: Point, to: Point) -> Self { + Line { from, to } + } + + /// Iterates the grid cells along the segment, inclusive of both endpoints. + /// + /// For a horizontal or vertical line this yields a contiguous run of cells + /// (the case v1 corridors rely on). For a sloped line it walks one cell per + /// step along the longer axis, stepping the shorter axis proportionally โ€” + /// the cells form a connected staircase but are not guaranteed to be a + /// "thin" Bresenham line; this suffices for corridor carving where any + /// connected run between the endpoints is acceptable. The endpoints are + /// always the first and last cells yielded. + pub fn cells(&self) -> impl Iterator { + let (from, to) = (self.from, self.to); + let dx = to.x - from.x; + let dy = to.y - from.y; + let steps = dx.abs().max(dy.abs()); + + (0..=steps).map(move |i| { + if steps == 0 { + // Degenerate line: from == to, a single cell. + from + } else { + // Linearly interpolate, rounding to the nearest cell. Rounding + // (rather than truncating) keeps the run symmetric end-to-end. + let x = from.x + (dx * i + sign(dx) * steps / 2) / steps; + let y = from.y + (dy * i + sign(dy) * steps / 2) / steps; + Point::new(x, y) + } + }) + } +} + +/// Returns -1, 0, or 1 matching the sign of `n`. +const fn sign(n: i32) -> i32 { + match n { + n if n > 0 => 1, + n if n < 0 => -1, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_manhattan_is_correct_and_symmetric() { + let a = Point::new(1, 2); + let b = Point::new(4, -2); + // |4-1| + |-2-2| = 3 + 4 = 7 + assert_eq!(a.manhattan(b), 7); + assert_eq!(a.manhattan(b), b.manhattan(a)); + // Distance to self is zero. + assert_eq!(a.manhattan(a), 0); + } + + #[test] + fn point_neighbors_offset_correctly() { + let p = Point::new(5, 5); + let n4 = p.neighbors4(); + assert!(n4.contains(&Point::new(5, 4))); + assert!(n4.contains(&Point::new(5, 6))); + assert!(n4.contains(&Point::new(4, 5))); + assert!(n4.contains(&Point::new(6, 5))); + + let n8 = p.neighbors8(); + assert_eq!(n8.len(), 8); + // All eight are distinct and none is the point itself. + assert!(!n8.contains(&p)); + assert!(n8.contains(&Point::new(4, 4))); + assert!(n8.contains(&Point::new(6, 6))); + } + + #[test] + fn rect_contains_corners_interior_and_rejects_outside() { + let r = Rect::new(2, 3, 4, 5); // x:2..6, y:3..8 + // Corners (inclusive). + assert!(r.contains(Point::new(2, 3))); // top-left + assert!(r.contains(Point::new(5, 3))); // top-right + assert!(r.contains(Point::new(2, 7))); // bottom-left + assert!(r.contains(Point::new(5, 7))); // bottom-right + // Interior. + assert!(r.contains(Point::new(3, 5))); + // Just outside on each side. + assert!(!r.contains(Point::new(1, 5))); // left of + assert!(!r.contains(Point::new(6, 5))); // right of (exclusive edge) + assert!(!r.contains(Point::new(3, 2))); // above + assert!(!r.contains(Point::new(3, 8))); // below (exclusive edge) + } + + #[test] + fn rect_intersects_is_symmetric_for_overlap() { + let a = Rect::new(0, 0, 4, 4); + let b = Rect::new(2, 2, 4, 4); // overlaps a in the 2..4 square + assert!(a.intersects(&b)); + assert!(b.intersects(&a)); // symmetric + } + + #[test] + fn rect_intersects_false_for_adjacent_disjoint() { + let a = Rect::new(0, 0, 4, 4); // x:0..4 + let b = Rect::new(4, 0, 4, 4); // x:4..8 โ€” touches a's right edge, shares no cell + assert!(!a.intersects(&b)); + assert!(!b.intersects(&a)); // symmetric + + // Fully separated rects also do not intersect. + let c = Rect::new(100, 100, 2, 2); + assert!(!a.intersects(&c)); + assert!(!c.intersects(&a)); + } + + #[test] + fn rect_intersects_handles_empty_rects() { + let a = Rect::new(0, 0, 4, 4); + let empty = Rect::new(1, 1, 0, 5); + assert!(!a.intersects(&empty)); + assert!(!empty.intersects(&a)); + } + + #[test] + fn rect_iter_yields_exactly_w_times_h_distinct_contained_points() { + let r = Rect::new(2, 3, 4, 5); + let points: Vec = r.iter().collect(); + + // Exactly w * h points. + assert_eq!(points.len() as i32, r.w * r.h); + + // All distinct. + let mut unique = points.clone(); + unique.sort_by_key(|p| (p.y, p.x)); + unique.dedup(); + assert_eq!(unique.len(), points.len()); + + // All contained. + assert!(points.iter().all(|&p| r.contains(p))); + } + + #[test] + fn rect_iter_empty_yields_nothing() { + let r = Rect::new(0, 0, 0, 5); + assert_eq!(r.iter().count(), 0); + let r = Rect::new(0, 0, 5, -1); + assert_eq!(r.iter().count(), 0); + } + + #[test] + fn rect_center_is_an_interior_point() { + let r = Rect::new(2, 3, 4, 5); + let c = r.center(); + assert!(r.contains(c)); + + // Odd extents center exactly. + let odd = Rect::new(0, 0, 5, 5); + assert_eq!(odd.center(), Point::new(2, 2)); + assert!(odd.contains(odd.center())); + } + + #[test] + fn rect_inflate_grows_and_deflate_shrinks() { + let r = Rect::new(5, 5, 4, 4); + let bigger = r.inflate(2); + assert_eq!(bigger, Rect::new(3, 3, 8, 8)); + // The original rect sits inside the inflated one. + assert!(r.iter().all(|p| bigger.contains(p))); + + let smaller = r.inflate(-1); + assert_eq!(smaller, Rect::new(6, 6, 2, 2)); + } + + #[test] + fn line_cells_horizontal_is_contiguous_inclusive() { + let line = Line::new(Point::new(2, 5), Point::new(6, 5)); + let cells: Vec = line.cells().collect(); + let expected: Vec = (2..=6).map(|x| Point::new(x, 5)).collect(); + assert_eq!(cells, expected); + // First and last are the endpoints. + assert_eq!(*cells.first().unwrap(), line.from); + assert_eq!(*cells.last().unwrap(), line.to); + } + + #[test] + fn line_cells_vertical_is_contiguous_inclusive() { + let line = Line::new(Point::new(3, 1), Point::new(3, 4)); + let cells: Vec = line.cells().collect(); + let expected: Vec = (1..=4).map(|y| Point::new(3, y)).collect(); + assert_eq!(cells, expected); + assert_eq!(*cells.first().unwrap(), line.from); + assert_eq!(*cells.last().unwrap(), line.to); + } + + #[test] + fn line_cells_reversed_run_is_inclusive() { + // Walking right-to-left still includes both endpoints in order. + let line = Line::new(Point::new(6, 5), Point::new(2, 5)); + let cells: Vec = line.cells().collect(); + let expected: Vec = (2..=6).rev().map(|x| Point::new(x, 5)).collect(); + assert_eq!(cells, expected); + } + + #[test] + fn line_cells_single_point() { + let line = Line::new(Point::new(7, 7), Point::new(7, 7)); + let cells: Vec = line.cells().collect(); + assert_eq!(cells, vec![Point::new(7, 7)]); + } + + #[test] + fn line_cells_diagonal_is_connected_and_inclusive() { + // A 45-degree run: one cell per step, endpoints included. + let line = Line::new(Point::new(0, 0), Point::new(3, 3)); + let cells: Vec = line.cells().collect(); + assert_eq!(*cells.first().unwrap(), line.from); + assert_eq!(*cells.last().unwrap(), line.to); + assert_eq!(cells.len(), 4); + // Consecutive cells differ by at most one in each axis (connected). + for w in cells.windows(2) { + assert!((w[0].x - w[1].x).abs() <= 1); + assert!((w[0].y - w[1].y).abs() <= 1); + } + } +} diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index e44e64c..af07bcf 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -2,3 +2,5 @@ //! //! This crate holds the pure generation logic with no rendering or GUI //! dependencies. Modules are added by later tasks. + +pub mod geometry;