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,615 @@
|
||||
//! Geographic feature tag extraction — Layer 1 (D-209).
|
||||
//!
|
||||
//! After D8 drainage analysis (D-208), this module extracts the 7
|
||||
//! `AttractorType` tags from the heightmap + river network. Each attractor has
|
||||
//! a pixel position and a normalized `strength` (0.0–1.0) derived from local
|
||||
//! terrain quality.
|
||||
//!
|
||||
//! **D-209 / D-223 reconciliation:** D-209 reads ocean/lake polygons from
|
||||
//! `markers.json`, but D-223 reduced markers to a names-only pool — those
|
||||
//! polygons no longer exist. Coast and lake cells are therefore derived from
|
||||
//! the heightmap itself: ocean = the largest connected below-sea-level water
|
||||
//! body; lakes = smaller enclosed below-sea-level bodies.
|
||||
//!
|
||||
//! **Determinism (D-010 #4):** all collections iterate in sorted/row-major
|
||||
//! order; the final attractor list is sorted by `(attractor_type, row, col)`.
|
||||
//! No `HashMap`/`HashSet` iteration. `strength` is f32 but is never used as a
|
||||
//! sort key.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use crate::atlas::drainage::DrainageResult;
|
||||
use crate::atlas::heightmap::BodyHeightmap;
|
||||
use crate::simulation::generator::AttractorType;
|
||||
|
||||
/// 8-neighbor offsets (dr, dc). Columns wrap horizontally (equirectangular
|
||||
/// globe); rows are bounds-clamped at the poles. Matches `drainage::D8`.
|
||||
const NB8: [(i32, i32); 8] = [
|
||||
(-1, 0),
|
||||
(1, 0),
|
||||
(0, 1),
|
||||
(0, -1),
|
||||
(-1, 1),
|
||||
(-1, -1),
|
||||
(1, 1),
|
||||
(1, -1),
|
||||
];
|
||||
|
||||
/// Minimum spacing (cells) between attractors of an areal type, so coastlines /
|
||||
/// valleys / plains yield a sparse, placement-friendly set rather than one
|
||||
/// attractor per pixel.
|
||||
const MIN_SPACING: i32 = 12;
|
||||
|
||||
/// Hard cap on attractors per body (keeps the #955 matching tractable).
|
||||
const MAX_ATTRACTORS: usize = 256;
|
||||
|
||||
/// A feature before sub-biome classification: position, type, strength.
|
||||
/// `layer1` enriches these into `GeographicAttractor`s.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct RawAttractor {
|
||||
pub row: u16,
|
||||
pub col: u16,
|
||||
pub attractor_type: AttractorType,
|
||||
pub strength: f32,
|
||||
}
|
||||
|
||||
/// Precomputed per-cell terrain fields, shared by feature extraction (D-209)
|
||||
/// and sub-biome classification (D-210) so neither recomputes them.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TerrainAnalysis {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
/// `elev < sea_level` (any submerged cell).
|
||||
pub ocean_mask: Vec<bool>,
|
||||
/// Submerged cells not part of the largest water body (enclosed lakes/seas).
|
||||
pub lake_mask: Vec<bool>,
|
||||
/// Chebyshev distance (cells) to the nearest ocean cell or river mouth,
|
||||
/// capped at `WATER_DIST_CAP`. Moisture proxy for habitability/sub-biome.
|
||||
pub water_dist: Vec<u16>,
|
||||
/// Local slope proxy in degrees: `atan(max |Δelev| over 8 neighbors)`.
|
||||
/// Elevation is normalized [0,1]; this is a relative steepness measure.
|
||||
pub slope_deg: Vec<f32>,
|
||||
/// Elevation percentile [0,1] among land cells (ocean cells = 0.0).
|
||||
pub elev_pct: Vec<f32>,
|
||||
}
|
||||
|
||||
const WATER_DIST_CAP: u16 = 255;
|
||||
|
||||
#[inline]
|
||||
fn idx(r: usize, c: usize, w: usize) -> usize {
|
||||
r * w + c
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn wrap_col(c: i32, w: i32) -> usize {
|
||||
c.rem_euclid(w) as usize
|
||||
}
|
||||
|
||||
impl TerrainAnalysis {
|
||||
/// Compute all shared terrain fields for a body. O(w·h).
|
||||
pub fn analyze(hm: &BodyHeightmap, drainage: &DrainageResult) -> TerrainAnalysis {
|
||||
let w = hm.width as usize;
|
||||
let h = hm.height as usize;
|
||||
let n = w * h;
|
||||
let elev = &hm.data;
|
||||
let sea = hm.sea_level;
|
||||
|
||||
let ocean_mask: Vec<bool> = (0..n).map(|i| elev[i] < sea).collect();
|
||||
let lake_mask = compute_lake_mask(&ocean_mask, w, h);
|
||||
let water_dist = compute_water_dist(&ocean_mask, &drainage.river_network.mouths, w, h);
|
||||
let slope_deg = compute_slope(elev, w, h);
|
||||
let elev_pct = compute_elev_percentile(elev, &ocean_mask, w, h);
|
||||
|
||||
TerrainAnalysis {
|
||||
w,
|
||||
h,
|
||||
ocean_mask,
|
||||
lake_mask,
|
||||
water_dist,
|
||||
slope_deg,
|
||||
elev_pct,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_ocean(&self, r: usize, c: usize) -> bool {
|
||||
self.ocean_mask[idx(r, c, self.w)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest connected below-sea-level component = ocean; all others = lakes.
|
||||
/// Deterministic: BFS seeds scanned row-major; ties broken by lowest cell index.
|
||||
fn compute_lake_mask(ocean_mask: &[bool], w: usize, h: usize) -> Vec<bool> {
|
||||
let n = w * h;
|
||||
let mut comp = vec![-1i32; n];
|
||||
let mut comp_sizes: Vec<usize> = Vec::new();
|
||||
let mut next_comp = 0i32;
|
||||
|
||||
for start in 0..n {
|
||||
if !ocean_mask[start] || comp[start] >= 0 {
|
||||
continue;
|
||||
}
|
||||
// Flood fill this component (row-major BFS = deterministic).
|
||||
let mut size = 0usize;
|
||||
let mut q = VecDeque::new();
|
||||
comp[start] = next_comp;
|
||||
q.push_back(start);
|
||||
while let Some(cur) = q.pop_front() {
|
||||
size += 1;
|
||||
let (r, c) = (cur / w, cur % w);
|
||||
for &(dr, dc) in &NB8 {
|
||||
let nr = r as i32 + dr;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let nc = wrap_col(c as i32 + dc, w as i32);
|
||||
let ni = idx(nr as usize, nc, w);
|
||||
if ocean_mask[ni] && comp[ni] < 0 {
|
||||
comp[ni] = next_comp;
|
||||
q.push_back(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
comp_sizes.push(size);
|
||||
next_comp += 1;
|
||||
}
|
||||
|
||||
if comp_sizes.is_empty() {
|
||||
return vec![false; n]; // no water at all
|
||||
}
|
||||
|
||||
// Largest component (tie → lowest comp id, which is the earliest row-major).
|
||||
let mut ocean_comp = 0i32;
|
||||
let mut best = 0usize;
|
||||
for (cid, &sz) in comp_sizes.iter().enumerate() {
|
||||
if sz > best {
|
||||
best = sz;
|
||||
ocean_comp = cid as i32;
|
||||
}
|
||||
}
|
||||
|
||||
// Lakes = submerged cells in any non-ocean component.
|
||||
(0..n).map(|i| comp[i] >= 0 && comp[i] != ocean_comp).collect()
|
||||
}
|
||||
|
||||
/// Multi-source BFS Chebyshev distance to nearest ocean cell or river mouth.
|
||||
fn compute_water_dist(ocean_mask: &[bool], mouths: &[(u16, u16)], w: usize, h: usize) -> Vec<u16> {
|
||||
let n = w * h;
|
||||
let mut dist = vec![WATER_DIST_CAP; n];
|
||||
let mut q = VecDeque::new();
|
||||
// Seeds in row-major order for determinism.
|
||||
for i in 0..n {
|
||||
if ocean_mask[i] {
|
||||
dist[i] = 0;
|
||||
q.push_back(i);
|
||||
}
|
||||
}
|
||||
for &(mr, mc) in mouths {
|
||||
let i = idx(mr as usize, mc as usize, w);
|
||||
if dist[i] != 0 {
|
||||
dist[i] = 0;
|
||||
q.push_back(i);
|
||||
}
|
||||
}
|
||||
while let Some(cur) = q.pop_front() {
|
||||
let d = dist[cur];
|
||||
if d >= WATER_DIST_CAP {
|
||||
continue;
|
||||
}
|
||||
let (r, c) = (cur / w, cur % w);
|
||||
for &(dr, dc) in &NB8 {
|
||||
let nr = r as i32 + dr;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let nc = wrap_col(c as i32 + dc, w as i32);
|
||||
let ni = idx(nr as usize, nc, w);
|
||||
if dist[ni] > d + 1 {
|
||||
dist[ni] = d + 1;
|
||||
q.push_back(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
dist
|
||||
}
|
||||
|
||||
/// Local slope proxy: `atan(max |Δelev| to 8 neighbors)` in degrees.
|
||||
fn compute_slope(elev: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
let n = w * h;
|
||||
let mut slope = vec![0.0f32; n];
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = idx(r, c, w);
|
||||
let e = elev[i];
|
||||
let mut max_grad = 0.0f32;
|
||||
for &(dr, dc) in &NB8 {
|
||||
let nr = r as i32 + dr;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let nc = wrap_col(c as i32 + dc, w as i32);
|
||||
let g = (e - elev[idx(nr as usize, nc, w)]).abs();
|
||||
if g > max_grad {
|
||||
max_grad = g;
|
||||
}
|
||||
}
|
||||
slope[i] = max_grad.atan().to_degrees();
|
||||
}
|
||||
}
|
||||
slope
|
||||
}
|
||||
|
||||
/// Elevation percentile [0,1] among land cells; ocean cells get 0.0.
|
||||
fn compute_elev_percentile(elev: &[f32], ocean_mask: &[bool], w: usize, h: usize) -> Vec<f32> {
|
||||
let n = w * h;
|
||||
// (scaled_elev, idx) for land cells; integer key for deterministic sort.
|
||||
let mut land: Vec<(i64, usize)> = (0..n)
|
||||
.filter(|&i| !ocean_mask[i])
|
||||
.map(|i| ((elev[i] as f64 * 1_000_000.0) as i64, i))
|
||||
.collect();
|
||||
land.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let mut pct = vec![0.0f32; n];
|
||||
let m = land.len();
|
||||
if m <= 1 {
|
||||
for &(_, i) in &land {
|
||||
pct[i] = 0.5;
|
||||
}
|
||||
return pct;
|
||||
}
|
||||
for (rank, &(_, i)) in land.iter().enumerate() {
|
||||
pct[i] = rank as f32 / (m - 1) as f32;
|
||||
}
|
||||
pct
|
||||
}
|
||||
|
||||
/// Habitability score [0,1] from elevation band, flatness, and moisture.
|
||||
/// Used by `ValleyFloor`/`PlainCenter` strengths and the D-210 classifier.
|
||||
pub fn habitability(elev_pct: f32, slope_deg: f32, water_dist: u16) -> f32 {
|
||||
let elev_score = (1.0 - (elev_pct - 0.35).abs() / 0.65).clamp(0.0, 1.0);
|
||||
let flat_score = (1.0 - slope_deg / 15.0).clamp(0.0, 1.0);
|
||||
let water_score = (1.0 - water_dist as f32 / 40.0).clamp(0.0, 1.0);
|
||||
(0.4 * elev_score + 0.3 * flat_score + 0.3 * water_score).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Extract the 7 D-209 attractor tags. Returns raw attractors (no sub-biome),
|
||||
/// sorted by `(attractor_type, row, col)` for determinism. Higher-priority
|
||||
/// types claim their cells first so a cell is tagged at most once.
|
||||
pub fn extract_attractors(
|
||||
hm: &BodyHeightmap,
|
||||
drainage: &DrainageResult,
|
||||
ta: &TerrainAnalysis,
|
||||
) -> Vec<RawAttractor> {
|
||||
let w = hm.width as usize;
|
||||
let h = hm.height as usize;
|
||||
let elev = &hm.data;
|
||||
let accum = &drainage.flow_accumulation;
|
||||
let max_accum = drainage.max_accumulation.max(1) as f32;
|
||||
|
||||
let mut claimed = vec![false; w * h];
|
||||
let mut out: Vec<RawAttractor> = Vec::new();
|
||||
|
||||
// O(1) river-cell membership (avoids a binary_search per valley candidate).
|
||||
let mut river_mask = vec![false; w * h];
|
||||
for &(r, c) in &drainage.river_network.river_cells {
|
||||
river_mask[idx(r as usize, c as usize, w)] = true;
|
||||
}
|
||||
|
||||
let claim = |out: &mut Vec<RawAttractor>,
|
||||
claimed: &mut [bool],
|
||||
r: usize,
|
||||
c: usize,
|
||||
at: AttractorType,
|
||||
strength: f32| {
|
||||
let i = idx(r, c, w);
|
||||
if claimed[i] {
|
||||
return;
|
||||
}
|
||||
claimed[i] = true;
|
||||
out.push(RawAttractor {
|
||||
row: r as u16,
|
||||
col: c as u16,
|
||||
attractor_type: at,
|
||||
strength: strength.clamp(0.0, 1.0),
|
||||
});
|
||||
};
|
||||
|
||||
// 1. RiverMouth — strength = accum / max_accum.
|
||||
for &(r, c) in &drainage.river_network.mouths {
|
||||
let i = idx(r as usize, c as usize, w);
|
||||
let s = accum[i] as f32 / max_accum;
|
||||
claim(&mut out, &mut claimed, r as usize, c as usize, AttractorType::RiverMouth, s);
|
||||
}
|
||||
|
||||
// 2. RiverCrossing — confluences, strength = accum / max_accum * 0.7.
|
||||
for &(r, c) in &drainage.river_network.confluences {
|
||||
let i = idx(r as usize, c as usize, w);
|
||||
let s = accum[i] as f32 / max_accum * 0.7;
|
||||
claim(&mut out, &mut claimed, r as usize, c as usize, AttractorType::RiverCrossing, s);
|
||||
}
|
||||
|
||||
// 3. CoastalAccess — land within 3 cells of ocean, thinned by spacing.
|
||||
// strength = 0.6 + coast-density bonus (capped).
|
||||
let mut coastal: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = idx(r, c, w);
|
||||
// water_dist (precomputed) is a cheap pre-filter: only cells within
|
||||
// 3 of water can be coastal, so skip the 49-cell scan for inland.
|
||||
if ta.ocean_mask[i] || claimed[i] || ta.water_dist[i] > 3 {
|
||||
continue;
|
||||
}
|
||||
let near = ocean_cells_within(ta, r, c, 3);
|
||||
if near > 0 {
|
||||
let bonus = (near as f32 / 24.0).min(0.3);
|
||||
coastal.push((r, c, 0.6 + bonus));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (r, c, s) in thin_by_spacing(coastal, &claimed, w) {
|
||||
claim(&mut out, &mut claimed, r, c, AttractorType::CoastalAccess, s);
|
||||
}
|
||||
|
||||
// 4. ValleyFloor — gentle slope, mid elevation, positive habitability.
|
||||
let mut valleys: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = idx(r, c, w);
|
||||
if ta.ocean_mask[i] || claimed[i] {
|
||||
continue;
|
||||
}
|
||||
if river_mask[i] || ta.slope_deg[i] >= 5.0 {
|
||||
continue;
|
||||
}
|
||||
if ta.elev_pct[i] < 0.10 || ta.elev_pct[i] > 0.60 {
|
||||
continue;
|
||||
}
|
||||
let hab = habitability(ta.elev_pct[i], ta.slope_deg[i], ta.water_dist[i]);
|
||||
if hab > 0.0 {
|
||||
valleys.push((r, c, hab));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (r, c, s) in thin_by_spacing(valleys, &claimed, w) {
|
||||
claim(&mut out, &mut claimed, r, c, AttractorType::ValleyFloor, s);
|
||||
}
|
||||
|
||||
// 5. PassEntrance — morphological saddles in higher terrain.
|
||||
// strength = 1 - elev_pct (lower passes score higher).
|
||||
let mut passes: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for r in 1..h.saturating_sub(1) {
|
||||
for c in 0..w {
|
||||
let i = idx(r, c, w);
|
||||
if ta.ocean_mask[i] || claimed[i] || ta.elev_pct[i] < 0.5 {
|
||||
continue;
|
||||
}
|
||||
if is_saddle(elev, r, c, w, h) {
|
||||
passes.push((r, c, 1.0 - ta.elev_pct[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (r, c, s) in thin_by_spacing(passes, &claimed, w) {
|
||||
claim(&mut out, &mut claimed, r, c, AttractorType::PassEntrance, s);
|
||||
}
|
||||
|
||||
// 6. LakeShore — land adjacent to an enclosed lake. strength = 0.5.
|
||||
let mut shores: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = idx(r, c, w);
|
||||
if ta.ocean_mask[i] || ta.lake_mask[i] || claimed[i] {
|
||||
continue;
|
||||
}
|
||||
if adjacent_to_lake(ta, r, c) {
|
||||
shores.push((r, c, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (r, c, s) in thin_by_spacing(shores, &claimed, w) {
|
||||
claim(&mut out, &mut claimed, r, c, AttractorType::LakeShore, s);
|
||||
}
|
||||
|
||||
// 7. PlainCenter — very flat, away from everything. strength = hab * 0.4.
|
||||
let mut plains: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = idx(r, c, w);
|
||||
if ta.ocean_mask[i] || claimed[i] || ta.slope_deg[i] >= 2.0 {
|
||||
continue;
|
||||
}
|
||||
let hab = habitability(ta.elev_pct[i], ta.slope_deg[i], ta.water_dist[i]);
|
||||
plains.push((r, c, hab * 0.4));
|
||||
}
|
||||
}
|
||||
for (r, c, s) in thin_by_spacing(plains, &claimed, w) {
|
||||
claim(&mut out, &mut claimed, r, c, AttractorType::PlainCenter, s);
|
||||
}
|
||||
|
||||
// Cap by keeping the strongest across ALL types (so a coast-heavy body
|
||||
// doesn't starve ValleyFloor/PassEntrance/etc.), then sort the survivors
|
||||
// deterministically by (attractor_type, row, col).
|
||||
if out.len() > MAX_ATTRACTORS {
|
||||
out.sort_by(|a, b| {
|
||||
let sa = (a.strength * 1e6) as i64;
|
||||
let sb = (b.strength * 1e6) as i64;
|
||||
sb.cmp(&sa).then(a.row.cmp(&b.row)).then(a.col.cmp(&b.col))
|
||||
});
|
||||
out.truncate(MAX_ATTRACTORS);
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
(a.attractor_type as u8, a.row, a.col).cmp(&(b.attractor_type as u8, b.row, b.col))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn ocean_cells_within(ta: &TerrainAnalysis, r: usize, c: usize, radius: i32) -> usize {
|
||||
let mut count = 0;
|
||||
for dr in -radius..=radius {
|
||||
let nr = r as i32 + dr;
|
||||
if nr < 0 || nr >= ta.h as i32 {
|
||||
continue;
|
||||
}
|
||||
for dc in -radius..=radius {
|
||||
let nc = wrap_col(c as i32 + dc, ta.w as i32);
|
||||
if ta.ocean_mask[idx(nr as usize, nc, ta.w)] {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn adjacent_to_lake(ta: &TerrainAnalysis, r: usize, c: usize) -> bool {
|
||||
for &(dr, dc) in &NB8 {
|
||||
let nr = r as i32 + dr;
|
||||
if nr < 0 || nr >= ta.h as i32 {
|
||||
continue;
|
||||
}
|
||||
let nc = wrap_col(c as i32 + dc, ta.w as i32);
|
||||
if ta.lake_mask[idx(nr as usize, nc, ta.w)] {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Morphological saddle: walking the 8-neighbor ring, the sign of
|
||||
/// `(neighbor - cell)` alternates at least 4 times (≥2 higher sectors
|
||||
/// separated by ≥2 lower sectors).
|
||||
fn is_saddle(elev: &[f32], r: usize, c: usize, w: usize, h: usize) -> bool {
|
||||
// Ring order (clockwise) so transitions are meaningful.
|
||||
const RING: [(i32, i32); 8] = [
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, 1),
|
||||
(1, 1),
|
||||
(1, 0),
|
||||
(1, -1),
|
||||
(0, -1),
|
||||
(-1, -1),
|
||||
];
|
||||
let e = elev[idx(r, c, w)];
|
||||
let mut signs = [0i8; 8];
|
||||
for (k, &(dr, dc)) in RING.iter().enumerate() {
|
||||
let nr = r as i32 + dr;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
return false; // poles can't be saddles in this scheme
|
||||
}
|
||||
let nc = wrap_col(c as i32 + dc, w as i32);
|
||||
signs[k] = if elev[idx(nr as usize, nc, w)] > e { 1 } else { -1 };
|
||||
}
|
||||
let mut transitions = 0;
|
||||
for k in 0..8 {
|
||||
if signs[k] != signs[(k + 1) % 8] {
|
||||
transitions += 1;
|
||||
}
|
||||
}
|
||||
transitions >= 4
|
||||
}
|
||||
|
||||
/// Greedy spatial thinning: sort candidates by descending strength (ties by
|
||||
/// row, col), keep one per `MIN_SPACING` Chebyshev neighborhood.
|
||||
///
|
||||
/// Uses a bucket grid (cell size = `MIN_SPACING`) so each candidate only checks
|
||||
/// the 3×3 neighboring buckets — O(k) amortized rather than O(k²). The kept
|
||||
/// order is fully determined by the sorted candidate iteration, so the internal
|
||||
/// `HashMap` (lookup only, never iterated for output) does not affect
|
||||
/// determinism.
|
||||
fn thin_by_spacing(
|
||||
mut cands: Vec<(usize, usize, f32)>,
|
||||
_claimed: &[bool],
|
||||
_w: usize,
|
||||
) -> Vec<(usize, usize, f32)> {
|
||||
// Deterministic order: strength desc, then row, col asc.
|
||||
cands.sort_by(|a, b| {
|
||||
let sa = (a.2 * 1e6) as i64;
|
||||
let sb = (b.2 * 1e6) as i64;
|
||||
sb.cmp(&sa).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1))
|
||||
});
|
||||
let sp = MIN_SPACING.max(1) as usize;
|
||||
let mut buckets: HashMap<(usize, usize), Vec<(usize, usize)>> = HashMap::new();
|
||||
let mut kept: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for (r, c, s) in cands {
|
||||
let (br, bc) = (r / sp, c / sp);
|
||||
let mut ok = true;
|
||||
'scan: for nbr in br.saturating_sub(1)..=br + 1 {
|
||||
for nbc in bc.saturating_sub(1)..=bc + 1 {
|
||||
if let Some(pts) = buckets.get(&(nbr, nbc)) {
|
||||
for &(kr, kc) in pts {
|
||||
let dr = (kr as i32 - r as i32).abs();
|
||||
let dc = (kc as i32 - c as i32).abs();
|
||||
if dr.max(dc) < MIN_SPACING {
|
||||
ok = false;
|
||||
break 'scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
buckets.entry((br, bc)).or_default().push((r, c));
|
||||
kept.push((r, c, s));
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas::drainage;
|
||||
|
||||
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(data: Vec<f32>, w: u32, h: u32, sea: f32) -> BodyHeightmap {
|
||||
BodyHeightmap {
|
||||
body_id: "T".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
sea_level: sea,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_extraction() {
|
||||
let h = hm(slope_grid(64, 32), 64, 32, 0.3);
|
||||
let dr = drainage::analyze(&h.data, 64, 32, 0.3);
|
||||
let ta = TerrainAnalysis::analyze(&h, &dr);
|
||||
let a1 = extract_attractors(&h, &dr, &ta);
|
||||
let a2 = extract_attractors(&h, &dr, &ta);
|
||||
assert_eq!(a1, a2, "attractor extraction must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attractors_sorted_and_bounded() {
|
||||
let h = hm(slope_grid(128, 64), 128, 64, 0.3);
|
||||
let dr = drainage::analyze(&h.data, 128, 64, 0.3);
|
||||
let ta = TerrainAnalysis::analyze(&h, &dr);
|
||||
let a = extract_attractors(&h, &dr, &ta);
|
||||
assert!(a.len() <= MAX_ATTRACTORS);
|
||||
// Sorted by (type as u8, row, col).
|
||||
for win in a.windows(2) {
|
||||
let ka = (win[0].attractor_type.clone() as u8, win[0].row, win[0].col);
|
||||
let kb = (win[1].attractor_type.clone() as u8, win[1].row, win[1].col);
|
||||
assert!(ka <= kb, "attractors must be sorted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_in_range() {
|
||||
let h = hm(slope_grid(32, 16), 32, 16, 0.3);
|
||||
let dr = drainage::analyze(&h.data, 32, 16, 0.3);
|
||||
let ta = TerrainAnalysis::analyze(&h, &dr);
|
||||
assert!(ta.elev_pct.iter().all(|&p| (0.0..=1.0).contains(&p)));
|
||||
assert_eq!(ta.slope_deg.len(), 32 * 16);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user