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