Skip to main content

proof_engine/weather/
precipitation.rs

1//! Precipitation physics subsystem.
2//!
3//! Simulates rain, snow, hail, sleet, and drizzle — including individual droplet physics,
4//! surface accumulation, puddle formation, snowpack, ice, and thunderstorm timing.
5
6use std::collections::HashMap;
7use super::{Vec3, lerp, smoothstep, fbm_2d, value_noise_2d};
8
9// ── Types of precipitation ────────────────────────────────────────────────────
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum PrecipitationType {
13    None,
14    Drizzle,
15    Rain,
16    Snow,
17    Sleet,
18    Hail,
19}
20
21impl PrecipitationType {
22    /// Terminal velocity (m/s) for a "typical" drop/particle.
23    pub fn terminal_velocity(self) -> f32 {
24        match self {
25            Self::None    => 0.0,
26            Self::Drizzle => 1.0,
27            Self::Rain    => 6.5,
28            Self::Snow    => 1.2,
29            Self::Sleet   => 4.0,
30            Self::Hail    => 20.0,
31        }
32    }
33
34    /// Typical particle radius (metres).
35    pub fn typical_radius_m(self) -> f32 {
36        match self {
37            Self::None    => 0.0,
38            Self::Drizzle => 0.000_2,
39            Self::Rain    => 0.001_5,
40            Self::Snow    => 0.003,
41            Self::Sleet   => 0.002,
42            Self::Hail    => 0.015,
43        }
44    }
45
46    /// Whether this type can form ice on surfaces.
47    pub fn can_ice(self) -> bool {
48        matches!(self, Self::Sleet | Self::Hail)
49    }
50
51    /// Whether this type accumulates as a soft layer.
52    pub fn is_soft_accumulation(self) -> bool {
53        matches!(self, Self::Snow | Self::Drizzle)
54    }
55}
56
57// ── Configuration ─────────────────────────────────────────────────────────────
58
59#[derive(Debug, Clone)]
60pub struct PrecipitationConfig {
61    /// Maximum simultaneous droplets tracked explicitly.
62    pub max_droplets: usize,
63    /// Grid width for accumulation tracking.
64    pub grid_width: usize,
65    /// Grid depth for accumulation tracking.
66    pub grid_depth: usize,
67    /// World units covered by each accumulation cell.
68    pub cell_size: f32,
69    /// Threshold humidity for rain formation [0,1].
70    pub rain_humidity_threshold: f32,
71    /// Temperature (°C) below which precipitation falls as snow.
72    pub snow_threshold_c: f32,
73    /// Temperature (°C) below which sleet transitions to hail.
74    pub hail_threshold_c: f32,
75    /// Puddle evaporation rate per second.
76    pub puddle_evap_rate: f32,
77    /// Snowpack settling rate (fractional per second).
78    pub snow_settle_rate: f32,
79}
80
81impl Default for PrecipitationConfig {
82    fn default() -> Self {
83        Self {
84            max_droplets: 4096,
85            grid_width: 64,
86            grid_depth: 64,
87            cell_size: 10.0,
88            rain_humidity_threshold: 0.75,
89            snow_threshold_c: 2.0,
90            hail_threshold_c: -5.0,
91            puddle_evap_rate: 0.001,
92            snow_settle_rate: 0.0005,
93        }
94    }
95}
96
97// ── Droplet Physics ───────────────────────────────────────────────────────────
98
99/// Physical parameters for a class of droplets/particles.
100#[derive(Debug, Clone, Copy)]
101pub struct DropletPhysics {
102    pub radius_m: f32,
103    pub density_kg_m3: f32,   // e.g. 1000 for water, 917 for ice
104    pub drag_coeff: f32,
105    pub air_density: f32,
106}
107
108impl DropletPhysics {
109    pub fn water_drop(radius_m: f32) -> Self {
110        Self { radius_m, density_kg_m3: 1000.0, drag_coeff: 0.47, air_density: 1.225 }
111    }
112    pub fn ice_particle(radius_m: f32) -> Self {
113        Self { radius_m, density_kg_m3: 917.0, drag_coeff: 0.55, air_density: 1.225 }
114    }
115    pub fn snowflake(radius_m: f32) -> Self {
116        Self { radius_m, density_kg_m3: 100.0, drag_coeff: 1.5, air_density: 1.225 }
117    }
118
119    /// Mass (kg) of the particle.
120    pub fn mass(&self) -> f32 {
121        let vol = (4.0 / 3.0) * std::f32::consts::PI * self.radius_m.powi(3);
122        vol * self.density_kg_m3
123    }
124
125    /// Cross-sectional area (m²).
126    pub fn area(&self) -> f32 {
127        std::f32::consts::PI * self.radius_m * self.radius_m
128    }
129
130    /// Terminal velocity (m/s) under gravity, positive downward.
131    pub fn terminal_velocity(&self) -> f32 {
132        let m   = self.mass();
133        let a   = self.area();
134        let cd  = self.drag_coeff;
135        let rho = self.air_density;
136        let g   = 9.807_f32;
137        (2.0 * m * g / (cd * rho * a)).sqrt()
138    }
139
140    /// Net acceleration given current velocity and ambient wind.
141    pub fn acceleration(&self, vel: Vec3, wind: Vec3) -> Vec3 {
142        let rel = vel.sub(wind); // velocity relative to air
143        let speed = rel.length();
144        let m = self.mass();
145        let a = self.area();
146        // Drag force opposing relative velocity
147        let drag_mag = 0.5 * self.air_density * self.drag_coeff * a * speed * speed;
148        let drag_dir = if speed > 1e-6 { rel.scale(-1.0 / speed) } else { Vec3::ZERO };
149        let drag = drag_dir.scale(drag_mag / m);
150        // Gravity
151        let gravity = Vec3::new(0.0, -9.807, 0.0);
152        gravity.add(drag)
153    }
154}
155
156// ── Individual Droplet ────────────────────────────────────────────────────────
157
158/// A single simulated precipitation particle.
159#[derive(Debug, Clone)]
160pub struct Droplet {
161    pub position: Vec3,
162    pub velocity: Vec3,
163    pub kind: PrecipitationType,
164    pub physics: DropletPhysics,
165    /// Remaining lifetime (seconds).
166    pub lifetime: f32,
167    /// Has this droplet hit a surface?
168    pub landed: bool,
169    /// Accumulated coalescence (rain drops grow by merging).
170    pub coalescence: f32,
171}
172
173impl Droplet {
174    pub fn new_rain(pos: Vec3) -> Self {
175        let r = 0.001 + value_noise_2d(pos.x * 0.1, pos.z * 0.1) * 0.002;
176        let phys = DropletPhysics::water_drop(r);
177        let vt = -phys.terminal_velocity();
178        Self {
179            position: pos,
180            velocity: Vec3::new(0.0, vt * 0.5, 0.0),
181            kind: PrecipitationType::Rain,
182            physics: phys,
183            lifetime: 10.0,
184            landed: false,
185            coalescence: 0.0,
186        }
187    }
188
189    pub fn new_snow(pos: Vec3) -> Self {
190        let r = 0.002 + value_noise_2d(pos.x * 0.05, pos.z * 0.05) * 0.003;
191        let phys = DropletPhysics::snowflake(r);
192        Self {
193            position: pos,
194            velocity: Vec3::new(0.0, -0.8, 0.0),
195            kind: PrecipitationType::Snow,
196            physics: phys,
197            lifetime: 20.0,
198            landed: false,
199            coalescence: 0.0,
200        }
201    }
202
203    pub fn new_hail(pos: Vec3) -> Self {
204        let r = 0.008 + value_noise_2d(pos.x * 0.02, pos.z * 0.02) * 0.015;
205        let phys = DropletPhysics::ice_particle(r);
206        let vt = -phys.terminal_velocity();
207        Self {
208            position: pos,
209            velocity: Vec3::new(0.0, vt * 0.3, 0.0),
210            kind: PrecipitationType::Hail,
211            physics: phys,
212            lifetime: 15.0,
213            landed: false,
214            coalescence: 0.0,
215        }
216    }
217
218    pub fn new_sleet(pos: Vec3) -> Self {
219        let r = 0.0015;
220        let phys = DropletPhysics::ice_particle(r);
221        let vt = -phys.terminal_velocity();
222        Self {
223            position: pos,
224            velocity: Vec3::new(0.0, vt * 0.6, 0.0),
225            kind: PrecipitationType::Sleet,
226            physics: phys,
227            lifetime: 8.0,
228            landed: false,
229            coalescence: 0.0,
230        }
231    }
232
233    /// Integrate motion by `dt` seconds with the given ambient wind.
234    pub fn tick(&mut self, dt: f32, wind: Vec3, surface_y: f32) {
235        if self.landed { return; }
236        let acc = self.physics.acceleration(self.velocity, wind);
237        self.velocity = self.velocity.add(acc.scale(dt));
238        self.position = self.position.add(self.velocity.scale(dt));
239        self.lifetime -= dt;
240        // Check surface contact
241        if self.position.y <= surface_y {
242            self.position.y = surface_y;
243            self.landed = true;
244        }
245    }
246
247    pub fn is_alive(&self) -> bool {
248        !self.landed && self.lifetime > 0.0
249    }
250}
251
252// ── Rain Band ─────────────────────────────────────────────────────────────────
253
254/// A mesoscale band of rain — used for efficient bulk simulation.
255#[derive(Debug, Clone)]
256pub struct RainBand {
257    /// Centre of the band in world (x, z).
258    pub centre: [f32; 2],
259    /// Radius of the band (m).
260    pub radius: f32,
261    /// Intensity (mm/hr equivalent, 0–100).
262    pub intensity: f32,
263    /// Drift velocity (m/s).
264    pub drift: [f32; 2],
265    /// Precipitation type in this band.
266    pub kind: PrecipitationType,
267    /// Lifetime remaining (seconds).
268    pub lifetime: f32,
269}
270
271impl RainBand {
272    pub fn new(cx: f32, cz: f32, radius: f32, intensity: f32, kind: PrecipitationType) -> Self {
273        Self {
274            centre: [cx, cz],
275            radius,
276            intensity,
277            drift: [2.0, 1.0],
278            kind,
279            lifetime: 3600.0,
280        }
281    }
282
283    pub fn tick(&mut self, dt: f32) {
284        self.centre[0] += self.drift[0] * dt;
285        self.centre[1] += self.drift[1] * dt;
286        self.lifetime -= dt;
287        // Intensity decays near end of life
288        if self.lifetime < 300.0 {
289            self.intensity *= 1.0 - dt / 300.0;
290        }
291    }
292
293    /// Intensity (0–1) at world position (x, z).
294    pub fn intensity_at(&self, x: f32, z: f32) -> f32 {
295        let dx = x - self.centre[0];
296        let dz = z - self.centre[1];
297        let dist = (dx * dx + dz * dz).sqrt();
298        if dist >= self.radius { return 0.0; }
299        let t = smoothstep(self.radius, 0.0, dist);
300        t * (self.intensity / 100.0)
301    }
302
303    pub fn is_alive(&self) -> bool {
304        self.lifetime > 0.0 && self.intensity > 0.01
305    }
306}
307
308// ── Surface Accumulation ─────────────────────────────────────────────────────
309
310/// Grid of surface water/snow/ice depths.
311#[derive(Debug, Clone)]
312pub struct SurfaceAccumulation {
313    pub width: usize,
314    pub depth: usize,
315    pub cell_size: f32,
316    /// Water depth (m) per cell.
317    pub water_depth: Vec<f32>,
318    /// Snow depth (m) per cell.
319    pub snow_depth: Vec<f32>,
320    /// Ice thickness (m) per cell.
321    pub ice_depth: Vec<f32>,
322}
323
324impl SurfaceAccumulation {
325    pub fn new(width: usize, depth: usize, cell_size: f32) -> Self {
326        let n = width * depth;
327        Self {
328            width,
329            depth,
330            cell_size,
331            water_depth: vec![0.0; n],
332            snow_depth:  vec![0.0; n],
333            ice_depth:   vec![0.0; n],
334        }
335    }
336
337    fn idx(&self, gx: usize, gz: usize) -> usize {
338        gz * self.width + gx
339    }
340
341    fn world_to_grid(&self, wx: f32, wz: f32) -> (usize, usize) {
342        let gx = (wx / self.cell_size).floor() as i32;
343        let gz = (wz / self.cell_size).floor() as i32;
344        (
345            gx.rem_euclid(self.width as i32) as usize,
346            gz.rem_euclid(self.depth as i32) as usize,
347        )
348    }
349
350    /// Add `depth_m` of water at world position (wx, wz).
351    pub fn add_water(&mut self, wx: f32, wz: f32, depth_m: f32) {
352        let (gx, gz) = self.world_to_grid(wx, wz);
353        let i = self.idx(gx, gz);
354        self.water_depth[i] = (self.water_depth[i] + depth_m).min(1.0);
355    }
356
357    /// Add `depth_m` of snow at world position (wx, wz).
358    pub fn add_snow(&mut self, wx: f32, wz: f32, depth_m: f32) {
359        let (gx, gz) = self.world_to_grid(wx, wz);
360        let i = self.idx(gx, gz);
361        self.snow_depth[i] = (self.snow_depth[i] + depth_m).min(2.0);
362    }
363
364    /// Add `thickness_m` of ice at world position (wx, wz).
365    pub fn add_ice(&mut self, wx: f32, wz: f32, thickness_m: f32) {
366        let (gx, gz) = self.world_to_grid(wx, wz);
367        let i = self.idx(gx, gz);
368        self.ice_depth[i] = (self.ice_depth[i] + thickness_m).min(0.1);
369    }
370
371    /// Sample water depth at world position.
372    pub fn water_at(&self, wx: f32, wz: f32) -> f32 {
373        let (gx, gz) = self.world_to_grid(wx, wz);
374        self.water_depth[self.idx(gx, gz)]
375    }
376
377    /// Sample snow depth at world position.
378    pub fn snow_at(&self, wx: f32, wz: f32) -> f32 {
379        let (gx, gz) = self.world_to_grid(wx, wz);
380        self.snow_depth[self.idx(gx, gz)]
381    }
382
383    /// Sample ice thickness at world position.
384    pub fn ice_at(&self, wx: f32, wz: f32) -> f32 {
385        let (gx, gz) = self.world_to_grid(wx, wz);
386        self.ice_depth[self.idx(gx, gz)]
387    }
388
389    /// Evaporate water uniformly.
390    pub fn evaporate(&mut self, rate: f32) {
391        for v in &mut self.water_depth {
392            *v = (*v - rate).max(0.0);
393        }
394    }
395
396    /// Melt snow into water at given temperature above 0°C.
397    pub fn melt_snow(&mut self, temp_above_zero_c: f32, dt: f32) {
398        // Approx melt rate: ~3mm per degree per hour
399        let melt = (temp_above_zero_c * 3e-3 / 3600.0) * dt;
400        for i in 0..self.snow_depth.len() {
401            let melted = self.snow_depth[i].min(melt);
402            self.snow_depth[i]  -= melted;
403            self.water_depth[i]  = (self.water_depth[i] + melted * 0.3).min(1.0);
404        }
405    }
406
407    /// Freeze surface water into ice at given temperature below 0°C.
408    pub fn freeze_water(&mut self, temp_below_zero_c: f32, dt: f32) {
409        let freeze_rate = temp_below_zero_c * 1e-4 * dt;
410        for i in 0..self.water_depth.len() {
411            let frozen = self.water_depth[i].min(freeze_rate);
412            self.water_depth[i] -= frozen;
413            self.ice_depth[i]    = (self.ice_depth[i] + frozen * 0.9).min(0.1);
414        }
415    }
416
417    /// Flow water downhill by simple gradient diffusion (treats each cell equally as flat land).
418    pub fn flow_water(&mut self, dt: f32) {
419        // Simplified: water spreads to adjacent cells with less water
420        let flow_rate = 0.05 * dt;
421        let old = self.water_depth.clone();
422        for gz in 0..self.depth {
423            for gx in 0..self.width {
424                let i = self.idx(gx, gz);
425                let h = old[i];
426                if h < 1e-5 { continue; }
427                // Four neighbours
428                let neighbours = [
429                    if gx > 0 { Some(self.idx(gx - 1, gz)) } else { None },
430                    if gx + 1 < self.width  { Some(self.idx(gx + 1, gz)) } else { None },
431                    if gz > 0 { Some(self.idx(gx, gz - 1)) } else { None },
432                    if gz + 1 < self.depth  { Some(self.idx(gx, gz + 1)) } else { None },
433                ];
434                for nb in neighbours.iter().flatten() {
435                    if old[*nb] < h {
436                        let transfer = (h - old[*nb]) * flow_rate * 0.25;
437                        self.water_depth[i]   -= transfer;
438                        self.water_depth[*nb] += transfer;
439                    }
440                }
441            }
442        }
443        for v in &mut self.water_depth {
444            *v = v.clamp(0.0, 1.0);
445        }
446    }
447}
448
449// ── Puddle ────────────────────────────────────────────────────────────────────
450
451/// A surface puddle — formed when water accumulates above a threshold.
452#[derive(Debug, Clone)]
453pub struct Puddle {
454    pub centre: [f32; 2],
455    pub radius: f32,
456    /// Water volume (m³).
457    pub volume: f32,
458    /// Surface area (m²).
459    pub area: f32,
460    /// Evaporation rate (m³/s) under current conditions.
461    pub evap_rate: f32,
462    /// Age (seconds).
463    pub age: f32,
464    /// Is frozen?
465    pub frozen: bool,
466}
467
468impl Puddle {
469    pub fn new(cx: f32, cz: f32, initial_volume: f32) -> Self {
470        let area = (initial_volume / 0.01).sqrt() * std::f32::consts::PI;
471        let radius = (area / std::f32::consts::PI).sqrt();
472        Self {
473            centre: [cx, cz],
474            radius,
475            volume: initial_volume,
476            area,
477            evap_rate: 1e-6,
478            age: 0.0,
479            frozen: false,
480        }
481    }
482
483    pub fn tick(&mut self, dt: f32, temp_c: f32, wind_speed: f32) {
484        self.age += dt;
485        if self.frozen { return; }
486
487        if temp_c < 0.0 {
488            self.frozen = true;
489            return;
490        }
491
492        // Evaporation: driven by temperature and wind
493        let evap = self.evap_rate * (1.0 + wind_speed * 0.1) * (1.0 + temp_c * 0.02) * dt;
494        self.volume = (self.volume - evap).max(0.0);
495        // Update geometry
496        self.area   = (self.volume / 0.01).sqrt() * std::f32::consts::PI;
497        self.radius = (self.area / std::f32::consts::PI).sqrt();
498    }
499
500    pub fn add_water(&mut self, volume: f32) {
501        if self.frozen { return; }
502        self.volume += volume;
503        self.area    = (self.volume / 0.01).sqrt() * std::f32::consts::PI;
504        self.radius  = (self.area / std::f32::consts::PI).sqrt();
505    }
506
507    pub fn is_alive(&self) -> bool {
508        self.volume > 1e-8
509    }
510
511    pub fn contains(&self, x: f32, z: f32) -> bool {
512        let dx = x - self.centre[0];
513        let dz = z - self.centre[1];
514        (dx * dx + dz * dz) <= self.radius * self.radius
515    }
516
517    /// Return water depth at the centre of the puddle.
518    pub fn centre_depth_m(&self) -> f32 {
519        if self.area < 1e-8 { return 0.0; }
520        self.volume / self.area
521    }
522}
523
524// ── Snowpack ──────────────────────────────────────────────────────────────────
525
526/// One layer in a snow column.
527#[derive(Debug, Clone)]
528pub struct SnowpackLayer {
529    /// Age since deposition (seconds).
530    pub age: f32,
531    /// Depth of this layer (m).
532    pub depth_m: f32,
533    /// Density (kg/m³) — freshly fallen ~50, settled ~300, firn ~600.
534    pub density: f32,
535    /// Temperature of the layer (K).
536    pub temp_k: f32,
537    /// Liquid water content [0,1] from melt.
538    pub liquid_water: f32,
539    /// Whether this layer has refrozen into ice.
540    pub is_ice_layer: bool,
541}
542
543impl SnowpackLayer {
544    pub fn fresh(depth_m: f32, temp_k: f32) -> Self {
545        Self {
546            age: 0.0,
547            depth_m,
548            density: 50.0,
549            temp_k,
550            liquid_water: 0.0,
551            is_ice_layer: false,
552        }
553    }
554
555    pub fn tick(&mut self, dt: f32, surface_temp_k: f32) {
556        self.age += dt;
557        // Heat diffusion (simplified)
558        self.temp_k = lerp(self.temp_k, surface_temp_k, 0.0001 * dt);
559        // Settling: density increases over time
560        let settle_rate = 5e-6 * dt * (self.density / 50.0).sqrt();
561        let new_density = (self.density + settle_rate * 300.0).min(917.0);
562        // Conserve mass (depth decreases as density increases)
563        if new_density > self.density + 1e-6 {
564            self.depth_m *= self.density / new_density;
565            self.density  = new_density;
566        }
567        // Melting above 273.15 K
568        if self.temp_k > 273.15 {
569            let melt = (self.temp_k - 273.15) * 0.001 * dt;
570            let melted = self.depth_m.min(melt);
571            self.depth_m    -= melted;
572            self.liquid_water = (self.liquid_water + melted * 0.5 / self.depth_m.max(0.01)).min(1.0);
573        }
574        // Refreeze if cold with liquid water
575        if self.temp_k < 271.0 && self.liquid_water > 0.0 {
576            self.is_ice_layer = true;
577        }
578    }
579
580    /// Snow water equivalent (m of water).
581    pub fn swe(&self) -> f32 {
582        self.depth_m * self.density / 1000.0
583    }
584}
585
586/// Full snowpack at a grid cell, composed of layers.
587#[derive(Debug, Clone)]
588pub struct Snowpack {
589    pub layers: Vec<SnowpackLayer>,
590    pub max_layers: usize,
591}
592
593impl Snowpack {
594    pub fn new() -> Self {
595        Self { layers: Vec::new(), max_layers: 16 }
596    }
597
598    /// Total depth (m).
599    pub fn total_depth(&self) -> f32 {
600        self.layers.iter().map(|l| l.depth_m).sum()
601    }
602
603    /// Total SWE (m).
604    pub fn total_swe(&self) -> f32 {
605        self.layers.iter().map(|l| l.swe()).sum()
606    }
607
608    /// Add a fresh snow layer of given depth.
609    pub fn deposit(&mut self, depth_m: f32, temp_k: f32) {
610        if depth_m < 1e-6 { return; }
611        self.layers.push(SnowpackLayer::fresh(depth_m, temp_k));
612        // Merge thin layers if we exceed max_layers
613        if self.layers.len() > self.max_layers {
614            let a = self.layers.remove(0);
615            let b = &mut self.layers[0];
616            let total_mass = a.depth_m * a.density + b.depth_m * b.density;
617            let total_depth = a.depth_m + b.depth_m;
618            b.depth_m = total_depth;
619            b.density = if total_depth > 0.0 { total_mass / total_depth } else { 100.0 };
620            b.age = b.age.min(a.age);
621        }
622    }
623
624    pub fn tick(&mut self, dt: f32, surface_temp_k: f32) {
625        for layer in &mut self.layers {
626            layer.tick(dt, surface_temp_k);
627        }
628        self.layers.retain(|l| l.depth_m > 1e-6);
629    }
630}
631
632impl Default for Snowpack {
633    fn default() -> Self { Self::new() }
634}
635
636// ── Ice Sheet ─────────────────────────────────────────────────────────────────
637
638/// Ice formation on a surface.
639#[derive(Debug, Clone)]
640pub struct IceSheet {
641    pub thickness_m: f32,
642    pub surface_temp_k: f32,
643    pub age: f32,
644    /// Black ice: forms from thin water films, transparent and slippery.
645    pub is_black_ice: bool,
646    /// Rime ice: forms from freezing fog droplets.
647    pub is_rime: bool,
648}
649
650impl IceSheet {
651    pub fn new(thickness_m: f32, temp_k: f32, is_black_ice: bool) -> Self {
652        Self {
653            thickness_m,
654            surface_temp_k: temp_k,
655            age: 0.0,
656            is_black_ice,
657            is_rime: false,
658        }
659    }
660
661    pub fn rime(thickness_m: f32) -> Self {
662        Self {
663            thickness_m,
664            surface_temp_k: 268.0,
665            age: 0.0,
666            is_black_ice: false,
667            is_rime: true,
668        }
669    }
670
671    pub fn tick(&mut self, dt: f32, surface_temp_k: f32) {
672        self.age += dt;
673        self.surface_temp_k = surface_temp_k;
674        if surface_temp_k > 273.15 {
675            let melt = (surface_temp_k - 273.15) * 5e-5 * dt;
676            self.thickness_m = (self.thickness_m - melt).max(0.0);
677        } else {
678            // Growth from residual moisture
679            self.thickness_m = (self.thickness_m + 1e-7 * dt).min(0.05);
680        }
681    }
682
683    pub fn friction_coefficient(&self) -> f32 {
684        if self.is_black_ice {
685            0.05 + self.thickness_m * 2.0 // very slippery
686        } else if self.is_rime {
687            0.15 + self.thickness_m * 5.0
688        } else {
689            0.1 + self.thickness_m * 3.0
690        }
691    }
692
693    pub fn is_alive(&self) -> bool { self.thickness_m > 1e-7 }
694}
695
696// ── Snow Crystal ──────────────────────────────────────────────────────────────
697
698/// A snow crystal with a simplified dendritic shape parameterisation.
699#[derive(Debug, Clone, Copy)]
700pub struct SnowCrystal {
701    /// Crystal habit encoded as integer (0=plate, 1=column, 2=dendrite, 3=needle, 4=spatial dendrite).
702    pub habit: u8,
703    /// Maximum dimension (m).
704    pub size_m: f32,
705    /// Mass (kg).
706    pub mass_kg: f32,
707    /// Growth rate factor based on supersaturation.
708    pub growth_rate: f32,
709    /// Temperature at which it formed (K).
710    pub formation_temp_k: f32,
711}
712
713impl SnowCrystal {
714    pub fn form(temp_k: f32, supersaturation: f32) -> Self {
715        let tc = temp_k - 273.15;
716        let habit = if tc > -2.0        { 0 } // thin plates
717            else if tc > -5.0           { 3 } // needles
718            else if tc > -10.0          { 2 } // dendrites
719            else if tc > -22.0          { 1 } // columns
720            else                        { 4 }; // spatial dendrites
721        let base_size = 0.001 + supersaturation * 0.003;
722        let mass = match habit {
723            0 => base_size.powi(2) * 1e-6 * 900.0,
724            1 => base_size.powi(3) * 1e-9 * 900.0,
725            _ => base_size.powi(2) * 3e-7 * 200.0,
726        };
727        Self {
728            habit,
729            size_m: base_size,
730            mass_kg: mass,
731            growth_rate: supersaturation * 1e-5,
732            formation_temp_k: temp_k,
733        }
734    }
735
736    pub fn grow(&mut self, dt: f32, supersaturation: f32) {
737        self.size_m  += self.growth_rate * supersaturation * dt;
738        self.mass_kg += self.growth_rate * supersaturation * dt * 1e-4;
739    }
740
741    pub fn habit_name(&self) -> &'static str {
742        match self.habit {
743            0 => "Thin plate",
744            1 => "Column",
745            2 => "Stellar dendrite",
746            3 => "Needle",
747            4 => "Spatial dendrite",
748            _ => "Unknown",
749        }
750    }
751}
752
753// ── Hail Stone ────────────────────────────────────────────────────────────────
754
755/// A hail stone that grows by cycling through a thunderstorm updraft.
756#[derive(Debug, Clone)]
757pub struct HailStone {
758    pub radius_m: f32,
759    pub mass_kg: f32,
760    pub position: Vec3,
761    pub velocity: Vec3,
762    /// Number of updraft cycles.
763    pub cycles: u32,
764    /// Total ice accumulated (kg).
765    pub ice_mass: f32,
766    /// Liquid water coat thickness (m) — from warm air excursions.
767    pub liquid_coat: f32,
768}
769
770impl HailStone {
771    pub fn new(pos: Vec3) -> Self {
772        Self {
773            radius_m: 0.002,
774            mass_kg:  1e-5,
775            position: pos,
776            velocity: Vec3::new(0.0, -5.0, 0.0),
777            cycles:   0,
778            ice_mass: 0.0,
779            liquid_coat: 0.0,
780        }
781    }
782
783    /// Grow by accreting supercooled liquid water.
784    pub fn accrete(&mut self, liquid_water_content: f32, dt: f32) {
785        let cross_section = std::f32::consts::PI * self.radius_m * self.radius_m;
786        let collection_efficiency = 0.8_f32;
787        let rel_speed = self.velocity.length();
788        let added_mass = liquid_water_content * cross_section * collection_efficiency * rel_speed * dt;
789        self.ice_mass  += added_mass;
790        self.mass_kg   += added_mass;
791        // Update radius
792        let vol = self.mass_kg / 917.0;
793        self.radius_m = (3.0 * vol / (4.0 * std::f32::consts::PI)).cbrt();
794    }
795
796    pub fn tick(&mut self, dt: f32, updraft: f32, wind: Vec3) {
797        let phys = DropletPhysics::ice_particle(self.radius_m);
798        let acc  = phys.acceleration(self.velocity, wind.add(Vec3::new(0.0, updraft, 0.0)));
799        self.velocity = self.velocity.add(acc.scale(dt));
800        self.position = self.position.add(self.velocity.scale(dt));
801        // Count updraft cycle crossings
802        if self.velocity.y > 0.0 { self.cycles += 0; } // already counting by altitude in system
803    }
804
805    pub fn diameter_mm(&self) -> f32 {
806        self.radius_m * 2000.0
807    }
808
809    pub fn severity_class(&self) -> &'static str {
810        let d = self.diameter_mm();
811        if d < 5.0       { "Pea" }
812        else if d < 19.0 { "Marble" }
813        else if d < 38.0 { "Golf ball" }
814        else if d < 64.0 { "Tennis ball" }
815        else              { "Softball" }
816    }
817}
818
819// ── Sleet Particle ────────────────────────────────────────────────────────────
820
821/// A sleet particle (partially frozen raindrop).
822#[derive(Debug, Clone, Copy)]
823pub struct SleetParticle {
824    pub position: Vec3,
825    pub velocity: Vec3,
826    pub radius_m: f32,
827    /// Ice fraction [0,1].
828    pub ice_fraction: f32,
829    pub lifetime: f32,
830}
831
832impl SleetParticle {
833    pub fn new(pos: Vec3) -> Self {
834        Self {
835            position: pos,
836            velocity: Vec3::new(0.0, -4.0, 0.0),
837            radius_m: 0.002,
838            ice_fraction: 0.5,
839            lifetime: 10.0,
840        }
841    }
842
843    pub fn tick(&mut self, dt: f32, temp_k: f32, wind: Vec3) {
844        let phys = DropletPhysics::ice_particle(self.radius_m);
845        let acc  = phys.acceleration(self.velocity, wind);
846        self.velocity = self.velocity.add(acc.scale(dt));
847        self.position = self.position.add(self.velocity.scale(dt));
848        self.lifetime -= dt;
849        // Freeze if cold enough
850        if temp_k < 270.0 {
851            self.ice_fraction = (self.ice_fraction + 0.1 * dt).min(1.0);
852        }
853    }
854}
855
856// ── Thunder & Lightning ───────────────────────────────────────────────────────
857
858/// A cumulonimbus thunderstorm cell.
859#[derive(Debug, Clone)]
860pub struct ThunderCell {
861    /// World position (x, z).
862    pub position: [f32; 2],
863    /// Altitude of the charge centre (m).
864    pub charge_altitude: f32,
865    /// Cloud base altitude (m).
866    pub cloud_base_m: f32,
867    /// Cloud top altitude (m).
868    pub cloud_top_m: f32,
869    /// Updraft speed (m/s).
870    pub updraft: f32,
871    /// Electric charge separation (Coulombs, simplified).
872    pub charge: f32,
873    /// Discharge threshold (Coulombs).
874    pub discharge_threshold: f32,
875    /// Active lightning bolts.
876    pub bolts: Vec<LightningBolt>,
877    /// Time since last discharge (s).
878    pub time_since_discharge: f32,
879    /// Average discharge interval (s).
880    pub mean_discharge_interval: f32,
881    /// Cell lifetime remaining (s).
882    pub lifetime: f32,
883    /// Cell intensity [0,1].
884    pub intensity: f32,
885    /// Random seed for this cell's internal variation.
886    cell_seed: f32,
887}
888
889impl ThunderCell {
890    pub fn new(cx: f32, cz: f32) -> Self {
891        Self {
892            position: [cx, cz],
893            charge_altitude: 6_000.0,
894            cloud_base_m: 1_500.0,
895            cloud_top_m: 12_000.0,
896            updraft: 25.0,
897            charge: 0.0,
898            discharge_threshold: 1.0,
899            bolts: Vec::new(),
900            time_since_discharge: 0.0,
901            mean_discharge_interval: 30.0,
902            lifetime: 7200.0,
903            intensity: 0.0,
904            cell_seed: cx.abs().fract() + cz.abs().fract(),
905        }
906    }
907
908    pub fn tick(&mut self, dt: f32, humidity: f32, updraft_forcing: f32) {
909        self.lifetime -= dt;
910        self.time_since_discharge += dt;
911
912        // Charge builds proportional to updraft and humidity
913        let charge_rate = self.updraft * humidity * 0.002;
914        self.charge += charge_rate * dt;
915        self.intensity = (self.charge / self.discharge_threshold).clamp(0.0, 1.0);
916
917        // Discharge when threshold is reached
918        if self.charge >= self.discharge_threshold {
919            self.discharge();
920        }
921
922        // Vary updraft
923        let noise = value_noise_2d(self.cell_seed + self.time_since_discharge * 0.001, 0.0);
924        self.updraft = (10.0 + updraft_forcing + noise * 20.0).max(0.0);
925
926        // Decay old bolts
927        self.bolts.retain(|b| b.is_alive());
928        for bolt in &mut self.bolts {
929            bolt.tick(dt);
930        }
931
932        // Decay intensity as cell ages
933        let age_fraction = 1.0 - self.lifetime / 7200.0;
934        if age_fraction > 0.7 {
935            self.updraft *= 0.999;
936        }
937    }
938
939    fn discharge(&mut self) {
940        self.charge = 0.0;
941        self.time_since_discharge = 0.0;
942        // Create a lightning bolt
943        let bolt = LightningBolt::new(
944            Vec3::new(self.position[0], self.charge_altitude, self.position[1]),
945            self.cloud_base_m,
946            self.intensity,
947        );
948        self.bolts.push(bolt);
949    }
950
951    /// Distance to nearest active bolt strike point.
952    pub fn nearest_strike_dist(&self, x: f32, z: f32) -> Option<f32> {
953        self.bolts.iter()
954            .filter(|b| b.is_alive() && b.struck)
955            .map(|b| {
956                let dx = b.strike_point.x - x;
957                let dz = b.strike_point.z - z;
958                (dx * dx + dz * dz).sqrt()
959            })
960            .reduce(f32::min)
961    }
962
963    /// Thunder delay (seconds) from strike at position (px, pz) to observer at (ox, oz).
964    pub fn thunder_delay_s(strike_x: f32, strike_z: f32, obs_x: f32, obs_z: f32) -> f32 {
965        let dx = strike_x - obs_x;
966        let dz = strike_z - obs_z;
967        let dist = (dx * dx + dz * dz).sqrt();
968        dist / 343.0 // speed of sound
969    }
970
971    pub fn is_alive(&self) -> bool { self.lifetime > 0.0 }
972}
973
974// ── Lightning Bolt ────────────────────────────────────────────────────────────
975
976/// A single lightning bolt with fractal channel geometry.
977#[derive(Debug, Clone)]
978pub struct LightningBolt {
979    /// Starting point (usually in the cloud).
980    pub origin: Vec3,
981    /// Endpoint of the main channel.
982    pub strike_point: Vec3,
983    /// Intermediate vertices of the channel.
984    pub channel: Vec<Vec3>,
985    /// Whether the bolt has actually struck the ground.
986    pub struck: bool,
987    /// Remaining visible lifetime (s).
988    pub visible_time: f32,
989    /// Peak current (kA).
990    pub peak_current_ka: f32,
991    /// Number of return strokes.
992    pub return_strokes: u32,
993    /// Thunder intensity [0,1].
994    pub thunder_intensity: f32,
995}
996
997impl LightningBolt {
998    pub fn new(origin: Vec3, cloud_base: f32, intensity: f32) -> Self {
999        let mut channel = Vec::new();
1000        // Build a simple fractal stepped leader
1001        let steps = 12usize;
1002        let mut pt = origin;
1003        let strike = Vec3::new(
1004            origin.x + (value_noise_2d(origin.x * 0.001, 0.5) * 2.0 - 1.0) * 2_000.0,
1005            cloud_base - 20.0,
1006            origin.z + (value_noise_2d(0.5, origin.z * 0.001) * 2.0 - 1.0) * 2_000.0,
1007        );
1008        channel.push(pt);
1009        for i in 1..steps {
1010            let t = i as f32 / steps as f32;
1011            let noise_x = (value_noise_2d(pt.x * 0.0001 + t, pt.z * 0.0001) * 2.0 - 1.0) * 300.0;
1012            let noise_z = (value_noise_2d(pt.z * 0.0001 + t + 7.0, pt.x * 0.0001) * 2.0 - 1.0) * 300.0;
1013            let next = Vec3::new(
1014                lerp(origin.x, strike.x, t) + noise_x,
1015                lerp(origin.y, strike.y, t),
1016                lerp(origin.z, strike.z, t) + noise_z,
1017            );
1018            channel.push(next);
1019            pt = next;
1020        }
1021        channel.push(strike);
1022        Self {
1023            origin,
1024            strike_point: strike,
1025            channel,
1026            struck: true,
1027            visible_time: 0.3,
1028            peak_current_ka: 20.0 + intensity * 80.0,
1029            return_strokes: 1 + (intensity * 3.0) as u32,
1030            thunder_intensity: intensity,
1031        }
1032    }
1033
1034    pub fn tick(&mut self, dt: f32) {
1035        self.visible_time -= dt;
1036    }
1037
1038    pub fn is_alive(&self) -> bool { self.visible_time > 0.0 }
1039
1040    /// Estimate danger radius (m) for lethal current.
1041    pub fn danger_radius_m(&self) -> f32 {
1042        self.peak_current_ka * 0.5
1043    }
1044}
1045
1046// ── Precipitation Event ───────────────────────────────────────────────────────
1047
1048/// A discrete precipitation event (for weather logging/callbacks).
1049#[derive(Debug, Clone)]
1050pub struct PrecipitationEvent {
1051    pub kind: PrecipitationType,
1052    pub intensity: f32,
1053    pub duration_s: f32,
1054    pub elapsed_s: f32,
1055    pub centre: [f32; 2],
1056    pub radius: f32,
1057}
1058
1059impl PrecipitationEvent {
1060    pub fn progress(&self) -> f32 {
1061        (self.elapsed_s / self.duration_s).clamp(0.0, 1.0)
1062    }
1063    pub fn is_finished(&self) -> bool { self.elapsed_s >= self.duration_s }
1064}
1065
1066// ── Precipitation System ──────────────────────────────────────────────────────
1067
1068/// The main precipitation simulation state.
1069#[derive(Debug, Clone)]
1070pub struct PrecipitationSystem {
1071    pub config: PrecipitationConfig,
1072    pub droplets: Vec<Droplet>,
1073    pub rain_bands: Vec<RainBand>,
1074    pub surface: SurfaceAccumulation,
1075    pub puddles: Vec<Puddle>,
1076    pub snowpacks: HashMap<(i32, i32), Snowpack>,
1077    pub ice_sheets: HashMap<(i32, i32), IceSheet>,
1078    pub hailstones: Vec<HailStone>,
1079    pub sleet_particles: Vec<SleetParticle>,
1080    pub thunder_cells: Vec<ThunderCell>,
1081    pub events: Vec<PrecipitationEvent>,
1082    pub dominant_kind: PrecipitationType,
1083    pub global_intensity: f32,  // 0–1
1084    /// Spawn accumulator for droplets.
1085    spawn_accum: f32,
1086    /// Total precipitation fallen (m water equivalent).
1087    pub total_precip_m: f32,
1088    /// Noise offset drifting over time.
1089    noise_t: f32,
1090}
1091
1092impl PrecipitationSystem {
1093    pub fn new() -> Self {
1094        Self::with_config(PrecipitationConfig::default())
1095    }
1096
1097    pub fn with_config(config: PrecipitationConfig) -> Self {
1098        let surface = SurfaceAccumulation::new(config.grid_width, config.grid_depth, config.cell_size);
1099        Self {
1100            config,
1101            droplets: Vec::new(),
1102            rain_bands: Vec::new(),
1103            surface,
1104            puddles: Vec::new(),
1105            snowpacks: HashMap::new(),
1106            ice_sheets: HashMap::new(),
1107            hailstones: Vec::new(),
1108            sleet_particles: Vec::new(),
1109            thunder_cells: Vec::new(),
1110            events: Vec::new(),
1111            dominant_kind: PrecipitationType::None,
1112            global_intensity: 0.0,
1113            spawn_accum: 0.0,
1114            total_precip_m: 0.0,
1115            noise_t: 0.0,
1116        }
1117    }
1118
1119    // ── Tick ─────────────────────────────────────────────────────────────────
1120
1121    pub fn tick(&mut self, dt: f32, temp_c: f32, humidity: f32, wind: Vec3, pressure_pa: f32) {
1122        self.noise_t += dt * 0.01;
1123
1124        // Determine precipitation type from temperature
1125        let kind = self.classify_precip(temp_c, humidity, pressure_pa);
1126        self.dominant_kind = kind;
1127
1128        // Global intensity from humidity excess
1129        let excess = (humidity - self.config.rain_humidity_threshold).max(0.0);
1130        let target_intensity = smoothstep(0.0, 0.3, excess);
1131        self.global_intensity = lerp(self.global_intensity, target_intensity, dt * 0.1);
1132
1133        // Spawn / manage rain bands
1134        self.update_rain_bands(dt, kind);
1135
1136        // Spawn individual droplets (for VFX-level simulation)
1137        if self.global_intensity > 0.05 {
1138            self.spawn_droplets(dt, kind, wind, temp_c);
1139        }
1140
1141        // Integrate droplets
1142        let surface_y = 0.0_f32;
1143        let mut land_events: Vec<(Vec3, PrecipitationType)> = Vec::new();
1144        for drop in &mut self.droplets {
1145            drop.tick(dt, wind, surface_y);
1146            if drop.landed {
1147                land_events.push((drop.position, drop.kind));
1148            }
1149        }
1150        self.droplets.retain(|d| d.is_alive());
1151
1152        // Process landing events
1153        for (pos, kind) in land_events {
1154            self.handle_landing(pos, kind, temp_c);
1155        }
1156
1157        // Update hailstones
1158        for h in &mut self.hailstones {
1159            h.tick(dt, 15.0, wind);
1160        }
1161        self.hailstones.retain(|h| h.position.y > 0.0);
1162
1163        // Update sleet
1164        for s in &mut self.sleet_particles {
1165            let temp_k = temp_c + 273.15;
1166            s.tick(dt, temp_k, wind);
1167        }
1168        self.sleet_particles.retain(|s| s.position.y > 0.0 && s.lifetime > 0.0);
1169
1170        // Update thunder cells
1171        let updraft = self.global_intensity * 20.0;
1172        for cell in &mut self.thunder_cells {
1173            cell.tick(dt, humidity, updraft);
1174        }
1175        self.thunder_cells.retain(|c| c.is_alive());
1176
1177        // Surface water flow and evaporation
1178        self.surface.flow_water(dt);
1179        let evap = self.config.puddle_evap_rate * (1.0 + (temp_c * 0.05).max(0.0)) * dt;
1180        self.surface.evaporate(evap);
1181
1182        // Melt or freeze snow
1183        if temp_c > 0.0 {
1184            self.surface.melt_snow(temp_c, dt);
1185        } else {
1186            self.surface.freeze_water(-temp_c, dt);
1187        }
1188
1189        // Update snowpacks
1190        let temp_k = temp_c + 273.15;
1191        for sp in self.snowpacks.values_mut() {
1192            sp.tick(dt, temp_k);
1193        }
1194        self.snowpacks.retain(|_, sp| sp.total_depth() > 1e-6);
1195
1196        // Update ice sheets
1197        for ice in self.ice_sheets.values_mut() {
1198            ice.tick(dt, temp_k);
1199        }
1200        self.ice_sheets.retain(|_, ice| ice.is_alive());
1201
1202        // Update puddles
1203        let wind_spd = wind.length();
1204        for puddle in &mut self.puddles {
1205            puddle.tick(dt, temp_c, wind_spd);
1206        }
1207        self.puddles.retain(|p| p.is_alive());
1208
1209        // Update events
1210        for ev in &mut self.events {
1211            ev.elapsed_s += dt;
1212        }
1213        self.events.retain(|e| !e.is_finished());
1214
1215        // Total precipitation accumulation
1216        self.total_precip_m += self.global_intensity * 5e-6 * dt;
1217    }
1218
1219    fn classify_precip(&self, temp_c: f32, humidity: f32, pressure_pa: f32) -> PrecipitationType {
1220        if humidity < self.config.rain_humidity_threshold { return PrecipitationType::None; }
1221        // High pressure suppresses precipitation
1222        if pressure_pa > 102_500.0 { return PrecipitationType::None; }
1223        if temp_c < self.config.hail_threshold_c { return PrecipitationType::Hail; }
1224        if temp_c < 0.0 { return PrecipitationType::Sleet; }
1225        if temp_c < self.config.snow_threshold_c { return PrecipitationType::Snow; }
1226        if humidity < 0.85 { return PrecipitationType::Drizzle; }
1227        PrecipitationType::Rain
1228    }
1229
1230    fn update_rain_bands(&mut self, dt: f32, kind: PrecipitationType) {
1231        for band in &mut self.rain_bands {
1232            band.tick(dt);
1233        }
1234        self.rain_bands.retain(|b| b.is_alive());
1235
1236        // Spawn a new band if intensity is high and we have few bands
1237        if self.global_intensity > 0.2 && self.rain_bands.len() < 4 {
1238            let cx = (value_noise_2d(self.noise_t, 0.3) * 2.0 - 1.0) * 5000.0;
1239            let cz = (value_noise_2d(0.7, self.noise_t + 1.0) * 2.0 - 1.0) * 5000.0;
1240            let band = RainBand::new(cx, cz, 3_000.0, self.global_intensity * 80.0, kind);
1241            self.rain_bands.push(band);
1242        }
1243
1244        // Possibly spawn thunder cells
1245        if kind == PrecipitationType::Rain && self.global_intensity > 0.6 && self.thunder_cells.is_empty() {
1246            let cx = (value_noise_2d(self.noise_t * 1.3, 0.0) * 2.0 - 1.0) * 8000.0;
1247            let cz = (value_noise_2d(0.0, self.noise_t * 1.3 + 2.0) * 2.0 - 1.0) * 8000.0;
1248            self.thunder_cells.push(ThunderCell::new(cx, cz));
1249        }
1250    }
1251
1252    fn spawn_droplets(&mut self, dt: f32, kind: PrecipitationType, wind: Vec3, temp_c: f32) {
1253        if self.droplets.len() >= self.config.max_droplets { return; }
1254        let spawn_rate = self.global_intensity * 50.0;
1255        self.spawn_accum += spawn_rate * dt;
1256        let to_spawn = self.spawn_accum as usize;
1257        self.spawn_accum -= to_spawn as f32;
1258
1259        for i in 0..to_spawn {
1260            if self.droplets.len() >= self.config.max_droplets { break; }
1261            let offset_x = (value_noise_2d(self.noise_t + i as f32 * 0.1, 0.0) * 2.0 - 1.0) * 200.0;
1262            let offset_z = (value_noise_2d(0.0, self.noise_t + i as f32 * 0.1 + 50.0) * 2.0 - 1.0) * 200.0;
1263            let spawn_y  = 300.0 + value_noise_2d(self.noise_t, i as f32) * 200.0;
1264            let pos = Vec3::new(offset_x + wind.x * 2.0, spawn_y, offset_z + wind.z * 2.0);
1265
1266            let drop = match kind {
1267                PrecipitationType::Rain | PrecipitationType::Drizzle => Droplet::new_rain(pos),
1268                PrecipitationType::Snow  => Droplet::new_snow(pos),
1269                PrecipitationType::Hail  => {
1270                    self.hailstones.push(HailStone::new(pos));
1271                    continue;
1272                }
1273                PrecipitationType::Sleet => {
1274                    self.sleet_particles.push(SleetParticle::new(pos));
1275                    continue;
1276                }
1277                PrecipitationType::None  => continue,
1278            };
1279            self.droplets.push(drop);
1280        }
1281    }
1282
1283    fn handle_landing(&mut self, pos: Vec3, kind: PrecipitationType, temp_c: f32) {
1284        match kind {
1285            PrecipitationType::Rain | PrecipitationType::Drizzle => {
1286                let depth = 1e-5;
1287                self.surface.add_water(pos.x, pos.z, depth);
1288                self.total_precip_m += depth;
1289                // Form or grow puddles
1290                let cell_water = self.surface.water_at(pos.x, pos.z);
1291                if cell_water > 0.005 {
1292                    // Check if there's a nearby puddle
1293                    let mut found = false;
1294                    for puddle in &mut self.puddles {
1295                        if puddle.contains(pos.x, pos.z) {
1296                            puddle.add_water(1e-6);
1297                            found = true;
1298                            break;
1299                        }
1300                    }
1301                    if !found && self.puddles.len() < 256 {
1302                        self.puddles.push(Puddle::new(pos.x, pos.z, 1e-4));
1303                    }
1304                }
1305            }
1306            PrecipitationType::Snow => {
1307                let key = (
1308                    (pos.x / self.config.cell_size).floor() as i32,
1309                    (pos.z / self.config.cell_size).floor() as i32,
1310                );
1311                let sp = self.snowpacks.entry(key).or_insert_with(Snowpack::new);
1312                sp.deposit(5e-6, temp_c + 273.15);
1313                self.surface.add_snow(pos.x, pos.z, 5e-6);
1314                self.total_precip_m += 5e-6 * 0.1;
1315            }
1316            PrecipitationType::Sleet => {
1317                self.surface.add_ice(pos.x, pos.z, 1e-6);
1318                let key = (
1319                    (pos.x / self.config.cell_size).floor() as i32,
1320                    (pos.z / self.config.cell_size).floor() as i32,
1321                );
1322                self.ice_sheets.entry(key)
1323                    .or_insert_with(|| IceSheet::new(0.0, temp_c + 273.15, temp_c < -1.0))
1324                    .thickness_m += 1e-6;
1325            }
1326            _ => {}
1327        }
1328    }
1329
1330    // ── Queries ───────────────────────────────────────────────────────────────
1331
1332    /// Total precipitation intensity (0–1) at world position.
1333    pub fn intensity_at(&self, x: f32, z: f32) -> f32 {
1334        let base = self.global_intensity;
1335        let band_contrib: f32 = self.rain_bands.iter()
1336            .map(|b| b.intensity_at(x, z))
1337            .fold(0.0_f32, f32::max);
1338        let noise = fbm_2d(x * 0.0001 + self.noise_t, z * 0.0001, 3, 2.0, 0.5) * 0.2;
1339        (base * 0.5 + band_contrib * 0.5 + noise).clamp(0.0, 1.0)
1340    }
1341
1342    pub fn dominant_type(&self) -> PrecipitationType {
1343        self.dominant_kind
1344    }
1345
1346    /// Return the nearest thunder cell if within range.
1347    pub fn nearest_thunder_cell(&self, x: f32, z: f32, max_dist: f32) -> Option<&ThunderCell> {
1348        self.thunder_cells.iter().filter(|c| {
1349            let dx = c.position[0] - x;
1350            let dz = c.position[1] - z;
1351            (dx * dx + dz * dz).sqrt() < max_dist
1352        }).min_by(|a, b| {
1353            let da = { let dx = a.position[0]-x; let dz = a.position[1]-z; dx*dx+dz*dz };
1354            let db = { let dx = b.position[0]-x; let dz = b.position[1]-z; dx*dx+dz*dz };
1355            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1356        })
1357    }
1358
1359    /// Manually trigger a precipitation event.
1360    pub fn trigger_event(&mut self, kind: PrecipitationType, intensity: f32, duration_s: f32, cx: f32, cz: f32, radius: f32) {
1361        self.events.push(PrecipitationEvent {
1362            kind,
1363            intensity,
1364            duration_s,
1365            elapsed_s: 0.0,
1366            centre: [cx, cz],
1367            radius,
1368        });
1369        self.rain_bands.push(RainBand::new(cx, cz, radius, intensity * 80.0, kind));
1370    }
1371
1372    /// How much snow (m depth) is at world position.
1373    pub fn snow_depth_at(&self, x: f32, z: f32) -> f32 {
1374        self.surface.snow_at(x, z)
1375    }
1376
1377    /// How much ice (m thickness) is at world position.
1378    pub fn ice_thickness_at(&self, x: f32, z: f32) -> f32 {
1379        self.surface.ice_at(x, z)
1380    }
1381
1382    /// How much water (m depth) is at world position.
1383    pub fn water_depth_at(&self, x: f32, z: f32) -> f32 {
1384        self.surface.water_at(x, z)
1385    }
1386}
1387
1388impl Default for PrecipitationSystem {
1389    fn default() -> Self { Self::new() }
1390}