Skip to main content

phyz_gravity/
poisson.rs

1//! Poisson solver for density-dependent gravity (Layer 2).
2//!
3//! Solves Poisson equation on a grid:
4//!   ∇² Φ = 4π G ρ
5//!   g = -∇ Φ
6//!
7//! Currently implements naive finite-difference Jacobi iteration.
8//! Future: FFT for periodic boundaries, multigrid for non-periodic.
9
10use crate::{G, GravityParticle, GravitySolver};
11use phyz_math::Vec3;
12
13/// Poisson solver for local gravity fields.
14#[derive(Debug, Clone)]
15pub struct PoissonSolver {
16    /// Grid resolution (cells per dimension).
17    pub resolution: usize,
18    /// Grid bounds (min, max).
19    pub bounds: (Vec3, Vec3),
20    /// Density grid (kg/m³).
21    pub density: Vec<f64>,
22    /// Potential grid Φ (m²/s²).
23    pub potential: Vec<f64>,
24    /// Max Jacobi iterations.
25    pub max_iter: usize,
26    /// Convergence tolerance.
27    pub tolerance: f64,
28}
29
30impl PoissonSolver {
31    /// Create a new Poisson solver.
32    pub fn new(resolution: usize, bounds: (Vec3, Vec3)) -> Self {
33        let n = resolution * resolution * resolution;
34        Self {
35            resolution,
36            bounds,
37            density: vec![0.0; n],
38            potential: vec![0.0; n],
39            max_iter: 100,
40            tolerance: 1e-6,
41        }
42    }
43
44    /// Convert 3D index to linear index.
45    fn idx(&self, i: usize, j: usize, k: usize) -> usize {
46        i + j * self.resolution + k * self.resolution * self.resolution
47    }
48
49    /// Grid spacing.
50    fn dx(&self) -> f64 {
51        (self.bounds.1.x - self.bounds.0.x) / self.resolution as f64
52    }
53
54    /// Deposit particle mass onto density grid (cloud-in-cell).
55    pub fn deposit_density(&mut self, particles: &[GravityParticle]) {
56        self.density.fill(0.0);
57
58        let dx = self.dx();
59        let min = self.bounds.0;
60
61        for p in particles {
62            // Grid coordinates
63            let gx = ((p.x.x - min.x) / dx).floor() as isize;
64            let gy = ((p.x.y - min.y) / dx).floor() as isize;
65            let gz = ((p.x.z - min.z) / dx).floor() as isize;
66
67            // Deposit to nearest grid point (simple NGP scheme)
68            if gx >= 0
69                && gx < self.resolution as isize
70                && gy >= 0
71                && gy < self.resolution as isize
72                && gz >= 0
73                && gz < self.resolution as isize
74            {
75                let idx = self.idx(gx as usize, gy as usize, gz as usize);
76                self.density[idx] += p.m / (dx * dx * dx);
77            }
78        }
79    }
80
81    /// Solve Poisson equation using Jacobi iteration.
82    pub fn solve_poisson(&mut self) {
83        let n = self.resolution;
84        let dx = self.dx();
85        let dx2 = dx * dx;
86
87        let mut new_potential = self.potential.clone();
88
89        for _iter in 0..self.max_iter {
90            let mut max_delta: f64 = 0.0;
91
92            for i in 1..n - 1 {
93                for j in 1..n - 1 {
94                    for k in 1..n - 1 {
95                        let idx = self.idx(i, j, k);
96
97                        // ∇² Φ = 4π G ρ
98                        let rhs = 4.0 * std::f64::consts::PI * G * self.density[idx];
99
100                        // Jacobi update: Φ_new = (sum of neighbors + dx² * rhs) / 6
101                        new_potential[idx] = (self.potential[self.idx(i + 1, j, k)]
102                            + self.potential[self.idx(i - 1, j, k)]
103                            + self.potential[self.idx(i, j + 1, k)]
104                            + self.potential[self.idx(i, j - 1, k)]
105                            + self.potential[self.idx(i, j, k + 1)]
106                            + self.potential[self.idx(i, j, k - 1)]
107                            + dx2 * rhs)
108                            / 6.0;
109
110                        let delta = (new_potential[idx] - self.potential[idx]).abs();
111                        max_delta = max_delta.max(delta);
112                    }
113                }
114            }
115
116            std::mem::swap(&mut self.potential, &mut new_potential);
117
118            if max_delta < self.tolerance {
119                break;
120            }
121        }
122    }
123
124    /// Compute gravitational force at a position via finite-difference gradient.
125    pub fn force_at(&self, x: Vec3) -> Vec3 {
126        let dx = self.dx();
127        let min = self.bounds.0;
128
129        // Grid coordinates
130        let gx = ((x.x - min.x) / dx).floor() as isize;
131        let gy = ((x.y - min.y) / dx).floor() as isize;
132        let gz = ((x.z - min.z) / dx).floor() as isize;
133
134        if gx < 1
135            || gx >= (self.resolution - 1) as isize
136            || gy < 1
137            || gy >= (self.resolution - 1) as isize
138            || gz < 1
139            || gz >= (self.resolution - 1) as isize
140        {
141            return Vec3::zeros();
142        }
143
144        let i = gx as usize;
145        let j = gy as usize;
146        let k = gz as usize;
147
148        // Central difference: g = -∇Φ
149        let grad_x = (self.potential[self.idx(i + 1, j, k)]
150            - self.potential[self.idx(i - 1, j, k)])
151            / (2.0 * dx);
152        let grad_y = (self.potential[self.idx(i, j + 1, k)]
153            - self.potential[self.idx(i, j - 1, k)])
154            / (2.0 * dx);
155        let grad_z = (self.potential[self.idx(i, j, k + 1)]
156            - self.potential[self.idx(i, j, k - 1)])
157            / (2.0 * dx);
158
159        Vec3::new(-grad_x, -grad_y, -grad_z)
160    }
161}
162
163impl GravitySolver for PoissonSolver {
164    fn compute_forces(&mut self, particles: &mut [GravityParticle]) {
165        // Deposit density
166        self.deposit_density(particles);
167
168        // Solve Poisson equation
169        self.solve_poisson();
170
171        // Compute forces
172        for p in particles.iter_mut() {
173            p.reset_force();
174            let g = self.force_at(p.x);
175            p.add_force(g * p.m);
176        }
177    }
178
179    fn potential_energy(&self, particles: &[GravityParticle]) -> f64 {
180        // U = Σ m * Φ(x)
181        let dx = self.dx();
182        let min = self.bounds.0;
183
184        particles
185            .iter()
186            .map(|p| {
187                let gx = ((p.x.x - min.x) / dx).floor() as isize;
188                let gy = ((p.x.y - min.y) / dx).floor() as isize;
189                let gz = ((p.x.z - min.z) / dx).floor() as isize;
190
191                if gx >= 0
192                    && gx < self.resolution as isize
193                    && gy >= 0
194                    && gy < self.resolution as isize
195                    && gz >= 0
196                    && gz < self.resolution as isize
197                {
198                    let idx = self.idx(gx as usize, gy as usize, gz as usize);
199                    p.m * self.potential[idx]
200                } else {
201                    0.0
202                }
203            })
204            .sum()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_poisson_solver_creation() {
214        let solver =
215            PoissonSolver::new(10, (Vec3::new(-5.0, -5.0, -5.0), Vec3::new(5.0, 5.0, 5.0)));
216        assert_eq!(solver.resolution, 10);
217        assert_eq!(solver.density.len(), 1000);
218    }
219
220    #[test]
221    fn test_density_deposition() {
222        let mut solver =
223            PoissonSolver::new(10, (Vec3::new(-5.0, -5.0, -5.0), Vec3::new(5.0, 5.0, 5.0)));
224
225        let particles = vec![GravityParticle::new(Vec3::zeros(), Vec3::zeros(), 1000.0)];
226
227        solver.deposit_density(&particles);
228
229        // Check that density is non-zero somewhere
230        let total_density: f64 = solver.density.iter().sum();
231        assert!(total_density > 0.0);
232    }
233}