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
Binary file not shown.
+16 -3
View File
@@ -17,6 +17,7 @@
use tracing::{error, warn};
use crate::atlas::features::NO_WATER_BEARING;
use crate::seed::{splitmix64, SeedChain};
use crate::simulation::generator::{
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant,
@@ -265,6 +266,7 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
strength: 50,
sub_biome: SubBiomeVariant::TemperateGrassland,
terrain_modification_cost: 100,
water_bearing: NO_WATER_BEARING, // inland synthetic — no water direction
}
}
@@ -282,6 +284,7 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
/// grids vary per seed (D-213) while staying deterministic (D-010).
fn city_character(
attractor_type: AttractorType,
water_bearing: u16,
economic_role: &str,
territorial_status: &TerritorialStatus,
city_id: u64,
@@ -290,9 +293,15 @@ fn city_character(
let archetype = political_archetype(territorial_status, economic_role);
let pattern = arrangement_pattern(&archetype, economic_role);
let free_bearing = (splitmix64(seed.seed() ^ city_id) % 360) as u16;
// river_bearing / coastal_facing are 0 until Layer 1 exposes terrain bearings
// (D-209 follow-on); free_bearing seeds the pioneer/open-terrain case.
let orientation = founding_orientation(&attractor_type, territorial_status, 0, 0, free_bearing);
// Layer-1 water bearing (#957) drives both the coastal facing and the river
// bearing (the anchoring attractor's direction-to-water); 360 = none → 0.
let wb = if water_bearing >= 360 {
0
} else {
water_bearing
};
let orientation =
founding_orientation(&attractor_type, territorial_status, wb, wb, free_bearing);
(archetype, pattern, orientation)
}
@@ -354,6 +363,7 @@ pub fn match_cities(
flag_mismatch(&cities[ci].name, score);
let (archetype, pattern, orientation) = city_character(
attractors[ai].attractor_type,
attractors[ai].water_bearing,
&cities[ci].economic_role,
territorial_status,
cities[ci].city_id,
@@ -420,6 +430,7 @@ pub fn match_cities(
flag_mismatch(&cities[ci].name, score);
let (archetype, pattern, orientation) = city_character(
attractors[ai].attractor_type,
attractors[ai].water_bearing,
&cities[ci].economic_role,
territorial_status,
cities[ci].city_id,
@@ -453,6 +464,7 @@ pub fn match_cities(
flag_mismatch(&city.name, score);
let (archetype, pattern, orientation) = city_character(
AttractorType::PlainCenter,
synthetic.water_bearing,
&city.economic_role,
territorial_status,
city.city_id,
@@ -659,6 +671,7 @@ mod tests {
strength,
sub_biome: SubBiomeVariant::TemperateGrassland,
terrain_modification_cost: 100,
water_bearing: NO_WATER_BEARING,
}
}
+64
View File
@@ -122,6 +122,70 @@ impl TerrainAnalysis {
pub fn is_ocean(&self, r: usize, c: usize) -> bool {
self.ocean_mask[idx(r, c, self.w)]
}
/// Compass bearing toward the nearest water from cell `(r, c)`, quantized to
/// 8 octants (0=N, 45=NE … 315=NW); `360` = "no water in range" (#957, D-234).
///
/// Reads the `water_dist` field's local gradient — the 8-neighbour with the
/// smallest distance-to-water points toward water. Integer-only (no `atan2`)
/// for D-010 determinism. Returns `360` when the cell is itself water or no
/// neighbour is closer to water (flat/inland).
pub fn water_bearing(&self, r: usize, c: usize) -> u16 {
let here = self.water_dist[idx(r, c, self.w)];
if here == 0 || here >= WATER_DIST_CAP {
return NO_WATER_BEARING; // on water, or no water within range
}
let mut best = here;
let mut bdir = (0i32, 0i32);
for &(dr, dc) in &NB8 {
let nr = r as i32 + dr;
if nr < 0 || nr >= self.h as i32 {
continue;
}
let nc = wrap_col(c as i32 + dc, self.w as i32);
let nd = self.water_dist[idx(nr as usize, nc, self.w)];
if nd < best {
best = nd;
bdir = (dr, dc);
}
}
if bdir == (0, 0) {
NO_WATER_BEARING
} else {
octant_bearing(bdir.0, bdir.1)
}
}
}
/// Sentinel for [`TerrainAnalysis::water_bearing`] meaning "no water direction".
pub const NO_WATER_BEARING: u16 = 360;
/// Quantize a (Δrow, Δcol) step to a compass octant bearing (0=N … 315=NW).
/// `Δrow < 0` is north (rows increase downward). Integer-only (D-010).
fn octant_bearing(drow: i32, dcol: i32) -> u16 {
let (ar, ac) = (drow.abs(), dcol.abs());
let north = drow < 0;
let east = dcol > 0;
if ar >= ac * 2 {
if north {
0
} else {
180
}
} else if ac >= ar * 2 {
if east {
90
} else {
270
}
} else {
match (north, east) {
(true, true) => 45,
(true, false) => 315,
(false, true) => 135,
(false, false) => 225,
}
}
}
/// Largest connected below-sea-level component = ocean; all others = lakes.
+3
View File
@@ -57,6 +57,9 @@ pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output {
strength: r.strength,
sub_biome,
terrain_modification_cost,
// Layer-1 water-direction extraction (#957, D-234) — feeds D-213
// founding orientation + the D-234 waterfront rule.
water_bearing: ta.water_bearing(r.row as usize, r.col as usize),
}
})
.collect();
+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);
+6
View File
@@ -529,6 +529,12 @@ pub struct GeographicAttractor {
/// `sub_biome` + local slope; penalizes marginal cities in the matching
/// pipeline (D-211). Integer for D-010 determinism (#955). (D-210)
pub terrain_modification_cost: i32,
/// Compass bearing (degrees, quantized to 8 octants: 0=N,45=NE…315=NW)
/// toward the nearest water from this position, or 360 = "no water nearby".
/// Layer-1 terrain extraction feeding D-213 founding orientation (coastal
/// facing / river bearing) and the D-234 waterfront rule. Integer 8-octant
/// keeps it D-010-deterministic (no `atan2`).
pub water_bearing: u16,
}
/// Compatibility weights between economic roles and attractor types.
+1
View File
@@ -562,6 +562,7 @@ fn generate_atlas_layer_response_fixtures() {
strength: 90,
sub_biome: SubBiomeVariant::CoastalLowland,
terrain_modification_cost: 170,
water_bearing: 90,
}],
grid_w: 512,
grid_h: 256,
File diff suppressed because it is too large Load Diff