feat(citygen): geometry primitives and Grid

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 16:40:04 +02:00
parent 76c07e2927
commit 698a6a13db
3 changed files with 347 additions and 0 deletions

318
crates/citygen/src/geom.rs Normal file
View File

@@ -0,0 +1,318 @@
//! Geometry primitives: positions, sizes, rectangles, directions and a dense grid.
use serde::{Deserialize, Serialize};
use std::ops::{Index, IndexMut};
/// A tile coordinate. Negative values are legal and simply out of bounds.
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
pub struct Pos {
pub x: i32,
pub y: i32,
}
impl Pos {
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
pub fn offset(self, dx: i32, dy: i32) -> Self {
Self::new(self.x + dx, self.y + dy)
}
pub fn step(self, d: Dir) -> Self {
let (dx, dy) = d.delta();
self.offset(dx, dy)
}
pub fn manhattan(self, o: Pos) -> i32 {
(self.x - o.x).abs() + (self.y - o.y).abs()
}
pub fn chebyshev(self, o: Pos) -> i32 {
(self.x - o.x).abs().max((self.y - o.y).abs())
}
pub fn dist2(self, o: Pos) -> i64 {
let dx = (self.x - o.x) as i64;
let dy = (self.y - o.y) as i64;
dx * dx + dy * dy
}
pub fn neighbors4(self) -> impl Iterator<Item = Pos> {
Dir::CARDINAL.into_iter().map(move |d| self.step(d))
}
pub fn neighbors8(self) -> impl Iterator<Item = Pos> {
Dir::ALL.into_iter().map(move |d| self.step(d))
}
}
/// Dimensions of a grid.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Size {
pub w: u32,
pub h: u32,
}
impl Size {
pub fn area(self) -> usize {
self.w as usize * self.h as usize
}
pub fn contains(self, p: Pos) -> bool {
p.x >= 0 && p.y >= 0 && (p.x as u32) < self.w && (p.y as u32) < self.h
}
pub fn index(self, p: Pos) -> usize {
p.y as usize * self.w as usize + p.x as usize
}
pub fn center(self) -> Pos {
Pos::new(self.w as i32 / 2, self.h as i32 / 2)
}
pub fn rect(self) -> Rect {
Rect::new(0, 0, self.w as i32, self.h as i32)
}
}
/// Axis-aligned rectangle; `right()` and `bottom()` are exclusive.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
Self {
x,
y,
w: if w < 0 { 0 } else { w },
h: if h < 0 { 0 } else { h },
}
}
pub fn right(self) -> i32 {
self.x + self.w
}
pub fn bottom(self) -> i32 {
self.y + self.h
}
pub fn is_empty(self) -> bool {
self.w == 0 || self.h == 0
}
pub fn area(self) -> usize {
self.w as usize * self.h as usize
}
pub fn contains(self, p: Pos) -> bool {
p.x >= self.x && p.y >= self.y && p.x < self.right() && p.y < self.bottom()
}
pub fn intersects(self, o: Rect) -> bool {
self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom()
}
pub fn intersection(self, o: Rect) -> Option<Rect> {
let x = self.x.max(o.x);
let y = self.y.max(o.y);
let r = self.right().min(o.right());
let b = self.bottom().min(o.bottom());
if r > x && b > y {
Some(Rect::new(x, y, r - x, b - y))
} else {
None
}
}
pub fn clamp_to(self, bounds: Rect) -> Option<Rect> {
self.intersection(bounds)
}
pub fn inset(self, n: i32) -> Rect {
Rect::new(self.x + n, self.y + n, self.w - 2 * n, self.h - 2 * n)
}
/// Grow by `n` on every side.
pub fn grow(self, n: i32) -> Rect {
self.inset(-n)
}
pub fn center(self) -> Pos {
Pos::new(self.x + self.w / 2, self.y + self.h / 2)
}
pub fn iter(self) -> impl Iterator<Item = Pos> {
(self.y..self.bottom())
.flat_map(move |y| (self.x..self.right()).map(move |x| Pos::new(x, y)))
}
pub fn border(self) -> impl Iterator<Item = Pos> {
self.iter().filter(move |p| {
p.x == self.x || p.y == self.y || p.x == self.right() - 1 || p.y == self.bottom() - 1
})
}
}
/// The eight compass directions.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Dir {
N,
NE,
E,
SE,
S,
SW,
W,
NW,
}
impl Dir {
pub const CARDINAL: [Dir; 4] = [Dir::N, Dir::E, Dir::S, Dir::W];
pub const ALL: [Dir; 8] = [
Dir::N,
Dir::NE,
Dir::E,
Dir::SE,
Dir::S,
Dir::SW,
Dir::W,
Dir::NW,
];
pub fn delta(self) -> (i32, i32) {
match self {
Dir::N => (0, -1),
Dir::NE => (1, -1),
Dir::E => (1, 0),
Dir::SE => (1, 1),
Dir::S => (0, 1),
Dir::SW => (-1, 1),
Dir::W => (-1, 0),
Dir::NW => (-1, -1),
}
}
pub fn opposite(self) -> Dir {
match self {
Dir::N => Dir::S,
Dir::NE => Dir::SW,
Dir::E => Dir::W,
Dir::SE => Dir::NW,
Dir::S => Dir::N,
Dir::SW => Dir::NE,
Dir::W => Dir::E,
Dir::NW => Dir::SE,
}
}
}
/// Dense row-major grid of `T`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grid<T> {
size: Size,
data: Vec<T>,
}
impl<T: Clone> Grid<T> {
pub fn filled(size: Size, v: T) -> Self {
Self {
size,
data: vec![v; size.area()],
}
}
pub fn size(&self) -> Size {
self.size
}
pub fn in_bounds(&self, p: Pos) -> bool {
self.size.contains(p)
}
pub fn get(&self, p: Pos) -> Option<&T> {
self.in_bounds(p).then(|| &self.data[self.size.index(p)])
}
pub fn get_mut(&mut self, p: Pos) -> Option<&mut T> {
if self.in_bounds(p) {
let i = self.size.index(p);
Some(&mut self.data[i])
} else {
None
}
}
/// Returns false when `p` is out of bounds.
pub fn set(&mut self, p: Pos, v: T) -> bool {
match self.get_mut(p) {
Some(slot) => {
*slot = v;
true
}
None => false,
}
}
pub fn iter(&self) -> impl Iterator<Item = (Pos, &T)> {
let w = self.size.w as i32;
self.data
.iter()
.enumerate()
.map(move |(i, t)| (Pos::new(i as i32 % w, i as i32 / w), t))
}
pub fn fill_rect(&mut self, r: Rect, v: T) {
for p in r.iter() {
self.set(p, v.clone());
}
}
pub fn as_slice(&self) -> &[T] {
&self.data
}
pub fn as_mut_slice(&mut self) -> &mut [T] {
&mut self.data
}
pub fn map<U: Clone>(&self, f: impl Fn(&T) -> U) -> Grid<U> {
Grid {
size: self.size,
data: self.data.iter().map(f).collect(),
}
}
pub fn count(&self, pred: impl Fn(&T) -> bool) -> usize {
self.data.iter().filter(|t| pred(t)).count()
}
}
impl<T: Clone> Index<Pos> for Grid<T> {
type Output = T;
fn index(&self, p: Pos) -> &T {
self.get(p).expect("grid index out of bounds")
}
}
impl<T: Clone> IndexMut<Pos> for Grid<T> {
fn index_mut(&mut self, p: Pos) -> &mut T {
self.get_mut(p).expect("grid index out of bounds")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rect_iter_and_border() {
let r = Rect::new(2, 3, 3, 2);
assert_eq!(r.area(), 6);
assert_eq!(r.iter().count(), 6);
assert_eq!(r.border().count(), 6);
assert!(r.contains(Pos::new(4, 4)));
assert!(!r.contains(Pos::new(5, 4)));
assert_eq!(r.inset(1), Rect::new(3, 4, 1, 0));
}
#[test]
fn rect_intersection_and_clamp() {
let a = Rect::new(0, 0, 10, 10);
let b = Rect::new(5, 5, 10, 10);
assert_eq!(a.intersection(b), Some(Rect::new(5, 5, 5, 5)));
assert_eq!(a.intersection(Rect::new(20, 20, 1, 1)), None);
assert_eq!(b.clamp_to(a), Some(Rect::new(5, 5, 5, 5)));
}
#[test]
fn grid_set_get_oob() {
let mut g = Grid::filled(Size { w: 4, h: 3 }, 0u8);
assert!(g.set(Pos::new(3, 2), 7));
assert!(!g.set(Pos::new(4, 2), 7));
assert_eq!(g.get(Pos::new(3, 2)), Some(&7));
assert_eq!(g.get(Pos::new(-1, 0)), None);
assert_eq!(g.iter().count(), 12);
g.fill_rect(Rect::new(0, 0, 2, 2), 9);
assert_eq!(g.count(|&v| v == 9), 4);
}
#[test]
fn dir_delta_opposite() {
assert_eq!(Dir::N.delta(), (0, -1));
assert_eq!(Dir::SE.opposite(), Dir::NW);
assert_eq!(Pos::new(1, 1).step(Dir::W), Pos::new(0, 1));
assert_eq!(Pos::new(0, 0).chebyshev(Pos::new(3, -2)), 3);
}
}

23
crates/citygen/src/ids.rs Normal file
View File

@@ -0,0 +1,23 @@
//! Newtype identifiers for world entities.
use serde::{Deserialize, Serialize};
macro_rules! id_type {
($name:ident) => {
#[derive(
Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
pub struct $name(pub u32);
impl $name {
pub fn index(self) -> usize {
self.0 as usize
}
}
};
}
id_type!(DistrictId);
id_type!(BuildingId);
id_type!(SubnetId);
id_type!(NodeId);

View File

@@ -1 +1,7 @@
//! World model and generator for the cyberpunk city.
pub mod geom;
pub mod ids;
pub use geom::{Dir, Grid, Pos, Rect, Size};
pub use ids::*;