Skip to main content

proof_engine/physics/
fluid.rs

1//! Eulerian grid-based fluid simulation (velocity + pressure + density).
2//!
3//! Implements a simplified Navier-Stokes solver on a regular 2D grid using:
4//! - Semi-Lagrangian advection (backtracing)
5//! - Gauss-Seidel pressure projection (incompressibility)
6//! - Vorticity confinement for turbulence detail
7//! - Density/color field advection for visual smoke/fluid
8//!
9//! Grid coordinates are cell-centered. Velocity is stored at cell centers
10//! (MAC grid variant not required for this simulation scale).
11//!
12//! ## Quick Start
13//! ```rust,no_run
14//! use proof_engine::physics::fluid::FluidGrid;
15//! let mut fluid = FluidGrid::new(64, 64, 1.0 / 64.0);
16//! fluid.add_velocity(32, 32, 0.0, 5.0);
17//! fluid.add_density(32, 32, 1.0);
18//! fluid.step(0.016);
19//! ```
20
21use glam::Vec2;
22
23// ── Constants ─────────────────────────────────────────────────────────────────
24
25const PRESSURE_ITERATIONS: usize = 20;
26const DIFFUSION_ITERATIONS: usize = 4;
27
28// ── Grid helpers ──────────────────────────────────────────────────────────────
29
30/// Clamp to valid cell index.
31#[inline]
32fn clamp_idx(v: i32, max: i32) -> usize {
33    v.clamp(0, max - 1) as usize
34}
35
36/// Bilinear interpolation from a flat grid array.
37fn bilinear(grid: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
38    let x0 = (x.floor() as i32).clamp(0, w as i32 - 2) as usize;
39    let y0 = (y.floor() as i32).clamp(0, h as i32 - 2) as usize;
40    let x1 = (x0 + 1).min(w - 1);
41    let y1 = (y0 + 1).min(h - 1);
42    let tx = x.fract().clamp(0.0, 1.0);
43    let ty = y.fract().clamp(0.0, 1.0);
44    let v00 = grid[y0 * w + x0];
45    let v10 = grid[y0 * w + x1];
46    let v01 = grid[y1 * w + x0];
47    let v11 = grid[y1 * w + x1];
48    let a = v00 + tx * (v10 - v00);
49    let b = v01 + tx * (v11 - v01);
50    a + ty * (b - a)
51}
52
53// ── FluidGrid ─────────────────────────────────────────────────────────────────
54
55/// 2D Eulerian fluid simulation grid.
56#[derive(Debug, Clone)]
57pub struct FluidGrid {
58    /// Grid width in cells.
59    pub width:  usize,
60    /// Grid height in cells.
61    pub height: usize,
62    /// Physical size of one cell (world units).
63    pub dx:     f32,
64
65    /// Velocity X component (cell-centered).
66    pub vx:  Vec<f32>,
67    /// Velocity Y component (cell-centered).
68    pub vy:  Vec<f32>,
69    /// Density / smoke field.
70    pub density:  Vec<f32>,
71    /// Temperature field (buoyancy driving).
72    pub temp:     Vec<f32>,
73    /// Color R channel for multi-fluid visualization.
74    pub color_r:  Vec<f32>,
75    /// Color G channel.
76    pub color_g:  Vec<f32>,
77    /// Color B channel.
78    pub color_b:  Vec<f32>,
79    /// Pressure field (intermediate, used in projection).
80    pub pressure: Vec<f32>,
81    /// Divergence field (intermediate).
82    divergence: Vec<f32>,
83    /// Obstacle / solid mask: 1.0 = fluid, 0.0 = solid.
84    pub obstacle: Vec<f32>,
85
86    // scratch buffers (avoid allocation each step)
87    vx0:     Vec<f32>,
88    vy0:     Vec<f32>,
89    dens0:   Vec<f32>,
90    temp0:   Vec<f32>,
91    col0_r:  Vec<f32>,
92    col0_g:  Vec<f32>,
93    col0_b:  Vec<f32>,
94
95    /// Kinematic viscosity (m^2/s). Default 1e-4.
96    pub viscosity:  f32,
97    /// Density diffusion coefficient.
98    pub diffusion:  f32,
99    /// Buoyancy lift coefficient (hot gas rises).
100    pub buoyancy:   f32,
101    /// Ambient temperature. Cells above this experience upward force.
102    pub ambient_temp: f32,
103    /// Vorticity confinement strength (adds swirl detail). Default 0.3.
104    pub vorticity_strength: f32,
105    /// Gravity direction and magnitude.
106    pub gravity:    Vec2,
107    /// Global density decay per second (smoke dissipation).
108    pub decay:      f32,
109
110    /// Seconds elapsed since creation.
111    pub time: f32,
112}
113
114impl FluidGrid {
115    /// Create a new fluid grid of given dimensions and cell size.
116    pub fn new(width: usize, height: usize, dx: f32) -> Self {
117        let n = width * height;
118        let ones = vec![1.0_f32; n];
119        Self {
120            width, height, dx,
121            vx:       vec![0.0; n],
122            vy:       vec![0.0; n],
123            density:  vec![0.0; n],
124            temp:     vec![20.0; n],  // 20°C ambient
125            color_r:  vec![0.0; n],
126            color_g:  vec![0.0; n],
127            color_b:  vec![0.0; n],
128            pressure: vec![0.0; n],
129            divergence: vec![0.0; n],
130            obstacle: ones,
131            vx0:      vec![0.0; n],
132            vy0:      vec![0.0; n],
133            dens0:    vec![0.0; n],
134            temp0:    vec![0.0; n],
135            col0_r:   vec![0.0; n],
136            col0_g:   vec![0.0; n],
137            col0_b:   vec![0.0; n],
138            viscosity:  1e-4,
139            diffusion:  1e-5,
140            buoyancy:   0.15,
141            ambient_temp: 20.0,
142            vorticity_strength: 0.3,
143            gravity:    Vec2::new(0.0, -9.8),
144            decay:      0.995,
145            time:       0.0,
146        }
147    }
148
149    // ── Cell addressing ────────────────────────────────────────────────────────
150
151    #[inline]
152    pub fn idx(&self, x: usize, y: usize) -> usize {
153        y * self.width + x
154    }
155
156    #[inline]
157    fn ix(&self, x: i32, y: i32) -> usize {
158        let cx = x.clamp(0, self.width as i32 - 1) as usize;
159        let cy = y.clamp(0, self.height as i32 - 1) as usize;
160        cy * self.width + cx
161    }
162
163    // ── Source injection ───────────────────────────────────────────────────────
164
165    /// Add velocity impulse at cell (cx, cy).
166    pub fn add_velocity(&mut self, cx: usize, cy: usize, dvx: f32, dvy: f32) {
167        if cx < self.width && cy < self.height {
168            let i = self.idx(cx, cy);
169            self.vx[i] += dvx;
170            self.vy[i] += dvy;
171        }
172    }
173
174    /// Add density at cell.
175    pub fn add_density(&mut self, cx: usize, cy: usize, amount: f32) {
176        if cx < self.width && cy < self.height {
177            let i = self.idx(cx, cy);
178            self.density[i] = (self.density[i] + amount).clamp(0.0, 10.0);
179        }
180    }
181
182    /// Add temperature at cell.
183    pub fn add_temperature(&mut self, cx: usize, cy: usize, dt: f32) {
184        if cx < self.width && cy < self.height {
185            let i = self.idx(cx, cy);
186            self.temp[i] += dt;
187        }
188    }
189
190    /// Add colored smoke/fluid at cell.
191    pub fn add_color(&mut self, cx: usize, cy: usize, r: f32, g: f32, b: f32) {
192        if cx < self.width && cy < self.height {
193            let i = self.idx(cx, cy);
194            self.color_r[i] = (self.color_r[i] + r).clamp(0.0, 1.0);
195            self.color_g[i] = (self.color_g[i] + g).clamp(0.0, 1.0);
196            self.color_b[i] = (self.color_b[i] + b).clamp(0.0, 1.0);
197        }
198    }
199
200    /// Paint a circular solid obstacle.
201    pub fn add_obstacle_circle(&mut self, cx: f32, cy: f32, radius: f32) {
202        let w = self.width;
203        let h = self.height;
204        for y in 0..h {
205            for x in 0..w {
206                let dx = x as f32 - cx;
207                let dy = y as f32 - cy;
208                if dx * dx + dy * dy <= radius * radius {
209                    let i = y * w + x;
210                    self.obstacle[i] = 0.0;
211                    self.vx[i] = 0.0;
212                    self.vy[i] = 0.0;
213                }
214            }
215        }
216    }
217
218    /// Paint a rectangular solid obstacle.
219    pub fn add_obstacle_rect(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
220        for y in y0.min(self.height)..y1.min(self.height) {
221            for x in x0.min(self.width)..x1.min(self.width) {
222                let i = y * self.width + x;
223                self.obstacle[i] = 0.0;
224                self.vx[i] = 0.0;
225                self.vy[i] = 0.0;
226            }
227        }
228    }
229
230    // ── Simulation step ────────────────────────────────────────────────────────
231
232    /// Advance the simulation by `dt` seconds.
233    pub fn step(&mut self, dt: f32) {
234        self.time += dt;
235
236        // 1. Add external forces (gravity, buoyancy) to velocity field
237        self.apply_forces(dt);
238
239        // 2. Vorticity confinement (swirl amplification)
240        if self.vorticity_strength > 0.0 {
241            self.apply_vorticity_confinement(dt);
242        }
243
244        // 3. Diffuse velocity (viscosity)
245        if self.viscosity > 0.0 {
246            self.diffuse_velocity(dt);
247        }
248
249        // 4. Project velocity to be divergence-free
250        self.project(dt);
251
252        // 5. Advect velocity field (semi-Lagrangian)
253        self.advect_velocity(dt);
254
255        // 6. Project again after advection
256        self.project(dt);
257
258        // 7. Diffuse density (stub — scalar diffusion handled in advection)
259        let _ = self.diffusion;
260
261        // 8. Advect density and color
262        self.advect_scalars(dt);
263
264        // 9. Apply decay
265        let decay = self.decay.powf(dt);
266        for v in &mut self.density  { *v *= decay; }
267        for v in &mut self.color_r  { *v *= decay; }
268        for v in &mut self.color_g  { *v *= decay; }
269        for v in &mut self.color_b  { *v *= decay; }
270
271        // 10. Cool temperature toward ambient
272        let cool = (-0.5 * dt).exp();
273        let ambient = self.ambient_temp;
274        for v in &mut self.temp {
275            *v = ambient + (*v - ambient) * cool;
276        }
277
278        // 11. Zero out obstacle velocities
279        let w = self.width;
280        let h = self.height;
281        for y in 0..h {
282            for x in 0..w {
283                let i = y * w + x;
284                if self.obstacle[i] == 0.0 {
285                    self.vx[i] = 0.0;
286                    self.vy[i] = 0.0;
287                }
288            }
289        }
290    }
291
292    fn make_dens_swap_vec(v: Vec<f32>) -> Vec<f32> { v }
293
294    // ── Force application ──────────────────────────────────────────────────────
295
296    fn apply_forces(&mut self, dt: f32) {
297        let w = self.width;
298        let h = self.height;
299        let gx = self.gravity.x * dt;
300        let gy = self.gravity.y * dt;
301        let buoy = self.buoyancy * dt;
302        let amb = self.ambient_temp;
303        for y in 0..h {
304            for x in 0..w {
305                let i = y * w + x;
306                if self.obstacle[i] == 0.0 { continue; }
307                self.vx[i] += gx;
308                self.vy[i] += gy;
309                // Buoyancy: hot cells rise (positive vy), cooler cells sink
310                let delta_t = self.temp[i] - amb;
311                self.vy[i] += buoy * delta_t;
312                // Density drag: dense fluid moves slower
313                let drag = 1.0 - 0.01 * self.density[i].clamp(0.0, 5.0);
314                self.vx[i] *= drag;
315                self.vy[i] *= drag;
316            }
317        }
318    }
319
320    // ── Vorticity confinement ──────────────────────────────────────────────────
321
322    fn apply_vorticity_confinement(&mut self, dt: f32) {
323        let w = self.width;
324        let h = self.height;
325        let strength = self.vorticity_strength * dt;
326
327        // Compute curl (vorticity) at each cell
328        let mut curl = vec![0.0_f32; w * h];
329        for y in 1..h - 1 {
330            for x in 1..w - 1 {
331                let dvx_dy = self.vx[self.ix(x as i32, y as i32 + 1)]
332                           - self.vx[self.ix(x as i32, y as i32 - 1)];
333                let dvy_dx = self.vy[self.ix(x as i32 + 1, y as i32)]
334                           - self.vy[self.ix(x as i32 - 1, y as i32)];
335                curl[y * w + x] = (dvy_dx - dvx_dy) * 0.5;
336            }
337        }
338
339        // Compute gradient of |curl| and apply confinement force
340        for y in 1..h - 1 {
341            for x in 1..w - 1 {
342                let i = y * w + x;
343                if self.obstacle[i] == 0.0 { continue; }
344                let dw_dx = curl[self.ix(x as i32 + 1, y as i32)].abs()
345                          - curl[self.ix(x as i32 - 1, y as i32)].abs();
346                let dw_dy = curl[self.ix(x as i32, y as i32 + 1)].abs()
347                          - curl[self.ix(x as i32, y as i32 - 1)].abs();
348                let len = (dw_dx * dw_dx + dw_dy * dw_dy).sqrt() + 1e-5;
349                let nx = dw_dx / len;
350                let ny = dw_dy / len;
351                let c = curl[i];
352                self.vx[i] += strength * ny * c;
353                self.vy[i] -= strength * nx * c;
354            }
355        }
356    }
357
358    // ── Velocity diffusion (Gauss-Seidel) ─────────────────────────────────────
359
360    fn diffuse_velocity(&mut self, dt: f32) {
361        let a = dt * self.viscosity * (self.width * self.height) as f32;
362        let w = self.width;
363        let h = self.height;
364        let obs = self.obstacle.clone();
365        let vx_src = self.vx.clone();
366        let vy_src = self.vy.clone();
367        for _ in 0..DIFFUSION_ITERATIONS {
368            for y in 1..h - 1 {
369                for x in 1..w - 1 {
370                    let i = y * w + x;
371                    if obs[i] == 0.0 { continue; }
372                    let n = self.ix(x as i32, y as i32 + 1);
373                    let s = self.ix(x as i32, y as i32 - 1);
374                    let e = self.ix(x as i32 + 1, y as i32);
375                    let ww = self.ix(x as i32 - 1, y as i32);
376                    self.vx[i] = (vx_src[i] + a * (self.vx[n] + self.vx[s]
377                                                    + self.vx[e] + self.vx[ww]))
378                               / (1.0 + 4.0 * a);
379                    self.vy[i] = (vy_src[i] + a * (self.vy[n] + self.vy[s]
380                                                    + self.vy[e] + self.vy[ww]))
381                               / (1.0 + 4.0 * a);
382                }
383            }
384            self.set_boundary_velocity();
385        }
386    }
387
388    // ── Pressure projection ────────────────────────────────────────────────────
389
390    fn project(&mut self, _dt: f32) {
391        let w = self.width;
392        let h = self.height;
393
394        // Compute divergence
395        for y in 1..h - 1 {
396            for x in 1..w - 1 {
397                let i = y * w + x;
398                if self.obstacle[i] == 0.0 {
399                    self.divergence[i] = 0.0;
400                    self.pressure[i] = 0.0;
401                    continue;
402                }
403                let dvx = self.vx[self.ix(x as i32 + 1, y as i32)]
404                        - self.vx[self.ix(x as i32 - 1, y as i32)];
405                let dvy = self.vy[self.ix(x as i32, y as i32 + 1)]
406                        - self.vy[self.ix(x as i32, y as i32 - 1)];
407                self.divergence[i] = -0.5 * (dvx + dvy) / self.dx;
408                self.pressure[i] = 0.0;
409            }
410        }
411        self.set_boundary_scalar_inplace_pressure();
412
413        // Gauss-Seidel pressure solve
414        let div = self.divergence.clone();
415        let obs = self.obstacle.clone();
416        for _ in 0..PRESSURE_ITERATIONS {
417            for y in 1..h - 1 {
418                for x in 1..w - 1 {
419                    let i = y * w + x;
420                    if obs[i] == 0.0 { continue; }
421                    let pn = self.pressure[self.ix(x as i32, y as i32 + 1)];
422                    let ps = self.pressure[self.ix(x as i32, y as i32 - 1)];
423                    let pe = self.pressure[self.ix(x as i32 + 1, y as i32)];
424                    let pw = self.pressure[self.ix(x as i32 - 1, y as i32)];
425                    self.pressure[i] = (div[i] + pn + ps + pe + pw) / 4.0;
426                }
427            }
428            self.set_boundary_scalar_inplace_pressure();
429        }
430
431        // Subtract pressure gradient from velocity
432        for y in 1..h - 1 {
433            for x in 1..w - 1 {
434                let i = y * w + x;
435                if obs[i] == 0.0 { continue; }
436                let dp_dx = self.pressure[self.ix(x as i32 + 1, y as i32)]
437                          - self.pressure[self.ix(x as i32 - 1, y as i32)];
438                let dp_dy = self.pressure[self.ix(x as i32, y as i32 + 1)]
439                          - self.pressure[self.ix(x as i32, y as i32 - 1)];
440                self.vx[i] -= 0.5 * dp_dx * self.dx;
441                self.vy[i] -= 0.5 * dp_dy * self.dx;
442            }
443        }
444        self.set_boundary_velocity();
445    }
446
447    // ── Semi-Lagrangian advection ──────────────────────────────────────────────
448
449    fn advect_velocity(&mut self, dt: f32) {
450        let w = self.width;
451        let h = self.height;
452        let dt0 = dt / self.dx;
453
454        self.vx0.copy_from_slice(&self.vx);
455        self.vy0.copy_from_slice(&self.vy);
456
457        for y in 1..h - 1 {
458            for x in 1..w - 1 {
459                let i = y * w + x;
460                if self.obstacle[i] == 0.0 { continue; }
461                let bx = x as f32 - dt0 * self.vx0[i];
462                let by = y as f32 - dt0 * self.vy0[i];
463                self.vx[i] = bilinear(&self.vx0, w, h, bx, by);
464                self.vy[i] = bilinear(&self.vy0, w, h, bx, by);
465            }
466        }
467        self.set_boundary_velocity();
468    }
469
470    fn advect_scalars(&mut self, dt: f32) {
471        let w = self.width;
472        let h = self.height;
473        let dt0 = dt / self.dx;
474
475        self.dens0.copy_from_slice(&self.density);
476        self.temp0.copy_from_slice(&self.temp);
477        self.col0_r.copy_from_slice(&self.color_r);
478        self.col0_g.copy_from_slice(&self.color_g);
479        self.col0_b.copy_from_slice(&self.color_b);
480
481        for y in 1..h - 1 {
482            for x in 1..w - 1 {
483                let i = y * w + x;
484                if self.obstacle[i] == 0.0 { continue; }
485                let bx = x as f32 - dt0 * self.vx[i];
486                let by = y as f32 - dt0 * self.vy[i];
487                self.density[i] = bilinear(&self.dens0, w, h, bx, by);
488                self.temp[i]    = bilinear(&self.temp0, w, h, bx, by);
489                self.color_r[i] = bilinear(&self.col0_r, w, h, bx, by);
490                self.color_g[i] = bilinear(&self.col0_g, w, h, bx, by);
491                self.color_b[i] = bilinear(&self.col0_b, w, h, bx, by);
492            }
493        }
494    }
495
496    // ── Scalar diffusion (generic) ─────────────────────────────────────────────
497
498    fn diffuse_scalar(field: &mut Vec<f32>, diff: f32, dt: f32, obs: &[f32]) {
499        // placeholder: simple Laplacian diffusion inline
500        let _ = (field, diff, dt, obs);
501    }
502
503    // ── Boundary conditions ────────────────────────────────────────────────────
504
505    fn set_boundary_velocity(&mut self) {
506        let w = self.width;
507        let h = self.height;
508        // Reflect at borders — compute indices before indexing to avoid aliased borrows
509        for x in 0..w {
510            let i00 = x;                // y=0
511            let i01 = w + x;            // y=1
512            let ih1 = (h-1)*w + x;      // y=h-1
513            let ih2 = (h-2)*w + x;      // y=h-2
514            self.vy[i00] = -self.vy[i01];
515            self.vy[ih1] = -self.vy[ih2];
516            self.vx[i00] =  self.vx[i01];
517            self.vx[ih1] =  self.vx[ih2];
518        }
519        for y in 0..h {
520            let i00 = y*w;          // x=0
521            let i01 = y*w + 1;      // x=1
522            let iw1 = y*w + w - 1;  // x=w-1
523            let iw2 = y*w + w - 2;  // x=w-2
524            self.vx[i00] = -self.vx[i01];
525            self.vx[iw1] = -self.vx[iw2];
526            self.vy[i00] =  self.vy[i01];
527            self.vy[iw1] =  self.vy[iw2];
528        }
529    }
530
531    fn set_boundary_scalar_inplace_pressure(&mut self) {
532        let w = self.width;
533        let h = self.height;
534        for x in 0..w {
535            let i00 = x;
536            let i01 = w + x;
537            let ih1 = (h-1)*w + x;
538            let ih2 = (h-2)*w + x;
539            self.pressure[i00] = self.pressure[i01];
540            self.pressure[ih1] = self.pressure[ih2];
541        }
542        for y in 0..h {
543            let i00 = y*w;
544            let i01 = y*w + 1;
545            let iw1 = y*w + w - 1;
546            let iw2 = y*w + w - 2;
547            self.pressure[i00] = self.pressure[i01];
548            self.pressure[iw1] = self.pressure[iw2];
549        }
550    }
551
552    // ── Queries ────────────────────────────────────────────────────────────────
553
554    /// Sample velocity at world position (bilinear).
555    pub fn sample_velocity(&self, wx: f32, wy: f32) -> Vec2 {
556        let cx = wx / self.dx;
557        let cy = wy / self.dx;
558        let vx = bilinear(&self.vx, self.width, self.height, cx, cy);
559        let vy = bilinear(&self.vy, self.width, self.height, cx, cy);
560        Vec2::new(vx, vy)
561    }
562
563    /// Sample density at world position.
564    pub fn sample_density(&self, wx: f32, wy: f32) -> f32 {
565        let cx = wx / self.dx;
566        let cy = wy / self.dx;
567        bilinear(&self.density, self.width, self.height, cx, cy)
568    }
569
570    /// Sample color at world position.
571    pub fn sample_color(&self, wx: f32, wy: f32) -> (f32, f32, f32) {
572        let cx = wx / self.dx;
573        let cy = wy / self.dx;
574        let r = bilinear(&self.color_r, self.width, self.height, cx, cy);
575        let g = bilinear(&self.color_g, self.width, self.height, cx, cy);
576        let b = bilinear(&self.color_b, self.width, self.height, cx, cy);
577        (r, g, b)
578    }
579
580    /// Maximum velocity magnitude in the grid.
581    pub fn max_velocity(&self) -> f32 {
582        let w = self.width;
583        let h = self.height;
584        let mut max_sq = 0.0_f32;
585        for i in 0..w * h {
586            let sq = self.vx[i] * self.vx[i] + self.vy[i] * self.vy[i];
587            if sq > max_sq { max_sq = sq; }
588        }
589        max_sq.sqrt()
590    }
591
592    /// Total density integral (conservation check).
593    pub fn total_density(&self) -> f32 {
594        self.density.iter().sum()
595    }
596
597    /// Clear all velocity, density, and color fields.
598    pub fn clear(&mut self) {
599        let n = self.width * self.height;
600        self.vx.fill(0.0);
601        self.vy.fill(0.0);
602        self.density.fill(0.0);
603        self.color_r.fill(0.0);
604        self.color_g.fill(0.0);
605        self.color_b.fill(0.0);
606        self.pressure.fill(0.0);
607        self.divergence.fill(0.0);
608        let _ = n;
609    }
610
611    /// Apply a circular velocity splash (radial outward impulse).
612    pub fn splash(&mut self, cx: f32, cy: f32, radius: f32, strength: f32) {
613        let w = self.width;
614        let h = self.height;
615        for y in 0..h {
616            for x in 0..w {
617                let dx = x as f32 - cx;
618                let dy = y as f32 - cy;
619                let dist = (dx * dx + dy * dy).sqrt();
620                if dist < radius && dist > 0.01 {
621                    let i = y * w + x;
622                    let factor = (1.0 - dist / radius) * strength;
623                    self.vx[i] += dx / dist * factor;
624                    self.vy[i] += dy / dist * factor;
625                }
626            }
627        }
628    }
629
630    /// Apply a swirl vortex impulse (clockwise tangential velocity).
631    pub fn swirl(&mut self, cx: f32, cy: f32, radius: f32, strength: f32) {
632        let w = self.width;
633        let h = self.height;
634        for y in 0..h {
635            for x in 0..w {
636                let dx = x as f32 - cx;
637                let dy = y as f32 - cy;
638                let dist = (dx * dx + dy * dy).sqrt();
639                if dist < radius && dist > 0.01 {
640                    let i = y * w + x;
641                    let factor = (1.0 - dist / radius) * strength;
642                    // Tangential: rotate (dx, dy) by 90°
643                    self.vx[i] += -dy / dist * factor;
644                    self.vy[i] +=  dx / dist * factor;
645                }
646            }
647        }
648    }
649
650    /// Resize the grid (clears all data).
651    pub fn resize(&mut self, new_width: usize, new_height: usize) {
652        *self = FluidGrid::new(new_width, new_height, self.dx);
653    }
654
655    /// Render the density field as a flat RGBA byte buffer (width × height × 4).
656    /// Useful for debug visualization.
657    pub fn to_rgba_density(&self, tint: (u8, u8, u8)) -> Vec<u8> {
658        let n = self.width * self.height;
659        let mut out = vec![0u8; n * 4];
660        for i in 0..n {
661            let d = (self.density[i].clamp(0.0, 1.0) * 255.0) as u8;
662            out[i * 4    ] = (tint.0 as f32 * self.density[i].clamp(0.0, 1.0)) as u8;
663            out[i * 4 + 1] = (tint.1 as f32 * self.density[i].clamp(0.0, 1.0)) as u8;
664            out[i * 4 + 2] = (tint.2 as f32 * self.density[i].clamp(0.0, 1.0)) as u8;
665            out[i * 4 + 3] = d;
666        }
667        out
668    }
669
670    /// Render the velocity field as a flat RGBA byte buffer (speed-encoded).
671    pub fn to_rgba_velocity(&self) -> Vec<u8> {
672        let n = self.width * self.height;
673        let max_v = self.max_velocity().max(0.01);
674        let mut out = vec![0u8; n * 4];
675        for i in 0..n {
676            let speed = (self.vx[i] * self.vx[i] + self.vy[i] * self.vy[i])
677                .sqrt() / max_v;
678            let angle = self.vy[i].atan2(self.vx[i]); // [-π, π]
679            let hue = (angle / std::f32::consts::TAU + 0.5).fract(); // [0, 1]
680            // HSV to RGB (saturation=1, value=speed)
681            let (r, g, b) = hsv_to_rgb(hue, 1.0, speed);
682            out[i * 4    ] = (r * 255.0) as u8;
683            out[i * 4 + 1] = (g * 255.0) as u8;
684            out[i * 4 + 2] = (b * 255.0) as u8;
685            out[i * 4 + 3] = 255;
686        }
687        out
688    }
689}
690
691fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
692    let i = (h * 6.0) as u32;
693    let f = h * 6.0 - i as f32;
694    let p = v * (1.0 - s);
695    let q = v * (1.0 - f * s);
696    let t = v * (1.0 - (1.0 - f) * s);
697    match i % 6 {
698        0 => (v, t, p),
699        1 => (q, v, p),
700        2 => (p, v, t),
701        3 => (p, q, v),
702        4 => (t, p, v),
703        _ => (v, p, q),
704    }
705}
706
707// ── FluidParams (preset configurations) ───────────────────────────────────────
708
709/// Preset fluid simulation configurations.
710pub struct FluidParams {
711    pub viscosity:          f32,
712    pub diffusion:          f32,
713    pub buoyancy:           f32,
714    pub vorticity_strength: f32,
715    pub decay:              f32,
716    pub gravity:            Vec2,
717}
718
719impl FluidParams {
720    /// Thin smoke rising upward.
721    pub fn smoke() -> Self {
722        Self {
723            viscosity:          1e-5,
724            diffusion:          1e-6,
725            buoyancy:           0.3,
726            vorticity_strength: 0.5,
727            decay:              0.998,
728            gravity:            Vec2::new(0.0, 0.0),
729        }
730    }
731
732    /// Thick viscous lava flow.
733    pub fn lava() -> Self {
734        Self {
735            viscosity:          0.01,
736            diffusion:          0.001,
737            buoyancy:           0.05,
738            vorticity_strength: 0.1,
739            decay:              0.9995,
740            gravity:            Vec2::new(0.0, -2.0),
741        }
742    }
743
744    /// Water (low viscosity, strong gravity).
745    pub fn water() -> Self {
746        Self {
747            viscosity:          1e-4,
748            diffusion:          0.0,
749            buoyancy:           0.0,
750            vorticity_strength: 0.2,
751            decay:              1.0,
752            gravity:            Vec2::new(0.0, -9.8),
753        }
754    }
755
756    /// Magical aether — no gravity, high vorticity, slow decay.
757    pub fn aether() -> Self {
758        Self {
759            viscosity:          5e-5,
760            diffusion:          2e-5,
761            buoyancy:           0.1,
762            vorticity_strength: 1.0,
763            decay:              0.9999,
764            gravity:            Vec2::ZERO,
765        }
766    }
767
768    /// Apply these params to a FluidGrid.
769    pub fn apply(&self, grid: &mut FluidGrid) {
770        grid.viscosity          = self.viscosity;
771        grid.diffusion          = self.diffusion;
772        grid.buoyancy           = self.buoyancy;
773        grid.vorticity_strength = self.vorticity_strength;
774        grid.decay              = self.decay;
775        grid.gravity            = self.gravity;
776    }
777}
778
779// ── FluidRecorder ─────────────────────────────────────────────────────────────
780
781/// Records fluid grid frames for replay or analysis.
782pub struct FluidRecorder {
783    pub frames:  Vec<FluidFrame>,
784    pub max_frames: usize,
785}
786
787/// A single snapshot of the fluid state.
788pub struct FluidFrame {
789    pub time:    f32,
790    pub density: Vec<f32>,
791    pub vx:      Vec<f32>,
792    pub vy:      Vec<f32>,
793}
794
795impl FluidRecorder {
796    pub fn new(max_frames: usize) -> Self {
797        Self { frames: Vec::new(), max_frames }
798    }
799
800    /// Snapshot the current fluid state.
801    pub fn record(&mut self, grid: &FluidGrid) {
802        if self.frames.len() >= self.max_frames {
803            self.frames.remove(0);
804        }
805        self.frames.push(FluidFrame {
806            time:    grid.time,
807            density: grid.density.clone(),
808            vx:      grid.vx.clone(),
809            vy:      grid.vy.clone(),
810        });
811    }
812
813    /// Number of recorded frames.
814    pub fn len(&self) -> usize { self.frames.len() }
815    pub fn is_empty(&self) -> bool { self.frames.is_empty() }
816
817    /// Average density across all recorded frames at cell (x, y).
818    pub fn mean_density_at(&self, x: usize, y: usize, width: usize) -> f32 {
819        if self.frames.is_empty() { return 0.0; }
820        let i = y * width + x;
821        let sum: f32 = self.frames.iter().map(|f| f.density.get(i).copied().unwrap_or(0.0)).sum();
822        sum / self.frames.len() as f32
823    }
824
825    /// Restore a grid to a recorded frame by index.
826    pub fn restore(&self, grid: &mut FluidGrid, frame_idx: usize) -> bool {
827        if frame_idx >= self.frames.len() { return false; }
828        let f = &self.frames[frame_idx];
829        grid.density.copy_from_slice(&f.density);
830        grid.vx.copy_from_slice(&f.vx);
831        grid.vy.copy_from_slice(&f.vy);
832        grid.time = f.time;
833        true
834    }
835}
836
837// ── Unit tests ─────────────────────────────────────────────────────────────────
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn test_new_grid() {
845        let g = FluidGrid::new(32, 32, 1.0 / 32.0);
846        assert_eq!(g.width, 32);
847        assert_eq!(g.height, 32);
848        assert_eq!(g.density.len(), 1024);
849        assert!(g.total_density() < 1e-6);
850    }
851
852    #[test]
853    fn test_add_density_and_velocity() {
854        let mut g = FluidGrid::new(32, 32, 1.0 / 32.0);
855        g.add_density(16, 16, 1.0);
856        g.add_velocity(16, 16, 1.0, 0.0);
857        assert!(g.density[g.idx(16, 16)] > 0.9);
858        assert!(g.vx[g.idx(16, 16)] > 0.5);
859    }
860
861    #[test]
862    fn test_step_runs() {
863        let mut g = FluidGrid::new(16, 16, 1.0 / 16.0);
864        g.add_density(8, 8, 1.0);
865        g.add_velocity(8, 8, 0.0, 2.0);
866        for _ in 0..10 {
867            g.step(0.016);
868        }
869        // After stepping, density should have spread or moved
870        assert!(g.total_density() > 0.0);
871    }
872
873    #[test]
874    fn test_obstacle() {
875        let mut g = FluidGrid::new(32, 32, 1.0 / 32.0);
876        g.add_obstacle_rect(10, 10, 20, 20);
877        assert_eq!(g.obstacle[g.idx(15, 15)], 0.0);
878        assert_eq!(g.obstacle[g.idx(5, 5)], 1.0);
879    }
880
881    #[test]
882    fn test_max_velocity() {
883        let mut g = FluidGrid::new(16, 16, 1.0 / 16.0);
884        g.add_velocity(8, 8, 3.0, 4.0);
885        assert!((g.max_velocity() - 5.0).abs() < 0.01);
886    }
887
888    #[test]
889    fn test_rgba_density() {
890        let mut g = FluidGrid::new(4, 4, 0.25);
891        g.add_density(2, 2, 0.5);
892        let buf = g.to_rgba_density((255, 128, 0));
893        assert_eq!(buf.len(), 4 * 4 * 4);
894    }
895
896    #[test]
897    fn test_splash_and_swirl() {
898        let mut g = FluidGrid::new(32, 32, 1.0 / 32.0);
899        g.splash(16.0, 16.0, 8.0, 5.0);
900        g.swirl(16.0, 16.0, 8.0, 3.0);
901        assert!(g.max_velocity() > 0.0);
902    }
903
904    #[test]
905    fn test_clear() {
906        let mut g = FluidGrid::new(16, 16, 0.0625);
907        g.add_density(8, 8, 1.0);
908        g.add_velocity(8, 8, 2.0, 2.0);
909        g.clear();
910        assert!(g.total_density() < 1e-6);
911        assert!(g.max_velocity() < 1e-6);
912    }
913
914    #[test]
915    fn test_fluid_params_apply() {
916        let mut g = FluidGrid::new(16, 16, 0.0625);
917        FluidParams::smoke().apply(&mut g);
918        assert!(g.buoyancy > 0.2);
919        FluidParams::lava().apply(&mut g);
920        assert!(g.viscosity > 0.005);
921    }
922
923    #[test]
924    fn test_recorder() {
925        let mut g = FluidGrid::new(8, 8, 0.125);
926        let mut rec = FluidRecorder::new(10);
927        g.add_density(4, 4, 1.0);
928        rec.record(&g);
929        assert_eq!(rec.len(), 1);
930        g.clear();
931        assert!(g.total_density() < 1e-6);
932        let restored = rec.restore(&mut g, 0);
933        assert!(restored);
934        assert!(g.total_density() > 0.5);
935    }
936}