feat(simulation): BuildingExteriorTag grammar + Layer-5 material fill + ops-surface (T-988/T-959/T-1097)

T-988 — BuildingExteriorTag {wall_material, roof_form, facade_rhythm,
setback_tier, color:HsvColor, street_surface}. New trait_exterior.rs:
3-step era-free derivation (visual_bundle filter -> zone-bias weighted
pick -> density->setback), color seed-sampled within the register band.
Resolved at GenerateSkeleton plan time (T-994 precedent), frozen on
BuildingPropertyTag; FillChunk only reads it. Four append-only
integer-discriminant enums (WallMaterial/RoofForm/FacadeRhythm/
StreetSurface), unknown token -> axis Generic + warn. trait_catalog_reader
now parses visual_bundle + the two new tables (OnceLock-cached).
New SeedDomain::TraitExterior (per-axis sub-chains — no cross-field
correlation).

T-959 — FillChunk reads the frozen exterior tag: wall_material/roof_form
onto Wall/Roof shell voxels (FilledChunk.surface_material). Additive,
ShellVoxel untouched, all shell tests pass. (Interstitial-fill-from-setback
split to T-1098 — needs a FillChunk block-metadata + geometry design pass;
nothing consumes FillChunk until Phase 5.)

T-1097 — BlockSkeleton.interstitial_character (OpenSpace|OperationsSurface),
D-233 bulk-driven: bulk-industry blocks' non-roofed remainder tags as built
economic infrastructure, not generic open space.

cargo check --all-targets clean; 1666 lib tests pass; no golden impact
(harnesses top out at CascadeLayer::RoadGraph, unreachable by Layer 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 09:42:20 +02:00
co-authored by Claude Fable 5
parent ba781f9324
commit 204359927a
11 changed files with 1777 additions and 29 deletions
+32 -3
View File
@@ -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(),
}],
);
+1
View File
@@ -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;
+40 -1
View File
@@ -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).
+132 -6
View File
@@ -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<ShellVoxelPos, ShellVoxel>,
/// 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<ShellVoxelPos, SurfaceMaterial>,
}
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<ShellVoxelPos, ShellVoxel> = BTreeMap::new();
let mut surface_material: BTreeMap<ShellVoxelPos, SurfaceMaterial> = 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<ShellVoxelPos, ShellVoxel>,
surface_material: &mut BTreeMap<ShellVoxelPos, SurfaceMaterial>,
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))
);
}
}
+383 -12
View File
@@ -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<ReservationId>; 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<Edge>,
block_chain: SeedChain,
exterior_catalog: &ExteriorCatalog,
) -> Vec<BuildingPropertyTag> {
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<BuildingPropertyTag>> {
// 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;
+562 -7
View File
@@ -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<DistrictType, u32>,
/// 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<WallMaterial>,
pub roof: Vec<RoofForm>,
pub facade: Vec<FacadeRhythm>,
pub street: Vec<StreetSurface>,
/// 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<String>,
/// 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<String, String>,
}
/// 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<u32>,
}
/// 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<WallMaterial, u32>,
pub roof: BTreeMap<RoofForm, u32>,
pub facade: BTreeMap<FacadeRhythm, u32>,
pub street: BTreeMap<StreetSurface, u32>,
}
/// 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<TraitTemplate>,
pub zone_bias: BTreeMap<(String, String), ZoneBias>,
pub color_bands: BTreeMap<String, ColorBand>,
}
// ---------------------------------------------------------------------------
// 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<Vec<TraitTemplate>>,
/// One-shot cache for `architecture_zone_bias` (T-988) — same immutable-
/// per-run rationale as `catalog_cache`.
zone_bias_cache: OnceLock<BTreeMap<(String, String), ZoneBias>>,
/// One-shot cache for `color_register_bands` (T-988) — same immutable-
/// per-run rationale as `catalog_cache`.
color_bands_cache: OnceLock<BTreeMap<String, ColorBand>>,
}
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<String>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<String>>(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<BTreeMap<(String, String), ZoneBias>, 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<BTreeMap<(String, String), ZoneBias>, 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<BTreeMap<String, ColorBand>, 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<BTreeMap<String, ColorBand>, 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<ExteriorCatalog, TraitCatalogReadError> {
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<String>,
#[serde(default)]
roof: Vec<String>,
#[serde(default)]
facade: Vec<String>,
#[serde(default)]
street: Vec<String>,
color_register: Option<String>,
#[serde(default)]
fallback: BTreeMap<String, String>,
}
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<String, u32>,
#[serde(default)]
roof: BTreeMap<String, u32>,
#[serde(default)]
facade: BTreeMap<String, u32>,
#[serde(default)]
street: BTreeMap<String, u32>,
}
/// `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);
}
}
+1
View File
@@ -460,6 +460,7 @@ mod tests {
base_weight,
weight_mods: BTreeMap::new(),
zone_affinity: zone_affinity.iter().cloned().collect(),
visual_bundle: Default::default(),
}
}
+443
View File
@@ -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.<axis>` 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<T: Copy + Ord>(
tokens: &[T],
bias: Option<&BTreeMap<T, u32>>,
rng: &mut AtlasRng,
) -> Option<T> {
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<String, ColorBand>,
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<String, ColorBand>,
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<String, ColorBand> {
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"
);
}
}
+1
View File
@@ -286,6 +286,7 @@ mod tests {
base_weight,
weight_mods: BTreeMap::new(),
zone_affinity: BTreeMap::new(),
visual_bundle: Default::default(),
}
}
+8
View File
@@ -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]
+174
View File
@@ -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<DoorSpec>` 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<LandmarkSlot>,
/// 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.