From 0d459e7058176d470d371a3b99825d2dd1b72b7c Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:30:54 +0200 Subject: [PATCH] fix(citygen): robustness found by the extended seed sweep - arterials merge onto existing roads and keep distance otherwise - isolated strata components get a drop-in public connector - blockless districts fall back to the largest clean rectangle - sliver districts merge into neighbours (district count is a maximum) - dressing removes blocking features that cut tiles off the street Co-Authored-By: Claude Fable 5.1 --- crates/citygen/src/generate/arterials.rs | 19 +++++-- crates/citygen/src/generate/connectivity.rs | 49 ++++++++++++++++ crates/citygen/src/generate/districts.rs | 63 ++++++++++++++++++++- crates/citygen/src/generate/dressing.rs | 39 ++++++++++++- crates/citygen/src/generate/streets.rs | 38 +++++++++++++ 5 files changed, 199 insertions(+), 9 deletions(-) diff --git a/crates/citygen/src/generate/arterials.rs b/crates/citygen/src/generate/arterials.rs index 39ed42d..0e43db9 100644 --- a/crates/citygen/src/generate/arterials.rs +++ b/crates/citygen/src/generate/arterials.rs @@ -62,17 +62,24 @@ fn plan_edges(centers: &[Pos], extra: usize) -> Vec<(Pos, Pos)> { } impl ArterialsStage { + /// Routes one arterial. Existing arterial tiles are cheap so roads merge instead of + /// running in parallel; tiles beside an arterial are dear so new roads keep their + /// distance; water is crossable at bridge cost. fn route(&self, ctx: &GenCtx, a: Pos, b: Pos) -> Option> { let land = &ctx.scratch.land; let arterial = &ctx.scratch.arterial; - astar(ctx.size(), a, b, |_, to| { + let size = ctx.size(); + astar(size, a, b, |_, to| { if !land[to] { - Some(40) - } else if arterial[to] { - Some(10) - } else { - Some(11) + return Some(40); } + if arterial[to] { + return Some(2); + } + let near = Rect::new(to.x - 3, to.y - 3, 7, 7) + .iter() + .any(|q| size.contains(q) && arterial[q]); + Some(if near { 16 } else { 10 }) }) } diff --git a/crates/citygen/src/generate/connectivity.rs b/crates/citygen/src/generate/connectivity.rs index 34c56f5..74f6cf8 100644 --- a/crates/citygen/src/generate/connectivity.rs +++ b/crates/citygen/src/generate/connectivity.rs @@ -188,6 +188,12 @@ fn repair_stratum(city: &mut City, k: StratumKind, reach: &Grid) { } } None => { + if drop_in_connector(city, k, &comp) { + for p in &comp { + reached.set(*p, true); + } + continue; + } let in_building = comp .iter() .any(|p| city.tile(k, *p).is_some_and(|t| t.building.is_some())); @@ -204,6 +210,49 @@ fn repair_stratum(city: &mut City, k: StratumKind, reach: &Grid) { } } +/// Gives an isolated component its own public connector from the street directly below +/// (or, for rooftops, a ladder from a walkway directly below). Returns true on success. +fn drop_in_connector(city: &mut City, k: StratumKind, comp: &[Pos]) -> bool { + let street_ok = |city: &City, p: Pos| { + city.tile(StratumKind::Street, p).is_some_and(|t| { + t.feature.is_none() + && matches!( + t.terrain, + Terrain::Sidewalk | Terrain::Alley | Terrain::Plaza | Terrain::Road + ) + }) && city.connectors_at(p).next().is_none() + }; + let spot = comp.iter().copied().find(|p| { + city.tile(k, *p) + .is_some_and(|t| t.feature.is_none() && !t.is_connector()) + && match k { + StratumKind::Heights => { + city.walkable(StratumKind::Skyways, *p) + && city.connectors_at(*p).next().is_none() + } + _ => street_ok(city, *p), + } + }); + let Some(pos) = spot else { return false }; + let (kind, serves) = match k { + StratumKind::Undercity => ( + ConnectorKind::Manhole, + vec![StratumKind::Undercity, StratumKind::Street], + ), + StratumKind::Skyways => ( + ConnectorKind::Stair, + vec![StratumKind::Street, StratumKind::Skyways], + ), + StratumKind::Heights => ( + ConnectorKind::Ladder, + vec![StratumKind::Skyways, StratumKind::Heights], + ), + _ => return false, + }; + ConnectivityStage::add_connector(city, kind, pos, serves); + true +} + impl ConnectivityStage { fn has_public(city: &City, d: usize, target: StratumKind) -> bool { let id = city.districts[d].id; diff --git a/crates/citygen/src/generate/districts.rs b/crates/citygen/src/generate/districts.rs index 2186187..19012dd 100644 --- a/crates/citygen/src/generate/districts.rs +++ b/crates/citygen/src/generate/districts.rs @@ -133,7 +133,60 @@ impl DistrictsStage { .collect() } + /// Assigns every land tile to its nearest seed on all physical strata. + fn assign_all(&self, 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); + if !ctx.is_land(p) { + continue; + } + let d = self.nearest_seed(p); + for k in StratumKind::PHYSICAL { + if let Some(t) = ctx.city.tile_mut(k, p) { + t.district = Some(d); + } + } + } + } + } + + /// Merges districts too small to hold a block into their neighbours, keeping at least + /// the minimum district count. Returns true if anything changed. + fn merge_slivers(&mut self, ctx: &mut GenCtx) -> bool { + let land = ctx.scratch.land.count(|&l| l); + let mut merged = false; + loop { + let n = self.seeds.len(); + if n <= 4 { + break; + } + let min_tiles = (600usize).max(land / (3 * n)); + let st = ctx.city.stratum(StratumKind::Street).expect("street"); + let mut counts = vec![0usize; n]; + for (_, t) in st.tiles.iter() { + if let Some(d) = t.district { + counts[d.index()] += 1; + } + } + let Some((smallest, _)) = counts + .iter() + .enumerate() + .filter(|(_, c)| **c < min_tiles) + .min_by_key(|(_, c)| **c) + else { + break; + }; + self.seeds.remove(smallest); + self.assign_all(ctx); + merged = true; + } + merged + } + fn finalize(&mut self, ctx: &mut GenCtx) { + self.merge_slivers(ctx); let n = self.seeds.len(); let size = ctx.size(); let noise = self.noise.as_ref().expect("begin sets noise"); @@ -274,7 +327,13 @@ mod tests { #[test] fn every_land_tile_has_a_district_and_counts_match() { let c = small(5, WaterMode::Bay, 6); - assert_eq!(c.districts.len(), 6); + // The requested count is a maximum: slivers merge into neighbours. + assert!( + (4..=6).contains(&c.districts.len()), + "{}", + c.districts.len() + ); + let n = c.districts.len(); let st = c.stratum(StratumKind::Street).unwrap(); for (_, t) in st.tiles.iter() { if t.terrain != Terrain::Water { @@ -285,7 +344,7 @@ mod tests { } let mut ids: Vec<_> = c.districts.iter().map(|d| d.id).collect(); ids.dedup(); - assert_eq!(ids.len(), 6); + assert_eq!(ids.len(), n); for (i, d) in c.districts.iter().enumerate() { assert_eq!(d.id.index(), i); assert!(d.bounds.contains(d.center)); diff --git a/crates/citygen/src/generate/dressing.rs b/crates/citygen/src/generate/dressing.rs index 195ff25..84fe0a3 100644 --- a/crates/citygen/src/generate/dressing.rs +++ b/crates/citygen/src/generate/dressing.rs @@ -1,11 +1,12 @@ //! Stage 9: features, light maps and per-tile variants. use super::{GenCtx, Stage, StageRunner, StepOut}; +use crate::generate::connectivity::reachable_from_street; use crate::geom::{Pos, Rect}; use crate::params::GenError; use crate::pathing::components; use crate::rng::{chance, tile_hash}; -use crate::tile::{Feature, Terrain}; +use crate::tile::{Feature, Terrain, Tile}; use crate::world::{StratumKind, Theme}; use rand::seq::IndexedRandom; use rand_chacha::ChaCha8Rng; @@ -14,6 +15,7 @@ use std::collections::VecDeque; enum Work { Facade(usize), Streets(usize), + Verify, Lights(StratumKind), Variants(StratumKind), } @@ -238,6 +240,39 @@ impl DressingStage { } } + /// Blocking features must never cut tiles off from the street: any blocker beside an + /// unreached walkable tile is removed, repeatedly, until nothing is cut off. + fn verify(ctx: &mut GenCtx) { + for _ in 0..6 { + let reach = reachable_from_street(&ctx.city); + let mut to_clear: Vec<(StratumKind, Pos)> = Vec::new(); + for k in StratumKind::PHYSICAL { + let st = ctx.city.stratum(k).expect("stratum"); + for (p, t) in st.tiles.iter() { + if !t.is_walkable() || reach[k.index()][p] { + continue; + } + for n in p.neighbors4() { + let blocker = st.tiles.get(n).is_some_and(|u| { + u.feature.is_some() + && !u.is_walkable() + && Tile::new(u.terrain).is_walkable() + }); + if blocker { + to_clear.push((k, n)); + } + } + } + } + if to_clear.is_empty() { + return; + } + for (k, p) in to_clear { + ctx.set_feature(k, p, None); + } + } + } + fn lights(ctx: &mut GenCtx, k: StratumKind) { let size = ctx.size(); let base = base_light(k); @@ -286,6 +321,7 @@ impl StageRunner for DressingStage { .extend((0..ctx.city.buildings.len()).map(Work::Facade)); self.queue .extend((0..ctx.city.districts.len()).map(Work::Streets)); + self.queue.push_back(Work::Verify); self.queue .extend(StratumKind::PHYSICAL.into_iter().map(Work::Lights)); self.queue @@ -301,6 +337,7 @@ impl StageRunner for DressingStage { match work { Work::Facade(i) => self.facade(ctx, i), Work::Streets(d) => self.streets(ctx, d), + Work::Verify => Self::verify(ctx), Work::Lights(k) => Self::lights(ctx, k), Work::Variants(k) => Self::variants(ctx, k), } diff --git a/crates/citygen/src/generate/streets.rs b/crates/citygen/src/generate/streets.rs index 538d8d8..54fc7da 100644 --- a/crates/citygen/src/generate/streets.rs +++ b/crates/citygen/src/generate/streets.rs @@ -368,6 +368,36 @@ impl Carver<'_> { None } + /// Greedy largest clean rectangle in the district bounds (at least 5x4), for districts + /// whose street style produced no usable block. + fn largest_clean_rect(&self) -> Option { + let b = self.bounds; + let mut best: Option = None; + for start in b.iter() { + if self.is_bad(start) { + continue; + } + let mut w = 0; + while start.x + w < b.right() && w < 40 && !self.is_bad(Pos::new(start.x + w, start.y)) + { + w += 1; + } + let mut h = 1; + while start.y + h < b.bottom() && h < 24 { + let row_clean = (0..w).all(|dx| !self.is_bad(Pos::new(start.x + dx, start.y + h))); + if !row_clean { + break; + } + h += 1; + } + let r = Rect::new(start.x, start.y, w, h); + if r.w >= 5 && r.h >= 4 && best.is_none_or(|o| r.area() > o.area()) { + best = Some(r); + } + } + best + } + /// 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) { @@ -459,6 +489,14 @@ impl StreetsStage { }); } } + if blocks.is_empty() + && let Some(rect) = carver.largest_clean_rect() + { + blocks.push(Block { + rect, + district: district.id, + }); + } // 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() {