Skip to main content

proof_engine/terrain/
biomes.rs

1//! Biome placement and ecological simulation.
2//!
3//! Implements the Whittaker biome classification diagram, biome blending,
4//! vegetation density maps, rock/soil type assignment, river carving,
5//! lake filling, and coastline detection.
6
7use super::heightmap::HeightMap;
8
9// ── Biome Types ───────────────────────────────────────────────────────────────
10
11/// All biome types derived from the Whittaker biome diagram.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum BiomeKind {
14    // Aquatic
15    Ocean,
16    ShallowWater,
17    Lake,
18    River,
19    // Hot / dry
20    Desert,
21    HotDesert,
22    SemiArid,
23    // Tropical
24    TropicalRainforest,
25    TropicalDryForest,
26    Savanna,
27    // Subtropical / temperate
28    Shrubland,
29    TemperateRainforest,
30    TemperateSeasonalForest,
31    TemperateGrassland,
32    // Cold
33    BorealForest,    // Taiga
34    Tundra,
35    Alpine,
36    // Special
37    Glacier,
38    Beach,
39    Wetland,
40    Mangrove,
41    Volcano,
42}
43
44impl BiomeKind {
45    /// Human-readable name.
46    pub fn name(self) -> &'static str {
47        match self {
48            BiomeKind::Ocean                    => "Ocean",
49            BiomeKind::ShallowWater             => "Shallow Water",
50            BiomeKind::Lake                     => "Lake",
51            BiomeKind::River                    => "River",
52            BiomeKind::Desert                   => "Desert",
53            BiomeKind::HotDesert                => "Hot Desert",
54            BiomeKind::SemiArid                 => "Semi-Arid",
55            BiomeKind::TropicalRainforest        => "Tropical Rainforest",
56            BiomeKind::TropicalDryForest         => "Tropical Dry Forest",
57            BiomeKind::Savanna                  => "Savanna",
58            BiomeKind::Shrubland                => "Shrubland",
59            BiomeKind::TemperateRainforest       => "Temperate Rainforest",
60            BiomeKind::TemperateSeasonalForest   => "Temperate Seasonal Forest",
61            BiomeKind::TemperateGrassland        => "Temperate Grassland",
62            BiomeKind::BorealForest              => "Boreal Forest (Taiga)",
63            BiomeKind::Tundra                   => "Tundra",
64            BiomeKind::Alpine                   => "Alpine",
65            BiomeKind::Glacier                  => "Glacier",
66            BiomeKind::Beach                    => "Beach",
67            BiomeKind::Wetland                  => "Wetland",
68            BiomeKind::Mangrove                 => "Mangrove",
69            BiomeKind::Volcano                  => "Volcano",
70        }
71    }
72
73    /// Representative RGB color (0–255) for map display.
74    pub fn map_color(self) -> [u8; 3] {
75        match self {
76            BiomeKind::Ocean                    => [0,   60,  120],
77            BiomeKind::ShallowWater             => [30,  120, 180],
78            BiomeKind::Lake                     => [50,  100, 200],
79            BiomeKind::River                    => [50,  130, 220],
80            BiomeKind::Desert                   => [230, 200, 130],
81            BiomeKind::HotDesert                => [240, 190, 100],
82            BiomeKind::SemiArid                 => [210, 185, 130],
83            BiomeKind::TropicalRainforest        => [10,  100,  20],
84            BiomeKind::TropicalDryForest         => [50,  130,  40],
85            BiomeKind::Savanna                  => [160, 180,  60],
86            BiomeKind::Shrubland                => [130, 150,  80],
87            BiomeKind::TemperateRainforest       => [30,  100,  60],
88            BiomeKind::TemperateSeasonalForest   => [60,  130,  50],
89            BiomeKind::TemperateGrassland        => [130, 170,  80],
90            BiomeKind::BorealForest              => [50,  100,  70],
91            BiomeKind::Tundra                   => [160, 170, 140],
92            BiomeKind::Alpine                   => [200, 200, 200],
93            BiomeKind::Glacier                  => [230, 245, 255],
94            BiomeKind::Beach                    => [230, 210, 150],
95            BiomeKind::Wetland                  => [60,  130, 100],
96            BiomeKind::Mangrove                 => [30,  90,   60],
97            BiomeKind::Volcano                  => [90,  30,   20],
98        }
99    }
100
101    /// Whether the biome is a water body.
102    pub fn is_water(self) -> bool {
103        matches!(self,
104            BiomeKind::Ocean | BiomeKind::ShallowWater |
105            BiomeKind::Lake  | BiomeKind::River)
106    }
107
108    /// Whether the biome supports forest vegetation.
109    pub fn is_forested(self) -> bool {
110        matches!(self,
111            BiomeKind::TropicalRainforest   |
112            BiomeKind::TropicalDryForest    |
113            BiomeKind::TemperateRainforest  |
114            BiomeKind::TemperateSeasonalForest |
115            BiomeKind::BorealForest         |
116            BiomeKind::Mangrove)
117    }
118}
119
120// ── Climate Parameters ────────────────────────────────────────────────────────
121
122/// Per-cell climate data used for biome classification.
123#[derive(Clone, Debug)]
124pub struct ClimateCell {
125    /// Normalized temperature: 0.0 = arctic, 1.0 = equatorial
126    pub temperature: f32,
127    /// Normalized annual precipitation: 0.0 = hyperarid, 1.0 = wet
128    pub moisture:    f32,
129    /// Altitude (normalized 0–1)
130    pub altitude:    f32,
131    /// Distance to nearest ocean cell (0 = coast, 1 = far inland)
132    pub continentality: f32,
133}
134
135// ── Whittaker Biome Classifier ────────────────────────────────────────────────
136
137/// Classify a biome using the Whittaker biome diagram.
138///
139/// Inputs are normalized temperature [0,1] and moisture [0,1].
140/// Additional altitude/continentality corrections are applied.
141pub struct WhittakerClassifier;
142
143impl WhittakerClassifier {
144    /// Primary classification from temperature and moisture.
145    pub fn classify(cell: &ClimateCell) -> BiomeKind {
146        let t = cell.temperature;
147        let m = cell.moisture;
148        let a = cell.altitude;
149
150        // Altitude overrides first
151        if a > 0.92 { return BiomeKind::Glacier; }
152        if a > 0.80 { return BiomeKind::Alpine;  }
153
154        // Ocean / lake handled by caller
155        // Very cold
156        if t < 0.10 {
157            return if a > 0.70 { BiomeKind::Alpine } else { BiomeKind::Glacier };
158        }
159        if t < 0.20 {
160            return BiomeKind::Tundra;
161        }
162
163        // Cold
164        if t < 0.35 {
165            return if m > 0.40 {
166                BiomeKind::BorealForest
167            } else {
168                BiomeKind::Tundra
169            };
170        }
171
172        // Cool temperate
173        if t < 0.50 {
174            return if m > 0.70 {
175                BiomeKind::TemperateRainforest
176            } else if m > 0.40 {
177                BiomeKind::TemperateSeasonalForest
178            } else if m > 0.20 {
179                BiomeKind::TemperateGrassland
180            } else {
181                BiomeKind::Desert
182            };
183        }
184
185        // Warm temperate
186        if t < 0.65 {
187            return if m > 0.65 {
188                BiomeKind::TemperateRainforest
189            } else if m > 0.40 {
190                BiomeKind::TemperateSeasonalForest
191            } else if m > 0.25 {
192                BiomeKind::Shrubland
193            } else if m > 0.12 {
194                BiomeKind::TemperateGrassland
195            } else {
196                BiomeKind::SemiArid
197            };
198        }
199
200        // Hot (subtropical / tropical)
201        if t < 0.80 {
202            return if m > 0.70 {
203                BiomeKind::TropicalDryForest
204            } else if m > 0.40 {
205                BiomeKind::Savanna
206            } else if m > 0.20 {
207                BiomeKind::SemiArid
208            } else {
209                BiomeKind::HotDesert
210            };
211        }
212
213        // Very hot (tropical)
214        if m > 0.75 {
215            BiomeKind::TropicalRainforest
216        } else if m > 0.50 {
217            BiomeKind::TropicalDryForest
218        } else if m > 0.30 {
219            BiomeKind::Savanna
220        } else if m > 0.15 {
221            BiomeKind::SemiArid
222        } else {
223            BiomeKind::HotDesert
224        }
225    }
226}
227
228// ── Biome Map ─────────────────────────────────────────────────────────────────
229
230/// Full biome classification for a terrain.
231#[derive(Clone, Debug)]
232pub struct BiomeMap {
233    pub width:  usize,
234    pub height: usize,
235    /// Primary biome per cell.
236    pub biomes: Vec<BiomeKind>,
237    /// Blend weights for up to 4 neighboring biomes per cell.
238    pub blend:  Vec<BiomeBlend>,
239    /// Per-cell climate.
240    pub climate: Vec<ClimateCell>,
241}
242
243/// Blending weights at a cell boundary.
244#[derive(Clone, Debug, Default)]
245pub struct BiomeBlend {
246    /// Up to 4 biomes and their weights (sum to 1.0).
247    pub entries: [(BiomeKind, f32); 4],
248    pub count:   usize,
249}
250
251impl BiomeBlend {
252    pub fn new_single(biome: BiomeKind) -> Self {
253        let mut b = Self::default();
254        b.entries[0] = (biome, 1.0);
255        b.count = 1;
256        b
257    }
258}
259
260impl Default for BiomeKind {
261    fn default() -> Self { BiomeKind::TemperateGrassland }
262}
263
264impl BiomeMap {
265    #[inline]
266    pub fn idx(&self, x: usize, y: usize) -> usize { y * self.width + x }
267
268    pub fn get(&self, x: usize, y: usize) -> BiomeKind { self.biomes[y * self.width + x] }
269
270    pub fn set(&mut self, x: usize, y: usize, b: BiomeKind) {
271        self.biomes[y * self.width + x] = b;
272    }
273}
274
275// ── Climate Generator ─────────────────────────────────────────────────────────
276
277/// Generates climate maps from a heightmap.
278///
279/// Temperature decreases with altitude and increases toward the equator
280/// (modeled as the center of the map). Moisture is generated via
281/// a simple prevailing-wind model.
282pub struct ClimateGenerator {
283    pub equatorial_band:  f32, // 0–1, fraction of map height as equator
284    pub moisture_falloff: f32, // How quickly moisture drops inland
285    pub seed:             u64,
286}
287
288impl ClimateGenerator {
289    pub fn new(seed: u64) -> Self {
290        Self {
291            equatorial_band:  0.5,
292            moisture_falloff: 0.6,
293            seed,
294        }
295    }
296
297    /// Generate climate for each cell of the heightmap.
298    pub fn generate(&self, heightmap: &HeightMap, sea_level: f32) -> Vec<ClimateCell> {
299        let w = heightmap.width;
300        let h = heightmap.height;
301        let mut cells = Vec::with_capacity(w * h);
302
303        // Compute ocean distance map (BFS) for continentality
304        let ocean_dist = self.ocean_distance_map(heightmap, sea_level);
305
306        // Simple noise for moisture variation
307        let noise = MoistureNoise::new(self.seed);
308
309        for y in 0..h {
310            for x in 0..w {
311                let altitude = heightmap.get(x, y);
312                // Latitude: 0 at equator (center), 1 at poles (edges)
313                let lat = (y as f32 / h as f32 - self.equatorial_band).abs() * 2.0;
314                let lat = lat.clamp(0.0, 1.0);
315
316                // Base temperature: warm at equator, cool at poles
317                let base_temp = 1.0 - lat;
318                // Altitude lapse: temperature drops with height
319                let lapse = altitude * 0.65;
320                let temperature = (base_temp - lapse).clamp(0.0, 1.0);
321
322                // Continentality (0 = coast, 1 = interior)
323                let max_dist = (w.max(h) / 2) as f32;
324                let continentality = (ocean_dist[y * w + x] as f32 / max_dist).clamp(0.0, 1.0);
325
326                // Moisture: high near ocean, decreases inland
327                // Also influenced by latitude (ITCZ near equator)
328                let itcz = 1.0 - (lat * 2.0 - 0.3).abs().min(1.0);
329                let base_moist = (1.0 - continentality * self.moisture_falloff + itcz * 0.3).clamp(0.0, 1.0);
330                // Add noise variation
331                let nx = x as f32 / w as f32 * 3.7;
332                let ny = y as f32 / h as f32 * 3.7;
333                let moist_noise = noise.sample(nx, ny) * 0.25;
334                let moisture = (base_moist + moist_noise - altitude * 0.15).clamp(0.0, 1.0);
335
336                cells.push(ClimateCell { temperature, moisture, altitude, continentality });
337            }
338        }
339
340        cells
341    }
342
343    /// Flood-fill BFS from ocean cells outward to compute distance.
344    fn ocean_distance_map(&self, heightmap: &HeightMap, sea_level: f32) -> Vec<u32> {
345        use std::collections::VecDeque;
346        let w = heightmap.width;
347        let h = heightmap.height;
348        let mut dist = vec![u32::MAX; w * h];
349        let mut queue = VecDeque::new();
350
351        // Seed ocean cells
352        for y in 0..h {
353            for x in 0..w {
354                if heightmap.get(x, y) < sea_level {
355                    dist[y * w + x] = 0;
356                    queue.push_back((x, y));
357                }
358            }
359        }
360
361        // BFS
362        while let Some((x, y)) = queue.pop_front() {
363            let d = dist[y * w + x];
364            for (dx, dy) in &[(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] {
365                let nx = x as i64 + dx;
366                let ny = y as i64 + dy;
367                if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }
368                let idx = ny as usize * w + nx as usize;
369                if dist[idx] == u32::MAX {
370                    dist[idx] = d + 1;
371                    queue.push_back((nx as usize, ny as usize));
372                }
373            }
374        }
375
376        dist
377    }
378}
379
380/// Minimal value noise for moisture variation.
381struct MoistureNoise {
382    seed: u64,
383}
384
385impl MoistureNoise {
386    fn new(seed: u64) -> Self { Self { seed } }
387
388    fn sample(&self, x: f32, y: f32) -> f32 {
389        let xi = x.floor() as i64;
390        let yi = y.floor() as i64;
391        let xf = x - xi as f32;
392        let yf = y - yi as f32;
393        let ux = xf * xf * (3.0 - 2.0 * xf);
394        let uy = yf * yf * (3.0 - 2.0 * yf);
395        let r = |ix: i64, iy: i64| -> f32 {
396            let mut h = self.seed;
397            h ^= ix.unsigned_abs().wrapping_mul(374761393);
398            h ^= iy.unsigned_abs().wrapping_mul(668265263);
399            h = h.wrapping_mul(0x9e3779b97f4a7c15);
400            h ^= h >> 33;
401            if ix < 0 { h ^= 0xabcd; }
402            if iy < 0 { h ^= 0xef01; }
403            (h >> 11) as f32 / (1u64 << 53) as f32
404        };
405        let v = r(xi, yi) * (1.0-ux)*(1.0-uy)
406              + r(xi+1,yi) *    ux  *(1.0-uy)
407              + r(xi,yi+1) * (1.0-ux)*  uy
408              + r(xi+1,yi+1)*   ux  *   uy;
409        v
410    }
411}
412
413// ── Full Biome Placement System ───────────────────────────────────────────────
414
415/// Generates a full biome map from a heightmap.
416pub struct BiomePlacer {
417    pub sea_level:          f32,
418    pub shallow_water_depth: f32,
419    pub beach_width:        f32,
420    pub glacier_altitude:   f32,
421    pub enable_rivers:      bool,
422    pub enable_lakes:       bool,
423    pub enable_mangroves:   bool,
424    pub river_count:        usize,
425    pub seed:               u64,
426}
427
428impl BiomePlacer {
429    pub fn new(seed: u64) -> Self {
430        Self {
431            sea_level:           0.35,
432            shallow_water_depth: 0.08,
433            beach_width:         0.02,
434            glacier_altitude:    0.92,
435            enable_rivers:       true,
436            enable_lakes:        true,
437            enable_mangroves:    true,
438            river_count:         8,
439            seed,
440        }
441    }
442
443    /// Generate a complete BiomeMap from a heightmap.
444    pub fn generate(&self, heightmap: &HeightMap) -> BiomeMap {
445        let w = heightmap.width;
446        let h = heightmap.height;
447
448        // Generate climate
449        let climate_gen = ClimateGenerator::new(self.seed);
450        let climate = climate_gen.generate(heightmap, self.sea_level);
451
452        // Initial biome classification
453        let mut biomes = Vec::with_capacity(w * h);
454        for y in 0..h {
455            for x in 0..w {
456                let alt = heightmap.get(x, y);
457                let cell = &climate[y * w + x];
458                let biome = if alt < self.sea_level - self.shallow_water_depth {
459                    BiomeKind::Ocean
460                } else if alt < self.sea_level {
461                    BiomeKind::ShallowWater
462                } else if alt < self.sea_level + self.beach_width {
463                    BiomeKind::Beach
464                } else {
465                    WhittakerClassifier::classify(cell)
466                };
467                biomes.push(biome);
468            }
469        }
470
471        let mut map = BiomeMap {
472            width: w,
473            height: h,
474            biomes,
475            blend: vec![BiomeBlend::new_single(BiomeKind::TemperateGrassland); w * h],
476            climate,
477        };
478
479        // Post-processing passes
480        if self.enable_rivers {
481            self.carve_rivers(&mut map, heightmap);
482        }
483        if self.enable_lakes {
484            self.fill_lakes(&mut map, heightmap);
485        }
486        if self.enable_mangroves {
487            self.place_mangroves(&mut map, heightmap);
488        }
489
490        // Compute blending at biome boundaries
491        self.compute_biome_blending(&mut map);
492
493        map
494    }
495
496    // ── River Carving ─────────────────────────────────────────────────────────
497
498    fn carve_rivers(&self, map: &mut BiomeMap, heightmap: &HeightMap) {
499        let w = heightmap.width;
500        let h = heightmap.height;
501        let mut rng = crate::terrain::heightmap::Rng::new(self.seed ^ 0xbeef);
502
503        for _ in 0..self.river_count {
504            // Find a high-altitude starting point that isn't ocean
505            let mut tries = 0;
506            let (mut x, mut y) = loop {
507                let cx = rng.next_i32_range(2, w as i32 - 2) as usize;
508                let cy = rng.next_i32_range(2, h as i32 - 2) as usize;
509                let alt = heightmap.get(cx, cy);
510                if alt > 0.65 && map.get(cx, cy) != BiomeKind::Ocean {
511                    break (cx, cy);
512                }
513                tries += 1;
514                if tries > 200 { break (cx, cy); }
515            };
516
517            // Flow downhill
518            let max_steps = (w + h) * 2;
519            for _ in 0..max_steps {
520                map.set(x, y, BiomeKind::River);
521
522                // Find the lowest neighbor (steepest descent)
523                let mut best_h = heightmap.get(x, y);
524                let mut best = None;
525                for (dx, dy) in &[(-1i64, 0i64), (1, 0), (0, -1), (0, 1),
526                                   (-1, -1), (1, -1), (-1, 1), (1, 1)] {
527                    let nx = x as i64 + dx;
528                    let ny = y as i64 + dy;
529                    if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }
530                    let nh = heightmap.get(nx as usize, ny as usize);
531                    if nh < best_h {
532                        best_h = nh;
533                        best = Some((nx as usize, ny as usize));
534                    }
535                }
536
537                match best {
538                    Some((nx, ny)) => {
539                        // Stop if we reach the sea
540                        if map.get(nx, ny) == BiomeKind::Ocean
541                        || map.get(nx, ny) == BiomeKind::ShallowWater {
542                            break;
543                        }
544                        x = nx;
545                        y = ny;
546                    }
547                    None => break, // Local minimum — fill as lake
548                }
549            }
550        }
551    }
552
553    // ── Lake Filling ──────────────────────────────────────────────────────────
554
555    fn fill_lakes(&self, map: &mut BiomeMap, heightmap: &HeightMap) {
556        let w = heightmap.width;
557        let h = heightmap.height;
558
559        // Find local depressions (cells lower than all 4 orthogonal neighbors)
560        // that are not already water or river, then flood-fill with lake.
561        let mut visited = vec![false; w * h];
562
563        for y in 1..h.saturating_sub(1) {
564            for x in 1..w.saturating_sub(1) {
565                if visited[y * w + x] { continue; }
566                let biome = map.get(x, y);
567                if biome.is_water() { continue; }
568
569                let center_h = heightmap.get(x, y);
570                let is_depression = [(0i64, -1i64), (0, 1), (-1, 0), (1, 0)]
571                    .iter()
572                    .all(|(dx, dy)| {
573                        let nx = (x as i64 + dx) as usize;
574                        let ny = (y as i64 + dy) as usize;
575                        heightmap.get(nx, ny) > center_h
576                    });
577
578                if is_depression && heightmap.get(x, y) < self.sea_level + 0.15 {
579                    // Flood fill a small lake around this depression
580                    self.flood_fill_lake(map, heightmap, x, y, &mut visited);
581                }
582            }
583        }
584    }
585
586    fn flood_fill_lake(
587        &self,
588        map: &mut BiomeMap,
589        heightmap: &HeightMap,
590        sx: usize,
591        sy: usize,
592        visited: &mut Vec<bool>,
593    ) {
594        use std::collections::VecDeque;
595        let w = heightmap.width;
596        let h = heightmap.height;
597        let seed_h = heightmap.get(sx, sy);
598        let lake_level = seed_h + 0.03; // small lake — fill slightly above depression
599
600        let mut queue = VecDeque::new();
601        queue.push_back((sx, sy));
602        visited[sy * w + sx] = true;
603
604        let mut cells_filled = 0usize;
605        let max_lake_cells = 500;
606
607        while let Some((x, y)) = queue.pop_front() {
608            if cells_filled >= max_lake_cells { break; }
609            if !map.get(x, y).is_water() {
610                map.set(x, y, BiomeKind::Lake);
611                cells_filled += 1;
612            }
613
614            for (dx, dy) in &[(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] {
615                let nx = x as i64 + dx;
616                let ny = y as i64 + dy;
617                if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }
618                let ni = ny as usize * w + nx as usize;
619                if visited[ni] { continue; }
620                let nh = heightmap.get(nx as usize, ny as usize);
621                if nh <= lake_level && !map.get(nx as usize, ny as usize).is_water() {
622                    visited[ni] = true;
623                    queue.push_back((nx as usize, ny as usize));
624                }
625            }
626        }
627    }
628
629    // ── Mangrove Placement ────────────────────────────────────────────────────
630
631    fn place_mangroves(&self, map: &mut BiomeMap, heightmap: &HeightMap) {
632        let w = heightmap.width;
633        let h = heightmap.height;
634
635        for y in 1..h.saturating_sub(1) {
636            for x in 1..w.saturating_sub(1) {
637                let biome = map.get(x, y);
638                if biome != BiomeKind::Beach { continue; }
639                let climate = &map.climate[y * w + x];
640                // Mangroves only in tropical/subtropical zones
641                if climate.temperature < 0.65 { continue; }
642                // Must be adjacent to shallow water
643                let adj_water = [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)]
644                    .iter()
645                    .any(|(dx, dy)| {
646                        let nx = (x as i64 + dx) as usize;
647                        let ny = (y as i64 + dy) as usize;
648                        matches!(map.get(nx, ny), BiomeKind::Ocean | BiomeKind::ShallowWater)
649                    });
650                if adj_water {
651                    map.set(x, y, BiomeKind::Mangrove);
652                }
653            }
654        }
655    }
656
657    // ── Biome Boundary Blending ───────────────────────────────────────────────
658
659    fn compute_biome_blending(&self, map: &mut BiomeMap) {
660        let w = map.width;
661        let h = map.height;
662        let biomes_snap = map.biomes.clone();
663
664        for y in 0..h {
665            for x in 0..w {
666                let center = biomes_snap[y * w + x];
667                let mut blend_map: std::collections::HashMap<BiomeKindKey, f32> =
668                    std::collections::HashMap::new();
669
670                // Sample a 3x3 neighborhood
671                let mut total = 0.0f32;
672                for dy in -1i64..=1 {
673                    for dx in -1i64..=1 {
674                        let nx = x as i64 + dx;
675                        let ny = y as i64 + dy;
676                        let biome = if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
677                            center
678                        } else {
679                            biomes_snap[ny as usize * w + nx as usize]
680                        };
681                        let weight = if dx == 0 && dy == 0 { 4.0 }
682                                     else if dx == 0 || dy == 0 { 2.0 }
683                                     else { 1.0 };
684                        *blend_map.entry(BiomeKindKey(biome)).or_insert(0.0) += weight;
685                        total += weight;
686                    }
687                }
688
689                // Sort by weight descending, keep top 4
690                let mut entries: Vec<(BiomeKind, f32)> = blend_map
691                    .into_iter()
692                    .map(|(k, w)| (k.0, w / total))
693                    .collect();
694                entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
695                entries.truncate(4);
696
697                // Renormalize after truncation
698                let sum: f32 = entries.iter().map(|e| e.1).sum();
699                let mut blend = BiomeBlend::default();
700                blend.count = entries.len();
701                for (i, (biome, weight)) in entries.into_iter().enumerate() {
702                    blend.entries[i] = (biome, if sum > 0.0 { weight / sum } else { 0.0 });
703                }
704
705                map.blend[y * w + x] = blend;
706            }
707        }
708    }
709}
710
711/// Newtype for HashMap key since BiomeKind doesn't implement Hash by default
712/// (it does via derive, but we need Eq too).
713#[derive(PartialEq, Eq, Hash)]
714struct BiomeKindKey(BiomeKind);
715
716// ── Vegetation Density Map ────────────────────────────────────────────────────
717
718/// Per-cell vegetation density values.
719#[derive(Clone, Debug)]
720pub struct VegetationDensityMap {
721    pub width:   usize,
722    pub height:  usize,
723    /// Tree density [0, 1]
724    pub trees:   Vec<f32>,
725    /// Grass density [0, 1]
726    pub grass:   Vec<f32>,
727    /// Shrub density [0, 1]
728    pub shrubs:  Vec<f32>,
729    /// Rock density [0, 1]
730    pub rocks:   Vec<f32>,
731}
732
733impl VegetationDensityMap {
734    pub fn generate(biome_map: &BiomeMap, heightmap: &HeightMap) -> Self {
735        let w = biome_map.width;
736        let h = biome_map.height;
737        let size = w * h;
738
739        let mut trees  = vec![0.0f32; size];
740        let mut grass  = vec![0.0f32; size];
741        let mut shrubs = vec![0.0f32; size];
742        let mut rocks  = vec![0.0f32; size];
743
744        for y in 0..h {
745            for x in 0..w {
746                let i = y * w + x;
747                let slope = heightmap.slope_at(x, y);
748                let biome = biome_map.get(x, y);
749                let (t, g, s, r) = vegetation_densities(biome, slope);
750                trees[i]  = t;
751                grass[i]  = g;
752                shrubs[i] = s;
753                rocks[i]  = r;
754            }
755        }
756
757        Self { width: w, height: h, trees, grass, shrubs, rocks }
758    }
759
760    pub fn tree_density_at(&self, x: usize, y: usize) -> f32 {
761        self.trees[y * self.width + x]
762    }
763
764    pub fn grass_density_at(&self, x: usize, y: usize) -> f32 {
765        self.grass[y * self.width + x]
766    }
767}
768
769/// Returns (tree, grass, shrub, rock) density for a biome + slope.
770fn vegetation_densities(biome: BiomeKind, slope: f32) -> (f32, f32, f32, f32) {
771    // Slope penalty: steep terrain → fewer plants, more rocks
772    let slope_pen = (slope * 3.0).clamp(0.0, 1.0);
773    let rock_bonus = slope_pen * 0.8;
774
775    let (t, g, s, r) = match biome {
776        BiomeKind::TropicalRainforest        => (0.90, 0.70, 0.40, 0.05),
777        BiomeKind::TropicalDryForest         => (0.65, 0.50, 0.30, 0.10),
778        BiomeKind::Savanna                   => (0.15, 0.80, 0.20, 0.10),
779        BiomeKind::TemperateRainforest        => (0.85, 0.60, 0.35, 0.08),
780        BiomeKind::TemperateSeasonalForest    => (0.70, 0.50, 0.25, 0.10),
781        BiomeKind::TemperateGrassland         => (0.05, 0.90, 0.15, 0.08),
782        BiomeKind::BorealForest               => (0.75, 0.20, 0.20, 0.12),
783        BiomeKind::Shrubland                  => (0.10, 0.50, 0.70, 0.15),
784        BiomeKind::Tundra                     => (0.00, 0.30, 0.10, 0.25),
785        BiomeKind::Alpine                     => (0.00, 0.10, 0.05, 0.70),
786        BiomeKind::Glacier                    => (0.00, 0.00, 0.00, 0.15),
787        BiomeKind::Desert | BiomeKind::HotDesert => (0.00, 0.05, 0.08, 0.20),
788        BiomeKind::SemiArid                   => (0.03, 0.25, 0.25, 0.18),
789        BiomeKind::Wetland                    => (0.20, 0.80, 0.30, 0.02),
790        BiomeKind::Mangrove                   => (0.80, 0.30, 0.10, 0.02),
791        BiomeKind::Beach                      => (0.00, 0.10, 0.05, 0.20),
792        BiomeKind::Ocean | BiomeKind::ShallowWater |
793        BiomeKind::Lake  | BiomeKind::River   => (0.00, 0.00, 0.00, 0.00),
794        BiomeKind::Volcano                    => (0.00, 0.00, 0.00, 0.80),
795    };
796
797    let t = (t * (1.0 - slope_pen)).clamp(0.0, 1.0);
798    let g = (g * (1.0 - slope_pen * 0.5)).clamp(0.0, 1.0);
799    let s = (s * (1.0 - slope_pen * 0.7)).clamp(0.0, 1.0);
800    let r = (r + rock_bonus).clamp(0.0, 1.0);
801
802    (t, g, s, r)
803}
804
805// ── Soil / Rock Type ──────────────────────────────────────────────────────────
806
807/// Rock and soil type for a terrain cell.
808#[derive(Clone, Copy, Debug, PartialEq, Eq)]
809pub enum SoilType {
810    Bedrock,
811    Granite,
812    Limestone,
813    Sandstone,
814    Basalt,
815    Soil,
816    LoamySoil,
817    ClayRich,
818    SandyLoam,
819    PeatBog,
820    Permafrost,
821    VolcanicAsh,
822}
823
824impl SoilType {
825    /// Drainage rate [0, 1]: 0 = waterlogged, 1 = free-draining.
826    pub fn drainage(self) -> f32 {
827        match self {
828            SoilType::Bedrock      => 0.9,
829            SoilType::Granite      => 0.85,
830            SoilType::Limestone    => 0.75,
831            SoilType::Sandstone    => 0.80,
832            SoilType::Basalt       => 0.70,
833            SoilType::Soil         => 0.55,
834            SoilType::LoamySoil    => 0.50,
835            SoilType::ClayRich     => 0.15,
836            SoilType::SandyLoam    => 0.80,
837            SoilType::PeatBog      => 0.05,
838            SoilType::Permafrost   => 0.02,
839            SoilType::VolcanicAsh  => 0.65,
840        }
841    }
842
843    /// Fertility [0, 1]: higher means more vegetation potential.
844    pub fn fertility(self) -> f32 {
845        match self {
846            SoilType::Bedrock      => 0.0,
847            SoilType::Granite      => 0.05,
848            SoilType::Limestone    => 0.30,
849            SoilType::Sandstone    => 0.20,
850            SoilType::Basalt       => 0.40,
851            SoilType::Soil         => 0.70,
852            SoilType::LoamySoil    => 0.90,
853            SoilType::ClayRich     => 0.60,
854            SoilType::SandyLoam    => 0.50,
855            SoilType::PeatBog      => 0.55,
856            SoilType::Permafrost   => 0.10,
857            SoilType::VolcanicAsh  => 0.80,
858        }
859    }
860}
861
862/// Assign soil types based on altitude and biome.
863pub struct SoilAssigner;
864
865impl SoilAssigner {
866    pub fn assign(biome_map: &BiomeMap, heightmap: &HeightMap) -> Vec<SoilType> {
867        let w = heightmap.width;
868        let h = heightmap.height;
869        let mut out = Vec::with_capacity(w * h);
870
871        for y in 0..h {
872            for x in 0..w {
873                let alt   = heightmap.get(x, y);
874                let biome = biome_map.get(x, y);
875                let slope = heightmap.slope_at(x, y);
876
877                let soil = Self::classify(biome, alt, slope);
878                out.push(soil);
879            }
880        }
881
882        out
883    }
884
885    fn classify(biome: BiomeKind, altitude: f32, slope: f32) -> SoilType {
886        if altitude > 0.90 { return SoilType::Bedrock; }
887        if slope > 0.60    { return SoilType::Granite; }
888
889        match biome {
890            BiomeKind::Glacier               => SoilType::Permafrost,
891            BiomeKind::Alpine                => SoilType::Bedrock,
892            BiomeKind::Tundra                => SoilType::Permafrost,
893            BiomeKind::BorealForest          => SoilType::PeatBog,
894            BiomeKind::TemperateRainforest |
895            BiomeKind::TropicalRainforest    => SoilType::LoamySoil,
896            BiomeKind::TemperateSeasonalForest |
897            BiomeKind::TropicalDryForest     => SoilType::Soil,
898            BiomeKind::TemperateGrassland |
899            BiomeKind::Savanna               => SoilType::LoamySoil,
900            BiomeKind::Shrubland             => SoilType::SandyLoam,
901            BiomeKind::Desert |
902            BiomeKind::HotDesert             => SoilType::Sandstone,
903            BiomeKind::SemiArid              => SoilType::SandyLoam,
904            BiomeKind::Wetland               => SoilType::PeatBog,
905            BiomeKind::Mangrove              => SoilType::ClayRich,
906            BiomeKind::Beach                 => SoilType::Sandstone,
907            BiomeKind::Volcano               => SoilType::VolcanicAsh,
908            BiomeKind::Ocean |
909            BiomeKind::ShallowWater          => SoilType::Basalt,
910            BiomeKind::Lake |
911            BiomeKind::River                 => SoilType::ClayRich,
912        }
913    }
914}
915
916// ── Coastline Detection ───────────────────────────────────────────────────────
917
918/// A single point on a coastline contour.
919#[derive(Clone, Debug)]
920pub struct CoastlinePoint {
921    pub x: usize,
922    pub y: usize,
923    pub facing: CoastFacing,
924}
925
926/// Which direction the coast faces.
927#[derive(Clone, Copy, Debug, PartialEq, Eq)]
928pub enum CoastFacing {
929    North, South, East, West,
930}
931
932/// Detect coastline cells (land cells adjacent to ocean).
933pub fn detect_coastline(map: &BiomeMap) -> Vec<CoastlinePoint> {
934    let w = map.width;
935    let h = map.height;
936    let mut points = Vec::new();
937
938    for y in 0..h {
939        for x in 0..w {
940            if map.get(x, y).is_water() { continue; }
941            // Check 4 orthogonal neighbors
942            let neighbors = [
943                (x as i64,     y as i64 - 1, CoastFacing::North),
944                (x as i64,     y as i64 + 1, CoastFacing::South),
945                (x as i64 + 1, y as i64,     CoastFacing::East),
946                (x as i64 - 1, y as i64,     CoastFacing::West),
947            ];
948            for (nx, ny, facing) in &neighbors {
949                if *nx < 0 || *ny < 0 || *nx >= w as i64 || *ny >= h as i64 { continue; }
950                let nb = map.get(*nx as usize, *ny as usize);
951                if nb == BiomeKind::Ocean || nb == BiomeKind::ShallowWater {
952                    points.push(CoastlinePoint { x, y, facing: *facing });
953                    break; // one entry per cell
954                }
955            }
956        }
957    }
958
959    points
960}
961
962// ── Biome Query Helpers ───────────────────────────────────────────────────────
963
964/// Summary statistics for a biome map.
965#[derive(Clone, Debug, Default)]
966pub struct BiomeStats {
967    pub ocean_fraction:    f32,
968    pub land_fraction:     f32,
969    pub forest_fraction:   f32,
970    pub desert_fraction:   f32,
971    pub unique_biomes:     usize,
972}
973
974impl BiomeStats {
975    pub fn compute(map: &BiomeMap) -> Self {
976        let total = (map.width * map.height) as f32;
977        let mut ocean = 0usize;
978        let mut forest = 0usize;
979        let mut desert = 0usize;
980        let mut kinds = std::collections::HashSet::new();
981
982        for &b in &map.biomes {
983            kinds.insert(b as u8);
984            if b.is_water()                    { ocean  += 1; }
985            if b.is_forested()                 { forest += 1; }
986            if matches!(b, BiomeKind::Desert | BiomeKind::HotDesert |
987                           BiomeKind::SemiArid) { desert += 1; }
988        }
989
990        Self {
991            ocean_fraction:  ocean  as f32 / total,
992            land_fraction:   (total as usize - ocean) as f32 / total,
993            forest_fraction: forest as f32 / total,
994            desert_fraction: desert as f32 / total,
995            unique_biomes:   kinds.len(),
996        }
997    }
998}
999
1000// ── Tests ─────────────────────────────────────────────────────────────────────
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use crate::terrain::heightmap::DiamondSquare;
1006
1007    fn make_test_heightmap() -> HeightMap {
1008        DiamondSquare::generate(32, 0.5, 42)
1009    }
1010
1011    #[test]
1012    fn test_whittaker_hot_wet() {
1013        let cell = ClimateCell {
1014            temperature:    0.90,
1015            moisture:       0.90,
1016            altitude:       0.40,
1017            continentality: 0.10,
1018        };
1019        assert_eq!(WhittakerClassifier::classify(&cell), BiomeKind::TropicalRainforest);
1020    }
1021
1022    #[test]
1023    fn test_whittaker_cold_dry() {
1024        let cell = ClimateCell {
1025            temperature:    0.15,
1026            moisture:       0.10,
1027            altitude:       0.30,
1028            continentality: 0.80,
1029        };
1030        assert_eq!(WhittakerClassifier::classify(&cell), BiomeKind::Tundra);
1031    }
1032
1033    #[test]
1034    fn test_whittaker_high_altitude() {
1035        let cell = ClimateCell {
1036            temperature:    0.50,
1037            moisture:       0.50,
1038            altitude:       0.95,
1039            continentality: 0.50,
1040        };
1041        assert_eq!(WhittakerClassifier::classify(&cell), BiomeKind::Glacier);
1042    }
1043
1044    #[test]
1045    fn test_biome_placer_coverage() {
1046        let hm = make_test_heightmap();
1047        let placer = BiomePlacer::new(1234);
1048        let bmap = placer.generate(&hm);
1049        assert_eq!(bmap.biomes.len(), hm.data.len());
1050    }
1051
1052    #[test]
1053    fn test_biome_blend_weights_sum_to_1() {
1054        let hm = make_test_heightmap();
1055        let placer = BiomePlacer::new(9999);
1056        let bmap = placer.generate(&hm);
1057        for blend in &bmap.blend {
1058            let sum: f32 = blend.entries[..blend.count].iter().map(|e| e.1).sum();
1059            assert!((sum - 1.0).abs() < 1e-4 || blend.count == 0,
1060                "blend weights don't sum to 1: {sum}");
1061        }
1062    }
1063
1064    #[test]
1065    fn test_vegetation_density_water_is_zero() {
1066        let (t, g, s, r) = vegetation_densities(BiomeKind::Ocean, 0.0);
1067        assert_eq!(t, 0.0);
1068        assert_eq!(g, 0.0);
1069        assert_eq!(s, 0.0);
1070        assert_eq!(r, 0.0);
1071    }
1072
1073    #[test]
1074    fn test_soil_assigner_count() {
1075        let hm = make_test_heightmap();
1076        let placer = BiomePlacer::new(0);
1077        let bmap = placer.generate(&hm);
1078        let soils = SoilAssigner::assign(&bmap, &hm);
1079        assert_eq!(soils.len(), hm.data.len());
1080    }
1081
1082    #[test]
1083    fn test_coastline_detection() {
1084        let hm = make_test_heightmap();
1085        let placer = BiomePlacer::new(5555);
1086        let bmap = placer.generate(&hm);
1087        let coast = detect_coastline(&bmap);
1088        // Should find at least some coastline if map has both land and ocean
1089        let has_ocean = bmap.biomes.iter().any(|b| *b == BiomeKind::Ocean);
1090        let has_land  = bmap.biomes.iter().any(|b| !b.is_water());
1091        if has_ocean && has_land {
1092            assert!(!coast.is_empty(), "expected coastline points");
1093        }
1094    }
1095
1096    #[test]
1097    fn test_biome_stats() {
1098        let hm = make_test_heightmap();
1099        let placer = BiomePlacer::new(42);
1100        let bmap = placer.generate(&hm);
1101        let stats = BiomeStats::compute(&bmap);
1102        assert!(stats.land_fraction + stats.ocean_fraction > 0.0);
1103        assert!(stats.unique_biomes > 0);
1104    }
1105}