From ac14d2c2990e21e96a677a428deee5e6b9f879a6 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:06:29 +0200 Subject: [PATCH] feat(citygen): building interiors, doors, windows, shafts Blocks now keep a one-tile margin from water, the map edge and other districts and get an alley ring; lots are cut as back-to-back rows so every building has an outdoor-facing wall for its door. Co-Authored-By: Claude Fable 5.1 --- crates/citygen/src/generate/interiors.rs | 359 +++++++++++++++++++++++ crates/citygen/src/generate/lots.rs | 78 +++-- crates/citygen/src/generate/mod.rs | 2 + crates/citygen/src/generate/streets.rs | 19 +- crates/citygen/src/generate/verticals.rs | 10 +- 5 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 crates/citygen/src/generate/interiors.rs diff --git a/crates/citygen/src/generate/interiors.rs b/crates/citygen/src/generate/interiors.rs new file mode 100644 index 0000000..32186cc --- /dev/null +++ b/crates/citygen/src/generate/interiors.rs @@ -0,0 +1,359 @@ +//! Stage 7: rooms, doors, windows and internal shafts. + +use super::{GenCtx, Stage, StageRunner, StepOut}; +use crate::geom::{Pos, Rect}; +use crate::params::GenError; +use crate::tile::Terrain; +use crate::world::{Building, Connector, ConnectorKind, StratumKind}; +use rand::Rng; +use rand::seq::IndexedRandom; +use rand_chacha::ChaCha8Rng; + +#[derive(Default)] +pub(crate) struct InteriorsStage { + next: usize, + rng: Option, +} + +fn is_corner(r: Rect, p: Pos) -> bool { + (p.x == r.x || p.x == r.right() - 1) && (p.y == r.y || p.y == r.bottom() - 1) +} + +/// The neighbour of a border tile that lies inside the footprint. +fn inward(r: Rect, p: Pos) -> Pos { + if p.x == r.x { + p.offset(1, 0) + } else if p.x == r.right() - 1 { + p.offset(-1, 0) + } else if p.y == r.y { + p.offset(0, 1) + } else { + p.offset(0, -1) + } +} + +fn outward(r: Rect, p: Pos) -> Pos { + let i = inward(r, p); + Pos::new(2 * p.x - i.x, 2 * p.y - i.y) +} + +impl InteriorsStage { + /// Recursive partitions: a wall line with one door, on floor tiles only. + fn partition(ctx: &mut GenCtx, rng: &mut ChaCha8Rng, k: StratumKind, inner: Rect, depth: u8) { + let can_v = inner.w >= 7; + let can_h = inner.h >= 7; + if depth == 0 || (!can_v && !can_h) { + return; + } + let vertical = if can_v && can_h { + inner.w >= inner.h + } else { + can_v + }; + if vertical { + let s = rng.random_range(inner.x + 3..=inner.right() - 4); + for y in inner.y..inner.bottom() { + let p = Pos::new(s, y); + if ctx.terrain(k, p) == Terrain::Floor { + ctx.set_terrain(k, p, Terrain::Wall); + } + } + let door = Pos::new(s, rng.random_range(inner.y..inner.bottom())); + if ctx.terrain(k, door) == Terrain::Wall { + ctx.set_terrain(k, door, Terrain::Door); + } + Self::partition( + ctx, + rng, + k, + Rect::new(inner.x, inner.y, s - inner.x, inner.h), + depth - 1, + ); + Self::partition( + ctx, + rng, + k, + Rect::new(s + 1, inner.y, inner.right() - s - 1, inner.h), + depth - 1, + ); + } else { + let s = rng.random_range(inner.y + 3..=inner.bottom() - 4); + for x in inner.x..inner.right() { + let p = Pos::new(x, s); + if ctx.terrain(k, p) == Terrain::Floor { + ctx.set_terrain(k, p, Terrain::Wall); + } + } + let door = Pos::new(rng.random_range(inner.x..inner.right()), s); + if ctx.terrain(k, door) == Terrain::Wall { + ctx.set_terrain(k, door, Terrain::Door); + } + Self::partition( + ctx, + rng, + k, + Rect::new(inner.x, inner.y, inner.w, s - inner.y), + depth - 1, + ); + Self::partition( + ctx, + rng, + k, + Rect::new(inner.x, s + 1, inner.w, inner.bottom() - s - 1), + depth - 1, + ); + } + } + + /// Opens a door on a border wall facing `outside_ok` terrain; returns the lobby tile. + fn exterior_door( + ctx: &mut GenCtx, + rng: &mut ChaCha8Rng, + k: StratumKind, + fp: Rect, + outside_ok: impl Fn(Terrain) -> bool, + prefer: Option, + ) -> Option { + let candidates: Vec = fp + .border() + .filter(|p| !is_corner(fp, *p) && ctx.terrain(k, *p) == Terrain::Wall) + .filter(|p| { + let o = outward(fp, *p); + ctx.city.size.contains(o) && outside_ok(ctx.terrain(k, o)) + }) + .collect(); + if candidates.is_empty() { + return None; + } + let preferred: Vec = match prefer { + Some(t) => candidates + .iter() + .copied() + .filter(|p| ctx.terrain(k, outward(fp, *p)) == t) + .collect(), + None => Vec::new(), + }; + let pool = if preferred.is_empty() { + &candidates + } else { + &preferred + }; + let door = *pool.choose(rng).expect("non-empty pool"); + ctx.set_terrain(k, door, Terrain::Door); + let lobby = inward(fp, door); + if !ctx.city.walkable(k, lobby) { + ctx.set_terrain(k, lobby, Terrain::Floor); + } + Some(lobby) + } + + fn windows(ctx: &mut GenCtx, fp: Rect) { + let k = StratumKind::Street; + let spots: Vec = fp + .border() + .filter(|p| !is_corner(fp, *p) && ctx.terrain(k, *p) == Terrain::Wall) + .filter(|p| (p.x + p.y) % 2 == 0) + .filter(|p| { + let o = outward(fp, *p); + ctx.tile(k, o).is_some_and(|t| t.is_outdoor_ground()) + }) + .collect(); + for p in spots { + ctx.set_terrain(k, p, Terrain::Window); + } + } + + /// Places the internal stair or lift and registers its connector. + fn shaft(ctx: &mut GenCtx, b: &Building) { + let fp = b.footprint; + let inner = fp.inset(1); + let strata: Vec = b.span.iter().collect(); + let open_everywhere = |ctx: &GenCtx, p: Pos| { + strata.iter().all(|k| { + let t = ctx.terrain(*k, p); + t != Terrain::Wall && t != Terrain::Void && ctx.city.walkable(*k, p) + }) + }; + let centre = fp.center(); + let mut best = inner + .iter() + .filter(|p| open_everywhere(ctx, *p)) + .min_by_key(|p| p.dist2(centre)); + if best.is_none() { + let c = if inner.is_empty() { + centre + } else { + inner.center() + }; + for k in &strata { + if *k != StratumKind::Heights { + ctx.set_terrain(*k, c, Terrain::Floor); + for n in c.neighbors4() { + if inner.contains(n) && ctx.terrain(*k, n) == Terrain::Wall { + ctx.set_terrain(*k, n, Terrain::Floor); + } + } + } + } + best = Some(c); + } + let pos = best.expect("set above"); + let kind = if strata.len() == 2 { + ConnectorKind::Stair + } else { + ConnectorKind::Lift + }; + let terrain = match kind { + ConnectorKind::Stair => Terrain::Stair, + _ => Terrain::Lift, + }; + for k in &strata { + ctx.set_terrain(*k, pos, terrain); + ctx.set_feature(*k, pos, None); + } + ctx.city.connectors.push(Connector { + kind, + pos, + serves: strata, + public: false, + }); + } + + fn furnish(&mut self, ctx: &mut GenCtx, i: usize) { + let b = ctx.city.buildings[i].clone(); + let rng = self.rng.as_mut().expect("begin sets rng"); + let fp = b.footprint; + for k in b.span.iter() { + if k == StratumKind::Heights { + continue; + } + Self::partition(ctx, rng, k, fp.inset(1), 4); + match k { + StratumKind::Street => { + let lobby = Self::exterior_door( + ctx, + rng, + k, + fp, + |t| { + matches!( + t, + Terrain::Road | Terrain::Sidewalk | Terrain::Alley | Terrain::Plaza + ) + }, + Some(Terrain::Sidewalk), + ); + if let Some(l) = lobby { + ctx.scratch.lobbies.push((b.id, l)); + } + Self::windows(ctx, fp); + } + StratumKind::Skyways => { + Self::exterior_door( + ctx, + rng, + k, + fp, + |t| matches!(t, Terrain::Walkway | Terrain::Bridge), + None, + ); + } + StratumKind::Undercity => { + Self::exterior_door( + ctx, + rng, + k, + fp, + |t| matches!(t, Terrain::Tunnel | Terrain::Rail | Terrain::Grate), + None, + ); + } + _ => {} + } + } + if b.span.len() >= 2 { + Self::shaft(ctx, &b); + } + } +} + +impl StageRunner for InteriorsStage { + fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> { + self.rng = Some(ctx.rng(Stage::Interiors)); + self.next = 0; + ctx.scratch.lobbies.clear(); + Ok(()) + } + + fn step(&mut self, ctx: &mut GenCtx) -> Result { + let n = ctx.city.buildings.len(); + if self.next >= n { + return Ok(StepOut::Finished); + } + self.furnish(ctx, self.next); + self.next += 1; + Ok(StepOut::Continue(self.next as f32 / n as f32)) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::testutil::small; + use crate::params::WaterMode; + use crate::pathing::components; + use crate::tile::Terrain; + use crate::world::StratumKind; + + #[test] + fn every_building_has_a_street_door_and_internal_connectivity() { + let c = small(61, WaterMode::Bay, 7); + let st = c.stratum(StratumKind::Street).unwrap(); + for b in &c.buildings { + let doors: Vec<_> = b + .footprint + .border() + .filter(|p| st.tiles[*p].terrain == Terrain::Door) + .collect(); + assert!(!doors.is_empty(), "{:?} has no door", b.id); + assert!(doors.iter().any(|d| { + d.neighbors4() + .any(|n| st.tiles.get(n).is_some_and(|t| t.is_outdoor_ground())) + })); + let inside = b.footprint; + let comps = components(st.tiles.size(), |p| { + inside.contains(p) && st.tiles[p].is_walkable() + }); + assert_eq!(comps.len(), 1, "{:?} interior split", b.id); + if b.span.len() >= 2 { + let conn = c + .connectors + .iter() + .find(|k| inside.contains(k.pos) && !k.public) + .expect("internal connector"); + for s in b.span.iter() { + assert!(conn.serves.contains(&s)); + assert!(c.walkable(s, conn.pos), "{:?} {s:?} {:?}", b.id, conn.pos); + } + } + } + } + + #[test] + fn rooms_respect_minimum_size_and_big_buildings_get_walls() { + let c = small(61, WaterMode::None, 6); + let st = c.stratum(StratumKind::Street).unwrap(); + let big = c + .buildings + .iter() + .find(|b| b.footprint.w >= 12 && b.footprint.h >= 9) + .expect("a big building"); + let walls_inside = big + .footprint + .inset(1) + .iter() + .filter(|p| st.tiles[*p].terrain == Terrain::Wall) + .count(); + assert!(walls_inside > 0, "big building should have internal walls"); + assert!(st.tiles.count(|t| t.terrain == Terrain::Window) > 20); + } +} diff --git a/crates/citygen/src/generate/lots.rs b/crates/citygen/src/generate/lots.rs index acfd30b..72023ac 100644 --- a/crates/citygen/src/generate/lots.rs +++ b/crates/citygen/src/generate/lots.rs @@ -18,41 +18,55 @@ pub(crate) struct LotsStage { rng: Option, } -/// Splits a block into lots no smaller than `min` and no larger than twice that. -fn bsp(rng: &mut ChaCha8Rng, rect: Rect, min: (i32, i32), out: &mut Vec) { - let (min_w, min_h) = min; - let split_w = rect.w > 2 * min_w; - let split_h = rect.h > 2 * min_h; - if !split_w && !split_h { - out.push(rect); +/// Splits a run `[lo, hi)` into pieces of length in `[min, 2*min)` with jittered cuts. +fn split_run(rng: &mut ChaCha8Rng, lo: i32, hi: i32, min: i32, out: &mut Vec<(i32, i32)>) { + let len = hi - lo; + if len < 2 * min { + out.push((lo, hi)); return; } - let vertical = if split_w && split_h { - rect.w as f32 / min_w as f32 >= rect.h as f32 / min_h as f32 - } else { - split_w - }; let jitter: f32 = rng.random_range(0.4..0.6); - if vertical { - let s = - (rect.x + (rect.w as f32 * jitter) as i32).clamp(rect.x + min_w, rect.right() - min_w); - bsp(rng, Rect::new(rect.x, rect.y, s - rect.x, rect.h), min, out); - bsp( - rng, - Rect::new(s, rect.y, rect.right() - s, rect.h), - min, - out, - ); + let s = (lo + (len as f32 * jitter) as i32).clamp(lo + min, hi - min); + split_run(rng, lo, s, min, out); + split_run(rng, s, hi, min, out); +} + +/// Splits a block into lots: at most two back-to-back rows (or columns), each cut into +/// strips along the long axis, so every lot touches the block perimeter. +fn bsp(rng: &mut ChaCha8Rng, rect: Rect, min: (i32, i32), out: &mut Vec) { + let (min_w, min_h) = min; + if rect.w >= rect.h { + let mut rows = Vec::new(); + if rect.h >= 2 * min_h { + let s = rect.y + rect.h / 2; + rows.push((rect.y, s)); + rows.push((s, rect.bottom())); + } else { + rows.push((rect.y, rect.bottom())); + } + for (y0, y1) in rows { + let mut cols = Vec::new(); + split_run(rng, rect.x, rect.right(), min_w, &mut cols); + for (x0, x1) in cols { + out.push(Rect::new(x0, y0, x1 - x0, y1 - y0)); + } + } } else { - let s = - (rect.y + (rect.h as f32 * jitter) as i32).clamp(rect.y + min_h, rect.bottom() - min_h); - bsp(rng, Rect::new(rect.x, rect.y, rect.w, s - rect.y), min, out); - bsp( - rng, - Rect::new(rect.x, s, rect.w, rect.bottom() - s), - min, - out, - ); + let mut cols = Vec::new(); + if rect.w >= 2 * min_w { + let s = rect.x + rect.w / 2; + cols.push((rect.x, s)); + cols.push((s, rect.right())); + } else { + cols.push((rect.x, rect.right())); + } + for (x0, x1) in cols { + let mut rows = Vec::new(); + split_run(rng, rect.y, rect.bottom(), min_h, &mut rows); + for (y0, y1) in rows { + out.push(Rect::new(x0, y0, x1 - x0, y1 - y0)); + } + } } } @@ -227,7 +241,7 @@ mod tests { fn buildings_fit_blocks_and_do_not_overlap() { let c = small(41, WaterMode::Bay, 7); assert!( - c.buildings.len() > 40, + c.buildings.len() > 30, "only {} buildings", c.buildings.len() ); diff --git a/crates/citygen/src/generate/mod.rs b/crates/citygen/src/generate/mod.rs index db4c991..a4daafb 100644 --- a/crates/citygen/src/generate/mod.rs +++ b/crates/citygen/src/generate/mod.rs @@ -2,6 +2,7 @@ pub mod arterials; pub mod districts; +pub mod interiors; pub mod lots; pub mod streets; pub mod terrain; @@ -231,6 +232,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box { Stage::Streets => Box::new(streets::StreetsStage::default()), Stage::Lots => Box::new(lots::LotsStage::default()), Stage::Verticals => Box::new(verticals::VerticalsStage::default()), + Stage::Interiors => Box::new(interiors::InteriorsStage::default()), _ => Box::new(NoopStage), } } diff --git a/crates/citygen/src/generate/streets.rs b/crates/citygen/src/generate/streets.rs index 48941e9..538d8d8 100644 --- a/crates/citygen/src/generate/streets.rs +++ b/crates/citygen/src/generate/streets.rs @@ -284,10 +284,21 @@ impl Carver<'_> { } } + /// A tile a block may not use: water, another district, anything already painted, or a + /// tile touching water, the map edge or another district (so a street ring always fits + /// around the block). fn is_bad(&self, p: Pos) -> bool { - !self.ctx.is_land(p) + if !self.ctx.is_land(p) || !self.ctx.in_district(p, self.d) || self.ctx.terrain(StratumKind::Street, p) != Terrain::Void + { + return true; + } + p.neighbors4().any(|q| { + !self.ctx.city.size.contains(q) + || !self.ctx.is_land(q) + || !self.ctx.in_district(q, self.d) + }) } /// Drops edge rows/columns that are entirely unusable. @@ -448,6 +459,12 @@ impl StreetsStage { }); } } + // Every block gets a walkable ring so each perimeter lot can open a door. + for b in &blocks { + for p in b.rect.grow(1).border() { + paint_cell(carver.ctx, p, district.id, Terrain::Alley); + } + } carver.ctx.scratch.blocks.extend(blocks); } } diff --git a/crates/citygen/src/generate/verticals.rs b/crates/citygen/src/generate/verticals.rs index 2e9e5da..c064194 100644 --- a/crates/citygen/src/generate/verticals.rs +++ b/crates/citygen/src/generate/verticals.rs @@ -377,10 +377,7 @@ mod tests { for b in &c.buildings { let p = b.footprint.center(); if b.span.contains(StratumKind::Skyways) { - assert!(matches!( - sk.tiles[p].terrain, - Terrain::Floor | Terrain::Wall - )); + assert!(sk.tiles[p].is_building_part() || sk.tiles[p].is_connector()); } else { // Air above a low roof may carry an elevated walkway. assert!(matches!( @@ -389,7 +386,10 @@ mod tests { )); } if b.span.contains(StratumKind::Heights) { - assert_eq!(hi.tiles[p].terrain, Terrain::Rooftop); + assert!(matches!( + hi.tiles[p].terrain, + Terrain::Rooftop | Terrain::Lift | Terrain::Stair + )); } else { assert!(matches!( hi.tiles[p].terrain,