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:
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user