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>
195 lines
7.1 KiB
Rust
195 lines
7.1 KiB
Rust
//! 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};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Full Layer-1 result for one body.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
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>,
|
|
/// Working-grid dimensions every position in this output (river cells, basin
|
|
/// boundaries, attractor positions) is expressed in — equals the downsampled
|
|
/// heightmap size. The client maps these onto the displayed heightmap (#960),
|
|
/// so the overlay scale stays correct for any source resolution (mod-safe).
|
|
pub grid_w: u32,
|
|
pub grid_h: u32,
|
|
}
|
|
|
|
/// 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,
|
|
// Layer-1 water-direction extraction (#957, D-234) — feeds D-213
|
|
// founding orientation + the D-234 waterfront rule.
|
|
water_bearing: ta.water_bearing(r.row as usize, r.col as usize),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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| {
|
|
// strength is integer now — rank directly (descending).
|
|
b.strength
|
|
.cmp(&a.strength)
|
|
.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| {
|
|
// strength is integer now — rank directly (descending).
|
|
b.strength
|
|
.cmp(&a.strength)
|
|
.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, b.strength);
|
|
assert_eq!(a.sub_biome, b.sub_biome);
|
|
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
|
|
}
|
|
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 >= 100));
|
|
assert!(o.attractors.iter().all(|a| (0..=100).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());
|
|
}
|
|
}
|