diff --git a/crates/citygen/src/generate/lots.rs b/crates/citygen/src/generate/lots.rs new file mode 100644 index 0000000..acfd30b --- /dev/null +++ b/crates/citygen/src/generate/lots.rs @@ -0,0 +1,313 @@ +//! Stage 5: lots and building placement. + +use super::{GenCtx, Lot, LotUse, Stage, StageRunner, StepOut}; +use crate::geom::{Pos, Rect}; +use crate::ids::BuildingId; +use crate::params::GenError; +use crate::rng::{chance, pick_weighted}; +use crate::theme::{StreetStyle, ThemeSpec}; +use crate::tile::Terrain; +use crate::world::{Building, BuildingKind, HeightClass, StrataSpan, StratumKind}; +use rand::Rng; +use rand_chacha::ChaCha8Rng; + +#[derive(Default)] +pub(crate) struct LotsStage { + next: usize, + filled: bool, + 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); + 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, + ); + } 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, + ); + } +} + +fn choose_class(rng: &mut ChaCha8Rng, kind: BuildingKind, spec: &ThemeSpec) -> HeightClass { + let allowed = kind.height_classes(); + if allowed.contains(&HeightClass::Tall) && chance(rng, spec.tall) { + HeightClass::Tall + } else if allowed.contains(&HeightClass::Mid) && chance(rng, spec.mid) { + HeightClass::Mid + } else if allowed.contains(&HeightClass::Low) { + HeightClass::Low + } else { + allowed[0] + } +} + +impl LotsStage { + fn place_block(&mut self, ctx: &mut GenCtx, idx: usize) { + let block = ctx.scratch.blocks[idx]; + let district = ctx.city.district(block.district).clone(); + let spec = district.theme.spec(); + let density = ctx.params().density; + let rng = self.rng.as_mut().expect("begin sets rng"); + + let mut lots = Vec::new(); + bsp(rng, block.rect, spec.min_lot, &mut lots); + for lot in lots { + if lot.w < 4 || lot.h < 3 { + ctx.stratum_fill(StratumKind::Street, lot, Terrain::Plaza); + ctx.scratch.lots.push(Lot { + rect: lot, + district: district.id, + use_: LotUse::Plaza, + }); + continue; + } + let plaza_p = spec.plaza * (1.3 - density); + if chance(rng, plaza_p) { + let parking = matches!( + district.theme, + crate::world::Theme::CorpCore | crate::world::Theme::Industrial + ) && rng.random_bool(0.5); + ctx.stratum_fill(StratumKind::Street, lot, Terrain::Plaza); + ctx.scratch.lots.push(Lot { + rect: lot, + district: district.id, + use_: if parking { + LotUse::Parking + } else { + LotUse::Plaza + }, + }); + continue; + } + let kind = *pick_weighted(rng, spec.mix); + let class = choose_class(rng, kind, spec); + let basement = chance(rng, spec.basement); + let mut footprint = lot; + if matches!(spec.street_style, StreetStyle::Organic | StreetStyle::Strip) + && chance(rng, 0.3) + { + footprint = match rng.random_range(0..4) { + 0 if lot.w > 4 => Rect::new(lot.x + 1, lot.y, lot.w - 1, lot.h), + 1 if lot.w > 4 => Rect::new(lot.x, lot.y, lot.w - 1, lot.h), + 2 if lot.h > 3 => Rect::new(lot.x, lot.y + 1, lot.w, lot.h - 1), + 3 if lot.h > 3 => Rect::new(lot.x, lot.y, lot.w, lot.h - 1), + _ => lot, + }; + } + let id = BuildingId(ctx.city.buildings.len() as u32); + let span = StrataSpan::from_class(class, basement); + for p in footprint.iter() { + let on_border = p.x == footprint.x + || p.y == footprint.y + || p.x == footprint.right() - 1 + || p.y == footprint.bottom() - 1; + ctx.set_terrain( + StratumKind::Street, + p, + if on_border { + Terrain::Wall + } else { + Terrain::Floor + }, + ); + for k in span.iter() { + if let Some(t) = ctx.city.tile_mut(k, p) { + t.building = Some(id); + } + } + } + ctx.city.buildings.push(Building { + id, + kind, + district: district.id, + footprint, + span, + name: None, + }); + ctx.scratch.lots.push(Lot { + rect: lot, + district: district.id, + use_: LotUse::Building { + kind, + class, + basement, + }, + }); + } + } + + /// Any zoned land still untouched becomes a back alley so nothing is unreachable void. + fn fill_leftovers(ctx: &mut GenCtx) { + let size = ctx.size(); + for y in 0..size.h as i32 { + for x in 0..size.w as i32 { + let p = Pos::new(x, y); + let Some(t) = ctx.city.tile(StratumKind::Street, p) else { + continue; + }; + if t.district.is_some() && t.terrain == Terrain::Void { + ctx.set_terrain(StratumKind::Street, p, Terrain::Alley); + } + } + } + } +} + +impl GenCtx { + /// Fills a rect with `terrain` on one stratum (no district check). + pub fn stratum_fill(&mut self, k: StratumKind, r: Rect, terrain: Terrain) { + for p in r.iter() { + self.set_terrain(k, p, terrain); + } + } +} + +impl StageRunner for LotsStage { + fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> { + self.rng = Some(ctx.rng(Stage::Lots)); + self.next = 0; + self.filled = false; + ctx.scratch.lots.clear(); + Ok(()) + } + + fn step(&mut self, ctx: &mut GenCtx) -> Result { + let n = ctx.scratch.blocks.len(); + if self.next < n { + self.place_block(ctx, self.next); + self.next += 1; + return Ok(StepOut::Continue(0.95 * self.next as f32 / n as f32)); + } + if !self.filled { + Self::fill_leftovers(ctx); + self.filled = true; + return Ok(StepOut::Continue(1.0)); + } + Ok(StepOut::Finished) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::Generator; + use crate::generate::testutil::small; + use crate::params::{GenParams, SizePreset, WaterMode}; + use crate::tile::Terrain; + use crate::world::{StratumKind, Theme}; + + #[test] + fn buildings_fit_blocks_and_do_not_overlap() { + let c = small(41, WaterMode::Bay, 7); + assert!( + c.buildings.len() > 40, + "only {} buildings", + c.buildings.len() + ); + let st = c.stratum(StratumKind::Street).unwrap(); + for (i, a) in c.buildings.iter().enumerate() { + assert_eq!(a.id.index(), i); + assert!( + a.footprint.w >= 4 && a.footprint.h >= 3, + "{:?}", + a.footprint + ); + for b in &c.buildings[i + 1..] { + assert!( + !a.footprint.intersects(b.footprint), + "{:?} overlaps {:?}", + a.id, + b.id + ); + } + for p in a.footprint.border() { + assert!( + matches!( + st.tiles[p].terrain, + Terrain::Wall | Terrain::Door | Terrain::Window + ), + "{p:?} is {:?}", + st.tiles[p].terrain + ); + assert_eq!(st.tiles[p].building, Some(a.id)); + } + for p in a.footprint.inset(1).iter() { + assert!(st.tiles[p].terrain != Terrain::Void && !st.tiles[p].is_outdoor_ground()); + } + assert_eq!(st.tiles[a.footprint.center()].district, Some(a.district)); + } + } + + #[test] + fn corp_core_has_tall_buildings_and_no_void_left_on_land() { + let c = small(41, WaterMode::None, 7); + let core = c + .districts + .iter() + .find(|d| d.theme == Theme::CorpCore) + .unwrap() + .id; + assert!( + c.buildings + .iter() + .any(|b| b.district == core && b.span.hi == StratumKind::Heights) + ); + let st = c.stratum(StratumKind::Street).unwrap(); + assert_eq!( + st.tiles + .iter() + .filter(|(_, t)| t.terrain == Terrain::Void && t.district.is_some()) + .count(), + 0 + ); + } + + #[test] + fn density_changes_plaza_share() { + let run = |seed, density| { + Generator::run(GenParams { + seed, + density, + size: SizePreset::Custom { w: 120, h: 60 }, + districts: 5, + ..Default::default() + }) + .unwrap() + .buildings + .len() + }; + let lo: usize = (4..8).map(|s| run(s, 0.2)).sum(); + let hi: usize = (4..8).map(|s| run(s, 0.95)).sum(); + assert!( + lo < hi, + "low density {lo} should yield fewer buildings than high {hi}" + ); + } +} diff --git a/crates/citygen/src/generate/mod.rs b/crates/citygen/src/generate/mod.rs index a9b72fe..d0304f6 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 lots; pub mod streets; pub mod terrain; @@ -227,6 +228,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box { Stage::Districts => Box::new(districts::DistrictsStage::default()), Stage::Arterials => Box::new(arterials::ArterialsStage::default()), Stage::Streets => Box::new(streets::StreetsStage::default()), + Stage::Lots => Box::new(lots::LotsStage::default()), _ => Box::new(NoopStage), } } diff --git a/crates/citygen/src/generate/streets.rs b/crates/citygen/src/generate/streets.rs index 6de03ae..48941e9 100644 --- a/crates/citygen/src/generate/streets.rs +++ b/crates/citygen/src/generate/streets.rs @@ -284,57 +284,71 @@ impl Carver<'_> { } } - /// Shrinks a candidate until every cell is untouched land of this district. - fn fit_block(&self, mut r: Rect) -> Option { - let bad = |ctx: &GenCtx, p: Pos| { - !ctx.is_land(p) - || !ctx.in_district(p, self.d) - || ctx.terrain(StratumKind::Street, p) != Terrain::Void - }; + fn is_bad(&self, p: Pos) -> bool { + !self.ctx.is_land(p) + || !self.ctx.in_district(p, self.d) + || self.ctx.terrain(StratumKind::Street, p) != Terrain::Void + } + + /// Drops edge rows/columns that are entirely unusable. + fn trim(&self, mut r: Rect) -> Rect { + loop { + if r.w < 1 || r.h < 1 { + return r; + } + if (r.x..r.right()).all(|x| self.is_bad(Pos::new(x, r.y))) { + r = Rect::new(r.x, r.y + 1, r.w, r.h - 1); + } else if (r.x..r.right()).all(|x| self.is_bad(Pos::new(x, r.bottom() - 1))) { + r = Rect::new(r.x, r.y, r.w, r.h - 1); + } else if (r.y..r.bottom()).all(|y| self.is_bad(Pos::new(r.x, y))) { + r = Rect::new(r.x + 1, r.y, r.w - 1, r.h); + } else if (r.y..r.bottom()).all(|y| self.is_bad(Pos::new(r.right() - 1, y))) { + r = Rect::new(r.x, r.y, r.w - 1, r.h); + } else { + return r; + } + } + } + + /// Greedy shrink from the dirtiest side until clean or too small. + fn shrink(&self, mut r: Rect) -> Option { for _ in 0..12 { if r.w < 5 || r.h < 4 { return None; } let top = (r.x..r.right()) - .filter(|x| bad(self.ctx, Pos::new(*x, r.y))) + .filter(|x| self.is_bad(Pos::new(*x, r.y))) .count(); let bottom = (r.x..r.right()) - .filter(|x| bad(self.ctx, Pos::new(*x, r.bottom() - 1))) + .filter(|x| self.is_bad(Pos::new(*x, r.bottom() - 1))) .count(); let left = (r.y..r.bottom()) - .filter(|y| bad(self.ctx, Pos::new(r.x, *y))) + .filter(|y| self.is_bad(Pos::new(r.x, *y))) .count(); let right = (r.y..r.bottom()) - .filter(|y| bad(self.ctx, Pos::new(r.right() - 1, *y))) + .filter(|y| self.is_bad(Pos::new(r.right() - 1, *y))) .count(); if top + bottom + left + right == 0 { - let interior_bad = r.inset(1).iter().any(|p| bad(self.ctx, p)); - if !interior_bad { - return Some(r); + match r.inset(1).iter().find(|p| self.is_bad(*p)) { + None => return Some(r), + Some(p) => { + if (p.x - r.x) * r.h < (p.y - r.y) * r.w { + r = Rect::new(p.x + 1, r.y, r.right() - p.x - 1, r.h); + } else { + r = Rect::new(r.x, p.y + 1, r.w, r.bottom() - p.y - 1); + } + continue; + } } - // An interior obstacle (arterial crossing); shrink the side nearest to it. - let p = r - .inset(1) - .iter() - .find(|p| bad(self.ctx, *p)) - .expect("interior_bad"); - if (p.x - r.x) * r.h < (p.y - r.y) * r.w { - r = Rect::new(p.x + 1, r.y, r.right() - p.x - 1, r.h); - } else { - r = Rect::new(r.x, p.y + 1, r.w, r.bottom() - p.y - 1); - } - continue; } - let top_f = top as f32 / r.w as f32; - let bottom_f = bottom as f32 / r.w as f32; - let left_f = left as f32 / r.h as f32; - let right_f = right as f32 / r.h as f32; - let max = top_f.max(bottom_f).max(left_f).max(right_f); - if max == top_f { + let fr = |n: usize, len: i32| n as f32 / len as f32; + let (t, b, l, rr) = (fr(top, r.w), fr(bottom, r.w), fr(left, r.h), fr(right, r.h)); + let max = t.max(b).max(l).max(rr); + if max == t { r = Rect::new(r.x, r.y + 1, r.w, r.h - 1); - } else if max == bottom_f { + } else if max == b { r = Rect::new(r.x, r.y, r.w, r.h - 1); - } else if max == left_f { + } else if max == l { r = Rect::new(r.x + 1, r.y, r.w - 1, r.h); } else { r = Rect::new(r.x, r.y, r.w - 1, r.h); @@ -342,6 +356,51 @@ impl Carver<'_> { } None } + + /// Turns one candidate into clean blocks: trims dead edges, and when the rest is still + /// dirty, splits it along the longer axis (painting an alley on the cut) and recurses. + fn fit_blocks(&mut self, r: Rect, depth: u8, out: &mut Vec) { + let r = self.trim(r); + if r.w < 5 || r.h < 4 { + return; + } + if !r.iter().any(|p| self.is_bad(p)) { + out.push(r); + return; + } + let can_split_w = r.w >= 11; + let can_split_h = r.h >= 9; + if depth > 0 && (can_split_w || can_split_h) { + let vertical = if can_split_w && can_split_h { + r.w >= r.h + } else { + can_split_w + }; + if vertical { + let s = r.x + r.w / 2; + paint_vline(self.ctx, s, r.y, r.bottom(), Line::ALLEY, self.d); + self.fit_blocks(Rect::new(r.x, r.y, s - r.x, r.h), depth - 1, out); + self.fit_blocks( + Rect::new(s + 1, r.y, r.right() - s - 1, r.h), + depth - 1, + out, + ); + } else { + let s = r.y + r.h / 2; + paint_hline(self.ctx, s, r.x, r.right(), Line::ALLEY, self.d); + self.fit_blocks(Rect::new(r.x, r.y, r.w, s - r.y), depth - 1, out); + self.fit_blocks( + Rect::new(r.x, s + 1, r.w, r.bottom() - s - 1), + depth - 1, + out, + ); + } + return; + } + if let Some(fitted) = self.shrink(r) { + out.push(fitted); + } + } } impl StreetsStage { @@ -373,16 +432,18 @@ impl StreetsStage { StreetStyle::Strip => carver.strip(), } let candidates = std::mem::take(&mut carver.candidates); - let mut blocks: Vec = Vec::new(); + let mut fitted = Vec::new(); for c in candidates { let Some(c) = c.clamp_to(carver.bounds) else { continue; }; - if let Some(fitted) = carver.fit_block(c) - && blocks.iter().all(|b| !b.rect.intersects(fitted)) - { + carver.fit_blocks(c, 4, &mut fitted); + } + let mut blocks: Vec = Vec::new(); + for rect in fitted { + if blocks.iter().all(|b| !b.rect.intersects(rect)) { blocks.push(Block { - rect: fitted, + rect, district: district.id, }); }