feat(simulation): cache TerrainAnalysis + derive basin_direction from the D8 thalweg (T-1044, T-1047)

T-1044: run_layer1 now returns TerrainAnalysis (carried transiently on
CascadeSnapshot, dropped after the district + road-graph passes), eliminating the
redundant per-body drainage::analyze + TerrainAnalysis::analyze re-run flagged by
PERF/TODO(T-1044). Not persisted on the LRU-cached state (D-203/T-1048 size concern).

T-1047: basin_direction is now derived from the real D8 thalweg. run_layer1
aggregates a per-district dominant D8 direction from the live fdir grid (carried
transiently on DrainageResult), threaded via Layer1Output.district_basin_dirs ->
derive_all_districts -> DistrictProfile.basin_direction; derive_chunk_context reads
it directly. Removed the false derive_basin_direction (it branched on ocean_fraction_q
then read seed bits despite a doc comment claiming an elev_q/slope_q D8 proxy) +
corrected the module contract. D-239 §8 (D8 thalweg) now actually honoured.

1559 tests pass; golden byte-identical (district_basin_dirs is #[serde(skip)], transient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 09:46:42 +02:00
co-authored by Claude Opus 4.8
parent 711ac65d20
commit 0263b68df9
12 changed files with 349 additions and 139 deletions
+27 -64
View File
@@ -10,7 +10,10 @@
//! **coarser than a chunk** — so there is no per-tile `flow_direction[64×64]`
//! here. Instead the chunk carries:
//! - **`basin_direction`** — a cardinal basin-flow direction (N/E/S/W, 4-way)
//! derived from the dominant D8 thalweg in the covering district.
//! sourced directly from `DistrictProfile.basin_direction`, which holds the
//! **true D8-computed dominant thalweg direction** aggregated in `run_layer1`
//! (T-1047, D-239 §8). Prior to T-1047 this was incorrectly derived from
//! seed bits; the false derivation (`derive_basin_direction`) has been removed.
//! - **`meander_phase`** and **`meander_wavelength_m`** — global meander-curve
//! params for the MeanderReach and AlluvialPlain families.
//!
@@ -55,28 +58,11 @@ use crate::atlas::district_profile::DistrictProfile;
use crate::atlas::scale;
use crate::seed::{SeedChain, SeedDomain};
// ---------------------------------------------------------------------------
// Basin direction (cardinal, 4-way)
// ---------------------------------------------------------------------------
/// Cardinal basin-flow direction — the dominant D8 thalweg direction in the
/// covering district. Coarser than a chunk (D-239 §10); derived from district slope
/// and morphology, NOT from a per-tile D8 grid.
///
/// Integer-discriminant, append-only (D-010).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum BasinDirection {
/// Flow toward the north (decreasing y in grid coords).
#[default]
North = 0,
/// Flow toward the east (increasing x in grid coords).
East = 1,
/// Flow toward the south (increasing y in grid coords).
South = 2,
/// Flow toward the west (decreasing x in grid coords).
West = 3,
}
// Re-export so existing consumers (voxel.rs etc.) keep their `chunk_context::BasinDirection` path.
// The canonical definition lives in `scale.rs` (T-1047) — `BasinDirection` is shared
// between `DistrictProfile` (district_profile.rs) and `ChunkContext` (this module),
// and placing it in the scale ladder breaks the potential circular import.
pub use crate::atlas::scale::BasinDirection;
// ---------------------------------------------------------------------------
// ChunkPos — position on the 64 m chunk grid
@@ -223,10 +209,18 @@ pub struct ChunkContext {
///
/// ## Seed usage
///
/// The **meander phase** and **basin direction** are seeded at district scale so
/// features with wavelength > 64 m are consistent across chunk boundaries.
/// The chunk-local seed (keyed on `chunk_pos`) is reserved for sub-chunk scatter
/// in the voxel pass — not consumed here.
/// The **meander phase** is seeded at district scale so features with
/// wavelength > 64 m are consistent across chunk boundaries. The chunk-local
/// seed (keyed on `chunk_pos`) is reserved for sub-chunk scatter in the voxel
/// pass — not consumed here.
///
/// ## Basin direction
///
/// `basin_direction` is read directly from `district.basin_direction` — the
/// **true D8-computed dominant thalweg direction** threaded from `run_layer1`
/// (T-1047, D-239 §8). No seed-bit proxy is used here. The former
/// `derive_basin_direction` helper (which branched on `ocean_fraction_q` and
/// seed bits, not on the actual D8 grid) has been removed.
pub fn derive_chunk_context(
world_seed: u64,
body_id: &str,
@@ -246,11 +240,10 @@ pub fn derive_chunk_context(
let district_seed = SeedChain::for_body(world_seed, body_id)
.derive(SeedDomain::ChunkContext, district_scale_id);
// Basin direction — derived from district slope_q (which encodes the
// dominant terrain gradient). We use the district's `elev_q` gradient
// direction as a proxy for the D8 thalweg direction.
// All integer arithmetic (D-010).
let basin_direction = derive_basin_direction(district, district_seed.seed());
// Basin direction — read directly from DistrictProfile (T-1047, D-239 §8).
// This is the true D8-computed dominant thalweg direction aggregated in
// run_layer1 from the fdir grid; it replaces the former seed-bit proxy.
let basin_direction = district.basin_direction;
// Meander phase — district-scale integer offset so the channel is consistent
// across all chunks in the same district. 0–255.
@@ -450,38 +443,6 @@ pub(crate) fn pos_to_id(pos: (i32, i32)) -> u64 {
.wrapping_add(y)
}
/// Derive basin direction from the district profile and a district-scale seed.
///
/// Uses `elev_q` and `slope_q` as a proxy for the dominant D8 gradient
/// direction. In lieu of a full D8 computation at this scale, the basin
/// direction is derived from the district's terrain characteristics:
/// - Coastal districts (high `ocean_fraction_q`) flow toward ocean (West fallback)
/// - High-elevation districts flow away from ridges (seed-derived direction)
/// - Low-slope districts use the seed for unbiased direction
///
/// All integer arithmetic (D-010).
fn derive_basin_direction(district: &DistrictProfile, district_seed: u64) -> BasinDirection {
// Coastal: flow toward the ocean (use seed to pick E/W/N/S with coastal bias).
if district.ocean_fraction_q >= 15 {
// The low 2 bits of seed give 4 directions; bias toward the most common
// coastal configurations (N or S for equatorial coasts, E/W for shelf).
return match (district_seed >> 2) & 0x3 {
0 => BasinDirection::South,
1 => BasinDirection::East,
2 => BasinDirection::North,
_ => BasinDirection::West,
};
}
// Interior: pure seed-derived direction (unbiased).
match district_seed & 0x3 {
0 => BasinDirection::North,
1 => BasinDirection::East,
2 => BasinDirection::South,
_ => BasinDirection::West,
}
}
/// Derive meander wavelength in metres from district morphology.
///
/// Low slope + high moisture → longer wavelength (wide meanders).
@@ -543,6 +504,7 @@ mod tests {
temperature_c: Some(18.0),
moisture_q: 55,
vegetation_class: VegetationClass::Forest,
basin_direction: BasinDirection::South,
}
}
@@ -679,6 +641,7 @@ mod tests {
temperature_c: Some(35.0),
moisture_q: 5,
vegetation_class: VegetationClass::Barren,
basin_direction: BasinDirection::North,
};
let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5), None);
assert!(