diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 78479da6d..15fda3c69 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -34,6 +34,7 @@ use crate::atlas::district_profile::BodyParams; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; use crate::atlas::shell::{fill_chunk, FilledChunk}; use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; +use crate::atlas::trait_catalog_reader::ExteriorCatalog; use crate::seed::SeedChain; use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState}; @@ -113,6 +114,15 @@ pub enum GenWorkItem { economic_role: String, population: i64, founding_age_years: u32, + /// D-235 exterior-grammar content (T-988): the trait-template catalog's + /// `visual_bundle`s plus the two sibling content tables + /// (`architecture_zone_bias`, `color_register_bands`), pre-resolved at + /// dispatch time — mirrors `context`'s own pre-resolution rationale, so + /// `assign_block_tags` never touches `systems.db` (T-987/D-230 purity). + /// A `Vec`/`BTreeMap`-backed struct is a handful of words on the stack + /// regardless of its contents' size (same reasoning already documented + /// for `FillChunk.block_tags` below), so this needs no `Box`. + exterior_catalog: ExteriorCatalog, }, /// Derive the building shell for one 64 m chunk of an existing quarter /// (D-230 derive phase, T-987). @@ -431,6 +441,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { economic_role, population, founding_age_years, + exterior_catalog, } => { // Build the Phase 1 skeleton from the pre-resolved D-199 context. // `economic_role`, `population`, and `founding_age_years` are the @@ -445,13 +456,16 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { *chain, ); // Step-3 building-property tags per footprint (D-229, #957): subdivide - // each block into building plots and tag them. + // each block into building plots and tag them. `exterior_catalog` + // (D-235, T-988) resolves each tag's BuildingExteriorTag in the + // same pass. let block_tags = assign_all_block_tags( &skeleton, context, economic_role, *founding_age_years, *chain, + exterior_catalog, ); GenCompletion::SkeletonGenerated { city_id: *city_id, @@ -591,6 +605,7 @@ mod tests { economic_role: "service_mixed".to_string(), population: 500_000, founding_age_years: 200, + exterior_catalog: ExteriorCatalog::default(), } } @@ -700,8 +715,10 @@ mod tests { fn quarter_with_one_building() -> QuarterWorldState { use crate::atlas::tile_condition::TileCondition; use crate::simulation::generator::{ - ArchitectureFlavorRef, BuildingEntryClass, BuildingPropertyTag, ConstructionEra, - EraCause, FloorExtent, FloorHeightProfile, QuarterSkeleton, TileRect, ZoneTypeId, + ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, BuildingPropertyTag, + ConstructionEra, EraCause, FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, + QuarterSkeleton, RoofForm, SetbackTier, StreetSurface, TileRect, WallMaterial, + ZoneTypeId, }; use std::collections::BTreeMap; @@ -721,6 +738,18 @@ mod tests { era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, + exterior: BuildingExteriorTag { + wall_material: WallMaterial::Generic, + roof_form: RoofForm::Generic, + facade_rhythm: FacadeRhythm::Generic, + setback_tier: SetbackTier::Standard, + color: HsvColor { + hue: 0, + sat: 0, + val: 5_000, + }, + street_surface: StreetSurface::Generic, + }, doors: Vec::new(), }], ); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 220c05628..5b3572223 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -33,6 +33,7 @@ pub mod subbiome; pub mod tile_condition; pub mod trait_catalog_reader; pub mod trait_draw; +pub mod trait_exterior; pub mod trait_swerve; pub mod voxel; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index f307ffcfa..04acd2a8f 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -27,7 +27,9 @@ use crate::atlas::road_graph::{RoadGraph, RoadNode}; use crate::atlas::scale; use crate::atlas::skeleton_gen::derive_complexity; use crate::atlas::source_resolver::BodySourceResolverResource; -use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate}; +use crate::atlas::trait_catalog_reader::{ + ExteriorCatalog, TraitBias, TraitCatalogReaderResource, TraitTemplate, +}; use crate::atlas::trait_draw::{ complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type, VocabularyDrawInputs, @@ -177,6 +179,25 @@ fn drain_generation_completions( ), None => (Vec::new(), Vec::new()), }; + // D-235 exterior-grammar content (T-988), read alongside the + // D-232 catalog above — same L3→L4 dispatch-time rationale + // (`assign_block_tags` stays DB-free downstream, T-987/D-230). + // `templates` reuses the already-fetched `catalog` (itself + // OnceLock-cached inside the reader) rather than re-querying. + let exterior_catalog: ExteriorCatalog = match trait_catalog.as_ref() { + Some(tc) => ExteriorCatalog { + templates: catalog.clone(), + zone_bias: tc.0.read_zone_bias().unwrap_or_else(|e| { + tracing::warn!(body_id = %body_id, error = %e, "zone bias read failed — uniform draw everywhere"); + BTreeMap::new() + }), + color_bands: tc.0.read_color_register_bands().unwrap_or_else(|e| { + tracing::warn!(body_id = %body_id, error = %e, "color register bands read failed — neutral color everywhere"); + BTreeMap::new() + }), + }, + None => ExteriorCatalog::default(), + }; let body_sector: Option<&str> = resolved .first() .and_then(|(_, rs)| rs.geographic_sector.as_deref()); @@ -208,6 +229,7 @@ fn drain_generation_completions( catalog: &catalog, eligible: &eligible, swerve_pools: &swerve_pools, + exterior_catalog: &exterior_catalog, }; for (placement, read_set) in resolved { @@ -364,6 +386,12 @@ struct BodyVocabularyContext<'a> { /// heritage-callback), cloned onto each settlement's context — the /// per-building wildcard draws from these at `assign_block_tags` time. swerve_pools: &'a SwervePools, + /// D-235 exterior-grammar content (T-988): the catalog's `visual_bundle`s + /// plus the two sibling content tables, read once per body and cloned + /// verbatim onto every settlement's `GenerateSkeleton` work item — the + /// per-building `BuildingExteriorTag` draw happens at `assign_block_tags` + /// time (`atlas::trait_exterior`), never inside `FillChunk`. + exterior_catalog: &'a ExteriorCatalog, } /// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's @@ -534,6 +562,7 @@ fn build_skeleton_work_item( economic_role, population, founding_age_years, + exterior_catalog: vocab.exterior_catalog.clone(), } } @@ -790,12 +819,19 @@ mod tests { foreign: Vec::new(), heritage: Vec::new(), }; + // `ExteriorCatalog::default()` isn't a const fn (derived `Default`), + // so a `static` binding isn't available the way it is for + // `EMPTY_POOLS` above — leak a tiny one-off value instead (test-only, + // matches this function's existing 'static-returning contract). + let exterior_catalog: &'static ExteriorCatalog = + Box::leak(Box::new(ExteriorCatalog::default())); BodyVocabularyContext { trait_selection: &[], body_district_type_mix: &[], catalog: &[], eligible: &[], swerve_pools: &EMPTY_POOLS, + exterior_catalog, } } @@ -902,6 +938,7 @@ mod tests { base_weight: 10_000, weight_mods: BTreeMap::new(), zone_affinity: [(DistrictType::MixedUse, 10_000)].into_iter().collect(), + visual_bundle: Default::default(), } } let catalog = vec![tmpl("temp_a"), tmpl("temp_b")]; @@ -912,12 +949,14 @@ mod tests { foreign: vec![("foreign_x".to_string(), 10_000)], heritage: vec![("herit_y".to_string(), 10_000)], }; + let exterior_catalog = ExteriorCatalog::default(); let vocab = BodyVocabularyContext { trait_selection: &trait_selection, body_district_type_mix: &mix, catalog: &catalog, eligible: &eligible, swerve_pools: &pools, + exterior_catalog: &exterior_catalog, }; // City 1 sits in the road graph with degree 2; city 2 has no node (degree 0). diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs index 4d91d80fa..95eb77a1a 100644 --- a/server/src/atlas/shell.rs +++ b/server/src/atlas/shell.rs @@ -54,7 +54,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::atlas::scale::{CHUNKS_PER_BLOCK, CHUNK_M, VOXELS_PER_CHUNK}; -use crate::simulation::generator::{BuildingPropertyTag, TileRect}; +use crate::simulation::generator::{BuildingPropertyTag, RoofForm, TileRect, WallMaterial}; /// One structural shell voxel material (D-230). /// @@ -63,7 +63,8 @@ use crate::simulation::generator::{BuildingPropertyTag, TileRect}; /// full four-way classification and so callers can match exhaustively. /// /// Integer-discriminant, append-only (D-010). Surface materials (D-235 `WallMaterial` -/// etc.) are a separate axis layered on top by T-988 — do not fold them in here. +/// etc.) are a separate axis layered on top — see [`SurfaceMaterial`] / T-988/T-959 — +/// do not fold them in here; this enum stays the pure structural shape. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[repr(u8)] pub enum ShellVoxel { @@ -78,6 +79,18 @@ pub enum ShellVoxel { Roof = 3, } +/// The D-235 surface material "layered on top of" a [`ShellVoxel::Wall`] or +/// [`ShellVoxel::Roof`] voxel (T-959/T-988) — read straight from the covering +/// building's frozen `BuildingPropertyTag.exterior`, never re-derived here +/// (this layer stays pure/cache-free, T-987). Absent for `Void`/`FloorSlab` +/// positions: D-235's vocabulary has no wall/roof token for interior air or a +/// floor slab's top surface (that is the separate D-228 `FloorMaterial` axis). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SurfaceMaterial { + Wall(WallMaterial), + Roof(RoofForm), +} + /// Chunk-local voxel coordinate: `(x, y)` in `0..64`, `z` quarter-ground-relative. /// /// `x`/`y` are chunk-local tile indices (D-243: a chunk is 64×64 voxels). `z` is the @@ -100,6 +113,14 @@ pub struct FilledChunk { pub sub_chunk: (u8, u8), /// Non-`Void` shell voxels, keyed by chunk-local position (D-010 ordered). pub voxels: BTreeMap, + /// D-235 surface material for every `Wall`/`Roof` entry in `voxels` + /// (T-959/T-988) — keyed identically, so a consumer joins the two maps by + /// position. Strictly additive over `voxels`: every key here has a + /// matching `Wall`/`Roof` entry there, but not vice versa is NOT + /// guaranteed to be exhaustive on a stale/foreign tag (see + /// `shell_derive_into`'s doc comment) — a missing entry means "render the + /// generic fallback for this axis," never a panic. + pub surface_material: BTreeMap, } impl FilledChunk { @@ -142,6 +163,7 @@ pub fn fill_chunk( "sub_chunk {sub_chunk:?} outside the block's {CHUNKS_PER_BLOCK}×{CHUNKS_PER_BLOCK} chunk grid" ); let mut voxels: BTreeMap = BTreeMap::new(); + let mut surface_material: BTreeMap = BTreeMap::new(); // Block-local tile range covered by this 64 m sub-chunk quadrant. let chunk_lo_x = sub_chunk.0 as i32 * CHUNK_M; @@ -152,6 +174,7 @@ pub fn fill_chunk( for tag in block_tags { shell_derive_into( &mut voxels, + &mut surface_material, tag, (chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y), ); @@ -162,17 +185,23 @@ pub fn fill_chunk( block_pos, sub_chunk, voxels, + surface_material, } } -/// Emit one building's shell voxels into `voxels`, clipped to the chunk's block-local -/// tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive). +/// Emit one building's shell voxels into `voxels` (plus their D-235 surface +/// material into `surface_material`, T-959/T-988), clipped to the chunk's +/// block-local tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive). /// /// Rectangle-containment (footprint ∩ chunk) × z-range (per-floor voxel bands from the /// [`FloorExtent`]), per D-230. Walls on the footprint perimeter for the full height, /// floor slabs on interior tiles at each floor base, a roof cap above the top floor. +/// Every `Wall`/`Roof` voxel this pass inserts gets a matching `surface_material` +/// entry from `tag.exterior` at the same position — the "layered on top of this +/// shell" T-988 promised. fn shell_derive_into( voxels: &mut BTreeMap, + surface_material: &mut BTreeMap, tag: &BuildingPropertyTag, window: (i32, i32, i32, i32), ) { @@ -239,6 +268,14 @@ fn shell_derive_into( continue; // interior air → Void, not stored }; voxels.insert((cx, cy, z), material); + if perimeter { + // D-235 (T-959/T-988): every Wall voxel carries the + // building's frozen exterior wall material alongside it. + surface_material.insert( + (cx, cy, z), + SurfaceMaterial::Wall(tag.exterior.wall_material), + ); + } } } } @@ -257,6 +294,10 @@ fn shell_derive_into( let cx = (tx - win_lo_x) as u8; let cy = (ty - win_lo_y) as u8; voxels.insert((cx, cy, rz), ShellVoxel::Roof); + // D-235 (T-959/T-988): every Roof voxel carries the building's + // frozen exterior roof form alongside it. + surface_material + .insert((cx, cy, rz), SurfaceMaterial::Roof(tag.exterior.roof_form)); } } } @@ -287,8 +328,9 @@ mod tests { use super::*; use crate::atlas::tile_condition::TileCondition; use crate::simulation::generator::{ - ArchitectureFlavorRef, BuildingEntryClass, ConstructionEra, EraCause, FloorExtent, - FloorHeightProfile, ZoneTypeId, + ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, ConstructionEra, EraCause, + FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, RoofForm, SetbackTier, + StreetSurface, WallMaterial, ZoneTypeId, }; /// Build a `BuildingPropertyTag` with the given block-local footprint and a @@ -312,6 +354,18 @@ mod tests { era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, + exterior: BuildingExteriorTag { + wall_material: WallMaterial::ConcreteWall, + roof_form: RoofForm::FlatRoof, + facade_rhythm: FacadeRhythm::RegularFacade, + setback_tier: SetbackTier::Standard, + color: HsvColor { + hue: 0, + sat: 0, + val: 5_000, + }, + street_surface: StreetSurface::Paved, + }, doors: Vec::new(), } } @@ -454,4 +508,76 @@ mod tests { fc.voxel_count() ); } + + // ── D-235 surface material — "layered on top of this shell" (T-959/T-988) ── + + #[test] + fn wall_voxels_carry_the_buildings_wall_material() { + let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + // Corner (2,2) is perimeter → Wall, per the existing shell test above. + assert_eq!(fc.get(2, 2, 0), ShellVoxel::Wall); + assert_eq!( + fc.surface_material.get(&(2, 2, 0)), + Some(&SurfaceMaterial::Wall(WallMaterial::ConcreteWall)) + ); + } + + #[test] + fn roof_voxels_carry_the_buildings_roof_form() { + let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + assert_eq!(fc.get(2, 2, 3), ShellVoxel::Roof); + assert_eq!( + fc.surface_material.get(&(2, 2, 3)), + Some(&SurfaceMaterial::Roof(RoofForm::FlatRoof)) + ); + } + + #[test] + fn floor_slab_and_void_carry_no_surface_material() { + let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + // Interior tile (3,3): FloorSlab at the base, Void above — see the + // shipped `single_storey_box_has_walls_floor_and_roof` test. + assert_eq!(fc.get(3, 3, 0), ShellVoxel::FloorSlab); + assert!(!fc.surface_material.contains_key(&(3, 3, 0))); + assert!(!fc.surface_material.contains_key(&(0, 0, 0))); // outside footprint (Void) + } + + #[test] + fn surface_material_count_matches_wall_plus_roof_voxels() { + let fc = fill_chunk( + 1, + (0, 0), + (0, 0), + &[tag((0, 0), (5, 5), 0, 3), tag((40, 40), (10, 8), -1, 2)], + ); + let wall_and_roof = fc + .voxels + .values() + .filter(|v| matches!(v, ShellVoxel::Wall | ShellVoxel::Roof)) + .count(); + assert_eq!(fc.surface_material.len(), wall_and_roof); + } + + #[test] + fn distinct_buildings_carry_their_own_distinct_materials() { + // Two buildings in the same chunk with different exterior tags must + // never bleed into each other's surface_material — this is per-tag + // data, not a chunk-wide constant. + let mut b1 = tag((0, 0), (5, 5), 0, 1); + b1.exterior.wall_material = WallMaterial::StoneWall; + b1.exterior.roof_form = RoofForm::VaultedRoof; + let mut b2 = tag((40, 40), (5, 5), 0, 1); + b2.exterior.wall_material = WallMaterial::TimberWall; + b2.exterior.roof_form = RoofForm::PitchedRoof; + + let fc = fill_chunk(1, (0, 0), (0, 0), &[b1, b2]); + assert_eq!( + fc.surface_material.get(&(0, 0, 0)), + Some(&SurfaceMaterial::Wall(WallMaterial::StoneWall)) + ); + assert_eq!( + fc.surface_material.get(&(40, 40, 0)), + Some(&SurfaceMaterial::Wall(WallMaterial::TimberWall)) + ); + } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 4040cb89a..b90577673 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -21,6 +21,8 @@ use crate::atlas::block_irregularity::block_irregularity; use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::tile_condition::{tile_condition, TileCondition}; +use crate::atlas::trait_catalog_reader::ExteriorCatalog; +use crate::atlas::trait_exterior; use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates}; use crate::seed::splitmix64; use crate::seed::{SeedChain, SeedDomain}; @@ -30,9 +32,9 @@ use crate::simulation::generator::{ AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext, ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause, - FloorExtent, FloorHeightProfile, FoundingOrientation, MorphologyZone, MultiBlockReservation, - PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, - SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType, + FloorExtent, FloorHeightProfile, FoundingOrientation, InterstitialCharacter, MorphologyZone, + MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, + ReservationId, SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType, }; // --------------------------------------------------------------------------- @@ -104,7 +106,12 @@ pub fn generate_quarter_skeleton( .first() .cloned() .unwrap_or(DistrictType::MixedUse); - let mut blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type); + let mut blocks = build_block_grid( + &mix.districts, + &block_reservation, + &primary_district_type, + &context.dominant_bulk_class, + ); // ── Street network (D-234) ──────────────────────────────────────────── // Local lattice modulation per block (Grid vs Organic), then the quarter's @@ -322,15 +329,24 @@ fn district_type_from_mix(primary: &DistrictType) -> DistrictType { /// /// Blocks are assigned row-major (index = row * 4 + col). /// Reserved blocks retain their zoning from the district mix but link to the reservation. +/// +/// `dominant_bulk_class` sets every block's `interstitial_character` (D-233, +/// T-1097): the settlement-wide bulk-class signal already drives +/// `roofed_coverage_pct`/`subdivide_block_footprints` uniformly across every +/// block regardless of that block's own zoning (an existing simplification, +/// not a new one) — the ops-surface classification follows the identical +/// granularity for consistency. fn build_block_grid( districts: &[DistrictType], block_reservation: &[[Option; 4]; 4], primary: &DistrictType, + dominant_bulk_class: &BulkClass, ) -> [[BlockSkeleton; 4]; 4] { // Pad or truncate district list to exactly 16. let district_iter: Vec<&DistrictType> = (0..16) .map(|i| districts.get(i).unwrap_or(primary)) .collect(); + let interstitial_character = interstitial_character_for(dominant_bulk_class); core::array::from_fn(|row| { core::array::from_fn(|col| { @@ -356,11 +372,24 @@ fn build_block_grid( era_cause: None, density_pct: density, landmark: None, + interstitial_character, } }) }) } +/// D-233 ops-surface classification (T-1097): bulk-industry settlements read +/// their blocks' non-roofed remainder as built economic infrastructure, never +/// generic interstitial open space. +fn interstitial_character_for(bulk: &BulkClass) -> InterstitialCharacter { + match bulk { + BulkClass::BulkSolid | BulkClass::BulkLiquid => InterstitialCharacter::OperationsSurface, + BulkClass::PrecisionDense | BulkClass::Perishable | BulkClass::NonPhysical => { + InterstitialCharacter::OpenSpace + } + } +} + /// Default build density percentage for a zoning type. fn density_for_zoning(zoning: &ZoningType) -> u8 { match zoning { @@ -859,6 +888,10 @@ fn subdivide_block_footprints( /// Tag every building footprint in one block (D-229). Reserved blocks (parks, /// terminals, plazas) are open/special-use and get no standard building fill. +/// +/// `exterior_catalog` is the D-235 exterior-grammar content (T-988), +/// pre-resolved at L3→L4 dispatch time alongside the rest of the D-232 catalog +/// data — this function never touches `systems.db` (T-987/D-230 purity). fn assign_block_tags( block: &BlockSkeleton, skeleton: &QuarterSkeleton, @@ -867,6 +900,7 @@ fn assign_block_tags( founding_age_years: u32, waterfront: Option, block_chain: SeedChain, + exterior_catalog: &ExteriorCatalog, ) -> Vec { if block.reservation.is_some() { return Vec::new(); @@ -937,6 +971,35 @@ fn assign_block_tags( Some(tag) => ArchitectureFlavorRef::Swerve(tag), None => district_dominant.clone(), }; + // D-235 (T-988): resolve the exterior grammar from this SAME + // finalized flavor_ref, at the same plan-time point — "Swerve(tag) + // refs resolve against the full catalog at the same point" (the + // T-994 phase-timing precedent this ticket follows). InVocabulary + // indexes into context.trait_selection; an out-of-bounds index or + // a Swerve tag absent from the catalog (a content gap, or a test + // fixture with no catalog wired) resolves to `None` — the + // derivation degrades to Generic/neutral rather than panicking. + let resolved_tag: Option<&str> = match &flavor_ref { + ArchitectureFlavorRef::InVocabulary(idx) => context + .trait_selection + .get(*idx as usize) + .map(String::as_str), + ArchitectureFlavorRef::Swerve(tag) => Some(tag.as_str()), + }; + let template = resolved_tag + .and_then(|tag| exterior_catalog.templates.iter().find(|t| t.tag == tag)); + let zone_bias = resolved_tag.and_then(|tag| { + exterior_catalog + .zone_bias + .get(&(tag.to_string(), zone_type_id.as_str().to_string())) + }); + let exterior = trait_exterior::derive_building_exterior( + template, + zone_bias, + &exterior_catalog.color_bands, + block.density_pct, + fp_chain, + ); BuildingPropertyTag { zone_type_id, footprint, @@ -946,6 +1009,7 @@ fn assign_block_tags( era, era_cause, initial_condition: initial, + exterior, doors: Vec::new(), // D-231 door derivation is #979 } }) @@ -954,12 +1018,16 @@ fn assign_block_tags( /// 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. +/// +/// `exterior_catalog` is the D-235 exterior-grammar content (T-988) — +/// threaded straight through to [`assign_block_tags`]; see its doc comment. pub fn assign_all_block_tags( skeleton: &QuarterSkeleton, context: &CityGenerationContext, economic_role: &str, founding_age_years: u32, chain: SeedChain, + exterior_catalog: &ExteriorCatalog, ) -> BTreeMap<(u8, u8), Vec> { // Water-facing quarter edge from the settlement's coastal founding // orientation (D-234b); blocks on it present flush to the quay. @@ -978,6 +1046,7 @@ pub fn assign_all_block_tags( founding_age_years, waterfront, block_chain, + exterior_catalog, ); if !tags.is_empty() { map.insert((row, col), tags); @@ -1685,7 +1754,14 @@ mod tests { 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)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 300, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); 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)] { @@ -1715,8 +1791,22 @@ mod tests { 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)); + let a = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &ExteriorCatalog::default(), + ); + let b = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &ExteriorCatalog::default(), + ); assert_eq!(a, b); } @@ -1738,7 +1828,14 @@ mod tests { ); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); let mut found_residential = false; for (pos, block_tags) in &tags { @@ -1768,7 +1865,14 @@ mod tests { // the harmless pre-T-994 degenerate behaviour. let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); for block_tags in tags.values() { for tag in block_tags { assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0)); @@ -1791,8 +1895,22 @@ mod tests { ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); - let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags_a = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); + let tags_b = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)"); let mut total = 0usize; @@ -1830,7 +1948,14 @@ mod tests { ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; // rates stay (0, 0) from make_context let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); for block_tags in tags.values() { for tag in block_tags { assert!(matches!( @@ -1841,6 +1966,252 @@ mod tests { } } + // ── D-235 BuildingExteriorTag integration (T-988) ──────────────────────── + + use crate::atlas::trait_catalog_reader::{TraitTemplate, VisualBundle}; + use crate::simulation::generator::{FacadeRhythm, RoofForm, StreetSurface, WallMaterial}; + + fn exterior_template(tag: &str, wall: WallMaterial, roof: RoofForm) -> TraitTemplate { + TraitTemplate { + tag: tag.to_string(), + corridor_pool: "cross_corridor".to_string(), + geographic_sector: None, + bulk_class_gate: Vec::new(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps: 0, + base_weight: 10_000, + weight_mods: BTreeMap::new(), + zone_affinity: BTreeMap::new(), + visual_bundle: VisualBundle { + wall: vec![wall], + roof: vec![roof], + facade: vec![FacadeRhythm::IndustrialGlazing], + street: vec![StreetSurface::HeavyHaul], + color_register: None, + fallback: BTreeMap::new(), + }, + } + } + + #[test] + fn assign_block_tags_resolves_exterior_from_in_vocabulary_flavor_ref() { + // The InVocabulary(0) path: flavor_ref indexes into trait_selection, + // which must resolve against exterior_catalog.templates to drive the + // frozen BuildingExteriorTag — the T-988 deliverable. + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + ctx.trait_selection = vec!["camp".to_string()]; + ctx.district_dominant_by_type.insert( + DistrictType::Residential, + ArchitectureFlavorRef::InVocabulary(0), + ); + let exterior_catalog = ExteriorCatalog { + templates: vec![exterior_template( + "camp", + WallMaterial::SteelFrame, + RoofForm::CorrugatedRoof, + )], + zone_bias: BTreeMap::new(), + color_bands: BTreeMap::new(), + }; + + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &exterior_catalog, + ); + + let mut found_residential = false; + for (pos, block_tags) in &tags { + let block = &sk.blocks[pos.0 as usize][pos.1 as usize]; + if block.district_type != DistrictType::Residential { + continue; + } + found_residential = true; + let expected_setback = trait_exterior::derive_setback_tier(block.density_pct); + for tag in block_tags { + assert_eq!(tag.exterior.wall_material, WallMaterial::SteelFrame); + assert_eq!(tag.exterior.roof_form, RoofForm::CorrugatedRoof); + assert_eq!( + tag.exterior.setback_tier, expected_setback, + "setback_tier must follow the block's own density_pct (D-235 step 3)" + ); + } + } + assert!( + found_residential, + "expected at least one Residential block (D-194 pop-tier guarantee)" + ); + } + + #[test] + fn assign_block_tags_resolves_exterior_for_swerved_buildings_too() { + // "Swerve(tag) refs resolve against the full catalog at the same + // point" — a swerved building's exterior must come from ITS OWN + // (out-of-vocabulary) template, distinct from the district-dominant one. + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + ctx.trait_selection = vec!["own".to_string()]; + ctx.swerve_rates_bps = (10_000, 0); // always swerve, foreign only + ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)]; + + let exterior_catalog = ExteriorCatalog { + templates: vec![ + exterior_template("own", WallMaterial::ConcreteWall, RoofForm::FlatRoof), + exterior_template( + "foreign_temple", + WallMaterial::TimberWall, + RoofForm::ClayTileRoof, + ), + ], + zone_bias: BTreeMap::new(), + color_bands: BTreeMap::new(), + }; + + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &exterior_catalog, + ); + + let mut any_swerved = false; + for block_tags in tags.values() { + for tag in block_tags { + if matches!(&tag.flavor_ref, ArchitectureFlavorRef::Swerve(t) if t == "foreign_temple") + { + any_swerved = true; + assert_eq!(tag.exterior.wall_material, WallMaterial::TimberWall); + assert_eq!(tag.exterior.roof_form, RoofForm::ClayTileRoof); + } + } + } + assert!( + any_swerved, + "swerve_rates_bps=10_000 must swerve every building" + ); + } + + #[test] + fn assign_block_tags_falls_back_to_generic_when_template_unresolvable() { + // An InVocabulary index with no matching catalog entry (empty + // ExteriorCatalog, e.g. no catalog reader wired) must degrade to + // Generic/neutral, never panic. + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags( + &sk, + &ctx, + "financial", + 200, + SeedChain::root(42), + &ExteriorCatalog::default(), + ); + assert!(!tags.is_empty()); + for block_tags in tags.values() { + for tag in block_tags { + assert_eq!(tag.exterior.wall_material, WallMaterial::Generic); + assert_eq!(tag.exterior.roof_form, RoofForm::Generic); + } + } + } + + #[test] + fn assign_block_tags_is_deterministic_including_exterior() { + let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); + ctx.trait_selection = vec!["camp".to_string()]; + let exterior_catalog = ExteriorCatalog { + templates: vec![exterior_template( + "camp", + WallMaterial::BrickWall, + RoofForm::PitchedRoof, + )], + zone_bias: BTreeMap::new(), + color_bands: BTreeMap::new(), + }; + 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), + &exterior_catalog, + ); + let b = assign_all_block_tags( + &sk, + &ctx, + "manufacturing", + 250, + SeedChain::root(5), + &exterior_catalog, + ); + assert_eq!(a, b, "same seeds must produce identical exterior tags too"); + } + + // ── D-233 operations-surface (T-1097) ──────────────────────────────────── + + #[test] + fn bulk_industry_blocks_read_as_operations_surface() { + assert_eq!( + interstitial_character_for(&BulkClass::BulkSolid), + InterstitialCharacter::OperationsSurface + ); + assert_eq!( + interstitial_character_for(&BulkClass::BulkLiquid), + InterstitialCharacter::OperationsSurface + ); + assert_eq!( + interstitial_character_for(&BulkClass::PrecisionDense), + InterstitialCharacter::OpenSpace + ); + assert_eq!( + interstitial_character_for(&BulkClass::Perishable), + InterstitialCharacter::OpenSpace + ); + assert_eq!( + interstitial_character_for(&BulkClass::NonPhysical), + InterstitialCharacter::OpenSpace + ); + } + + #[test] + fn every_block_in_a_bulk_industry_settlement_reads_operations_surface() { + let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); + ctx.dominant_bulk_class = BulkClass::BulkSolid; + let sk = + generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5)); + for row in &sk.blocks { + for block in row { + assert_eq!( + block.interstitial_character, + InterstitialCharacter::OperationsSurface + ); + } + } + } + + #[test] + fn non_bulk_settlement_keeps_open_space() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + // make_context defaults dominant_bulk_class to NonPhysical. + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + for row in &sk.blocks { + for block in row { + assert_eq!( + block.interstitial_character, + InterstitialCharacter::OpenSpace + ); + } + } + } + // ── Street network (#957, D-234) ───────────────────────────────────────── use crate::simulation::generator::AccessKind; diff --git a/server/src/atlas/trait_catalog_reader.rs b/server/src/atlas/trait_catalog_reader.rs index d9cb97f0a..9d3796eff 100644 --- a/server/src/atlas/trait_catalog_reader.rs +++ b/server/src/atlas/trait_catalog_reader.rs @@ -14,17 +14,23 @@ //! table; it never writes to `systems.db`. //! //! **Scope (T-994):** only the fields the three-phase draw mechanism needs are -//! parsed — `allow_tags`/`block_tags`/`era_scope`/`visual_bundle`/ -//! `cultural_description` are D-235 (visual bundle resolution) territory and are -//! left unparsed here. +//! parsed — `allow_tags`/`block_tags`/`era_scope`/`cultural_description` stay +//! unparsed (never consumed downstream of the draw). `visual_bundle` — D-235 +//! visual-bundle resolution territory — is parsed as of T-988, alongside the +//! two sibling D-235 content tables (`architecture_zone_bias`, +//! `color_register_bands`) this reader also owns. +use std::collections::BTreeMap; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use rusqlite::{Connection, OpenFlags}; use thiserror::Error; -use crate::simulation::generator::{BulkClass, DistrictType, ProductionUbiquity}; +use crate::simulation::generator::{ + BulkClass, DistrictType, FacadeRhythm, ProductionUbiquity, RoofForm, StreetSurface, + WallMaterial, +}; // --------------------------------------------------------------------------- // Error type @@ -76,6 +82,35 @@ pub struct TraitTemplate { /// `DistrictType` → weight_bps. The phase-2 dominant-template pick's sole /// input (D-232: "district-dominant by zone_affinity"). pub zone_affinity: std::collections::BTreeMap, + /// D-235 visual bundle (T-988): the per-axis token set this template's + /// buildings are filtered to, plus the `color_register` they sample from. + /// Empty/`None` fields when the column is absent (bootstrap/test catalogs). + pub visual_bundle: VisualBundle, +} + +/// Parsed `visual_bundle` column (D-235, T-988) — the D-235-step-1 filtered +/// token set per axis, this template's color register, and the (illustrative, +/// non-exhaustive — D-235 amendment) per-template asset fallback chain. +/// +/// `wall`/`roof`/`facade`/`street` are the axes `BuildingExteriorTag` draws +/// from (`atlas::trait_exterior`); never mixed across axes (a template is a +/// coherent bundle, D-232). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VisualBundle { + pub wall: Vec, + pub roof: Vec, + pub facade: Vec, + pub street: Vec, + /// Key into `color_register_bands` (T-988). `None` for a template with no + /// authored register (bootstrap/test catalogs) — callers fall back to a + /// neutral default color rather than treating it as an error. + pub color_register: Option, + /// Specific token → generic-parent token (D-235 asset-resolution fallback + /// chain). Illustrative/non-exhaustive per-template documentation, NOT the + /// validated fallback graph (that lives in `object_tag_vocabulary.toml`, + /// V-TT-04) — carried for completeness; Phase-4 generation never reads it + /// (render-fidelity degrade is a Phase 5+ asset-resolution concern). + pub fallback: BTreeMap, } /// Bias kind on an `atlas_body_trait_bias` row (D-232 hero-body wiki bias). @@ -98,6 +133,45 @@ pub struct TraitBias { pub weight_multiplier_bps: Option, } +/// One `(template, zone_type)` D-235 step-2 bias entry (T-988): per-axis +/// token weight overrides (basis points) within that template's own filtered +/// `visual_bundle`. Only axes/tokens the wiki authored a lean for are +/// present — an axis with no entry (or a token missing from a present axis +/// entry) uses the uniform baseline (`architecture_zone_bias.toml`'s header). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ZoneBias { + pub wall: BTreeMap, + pub roof: BTreeMap, + pub facade: BTreeMap, + pub street: BTreeMap, +} + +/// One integer HSV sampling band for a `color_register` value (D-235, +/// T-988). `hue` is centidegrees (0..36000); `sat`/`val` are basis points +/// (0..10000) — see `color_register_bands.toml`'s header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorBand { + pub hue_min: u32, + pub hue_max: u32, + pub sat_min: u32, + pub sat_max: u32, + pub val_min: u32, + pub val_max: u32, +} + +/// The full D-235 exterior-grammar content bundle (T-988): the template +/// catalog plus the two sibling tables that bias and color-sample within it. +/// Read once per body dispatch ([`TraitCatalogReader::read_exterior_catalog`]) +/// and threaded verbatim through every settlement's `GenerateSkeleton` work +/// item — `assign_block_tags` (`atlas::skeleton_gen`) never touches +/// `systems.db` (T-987/D-230 purity). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ExteriorCatalog { + pub templates: Vec, + pub zone_bias: BTreeMap<(String, String), ZoneBias>, + pub color_bands: BTreeMap, +} + // --------------------------------------------------------------------------- // Reader // --------------------------------------------------------------------------- @@ -114,6 +188,12 @@ pub struct TraitCatalogReader { /// every later body dispatch clones the parsed rows. Per-body bias is NOT /// cached — it legitimately varies per body. catalog_cache: OnceLock>, + /// One-shot cache for `architecture_zone_bias` (T-988) — same immutable- + /// per-run rationale as `catalog_cache`. + zone_bias_cache: OnceLock>, + /// One-shot cache for `color_register_bands` (T-988) — same immutable- + /// per-run rationale as `catalog_cache`. + color_bands_cache: OnceLock>, } impl TraitCatalogReader { @@ -124,6 +204,8 @@ impl TraitCatalogReader { Ok(Self { conn: Arc::new(Mutex::new(conn)), catalog_cache: OnceLock::new(), + zone_bias_cache: OnceLock::new(), + color_bands_cache: OnceLock::new(), }) } @@ -150,7 +232,7 @@ impl TraitCatalogReader { .prepare( "SELECT tag, corridor_pool, geographic_sector, bulk_class_gate, production_ubiquity_gate, min_prosperity_bps, base_weight, - weight_mods, zone_affinity + weight_mods, zone_affinity, visual_bundle FROM trait_templates ORDER BY tag", ) @@ -167,6 +249,7 @@ impl TraitCatalogReader { row.get::<_, i64>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, )) }) .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; @@ -183,6 +266,7 @@ impl TraitCatalogReader { base_weight, weight_mods_json, zone_affinity_json, + visual_bundle_json, ) = r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; out.push(TraitTemplate { @@ -195,6 +279,7 @@ impl TraitCatalogReader { base_weight: base_weight.max(0) as u32, weight_mods: parse_weight_mods(weight_mods_json.as_deref(), &tag), zone_affinity: parse_zone_affinity(zone_affinity_json.as_deref(), &tag), + visual_bundle: parse_visual_bundle(visual_bundle_json.as_deref(), &tag), tag, corridor_pool, geographic_sector, @@ -249,6 +334,131 @@ impl TraitCatalogReader { } Ok(out) } + + /// Read the D-235 step-2 zone-type bias table (T-988), keyed by + /// `(template_tag, zone_type_id)`. Sparse by design (`architecture_zone_bias.toml`'s + /// own header): a missing key means "uniform draw", not an error — see + /// `atlas::trait_exterior`. Cached after the first successful read (H5, + /// same rationale as [`Self::read_catalog`]). + pub fn read_zone_bias( + &self, + ) -> Result, TraitCatalogReadError> { + if let Some(cached) = self.zone_bias_cache.get() { + return Ok(cached.clone()); + } + let map = self.read_zone_bias_uncached()?; + let _ = self.zone_bias_cache.set(map.clone()); + Ok(map) + } + + fn read_zone_bias_uncached( + &self, + ) -> Result, TraitCatalogReadError> { + let conn = self + .conn + .lock() + .map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT template_tag, zone_type_id, bias + FROM architecture_zone_bias + ORDER BY template_tag, zone_type_id", + ) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + let mut out = BTreeMap::new(); + for r in rows { + let (template_tag, zone_type_id, bias_json) = + r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let key_label = format!("{template_tag}.{zone_type_id}"); + let bias = parse_zone_bias(&bias_json, &key_label); + out.insert((template_tag, zone_type_id), bias); + } + Ok(out) + } + + /// Read the D-235 color-register HSV bands (T-988), keyed by + /// `color_register`. Cached after the first successful read (H5, same + /// rationale as [`Self::read_catalog`]). + pub fn read_color_register_bands( + &self, + ) -> Result, TraitCatalogReadError> { + if let Some(cached) = self.color_bands_cache.get() { + return Ok(cached.clone()); + } + let map = self.read_color_register_bands_uncached()?; + let _ = self.color_bands_cache.set(map.clone()); + Ok(map) + } + + fn read_color_register_bands_uncached( + &self, + ) -> Result, TraitCatalogReadError> { + let conn = self + .conn + .lock() + .map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max + FROM color_register_bands + ORDER BY color_register", + ) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + }) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + let mut out = BTreeMap::new(); + for r in rows { + let (color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max) = + r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + out.insert( + color_register, + ColorBand { + hue_min: hue_min.max(0) as u32, + hue_max: hue_max.max(0) as u32, + sat_min: sat_min.max(0) as u32, + sat_max: sat_max.max(0) as u32, + val_min: val_min.max(0) as u32, + val_max: val_max.max(0) as u32, + }, + ); + } + Ok(out) + } + + /// Read the full D-235 exterior-grammar catalog in one call (T-988): + /// the template catalog plus its two sibling content tables. Convenience + /// wrapper for callers (`atlas::plugin`'s L3→L4 dispatch) that need all + /// three to build an [`ExteriorCatalog`] to thread through a settlement's + /// `GenerateSkeleton` work item. + pub fn read_exterior_catalog(&self) -> Result { + Ok(ExteriorCatalog { + templates: self.read_catalog()?, + zone_bias: self.read_zone_bias()?, + color_bands: self.read_color_register_bands()?, + }) + } } // --------------------------------------------------------------------------- @@ -394,6 +604,204 @@ fn parse_zone_affinity( .collect() } +// --------------------------------------------------------------------------- +// D-235 ObjectTag axis parsing (T-988) — patch tolerance: an unrecognized +// registry string never fails the read, it degrades to the axis `Generic` +// variant with a `tracing::warn!` (matches the wider "unknown -> safe +// fallback" convention already used above for bias_kind/BulkClass/etc., but +// these four are the ONLY axes where the safe fallback is a *type-level* +// enum variant rather than an omission from a Vec/map). +// --------------------------------------------------------------------------- + +fn parse_wall_material(s: &str, tag: &str) -> WallMaterial { + match s { + "concrete_wall" => WallMaterial::ConcreteWall, + "steel_frame" => WallMaterial::SteelFrame, + "brick_wall" => WallMaterial::BrickWall, + "rendered_wall" => WallMaterial::RenderedWall, + "stone_wall" => WallMaterial::StoneWall, + "timber_wall" => WallMaterial::TimberWall, + "stucco_wall" => WallMaterial::StuccoWall, + "glass_curtain_wall" => WallMaterial::GlassCurtainWall, + "composite_panel" => WallMaterial::CompositePanel, + "rammed_earth_wall" => WallMaterial::RammedEarthWall, + "generic_wall" => WallMaterial::Generic, + other => { + tracing::warn!( + tag, + axis = "wall", + value = other, + "unrecognized WallMaterial token — falling back to Generic" + ); + WallMaterial::Generic + } + } +} + +fn parse_roof_form(s: &str, tag: &str) -> RoofForm { + match s { + "flat_roof" => RoofForm::FlatRoof, + "pitched_roof" => RoofForm::PitchedRoof, + "corrugated_roof" => RoofForm::CorrugatedRoof, + "clay_tile_roof" => RoofForm::ClayTileRoof, + "terraced_roof" => RoofForm::TerracedRoof, + "vaulted_roof" => RoofForm::VaultedRoof, + "green_roof" => RoofForm::GreenRoof, + "generic_roof" => RoofForm::Generic, + other => { + tracing::warn!( + tag, + axis = "roof", + value = other, + "unrecognized RoofForm token — falling back to Generic" + ); + RoofForm::Generic + } + } +} + +fn parse_facade_rhythm(s: &str, tag: &str) -> FacadeRhythm { + match s { + "regular_facade" => FacadeRhythm::RegularFacade, + "ornamental_facade" => FacadeRhythm::OrnamentalFacade, + "industrial_glazing" => FacadeRhythm::IndustrialGlazing, + "arcade_facade" => FacadeRhythm::ArcadeFacade, + "shuttered_facade" => FacadeRhythm::ShutteredFacade, + "screen_facade" => FacadeRhythm::ScreenFacade, + "colonnade" => FacadeRhythm::Colonnade, + "lattice_screen" => FacadeRhythm::LatticeScreen, + "generic_facade" => FacadeRhythm::Generic, + other => { + tracing::warn!( + tag, + axis = "facade", + value = other, + "unrecognized FacadeRhythm token — falling back to Generic" + ); + FacadeRhythm::Generic + } + } +} + +fn parse_street_surface(s: &str, tag: &str) -> StreetSurface { + match s { + "paved" => StreetSurface::Paved, + "cobble" => StreetSurface::Cobble, + "packed_earth" => StreetSurface::PackedEarth, + "canal_way" => StreetSurface::CanalWay, + "elevated_walkway" => StreetSurface::ElevatedWalkway, + "heavy_haul" => StreetSurface::HeavyHaul, + "boardwalk" => StreetSurface::Boardwalk, + "generic_street" => StreetSurface::Generic, + other => { + tracing::warn!( + tag, + axis = "street", + value = other, + "unrecognized StreetSurface token — falling back to Generic" + ); + StreetSurface::Generic + } + } +} + +/// Raw shape of the `visual_bundle` JSON column — deserialized once, then +/// each axis's string tokens are mapped through the `parse_*` functions above. +#[derive(serde::Deserialize)] +struct RawVisualBundle { + #[serde(default)] + wall: Vec, + #[serde(default)] + roof: Vec, + #[serde(default)] + facade: Vec, + #[serde(default)] + street: Vec, + color_register: Option, + #[serde(default)] + fallback: BTreeMap, +} + +fn parse_visual_bundle(json: Option<&str>, tag: &str) -> VisualBundle { + let Some(json) = json else { + return VisualBundle::default(); + }; + let raw: RawVisualBundle = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!(tag, error = %e, "malformed visual_bundle JSON — treating as empty"); + return VisualBundle::default(); + } + }; + VisualBundle { + wall: raw + .wall + .iter() + .map(|s| parse_wall_material(s, tag)) + .collect(), + roof: raw.roof.iter().map(|s| parse_roof_form(s, tag)).collect(), + facade: raw + .facade + .iter() + .map(|s| parse_facade_rhythm(s, tag)) + .collect(), + street: raw + .street + .iter() + .map(|s| parse_street_surface(s, tag)) + .collect(), + color_register: raw.color_register, + fallback: raw.fallback, + } +} + +/// Raw shape of one `architecture_zone_bias.bias` JSON column — each axis is +/// optional (a template/zone_type entry rarely biases every axis). +#[derive(serde::Deserialize, Default)] +struct RawZoneBias { + #[serde(default)] + wall: BTreeMap, + #[serde(default)] + roof: BTreeMap, + #[serde(default)] + facade: BTreeMap, + #[serde(default)] + street: BTreeMap, +} + +/// `label` is `"{template_tag}.{zone_type_id}"`, for warning context only. +fn parse_zone_bias(json: &str, label: &str) -> ZoneBias { + let raw: RawZoneBias = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!(label, error = %e, "malformed architecture_zone_bias JSON — treating as empty"); + return ZoneBias::default(); + } + }; + ZoneBias { + wall: raw + .wall + .into_iter() + .map(|(k, w)| (parse_wall_material(&k, label), w)) + .collect(), + roof: raw + .roof + .into_iter() + .map(|(k, w)| (parse_roof_form(&k, label), w)) + .collect(), + facade: raw + .facade + .into_iter() + .map(|(k, w)| (parse_facade_rhythm(&k, label), w)) + .collect(), + street: raw + .street + .into_iter() + .map(|(k, w)| (parse_street_surface(&k, label), w)) + .collect(), + } +} + // --------------------------------------------------------------------------- // Bevy resource wrapper // --------------------------------------------------------------------------- @@ -466,12 +874,13 @@ mod tests { "INSERT INTO trait_templates (tag, label, corridor_pool, geographic_sector, bulk_class_gate, production_ubiquity_gate, min_prosperity_bps, base_weight, - weight_mods, zone_affinity) + weight_mods, zone_affinity, visual_bundle) VALUES ('extraction_camp', 'Extraction Camp', 'cross_corridor', NULL, '[\"BulkSolid\",\"BulkLiquid\"]', '[\"MonopolySource\",\"Specialist\"]', 0, 12000, '{\"economic_role\":{\"mining\":20000},\"geographic_sector\":{\"east_reach\":13000}}', - '{\"Industrial\":20000,\"LogisticsHub\":13000}')", + '{\"Industrial\":20000,\"LogisticsHub\":13000}', + '{\"wall\":[\"steel_frame\",\"concrete_wall\"],\"roof\":[\"corrugated_roof\"],\"facade\":[\"industrial_glazing\"],\"street\":[\"heavy_haul\"],\"color_register\":\"oxide_and_dust\",\"fallback\":{\"steel_frame\":\"generic_wall\"}}')", [], ) .expect("insert extraction_camp"); @@ -489,6 +898,39 @@ mod tests { ) .expect("insert bias 2"); + // T-988: the two sibling D-235 content tables. + conn.execute_batch( + "CREATE TABLE architecture_zone_bias ( + template_tag TEXT NOT NULL, + zone_type_id TEXT NOT NULL, + bias TEXT NOT NULL, + PRIMARY KEY (template_tag, zone_type_id) + ); + CREATE TABLE color_register_bands ( + color_register TEXT PRIMARY KEY, + hue_min INTEGER NOT NULL, + hue_max INTEGER NOT NULL, + sat_min INTEGER NOT NULL, + sat_max INTEGER NOT NULL, + val_min INTEGER NOT NULL, + val_max INTEGER NOT NULL + );", + ) + .expect("create D-235 content tables"); + conn.execute( + "INSERT INTO architecture_zone_bias (template_tag, zone_type_id, bias) + VALUES ('extraction_camp', 'extraction_platform', '{\"wall\":{\"steel_frame\":18000}}')", + [], + ) + .expect("insert zone bias"); + conn.execute( + "INSERT INTO color_register_bands + (color_register, hue_min, hue_max, sat_min, sat_max, val_min, val_max) + VALUES ('oxide_and_dust', 2000, 3000, 1800, 3200, 3200, 4800)", + [], + ) + .expect("insert color band"); + drop(conn); path } @@ -580,4 +1022,117 @@ mod tests { .expect("read bias"); assert!(bias.is_empty()); } + + // ── D-235 visual_bundle parsing (T-988) ────────────────────────────────── + + #[test] + fn read_catalog_parses_visual_bundle() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let catalog = reader.read_catalog().expect("read catalog"); + let camp = catalog + .iter() + .find(|t| t.tag == "extraction_camp") + .expect("extraction_camp present"); + assert_eq!( + camp.visual_bundle.wall, + vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall] + ); + assert_eq!(camp.visual_bundle.roof, vec![RoofForm::CorrugatedRoof]); + assert_eq!( + camp.visual_bundle.facade, + vec![FacadeRhythm::IndustrialGlazing] + ); + assert_eq!(camp.visual_bundle.street, vec![StreetSurface::HeavyHaul]); + assert_eq!( + camp.visual_bundle.color_register.as_deref(), + Some("oxide_and_dust") + ); + assert_eq!( + camp.visual_bundle.fallback.get("steel_frame"), + Some(&"generic_wall".to_string()) + ); + + // generic_baseline has no visual_bundle column value in this fixture — + // must degrade to an empty bundle, never panic. + let baseline = catalog + .iter() + .find(|t| t.tag == "generic_baseline") + .expect("generic_baseline present"); + assert_eq!(baseline.visual_bundle, VisualBundle::default()); + } + + #[test] + fn parse_wall_material_unknown_falls_back_to_generic() { + assert_eq!( + parse_wall_material("nonexistent_token", "test"), + WallMaterial::Generic + ); + assert_eq!( + parse_roof_form("nonexistent_token", "test"), + RoofForm::Generic + ); + assert_eq!( + parse_facade_rhythm("nonexistent_token", "test"), + FacadeRhythm::Generic + ); + assert_eq!( + parse_street_surface("nonexistent_token", "test"), + StreetSurface::Generic + ); + } + + // ── architecture_zone_bias / color_register_bands readers (T-988) ─────── + + #[test] + fn read_zone_bias_parses_sparse_table() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let zone_bias = reader.read_zone_bias().expect("read zone bias"); + let key = ( + "extraction_camp".to_string(), + "extraction_platform".to_string(), + ); + let entry = zone_bias.get(&key).expect("entry present"); + assert_eq!(entry.wall.get(&WallMaterial::SteelFrame), Some(&18000)); + assert!(entry.roof.is_empty(), "unauthored axis stays empty"); + // A (template, zone_type) pair never authored is simply absent. + assert!(!zone_bias.contains_key(&( + "extraction_camp".to_string(), + "residential_surface".to_string() + ))); + } + + #[test] + fn read_color_register_bands_parses_table() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let bands = reader + .read_color_register_bands() + .expect("read color bands"); + let band = bands.get("oxide_and_dust").expect("band present"); + assert_eq!( + *band, + ColorBand { + hue_min: 2000, + hue_max: 3000, + sat_min: 1800, + sat_max: 3200, + val_min: 3200, + val_max: 4800, + } + ); + } + + #[test] + fn read_exterior_catalog_bundles_all_three() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let bundle = reader + .read_exterior_catalog() + .expect("read exterior catalog"); + assert_eq!(bundle.templates.len(), 2); + assert_eq!(bundle.zone_bias.len(), 1); + assert_eq!(bundle.color_bands.len(), 1); + } } diff --git a/server/src/atlas/trait_draw.rs b/server/src/atlas/trait_draw.rs index 5583e465e..6e16ed1b0 100644 --- a/server/src/atlas/trait_draw.rs +++ b/server/src/atlas/trait_draw.rs @@ -460,6 +460,7 @@ mod tests { base_weight, weight_mods: BTreeMap::new(), zone_affinity: zone_affinity.iter().cloned().collect(), + visual_bundle: Default::default(), } } diff --git a/server/src/atlas/trait_exterior.rs b/server/src/atlas/trait_exterior.rs new file mode 100644 index 000000000..f18d0b062 --- /dev/null +++ b/server/src/atlas/trait_exterior.rs @@ -0,0 +1,443 @@ +//! D-235 building-exterior visual grammar — the `BuildingExteriorTag` +//! derivation (T-988). +//! +//! **3-step derivation, era-free** (D-235 amendment / D-232 reframe — era is +//! never a material gate, it reads as maintenance/wear via the D-217 +//! condition layer instead): +//! +//! 1. The resolved trait template's `visual_bundle` filters each axis +//! (`wall`/`roof`/`facade`/`street`) to its own coherent token set — a +//! template is drawn together, never mixed across axes (D-232). +//! 2. The building's own `zone_type_id` biases the pick within that filtered +//! set (`architecture_zone_bias.toml`, sparse — uniform where unauthored). +//! 3. The block's `density_pct` sets `setback_tier` ([`derive_setback_tier`]). +//! +//! `color` is seed-picked uniformly within the template's `color_register` +//! band (`color_register_bands.toml`), independently of the four token axes. +//! +//! Like [`crate::atlas::trait_swerve`] and [`crate::atlas::trait_draw`], this +//! module is pure: the template/zone-bias/color-band lookups are resolved by +//! the caller ([`crate::atlas::skeleton_gen::assign_block_tags`]) from data +//! already threaded onto the work item (`ExteriorCatalog`, +//! [`crate::atlas::trait_catalog_reader`]) — this module never touches +//! `systems.db`, and the per-building roll happens at `GenerateSkeleton` plan +//! time, never inside `FillChunk` (T-987 purity). +//! +//! All numbers are integer (D-010): weights are basis points, color +//! components are centidegrees/basis-points, and every RNG draw is an +//! independent [`SeedDomain::TraitExterior`] sub-chain per axis — sharing one +//! chain across axes previously correlated unrelated fields elsewhere in this +//! cascade (`skeleton_gen::assign_block_tags`'s own doc comment), so each of +//! wall/roof/facade/street/hue/sat/val gets its own derive here too. + +use std::collections::BTreeMap; + +use crate::atlas::trait_catalog_reader::{ColorBand, TraitTemplate, ZoneBias}; +use crate::seed::{AtlasRng, SeedChain, SeedDomain}; +use crate::simulation::generator::{ + BuildingExteriorTag, FacadeRhythm, HsvColor, RoofForm, SetbackTier, StreetSurface, WallMaterial, +}; + +/// Neutral fallback color (mid-grey, zero saturation) — used when a template +/// has no resolvable `color_register` (missing template, unauthored register, +/// or a register absent from `color_register_bands` — a content gap the +/// importer's V-TT-07 guards against for real content, but generation must +/// still degrade gracefully rather than panic on a stale/foreign template). +const NEUTRAL_COLOR: HsvColor = HsvColor { + hue: 0, + sat: 0, + val: 5_000, +}; + +/// Step 3 (D-235): setback tier from a block's build density. +/// +/// The five-tier vocabulary (`zero_lot | tight | standard | generous | +/// campus`) is fixed by D-235; these density-band **thresholds are +/// calibration placeholders** (T-988) pending Nigel/Araminta tuning — same +/// status as the swerve-rate constants (`trait_swerve`). Bands mirror the +/// density tables already used elsewhere in this cascade +/// (`skeleton_gen::floor_extent`, `roofed_coverage_pct`): Dense/Compressed +/// blocks (high `density_pct`) read tight/zero-lot; Frontier blocks (low +/// `density_pct`) read campus. +pub fn derive_setback_tier(density_pct: u8) -> SetbackTier { + match density_pct { + 0..=20 => SetbackTier::Campus, + 21..=45 => SetbackTier::Generous, + 46..=65 => SetbackTier::Standard, + 66..=85 => SetbackTier::Tight, + _ => SetbackTier::ZeroLot, + } +} + +/// Weighted pick of one token from `tokens` (a template's filtered +/// `visual_bundle.` list), biased by `bias` (the zone-type's authored +/// overrides for this axis, `None`/missing-token defaulting to the uniform +/// 10 000 bps baseline per `architecture_zone_bias.toml`'s own model). +/// +/// `None` only when `tokens` is empty (a template with zero tokens on this +/// axis — not expected from a well-formed catalog, but the caller must not +/// panic on a content gap or an unresolved template). +fn weighted_axis_pick( + tokens: &[T], + bias: Option<&BTreeMap>, + rng: &mut AtlasRng, +) -> Option { + if tokens.is_empty() { + return None; + } + let weight_of = + |t: &T| -> u64 { bias.and_then(|b| b.get(t)).copied().unwrap_or(10_000) as u64 }; + let total: u64 = tokens.iter().map(weight_of).sum(); + let mut roll = (rng.next_u32() as u64) % total.max(1); + for t in tokens { + let w = weight_of(t); + if roll < w { + return Some(*t); + } + roll -= w; + } + tokens.last().copied() +} + +/// Uniform integer sample within the inclusive range `[lo, hi]` (D-010 +/// integer-only). `lo` if the range is degenerate (`hi <= lo`). +fn sample_in_range(lo: u32, hi: u32, rng: &mut AtlasRng) -> u32 { + if hi <= lo { + return lo; + } + let span = (hi - lo) as u64 + 1; + lo + (rng.next_u32() as u64 % span) as u32 +} + +/// Color sub-step: seed-pick a single (hue, sat, val) point uniformly within +/// `color_register`'s band (D-235 — "always within the template's register"). +/// Falls back to [`NEUTRAL_COLOR`] when the template has no register, or the +/// register isn't in `color_bands` (with a warning in the latter case — a +/// genuine content gap the importer's V-TT-07 guards against). +fn sample_color( + color_register: Option<&str>, + color_bands: &BTreeMap, + chain: SeedChain, +) -> HsvColor { + let Some(register) = color_register else { + return NEUTRAL_COLOR; + }; + let Some(band) = color_bands.get(register) else { + tracing::warn!( + register, + "color_register not found in color_register_bands — falling back to neutral" + ); + return NEUTRAL_COLOR; + }; + let mut hue_rng = chain.derive(SeedDomain::TraitExterior, 4).atlas_rng(); + let mut sat_rng = chain.derive(SeedDomain::TraitExterior, 5).atlas_rng(); + let mut val_rng = chain.derive(SeedDomain::TraitExterior, 6).atlas_rng(); + HsvColor { + hue: sample_in_range(band.hue_min, band.hue_max, &mut hue_rng), + sat: sample_in_range(band.sat_min, band.sat_max, &mut sat_rng), + val: sample_in_range(band.val_min, band.val_max, &mut val_rng), + } +} + +/// Derive the frozen [`BuildingExteriorTag`] for one building (T-988). +/// +/// `template` is the building's resolved trait template — `None` when the +/// `flavor_ref` couldn't be resolved against the catalog (empty catalog, +/// out-of-bounds `InVocabulary` index, or a `Swerve` tag absent from the +/// catalog): every token axis degrades to `Generic`, color to +/// [`NEUTRAL_COLOR`], and `setback_tier` is still computed (it only needs +/// `density_pct`, not the template). `zone_bias` is the pre-looked-up +/// `(template_tag, zone_type_id)` entry from `ExteriorCatalog.zone_bias` — +/// `None` for the overwhelming majority of pairs (the table is sparse by +/// design), which is identical to an entry with no override for any token. +/// +/// `chain` should be a per-footprint sub-chain (mirroring the `fp_chain` used +/// for `flavor_ref`/`zone_type_id`/era/extent in `assign_block_tags`) — this +/// function derives its own `SeedDomain::TraitExterior` streams, one per axis, +/// from it. +pub fn derive_building_exterior( + template: Option<&TraitTemplate>, + zone_bias: Option<&ZoneBias>, + color_bands: &BTreeMap, + density_pct: u8, + chain: SeedChain, +) -> BuildingExteriorTag { + let setback_tier = derive_setback_tier(density_pct); + + let Some(template) = template else { + tracing::debug!( + "no resolved trait template for this building — exterior falls back to Generic/neutral" + ); + return BuildingExteriorTag { + wall_material: WallMaterial::default(), + roof_form: RoofForm::default(), + facade_rhythm: FacadeRhythm::default(), + setback_tier, + color: NEUTRAL_COLOR, + street_surface: StreetSurface::default(), + }; + }; + let vb = &template.visual_bundle; + + let mut wall_rng = chain.derive(SeedDomain::TraitExterior, 0).atlas_rng(); + let wall_material = + weighted_axis_pick(&vb.wall, zone_bias.map(|z| &z.wall), &mut wall_rng).unwrap_or_default(); + + let mut roof_rng = chain.derive(SeedDomain::TraitExterior, 1).atlas_rng(); + let roof_form = + weighted_axis_pick(&vb.roof, zone_bias.map(|z| &z.roof), &mut roof_rng).unwrap_or_default(); + + let mut facade_rng = chain.derive(SeedDomain::TraitExterior, 2).atlas_rng(); + let facade_rhythm = + weighted_axis_pick(&vb.facade, zone_bias.map(|z| &z.facade), &mut facade_rng) + .unwrap_or_default(); + + let mut street_rng = chain.derive(SeedDomain::TraitExterior, 3).atlas_rng(); + let street_surface = + weighted_axis_pick(&vb.street, zone_bias.map(|z| &z.street), &mut street_rng) + .unwrap_or_default(); + + let color = sample_color(vb.color_register.as_deref(), color_bands, chain); + + BuildingExteriorTag { + wall_material, + roof_form, + facade_rhythm, + setback_tier, + color, + street_surface, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn template_with_bundle(tag: &str, color_register: Option<&str>) -> TraitTemplate { + use crate::atlas::trait_catalog_reader::VisualBundle; + TraitTemplate { + tag: tag.to_string(), + corridor_pool: "cross_corridor".to_string(), + geographic_sector: None, + bulk_class_gate: Vec::new(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps: 0, + base_weight: 10_000, + weight_mods: BTreeMap::new(), + zone_affinity: BTreeMap::new(), + visual_bundle: VisualBundle { + wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall], + roof: vec![RoofForm::CorrugatedRoof], + facade: vec![FacadeRhythm::IndustrialGlazing, FacadeRhythm::RegularFacade], + street: vec![StreetSurface::HeavyHaul, StreetSurface::Paved], + color_register: color_register.map(str::to_string), + fallback: BTreeMap::new(), + }, + } + } + + fn oxide_band() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "oxide_and_dust".to_string(), + ColorBand { + hue_min: 2000, + hue_max: 3000, + sat_min: 1800, + sat_max: 3200, + val_min: 3200, + val_max: 4800, + }, + ); + m + } + + // ── derive_setback_tier ─────────────────────────────────────────────── + + #[test] + fn setback_tier_bands_match_density() { + assert_eq!(derive_setback_tier(10), SetbackTier::Campus); + assert_eq!(derive_setback_tier(30), SetbackTier::Generous); + assert_eq!(derive_setback_tier(50), SetbackTier::Standard); + assert_eq!(derive_setback_tier(75), SetbackTier::Tight); + assert_eq!(derive_setback_tier(95), SetbackTier::ZeroLot); + } + + #[test] + fn setback_tier_dense_to_frontier_orders_tight_to_campus() { + // D-235: "density sets setback_tier (Dense -> zero_lot -> ... -> + // Frontier -> campus)". + assert!(derive_setback_tier(95) < derive_setback_tier(10)); + } + + // ── derive_building_exterior: template present ─────────────────────── + + #[test] + fn resolved_template_picks_only_from_its_own_visual_bundle() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + for seed in 0..50u64 { + let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed)); + assert!(matches!( + tag.wall_material, + WallMaterial::SteelFrame | WallMaterial::ConcreteWall + )); + assert_eq!(tag.roof_form, RoofForm::CorrugatedRoof); + assert!(matches!( + tag.facade_rhythm, + FacadeRhythm::IndustrialGlazing | FacadeRhythm::RegularFacade + )); + assert!(matches!( + tag.street_surface, + StreetSurface::HeavyHaul | StreetSurface::Paved + )); + } + } + + #[test] + fn color_samples_within_the_registers_band() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + for seed in 0..50u64 { + let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed)); + assert!((2000..=3000).contains(&tag.color.hue)); + assert!((1800..=3200).contains(&tag.color.sat)); + assert!((3200..=4800).contains(&tag.color.val)); + } + } + + #[test] + fn missing_color_register_falls_back_to_neutral() { + let t = template_with_bundle("no_register", None); + let tag = + derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1)); + assert_eq!(tag.color, NEUTRAL_COLOR); + } + + #[test] + fn unbanded_color_register_falls_back_to_neutral() { + // color_register is set but color_bands is empty (content gap) — must + // degrade, not panic. + let t = template_with_bundle("orphan", Some("nonexistent_register")); + let tag = + derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1)); + assert_eq!(tag.color, NEUTRAL_COLOR); + } + + #[test] + fn no_template_falls_back_to_generic_everywhere_but_still_computes_setback() { + let tag = derive_building_exterior(None, None, &BTreeMap::new(), 90, SeedChain::root(1)); + assert_eq!(tag.wall_material, WallMaterial::Generic); + assert_eq!(tag.roof_form, RoofForm::Generic); + assert_eq!(tag.facade_rhythm, FacadeRhythm::Generic); + assert_eq!(tag.street_surface, StreetSurface::Generic); + assert_eq!(tag.color, NEUTRAL_COLOR); + // setback_tier only needs density_pct, not the template. + assert_eq!(tag.setback_tier, SetbackTier::ZeroLot); + } + + // ── zone bias skews the draw ────────────────────────────────────────── + + #[test] + fn zone_bias_skews_the_weighted_pick() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + let mut bias = ZoneBias::default(); + // Overwhelming weight toward SteelFrame (vs. ConcreteWall's uniform + // 10_000 baseline) — the draw must skew heavily toward it. + bias.wall.insert(WallMaterial::SteelFrame, 200_000); + + let mut steel = 0; + let mut concrete = 0; + for seed in 0..200u64 { + let tag = + derive_building_exterior(Some(&t), Some(&bias), &bands, 70, SeedChain::root(seed)); + match tag.wall_material { + WallMaterial::SteelFrame => steel += 1, + WallMaterial::ConcreteWall => concrete += 1, + other => panic!("unexpected wall material {other:?}"), + } + } + assert!( + steel > concrete * 5, + "expected steel_frame to dominate under a 200_000 bps bias, got steel={steel} concrete={concrete}" + ); + } + + #[test] + fn no_zone_bias_is_uniform_across_a_large_sample() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + let mut steel = 0; + let mut concrete = 0; + for seed in 0..400u64 { + let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed)); + match tag.wall_material { + WallMaterial::SteelFrame => steel += 1, + WallMaterial::ConcreteWall => concrete += 1, + other => panic!("unexpected wall material {other:?}"), + } + } + // Roughly even split expected (generous band — this is a uniformity + // sanity check, not a distribution test). + assert!( + (steel as i64 - concrete as i64).abs() < 120, + "expected roughly even split without bias, got steel={steel} concrete={concrete}" + ); + } + + // ── determinism + independence ──────────────────────────────────────── + + #[test] + fn same_chain_same_inputs_is_deterministic() { + let t = template_with_bundle("extraction_camp", Some("oxide_and_dust")); + let bands = oxide_band(); + let a = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99)); + let b = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99)); + assert_eq!(a, b); + } + + #[test] + fn axes_draw_independent_entropy_not_a_shared_roll() { + // Regression guard for the "one shared chain correlates every field" + // bug class already fixed once in `assign_block_tags` — wall/roof/ + // facade/street must vary independently across seeds, not lock-step. + let t = TraitTemplate { + visual_bundle: crate::atlas::trait_catalog_reader::VisualBundle { + wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall], + roof: vec![RoofForm::FlatRoof, RoofForm::PitchedRoof], + facade: vec![FacadeRhythm::RegularFacade, FacadeRhythm::Colonnade], + street: vec![StreetSurface::Paved, StreetSurface::Cobble], + color_register: None, + fallback: BTreeMap::new(), + }, + ..template_with_bundle("mixed", None) + }; + let mut saw_wall_a_roof_b = false; + let mut saw_wall_a_roof_a = false; + for seed in 0..100u64 { + let tag = derive_building_exterior( + Some(&t), + None, + &BTreeMap::new(), + 50, + SeedChain::root(seed), + ); + if tag.wall_material == WallMaterial::SteelFrame { + if tag.roof_form == RoofForm::PitchedRoof { + saw_wall_a_roof_b = true; + } else { + saw_wall_a_roof_a = true; + } + } + } + assert!( + saw_wall_a_roof_b && saw_wall_a_roof_a, + "wall and roof picks must vary independently, not track each other" + ); + } +} diff --git a/server/src/atlas/trait_swerve.rs b/server/src/atlas/trait_swerve.rs index c6240d9bf..fb57efe36 100644 --- a/server/src/atlas/trait_swerve.rs +++ b/server/src/atlas/trait_swerve.rs @@ -286,6 +286,7 @@ mod tests { base_weight, weight_mods: BTreeMap::new(), zone_affinity: BTreeMap::new(), + visual_bundle: Default::default(), } } diff --git a/server/src/seed.rs b/server/src/seed.rs index 657aea0db..517a313df 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -139,6 +139,13 @@ pub enum SeedDomain { /// domain so the rare-wildcard roll can never correlate with the zone/era/ /// extent draws sharing that chain. TraitSwerve = 15, + /// Per-building D-235 exterior-grammar draw (`BuildingExteriorTag`, T-988): + /// wall/roof/facade/street token picks + color HSV sample. Derived off the + /// footprint's own chain (like `TraitSwerve`), keyed per axis (0=wall, + /// 1=roof, 2=facade, 3=street, 4=hue, 5=sat, 6=val) so each axis draws + /// independent entropy — the `assign_block_tags` lesson (distinct + /// sub-chains per field, not one shared roll) applies here too. + TraitExterior = 16, } /// A position in the deterministic seed tree (D-224). @@ -303,6 +310,7 @@ mod tests { assert_eq!(SeedDomain::TraitVocabulary as u64, 13); assert_eq!(SeedDomain::TraitDistrict as u64, 14); assert_eq!(SeedDomain::TraitSwerve as u64, 15); + assert_eq!(SeedDomain::TraitExterior as u64, 16); } #[test] diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 71c3df7a3..f496cf0a4 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -850,6 +850,172 @@ pub enum ArchitectureFlavorRef { Swerve(String), } +// --------------------------------------------------------------------------- +// D-235 building exterior visual grammar (T-988) +// --------------------------------------------------------------------------- +// +// The vocabulary below is the RATIFIED ObjectTag palette (T-995, resolving +// Q-049) — `wiki/economics/object_tag_vocabulary.toml` — not the illustrative +// example lists in D-235's original body text (superseded by the 2026-07-07 +// amendment). Each axis carries its specific tokens plus one `Generic` +// fallback-terminal placeholder (the D-235 asset-resolution degrade target). +// +// Integer-discriminant, append-only (D-010) — these values round-trip through +// `systems.db`-backed derivation today and are candidates for savegame +// persistence in Phase 5+ (D-227), so historical discriminants must never be +// renumbered, only appended to. Unknown registry strings parse to the axis's +// `Generic` variant with a `tracing::warn!` (patch tolerance) — see +// `atlas::trait_catalog_reader`. + +/// Wall material token (D-235 `wall` axis, 10 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum WallMaterial { + ConcreteWall = 0, + SteelFrame = 1, + BrickWall = 2, + RenderedWall = 3, + StoneWall = 4, + TimberWall = 5, + StuccoWall = 6, + GlassCurtainWall = 7, + CompositePanel = 8, + RammedEarthWall = 9, + /// Fallback-terminal placeholder (`generic_wall`) — renders until the + /// specific asset ships (D-235), and the "unrecognized token" parse target. + #[default] + Generic = 10, +} + +/// Roof form token (D-235 `roof` axis, 7 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum RoofForm { + FlatRoof = 0, + PitchedRoof = 1, + CorrugatedRoof = 2, + ClayTileRoof = 3, + TerracedRoof = 4, + VaultedRoof = 5, + GreenRoof = 6, + /// Fallback-terminal placeholder (`generic_roof`) — see [`WallMaterial::Generic`]. + #[default] + Generic = 7, +} + +/// Facade rhythm token (D-235 `facade` axis, 8 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum FacadeRhythm { + RegularFacade = 0, + OrnamentalFacade = 1, + IndustrialGlazing = 2, + ArcadeFacade = 3, + ShutteredFacade = 4, + ScreenFacade = 5, + Colonnade = 6, + LatticeScreen = 7, + /// Fallback-terminal placeholder (`generic_facade`) — see [`WallMaterial::Generic`]. + #[default] + Generic = 8, +} + +/// Street surface token (D-235 `street` axis, 7 specific + `Generic`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum StreetSurface { + Paved = 0, + Cobble = 1, + PackedEarth = 2, + CanalWay = 3, + ElevatedWalkway = 4, + HeavyHaul = 5, + Boardwalk = 6, + /// Fallback-terminal placeholder (`generic_street`) — see [`WallMaterial::Generic`]. + #[default] + Generic = 7, +} + +/// Building setback tier (D-235 step 3): derived from `BlockSkeleton.density_pct` +/// bands — Dense blocks read `ZeroLot`, Frontier blocks read `Campus`. Drives +/// the interstitial-space character (`void`/`court`/`garden`/`plaza`/ +/// `dock_slip`/`market_pad`/`open_lawn`, D-235) a future fill pass reads. +/// +/// The five-tier vocabulary is fixed by D-235 (`zero_lot | tight | standard | +/// generous | campus`); the density-band **thresholds** that map onto it are +/// **calibration placeholders** (T-988) pending Nigel/Araminta tuning, same +/// status as the swerve-rate constants (`trait_swerve`). Declaration order is +/// the tier's natural density ordering (tightest → loosest), so `Ord` +/// comparisons read correctly; not currently used as a save-critical +/// discriminant, so it is not `#[repr(u8)]`-pinned like the axis tokens above. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum SetbackTier { + /// Buildings meet the lot line — no yard, shared walls common. + ZeroLot, + Tight, + Standard, + Generous, + /// Buildings sit well back within generous open grounds. + Campus, +} + +/// A single sampled point within a D-235 `color_register` HSV band (T-988). +/// +/// Integer-only (D-010, save-critical under D-227): `hue` is centidegrees +/// (degrees × 100, 0..36000); `sat`/`val` are basis points (0..10000). Sampled +/// once per building at plan time and frozen — never re-derived, no drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct HsvColor { + pub hue: u32, + pub sat: u32, + pub val: u32, +} + +/// The D-235 visible-form layer over a building's tags (T-988) — the frozen +/// output of the 3-step derivation: **(1)** the resolved trait template's +/// `visual_bundle` filters each axis's available token set; **(2)** the +/// building's `zone_type_id` biases the pick within that filtered set +/// (`architecture_zone_bias.toml`, uniform where unauthored); **(3)** the +/// block's `density_pct` sets `setback_tier`. `color` is seed-picked uniformly +/// within the template's `color_register` band. +/// +/// **Era is deliberately absent** (D-232 reframe, D-235 amendment): the Reach +/// has no material-technology ladder, so era is never a material gate — it +/// reads as maintenance/wear via the D-217 condition layer instead. +/// +/// Resolved once inside `GenerateSkeleton` (the same plan-time phase as +/// `flavor_ref`/`zone_type_id`) and stored frozen on `BuildingPropertyTag`; +/// `FillChunk` only reads it (T-987 purity) — see `atlas::trait_exterior`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildingExteriorTag { + pub wall_material: WallMaterial, + pub roof_form: RoofForm, + pub facade_rhythm: FacadeRhythm, + pub setback_tier: SetbackTier, + pub color: HsvColor, + pub street_surface: StreetSurface, +} + +/// Character of a block's non-roofed remainder — the space between building +/// footprints (D-233, T-1097). +/// +/// Bulk-industry blocks (`BulkClass::BulkSolid`/`BulkLiquid`) read this space +/// as **built economic infrastructure** (haul roads, ore pads, conveyor runs, +/// tank berms) — functional ground the settlement's economy depends on, never +/// generic open space. Every other `BulkClass` keeps the pre-T-1097 default: +/// informal interstitial space (yards, parks, lots) whose specific character +/// (`void`/`court`/`garden`/`plaza`/…) is the separate `SetbackTier`-driven +/// axis (D-235, T-988) — the two systems are distinct derivation axes sharing +/// the same physical space, not alternatives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum InterstitialCharacter { + /// Generic open space — yards, parks, informal lots. + #[default] + OpenSpace, + /// Built economic infrastructure (D-233) — never open space. + OperationsSurface, +} + /// A single building footprint tag — the frozen step-3 output placed on every /// building footprint at plan time (D-229). /// @@ -874,6 +1040,9 @@ pub struct BuildingPropertyTag { /// Frozen-amber condition snapshot from `prosperity_baseline_bps` (D-197/D-217). /// The rolling condition overlay (D-198) paints over this; never mutates the tag. pub initial_condition: crate::atlas::tile_condition::TileCondition, + /// D-235 visible-form layer (T-988): wall/roof/facade/street materials, + /// setback tier, and color — resolved once here, frozen thereafter. + pub exterior: BuildingExteriorTag, /// Doors into / out of this building (D-231). At least one `Main` door. /// /// Using `Vec` rather than `SmallVec<[DoorSpec; 4]>` for now; @@ -1232,6 +1401,11 @@ pub struct BlockSkeleton { /// Integer to avoid f32 non-determinism (D-010). pub density_pct: u8, pub landmark: Option, + /// Character of this block's non-roofed remainder (D-233, T-1097): + /// `OperationsSurface` for bulk-industry blocks, `OpenSpace` otherwise. + /// Derived once from `CityGenerationContext.dominant_bulk_class` at the + /// same plan-time pass as the rest of this skeleton. + pub interstitial_character: InterstitialCharacter, } /// Floor zone within a multi-level reservation.