feat(core): geometry primitives (Point, Rect, Line)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:02:36 -06:00
parent 08e99e38a3
commit f707bd13be
2 changed files with 415 additions and 0 deletions

View file

@ -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<Item = Point> + '_ {
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<Item = Point> {
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<Point> = 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<Point> = line.cells().collect();
let expected: Vec<Point> = (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<Point> = line.cells().collect();
let expected: Vec<Point> = (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<Point> = line.cells().collect();
let expected: Vec<Point> = (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<Point> = 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<Point> = 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);
}
}
}

View file

@ -2,3 +2,5 @@
//! //!
//! This crate holds the pure generation logic with no rendering or GUI //! This crate holds the pure generation logic with no rendering or GUI
//! dependencies. Modules are added by later tasks. //! dependencies. Modules are added by later tasks.
pub mod geometry;