feat(simulation): interstitial fill — ground-tile character between building footprints (T-1098)

The BSP leaves that lose the D-233 coverage roll in
subdivide_block_footprints were computed and discarded; they are now
surfaced as the interstitial rect set (BlockSubdivision), making the
ground-plane classification exhaustive by construction: footprint /
interstitial / street-margin-or-reserved. FillChunk carries the leaves
plus a minimal BlockFillContext (interstitial_character + setback_tier,
re-derived at block level via the existing pure fn); FilledChunk gains
a sparse interstitial map whose absence contract is stated on the
struct (missing key = footprint/street/reserved, never unknown). The
pure resolution maps OperationsSurface (D-233) first, else setback_tier
onto five of D-235's seven interstitial values — dock_slip/market_pad
have no specified trigger in the record and point at T-1209 rather
than an invented mapping. Design brief with the geometry model at
docs/architecture/interstitial-fill-t1098.md (lead-approved
checkpoint). Bonus fix: a degenerate setback shrink previously vanished
from BOTH lists silently; it now falls through to interstitial. 14 new
tests; full cargo test green incl. all golden harnesses; purity per
T-987 (plan-time compute, pre-resolved work items, no cache reads).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 14:13:08 +02:00
co-authored by Claude Fable 5
parent 63ec09b089
commit 1d676a91f8
6 changed files with 1020 additions and 93 deletions
+67 -13
View File
@@ -43,7 +43,9 @@ use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleto
use crate::atlas::trait_catalog_reader::ExteriorCatalog;
use crate::bridge::ConnectionId;
use crate::seed::SeedChain;
use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState};
use crate::simulation::generator::{
BlockFillContext, BuildingPropertyTag, CityGenerationContext, QuarterWorldState, TileRect,
};
// ---------------------------------------------------------------------------
// Priority
@@ -174,12 +176,14 @@ pub enum GenWorkItem {
heightmap: std::sync::Arc<crate::atlas::heightmap::BodyHeightmap>,
},
/// Derive the building shell for one 64 m chunk of an existing quarter
/// (D-230 derive phase, T-987).
/// (D-230 derive phase, T-987), plus its T-1098 interstitial ground-tile
/// classification.
///
/// The covering block's `block_tags` are **pre-resolved into the item** at enqueue
/// time because `run_work_item` is cache-free (mirrors `GenerateSkeleton`). A 64 m
/// chunk lies wholly within one 128 m block and footprints are block-confined, so
/// the covering block's tags are exactly the relevant set. Build items with
/// The covering block's `block_tags`/`interstitial_leaves`/`block_character` are
/// **pre-resolved into the item** at enqueue time because `run_work_item` is
/// cache-free (mirrors `GenerateSkeleton`). A 64 m chunk lies wholly within one
/// 128 m block and footprints/interstitial leaves are block-confined, so the
/// covering block's data is exactly the relevant set. Build items with
/// [`build_fill_chunk_item`] — the D-230 "skeleton not yet processed → re-enqueue
/// at `High`" precondition is the caller's cache lookup, which only reaches this
/// constructor once the `QuarterWorldState` exists.
@@ -196,6 +200,16 @@ pub enum GenWorkItem {
/// live behind the pointer), so — unlike `AnalyzeBody`'s boxed `BodyParams` —
/// this variant needs no `Box` to stay clippy `large_enum_variant`-clean.
block_tags: Vec<BuildingPropertyTag>,
/// T-1098: covering block's interstitial ground-tile rects, pre-resolved
/// from `QuarterWorldState.block_interstitial`. Empty for a reserved block
/// or a block whose BSP leaves all became buildings — both legitimate.
interstitial_leaves: Vec<TileRect>,
/// T-1098: the two block-level scalars an interstitial tile's resolution
/// needs (`InterstitialCharacter` + `SetbackTier`) — NOT the whole
/// `BlockSkeleton` (see `BlockFillContext`'s doc for why). Re-derived once
/// at enqueue time from the cached `QuarterSkeleton`'s block grid, mirroring
/// how `block_tags` is pulled from the cache rather than recomputed here.
block_character: BlockFillContext,
},
/// Derive a district-resolution window (D-226 T-1124 amendment, T-1137).
///
@@ -1071,8 +1085,9 @@ fn run_work_item(
// Step-3 building-property tags per footprint (D-229, #957): subdivide
// 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(
// same pass. T-1098: the sibling `block_interstitial` map threads
// block-level ground-tile geometry alongside `block_tags`.
let assignment = assign_all_block_tags(
&skeleton,
&resolved_context,
economic_role,
@@ -1085,7 +1100,8 @@ fn run_work_item(
body_id: body_id.clone(),
state: Box::new(QuarterWorldState {
skeleton,
block_tags,
block_tags: assignment.block_tags,
block_interstitial: assignment.block_interstitial,
}),
}
}
@@ -1094,11 +1110,21 @@ fn run_work_item(
block_pos,
sub_chunk,
block_tags,
interstitial_leaves,
block_character,
} => {
// D-230 derive phase: pure rectangle-containment + z-range shell fill over
// the pre-resolved tags. No cache read here — that is what keeps FillChunk
// trivially fast and re-derivable (D-227).
let filled = fill_chunk(*quarter_id, *block_pos, *sub_chunk, block_tags);
// trivially fast and re-derivable (D-227). T-1098: interstitial ground-tile
// classification rides the same pre-resolved, cache-free contract.
let filled = fill_chunk(
*quarter_id,
*block_pos,
*sub_chunk,
block_tags,
interstitial_leaves,
*block_character,
);
GenCompletion::ChunkFilled {
filled: Box::new(filled),
}
@@ -1240,14 +1266,22 @@ fn run_work_item(
}
/// Build a [`GenWorkItem::FillChunk`] for one 64 m sub-chunk of a quarter, pulling the
/// covering block's tags out of the cached `QuarterWorldState` (D-230 derive phase, T-987).
/// covering block's tags + T-1098 interstitial data out of the cached `QuarterWorldState`
/// (D-230 derive phase, T-987).
///
/// Pure (no queue/cache handle), so it unit-tests without a running app. The D-230
/// precondition — "`FillChunk` is only dispatched after `SkeletonGenerated` for that
/// district has been processed; if absent, re-enqueue at `High`" — is the caller's
/// cache lookup: this constructor only runs once the `QuarterWorldState` exists. A
/// block with no buildings yields empty `block_tags` (→ an empty, terrain-only shell),
/// which is a valid ready state, not a not-yet-generated one.
/// which is a valid ready state, not a not-yet-generated one. Likewise an empty
/// `interstitial_leaves` (reserved block, or every BSP leaf became a building).
///
/// `block_character` is re-derived from the cached `QuarterSkeleton`'s block grid —
/// `setback_tier` via the same pure `derive_setback_tier(density_pct)` every building
/// in the block already uses (T-988), `interstitial_character` read straight off
/// `BlockSkeleton` (D-233/T-1097) — rather than carrying the whole `BlockSkeleton`
/// into the work item (see `BlockFillContext`'s doc for why only these two scalars).
pub fn build_fill_chunk_item(
quarter: &QuarterWorldState,
block_pos: (u8, u8),
@@ -1258,11 +1292,23 @@ pub fn build_fill_chunk_item(
.get(&block_pos)
.cloned()
.unwrap_or_default();
let interstitial_leaves = quarter
.block_interstitial
.get(&block_pos)
.cloned()
.unwrap_or_default();
let block = &quarter.skeleton.blocks[block_pos.0 as usize][block_pos.1 as usize];
let block_character = BlockFillContext {
interstitial_character: block.interstitial_character,
setback_tier: crate::atlas::trait_exterior::derive_setback_tier(block.density_pct),
};
GenWorkItem::FillChunk {
quarter_id: quarter.skeleton.quarter_id,
block_pos,
sub_chunk,
block_tags,
interstitial_leaves,
block_character,
}
}
@@ -1464,6 +1510,12 @@ mod tests {
block_pos: (0, 0),
sub_chunk: (0, 0),
block_tags: vec![],
interstitial_leaves: vec![],
block_character: BlockFillContext {
interstitial_character:
crate::simulation::generator::InterstitialCharacter::OpenSpace,
setback_tier: crate::simulation::generator::SetbackTier::Standard,
},
},
GenPriority::High,
);
@@ -1521,6 +1573,7 @@ mod tests {
..Default::default()
},
block_tags,
block_interstitial: BTreeMap::new(),
}
}
@@ -1533,6 +1586,7 @@ mod tests {
block_pos,
sub_chunk,
block_tags,
..
} = item
else {
panic!("expected FillChunk");
+5
View File
@@ -5185,6 +5185,7 @@ mod tests {
..Default::default()
},
block_tags: Default::default(),
block_interstitial: Default::default(),
},
);
@@ -5252,6 +5253,7 @@ mod tests {
..Default::default()
},
block_tags: Default::default(),
block_interstitial: Default::default(),
},
);
@@ -5320,6 +5322,7 @@ mod tests {
..Default::default()
},
block_tags: Default::default(),
block_interstitial: Default::default(),
},
);
@@ -5386,6 +5389,7 @@ mod tests {
..Default::default()
},
block_tags: Default::default(),
block_interstitial: Default::default(),
},
);
@@ -5627,6 +5631,7 @@ mod tests {
..Default::default()
},
block_tags: Default::default(),
block_interstitial: Default::default(),
},
);
+283 -33
View File
@@ -17,9 +17,12 @@
//! 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.
//! layered on top of this shell. Street fill (D-234's future street-network step) and the
//! rolling condition overlay (D-198, T-999) are likewise out of scope here. Ground-plane
//! interstitial classification (the gap between footprints, D-233/D-235) **is** in scope
//! as of T-1098 — see [`FilledChunk::interstitial`] — but street-margin tiles are not:
//! this layer emits the four shell materials plus interstitial character, never a street
//! surface stamp.
//!
//! ## Scale + coordinates (D-243)
//!
@@ -54,7 +57,10 @@ 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, RoofForm, TileRect, WallMaterial};
use crate::simulation::generator::{
resolve_interstitial_type, BlockFillContext, BuildingPropertyTag, InterstitialType, RoofForm,
TileRect, WallMaterial,
};
/// One structural shell voxel material (D-230).
///
@@ -98,11 +104,38 @@ pub enum SurfaceMaterial {
/// signed.
pub type ShellVoxelPos = (u8, u8, i32);
/// The derived shell of a single 64 m chunk — the D-230 `FillChunk` output (T-987).
/// Chunk-local ground-tile coordinate: `(x, y)` in `0..64` — no `z`. Interstitial
/// classification (T-1098) is a ground-plane property, one value per tile column,
/// not a voxel-height concept (unlike [`ShellVoxelPos`]).
pub type GroundTilePos = (u8, u8);
/// The derived shell of a single 64 m chunk — the D-230 `FillChunk` output (T-987),
/// extended by T-1098 with ground-plane interstitial classification.
///
/// 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.
///
/// **Absence contract (T-1098) — every block-local ground tile in this chunk is one
/// of exactly three kinds, and the three maps below encode which:**
///
/// 1. **Footprint-covered** — has an entry in `voxels` (and, if `Wall`/`Roof`, in
/// `surface_material`). Never has an entry in `interstitial`.
/// 2. **Interstitial** (the gap between footprints, D-233/D-235) — has an entry in
/// `interstitial`. Never has an entry in `voxels`/`surface_material` at any `z`
/// (ground-plane classification only; a footprint's own tiles are never
/// reclassified as interstitial).
/// 3. **Street-margin, or inside a reserved block** — has **no** entry in any of the
/// three maps. This is future D-234 street-network scope, not an unresolved or
/// "unknown" state: a missing key in `interstitial` means "check `voxels` — if
/// that's also empty at this `(x, y)`, this tile is street-margin or reserved,
/// never an unclassified fourth case."
///
/// A consumer asking "is this tile interstitial" must read absence-from-`interstitial`
/// as "resolve against `voxels` to tell footprint from street/reserved," never as
/// "unknown" — the exhaustiveness lives in the block-plan-time classification
/// (`atlas::skeleton_gen::subdivide_block_footprints`'s `BlockSubdivision`), and this
/// struct's contract must not reintroduce ambiguity one level up.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct FilledChunk {
/// Stable id of the quarter this chunk belongs to (D-194/D-230).
@@ -121,6 +154,12 @@ pub struct FilledChunk {
/// `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>,
/// T-1098: D-235/D-233 ground-tile character for every chunk-local tile
/// classified interstitial (the gap between footprints) — dense over that
/// area, keyed by 2-D chunk-local position (no `z`; see [`GroundTilePos`]).
/// See this struct's doc for the absence contract that ties this map to
/// `voxels`.
pub interstitial: BTreeMap<GroundTilePos, InterstitialType>,
}
impl FilledChunk {
@@ -144,11 +183,14 @@ impl FilledChunk {
}
/// 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).
/// given that block's pre-resolved building tags (D-230 derive phase, T-987) and
/// T-1098 interstitial data.
///
/// 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
/// is the covering block's `Vec<BuildingPropertyTag>`, `interstitial_leaves` its
/// `Vec<TileRect>` of interstitial ground-tile rects, and `block_character` its
/// `BlockFillContext` — all from the cached `QuarterWorldState`/`QuarterSkeleton`,
/// pre-resolved by the caller because the work executor is cache-free
/// ([`crate::atlas::gen_queue`]).
///
/// `quarter_id` is threaded through for addressing only.
@@ -157,6 +199,8 @@ pub fn fill_chunk(
block_pos: (u8, u8),
sub_chunk: (u8, u8),
block_tags: &[BuildingPropertyTag],
interstitial_leaves: &[TileRect],
block_character: BlockFillContext,
) -> FilledChunk {
debug_assert!(
(sub_chunk.0 as i32) < CHUNKS_PER_BLOCK && (sub_chunk.1 as i32) < CHUNKS_PER_BLOCK,
@@ -164,20 +208,22 @@ pub fn fill_chunk(
);
let mut voxels: BTreeMap<ShellVoxelPos, ShellVoxel> = BTreeMap::new();
let mut surface_material: BTreeMap<ShellVoxelPos, SurfaceMaterial> = BTreeMap::new();
let mut interstitial: BTreeMap<GroundTilePos, InterstitialType> = 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
let window = (chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y);
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),
);
shell_derive_into(&mut voxels, &mut surface_material, tag, window);
}
let interstitial_type = resolve_interstitial_type(block_character);
for leaf in interstitial_leaves {
interstitial_fill_into(&mut interstitial, leaf, interstitial_type, window);
}
FilledChunk {
@@ -186,6 +232,7 @@ pub fn fill_chunk(
sub_chunk,
voxels,
surface_material,
interstitial,
}
}
@@ -315,6 +362,45 @@ fn is_perimeter(footprint: &TileRect, tx: i32, ty: i32) -> bool {
tx == lo_x || tx == hi_x || ty == lo_y || ty == hi_y
}
/// Emit one interstitial leaf's ground-tile classification into `interstitial`
/// (T-1098), clipped to the chunk's block-local tile window `(lo_x, lo_y, hi_x,
/// hi_y)` (hi exclusive) — mirrors [`shell_derive_into`]'s clip-to-window shape.
///
/// Rectangle-containment only (no z-range/floors — interstitial fill is a
/// ground-plane classification, one value per tile column). Every tile inside
/// `leaf ∩ window` gets `interstitial_type` — the same value for the whole leaf,
/// since character is a frozen block-level fact (`BlockFillContext`), not a
/// per-tile roll.
fn interstitial_fill_into(
interstitial: &mut BTreeMap<GroundTilePos, InterstitialType>,
leaf: &TileRect,
interstitial_type: InterstitialType,
window: (i32, i32, i32, i32),
) {
let (win_lo_x, win_lo_y, win_hi_x, win_hi_y) = window;
let leaf_lo_x = leaf.origin.0 as i32;
let leaf_lo_y = leaf.origin.1 as i32;
let leaf_hi_x = leaf_lo_x + leaf.size.0.max(1) as i32;
let leaf_hi_y = leaf_lo_y + leaf.size.1.max(1) as i32;
let lo_x = leaf_lo_x.max(win_lo_x);
let lo_y = leaf_lo_y.max(win_lo_y);
let hi_x = leaf_hi_x.min(win_hi_x);
let hi_y = leaf_hi_y.min(win_hi_y);
if lo_x >= hi_x || lo_y >= hi_y {
return; // leaf disjoint from this chunk's window
}
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;
interstitial.insert((cx, cy), interstitial_type);
}
}
}
/// 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);
@@ -329,10 +415,37 @@ mod tests {
use crate::atlas::tile_condition::TileCondition;
use crate::simulation::generator::{
ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, ConstructionEra, EraCause,
FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, RoofForm, SetbackTier,
StreetSurface, WallMaterial, ZoneTypeId,
FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, InterstitialCharacter, RoofForm,
SetbackTier, StreetSurface, WallMaterial, ZoneTypeId,
};
/// Neutral `BlockFillContext` for shell-only tests that don't exercise T-1098
/// interstitial fill (`OpenSpace`/`Standard` — resolves to `Garden`, irrelevant
/// since these tests pass no `interstitial_leaves`).
const NO_INTERSTITIAL_CTX: BlockFillContext = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::Standard,
};
/// Test helper: call `fill_chunk` with no interstitial leaves, for the
/// pre-T-1098 shell-only test suite below — keeps those tests focused on
/// shell derivation without threading unused T-1098 args through each call.
fn fill_chunk_shell_only(
quarter_id: u64,
block_pos: (u8, u8),
sub_chunk: (u8, u8),
block_tags: &[BuildingPropertyTag],
) -> FilledChunk {
fill_chunk(
quarter_id,
block_pos,
sub_chunk,
block_tags,
&[],
NO_INTERSTITIAL_CTX,
)
}
/// Build a `BuildingPropertyTag` with the given block-local footprint and a
/// uniform 3-voxel-per-floor extent (the D-229 default).
fn tag(
@@ -372,7 +485,7 @@ mod tests {
#[test]
fn empty_block_yields_empty_chunk() {
let fc = fill_chunk(7, (0, 0), (0, 0), &[]);
let fc = fill_chunk_shell_only(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));
@@ -380,7 +493,7 @@ mod tests {
#[test]
fn chunk_in_quarter_maps_block_and_sub_chunk() {
let fc = fill_chunk(0, (3, 2), (1, 0), &[]);
let fc = fill_chunk_shell_only(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));
}
@@ -388,7 +501,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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.
@@ -407,7 +520,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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);
@@ -423,7 +536,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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.
@@ -436,7 +549,7 @@ mod tests {
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)]);
let fc = fill_chunk_shell_only(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).
@@ -450,7 +563,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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);
}
@@ -463,8 +576,8 @@ mod tests {
// 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));
let left = fill_chunk_shell_only(1, (0, 0), (0, 0), std::slice::from_ref(&building));
let right = fill_chunk_shell_only(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.
@@ -478,8 +591,8 @@ mod tests {
#[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);
let a = fill_chunk_shell_only(99, (1, 1), (0, 1), &tags);
let b = fill_chunk_shell_only(99, (1, 1), (0, 1), &tags);
assert_eq!(a, b);
}
@@ -496,7 +609,7 @@ mod tests {
tags.push(tag((gx * 16, gy * 16), (14, 14), 0, 5));
}
}
let fc = fill_chunk(1, (0, 0), (0, 0), &tags);
let fc = fill_chunk_shell_only(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);
@@ -513,7 +626,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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!(
@@ -524,7 +637,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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)),
@@ -534,7 +647,7 @@ mod tests {
#[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)]);
let fc = fill_chunk_shell_only(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);
@@ -544,7 +657,7 @@ mod tests {
#[test]
fn surface_material_count_matches_wall_plus_roof_voxels() {
let fc = fill_chunk(
let fc = fill_chunk_shell_only(
1,
(0, 0),
(0, 0),
@@ -570,7 +683,7 @@ mod tests {
b2.exterior.wall_material = WallMaterial::TimberWall;
b2.exterior.roof_form = RoofForm::PitchedRoof;
let fc = fill_chunk(1, (0, 0), (0, 0), &[b1, b2]);
let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[b1, b2]);
assert_eq!(
fc.surface_material.get(&(0, 0, 0)),
Some(&SurfaceMaterial::Wall(WallMaterial::StoneWall))
@@ -580,4 +693,141 @@ mod tests {
Some(&SurfaceMaterial::Wall(WallMaterial::TimberWall))
);
}
// ── T-1098: interstitial ground-tile fill ────────────────────────────────
#[test]
fn interstitial_leaf_stamps_the_resolved_type_over_its_tiles() {
// A single interstitial leaf, ZeroLot/OpenSpace → Void, at block-local
// (10,10)-(14,14) (a 4x4 leaf), fully inside sub-chunk (0,0).
let leaf = TileRect::new(10, 10, 4, 4);
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::ZeroLot,
};
let fc = fill_chunk(1, (0, 0), (0, 0), &[], &[leaf], ctx);
for tx in 10..14u8 {
for ty in 10..14u8 {
assert_eq!(
fc.interstitial.get(&(tx, ty)),
Some(&InterstitialType::Void),
"tile ({tx},{ty}) inside the leaf must resolve to Void (ZeroLot)"
);
}
}
// Outside the leaf: no entry.
assert_eq!(fc.interstitial.get(&(0, 0)), None);
assert_eq!(fc.interstitial.get(&(20, 20)), None);
}
#[test]
fn every_setback_tier_resolves_to_its_own_interstitial_type() {
let leaf = TileRect::new(0, 0, 2, 2);
let cases = [
(SetbackTier::ZeroLot, InterstitialType::Void),
(SetbackTier::Tight, InterstitialType::Court),
(SetbackTier::Standard, InterstitialType::Garden),
(SetbackTier::Generous, InterstitialType::Plaza),
(SetbackTier::Campus, InterstitialType::OpenLawn),
];
for (tier, expected) in cases {
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: tier,
};
let fc = fill_chunk(1, (0, 0), (0, 0), &[], std::slice::from_ref(&leaf), ctx);
assert_eq!(
fc.interstitial.get(&(0, 0)),
Some(&expected),
"setback tier {tier:?} must stamp {expected:?}"
);
}
}
#[test]
fn operations_surface_short_circuits_regardless_of_setback_in_fill_chunk() {
// D-233: bulk-industry blocks read as OperationsSurface no matter the
// setback tier — verified at the fill_chunk level, not just resolve().
let leaf = TileRect::new(5, 5, 3, 3);
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OperationsSurface,
setback_tier: SetbackTier::Campus, // loosest tier — must NOT win
};
let fc = fill_chunk(1, (0, 0), (0, 0), &[], &[leaf], ctx);
assert_eq!(
fc.interstitial.get(&(5, 5)),
Some(&InterstitialType::OperationsSurface)
);
}
#[test]
fn footprint_and_interstitial_tiles_are_mutually_exclusive() {
// A building footprint and an adjacent-but-disjoint interstitial leaf
// in the same chunk: no chunk-local tile appears in both maps.
let building = tag((0, 0), (4, 4), 0, 1);
let leaf = TileRect::new(10, 10, 4, 4);
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::Standard,
};
let fc = fill_chunk(1, (0, 0), (0, 0), &[building], &[leaf], ctx);
assert!(!fc.interstitial.is_empty(), "leaf must produce entries");
assert!(
fc.voxels
.keys()
.any(|(x, y, _)| (0..4).contains(x) && (0..4).contains(y)),
"footprint must produce voxel entries"
);
for (x, y) in fc.interstitial.keys() {
assert!(
fc.voxels.keys().all(|(vx, vy, _)| vx != x || vy != y),
"tile ({x},{y}) must not appear in both voxels and interstitial"
);
}
}
#[test]
fn interstitial_leaf_clipped_to_sub_chunk() {
// A leaf spanning the block's sub-chunk seam at x=64: only the portion
// inside each sub-chunk's window is stamped, chunk-local.
let leaf = TileRect::new(60, 10, 8, 4); // block-local x 60..68
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::Standard,
};
let left = fill_chunk(1, (0, 0), (0, 0), &[], std::slice::from_ref(&leaf), ctx);
let right = fill_chunk(1, (0, 0), (1, 0), &[], &[leaf], ctx);
assert!(!left.interstitial.is_empty());
assert!(left.interstitial.keys().all(|(x, _)| (60..64).contains(x)));
assert!(!right.interstitial.is_empty());
assert!(right.interstitial.keys().all(|(x, _)| (0..4).contains(x)));
}
#[test]
fn interstitial_fill_is_deterministic() {
let leaves = vec![TileRect::new(0, 0, 6, 6), TileRect::new(40, 40, 10, 8)];
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::Generous,
};
let a = fill_chunk(99, (1, 1), (0, 1), &[], &leaves, ctx);
let b = fill_chunk(99, (1, 1), (0, 1), &[], &leaves, ctx);
assert_eq!(a, b);
}
#[test]
fn empty_interstitial_leaves_yields_no_interstitial_entries() {
// A reserved block (or a block whose BSP leaves all became buildings)
// passes an empty interstitial_leaves list — must not error, and must
// not fabricate entries.
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::Standard,
};
let fc = fill_chunk(1, (0, 0), (0, 0), &[], &[], ctx);
assert!(fc.interstitial.is_empty());
}
}
+186 -47
View File
@@ -842,6 +842,21 @@ fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
}
}
/// The two-way split of a block's BSP leaves (T-1098): the leaves that won
/// their D-233 coverage roll become building footprints; the leaves that lost
/// it are the block's interstitial ground — the gap between footprints. Both
/// lists are exhaustive over the block's inner rect (street-margin-excluded)
/// tile-space by construction: every BSP leaf lands in exactly one list.
#[derive(Debug, Clone, PartialEq, Eq)]
struct BlockSubdivision {
/// Building footprints — shrunk by `setback` to leave street frontage.
footprints: Vec<TileRect>,
/// Interstitial ground-tile rects — the *whole* BSP leaf, unshrunk (see
/// `subdivide_block_footprints`'s doc for why no setback shrink applies
/// here).
interstitial: Vec<TileRect>,
}
/// Subdivide one block into building footprints (D-220/D-229/D-233/D-234).
///
/// Lot size + setback scale with `density_pct` (Frontier → few big lots, wide
@@ -850,6 +865,16 @@ fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
/// open space (yards/parks/lots). Footprints are axis-aligned `TileRect`s in
/// block tile-space. `waterfront` (D-234b): on the water-facing edge the street
/// margin drops to 0 — buildings present flush to the quay (dock-orthogonal).
///
/// **T-1098:** returns both the footprint list *and* the interstitial leaf
/// list — the BSP leaves that lost the coverage roll, previously discarded.
/// An interstitial leaf is **not** shrunk by `setback`: the setback shrink
/// only applies to leaves that become buildings (pulling back from the lot
/// line to leave frontage for the *building's own* setback); an interstitial
/// leaf has no building on the other side to setback from, so it keeps the
/// whole BSP leaf rect — that full rect is exactly "the gap between
/// buildings," including the frontage a neighbor's setback would have
/// consumed.
fn subdivide_block_footprints(
density_pct: u8,
bulk: &BulkClass,
@@ -859,7 +884,7 @@ fn subdivide_block_footprints(
_morphology: &MorphologyZone,
waterfront: Option<Edge>,
seed: SeedChain,
) -> Vec<TileRect> {
) -> BlockSubdivision {
let (min_lot, max_lot, setback) = match density_pct {
0..=20 => (24u8, 48u8, 3u8), // Frontier
21..=45 => (16, 32, 2), // Settled
@@ -889,24 +914,38 @@ fn subdivide_block_footprints(
bsp(inner, 0, min_lot, max_lot, seed.seed(), &mut leaves);
// Keep the coverage fraction as buildings; shrink each by the setback to
// leave street frontage. The rest is interstitial open space.
let mut out = Vec::new();
// leave street frontage. The rest (T-1098) is interstitial ground —
// collected, not discarded.
let mut footprints = Vec::new();
let mut interstitial = Vec::new();
for (i, lot) in leaves.iter().enumerate() {
let roll = (splitmix64(seed.seed() ^ (i as u64 + 1)) % 100) as u8;
if roll >= coverage {
continue; // interstitial gap
interstitial.push(lot.clone()); // T-1098: the whole leaf, unshrunk
continue;
}
let w = lot.size.0.saturating_sub(setback);
let h = lot.size.1.saturating_sub(setback);
if w >= 1 && h >= 1 {
out.push(TileRect::new(lot.origin.0, lot.origin.1, w, h));
footprints.push(TileRect::new(lot.origin.0, lot.origin.1, w, h));
} else {
// Degenerate shrink (setback ate the whole lot) — the lot still
// reads as ground, not a building; T-1098 keeps it interstitial
// rather than silently vanishing from both lists.
interstitial.push(lot.clone());
}
}
out
BlockSubdivision {
footprints,
interstitial,
}
}
/// Tag every building footprint in one block (D-229). Reserved blocks (parks,
/// terminals, plazas) are open/special-use and get no standard building fill.
/// Tag every building footprint in one block (D-229), plus the block's T-1098
/// interstitial ground-tile rects. Reserved blocks (parks, terminals, plazas)
/// are open/special-use and get no standard building fill (nor interstitial
/// leaves — a reserved block's own fill story is out of this ticket's scope,
/// see the T-1098 brief §4).
///
/// `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
@@ -920,9 +959,9 @@ fn assign_block_tags(
waterfront: Option<Edge>,
block_chain: SeedChain,
exterior_catalog: &ExteriorCatalog,
) -> Vec<BuildingPropertyTag> {
) -> (Vec<BuildingPropertyTag>, Vec<TileRect>) {
if block.reservation.is_some() {
return Vec::new();
return (Vec::new(), Vec::new());
}
let prosperity = context.prosperity_baseline_bps;
let setting = &context.surrounding_biome;
@@ -944,15 +983,17 @@ fn assign_block_tags(
heritage_bps: context.swerve_rates_bps.1,
};
let footprints = subdivide_block_footprints(
let subdivision = subdivide_block_footprints(
block.density_pct,
&context.dominant_bulk_class,
&context.morphology_zone,
waterfront,
block_chain,
);
let interstitial = subdivision.interstitial;
footprints
let tags = subdivision
.footprints
.into_iter()
.enumerate()
.map(|(i, footprint)| {
@@ -1032,11 +1073,24 @@ fn assign_block_tags(
doors: Vec::new(), // D-231 door derivation is #979
}
})
.collect()
.collect();
(tags, interstitial)
}
/// 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.
/// Both block-keyed maps `assign_all_block_tags` produces (T-1098): the
/// existing per-building tags, plus the sibling interstitial-leaf map that
/// threads block-level ground-tile geometry to `FillChunk` (see
/// `simulation::generator::QuarterWorldState.block_interstitial`).
#[derive(Debug, Clone, PartialEq)]
pub struct BlockTagAssignment {
pub block_tags: BTreeMap<(u8, u8), Vec<BuildingPropertyTag>>,
pub block_interstitial: BTreeMap<(u8, u8), Vec<TileRect>>,
}
/// Build the full `block_tags` (+ T-1098 `block_interstitial`) maps for a
/// quarter (D-229/D-230): subdivide and tag every non-reserved block's
/// footprints. Both maps are 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.
@@ -1047,7 +1101,7 @@ pub fn assign_all_block_tags(
founding_age_years: u32,
chain: SeedChain,
exterior_catalog: &ExteriorCatalog,
) -> BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> {
) -> BlockTagAssignment {
// Flush-frontage quarter edge: water-facing from a coastal founding
// orientation (D-234b — blocks present flush to the quay), or rail-facing
// from a RailHeadFacing orientation (T-1076 §4 — blocks present flush
@@ -1055,13 +1109,14 @@ pub fn assign_all_block_tags(
// mutually exclusive by construction (one orientation per settlement).
let flush_edge = coastal_edge(&context.founding_orientation)
.or_else(|| railhead_edge(&context.founding_orientation));
let mut map = BTreeMap::new();
let mut block_tags = BTreeMap::new();
let mut block_interstitial = BTreeMap::new();
for row in 0..4u8 {
for col in 0..4u8 {
let block = &skeleton.blocks[row as usize][col as usize];
let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64);
let waterfront = flush_edge.filter(|&e| block_on_quarter_edge(row, col, e));
let tags = assign_block_tags(
let (tags, interstitial) = assign_block_tags(
block,
skeleton,
context,
@@ -1072,11 +1127,17 @@ pub fn assign_all_block_tags(
exterior_catalog,
);
if !tags.is_empty() {
map.insert((row, col), tags);
block_tags.insert((row, col), tags);
}
if !interstitial.is_empty() {
block_interstitial.insert((row, col), interstitial);
}
}
}
map
BlockTagAssignment {
block_tags,
block_interstitial,
}
}
// ---------------------------------------------------------------------------
@@ -1726,15 +1787,18 @@ mod tests {
#[test]
fn footprints_fit_within_block_bounds() {
let fps = subdivide_block_footprints(
let sub = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(1),
);
assert!(!fps.is_empty(), "a dense block should produce footprints");
for fp in &fps {
assert!(
!sub.footprints.is_empty(),
"a dense block should produce footprints"
);
for fp in &sub.footprints {
assert!(fp.size.0 >= 1 && fp.size.1 >= 1);
assert!(fp.origin.0 as u16 + fp.size.0 as u16 <= BLOCK_TILES as u16);
assert!(fp.origin.1 as u16 + fp.size.1 as u16 <= BLOCK_TILES as u16);
@@ -1758,10 +1822,81 @@ mod tests {
SeedChain::root(7),
);
assert!(
dense.len() > sparse.len(),
dense.footprints.len() > sparse.footprints.len(),
"dense ({}) should pack more lots than sparse ({})",
dense.len(),
sparse.len()
dense.footprints.len(),
sparse.footprints.len()
);
}
// ── T-1098: interstitial leaves from subdivide_block_footprints ─────────
#[test]
fn interstitial_leaves_are_produced_alongside_footprints() {
let sub = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(1),
);
assert!(
!sub.interstitial.is_empty(),
"a block with coverage < 100% must produce interstitial leaves"
);
for leaf in &sub.interstitial {
assert!(leaf.size.0 >= 1 && leaf.size.1 >= 1);
assert!(leaf.origin.0 as u16 + leaf.size.0 as u16 <= BLOCK_TILES as u16);
assert!(leaf.origin.1 as u16 + leaf.size.1 as u16 <= BLOCK_TILES as u16);
}
}
#[test]
fn footprints_and_interstitial_leaves_do_not_overlap() {
// Every BSP leaf becomes exactly one of a (shrunk) footprint or an
// (unshrunk) interstitial rect — never both, never neither is
// reflected in this test by checking no footprint origin coincides
// with an interstitial leaf's origin (BSP leaves are disjoint, so
// distinct origins is a sufficient proxy for "different leaves").
let sub = subdivide_block_footprints(
50,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(3),
);
for fp in &sub.footprints {
assert!(
!sub.interstitial.contains(fp),
"a footprint rect must not also appear as an interstitial leaf"
);
}
}
#[test]
fn low_coverage_bulk_class_yields_more_interstitial_than_high_coverage() {
// BulkSolid (25-40% coverage) vs NonPhysical (85-95% coverage) at the
// same density/seed: BulkSolid must leave far more ground uncovered.
let bulk_solid = subdivide_block_footprints(
70,
&BulkClass::BulkSolid,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(9),
);
let non_physical = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(9),
);
assert!(
bulk_solid.interstitial.len() >= non_physical.interstitial.len(),
"low-coverage BulkSolid ({}) should leave at least as much interstitial \
ground as high-coverage NonPhysical ({})",
bulk_solid.interstitial.len(),
non_physical.interstitial.len()
);
}
@@ -1777,7 +1912,7 @@ 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(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -1785,6 +1920,7 @@ mod tests {
SeedChain::root(42),
&ExteriorCatalog::default(),
);
let tags = &assignment.block_tags;
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)] {
@@ -1851,7 +1987,7 @@ mod tests {
);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -1861,7 +1997,7 @@ mod tests {
);
let mut found_residential = false;
for (pos, block_tags) in &tags {
for (pos, block_tags) in &assignment.block_tags {
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
if block.district_type != DistrictType::Residential {
continue;
@@ -1888,7 +2024,7 @@ 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(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -1896,7 +2032,7 @@ mod tests {
SeedChain::root(42),
&ExteriorCatalog::default(),
);
for block_tags in tags.values() {
for block_tags in assignment.block_tags.values() {
for tag in block_tags {
assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0));
}
@@ -1918,7 +2054,7 @@ 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(
let assignment_a = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -1926,7 +2062,7 @@ mod tests {
SeedChain::root(42),
&ExteriorCatalog::default(),
);
let tags_b = assign_all_block_tags(
let assignment_b = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -1934,11 +2070,14 @@ mod tests {
SeedChain::root(42),
&ExteriorCatalog::default(),
);
assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)");
assert_eq!(
assignment_a, assignment_b,
"same seeds → same swerves (D-010)"
);
let mut total = 0usize;
let mut swerved = 0usize;
for block_tags in tags_a.values() {
for block_tags in assignment_a.block_tags.values() {
for tag in block_tags {
total += 1;
match &tag.flavor_ref {
@@ -1971,7 +2110,7 @@ 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(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -1979,7 +2118,7 @@ mod tests {
SeedChain::root(42),
&ExteriorCatalog::default(),
);
for block_tags in tags.values() {
for block_tags in assignment.block_tags.values() {
for tag in block_tags {
assert!(matches!(
tag.flavor_ref,
@@ -2038,7 +2177,7 @@ mod tests {
};
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -2048,7 +2187,7 @@ mod tests {
);
let mut found_residential = false;
for (pos, block_tags) in &tags {
for (pos, block_tags) in &assignment.block_tags {
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
if block.district_type != DistrictType::Residential {
continue;
@@ -2094,7 +2233,7 @@ mod tests {
};
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -2104,7 +2243,7 @@ mod tests {
);
let mut any_swerved = false;
for block_tags in tags.values() {
for block_tags in assignment.block_tags.values() {
for tag in block_tags {
if matches!(&tag.flavor_ref, ArchitectureFlavorRef::Swerve(t) if t == "foreign_temple")
{
@@ -2127,7 +2266,7 @@ mod tests {
// 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(
let assignment = assign_all_block_tags(
&sk,
&ctx,
"financial",
@@ -2135,8 +2274,8 @@ mod tests {
SeedChain::root(42),
&ExteriorCatalog::default(),
);
assert!(!tags.is_empty());
for block_tags in tags.values() {
assert!(!assignment.block_tags.is_empty());
for block_tags in assignment.block_tags.values() {
for tag in block_tags {
assert_eq!(tag.exterior.wall_material, WallMaterial::Generic);
assert_eq!(tag.exterior.roof_form, RoofForm::Generic);
@@ -2365,9 +2504,9 @@ mod tests {
SeedChain::root(3),
);
let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX);
assert!(min_y(&inland) >= BLOCK_MARGIN);
assert!(min_y(&inland.footprints) >= BLOCK_MARGIN);
assert!(
min_y(&quay) < min_y(&inland),
min_y(&quay.footprints) < min_y(&inland.footprints),
"quay buildings should reach the water edge"
);
}
@@ -2390,7 +2529,7 @@ mod tests {
SeedChain::root(11),
);
let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX);
assert!(min_y(&quay) < min_y(&inland));
assert!(min_y(&quay.footprints) < min_y(&inland.footprints));
}
#[test]
+142
View File
@@ -1032,6 +1032,80 @@ pub enum InterstitialCharacter {
OperationsSurface,
}
/// The D-235 ground-tile character resolved for an interstitial tile (T-1098) —
/// the space between building footprints, once classified as neither
/// footprint-covered nor street-margin (see `atlas::shell`'s three-way
/// partition doc). `OperationsSurface` short-circuits from `InterstitialCharacter`
/// (D-233, bulk-industry blocks); every other case reads `SetbackTier` (D-235
/// step 3).
///
/// **Five-value subset, not the closed vocabulary.** D-235 names seven
/// interstitial values in prose (`void`/`court`/`garden`/`plaza`/`dock_slip`/
/// `market_pad`/`open_lawn`); this enum ships the five with an unambiguous
/// `SetbackTier` trigger. `DockSlip`/`MarketPad` are **not yet included** —
/// D-235 gives no concrete trigger condition for either (waterfront edge?
/// zone_type? both?) — see **T-1209**. Add them here (append-only, D-010) once
/// T-1209 lands a derivation rule; do not guess one in to close the enum early.
///
/// Integer-discriminant, append-only (D-010) — same convention as
/// `ShellVoxel`/`SetbackTier`/the D-235 material axes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum InterstitialType {
/// Zero-lot setback reads as bare ground — no room for a yard.
Void = 0,
/// Tight setback — a narrow shared court between buildings.
Court = 1,
/// Standard setback — an informal garden/yard.
Garden = 2,
/// Generous setback — open enough to read as a small plaza.
Plaza = 3,
/// Campus setback — wide open lawn around the building.
OpenLawn = 4,
/// D-233 built economic infrastructure (bulk industry) — never open space.
OperationsSurface = 5,
}
/// Minimal block-level context a `FillChunk` interstitial resolution needs
/// (T-1098) — `InterstitialCharacter` (D-233) + `SetbackTier` (D-235), the
/// only two scalars `resolve_interstitial_type` reads. Deliberately **not**
/// the whole `BlockSkeleton`: `hosted_sites`/`chunk_layout`/`landmark`/etc.
/// have no bearing on interstitial-tile character, and carrying them into
/// `FillChunk` would violate the same "only what's needed" discipline
/// `BuildingPropertyTag` already follows by freezing a projection rather than
/// holding a `CityGenerationContext` reference.
///
/// Both fields are re-derived once at block level from data that already
/// determines them uniformly across the block (`BlockSkeleton.density_pct` /
/// `interstitial_character`) — not read off any one building's tag, so an
/// all-interstitial or reservation-adjacent block still resolves correctly
/// with no building to borrow a tag from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockFillContext {
pub interstitial_character: InterstitialCharacter,
pub setback_tier: SetbackTier,
}
/// Resolve a classified-interstitial tile to its D-235/D-233 character
/// (T-1098). Pure total function — no RNG, no cache read (T-987 purity); every
/// interstitial tile in a block resolves identically, since both inputs are
/// frozen block-level facts, not per-tile ones.
pub fn resolve_interstitial_type(ctx: BlockFillContext) -> InterstitialType {
if ctx.interstitial_character == InterstitialCharacter::OperationsSurface {
return InterstitialType::OperationsSurface; // D-233 — never open space
}
// D-233 OpenSpace path only. Five of D-235's seven named values map
// unambiguously onto SetbackTier; `dock_slip`/`market_pad` are T-1209 —
// not resolved here (see `InterstitialType` doc).
match ctx.setback_tier {
SetbackTier::ZeroLot => InterstitialType::Void,
SetbackTier::Tight => InterstitialType::Court,
SetbackTier::Standard => InterstitialType::Garden,
SetbackTier::Generous => InterstitialType::Plaza,
SetbackTier::Campus => InterstitialType::OpenLawn,
}
}
/// A single building footprint tag — the frozen step-3 output placed on every
/// building footprint at plan time (D-229).
///
@@ -1292,6 +1366,13 @@ pub struct QuarterWorldState {
/// Each entry is a Vec of one tag per building footprint placed in that block.
/// `BTreeMap` for D-010 determinism (no HashMap non-determinism).
pub block_tags: BTreeMap<(u8, u8), Vec<BuildingPropertyTag>>,
/// T-1098: interstitial ground-tile rects keyed by the same block grid
/// position — the BSP leaves `subdivide_block_footprints` produced that
/// lost their D-233 coverage roll (the gap between building footprints).
/// A block with no entry here is either reserved (no subdivision run at
/// all) or every leaf became a building (coverage rolled 100%) — both
/// legitimate, not an error. `BTreeMap` for D-010 determinism.
pub block_interstitial: BTreeMap<(u8, u8), Vec<TileRect>>,
}
/// Data contract between build-time (systems.db) and the runtime-background
@@ -1775,4 +1856,65 @@ mod tests {
assert_u8(z_levels);
assert!(z_levels > 0, "z_levels is always at least 1");
}
// ── T-1098: resolve_interstitial_type ───────────────────────────────
#[test]
fn operations_surface_short_circuits_regardless_of_setback() {
// D-233: bulk-industry blocks read as built infrastructure no matter
// how loose the setback tier — OperationsSurface must win every time.
for tier in [
SetbackTier::ZeroLot,
SetbackTier::Tight,
SetbackTier::Standard,
SetbackTier::Generous,
SetbackTier::Campus,
] {
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OperationsSurface,
setback_tier: tier,
};
assert_eq!(
resolve_interstitial_type(ctx),
InterstitialType::OperationsSurface,
"OperationsSurface must short-circuit for setback tier {tier:?}"
);
}
}
#[test]
fn open_space_maps_setback_tier_to_interstitial_type() {
// D-235 step 3: the five unambiguous values, tightest to loosest.
let cases = [
(SetbackTier::ZeroLot, InterstitialType::Void),
(SetbackTier::Tight, InterstitialType::Court),
(SetbackTier::Standard, InterstitialType::Garden),
(SetbackTier::Generous, InterstitialType::Plaza),
(SetbackTier::Campus, InterstitialType::OpenLawn),
];
for (tier, expected) in cases {
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: tier,
};
assert_eq!(
resolve_interstitial_type(ctx),
expected,
"setback tier {tier:?} must resolve to {expected:?}"
);
}
}
#[test]
fn resolve_interstitial_type_is_pure_and_deterministic() {
let ctx = BlockFillContext {
interstitial_character: InterstitialCharacter::OpenSpace,
setback_tier: SetbackTier::Standard,
};
assert_eq!(
resolve_interstitial_type(ctx),
resolve_interstitial_type(ctx),
"same input must yield the same output every call"
);
}
}