Skip to main content

oxiphysics_python/world_api/
sph.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! SPH (Smoothed Particle Hydrodynamics) Simulation.
6
7#![allow(missing_docs)]
8
9// ===========================================================================
10// SPH (Smoothed Particle Hydrodynamics) Simulation
11// ===========================================================================
12
13/// Configuration for an SPH particle simulation.
14#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct PySphConfig {
17    /// Smoothing length (kernel radius).
18    pub h: f64,
19    /// Reference density (kg/m^3).
20    pub rest_density: f64,
21    /// Pressure stiffness constant k.
22    pub stiffness: f64,
23    /// Dynamic viscosity coefficient.
24    pub viscosity: f64,
25    /// Gravity `[gx, gy, gz]`.
26    pub gravity: [f64; 3],
27    /// Particle mass.
28    pub particle_mass: f64,
29}
30
31impl PySphConfig {
32    /// Create a default water-like SPH configuration.
33    pub fn water() -> Self {
34        Self {
35            h: 0.1,
36            rest_density: 1000.0,
37            stiffness: 200.0,
38            viscosity: 0.01,
39            gravity: [0.0, -9.81, 0.0],
40            particle_mass: 0.02,
41        }
42    }
43}
44
45impl Default for PySphConfig {
46    fn default() -> Self {
47        Self::water()
48    }
49}
50
51/// SPH particle simulation (simplified 3-D, poly6/spiky kernels).
52#[derive(Debug, Clone)]
53#[allow(dead_code)]
54pub struct PySphSim {
55    /// Particle positions `[x, y, z]` per particle.
56    positions: Vec<[f64; 3]>,
57    /// Particle velocities `[vx, vy, vz]` per particle.
58    velocities: Vec<[f64; 3]>,
59    /// Particle densities (one per particle).
60    densities: Vec<f64>,
61    /// Particle pressures.
62    pressures: Vec<f64>,
63    /// Configuration.
64    config: PySphConfig,
65    /// Accumulated simulation time.
66    time: f64,
67}
68
69impl PySphSim {
70    /// Create an empty SPH simulation.
71    pub fn new(config: PySphConfig) -> Self {
72        Self {
73            positions: Vec::new(),
74            velocities: Vec::new(),
75            densities: Vec::new(),
76            pressures: Vec::new(),
77            config,
78            time: 0.0,
79        }
80    }
81
82    /// Add a particle at position `[x, y, z]` with zero velocity.
83    pub fn add_particle(&mut self, pos: [f64; 3]) {
84        self.positions.push(pos);
85        self.velocities.push([0.0; 3]);
86        self.densities.push(self.config.rest_density);
87        self.pressures.push(0.0);
88    }
89
90    /// Add `n` particles arranged in a 3-D lattice starting at `origin` with
91    /// spacing `dx`.
92    pub fn add_particle_block(
93        &mut self,
94        origin: [f64; 3],
95        nx: usize,
96        ny: usize,
97        nz: usize,
98        dx: f64,
99    ) {
100        for iz in 0..nz {
101            for iy in 0..ny {
102                for ix in 0..nx {
103                    let pos = [
104                        origin[0] + ix as f64 * dx,
105                        origin[1] + iy as f64 * dx,
106                        origin[2] + iz as f64 * dx,
107                    ];
108                    self.add_particle(pos);
109                }
110            }
111        }
112    }
113
114    /// Number of particles.
115    pub fn particle_count(&self) -> usize {
116        self.positions.len()
117    }
118
119    /// Get the position of particle `i`, or `None` if out of bounds.
120    pub fn position(&self, i: usize) -> Option<[f64; 3]> {
121        self.positions.get(i).copied()
122    }
123
124    /// Get the velocity of particle `i`, or `None` if out of bounds.
125    pub fn velocity(&self, i: usize) -> Option<[f64; 3]> {
126        self.velocities.get(i).copied()
127    }
128
129    /// Get the density of particle `i`, or `None` if out of bounds.
130    pub fn density(&self, i: usize) -> Option<f64> {
131        self.densities.get(i).copied()
132    }
133
134    /// Get the pressure of particle `i`, or `None` if out of bounds.
135    pub fn pressure(&self, i: usize) -> Option<f64> {
136        self.pressures.get(i).copied()
137    }
138
139    /// Mutable access to the velocity slice (for initial-condition setup).
140    pub fn velocities_mut(&mut self) -> &mut Vec<[f64; 3]> {
141        &mut self.velocities
142    }
143
144    /// Smoothing length `h`.
145    pub fn smoothing_length(&self) -> f64 {
146        self.config.h
147    }
148
149    /// Rest density ρ₀.
150    pub fn rest_density(&self) -> f64 {
151        self.config.rest_density
152    }
153
154    /// Particle mass.
155    pub fn particle_mass(&self) -> f64 {
156        self.config.particle_mass
157    }
158
159    /// Return all positions as a flat `Vec`f64` of `\[x, y, z\]` triples.
160    pub fn all_positions(&self) -> Vec<f64> {
161        self.positions
162            .iter()
163            .flat_map(|p| p.iter().copied())
164            .collect()
165    }
166
167    /// Return all velocities as a flat `Vec`f64` of `[vx, vy, vz]` triples.
168    pub fn all_velocities(&self) -> Vec<f64> {
169        self.velocities
170            .iter()
171            .flat_map(|v| v.iter().copied())
172            .collect()
173    }
174
175    /// Accumulated simulation time in seconds.
176    pub fn time(&self) -> f64 {
177        self.time
178    }
179
180    /// Advance the simulation by `dt` seconds.
181    ///
182    /// Uses a simple WCSPH scheme:
183    /// 1. Compute densities and pressures (poly6 kernel).
184    /// 2. Compute pressure + viscosity forces (spiky/laplacian kernels).
185    /// 3. Integrate with leapfrog.
186    pub fn step(&mut self, dt: f64) {
187        let n = self.positions.len();
188        if n == 0 {
189            return;
190        }
191
192        let h = self.config.h;
193        let h2 = h * h;
194        let m = self.config.particle_mass;
195        let rho0 = self.config.rest_density;
196        let k = self.config.stiffness;
197        let mu = self.config.viscosity;
198        let g = self.config.gravity;
199
200        // -- Density computation (poly6 kernel) --
201        let poly6_coeff = 315.0 / (64.0 * std::f64::consts::PI * h.powi(9));
202        for i in 0..n {
203            let mut rho = 0.0f64;
204            for j in 0..n {
205                let dx = self.positions[i][0] - self.positions[j][0];
206                let dy = self.positions[i][1] - self.positions[j][1];
207                let dz = self.positions[i][2] - self.positions[j][2];
208                let r2 = dx * dx + dy * dy + dz * dz;
209                if r2 < h2 {
210                    let diff = h2 - r2;
211                    rho += m * poly6_coeff * diff * diff * diff;
212                }
213            }
214            self.densities[i] = rho.max(1e-3);
215            self.pressures[i] = k * (self.densities[i] - rho0);
216        }
217
218        // -- Force computation --
219        let spiky_coeff = -45.0 / (std::f64::consts::PI * h.powi(6));
220        let visc_coeff = 45.0 / (std::f64::consts::PI * h.powi(6));
221        let mut forces = vec![[0.0f64; 3]; n];
222
223        for i in 0..n {
224            let mut fp = [0.0f64; 3];
225            let mut fv = [0.0f64; 3];
226            for j in 0..n {
227                if i == j {
228                    continue;
229                }
230                let dx = self.positions[i][0] - self.positions[j][0];
231                let dy = self.positions[i][1] - self.positions[j][1];
232                let dz = self.positions[i][2] - self.positions[j][2];
233                let r2 = dx * dx + dy * dy + dz * dz;
234                if r2 >= h2 || r2 < 1e-20 {
235                    continue;
236                }
237                let r = r2.sqrt();
238                let hr = h - r;
239                // Pressure force (spiky gradient)
240                let pf = -m * (self.pressures[i] + self.pressures[j]) / (2.0 * self.densities[j])
241                    * spiky_coeff
242                    * hr
243                    * hr
244                    / r;
245                fp[0] += pf * dx;
246                fp[1] += pf * dy;
247                fp[2] += pf * dz;
248                // Viscosity force (laplacian)
249                let vf = mu * m / self.densities[j] * visc_coeff * hr;
250                fv[0] += vf * (self.velocities[j][0] - self.velocities[i][0]);
251                fv[1] += vf * (self.velocities[j][1] - self.velocities[i][1]);
252                fv[2] += vf * (self.velocities[j][2] - self.velocities[i][2]);
253            }
254            // Gravity
255            let rho_i = self.densities[i];
256            forces[i][0] = (fp[0] + fv[0]) / rho_i + g[0];
257            forces[i][1] = (fp[1] + fv[1]) / rho_i + g[1];
258            forces[i][2] = (fp[2] + fv[2]) / rho_i + g[2];
259        }
260
261        // -- Integration (Euler) --
262        for i in 0..n {
263            self.velocities[i][0] += forces[i][0] * dt;
264            self.velocities[i][1] += forces[i][1] * dt;
265            self.velocities[i][2] += forces[i][2] * dt;
266            self.positions[i][0] += self.velocities[i][0] * dt;
267            self.positions[i][1] += self.velocities[i][1] * dt;
268            self.positions[i][2] += self.velocities[i][2] * dt;
269        }
270
271        self.time += dt;
272    }
273}