feat(simulation): Layer-1 water bearing + D-234 waterfront rule (#957)

Close the last D-234 piece — terrain water-direction extraction wired through
to founding orientation and the quarter waterfront rule:

- Layer 1: TerrainAnalysis::water_bearing — 8-octant integer bearing toward the
  nearest water from the water_dist gradient (D-010, no atan2). Stored on
  GeographicAttractor.water_bearing (360 = none).
- #956 founding orientation: coastal/river settlements now get a real
  water-facing bearing (the anchoring attractor's), replacing the 0 stub.
- #957 waterfront rule (D-234b): the water-facing quarter edge (from the
  settlement's Coastal founding orientation) drops its block setback to 0 so
  buildings present flush to the quay (dock-orthogonal). Typed Edge + coastal_edge
  + per-block gating.

Golden + atlas_response fixture rebaked (additive water_bearing field only).
8 new tests. All integer-deterministic (D-010).

Pending: the waterfront rule reads context.founding_orientation, which
city_context_reader still stubs to Cardinal — real per-settlement orientation
reaches quarter generation once the Layer-3 placement -> Layer-4 GenerateSkeleton
dispatch is wired (the remaining cross-layer integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:22:14 +02:00
co-authored by Claude Opus 4.8
parent 4c6fe596d1
commit b73b4e6d92
8 changed files with 704 additions and 268 deletions
+102 -9
View File
@@ -29,9 +29,9 @@ use crate::simulation::generator::{
AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton,
BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext,
ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause,
FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation, PoliticalArchetype,
QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, SettingType, TileRect,
WorldTier, ZoneTypeId, ZoningType,
FloorExtent, FloorHeightProfile, FoundingOrientation, MorphologyZone, MultiBlockReservation,
PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId,
SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType,
};
// ---------------------------------------------------------------------------
@@ -752,17 +752,54 @@ fn bsp(rect: TileRect, depth: u8, min_lot: u8, max_lot: u8, seed: u64, out: &mut
bsp(b, depth + 1, min_lot, max_lot, splitmix64(child), out);
}
/// Subdivide one block into building footprints (D-220/D-229/D-233).
/// A quarter (or block) edge — used for the D-234 waterfront rule.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Edge {
North,
East,
South,
West,
}
/// The quarter edge facing water, from the settlement's founding orientation
/// (D-234b). Only `Coastal` orientations have a water-facing edge; the 8-octant
/// facing snaps to the nearest cardinal edge.
fn coastal_edge(orientation: &FoundingOrientation) -> Option<Edge> {
if let FoundingOrientation::Coastal { facing_degrees } = orientation {
Some(match facing_degrees % 360 {
d if !(45..315).contains(&d) => Edge::North,
d if d < 135 => Edge::East,
d if d < 225 => Edge::South,
_ => Edge::West,
})
} else {
None
}
}
/// Whether block `(row, col)` sits on the quarter's `edge` (4×4 grid).
fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
match edge {
Edge::North => row == 0,
Edge::South => row == 3,
Edge::West => col == 0,
Edge::East => col == 3,
}
}
/// 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
/// gaps; Compressed → many small lots, shared walls). The D-233 roofed coverage
/// (from `bulk`) decides what fraction of lots are buildings vs interstitial
/// open space (yards/parks/lots). Footprints are axis-aligned `TileRect`s in
/// block tile-space.
/// 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).
fn subdivide_block_footprints(
density_pct: u8,
bulk: &BulkClass,
_morphology: &MorphologyZone,
waterfront: Option<Edge>,
seed: SeedChain,
) -> Vec<TileRect> {
let (min_lot, max_lot, setback) = match density_pct {
@@ -774,11 +811,21 @@ fn subdivide_block_footprints(
};
let coverage = roofed_coverage_pct(density_pct, bulk);
// Perimeter street margin — dropped to 0 on a water-facing edge (D-234b).
let (mut top, mut bottom, mut left, mut right) =
(BLOCK_MARGIN, BLOCK_MARGIN, BLOCK_MARGIN, BLOCK_MARGIN);
match waterfront {
Some(Edge::North) => top = 0,
Some(Edge::South) => bottom = 0,
Some(Edge::West) => left = 0,
Some(Edge::East) => right = 0,
None => {}
}
let inner = TileRect::new(
BLOCK_MARGIN,
BLOCK_MARGIN,
BLOCK_TILES - 2 * BLOCK_MARGIN,
BLOCK_TILES - 2 * BLOCK_MARGIN,
left,
top,
BLOCK_TILES - left - right,
BLOCK_TILES - top - bottom,
);
let mut leaves = Vec::new();
bsp(inner, 0, min_lot, max_lot, seed.seed(), &mut leaves);
@@ -808,6 +855,7 @@ fn assign_block_tags(
context: &CityGenerationContext,
economic_role: &str,
founding_age_years: u32,
waterfront: Option<Edge>,
block_chain: SeedChain,
) -> Vec<BuildingPropertyTag> {
if block.reservation.is_some() {
@@ -821,6 +869,7 @@ fn assign_block_tags(
block.density_pct,
&context.dominant_bulk_class,
&context.morphology_zone,
waterfront,
block_chain,
);
@@ -873,17 +922,22 @@ pub fn assign_all_block_tags(
founding_age_years: u32,
chain: SeedChain,
) -> BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> {
// Water-facing quarter edge from the settlement's coastal founding
// orientation (D-234b); blocks on it present flush to the quay.
let water_edge = coastal_edge(&context.founding_orientation);
let mut map = 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 = water_edge.filter(|&e| block_on_quarter_edge(row, col, e));
let tags = assign_block_tags(
block,
skeleton,
context,
economic_role,
founding_age_years,
waterfront,
block_chain,
);
if !tags.is_empty() {
@@ -1523,6 +1577,7 @@ mod tests {
70,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(1),
);
assert!(!fps.is_empty(), "a dense block should produce footprints");
@@ -1539,12 +1594,14 @@ mod tests {
15,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(7),
);
let dense = subdivide_block_footprints(
85,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(7),
);
assert!(
@@ -1676,6 +1733,42 @@ mod tests {
assert!(corridors.iter().all(|c| c.from == hub || c.to == hub));
}
#[test]
fn coastal_edge_maps_facing_to_cardinal() {
let c = |d| coastal_edge(&FoundingOrientation::Coastal { facing_degrees: d });
assert_eq!(c(0), Some(Edge::North));
assert_eq!(c(90), Some(Edge::East));
assert_eq!(c(180), Some(Edge::South));
assert_eq!(c(270), Some(Edge::West));
assert_eq!(coastal_edge(&FoundingOrientation::Cardinal), None);
}
#[test]
fn waterfront_footprints_present_to_the_quay() {
// The water-facing (north) edge drops its setback → buildings sit flush
// (origin.1 == 0), unlike the standard margin (D-234b).
let inland = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::CoastalLowland,
None,
SeedChain::root(3),
);
let quay = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::CoastalLowland,
Some(Edge::North),
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(&quay) < min_y(&inland),
"quay buildings should reach the water edge"
);
}
#[test]
fn skeleton_has_streets_and_local_lattice() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);