Files
settled-reach/server/src/atlas/shell.rs
T
jpmschweitzerandClaude Opus 4.8 7597e3165e fix(simulation): address PR #171 review — all findings processed (T-987)
Every review finding fixed (no non-blocking parking lot):
- shell.rs: debug_assert the D-110 floor-range i8 invariant (H1); clarify the
  ground_offset fallback comment (T1) and the roof_z None-case (T3); clarify the
  sub-chunk clip test comment (T4); add a test for the elevated/no-ground-floor
  fallback (H2).
- scale.rs: add CHUNKS_PER_BLOCK (= BLOCK_M/CHUNK_M) + compile-time asserts; used
  by shell.rs fill_chunk's sub_chunk bounds assert (T5).
- gen_queue.rs: document why FillChunk's Vec<BuildingPropertyTag> needs no Box
  (large_enum_variant non-issue) (T2).

clippy --all-targets -D warnings clean; 1572 lib tests pass (+1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:24:30 +02:00

458 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Building-shell derivation — the D-230 on-demand `FillChunk` derive phase (T-987).
//!
//! This is the second half of the two-phase fill model (D-230). The **plan phase**
//! (`GenerateSkeleton`, [`crate::atlas::skeleton_gen`]) produces the per-footprint
//! [`BuildingPropertyTag`]s and caches them on the body's `QuarterWorldState`. This
//! module is the **derive phase**: given those frozen tags, it derives the actual
//! `{Void | Wall | FloorSlab | Roof}` shell voxels for a single 64 m chunk, purely
//! and on demand (no cache read, no side effects — the caller pre-resolves the tags
//! into the work item, mirroring the rest of the generation queue, [`crate::atlas::gen_queue`]).
//!
//! ## What this layer is (and is not)
//!
//! - **Is:** the structural *shell* — the four materials named in D-230. The geometry
//! is pure rectangle-containment (is the tile inside a footprint?) + z-range lookup
//! (which floor / roof does this voxel-z belong to?) over the cached tags. No RNG;
//! the shell is fully determined by `(footprint, extent)`, which are themselves a
//! deterministic function of the seed (D-010).
//! - **Is not:** the *surface* material vocabulary (`WallMaterial`/`RoofForm`/
//! `StreetSurface`, D-235) — that is the `BuildingExteriorTag` visual grammar (T-988),
//! layered on top of this shell. Interstitial street / open-space fill (D-215) and the
//! rolling condition overlay (D-198, T-999) are likewise out of scope here; this layer
//! emits only the four shell materials.
//!
//! ## Scale + coordinates (D-243)
//!
//! A quarter is 512 m = 4×4 **blocks** (128 m) = 8×8 **chunks** (64 m). A 64 m chunk is
//! exactly one quadrant of a 128 m block, so every chunk lies wholly inside a single
//! block — and a [`BuildingPropertyTag`]'s footprint is block-confined (`TileRect` is
//! block-local, 0..128). Therefore the *only* tags that can touch a chunk are the
//! covering block's tags. The fill works in block-local tile space and subtracts the
//! sub-chunk origin to land in chunk-local space (0..64).
//!
//! ## Vertical origin (D-110)
//!
//! [`FloorExtent`] addresses floors building-relative (base floor bottom = 0), but a
//! chunk mixes buildings with different basement depths, so the shell is emitted in a
//! single **quarter-ground** frame: the ground floor (index 0) bottom sits at `z = 0`,
//! basements are negative, upper floors positive. This is the D-110 convention
//! ("the quarter's ground level is always 0").
//!
//! ## D-010 compliance
//!
//! All arithmetic is integer. `FilledChunk` stores voxels in a `BTreeMap` so iteration
//! order is deterministic; only non-`Void` voxels are stored — the shell is **sparse**,
//! holding wall / per-floor-slab / roof *surfaces* but never the interior air between
//! floors. The derive is `O(built surface)` integer work (rectangle-containment +
//! z-range), which is what keeps it within the D-230 `<5 ms`/chunk budget. (If a future
//! profile shows the `BTreeMap` inserts hot for pathologically dense towers, a
//! pre-sized dense column buffer is the drop-in optimisation — matching D-230's
//! flat-array time estimate — without changing this layer's contract.)
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};
/// One structural shell voxel material (D-230).
///
/// `Void` (interior air / open space) is the implicit default and is **never stored**
/// in [`FilledChunk`]; it exists in the vocabulary so the type system can name the
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum ShellVoxel {
/// Interior air / open space — the implicit default, never stored.
#[default]
Void = 0,
/// Vertical structural wall — a footprint-perimeter column voxel.
Wall = 1,
/// Horizontal floor slab — the base voxel of a floor's interior.
FloorSlab = 2,
/// Roof cap — the voxel layer immediately above the topmost floor.
Roof = 3,
}
/// 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
/// D-110 quarter-ground frame (ground floor bottom = 0, basements negative), so it is
/// signed.
pub type ShellVoxelPos = (u8, u8, i32);
/// The derived shell of a single 64 m chunk — the D-230 `FillChunk` output (T-987).
///
/// Sparse: only non-[`ShellVoxel::Void`] voxels are present. `BTreeMap` keeps iteration
/// deterministic (D-010). Carries its own quarter-relative address so a consumer
/// (Phase 5 rendering) can place it without re-deriving the mapping.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct FilledChunk {
/// Stable id of the quarter this chunk belongs to (D-194/D-230).
pub quarter_id: u64,
/// Block grid position within the quarter's 4×4 block grid (0..4, 0..4).
pub block_pos: (u8, u8),
/// Sub-chunk quadrant within the block (0..2, 0..2) — a 128 m block is 2×2 chunks.
pub sub_chunk: (u8, u8),
/// Non-`Void` shell voxels, keyed by chunk-local position (D-010 ordered).
pub voxels: BTreeMap<ShellVoxelPos, ShellVoxel>,
}
impl FilledChunk {
/// Quarter-local chunk index `(0..8, 0..8)`: `block * 2 + sub_chunk`.
pub fn chunk_in_quarter(&self) -> (u8, u8) {
(
self.block_pos.0 * 2 + self.sub_chunk.0,
self.block_pos.1 * 2 + self.sub_chunk.1,
)
}
/// Number of non-`Void` voxels in this chunk.
pub fn voxel_count(&self) -> usize {
self.voxels.len()
}
/// Material at a chunk-local position; `Void` if nothing was emitted there.
pub fn get(&self, x: u8, y: u8, z: i32) -> ShellVoxel {
self.voxels.get(&(x, y, z)).copied().unwrap_or_default()
}
}
/// Derive the shell [`FilledChunk`] for the sub-chunk `sub_chunk` of block `block_pos`,
/// given that block's pre-resolved building tags (D-230 derive phase, T-987).
///
/// Pure: the output is a total deterministic function of the inputs (D-010). `block_tags`
/// is the covering block's `Vec<BuildingPropertyTag>` from the cached `QuarterWorldState`
/// — pre-resolved by the caller because the work executor is cache-free
/// ([`crate::atlas::gen_queue`]).
///
/// `quarter_id` is threaded through for addressing only.
pub fn fill_chunk(
quarter_id: u64,
block_pos: (u8, u8),
sub_chunk: (u8, u8),
block_tags: &[BuildingPropertyTag],
) -> FilledChunk {
debug_assert!(
(sub_chunk.0 as i32) < CHUNKS_PER_BLOCK && (sub_chunk.1 as i32) < CHUNKS_PER_BLOCK,
"sub_chunk {sub_chunk:?} outside the block's {CHUNKS_PER_BLOCK}×{CHUNKS_PER_BLOCK} chunk grid"
);
let mut voxels: BTreeMap<ShellVoxelPos, ShellVoxel> = 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;
let chunk_lo_y = sub_chunk.1 as i32 * CHUNK_M;
let chunk_hi_x = chunk_lo_x + CHUNK_M; // exclusive
let chunk_hi_y = chunk_lo_y + CHUNK_M; // exclusive
for tag in block_tags {
shell_derive_into(
&mut voxels,
tag,
(chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y),
);
}
FilledChunk {
quarter_id,
block_pos,
sub_chunk,
voxels,
}
}
/// 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).
///
/// 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.
fn shell_derive_into(
voxels: &mut BTreeMap<ShellVoxelPos, ShellVoxel>,
tag: &BuildingPropertyTag,
window: (i32, i32, i32, i32),
) {
let (win_lo_x, win_lo_y, win_hi_x, win_hi_y) = window;
let footprint = &tag.footprint;
let extent = &tag.extent;
// D-110 floor indices must fit i8 so the top-floor comparison and the roof
// derivation below cannot wrap. The generator caps floor counts well under this;
// the assert pins the invariant so a future change can't silently drop the roof.
debug_assert!(
extent.base_floor as i16 + extent.floor_count as i16 - 1 <= i8::MAX as i16,
"building floor range exceeds i8 — roof derivation would wrap"
);
// Footprint block-local tile span (inclusive lo, exclusive hi).
let fp_lo_x = footprint.origin.0 as i32;
let fp_lo_y = footprint.origin.1 as i32;
let fp_hi_x = fp_lo_x + footprint.size.0.max(1) as i32;
let fp_hi_y = fp_lo_y + footprint.size.1.max(1) as i32;
// Intersect footprint with the chunk window — nothing to do if disjoint.
let lo_x = fp_lo_x.max(win_lo_x);
let lo_y = fp_lo_y.max(win_lo_y);
let hi_x = fp_hi_x.min(win_hi_x);
let hi_y = fp_hi_y.min(win_hi_y);
if lo_x >= hi_x || lo_y >= hi_y {
return;
}
// Quarter-ground z origin (D-110): subtract the ground floor's building-relative
// base so floor 0 bottom lands at z = 0. `FloorExtent` addresses floors relative to
// `base_floor` (whose bottom is always its own 0), so when a building has no floor 0
// (all-basement / all-elevated — unreachable from the generator today) the fallback
// of 0 applies no shift: the building-relative z passes through unchanged.
let ground_offset = extent
.voxel_range_for_floor(0)
.map(|(lo, _)| lo)
.unwrap_or(0);
let top_floor = (extent.base_floor as i16 + extent.floor_count as i16 - 1) as i8;
let mut roof_z: Option<i32> = None;
for f_offset in 0..extent.floor_count {
let floor_index = (extent.base_floor as i16 + f_offset as i16) as i8;
let Some((rel_lo, rel_hi)) = extent.voxel_range_for_floor(floor_index) else {
continue;
};
let floor_base_z = rel_lo - ground_offset;
let floor_top_z = rel_hi - ground_offset;
for tx in lo_x..hi_x {
for ty in lo_y..hi_y {
let perimeter = is_perimeter(footprint, tx, ty);
// Chunk-local coordinate (0..64).
let cx = (tx - win_lo_x) as u8;
let cy = (ty - win_lo_y) as u8;
for z in floor_base_z..=floor_top_z {
let material = if perimeter {
ShellVoxel::Wall
} else if z == floor_base_z {
ShellVoxel::FloorSlab
} else {
continue; // interior air → Void, not stored
};
voxels.insert((cx, cy, z), material);
}
}
}
if floor_index == top_floor {
roof_z = Some(floor_top_z + 1);
}
}
// Roof cap: one voxel layer above the topmost floor, over the full footprint.
// `roof_z` is `None` only if the top floor's `voxel_range_for_floor` returned `None`
// (impossible for a well-formed `FloorExtent`) — in that case no roof is emitted.
if let Some(rz) = roof_z {
for tx in lo_x..hi_x {
for ty in lo_y..hi_y {
let cx = (tx - win_lo_x) as u8;
let cy = (ty - win_lo_y) as u8;
voxels.insert((cx, cy, rz), ShellVoxel::Roof);
}
}
}
}
/// Whether block-local tile `(tx, ty)` is on the outer ring of `footprint`.
///
/// A 1-wide footprint is all perimeter (no interior); callers rely on that so such
/// buildings become solid wall columns rather than empty shells.
fn is_perimeter(footprint: &TileRect, tx: i32, ty: i32) -> bool {
let lo_x = footprint.origin.0 as i32;
let lo_y = footprint.origin.1 as i32;
let hi_x = lo_x + footprint.size.0.max(1) as i32 - 1;
let hi_y = lo_y + footprint.size.1.max(1) as i32 - 1;
tx == lo_x || tx == hi_x || ty == lo_y || ty == hi_y
}
/// Compile-time sanity: a chunk is 64 voxels on a side, so chunk-local indices fit a u8.
const _: () = assert!(VOXELS_PER_CHUNK == CHUNK_M);
const _: () = assert!(CHUNK_M <= u8::MAX as i32 + 1);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::tile_condition::TileCondition;
use crate::simulation::generator::{
ArchitectureFlavorRef, BuildingEntryClass, ConstructionEra, EraCause, FloorExtent,
FloorHeightProfile, ZoneTypeId,
};
/// Build a `BuildingPropertyTag` with the given block-local footprint and a
/// uniform 3-voxel-per-floor extent (the D-229 default).
fn tag(
origin: (u8, u8),
size: (u8, u8),
base_floor: i8,
floor_count: u8,
) -> BuildingPropertyTag {
BuildingPropertyTag {
zone_type_id: ZoneTypeId::new("residential_low"),
footprint: TileRect::new(origin.0, origin.1, size.0, size.1),
extent: FloorExtent {
base_floor,
floor_count,
heights: FloorHeightProfile::Uniform(3),
},
entry_class: BuildingEntryClass::Public,
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
era: ConstructionEra::Founding,
era_cause: EraCause::Original,
initial_condition: TileCondition::Intact,
doors: Vec::new(),
}
}
#[test]
fn empty_block_yields_empty_chunk() {
let fc = fill_chunk(7, (0, 0), (0, 0), &[]);
assert_eq!(fc.voxel_count(), 0);
assert_eq!(fc.quarter_id, 7);
assert_eq!(fc.chunk_in_quarter(), (0, 0));
}
#[test]
fn chunk_in_quarter_maps_block_and_sub_chunk() {
let fc = fill_chunk(0, (3, 2), (1, 0), &[]);
// block (3,2) sub-chunk (1,0) → quarter chunk (3*2+1, 2*2+0) = (7, 4).
assert_eq!(fc.chunk_in_quarter(), (7, 4));
}
#[test]
fn single_storey_box_has_walls_floor_and_roof() {
// 4×4 single-storey building at block-local origin (2,2), sub-chunk (0,0).
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]);
// Ground floor (3 voxels: z 0,1,2). Roof at z = 3.
// Corner (2,2) is perimeter → Wall through z 0..=2.
assert_eq!(fc.get(2, 2, 0), ShellVoxel::Wall);
assert_eq!(fc.get(2, 2, 2), ShellVoxel::Wall);
// Interior tile (3,3) → FloorSlab at the floor base (z 0), Void above.
assert_eq!(fc.get(3, 3, 0), ShellVoxel::FloorSlab);
assert_eq!(fc.get(3, 3, 1), ShellVoxel::Void);
// Roof caps the whole footprint at z = 3 (perimeter and interior alike).
assert_eq!(fc.get(2, 2, 3), ShellVoxel::Roof);
assert_eq!(fc.get(3, 3, 3), ShellVoxel::Roof);
// Outside the footprint → Void.
assert_eq!(fc.get(0, 0, 0), ShellVoxel::Void);
}
#[test]
fn multi_storey_stacks_floor_slabs() {
// 5×5, three storeys (z bands 0..2, 3..5, 6..8). Roof at z = 9.
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (5, 5), 0, 3)]);
// Interior tile gets a slab at each floor base: z 0, 3, 6.
assert_eq!(fc.get(2, 2, 0), ShellVoxel::FloorSlab);
assert_eq!(fc.get(2, 2, 3), ShellVoxel::FloorSlab);
assert_eq!(fc.get(2, 2, 6), ShellVoxel::FloorSlab);
// Between slabs is interior air.
assert_eq!(fc.get(2, 2, 1), ShellVoxel::Void);
// Perimeter wall runs the full height to the top floor's top voxel (z 8).
assert_eq!(fc.get(0, 0, 8), ShellVoxel::Wall);
// Roof one voxel above the top floor.
assert_eq!(fc.get(2, 2, 9), ShellVoxel::Roof);
}
#[test]
fn basement_floor_is_below_ground_zero() {
// base_floor = -1, 2 floors → basement (z -3..-1) + ground (z 0..2).
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (3, 3), -1, 2)]);
// Ground floor interior slab at z = 0 (D-110: ground bottom is the origin).
assert_eq!(fc.get(1, 1, 0), ShellVoxel::FloorSlab);
// Basement interior slab is below zero.
assert_eq!(fc.get(1, 1, -3), ShellVoxel::FloorSlab);
// Basement perimeter is wall.
assert_eq!(fc.get(0, 0, -1), ShellVoxel::Wall);
}
#[test]
fn elevated_building_with_no_ground_floor_anchors_at_its_own_bottom() {
// base_floor = 2, no floor 0 → ground_offset falls back to 0, so the building's
// own bottom maps to chunk-z 0 (no shift). 2 floors × 3 voxels, then a roof.
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (3, 3), 2, 2)]);
// Lowest present floor's interior slab sits at chunk-z 0.
assert_eq!(fc.get(1, 1, 0), ShellVoxel::FloorSlab);
// Second floor's slab one storey up (z 3).
assert_eq!(fc.get(1, 1, 3), ShellVoxel::FloorSlab);
// Perimeter wall from the bottom.
assert_eq!(fc.get(0, 0, 0), ShellVoxel::Wall);
// Roof one voxel above the two storeys (z 6).
assert_eq!(fc.get(1, 1, 6), ShellVoxel::Roof);
}
#[test]
fn one_wide_building_is_all_wall() {
// 1×4 footprint — every tile is perimeter, so all Wall (no interior slab).
let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (1, 4), 0, 1)]);
for ty in 0..4u8 {
assert_eq!(fc.get(0, ty, 0), ShellVoxel::Wall);
}
// No FloorSlab anywhere (no interior tiles).
assert!(!fc.voxels.values().any(|v| *v == ShellVoxel::FloorSlab));
}
#[test]
fn footprint_clipped_to_sub_chunk() {
// A building spanning the block's left edge into the second sub-chunk.
// Footprint block-local x 60..68 straddles the x=64 sub-chunk seam.
let building = tag((60, 10), (8, 4), 0, 1);
let left = fill_chunk(1, (0, 0), (0, 0), std::slice::from_ref(&building));
let right = fill_chunk(1, (0, 0), (1, 0), std::slice::from_ref(&building));
// Left sub-chunk (0,0): the window origin is 0, so here chunk-local == block-local
// (x 60..64). The right sub-chunk below is the general case where they differ.
assert_ne!(left.voxel_count(), 0);
assert!(left.voxels.keys().all(|(x, _, _)| (60..64).contains(x)));
// Right sub-chunk holds block-local x 64..68 → chunk-local x 0..4.
assert_ne!(right.voxel_count(), 0);
assert!(right.voxels.keys().all(|(x, _, _)| (0..4).contains(x)));
}
#[test]
fn fill_is_deterministic() {
let tags = vec![tag((0, 0), (6, 6), -1, 4), tag((40, 40), (10, 8), 0, 2)];
let a = fill_chunk(99, (1, 1), (0, 1), &tags);
let b = fill_chunk(99, (1, 1), (0, 1), &tags);
assert_eq!(a, b);
}
#[test]
fn shell_is_sparse_versus_dense_volume() {
// A realistically dense chunk: a 4×4 grid of 14×14 buildings (2-tile gaps),
// each 5 storeys (15 voxels) + roof. The shell stores only surfaces — walls,
// per-floor slabs, roof — so it must hold strictly fewer voxels than the dense
// building volume (which would also store the interior air between floors).
// This sparsity is what keeps the derive within the D-230 <5 ms budget.
let mut tags = Vec::new();
for gx in 0..4u8 {
for gy in 0..4u8 {
tags.push(tag((gx * 16, gy * 16), (14, 14), 0, 5));
}
}
let fc = fill_chunk(1, (0, 0), (0, 0), &tags);
// Dense volume: 16 buildings × (14×14 footprint) × (5 floors × 3 + 1 roof).
let dense_volume = 16 * 14 * 14 * (5 * 3 + 1);
assert!(fc.voxel_count() > 0, "a built chunk must derive voxels");
assert!(
fc.voxel_count() < dense_volume,
"shell ({}) must be sparser than the dense volume ({dense_volume}) — \
interior air must not be stored",
fc.voxel_count()
);
}
}