feat(simulation): quarter footprint subdivision + block-tag assembly (#957)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:08:19 +02:00
co-authored by Claude Opus 4.8
parent aaa94152d0
commit 728a27e1b4
3 changed files with 323 additions and 8 deletions
+11 -2
View File
@@ -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,
}),
}
}
+310 -5
View File
@@ -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 4060% 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<TileRect>) {
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<TileRect> {
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<BuildingPropertyTag> {
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<BuildingPropertyTag>> {
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, 2540%) 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);
}
}
+2 -1
View File
@@ -1052,7 +1052,8 @@ pub struct BlockSkeleton {
pub reservation: Option<ReservationId>,
pub chunk_layout: ChunkLayout,
pub hosted_sites: Vec<SocialSiteId>,
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<EraModification>,
pub era_cause: Option<EraCause>,
/// Build density percentage (0 = open/empty, 100 = fully built-up).