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:
+141
-6
@@ -6,19 +6,28 @@
|
||||
//! elevation percentile) — D-209/D-210 inputs
|
||||
//! 3. 7-tag geographic feature extraction (D-209)
|
||||
//! 4. sub-biome + terrain_modification_cost classification (D-210)
|
||||
//! 5. per-district dominant D8 basin direction (T-1047, D-239 §8)
|
||||
//!
|
||||
//! Output is the in-memory `Layer1Output`, which maps directly onto
|
||||
//! `BodyWorldState` (D-203). Name attachment (D-223) is a separate, cheap step
|
||||
//! (`attach_feature_names`) so the compute can be benchmarked in isolation and
|
||||
//! names sourced from the DB pool independently.
|
||||
//!
|
||||
//! `run_layer1` returns `(Layer1Output, TerrainAnalysis)` so `cascade.rs` can
|
||||
//! reuse the `TerrainAnalysis` held on `CascadeSnapshot.terrain_analysis`
|
||||
//! (transient — dropped after DistrictProfile + RoadGraph consume it; D-203 /
|
||||
//! T-1044) without re-running the ~45 ms drainage pass per body.
|
||||
//!
|
||||
//! **Determinism (D-010 #4):** every stage is deterministic; the same heightmap
|
||||
//! yields bit-identical attractors and river networks.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
|
||||
use crate::atlas::drainage::{self, DrainageResult};
|
||||
use crate::atlas::features::{self, TerrainAnalysis};
|
||||
use crate::atlas::heightmap::BodyHeightmap;
|
||||
use crate::atlas::scale::{BasinDirection, DistrictPos, HEIGHTMAP_CELLS_PER_DISTRICT};
|
||||
use crate::atlas::subbiome;
|
||||
use crate::simulation::generator::{AttractorType, GeographicAttractor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -38,10 +47,32 @@ pub struct Layer1Output {
|
||||
/// so the overlay scale stays correct for any source resolution (mod-safe).
|
||||
pub grid_w: u32,
|
||||
pub grid_h: u32,
|
||||
/// Dominant D8 thalweg direction per district, aggregated from the `fdir`
|
||||
/// grid during the Layer-1 drainage pass (T-1047, D-239 §8). Each entry
|
||||
/// holds the cardinal direction with the most votes among non-ocean cells in
|
||||
/// that district. Keyed by `DistrictPos` using `HEIGHTMAP_CELLS_PER_DISTRICT`
|
||||
/// as the grid-to-district mapping.
|
||||
///
|
||||
/// This is the **true D8-computed direction** — not a seed-bit proxy — so
|
||||
/// `DistrictProfile.basin_direction` (and downstream `ChunkContext`) respect
|
||||
/// drainage monotonicity (D-239 §8: respect the D8 thalweg).
|
||||
///
|
||||
/// **Transient:** skipped in serialization (`#[serde(skip)]`) — this field is
|
||||
/// a cascade-internal transport from `run_layer1` to `derive_all_districts`
|
||||
/// and is re-derived on each `run_layer1` call. The per-district direction is
|
||||
/// persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
|
||||
/// after the cascade consumes it.
|
||||
#[serde(skip)]
|
||||
pub district_basin_dirs: BTreeMap<DistrictPos, BasinDirection>,
|
||||
}
|
||||
|
||||
/// Run the Layer-1 topography pipeline for a single body.
|
||||
pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output {
|
||||
///
|
||||
/// Returns `(Layer1Output, TerrainAnalysis)`. The `TerrainAnalysis` is carried
|
||||
/// transiently on `CascadeSnapshot.terrain_analysis` so `cascade.rs` can pass
|
||||
/// it to `derive_all_districts` and `build_road_graph` without re-running the
|
||||
/// full D8 drainage pass (T-1044 — eliminates the PERF/TODO re-run).
|
||||
pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
let drainage: DrainageResult = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage);
|
||||
|
||||
@@ -64,14 +95,112 @@ pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output {
|
||||
})
|
||||
.collect();
|
||||
|
||||
Layer1Output {
|
||||
// Aggregate per-district dominant D8 direction from the fdir grid (T-1047,
|
||||
// D-239 §8). fdir is available here before it is discarded — do NOT expose
|
||||
// the full grid on DrainageResult externally. The compact per-district map
|
||||
// (~6 000 entries) is what propagates into Layer1Output and DistrictProfile.
|
||||
//
|
||||
// Mapping fdir index → 4-way cardinal (D-010 integer; matches D8 table):
|
||||
// 0 N, 1 S, 2 E, 3 W (pure cardinals)
|
||||
// 4 NE → N (|dr|=|dc|=1; row component wins per D8 priority order)
|
||||
// 5 NW → N
|
||||
// 6 SE → S
|
||||
// 7 SW → S
|
||||
// -1 → skip (no outflow: edge, flat peak, ocean)
|
||||
let district_basin_dirs =
|
||||
aggregate_district_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
|
||||
|
||||
let l1 = Layer1Output {
|
||||
body_id: hm.body_id.clone(),
|
||||
river_network: drainage.river_network,
|
||||
drainage_basins: drainage.drainage_basins,
|
||||
attractors,
|
||||
grid_w: hm.width,
|
||||
grid_h: hm.height,
|
||||
district_basin_dirs,
|
||||
};
|
||||
(l1, ta)
|
||||
}
|
||||
|
||||
/// Aggregate a per-district dominant D8 flow direction from the full-grid `fdir`
|
||||
/// (index into the D8 table, -1 = no outflow). Ocean-masked cells are excluded
|
||||
/// from voting so coastal districts do not skew toward the ocean sink direction.
|
||||
///
|
||||
/// Each non-ocean, non-sink cell casts one vote for its cardinal direction
|
||||
/// (diagonals NE/NW fold to N, SE/SW fold to S). Ties broken by cardinal
|
||||
/// precedence (N > S > E > W). Districts with no valid votes default to `North`.
|
||||
///
|
||||
/// Integer arithmetic throughout (D-010).
|
||||
fn aggregate_district_basin_dirs(
|
||||
fdir: &[i8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
ocean_mask: &[bool],
|
||||
) -> BTreeMap<DistrictPos, BasinDirection> {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let gcpd = HEIGHTMAP_CELLS_PER_DISTRICT;
|
||||
|
||||
// Per-district vote counts: [N, S, E, W].
|
||||
let mut votes: BTreeMap<DistrictPos, [i32; 4]> = BTreeMap::new();
|
||||
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = r * w + c;
|
||||
let k = fdir[i];
|
||||
if k < 0 || ocean_mask[i] {
|
||||
continue; // no-outflow or ocean — skip
|
||||
}
|
||||
// Map D8 index to 4-way cardinal vote index: [N=0, S=1, E=2, W=3].
|
||||
let vote = match k {
|
||||
0 => 0, // N
|
||||
1 => 1, // S
|
||||
2 => 2, // E
|
||||
3 => 3, // W
|
||||
4 => 0, // NE → N (row component wins; |dr|=|dc|=1)
|
||||
5 => 0, // NW → N
|
||||
6 => 1, // SE → S
|
||||
7 => 1, // SW → S
|
||||
_ => continue,
|
||||
};
|
||||
let district_pos: DistrictPos = ((c / gcpd) as i32, (r / gcpd) as i32);
|
||||
votes.entry(district_pos).or_insert([0i32; 4])[vote] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// For each district, pick the cardinal with the most votes.
|
||||
// Tie-breaking order: N > S > E > W (matches D8 priority).
|
||||
let district_cols = w.div_ceil(gcpd) as i32;
|
||||
let district_rows = h.div_ceil(gcpd) as i32;
|
||||
let mut out = BTreeMap::new();
|
||||
for dy in 0..district_rows {
|
||||
for dx in 0..district_cols {
|
||||
let pos: DistrictPos = (dx, dy);
|
||||
let dir = if let Some(v) = votes.get(&pos) {
|
||||
// N=0, S=1, E=2, W=3 in descending priority for tie-breaking.
|
||||
let mut best_votes = -1i32;
|
||||
let mut best_dir = BasinDirection::North;
|
||||
for (cardinal_idx, &count) in v.iter().enumerate() {
|
||||
// Strictly greater-than preserves the first (highest-priority)
|
||||
// cardinal in case of tie.
|
||||
if count > best_votes {
|
||||
best_votes = count;
|
||||
best_dir = match cardinal_idx {
|
||||
0 => BasinDirection::North,
|
||||
1 => BasinDirection::South,
|
||||
2 => BasinDirection::East,
|
||||
_ => BasinDirection::West,
|
||||
};
|
||||
}
|
||||
}
|
||||
best_dir
|
||||
} else {
|
||||
BasinDirection::North // ocean-only or empty district: default
|
||||
};
|
||||
out.insert(pos, dir);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Attach pool names (D-223) to the largest computed rivers and mountains.
|
||||
@@ -160,8 +289,8 @@ mod tests {
|
||||
#[test]
|
||||
fn run_layer1_is_deterministic() {
|
||||
let h = hm(128, 64);
|
||||
let o1 = run_layer1(&h);
|
||||
let o2 = run_layer1(&h);
|
||||
let (o1, _ta1) = run_layer1(&h);
|
||||
let (o2, _ta2) = run_layer1(&h);
|
||||
assert_eq!(o1.attractors.len(), o2.attractors.len());
|
||||
for (a, b) in o1.attractors.iter().zip(o2.attractors.iter()) {
|
||||
assert_eq!(a.position, b.position);
|
||||
@@ -171,11 +300,17 @@ mod tests {
|
||||
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
|
||||
}
|
||||
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
|
||||
// district_basin_dirs is deterministic and non-empty on a slope grid.
|
||||
assert_eq!(o1.district_basin_dirs, o2.district_basin_dirs);
|
||||
assert!(
|
||||
!o1.district_basin_dirs.is_empty(),
|
||||
"slope grid must produce district basin directions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn produces_attractors_and_costs() {
|
||||
let o = run_layer1(&hm(256, 128));
|
||||
let (o, _ta) = run_layer1(&hm(256, 128));
|
||||
assert!(!o.attractors.is_empty(), "expected some attractors");
|
||||
assert!(o
|
||||
.attractors
|
||||
@@ -186,7 +321,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn name_attachment_respects_pool_size() {
|
||||
let o = run_layer1(&hm(256, 128));
|
||||
let (o, _ta) = run_layer1(&hm(256, 128));
|
||||
let names = vec!["Aldren".to_string(), "Brook".to_string()];
|
||||
let (rivers, _mtn) = attach_feature_names(&o, &names, &[]);
|
||||
assert!(rivers.len() <= names.len());
|
||||
|
||||
Reference in New Issue
Block a user