feat(simulation): Layer-1 topography pipeline — features, sub-biome, orchestrator (#953)

Wire the empty-world topography cascade (D-208/209/210):
- generator.rs: add SubBiomeVariant (11 variants, D-210) + sub_biome and
  terrain_modification_cost fields on GeographicAttractor; AttractorType
  is now Copy.
- features.rs (new, D-209): extract the 7 attractor tags from heightmap +
  drainage. Coast/lake derived from the heightmap (D-209/D-223
  reconciliation — markers are names-only now, no polygons). Deterministic
  (sorted seeds, integer keys, bucket-grid thinning); strength-capped at
  MAX_ATTRACTORS preserving type diversity. Shared TerrainAnalysis
  (masks/slope/moisture/percentile) feeds both extraction and sub-biome.
- subbiome.rs (new, D-210): classify sub-biome + terrain_modification_cost
  from elevation/slope/moisture/latitude. Volcanic stays in the enum but
  is not emitted (no heightmap signal).
- layer1.rs (new): run_layer1 orchestrator + attach_feature_names (D-223
  pool names to largest rivers / Alpine peaks).
- attractor_matching constructors updated for the new fields.

76 atlas tests pass; run_layer1 determinism verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 00:35:39 +02:00
co-authored by Claude Opus 4.7
parent 7cf7614342
commit d418eba8bb
6 changed files with 997 additions and 3 deletions
+186
View File
@@ -0,0 +1,186 @@
//! Layer 1 orchestrator — empty-world topography (#953).
//!
//! Runs the full Layer-1 pipeline for one body, in order:
//! 1. D8 priority-flood drainage (D-208) → river network + basins
//! 2. shared terrain analysis (ocean/lake masks, water distance, slope,
//! elevation percentile) — D-209/D-210 inputs
//! 3. 7-tag geographic feature extraction (D-209)
//! 4. sub-biome + terrain_modification_cost classification (D-210)
//!
//! 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.
//!
//! **Determinism (D-010 #4):** every stage is deterministic; the same heightmap
//! yields bit-identical attractors and river networks.
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::subbiome;
use crate::simulation::generator::{AttractorType, GeographicAttractor};
/// Full Layer-1 result for one body.
#[derive(Debug, Clone)]
pub struct Layer1Output {
pub body_id: String,
pub river_network: RiverNetwork,
pub drainage_basins: Vec<DrainageBasin>,
/// Geographic attractors (D-209) with sub-biome + cost (D-210), sorted by
/// `(attractor_type, row, col)`.
pub attractors: Vec<GeographicAttractor>,
}
/// Run the Layer-1 topography pipeline for a single body.
pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output {
let drainage: DrainageResult =
drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage);
let raw = features::extract_attractors(hm, &drainage, &ta);
let attractors: Vec<GeographicAttractor> = raw
.iter()
.map(|r| {
let (sub_biome, terrain_modification_cost) =
subbiome::classify(&ta, r.row as usize, r.col as usize);
GeographicAttractor {
position: (r.row, r.col),
attractor_type: r.attractor_type,
strength: r.strength,
sub_biome,
terrain_modification_cost,
}
})
.collect();
Layer1Output {
body_id: hm.body_id.clone(),
river_network: drainage.river_network,
drainage_basins: drainage.drainage_basins,
attractors,
}
}
/// Attach pool names (D-223) to the largest computed rivers and mountains.
///
/// Rivers are ranked by mouth strength (a proxy for catchment size) descending;
/// `RiverMouth` attractors take names from `river_names` in that order. Mountain
/// names attach to the highest-elevation `Alpine`/`PassEntrance` attractors.
/// Returns `(river_assignments, mountain_assignments)` as `(position, name)`
/// pairs; positions that outrun the pool get no name (the pool is finite).
pub fn attach_feature_names(
output: &Layer1Output,
river_names: &[String],
mountain_names: &[String],
) -> (Vec<((u16, u16), String)>, Vec<((u16, u16), String)>) {
// Rivers: RiverMouth attractors, strongest first (ties by row, col).
let mut mouths: Vec<&GeographicAttractor> = output
.attractors
.iter()
.filter(|a| a.attractor_type == AttractorType::RiverMouth)
.collect();
mouths.sort_by(|a, b| {
let sa = (a.strength * 1e6) as i64;
let sb = (b.strength * 1e6) as i64;
sb.cmp(&sa)
.then(a.position.0.cmp(&b.position.0))
.then(a.position.1.cmp(&b.position.1))
});
let rivers = mouths
.iter()
.zip(river_names.iter())
.map(|(a, n)| (a.position, n.clone()))
.collect();
// Mountains: Alpine attractors, strongest first.
let mut peaks: Vec<&GeographicAttractor> = output
.attractors
.iter()
.filter(|a| {
matches!(
a.sub_biome,
crate::simulation::generator::SubBiomeVariant::Alpine
)
})
.collect();
peaks.sort_by(|a, b| {
let sa = (a.strength * 1e6) as i64;
let sb = (b.strength * 1e6) as i64;
sb.cmp(&sa)
.then(a.position.0.cmp(&b.position.0))
.then(a.position.1.cmp(&b.position.1))
});
let mountains = peaks
.iter()
.zip(mountain_names.iter())
.map(|(a, n)| (a.position, n.clone()))
.collect();
(rivers, mountains)
}
#[cfg(test)]
mod tests {
use super::*;
fn slope_grid(w: u32, h: u32) -> Vec<f32> {
let n = (w * h) as usize;
(0..n)
.map(|i| {
let r = i / w as usize;
let c = i % w as usize;
1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5)
})
.collect()
}
fn hm(w: u32, h: u32) -> BodyHeightmap {
BodyHeightmap {
body_id: "TestBody".into(),
width: w,
height: h,
data: slope_grid(w, h),
sea_level: 0.3,
}
}
#[test]
fn run_layer1_is_deterministic() {
let h = hm(128, 64);
let o1 = run_layer1(&h);
let o2 = 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);
assert_eq!(a.attractor_type, b.attractor_type);
assert_eq!(a.strength.to_bits(), b.strength.to_bits());
assert_eq!(a.sub_biome, b.sub_biome);
assert_eq!(
a.terrain_modification_cost.to_bits(),
b.terrain_modification_cost.to_bits()
);
}
assert_eq!(
o1.river_network.river_cells,
o2.river_network.river_cells
);
}
#[test]
fn produces_attractors_and_costs() {
let o = run_layer1(&hm(256, 128));
assert!(!o.attractors.is_empty(), "expected some attractors");
assert!(o.attractors.iter().all(|a| a.terrain_modification_cost >= 1.0));
assert!(o.attractors.iter().all(|a| (0.0..=1.0).contains(&a.strength)));
}
#[test]
fn name_attachment_respects_pool_size() {
let o = 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());
}
}