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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user