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
+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]