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>
444 lines
18 KiB
Rust
444 lines
18 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|