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
+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).