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:
Binary file not shown.
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user