style(simulation): rustfmt + clippy fixes for Layer-1/heightmap code (#953 #963)

Pre-push surfaced fmt + clippy (-D warnings) failures in the new code:
- rustfmt the 6 new/changed atlas files + the bench example.
- features.rs: HashMap → BTreeMap (project bans HashMap for determinism via
  clippy disallowed_types; the bucket map is lookup-only either way).
- attractor_matching.rs: drop now-redundant .clone() on AttractorType (it
  became Copy in #953) — clippy clone_on_copy.
- drainage.rs tests: manual range → (1..=12).contains(&n).
- features.rs test: drop .clone() on Copy AttractorType.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:11:43 +02:00
co-authored by Claude Opus 4.7
parent 7440e933d6
commit f3e017a12f
7 changed files with 96 additions and 43 deletions
+3 -1
View File
@@ -50,7 +50,9 @@ fn main() {
let o = run_layer1(&hm);
let mut by_type: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for a in &o.attractors {
*by_type.entry(format!("{:?}", a.attractor_type)).or_default() += 1;
*by_type
.entry(format!("{:?}", a.attractor_type))
.or_default() += 1;
}
println!("=== Layer-1 output for one 512×256 body ===");
println!(
+2 -2
View File
@@ -314,7 +314,7 @@ pub fn match_cities(
placements.push(CityPlacement {
city_id: cities[ci].city_id,
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type.clone(),
attractor_type: attractors[ai].attractor_type,
score,
synthetic: false,
});
@@ -369,7 +369,7 @@ pub fn match_cities(
placements.push(CityPlacement {
city_id: cities[ci].city_id,
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type.clone(),
attractor_type: attractors[ai].attractor_type,
score,
synthetic: false,
});
+10 -4
View File
@@ -457,7 +457,10 @@ fn merge_small_basins(
while active.len() > min_count {
// Smallest active basin (tie → lowest id; BTreeSet iterates ascending).
let smallest = *active.iter().min_by_key(|&&b| (size[b as usize], b)).unwrap();
let smallest = *active
.iter()
.min_by_key(|&&b| (size[b as usize], b))
.unwrap();
let smallest_size = size[smallest as usize];
if active.len() <= max_count && smallest_size as f64 / n as f64 >= min_frac {
break;
@@ -661,7 +664,7 @@ mod tests {
let result = analyze(&elev, 128, 64, 0.3);
let n = result.drainage_basins.len();
assert!(
n >= 1 && n <= 12,
(1..=12).contains(&n),
"Basin count {} out of expected range [1, 12]",
n
);
@@ -693,7 +696,10 @@ mod tests {
let r2 = analyze(&elev, 64, 32, 0.3);
assert_eq!(r1.flow_accumulation, r2.flow_accumulation);
assert_eq!(r1.max_accumulation, r2.max_accumulation);
assert!(r1.max_accumulation >= 1, "max_accumulation must be clamped ≥ 1");
assert!(
r1.max_accumulation >= 1,
"max_accumulation must be clamped ≥ 1"
);
// Flat / all-ocean world: still well-defined (no division by zero).
let flat = flat_grid(16, 8, 0.5);
let rf = analyze(&flat, 16, 8, 0.9); // sea_level above all terrain
@@ -714,6 +720,6 @@ mod tests {
}
let res = analyze(&elev, w as u32, h as u32, 0.3);
let n = res.drainage_basins.len();
assert!(n >= 1 && n <= 12, "basin count {n} out of range");
assert!((1..=12).contains(&n), "basin count {n} out of range");
}
}
+45 -14
View File
@@ -16,7 +16,7 @@
//! No `HashMap`/`HashSet` iteration. `strength` is f32 but is never used as a
//! sort key.
use std::collections::{HashMap, VecDeque};
use std::collections::{BTreeMap, VecDeque};
use crate::atlas::drainage::DrainageResult;
use crate::atlas::heightmap::BodyHeightmap;
@@ -169,7 +169,9 @@ fn compute_lake_mask(ocean_mask: &[bool], w: usize, h: usize) -> Vec<bool> {
}
// Lakes = submerged cells in any non-ocean component.
(0..n).map(|i| comp[i] >= 0 && comp[i] != ocean_comp).collect()
(0..n)
.map(|i| comp[i] >= 0 && comp[i] != ocean_comp)
.collect()
}
/// Multi-source BFS Chebyshev distance to nearest ocean cell or river mouth.
@@ -317,14 +319,28 @@ pub fn extract_attractors(
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);
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);
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.
@@ -346,7 +362,14 @@ pub fn extract_attractors(
}
}
for (r, c, s) in thin_by_spacing(coastal, &claimed, w) {
claim(&mut out, &mut claimed, r, c, AttractorType::CoastalAccess, s);
claim(
&mut out,
&mut claimed,
r,
c,
AttractorType::CoastalAccess,
s,
);
}
// 4. ValleyFloor — gentle slope, mid elevation, positive habitability.
@@ -524,7 +547,11 @@ fn is_saddle(elev: &[f32], r: usize, c: usize, w: usize, h: usize) -> bool {
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 };
signs[k] = if elev[idx(nr as usize, nc, w)] > e {
1
} else {
-1
};
}
let mut transitions = 0;
for k in 0..8 {
@@ -540,9 +567,9 @@ fn is_saddle(elev: &[f32], r: usize, c: usize, w: usize, h: usize) -> bool {
///
/// 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.
/// order is fully determined by the sorted candidate iteration; the bucket map
/// is `BTreeMap` (the project bans `HashMap` for determinism) and is lookup-only
/// regardless.
fn thin_by_spacing(
mut cands: Vec<(usize, usize, f32)>,
_claimed: &[bool],
@@ -555,7 +582,7 @@ fn thin_by_spacing(
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 buckets: BTreeMap<(usize, usize), Vec<(usize, usize)>> = BTreeMap::new();
let mut kept: Vec<(usize, usize, f32)> = Vec::new();
for (r, c, s) in cands {
let (br, bc) = (r / sp, c / sp);
@@ -627,8 +654,8 @@ mod tests {
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);
let ka = (win[0].attractor_type as u8, win[0].row, win[0].col);
let kb = (win[1].attractor_type as u8, win[1].row, win[1].col);
assert!(ka <= kb, "attractors must be sorted");
}
}
@@ -668,12 +695,16 @@ mod tests {
// (a global-strength cap would drop all of them — the bug Hoshe caught).
let h = hm(sine_grid(512, 256), 512, 256, 0.40);
let dr = drainage::analyze(&h.data, 512, 256, 0.40);
assert!(!dr.river_network.mouths.is_empty(), "fixture must have mouths");
assert!(
!dr.river_network.mouths.is_empty(),
"fixture must have mouths"
);
let ta = TerrainAnalysis::analyze(&h, &dr);
let a = extract_attractors(&h, &dr, &ta);
assert!(a.len() <= MAX_ATTRACTORS);
assert!(
a.iter().any(|x| x.attractor_type == AttractorType::RiverMouth),
a.iter()
.any(|x| x.attractor_type == AttractorType::RiverMouth),
"RiverMouth attractors must survive the cap when mouths exist"
);
}
+7 -1
View File
@@ -136,7 +136,13 @@ pub fn load_heightmap_reader<R: Read>(
.find(|c| c.keyword == "sea_level")
.and_then(|c| c.text.trim().parse::<f32>().ok())
.unwrap_or(default_sea_level);
(info.width, info.height, info.bit_depth, info.color_type, sea_level)
(
info.width,
info.height,
info.bit_depth,
info.color_type,
sea_level,
)
};
let mut buf = vec![0u8; png_reader.output_buffer_size()];
let frame = png_reader.next_frame(&mut buf)?;
+10 -8
View File
@@ -35,8 +35,7 @@ pub struct Layer1Output {
/// 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 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);
@@ -162,18 +161,21 @@ mod tests {
b.terrain_modification_cost.to_bits()
);
}
assert_eq!(
o1.river_network.river_cells,
o2.river_network.river_cells
);
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)));
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]
+19 -13
View File
@@ -134,7 +134,10 @@ mod tests {
#[test]
fn alpine_for_high_elevation() {
// elev_pct > 0.8 → Alpine regardless of other signals.
let (v, cost) = (classify_variant(0.95, 30.0, 100, 0.5), base_cost(SubBiomeVariant::Alpine));
let (v, cost) = (
classify_variant(0.95, 30.0, 100, 0.5),
base_cost(SubBiomeVariant::Alpine),
);
assert_eq!(v, SubBiomeVariant::Alpine);
assert!(cost > 3.0);
}
@@ -163,22 +166,25 @@ mod tests {
// (elev_pct, slope, water_dist, temp) → expected variant, per the
// classify_variant branch order. Covers all 10 derivable variants.
let cases: &[(f32, f32, u16, f32, SubBiomeVariant)] = &[
(0.95, 0.0, 100, 0.5, Alpine), // high elevation dominates
(0.10, 0.0, 1, 0.5, Wetland), // saturated low ground
(0.35, 0.0, 4, 0.5, CoastalLowland), // near coast, low
(0.50, 0.0, 100, 0.10, Tundra), // cold pole
(0.50, 0.0, 100, 0.30, BorealForest), // cool
(0.50, 0.0, 10, 0.90, TropicalWet), // hot + moist
(0.50, 0.0, 30, 0.90, Savanna), // hot + mid-dry
(0.50, 0.0, 50, 0.90, Desert), // hot + dry
(0.50, 0.0, 10, 0.50, TemperateForest), // temperate + moist
(0.50, 0.0, 40, 0.50, TemperateGrassland),// temperate + mid
(0.50, 0.0, 70, 0.50, Desert), // temperate + arid
(0.95, 0.0, 100, 0.5, Alpine), // high elevation dominates
(0.10, 0.0, 1, 0.5, Wetland), // saturated low ground
(0.35, 0.0, 4, 0.5, CoastalLowland), // near coast, low
(0.50, 0.0, 100, 0.10, Tundra), // cold pole
(0.50, 0.0, 100, 0.30, BorealForest), // cool
(0.50, 0.0, 10, 0.90, TropicalWet), // hot + moist
(0.50, 0.0, 30, 0.90, Savanna), // hot + mid-dry
(0.50, 0.0, 50, 0.90, Desert), // hot + dry
(0.50, 0.0, 10, 0.50, TemperateForest), // temperate + moist
(0.50, 0.0, 40, 0.50, TemperateGrassland), // temperate + mid
(0.50, 0.0, 70, 0.50, Desert), // temperate + arid
];
for &(e, s, w, t, expected) in cases {
let got = classify_variant(e, s, w, t);
assert_eq!(got, expected, "classify_variant({e},{s},{w},{t})");
assert_ne!(got, Volcanic, "Volcanic must never be emitted (no L1 signal)");
assert_ne!(
got, Volcanic,
"Volcanic must never be emitted (no L1 signal)"
);
}
}
}