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