From 466e587fb7fef546003963d4173cceba7651f566 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:01:05 +0200 Subject: [PATCH] feat(citygen): undercity, skyways and heights verticals Co-Authored-By: Claude Fable 5.1 --- crates/citygen/src/generate/mod.rs | 2 + crates/citygen/src/generate/verticals.rs | 406 +++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 crates/citygen/src/generate/verticals.rs diff --git a/crates/citygen/src/generate/mod.rs b/crates/citygen/src/generate/mod.rs index d0304f6..db4c991 100644 --- a/crates/citygen/src/generate/mod.rs +++ b/crates/citygen/src/generate/mod.rs @@ -5,6 +5,7 @@ pub mod districts; pub mod lots; pub mod streets; pub mod terrain; +pub mod verticals; use crate::flavor::FlavorPack; use crate::geom::{Grid, Pos, Rect, Size}; @@ -229,6 +230,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box { Stage::Arterials => Box::new(arterials::ArterialsStage::default()), Stage::Streets => Box::new(streets::StreetsStage::default()), Stage::Lots => Box::new(lots::LotsStage::default()), + Stage::Verticals => Box::new(verticals::VerticalsStage::default()), _ => Box::new(NoopStage), } } diff --git a/crates/citygen/src/generate/verticals.rs b/crates/citygen/src/generate/verticals.rs new file mode 100644 index 0000000..2e9e5da --- /dev/null +++ b/crates/citygen/src/generate/verticals.rs @@ -0,0 +1,406 @@ +//! Stage 6: Undercity, Skyways and Heights derived from the street-level plan. + +use super::{GenCtx, Stage, StageRunner, StepOut}; +use crate::geom::{Pos, Rect}; +use crate::params::GenError; +use crate::rng::chance; +use crate::tile::Terrain; +use crate::world::{StratumKind, Theme}; +use noise::{NoiseFn, OpenSimplex}; +use rand::Rng; +use rand_chacha::ChaCha8Rng; +use std::collections::VecDeque; + +enum Work { + Arterial(usize), + Block(usize), + Rail, + Building(usize), + Walkways(usize), + Platforms(usize), + Bridges(usize), +} + +#[derive(Default)] +pub(crate) struct VerticalsStage { + queue: VecDeque, + total: usize, + rng: Option, +} + +/// Chebyshev gap between two rects (0 when touching or overlapping). +fn rect_gap(a: Rect, b: Rect) -> i32 { + let dx = (b.x - a.right()).max(a.x - b.right()).max(0); + let dy = (b.y - a.bottom()).max(a.y - b.bottom()).max(0); + dx.max(dy) +} + +/// Overlapping range of two half-open intervals. +fn overlap(a0: i32, a1: i32, b0: i32, b1: i32) -> Option<(i32, i32)> { + let lo = a0.max(b0); + let hi = a1.min(b1); + (hi > lo).then_some((lo, hi)) +} + +fn paint_if_void(ctx: &mut GenCtx, k: StratumKind, p: Pos, t: Terrain) { + if ctx.is_land(p) && ctx.terrain(k, p) == Terrain::Void { + ctx.set_terrain(k, p, t); + } +} + +impl VerticalsStage { + fn arterial(&self, ctx: &mut GenCtx, i: usize) { + let path = ctx.city.arterials[i].clone(); + for (step, p) in path.iter().enumerate() { + let channel = Rect::new(p.x, p.y, 2, 2); + let stamp = Rect::new(p.x - 1, p.y - 1, 4, 4); + for q in stamp.iter() { + if !ctx.is_land(q) { + continue; + } + if channel.contains(q) { + let t = if step % 12 == 0 { + Terrain::Grate + } else { + Terrain::Sewage + }; + let cur = ctx.terrain(StratumKind::Undercity, q); + if cur != Terrain::Grate { + ctx.set_terrain(StratumKind::Undercity, q, t); + } + } else { + paint_if_void(ctx, StratumKind::Undercity, q, Terrain::Tunnel); + } + } + } + } + + fn block(&mut self, ctx: &mut GenCtx, i: usize) { + let rng = self.rng.as_mut().expect("begin sets rng"); + if !chance(rng, 1.0 / 3.0) { + return; + } + let block = ctx.scratch.blocks[i]; + for p in block.rect.grow(2).iter() { + let street = matches!( + ctx.terrain(StratumKind::Street, p), + Terrain::Road | Terrain::Alley | Terrain::Sidewalk + ); + if street { + paint_if_void(ctx, StratumKind::Undercity, p, Terrain::Tunnel); + } + } + } + + fn rail(&mut self, ctx: &mut GenCtx) { + let size = ctx.size(); + let rng = self.rng.as_mut().expect("begin sets rng"); + let h = size.h as i32; + let y0 = h / 2 + rng.random_range(-(h / 8)..=(h / 8)); + let noise = OpenSimplex::new((ctx.seed ^ 0x8A11) as u32); + let mut prev_y = y0; + for x in 0..size.w as i32 { + let y = (y0 as f64 + noise.get([x as f64 * 0.02, 0.5]) * 4.0).round() as i32; + let (lo, hi) = (prev_y.min(y), prev_y.max(y)); + for yy in lo..=hi { + let p = Pos::new(x, yy); + if ctx.is_land(p) { + ctx.set_terrain(StratumKind::Undercity, p, Terrain::Rail); + paint_if_void( + ctx, + StratumKind::Undercity, + p.offset(0, -1), + Terrain::Tunnel, + ); + paint_if_void(ctx, StratumKind::Undercity, p.offset(0, 1), Terrain::Tunnel); + } + } + prev_y = y; + } + } + + fn building(&self, ctx: &mut GenCtx, i: usize) { + let b = ctx.city.buildings[i].clone(); + for k in b.span.iter() { + if k == StratumKind::Street { + continue; + } + for p in b.footprint.iter() { + let t = if k == StratumKind::Heights { + Terrain::Rooftop + } else if b.footprint.border().any(|q| q == p) { + Terrain::Wall + } else { + Terrain::Floor + }; + if k == StratumKind::Undercity { + // Tunnels, sewers and rail run under buildings; basements fill the rest. + paint_if_void(ctx, k, p, t); + } else { + ctx.set_terrain(k, p, t); + } + } + } + } + + /// Paints a 2-wide L-shaped walkway between the facing edges of two footprints. + fn connect_walkway(ctx: &mut GenCtx, a: Rect, b: Rect) -> bool { + let k = StratumKind::Skyways; + let horizontal_gap = b.x >= a.right() || a.x >= b.right(); + let (start, end) = if horizontal_gap { + let (left, right) = if b.x >= a.right() { (a, b) } else { (b, a) }; + let y_a = overlap(left.y, left.bottom(), right.y, right.bottom()) + .map(|(lo, hi)| (lo + hi) / 2) + .unwrap_or(left.center().y); + let y_b = overlap(left.y, left.bottom(), right.y, right.bottom()) + .map(|(lo, hi)| (lo + hi) / 2) + .unwrap_or(right.center().y); + (Pos::new(left.right(), y_a), Pos::new(right.x - 1, y_b)) + } else { + let (top, bottom) = if b.y >= a.bottom() { (a, b) } else { (b, a) }; + let x_a = overlap(top.x, top.right(), bottom.x, bottom.right()) + .map(|(lo, hi)| (lo + hi) / 2) + .unwrap_or(top.center().x); + let x_b = overlap(top.x, top.right(), bottom.x, bottom.right()) + .map(|(lo, hi)| (lo + hi) / 2) + .unwrap_or(bottom.center().x); + (Pos::new(x_a, top.bottom()), Pos::new(x_b, bottom.y - 1)) + }; + for first_horizontal in [horizontal_gap, !horizontal_gap] { + let mut tiles = Vec::new(); + let corner = if first_horizontal { + Pos::new(end.x, start.y) + } else { + Pos::new(start.x, end.y) + }; + for seg in [(start, corner), (corner, end)] { + let (p, q) = seg; + if p.y == q.y { + let (x0, x1) = (p.x.min(q.x), p.x.max(q.x)); + for x in x0..=x1 { + tiles.push(Pos::new(x, p.y)); + tiles.push(Pos::new(x, p.y + 1)); + } + } else { + let (y0, y1) = (p.y.min(q.y), p.y.max(q.y)); + for y in y0..=y1 { + tiles.push(Pos::new(p.x, y)); + tiles.push(Pos::new(p.x + 1, y)); + } + } + } + let clear = tiles.iter().all(|t| { + ctx.city.size.contains(*t) + && matches!(ctx.terrain(k, *t), Terrain::Void | Terrain::Walkway) + && !a.contains(*t) + && !b.contains(*t) + }); + if clear { + for t in tiles { + ctx.set_terrain(k, t, Terrain::Walkway); + } + return true; + } + } + false + } + + fn walkways(&self, ctx: &mut GenCtx, i: usize) { + let a = ctx.city.buildings[i].clone(); + if !a.span.contains(StratumKind::Skyways) { + return; + } + let mut partners: Vec<(i32, usize)> = ctx + .city + .buildings + .iter() + .enumerate() + .skip(i + 1) + .filter(|(_, b)| b.span.contains(StratumKind::Skyways)) + .map(|(j, b)| (rect_gap(a.footprint, b.footprint), j)) + .filter(|(gap, _)| *gap >= 1 && *gap <= 12) + .collect(); + partners.sort(); + for (_, j) in partners.into_iter().take(2) { + let b = ctx.city.buildings[j].footprint; + Self::connect_walkway(ctx, a.footprint, b); + } + } + + fn platforms(&self, ctx: &mut GenCtx, d: usize) { + let district = ctx.city.districts[d].clone(); + if !matches!(district.theme, Theme::CorpCore | Theme::NeonStrip) { + return; + } + let mut spots = Vec::new(); + for p in district.bounds.iter() { + if !ctx.in_district(p, district.id) + || ctx.terrain(StratumKind::Street, p) != Terrain::Road + { + continue; + } + let roads = p + .neighbors4() + .filter(|q| ctx.terrain(StratumKind::Street, *q) == Terrain::Road) + .count(); + if roads >= 3 && (p.x + p.y) % 7 == 0 { + spots.push(p); + } + } + for p in spots { + for q in Rect::new(p.x - 2, p.y - 2, 5, 5).iter() { + paint_if_void(ctx, StratumKind::Skyways, q, Terrain::Walkway); + } + } + } + + fn bridges(&self, ctx: &mut GenCtx, i: usize) { + let a = ctx.city.buildings[i].clone(); + if !a.span.contains(StratumKind::Heights) { + return; + } + let k = StratumKind::Heights; + let partner = ctx + .city + .buildings + .iter() + .enumerate() + .skip(i + 1) + .filter(|(_, b)| b.span.contains(StratumKind::Heights)) + .map(|(j, b)| (rect_gap(a.footprint, b.footprint), j)) + .filter(|(gap, _)| *gap >= 1 && *gap <= 8) + .min(); + let Some((_, j)) = partner else { return }; + let b = ctx.city.buildings[j].footprint; + let fa = a.footprint; + let mut tiles = Vec::new(); + if let Some((lo, hi)) = overlap(fa.y, fa.bottom(), b.y, b.bottom()) { + let y = (lo + hi) / 2; + let (x0, x1) = if b.x >= fa.right() { + (fa.right(), b.x) + } else { + (b.right(), fa.x) + }; + tiles.extend((x0..x1).map(|x| Pos::new(x, y))); + } else if let Some((lo, hi)) = overlap(fa.x, fa.right(), b.x, b.right()) { + let x = (lo + hi) / 2; + let (y0, y1) = if b.y >= fa.bottom() { + (fa.bottom(), b.y) + } else { + (b.bottom(), fa.y) + }; + tiles.extend((y0..y1).map(|y| Pos::new(x, y))); + } + if !tiles.is_empty() && tiles.iter().all(|t| ctx.terrain(k, *t) == Terrain::Void) { + for t in tiles { + ctx.set_terrain(k, t, Terrain::Bridge); + } + } + } +} + +impl StageRunner for VerticalsStage { + fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> { + self.rng = Some(ctx.rng(Stage::Verticals)); + self.queue.clear(); + self.queue + .extend((0..ctx.city.arterials.len()).map(Work::Arterial)); + self.queue + .extend((0..ctx.scratch.blocks.len()).map(Work::Block)); + self.queue.push_back(Work::Rail); + let n = ctx.city.buildings.len(); + self.queue.extend((0..n).map(Work::Building)); + self.queue.extend((0..n).map(Work::Walkways)); + self.queue + .extend((0..ctx.city.districts.len()).map(Work::Platforms)); + self.queue.extend((0..n).map(Work::Bridges)); + self.total = self.queue.len().max(1); + Ok(()) + } + + fn step(&mut self, ctx: &mut GenCtx) -> Result { + let Some(work) = self.queue.pop_front() else { + return Ok(StepOut::Finished); + }; + match work { + Work::Arterial(i) => self.arterial(ctx, i), + Work::Block(i) => self.block(ctx, i), + Work::Rail => self.rail(ctx), + Work::Building(i) => self.building(ctx, i), + Work::Walkways(i) => self.walkways(ctx, i), + Work::Platforms(d) => self.platforms(ctx, d), + Work::Bridges(i) => self.bridges(ctx, i), + } + let done = self.total - self.queue.len(); + Ok(StepOut::Continue(done as f32 / self.total as f32)) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::testutil::small; + use crate::params::WaterMode; + use crate::tile::Terrain; + use crate::world::StratumKind; + + #[test] + fn undercity_has_tunnels_under_arterials_and_a_rail() { + let c = small(51, WaterMode::Bay, 7); + let uc = c.stratum(StratumKind::Undercity).unwrap(); + for path in &c.arterials { + for p in path { + assert!( + matches!( + uc.tiles[*p].terrain, + Terrain::Tunnel | Terrain::Sewage | Terrain::Grate | Terrain::Rail + ), + "{p:?} is {:?}", + uc.tiles[*p].terrain + ); + } + } + let rail = uc.tiles.count(|t| t.terrain == Terrain::Rail); + assert!( + rail >= (uc.tiles.size().w as f32 * 0.7) as usize, + "rail {rail}" + ); + assert!(uc.tiles.count(|t| t.terrain == Terrain::Sewage) > 0); + assert!(uc.tiles.count(|t| t.terrain == Terrain::Grate) > 0); + } + + #[test] + fn skyways_and_heights_follow_spans() { + let c = small(51, WaterMode::None, 7); + let sk = c.stratum(StratumKind::Skyways).unwrap(); + let hi = c.stratum(StratumKind::Heights).unwrap(); + let uc = c.stratum(StratumKind::Undercity).unwrap(); + 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 + )); + } else { + // Air above a low roof may carry an elevated walkway. + assert!(matches!( + sk.tiles[p].terrain, + Terrain::Void | Terrain::Walkway + )); + } + if b.span.contains(StratumKind::Heights) { + assert_eq!(hi.tiles[p].terrain, Terrain::Rooftop); + } else { + assert!(matches!( + hi.tiles[p].terrain, + Terrain::Void | Terrain::Bridge + )); + } + if b.span.contains(StratumKind::Undercity) { + // Basement, or a tunnel/rail that already ran under the building. + assert_ne!(uc.tiles[p].terrain, Terrain::Void); + } + } + assert!(sk.tiles.count(|t| t.terrain == Terrain::Walkway) > 50); + } +}