From 728a27e1b4529fbdfc988ac606381dcc798bec1a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 5 Jun 2026 23:08:19 +0200 Subject: [PATCH] feat(simulation): quarter footprint subdivision + block-tag assembly (#957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the per-quarter building geometry + tags (absorbs #976's footprint subdivision into #957 — one coherent walkable-quarter deliverable): - subdivide_block_footprints: deterministic BSP of each 128x128-tile block into variable axis-aligned building plots (D-229 fast-path). Lot size + setback scale with D-220 density (Frontier -> few big lots/wide gaps; Compressed -> many small lots/shared walls); the D-233 BulkClass roofed-coverage fraction decides building-vs-interstitial. morphology is wired in for the D-234 waterfront/street layer (needs Layer-1 terrain water-adjacency threaded up). - assign_all_block_tags: subdivides + tags every non-reserved block's footprints via the #957 derivation helpers, populating QuarterWorldState.block_tags in the GenerateSkeleton plan-phase (D-230). Reserved blocks get no standard fill. - Remove the write-only BlockSkeleton.era String stub - construction era now lives per-footprint on BuildingPropertyTag.era (typed ConstructionEra). All integer-deterministic (D-010). 5 new tests. Doors stay Vec::new() (#979). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/gen_queue.rs | 13 +- server/src/atlas/skeleton_gen.rs | 315 ++++++++++++++++++++++++++++- server/src/simulation/generator.rs | 3 +- 3 files changed, 323 insertions(+), 8 deletions(-) diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index e09df392d..31e6b1aa8 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -31,7 +31,7 @@ use crate::atlas::attractor_matching::CityRecord; use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; -use crate::atlas::skeleton_gen::generate_quarter_skeleton; +use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; use crate::seed::SeedChain; use crate::simulation::generator::{CityGenerationContext, QuarterWorldState}; @@ -410,12 +410,21 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { *founding_age_years, *chain, ); + // Step-3 building-property tags per footprint (D-229, #957): subdivide + // each block into building plots and tag them. + let block_tags = assign_all_block_tags( + &skeleton, + context, + economic_role, + *founding_age_years, + *chain, + ); GenCompletion::SkeletonGenerated { city_id: *city_id, body_id: body_id.clone(), state: Box::new(QuarterWorldState { skeleton, - block_tags: std::collections::BTreeMap::new(), + block_tags, }), } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index c901fbad9..a2ff7987a 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -23,11 +23,14 @@ use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::tile_condition::{tile_condition, TileCondition}; use crate::seed::splitmix64; use crate::seed::{SeedChain, SeedDomain}; +use std::collections::BTreeMap; + use crate::simulation::generator::{ - BlockPlacement, BlockSkeleton, BuildingEntryClass, CityGenerationContext, ComplexityTier, - ConstructionEra, DistrictLayoutMode, DistrictType, EraCause, FloorExtent, FloorHeightProfile, - MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, - ReservationId, SettingType, WorldTier, ZoneTypeId, ZoningType, + ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, BuildingEntryClass, BuildingPropertyTag, + BulkClass, CityGenerationContext, ComplexityTier, ConstructionEra, DistrictLayoutMode, + DistrictType, EraCause, FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation, + PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, + SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType, }; // --------------------------------------------------------------------------- @@ -328,7 +331,6 @@ fn build_block_grid( reservation, chunk_layout: String::new(), // stub hosted_sites: Vec::new(), - era: String::new(), // stub era_modifications: Vec::new(), era_cause: None, density_pct: density, @@ -662,6 +664,222 @@ pub fn initial_condition(prosperity_bps: u32, era_cause: &EraCause) -> TileCondi tile_condition(prosperity_bps as f32 / 10_000.0, Some(era_cause)) } +// --------------------------------------------------------------------------- +// Footprint subdivision + block-tag assembly (D-220, D-229, D-233, D-234, #957) +// +// Subdivides each 128×128-tile block into variable axis-aligned building +// footprints (D-229 fast-path), then tags each via the derivation helpers above. +// Coverage = D-233 BulkClass roofed fraction; lot size + setback scale with +// D-220 density. The morphology-gated street network + per-edge waterfront rule +// (D-234) need Layer-1 terrain water-adjacency threaded to the skeleton and land +// with the street layer; `morphology` is wired here so the seam is ready. +// --------------------------------------------------------------------------- + +/// Edge length of one block in tiles (D-220: Block = 128×128 tiles). +const BLOCK_TILES: u8 = 128; +/// Perimeter street margin left unbuilt around each block (tiles). +const BLOCK_MARGIN: u8 = 2; +/// Recursion bound on the BSP subdivision. +const BSP_MAX_DEPTH: u8 = 7; + +/// D-233 roofed-coverage fraction (percent) for a block's dominant `BulkClass`, +/// interpolated within the class's authored range by build density. +fn roofed_coverage_pct(density_pct: u8, bulk: &BulkClass) -> u8 { + let (lo, hi) = match bulk { + BulkClass::BulkSolid => (25u8, 40u8), + BulkClass::BulkLiquid => (20, 35), + BulkClass::Perishable => (50, 65), + BulkClass::PrecisionDense => (75, 90), + BulkClass::NonPhysical => (85, 95), + }; + lo + ((hi - lo) as u32 * density_pct as u32 / 100) as u8 +} + +/// Split a rect into two along the given axis at `cut` (tiles from the origin). +fn split_rect(r: TileRect, along_x: bool, cut: u8) -> (TileRect, TileRect) { + let (ox, oy) = r.origin; + let (w, h) = r.size; + if along_x { + ( + TileRect::new(ox, oy, cut, h), + TileRect::new(ox + cut, oy, w - cut, h), + ) + } else { + ( + TileRect::new(ox, oy, w, cut), + TileRect::new(ox, oy + cut, w, h - cut), + ) + } +} + +/// Recursive binary space partition of `rect` into lot-sized leaves. Splits the +/// over-long axis at a seed-chosen 40–60% ratio until no dimension exceeds +/// `max_lot` (or a half would fall below `min_lot`, or depth is exhausted). +fn bsp(rect: TileRect, depth: u8, min_lot: u8, max_lot: u8, seed: u64, out: &mut Vec) { + let (w, h) = rect.size; + let can_split_x = w > max_lot && w >= min_lot * 2; + let can_split_y = h > max_lot && h >= min_lot * 2; + if depth >= BSP_MAX_DEPTH || (!can_split_x && !can_split_y) { + out.push(rect); + return; + } + // Split the longer eligible axis. + let along_x = if can_split_x && can_split_y { + w >= h + } else { + can_split_x + }; + let len = if along_x { w } else { h }; + let ratio = 40 + (seed % 21) as u32; // 40..=60 + let cut = ((len as u32 * ratio / 100) as u8).clamp(min_lot, len - min_lot); + let (a, b) = split_rect(rect, along_x, cut); + let child = splitmix64(seed); + bsp(a, depth + 1, min_lot, max_lot, child, out); + bsp(b, depth + 1, min_lot, max_lot, splitmix64(child), out); +} + +/// Subdivide one block into building footprints (D-220/D-229/D-233). +/// +/// Lot size + setback scale with `density_pct` (Frontier → few big lots, wide +/// gaps; Compressed → many small lots, shared walls). The D-233 roofed coverage +/// (from `bulk`) decides what fraction of lots are buildings vs interstitial +/// open space (yards/parks/lots). Footprints are axis-aligned `TileRect`s in +/// block tile-space. +fn subdivide_block_footprints( + density_pct: u8, + bulk: &BulkClass, + _morphology: &MorphologyZone, + seed: SeedChain, +) -> Vec { + let (min_lot, max_lot, setback) = match density_pct { + 0..=20 => (24u8, 48u8, 3u8), // Frontier + 21..=45 => (16, 32, 2), // Settled + 46..=65 => (12, 24, 2), // Established + 66..=85 => (8, 16, 1), // Dense + _ => (6, 12, 1), // Compressed — shared walls + }; + let coverage = roofed_coverage_pct(density_pct, bulk); + + let inner = TileRect::new( + BLOCK_MARGIN, + BLOCK_MARGIN, + BLOCK_TILES - 2 * BLOCK_MARGIN, + BLOCK_TILES - 2 * BLOCK_MARGIN, + ); + let mut leaves = Vec::new(); + bsp(inner, 0, min_lot, max_lot, seed.seed(), &mut leaves); + + // Keep the coverage fraction as buildings; shrink each by the setback to + // leave street frontage. The rest is interstitial open space. + let mut out = Vec::new(); + for (i, lot) in leaves.iter().enumerate() { + let roll = (splitmix64(seed.seed() ^ (i as u64 + 1)) % 100) as u8; + if roll >= coverage { + continue; // interstitial gap + } + let w = lot.size.0.saturating_sub(setback); + let h = lot.size.1.saturating_sub(setback); + if w >= 1 && h >= 1 { + out.push(TileRect::new(lot.origin.0, lot.origin.1, w, h)); + } + } + out +} + +/// Tag every building footprint in one block (D-229). Reserved blocks (parks, +/// terminals, plazas) are open/special-use and get no standard building fill. +fn assign_block_tags( + block: &BlockSkeleton, + skeleton: &QuarterSkeleton, + context: &CityGenerationContext, + economic_role: &str, + founding_age_years: u32, + block_chain: SeedChain, +) -> Vec { + if block.reservation.is_some() { + return Vec::new(); + } + let prosperity = context.prosperity_baseline_bps; + let setting = &context.surrounding_biome; + let flavor_n = context.trait_selection.len().max(1) as u64; + + let footprints = subdivide_block_footprints( + block.density_pct, + &context.dominant_bulk_class, + &context.morphology_zone, + block_chain, + ); + + footprints + .into_iter() + .enumerate() + .map(|(i, footprint)| { + let fp_chain = block_chain.derive(SeedDomain::Block, i as u64 + 1); + let zone_type_id = zone_type_for(&block.zoning, economic_role, setting, fp_chain); + let entry_class = + building_entry_class(&block.zoning, &skeleton.layout_mode, prosperity); + let (era, era_cause) = construction_era(founding_age_years, prosperity, fp_chain); + let initial = initial_condition(prosperity, &era_cause); + let extent = floor_extent(block.density_pct, fp_chain); + // D-232: deterministic (seed + zone_type) → flavor index into the + // body's K-tag trait selection. + let flavor_index = + (splitmix64(fp_chain.seed() ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8; + BuildingPropertyTag { + zone_type_id, + footprint, + extent, + entry_class, + flavor_ref: ArchitectureFlavorRef { flavor_index }, + era, + era_cause, + initial_condition: initial, + doors: Vec::new(), // D-231 door derivation is #979 + } + }) + .collect() +} + +/// Stable hash of a zone-type id for the D-232 flavor draw (FNV-1a over bytes). +fn zone_type_hash(id: &ZoneTypeId) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in id.as_str().bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag +/// every non-reserved block's footprints. Keyed by 4×4 block grid position. +pub fn assign_all_block_tags( + skeleton: &QuarterSkeleton, + context: &CityGenerationContext, + economic_role: &str, + founding_age_years: u32, + chain: SeedChain, +) -> BTreeMap<(u8, u8), Vec> { + let mut map = BTreeMap::new(); + for row in 0..4u8 { + for col in 0..4u8 { + let block = &skeleton.blocks[row as usize][col as usize]; + let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64); + let tags = assign_block_tags( + block, + skeleton, + context, + economic_role, + founding_age_years, + block_chain, + ); + if !tags.is_empty() { + map.insert((row, col), tags); + } + } + } + map +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1058,4 +1276,91 @@ mod tests { TileCondition::Broken ); } + + // ── Footprint subdivision + block-tag assembly (#957) ──────────────────── + + #[test] + fn footprints_fit_within_block_bounds() { + let fps = subdivide_block_footprints( + 70, + &BulkClass::NonPhysical, + &MorphologyZone::AlluvialPlain, + SeedChain::root(1), + ); + assert!(!fps.is_empty(), "a dense block should produce footprints"); + for fp in &fps { + assert!(fp.size.0 >= 1 && fp.size.1 >= 1); + assert!(fp.origin.0 as u16 + fp.size.0 as u16 <= BLOCK_TILES as u16); + assert!(fp.origin.1 as u16 + fp.size.1 as u16 <= BLOCK_TILES as u16); + } + } + + #[test] + fn denser_blocks_pack_more_footprints() { + let sparse = subdivide_block_footprints( + 15, + &BulkClass::NonPhysical, + &MorphologyZone::AlluvialPlain, + SeedChain::root(7), + ); + let dense = subdivide_block_footprints( + 85, + &BulkClass::NonPhysical, + &MorphologyZone::AlluvialPlain, + SeedChain::root(7), + ); + assert!( + dense.len() > sparse.len(), + "dense ({}) should pack more lots than sparse ({})", + dense.len(), + sparse.len() + ); + } + + #[test] + fn bulk_class_drives_coverage() { + // Open-yard bulk (BulkSolid, 25–40%) roofs less than information economy. + assert!(roofed_coverage_pct(100, &BulkClass::BulkSolid) <= 40); + assert!(roofed_coverage_pct(100, &BulkClass::NonPhysical) >= 85); + } + + #[test] + fn block_tags_cover_quarter_and_skip_reservations() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + let sk = + generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42)); + let tags = assign_all_block_tags(&sk, &ctx, "financial", 300, SeedChain::root(42)); + assert!(!tags.is_empty(), "quarter should produce building tags"); + // Park reservation blocks (1,2),(1,3),(2,2),(2,3) get no standard fill. + for &reserved in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] { + assert!( + !tags.contains_key(&reserved), + "reserved block {reserved:?} must not be tagged" + ); + } + // Every tag is a planetary (non-station) zone with ≥1 floor. + let banned = [ + "residential_station", + "extraction_space", + "port_space", + "rural_orbital", + ]; + for block in tags.values() { + for tag in block { + assert!(!banned.contains(&tag.zone_type_id.as_str())); + assert!(tag.extent.floor_count >= 1); + assert!(tag.doors.is_empty()); // D-231 doors deferred to #979 + } + } + } + + #[test] + fn block_tags_are_deterministic() { + let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); + let sk = + generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5)); + let a = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5)); + let b = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5)); + assert_eq!(a, b); + } } diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 5a4481784..96aab313e 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -1052,7 +1052,8 @@ pub struct BlockSkeleton { pub reservation: Option, pub chunk_layout: ChunkLayout, pub hosted_sites: Vec, - pub era: Era, + // Per-block `era: Era` (String stub) removed in #957 — construction era now + // lives per-footprint on `BuildingPropertyTag.era` (typed `ConstructionEra`). pub era_modifications: Vec, pub era_cause: Option, /// Build density percentage (0 = open/empty, 100 = fully built-up).