use crate::common::{FluidConfig, FluidParticle};
use crate::error::{PravashError, Result};
use std::f64::consts::PI;
use hisab::DVec3;
use hisab::SpatialHash;
use hisab::Vec3;
use hisab::num::matrix_inverse;
use tracing::trace_span;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
const MIN_DENSITY: f64 = 1e-10;
#[derive(Debug, Clone, Copy)]
struct KernelCoeffs {
h: f64,
h2: f64,
poly6: f64,
spiky_grad: f64,
visc_lap: f64,
poly6_grad: f64,
poly6_lap: f64,
}
impl KernelCoeffs {
#[inline]
fn new(h: f64) -> Self {
debug_assert!(h > 0.0, "smoothing radius must be positive");
let h2 = h * h;
let h3 = h2 * h;
let h6 = h3 * h3;
let h9 = h6 * h3;
Self {
h,
h2,
poly6: 315.0 / (64.0 * PI * h9),
spiky_grad: -45.0 / (PI * h6),
visc_lap: 45.0 / (PI * h6),
poly6_grad: -945.0 / (32.0 * PI * h9),
poly6_lap: -945.0 / (32.0 * PI * h9),
}
}
#[inline]
fn poly6(&self, r2: f64) -> f64 {
if r2 > self.h2 {
return 0.0;
}
let diff = self.h2 - r2;
self.poly6 * diff * diff * diff
}
#[inline]
fn poly6_grad_scalar(&self, r2: f64) -> f64 {
if r2 > self.h2 {
return 0.0;
}
let diff = self.h2 - r2;
self.poly6_grad * diff * diff
}
#[inline]
fn poly6_laplacian(&self, r2: f64) -> f64 {
if r2 > self.h2 {
return 0.0;
}
let diff = self.h2 - r2;
self.poly6_lap * diff * (3.0 * self.h2 - 7.0 * r2)
}
#[inline]
fn spiky_grad(&self, r: f64) -> f64 {
if r > self.h || r <= 0.0 {
return 0.0;
}
let diff = self.h - r;
self.spiky_grad * diff * diff
}
#[inline]
fn visc_laplacian(&self, r: f64) -> f64 {
if r > self.h || r <= 0.0 {
return 0.0;
}
self.visc_lap * (self.h - r)
}
}
#[inline]
#[must_use]
pub fn kernel_poly6(r: f64, h: f64) -> f64 {
if r < 0.0 {
return 0.0;
}
KernelCoeffs::new(h).poly6(r * r)
}
#[inline]
#[must_use]
pub fn kernel_spiky_grad(r: f64, h: f64) -> f64 {
KernelCoeffs::new(h).spiky_grad(r)
}
#[inline]
#[must_use]
pub fn kernel_viscosity_laplacian(r: f64, h: f64) -> f64 {
KernelCoeffs::new(h).visc_laplacian(r)
}
#[inline]
#[must_use]
pub fn kernel_wendland_c2(r: f64, h: f64) -> f64 {
let q = r / h;
if !(0.0..2.0).contains(&q) {
return 0.0;
}
let t = 1.0 - 0.5 * q;
(7.0 / (4.0 * PI * h * h)) * t * t * t * t * (1.0 + 2.0 * q)
}
#[inline]
#[must_use]
pub fn kernel_wendland_c2_grad(r: f64, h: f64) -> f64 {
let q = r / h;
if q >= 2.0 || q <= 0.0 {
return 0.0;
}
let t = 1.0 - 0.5 * q;
(7.0 / (4.0 * PI * h * h)) * (-5.0 * q) * t * t * t / h
}
#[inline]
#[must_use]
pub fn kernel_wendland_c4(r: f64, h: f64) -> f64 {
let q = r / h;
if !(0.0..2.0).contains(&q) {
return 0.0;
}
let t = 1.0 - 0.5 * q;
let t6 = t * t * t * t * t * t;
(9.0 / (4.0 * PI * h * h)) * t6 * (1.0 + 3.0 * q + 35.0 / 12.0 * q * q)
}
#[inline]
#[must_use]
pub fn kernel_wendland_c4_grad(r: f64, h: f64) -> f64 {
let q = r / h;
if q >= 2.0 || q <= 0.0 {
return 0.0;
}
let t = 1.0 - 0.5 * q;
let t5 = t * t * t * t * t;
let g = 1.0 + 3.0 * q + 35.0 / 12.0 * q * q;
let gp = 3.0 + 35.0 / 6.0 * q;
let dfdq = t5 * (-3.0 * g + t * gp);
(9.0 / (4.0 * PI * h * h)) * dfdq / h
}
#[derive(Debug, Clone, Copy)]
pub struct PhaseProperties {
pub rest_density: f64,
pub gas_constant: f64,
pub viscosity: f64,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MultiPhaseConfig {
pub phases: Vec<PhaseProperties>,
pub interface_tension: f64,
}
impl MultiPhaseConfig {
#[must_use]
pub fn single(rest_density: f64, gas_constant: f64, viscosity: f64) -> Self {
Self {
phases: vec![PhaseProperties {
rest_density,
gas_constant,
viscosity,
}],
interface_tension: 0.0,
}
}
#[must_use]
pub fn two_phase(
phase_a: PhaseProperties,
phase_b: PhaseProperties,
interface_tension: f64,
) -> Self {
Self {
phases: vec![phase_a, phase_b],
interface_tension,
}
}
#[inline]
#[must_use]
fn get(&self, phase: u8) -> &PhaseProperties {
self.phases.get(phase as usize).unwrap_or_else(|| {
tracing::warn!(
requested = phase,
available = self.phases.len(),
"phase index out of bounds, falling back to phase 0"
);
&self.phases[0]
})
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct ViscoelasticConfig {
pub elastic_modulus: f64,
pub relaxation_time: f64,
}
pub fn update_viscoelastic(
particles: &mut [FluidParticle],
neighbor_offsets: &[u32],
neighbor_indices: &[usize],
h: f64,
ve: &ViscoelasticConfig,
dt: f64,
) {
let _span = trace_span!("sph::viscoelastic", n = particles.len()).entered();
let n = particles.len();
if n == 0 {
return;
}
let kc = KernelCoeffs::new(h);
let inv_lambda = 1.0 / ve.relaxation_time.max(1e-20);
let snap: Vec<FluidParticle> = particles.to_vec();
for i in 0..n {
let pi = &snap[i];
let rho = pi.density.max(MIN_DENSITY);
let start = neighbor_offsets[i] as usize;
let end = neighbor_offsets[i + 1] as usize;
let mut dudx = 0.0;
let mut dudy = 0.0;
let mut dvdx = 0.0;
let mut dvdy = 0.0;
for &j in &neighbor_indices[start..end] {
if j == i {
continue;
}
let pj = &snap[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let grad = kc.spiky_grad(r);
let scale = pj.mass / rho_j * grad / r;
let dx = pi.position[0] - pj.position[0];
let dy = pi.position[1] - pj.position[1];
let dvx = pj.velocity[0] - pi.velocity[0];
let dvy = pj.velocity[1] - pi.velocity[1];
dudx += dvx * scale * dx;
dudy += dvx * scale * dy;
dvdx += dvy * scale * dx;
dvdy += dvy * scale * dy;
}
let [c_xx, c_xy, c_yy] = pi.conformation;
let dcxx =
(c_xx * dudx + c_xy * dvdx) + (dudx * c_xx + dudy * c_xy) - (c_xx - 1.0) * inv_lambda;
let dcxy = 0.5 * ((c_xx * dudy + c_xy * dvdy) + (c_xy * dudx + c_yy * dvdx))
+ 0.5 * ((dvdx * c_xx + dvdy * c_xy) + (dudx * c_xy + dudy * c_yy))
- c_xy * inv_lambda;
let dcyy =
(c_xy * dudy + c_yy * dvdy) + (dvdx * c_xy + dvdy * c_yy) - (c_yy - 1.0) * inv_lambda;
particles[i].conformation = [c_xx + dcxx * dt, c_xy + dcxy * dt, c_yy + dcyy * dt];
let mut fx = 0.0;
let mut fy = 0.0;
for &j in &neighbor_indices[start..end] {
if j == i {
continue;
}
let pj = &snap[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let grad = kc.spiky_grad(r);
let scale = pj.mass / rho_j * grad / r;
let dx = pi.position[0] - pj.position[0];
let dy = pi.position[1] - pj.position[1];
let dc_xx = pj.conformation[0] - c_xx;
let dc_xy = pj.conformation[1] - c_xy;
let dc_yy = pj.conformation[2] - c_yy;
fx += ve.elastic_modulus * (dc_xx * dx + dc_xy * dy) * scale / rho;
fy += ve.elastic_modulus * (dc_xy * dx + dc_yy * dy) * scale / rho;
}
particles[i].acceleration[0] += fx;
particles[i].acceleration[1] += fy;
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct HeatConfig {
pub diffusivity: f64,
}
pub fn update_heat(
particles: &mut [FluidParticle],
neighbor_offsets: &[u32],
neighbor_indices: &[usize],
h: f64,
heat: &HeatConfig,
dt: f64,
) {
let _span = trace_span!("sph::heat", n = particles.len()).entered();
let n = particles.len();
if n == 0 || heat.diffusivity <= 0.0 {
return;
}
let kc = KernelCoeffs::new(h);
let snap: Vec<f64> = particles.iter().map(|p| p.temperature).collect();
for i in 0..n {
let pi = &particles[i];
let rho = pi.density.max(MIN_DENSITY);
let start = neighbor_offsets[i] as usize;
let end = neighbor_offsets[i + 1] as usize;
let mut dt_temp = 0.0;
for &j in &neighbor_indices[start..end] {
if j == i {
continue;
}
let pj = &particles[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let lap = kc.visc_laplacian(r);
dt_temp += pj.mass / rho_j * (snap[j] - snap[i]) * lap;
}
particles[i].temperature += heat.diffusivity * dt_temp / rho * dt;
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct PhaseChangeConfig {
pub transition_temperature: f64,
pub latent_heat: f64,
pub heat_capacity: f64,
pub phase_below: u8,
pub phase_above: u8,
}
pub fn update_phase_change(particles: &mut [FluidParticle], config: &PhaseChangeConfig, _dt: f64) {
let _span = trace_span!("sph::phase_change", n = particles.len()).entered();
let t_trans = config.transition_temperature;
let l_heat = config.latent_heat;
let cp = config.heat_capacity.max(1e-10);
for p in particles.iter_mut() {
if p.temperature > t_trans && p.phase == config.phase_below {
let excess = p.temperature - t_trans;
let thermal_energy = excess * cp;
let fraction = (thermal_energy / l_heat).min(1.0);
if fraction >= 1.0 {
p.phase = config.phase_above;
p.temperature = t_trans + (thermal_energy - l_heat) / cp;
} else {
p.temperature = t_trans; }
} else if p.temperature < t_trans && p.phase == config.phase_above {
let deficit = t_trans - p.temperature;
let thermal_energy = deficit * cp;
let fraction = (thermal_energy / l_heat).min(1.0);
if fraction >= 1.0 {
p.phase = config.phase_below;
p.temperature = t_trans - (thermal_energy - l_heat) / cp;
} else {
p.temperature = t_trans;
}
}
}
}
pub trait ReactionProvider {
fn react(&self, temperature: f64, fuel: f64, dt: f64) -> (f64, f64);
}
pub fn update_reaction(particles: &mut [FluidParticle], provider: &dyn ReactionProvider, dt: f64) {
let _span = trace_span!("sph::reaction", n = particles.len()).entered();
for p in particles.iter_mut() {
if p.fuel > 0.0 {
let (consumed, heat) = provider.react(p.temperature, p.fuel, dt);
p.fuel = (p.fuel - consumed).max(0.0);
p.temperature += heat;
}
}
}
impl ReactionProvider for CombustionConfig {
fn react(&self, temperature: f64, fuel: f64, dt: f64) -> (f64, f64) {
if temperature <= self.ignition_temperature || fuel <= 0.0 {
return (0.0, 0.0);
}
let consumed = fuel - fuel / (1.0 + self.reaction_rate * dt);
(consumed, self.heat_release * consumed)
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct CombustionConfig {
pub ignition_temperature: f64,
pub reaction_rate: f64,
pub heat_release: f64,
}
impl Default for CombustionConfig {
fn default() -> Self {
Self {
ignition_temperature: 500.0,
reaction_rate: 10.0,
heat_release: 1000.0,
}
}
}
pub fn update_combustion(particles: &mut [FluidParticle], config: &CombustionConfig, dt: f64) {
let _span = trace_span!("sph::combustion", n = particles.len()).entered();
let t_ign = config.ignition_temperature;
let rate = config.reaction_rate;
let heat = config.heat_release;
for p in particles.iter_mut() {
if p.temperature > t_ign && p.fuel > 0.0 {
let consumed = p.fuel - p.fuel / (1.0 + rate * dt);
p.fuel -= consumed;
p.fuel = p.fuel.max(0.0);
p.temperature += heat * consumed;
}
}
}
pub fn apply_contact_angle(
particles: &mut [FluidParticle],
config: &FluidConfig,
contact_angle: f64,
surface_tension: f64,
) {
let _span = trace_span!("sph::contact_angle", n = particles.len()).entered();
let cos_theta = contact_angle.cos();
let sin_theta = contact_angle.sin();
let h = config.smoothing_radius;
let lo = config.bounds_min;
let hi = config.bounds_max;
for p in particles.iter_mut() {
let mut wall_normal = DVec3::ZERO;
if p.position.x - lo.x < h {
wall_normal.x += 1.0;
}
if hi.x - p.position.x < h && hi.x > lo.x {
wall_normal.x -= 1.0;
}
if p.position.y - lo.y < h {
wall_normal.y += 1.0;
}
if hi.y - p.position.y < h && hi.y > lo.y {
wall_normal.y -= 1.0;
}
if p.position.z - lo.z < h {
wall_normal.z += 1.0;
}
if hi.z - p.position.z < h && hi.z > lo.z {
wall_normal.z -= 1.0;
}
if wall_normal.length_squared() < 1e-20 {
continue;
}
let n_wall = wall_normal.normalize();
let approach = p.velocity.dot(n_wall);
let wetting_force = surface_tension * (cos_theta - approach.signum() * sin_theta) / h;
p.acceleration += n_wall * wetting_force / p.mass.max(1e-20);
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum NonNewtonianViscosity {
PowerLaw { consistency: f64, power_index: f64 },
Bingham {
yield_stress: f64,
plastic_viscosity: f64,
},
HerschelBulkley {
yield_stress: f64,
consistency: f64,
power_index: f64,
},
}
impl NonNewtonianViscosity {
#[inline]
#[must_use]
pub fn effective_viscosity(&self, strain_rate: f64) -> f64 {
let eps = 1e-10;
let sr = strain_rate.max(eps);
match *self {
NonNewtonianViscosity::PowerLaw {
consistency,
power_index,
} => consistency * sr.powf(power_index - 1.0),
NonNewtonianViscosity::Bingham {
yield_stress,
plastic_viscosity,
} => {
let mu = plastic_viscosity + yield_stress / sr;
mu.min(1e6 * plastic_viscosity)
}
NonNewtonianViscosity::HerschelBulkley {
yield_stress,
consistency,
power_index,
} => {
let base_visc = consistency * sr.powf(power_index - 1.0);
let mu = base_visc + yield_stress / sr;
mu.min(1e6 * consistency)
}
}
}
}
pub fn apply_delta_sph(
particles: &mut [FluidParticle],
neighbor_offsets: &[u32],
neighbor_indices: &[usize],
h: f64,
speed_of_sound: f64,
delta: f64,
dt: f64,
) {
let _span = trace_span!("sph::delta_sph", n = particles.len()).entered();
let n = particles.len();
if n == 0 || delta <= 0.0 {
return;
}
let kc = KernelCoeffs::new(h);
let eps = 0.01 * h * h;
let snap: Vec<(f64, DVec3, f64)> = particles
.iter()
.map(|p| (p.density, p.position, p.mass))
.collect();
for i in 0..n {
let (rho_i, pos_i, _) = snap[i];
let start = neighbor_offsets[i] as usize;
let end = neighbor_offsets[i + 1] as usize;
let mut correction = 0.0;
for &j in &neighbor_indices[start..end] {
if j == i {
continue;
}
let (rho_j, pos_j, mass_j) = snap[j];
let dx = pos_i.x - pos_j.x;
let dy = pos_i.y - pos_j.y;
let dz = pos_i.z - pos_j.z;
let r2 = dx * dx + dy * dy + dz * dz;
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j_safe = rho_j.max(MIN_DENSITY);
let vol_j = mass_j / rho_j_safe;
let psi_scale = 2.0 * (rho_j - rho_i) / (r2 + eps);
let grad = kc.spiky_grad(r);
let grad_dot_r = grad; correction += vol_j * psi_scale * r * grad_dot_r;
}
particles[i].density += delta * h * speed_of_sound * correction * dt;
}
}
#[inline]
#[must_use]
pub fn compute_density(particle_idx: usize, particles: &[FluidParticle], h: f64) -> f64 {
compute_density_inner(&particles[particle_idx], particles, &KernelCoeffs::new(h))
}
#[inline]
fn compute_density_inner(
pi: &FluidParticle,
particles: &[FluidParticle],
kc: &KernelCoeffs,
) -> f64 {
let mut density = 0.0;
for pj in particles {
let r2 = pi.distance_squared_to(pj);
if r2 <= kc.h2 {
density += pj.mass * kc.poly6(r2);
}
}
density
}
#[inline]
#[must_use]
pub fn equation_of_state(density: f64, rest_density: f64, gas_constant: f64) -> f64 {
gas_constant * (density - rest_density)
}
#[inline]
#[must_use]
pub fn equation_of_state_tait(
density: f64,
rest_density: f64,
speed_of_sound: f64,
gamma: f64,
) -> f64 {
let b = rest_density * speed_of_sound * speed_of_sound / gamma;
b * ((density / rest_density.max(MIN_DENSITY)).powf(gamma) - 1.0)
}
#[must_use]
pub fn pressure_force(particle_idx: usize, particles: &[FluidParticle], h: f64) -> [f64; 3] {
let _span = trace_span!("sph::pressure_force", n = particles.len()).entered();
let kc = KernelCoeffs::new(h);
let pi = &particles[particle_idx];
let mut force = [0.0; 3];
let rho_i = pi.density.max(MIN_DENSITY);
for (j, pj) in particles.iter().enumerate() {
if j == particle_idx {
continue;
}
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let sym_pressure = pi.pressure / (rho_i * rho_i) + pj.pressure / (rho_j * rho_j);
let grad = kc.spiky_grad(r);
let scale = -pi.mass * pj.mass * sym_pressure * grad / r;
force[0] += scale * (pi.position.x - pj.position.x);
force[1] += scale * (pi.position.y - pj.position.y);
force[2] += scale * (pi.position.z - pj.position.z);
}
force
}
#[must_use]
pub fn viscosity_force(
particle_idx: usize,
particles: &[FluidParticle],
h: f64,
viscosity: f64,
) -> [f64; 3] {
let _span = trace_span!("sph::viscosity_force", n = particles.len()).entered();
let kc = KernelCoeffs::new(h);
let pi = &particles[particle_idx];
let mut force = [0.0; 3];
for (j, pj) in particles.iter().enumerate() {
if j == particle_idx {
continue;
}
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let lap = kc.visc_laplacian(r);
let rho_j = pj.density.max(MIN_DENSITY);
let scale = viscosity * pj.mass * lap / rho_j;
force[0] += scale * (pj.velocity.x - pi.velocity.x);
force[1] += scale * (pj.velocity.y - pi.velocity.y);
force[2] += scale * (pj.velocity.z - pi.velocity.z);
}
force
}
#[non_exhaustive]
pub struct SphSolver {
grid: SpatialHash,
cell_size: f32,
densities: Vec<f64>,
snapshot: Vec<FluidParticle>,
positions_f32: Vec<Vec3>,
neighbor_indices: Vec<usize>,
neighbor_offsets: Vec<u32>,
pub surface_tension: f64,
pcisph_accel: Vec<DVec3>,
pcisph_pressures: Vec<f64>,
pcisph_pred_pos: Vec<DVec3>,
pcisph_pred_vel: Vec<DVec3>,
pub use_verlet: bool,
prev_accel: Vec<DVec3>,
}
impl SphSolver {
#[must_use]
pub fn new() -> Self {
let grid = match SpatialHash::new(1.0) {
Ok(g) => g,
Err(_) => unreachable!(),
};
Self {
grid,
cell_size: 1.0,
densities: Vec::new(),
snapshot: Vec::new(),
positions_f32: Vec::new(),
neighbor_indices: Vec::new(),
neighbor_offsets: Vec::new(),
surface_tension: 0.0,
pcisph_accel: Vec::new(),
pcisph_pressures: Vec::new(),
pcisph_pred_pos: Vec::new(),
pcisph_pred_vel: Vec::new(),
use_verlet: false,
prev_accel: Vec::new(),
}
}
#[must_use]
pub fn with_surface_tension(surface_tension: f64) -> Self {
let mut s = Self::new();
s.surface_tension = surface_tension;
s
}
fn build_neighbors(&mut self, particles: &[FluidParticle], h: f64, h_f32: f32) -> Result<()> {
let n = particles.len();
let inv_cs = 1.0 / h_f32;
let half_cs = h_f32 * 0.5;
if (self.cell_size - h_f32).abs() > f32::EPSILON {
self.grid =
SpatialHash::new(h_f32).map_err(|_| PravashError::InvalidSmoothingRadius { h })?;
self.cell_size = h_f32;
} else {
self.grid.clear();
}
self.positions_f32.clear();
self.positions_f32.reserve(n);
for (i, p) in particles.iter().enumerate() {
let pos = Vec3::new(
p.position[0] as f32,
p.position[1] as f32,
p.position[2] as f32,
);
self.positions_f32.push(pos);
self.grid.insert(pos, i);
}
self.neighbor_indices.clear();
self.neighbor_offsets.clear();
self.neighbor_offsets.reserve(n + 1);
self.neighbor_offsets.push(0);
for pos in &self.positions_f32 {
let cx = (pos.x * inv_cs).floor() as i32;
let cy = (pos.y * inv_cs).floor() as i32;
let cz = (pos.z * inv_cs).floor() as i32;
for dz in -1..=1i32 {
for dy in -1..=1i32 {
for dx in -1..=1i32 {
let probe = Vec3::new(
(cx + dx) as f32 * h_f32 + half_cs,
(cy + dy) as f32 * h_f32 + half_cs,
(cz + dz) as f32 * h_f32 + half_cs,
);
let cell = self.grid.query_cell(probe);
self.neighbor_indices.extend_from_slice(cell);
}
}
}
self.neighbor_offsets
.push(self.neighbor_indices.len() as u32);
}
Ok(())
}
#[inline]
fn neighbors(&self, i: usize) -> &[usize] {
let start = self.neighbor_offsets[i] as usize;
let end = self.neighbor_offsets[i + 1] as usize;
&self.neighbor_indices[start..end]
}
pub fn step(
&mut self,
particles: &mut [FluidParticle],
config: &FluidConfig,
viscosity: f64,
) -> Result<()> {
let _span = trace_span!("sph::solver_step", n = particles.len()).entered();
config.validate()?;
let h = config.smoothing_radius;
let n = particles.len();
if n == 0 {
return Ok(());
}
let h_f32 = h as f32;
if !h_f32.is_finite() || h_f32 <= 0.0 {
return Err(PravashError::InvalidSmoothingRadius { h });
}
let kc = KernelCoeffs::new(h);
{
let _span = trace_span!("sph::build_neighbors", n).entered();
self.build_neighbors(particles, h, h_f32)?;
}
{
let _span = trace_span!("sph::density", n).entered();
self.densities.resize(n, 0.0);
let offsets = &self.neighbor_offsets;
let indices = &self.neighbor_indices;
let compute_density_i = |i: usize| -> f64 {
let pi = &particles[i];
let start = offsets[i] as usize;
let end = offsets[i + 1] as usize;
let mut d = 0.0;
for &j in &indices[start..end] {
let r2 = pi.distance_squared_to(&particles[j]);
if r2 <= kc.h2 {
d += particles[j].mass * kc.poly6(r2);
}
}
d
};
#[cfg(feature = "parallel")]
{
self.densities
.par_iter_mut()
.enumerate()
.for_each(|(i, d)| *d = compute_density_i(i));
}
#[cfg(not(feature = "parallel"))]
{
for i in 0..n {
self.densities[i] = compute_density_i(i);
}
}
for (i, p) in particles.iter_mut().enumerate() {
p.density = self.densities[i];
p.pressure = equation_of_state(p.density, config.rest_density, config.gas_constant);
}
}
self.snapshot
.resize(n, FluidParticle::new(DVec3::ZERO, 0.0));
self.snapshot.copy_from_slice(particles);
{
let _span = trace_span!("sph::forces", n).entered();
let st = self.surface_tension;
let snapshot = &self.snapshot;
let offsets = &self.neighbor_offsets;
let indices = &self.neighbor_indices;
let gravity = config.gravity;
let compute_accel_i = |i: usize| -> DVec3 {
let pi = &snapshot[i];
let mut ax = gravity.x;
let mut ay = gravity.y;
let mut az = gravity.z;
let rho = pi.density.max(MIN_DENSITY);
let mut cn_x = 0.0;
let mut cn_y = 0.0;
let mut cn_z = 0.0;
let mut laplacian_color = 0.0;
let start = offsets[i] as usize;
let end = offsets[i + 1] as usize;
for &j in &indices[start..end] {
if j == i {
continue;
}
let pj = &snapshot[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let dx = pi.position.x - pj.position.x;
let dy = pi.position.y - pj.position.y;
let dz = pi.position.z - pj.position.z;
let sym_pressure = pi.pressure / (rho * rho) + pj.pressure / (rho_j * rho_j);
let grad = kc.spiky_grad(r);
let p_scale = -pj.mass * sym_pressure * grad / r;
ax += p_scale * dx;
ay += p_scale * dy;
az += p_scale * dz;
let lap = kc.visc_laplacian(r);
let v_scale = viscosity * pj.mass * lap / rho_j / rho;
ax += v_scale * (pj.velocity.x - pi.velocity.x);
ay += v_scale * (pj.velocity.y - pi.velocity.y);
az += v_scale * (pj.velocity.z - pi.velocity.z);
if st > 0.0 {
let mass_over_rho = pj.mass / rho_j;
let pg = kc.poly6_grad_scalar(r2);
cn_x += mass_over_rho * pg * dx;
cn_y += mass_over_rho * pg * dy;
cn_z += mass_over_rho * pg * dz;
laplacian_color += mass_over_rho * kc.poly6_laplacian(r2);
}
}
if st > 0.0 {
let cn_mag = (cn_x * cn_x + cn_y * cn_y + cn_z * cn_z).sqrt();
let threshold = (1e-6 / kc.h).max(1e-10);
if cn_mag > threshold {
let kappa = -laplacian_color / cn_mag;
let st_scale = st * kappa / (rho * cn_mag);
ax += st_scale * cn_x;
ay += st_scale * cn_y;
az += st_scale * cn_z;
}
}
DVec3::new(ax, ay, az)
};
#[cfg(feature = "parallel")]
let accels: Vec<DVec3> = (0..n).into_par_iter().map(compute_accel_i).collect();
#[cfg(not(feature = "parallel"))]
let accels: Vec<DVec3> = (0..n).map(compute_accel_i).collect();
for (i, accel) in accels.iter().enumerate() {
if !accel.x.is_finite() || !accel.y.is_finite() || !accel.z.is_finite() {
return Err(PravashError::Diverged {
reason: format!("NaN/Inf in acceleration at particle {i}").into(),
});
}
particles[i].acceleration = *accel;
}
}
let dt = config.dt;
if self.use_verlet {
if self.prev_accel.len() != n {
self.prev_accel.resize(n, DVec3::ZERO);
}
for (i, p) in particles.iter_mut().enumerate() {
let a_old = self.prev_accel[i];
p.position += p.velocity * dt + 0.5 * a_old * dt * dt;
p.velocity += 0.5 * (a_old + p.acceleration) * dt;
self.prev_accel[i] = p.acceleration;
}
} else {
for p in particles.iter_mut() {
p.velocity += p.acceleration * dt;
p.position += p.velocity * dt;
}
}
let lo = config.bounds_min;
let hi = config.bounds_max;
let damp = config.boundary_damping;
for p in particles.iter_mut() {
if p.position.x < lo.x {
p.position.x = lo.x;
p.velocity.x *= -damp;
} else if p.position.x > hi.x && hi.x > lo.x {
p.position.x = hi.x;
p.velocity.x *= -damp;
}
if p.position.y < lo.y {
p.position.y = lo.y;
p.velocity.y *= -damp;
} else if p.position.y > hi.y && hi.y > lo.y {
p.position.y = hi.y;
p.velocity.y *= -damp;
}
if p.position.z < lo.z {
p.position.z = lo.z;
p.velocity.z *= -damp;
} else if p.position.z > hi.z && hi.z > lo.z {
p.position.z = hi.z;
p.velocity.z *= -damp;
}
}
Ok(())
}
pub fn step_pcisph(
&mut self,
particles: &mut [FluidParticle],
config: &FluidConfig,
viscosity: f64,
max_iterations: usize,
max_density_error: f64,
) -> Result<()> {
let _span = trace_span!("sph::pcisph", n = particles.len()).entered();
config.validate()?;
let h = config.smoothing_radius;
let n = particles.len();
if n == 0 {
return Ok(());
}
let h_f32 = h as f32;
if !h_f32.is_finite() || h_f32 <= 0.0 {
return Err(PravashError::InvalidSmoothingRadius { h });
}
let kc = KernelCoeffs::new(h);
let dt = config.dt;
let rest_density = config.rest_density;
{
let _span = trace_span!("sph::build_neighbors", n).entered();
self.build_neighbors(particles, h, h_f32)?;
}
self.densities.resize(n, 0.0);
for i in 0..n {
let pi = &particles[i];
let mut d = 0.0;
for &j in self.neighbors(i) {
let r2 = pi.distance_squared_to(&particles[j]);
if r2 <= kc.h2 {
d += particles[j].mass * kc.poly6(r2);
}
}
self.densities[i] = d;
}
for (i, p) in particles.iter_mut().enumerate() {
p.density = self.densities[i];
p.pressure = 0.0; }
self.snapshot
.resize(n, FluidParticle::new(DVec3::ZERO, 0.0));
self.snapshot.copy_from_slice(particles);
let st = self.surface_tension;
let mut non_pressure_accel = std::mem::take(&mut self.pcisph_accel);
non_pressure_accel.resize(n, DVec3::ZERO);
non_pressure_accel.fill(DVec3::ZERO);
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let pi = &self.snapshot[i];
let mut ax = config.gravity.x;
let mut ay = config.gravity.y;
let mut az = config.gravity.z;
let rho = pi.density.max(MIN_DENSITY);
let mut cn_x = 0.0;
let mut cn_y = 0.0;
let mut cn_z = 0.0;
let mut laplacian_color = 0.0;
for &j in self.neighbors(i) {
if j == i {
continue;
}
let pj = &self.snapshot[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let lap = kc.visc_laplacian(r);
let v_scale = viscosity * pj.mass * lap / rho_j / rho;
ax += v_scale * (pj.velocity.x - pi.velocity.x);
ay += v_scale * (pj.velocity.y - pi.velocity.y);
az += v_scale * (pj.velocity.z - pi.velocity.z);
if st > 0.0 {
let dx = pi.position.x - pj.position.x;
let dy = pi.position.y - pj.position.y;
let dz = pi.position.z - pj.position.z;
let mass_over_rho = pj.mass / rho_j;
let pg = kc.poly6_grad_scalar(r2);
cn_x += mass_over_rho * pg * dx;
cn_y += mass_over_rho * pg * dy;
cn_z += mass_over_rho * pg * dz;
laplacian_color += mass_over_rho * kc.poly6_laplacian(r2);
}
}
if st > 0.0 {
let cn_mag = (cn_x * cn_x + cn_y * cn_y + cn_z * cn_z).sqrt();
let threshold = (1e-6 / kc.h).max(1e-10);
if cn_mag > threshold {
let kappa = -laplacian_color / cn_mag;
let st_scale = st * kappa / (rho * cn_mag);
ax += st_scale * cn_x;
ay += st_scale * cn_y;
az += st_scale * cn_z;
}
}
if !ax.is_finite() || !ay.is_finite() || !az.is_finite() {
self.pcisph_accel = non_pressure_accel;
return Err(PravashError::Diverged {
reason: format!("NaN/Inf in non-pressure acceleration at particle {i}").into(),
});
}
non_pressure_accel[i] = DVec3::new(ax, ay, az);
}
let delta = {
let mut delta_sum = 0.0f64;
let mut delta_count = 0usize;
for pi_idx in 0..n {
let pi = &self.snapshot[pi_idx];
let mut sum_grad = [0.0f64; 3];
let mut sum_grad_sq = 0.0f64;
let mut has_neighbors = false;
for &j in self.neighbors(pi_idx) {
if j == pi_idx {
continue;
}
let pj = &self.snapshot[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
has_neighbors = true;
let r = r2.sqrt();
let grad = kc.spiky_grad(r);
let grad_w = [
grad / r * (pi.position.x - pj.position.x),
grad / r * (pi.position.y - pj.position.y),
grad / r * (pi.position.z - pj.position.z),
];
sum_grad[0] += grad_w[0];
sum_grad[1] += grad_w[1];
sum_grad[2] += grad_w[2];
sum_grad_sq +=
grad_w[0] * grad_w[0] + grad_w[1] * grad_w[1] + grad_w[2] * grad_w[2];
}
if !has_neighbors {
continue;
}
let sum_dot = sum_grad[0] * sum_grad[0]
+ sum_grad[1] * sum_grad[1]
+ sum_grad[2] * sum_grad[2];
let beta = 2.0 * (dt * pi.mass / rest_density).powi(2);
let denom = beta * (sum_dot + sum_grad_sq);
if denom.abs() > 1e-20 {
delta_sum += 1.0 / denom;
delta_count += 1;
}
}
if delta_count > 0 {
delta_sum / delta_count as f64
} else {
0.0
}
};
let mut pressures = std::mem::take(&mut self.pcisph_pressures);
pressures.resize(n, 0.0);
pressures.fill(0.0);
let mut predicted_pos = std::mem::take(&mut self.pcisph_pred_pos);
predicted_pos.resize(n, DVec3::ZERO);
let mut predicted_vel = std::mem::take(&mut self.pcisph_pred_vel);
predicted_vel.resize(n, DVec3::ZERO);
for iter in 0..max_iterations {
let _span = trace_span!("sph::pcisph_iter", iter).entered();
for i in 0..n {
let pi = &self.snapshot[i];
let rho = pi.density.max(MIN_DENSITY);
let mut pax = 0.0;
let mut pay = 0.0;
let mut paz = 0.0;
for &j in self.neighbors(i) {
if j == i {
continue;
}
let pj = &self.snapshot[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let sym = pressures[i] / (rho * rho) + pressures[j] / (rho_j * rho_j);
let grad = kc.spiky_grad(r);
let s = -pj.mass * sym * grad / r;
pax += s * (pi.position.x - pj.position.x);
pay += s * (pi.position.y - pj.position.y);
paz += s * (pi.position.z - pj.position.z);
}
let total_ax = non_pressure_accel[i].x + pax;
let total_ay = non_pressure_accel[i].y + pay;
let total_az = non_pressure_accel[i].z + paz;
predicted_vel[i] = DVec3::new(
pi.velocity.x + total_ax * dt,
pi.velocity.y + total_ay * dt,
pi.velocity.z + total_az * dt,
);
predicted_pos[i] = DVec3::new(
pi.position.x + predicted_vel[i].x * dt,
pi.position.y + predicted_vel[i].y * dt,
pi.position.z + predicted_vel[i].z * dt,
);
}
let mut max_error = 0.0f64;
for i in 0..n {
let mut pred_density = 0.0;
for &j in self.neighbors(i) {
let dx = predicted_pos[i].x - predicted_pos[j].x;
let dy = predicted_pos[i].y - predicted_pos[j].y;
let dz = predicted_pos[i].z - predicted_pos[j].z;
let r2 = dx * dx + dy * dy + dz * dz;
if r2 <= kc.h2 {
pred_density += self.snapshot[j].mass * kc.poly6(r2);
}
}
let density_error = pred_density - rest_density;
max_error = max_error.max(density_error.abs() / rest_density);
pressures[i] = (pressures[i] + density_error * delta).max(0.0);
}
if max_error < max_density_error {
break;
}
}
for i in 0..n {
let pos = predicted_pos[i];
if !pos.x.is_finite() || !pos.y.is_finite() || !pos.z.is_finite() {
self.pcisph_accel = non_pressure_accel;
self.pcisph_pressures = pressures;
self.pcisph_pred_pos = predicted_pos;
self.pcisph_pred_vel = predicted_vel;
return Err(PravashError::Diverged {
reason: format!("NaN/Inf in predicted position at particle {i}").into(),
});
}
particles[i].velocity = predicted_vel[i];
particles[i].position = pos;
particles[i].pressure = pressures[i];
}
let lo = config.bounds_min;
let hi = config.bounds_max;
let damp = config.boundary_damping;
for p in particles.iter_mut() {
if p.position.x < lo.x {
p.position.x = lo.x;
p.velocity.x *= -damp;
} else if p.position.x > hi.x && hi.x > lo.x {
p.position.x = hi.x;
p.velocity.x *= -damp;
}
if p.position.y < lo.y {
p.position.y = lo.y;
p.velocity.y *= -damp;
} else if p.position.y > hi.y && hi.y > lo.y {
p.position.y = hi.y;
p.velocity.y *= -damp;
}
if p.position.z < lo.z {
p.position.z = lo.z;
p.velocity.z *= -damp;
} else if p.position.z > hi.z && hi.z > lo.z {
p.position.z = hi.z;
p.velocity.z *= -damp;
}
}
self.pcisph_accel = non_pressure_accel;
self.pcisph_pressures = pressures;
self.pcisph_pred_pos = predicted_pos;
self.pcisph_pred_vel = predicted_vel;
Ok(())
}
pub fn step_dfsph(
&mut self,
particles: &mut [FluidParticle],
config: &FluidConfig,
viscosity: f64,
max_iterations: usize,
max_error: f64,
) -> Result<()> {
let _span = trace_span!("sph::dfsph", n = particles.len()).entered();
config.validate()?;
let h = config.smoothing_radius;
let n = particles.len();
if n == 0 {
return Ok(());
}
let h_f32 = h as f32;
if !h_f32.is_finite() || h_f32 <= 0.0 {
return Err(PravashError::InvalidSmoothingRadius { h });
}
let kc = KernelCoeffs::new(h);
let dt = config.dt;
let rest_density = config.rest_density;
{
let _span = trace_span!("sph::build_neighbors", n).entered();
self.build_neighbors(particles, h, h_f32)?;
}
self.densities.resize(n, 0.0);
for i in 0..n {
let pi = &particles[i];
let mut d = 0.0;
for &j in self.neighbors(i) {
let r2 = pi.position.distance_squared(particles[j].position);
if r2 <= kc.h2 {
d += particles[j].mass * kc.poly6(r2);
}
}
self.densities[i] = d;
}
for (i, p) in particles.iter_mut().enumerate() {
p.density = self.densities[i];
}
let mut alphas = vec![0.0f64; n];
for i in 0..n {
let pi = &particles[i];
let mut sum_grad = DVec3::ZERO;
let mut sum_grad_sq = 0.0f64;
for &j in self.neighbors(i) {
if j == i {
continue;
}
let pj = &particles[j];
let diff = pi.position - pj.position;
let r2 = diff.length_squared();
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let grad = kc.spiky_grad(r);
let grad_w = diff * (pj.mass * grad / r);
sum_grad += grad_w;
sum_grad_sq += grad_w.length_squared();
}
let denom = sum_grad.length_squared() + sum_grad_sq;
alphas[i] = if denom > 1e-20 {
pi.density.max(MIN_DENSITY) / denom
} else {
0.0
};
}
self.snapshot
.resize(n, FluidParticle::new(DVec3::ZERO, 0.0));
self.snapshot.copy_from_slice(particles);
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let pi = &self.snapshot[i];
let mut accel = config.gravity;
let rho = pi.density.max(MIN_DENSITY);
for &j in self.neighbors(i) {
if j == i {
continue;
}
let pj = &self.snapshot[j];
let r2 = pi.position.distance_squared(pj.position);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let lap = kc.visc_laplacian(r);
let v_scale = viscosity * pj.mass * lap / rho_j / rho;
accel += (pj.velocity - pi.velocity) * v_scale;
}
particles[i].velocity += accel * dt;
}
for _iter in 0..max_iterations {
let _span = trace_span!("sph::dfsph_div").entered();
let snap_vel: Vec<DVec3> = particles.iter().map(|p| p.velocity).collect();
let mut max_div = 0.0f64;
for i in 0..n {
let pos_i = particles[i].position;
let rho_i = particles[i].density.max(MIN_DENSITY);
let mut div_v = 0.0;
for &j in self.neighbors(i) {
if j == i {
continue;
}
let diff = pos_i - particles[j].position;
let r2 = diff.length_squared();
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let grad = kc.spiky_grad(r);
let grad_w = diff * (grad / r);
div_v += particles[j].mass * (snap_vel[j] - snap_vel[i]).dot(grad_w);
}
max_div = max_div.max(div_v.abs());
if alphas[i] > 0.0 {
let kappa = div_v * alphas[i] / dt;
let mut vel_correction = DVec3::ZERO;
for &j in self.neighbors(i) {
if j == i {
continue;
}
let diff = pos_i - particles[j].position;
let r2 = diff.length_squared();
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = particles[j].density.max(MIN_DENSITY);
let grad = kc.spiky_grad(r);
vel_correction += diff
* (dt
* particles[j].mass
* (kappa / (rho_i * rho_i) + kappa / (rho_j * rho_j))
* grad
/ r);
}
particles[i].velocity -= vel_correction;
}
}
if max_div < max_error * rest_density {
break;
}
}
for p in particles.iter_mut() {
p.position += p.velocity * dt;
}
for i in 0..n {
let pi = &particles[i];
let mut d = 0.0;
for &j in self.neighbors(i) {
let r2 = pi.position.distance_squared(particles[j].position);
if r2 <= kc.h2 {
d += particles[j].mass * kc.poly6(r2);
}
}
particles[i].density = d;
}
for _iter in 0..max_iterations {
let _span = trace_span!("sph::dfsph_density").entered();
let mut max_rho_err = 0.0f64;
for i in 0..n {
let rho_err = particles[i].density - rest_density;
max_rho_err = max_rho_err.max(rho_err.abs() / rest_density);
if alphas[i] > 0.0 && rho_err.abs() > 1e-10 {
let kappa = rho_err * alphas[i] / (dt * dt);
let pi_pos = particles[i].position;
let pi_rho = particles[i].density.max(MIN_DENSITY);
for &j in self.neighbors(i) {
if j == i {
continue;
}
let diff = pi_pos - particles[j].position;
let r2 = diff.length_squared();
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = particles[j].density.max(MIN_DENSITY);
let grad = kc.spiky_grad(r);
let correction = diff
* (dt
* particles[j].mass
* (kappa / (pi_rho * pi_rho) + kappa / (rho_j * rho_j))
* grad
/ r);
particles[i].velocity -= correction;
}
}
}
if max_rho_err < max_error {
break;
}
}
for p in particles.iter_mut() {
p.pressure = equation_of_state(p.density, rest_density, config.gas_constant);
}
let lo = config.bounds_min;
let hi = config.bounds_max;
let damp = config.boundary_damping;
for p in particles.iter_mut() {
if p.position.x < lo.x {
p.position.x = lo.x;
p.velocity.x *= -damp;
} else if p.position.x > hi.x && hi.x > lo.x {
p.position.x = hi.x;
p.velocity.x *= -damp;
}
if p.position.y < lo.y {
p.position.y = lo.y;
p.velocity.y *= -damp;
} else if p.position.y > hi.y && hi.y > lo.y {
p.position.y = hi.y;
p.velocity.y *= -damp;
}
if p.position.z < lo.z {
p.position.z = lo.z;
p.velocity.z *= -damp;
} else if p.position.z > hi.z && hi.z > lo.z {
p.position.z = hi.z;
p.velocity.z *= -damp;
}
}
Ok(())
}
#[must_use]
pub fn adaptive_dt(
particles: &[FluidParticle],
smoothing_radius: f64,
viscosity: f64,
cfl_factor: f64,
dt_min: f64,
dt_max: f64,
) -> f64 {
if !smoothing_radius.is_finite()
|| smoothing_radius <= 0.0
|| !cfl_factor.is_finite()
|| cfl_factor <= 0.0
|| !dt_min.is_finite()
|| !dt_max.is_finite()
{
return dt_max.max(dt_min);
}
let h = smoothing_radius;
let mut dt_cfl = f64::MAX;
let max_v = max_speed(particles);
if max_v > 1e-20 {
dt_cfl = dt_cfl.min(h / max_v);
}
let max_a = particles
.iter()
.map(|p| {
let a = &p.acceleration;
a[0] * a[0] + a[1] * a[1] + a[2] * a[2]
})
.fold(0.0f64, f64::max)
.sqrt();
if max_a > 1e-20 {
dt_cfl = dt_cfl.min((h / max_a).sqrt());
}
if viscosity > 1e-20 {
dt_cfl = dt_cfl.min(h * h / (6.0 * viscosity));
}
(cfl_factor * dt_cfl).clamp(dt_min, dt_max)
}
pub fn step_multiphase(
&mut self,
particles: &mut [FluidParticle],
config: &FluidConfig,
phase_config: &MultiPhaseConfig,
) -> Result<()> {
let _span = trace_span!("sph::step_multiphase", n = particles.len()).entered();
config.validate()?;
let h = config.smoothing_radius;
let n = particles.len();
if n == 0 {
return Ok(());
}
let h_f32 = h as f32;
if !h_f32.is_finite() || h_f32 <= 0.0 {
return Err(PravashError::InvalidSmoothingRadius { h });
}
let kc = KernelCoeffs::new(h);
{
let _span = trace_span!("sph::build_neighbors", n).entered();
self.build_neighbors(particles, h, h_f32)?;
}
{
let _span = trace_span!("sph::density", n).entered();
self.densities.resize(n, 0.0);
let offsets = &self.neighbor_offsets;
let indices = &self.neighbor_indices;
for i in 0..n {
let pi = &particles[i];
let start = offsets[i] as usize;
let end = offsets[i + 1] as usize;
let mut d = 0.0;
for &j in &indices[start..end] {
let r2 = pi.distance_squared_to(&particles[j]);
if r2 <= kc.h2 {
d += particles[j].mass * kc.poly6(r2);
}
}
self.densities[i] = d;
}
for (i, p) in particles.iter_mut().enumerate() {
p.density = self.densities[i];
let props = phase_config.get(p.phase);
p.pressure = equation_of_state(p.density, props.rest_density, props.gas_constant);
}
}
self.snapshot
.resize(n, FluidParticle::new(DVec3::ZERO, 0.0));
self.snapshot.copy_from_slice(particles);
{
let _span = trace_span!("sph::forces", n).entered();
let st = self.surface_tension;
let it = phase_config.interface_tension;
let snapshot = &self.snapshot;
let offsets = &self.neighbor_offsets;
let indices = &self.neighbor_indices;
let gravity = config.gravity;
let compute_accel_i = |i: usize| -> DVec3 {
let pi = &snapshot[i];
let mut ax = gravity.x;
let mut ay = gravity.y;
let mut az = gravity.z;
let rho = pi.density.max(MIN_DENSITY);
let props_i = phase_config.get(pi.phase);
let mut cn_x = 0.0;
let mut cn_y = 0.0;
let mut cn_z = 0.0;
let mut laplacian_color = 0.0;
let start = offsets[i] as usize;
let end = offsets[i + 1] as usize;
for &j in &indices[start..end] {
if j == i {
continue;
}
let pj = &snapshot[j];
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let dx = pi.position.x - pj.position.x;
let dy = pi.position.y - pj.position.y;
let dz = pi.position.z - pj.position.z;
let sym_pressure = pi.pressure / (rho * rho) + pj.pressure / (rho_j * rho_j);
let grad = kc.spiky_grad(r);
let p_scale = -pj.mass * sym_pressure * grad / r;
ax += p_scale * dx;
ay += p_scale * dy;
az += p_scale * dz;
let props_j = phase_config.get(pj.phase);
let visc = 0.5 * (props_i.viscosity + props_j.viscosity);
let lap = kc.visc_laplacian(r);
let v_scale = visc * pj.mass * lap / rho_j / rho;
ax += v_scale * (pj.velocity.x - pi.velocity.x);
ay += v_scale * (pj.velocity.y - pi.velocity.y);
az += v_scale * (pj.velocity.z - pi.velocity.z);
let effective_st = if pi.phase != pj.phase { st + it } else { st };
if effective_st > 0.0 {
let mass_over_rho = pj.mass / rho_j;
let pg = kc.poly6_grad_scalar(r2);
cn_x += mass_over_rho * pg * dx;
cn_y += mass_over_rho * pg * dy;
cn_z += mass_over_rho * pg * dz;
laplacian_color += mass_over_rho * kc.poly6_laplacian(r2);
}
}
if st > 0.0 || it > 0.0 {
let cn_mag = (cn_x * cn_x + cn_y * cn_y + cn_z * cn_z).sqrt();
let threshold = (1e-6 / kc.h).max(1e-10);
if cn_mag > threshold {
let kappa = -laplacian_color / cn_mag;
let st_scale = (st + it) * kappa / (rho * cn_mag);
ax += st_scale * cn_x;
ay += st_scale * cn_y;
az += st_scale * cn_z;
}
}
DVec3::new(ax, ay, az)
};
#[cfg(feature = "parallel")]
let accels: Vec<DVec3> = (0..n).into_par_iter().map(compute_accel_i).collect();
#[cfg(not(feature = "parallel"))]
let accels: Vec<DVec3> = (0..n).map(compute_accel_i).collect();
for (i, accel) in accels.iter().enumerate() {
if !accel.x.is_finite() || !accel.y.is_finite() || !accel.z.is_finite() {
return Err(PravashError::Diverged {
reason: format!("NaN/Inf in acceleration at particle {i}").into(),
});
}
particles[i].acceleration = *accel;
}
}
let dt = config.dt;
for p in particles.iter_mut() {
p.velocity += p.acceleration * dt;
p.position += p.velocity * dt;
}
let lo = config.bounds_min;
let hi = config.bounds_max;
let damp = config.boundary_damping;
for p in particles.iter_mut() {
if p.position.x < lo.x {
p.position.x = lo.x;
p.velocity.x *= -damp;
} else if p.position.x > hi.x && hi.x > lo.x {
p.position.x = hi.x;
p.velocity.x *= -damp;
}
if p.position.y < lo.y {
p.position.y = lo.y;
p.velocity.y *= -damp;
} else if p.position.y > hi.y && hi.y > lo.y {
p.position.y = hi.y;
p.velocity.y *= -damp;
}
if p.position.z < lo.z {
p.position.z = lo.z;
p.velocity.z *= -damp;
} else if p.position.z > hi.z && hi.z > lo.z {
p.position.z = hi.z;
p.velocity.z *= -damp;
}
}
Ok(())
}
}
impl Default for SphSolver {
fn default() -> Self {
Self::new()
}
}
pub fn step(particles: &mut [FluidParticle], config: &FluidConfig, viscosity: f64) -> Result<()> {
let _span = trace_span!("sph::step", n = particles.len()).entered();
config.validate()?;
let h = config.smoothing_radius;
let n = particles.len();
if n == 0 {
return Ok(());
}
let kc = KernelCoeffs::new(h);
{
let _span = trace_span!("sph::density", n).entered();
let mut densities = vec![0.0f64; n];
for i in 0..n {
densities[i] = compute_density_inner(&particles[i], particles, &kc);
}
for (i, p) in particles.iter_mut().enumerate() {
p.density = densities[i];
p.pressure = equation_of_state(p.density, config.rest_density, config.gas_constant);
}
}
let snapshot: Vec<FluidParticle> = particles.to_vec();
{
let _span = trace_span!("sph::forces", n).entered();
for i in 0..n {
let pi = &snapshot[i];
let mut ax = config.gravity.x;
let mut ay = config.gravity.y;
let mut az = config.gravity.z;
let rho = pi.density.max(MIN_DENSITY);
for (j, pj) in snapshot.iter().enumerate() {
if j == i {
continue;
}
let r2 = pi.distance_squared_to(pj);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let sym_pressure = pi.pressure / (rho * rho) + pj.pressure / (rho_j * rho_j);
let grad = kc.spiky_grad(r);
let p_scale = -pj.mass * sym_pressure * grad / r;
let dx = pi.position.x - pj.position.x;
let dy = pi.position.y - pj.position.y;
let dz = pi.position.z - pj.position.z;
ax += p_scale * dx;
ay += p_scale * dy;
az += p_scale * dz;
let lap = kc.visc_laplacian(r);
let v_scale = viscosity * pj.mass * lap / rho_j / rho;
ax += v_scale * (pj.velocity.x - pi.velocity.x);
ay += v_scale * (pj.velocity.y - pi.velocity.y);
az += v_scale * (pj.velocity.z - pi.velocity.z);
}
if !ax.is_finite() || !ay.is_finite() || !az.is_finite() {
return Err(PravashError::Diverged {
reason: format!("NaN/Inf in acceleration at particle {i}").into(),
});
}
particles[i].acceleration = DVec3::new(ax, ay, az);
}
}
let dt = config.dt;
for p in particles.iter_mut() {
p.velocity += p.acceleration * dt;
p.position += p.velocity * dt;
}
let lo = config.bounds_min;
let hi = config.bounds_max;
let damp = config.boundary_damping;
for p in particles.iter_mut() {
if p.position.x < lo.x {
p.position.x = lo.x;
p.velocity.x *= -damp;
} else if p.position.x > hi.x && hi.x > lo.x {
p.position.x = hi.x;
p.velocity.x *= -damp;
}
if p.position.y < lo.y {
p.position.y = lo.y;
p.velocity.y *= -damp;
} else if p.position.y > hi.y && hi.y > lo.y {
p.position.y = hi.y;
p.velocity.y *= -damp;
}
if p.position.z < lo.z {
p.position.z = lo.z;
p.velocity.z *= -damp;
} else if p.position.z > hi.z && hi.z > lo.z {
p.position.z = hi.z;
p.velocity.z *= -damp;
}
}
Ok(())
}
#[must_use]
pub fn compute_gradient_corrections(
particles: &[FluidParticle],
neighbor_offsets: &[u32],
neighbor_indices: &[usize],
h: f64,
) -> Vec<[f64; 9]> {
let _span = trace_span!("sph::mls_correction", n = particles.len()).entered();
let n = particles.len();
let kc = KernelCoeffs::new(h);
let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
let mut corrections = vec![identity; n];
for i in 0..n {
let pi = &particles[i];
let start = neighbor_offsets[i] as usize;
let end = neighbor_offsets[i + 1] as usize;
let mut m = [0.0f64; 9];
for &j in &neighbor_indices[start..end] {
if j == i {
continue;
}
let pj = &particles[j];
let diff = pj.position - pi.position;
let r2 = diff.length_squared();
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = pj.density.max(MIN_DENSITY);
let vol_j = pj.mass / rho_j;
let grad = kc.spiky_grad(r);
let dir = (pi.position - pj.position) * (grad / r);
let gw = [dir.x, dir.y, dir.z];
let d = [diff.x, diff.y, diff.z];
for row in 0..3 {
for col in 0..3 {
m[row * 3 + col] += vol_j * d[row] * gw[col];
}
}
}
if let Some(inv) = invert_3x3(&m) {
corrections[i] = inv;
}
}
corrections
}
#[inline]
fn invert_3x3(m: &[f64; 9]) -> Option<[f64; 9]> {
let rows = vec![
vec![m[0], m[1], m[2]],
vec![m[3], m[4], m[5]],
vec![m[6], m[7], m[8]],
];
let inv = matrix_inverse(&rows).ok()?;
Some([
inv[0][0], inv[0][1], inv[0][2], inv[1][0], inv[1][1], inv[1][2], inv[2][0], inv[2][1],
inv[2][2],
])
}
#[inline]
#[must_use]
pub fn apply_gradient_correction(correction: &[f64; 9], gw: DVec3) -> DVec3 {
DVec3::new(
correction[0] * gw.x + correction[1] * gw.y + correction[2] * gw.z,
correction[3] * gw.x + correction[4] * gw.y + correction[5] * gw.z,
correction[6] * gw.x + correction[7] * gw.y + correction[8] * gw.z,
)
}
pub fn apply_implicit_viscosity(
particles: &mut [FluidParticle],
neighbor_offsets: &[u32],
neighbor_indices: &[usize],
h: f64,
viscosity: f64,
dt: f64,
iterations: usize,
) {
let _span = trace_span!("sph::implicit_visc", n = particles.len()).entered();
let n = particles.len();
if n == 0 || viscosity <= 0.0 {
return;
}
let kc = KernelCoeffs::new(h);
const CONVERGENCE_THRESHOLD: f64 = 1e-8;
for _iter in 0..iterations {
let snap_vel: Vec<DVec3> = particles.iter().map(|p| p.velocity).collect();
let mut max_change: f64 = 0.0;
for i in 0..n {
let pi_pos = particles[i].position;
let rho = particles[i].density.max(MIN_DENSITY);
let start = neighbor_offsets[i] as usize;
let end = neighbor_offsets[i + 1] as usize;
let mut laplacian = DVec3::ZERO;
for &j in &neighbor_indices[start..end] {
if j == i {
continue;
}
let r2 = pi_pos.distance_squared(particles[j].position);
if r2 > kc.h2 || r2 < 1e-20 {
continue;
}
let r = r2.sqrt();
let rho_j = particles[j].density.max(MIN_DENSITY);
let lap = kc.visc_laplacian(r);
laplacian += (snap_vel[j] - snap_vel[i]) * (particles[j].mass * lap / rho_j);
}
let new_vel = snap_vel[i] + laplacian * (viscosity * dt / rho);
let change = (new_vel - snap_vel[i]).length_squared();
max_change = max_change.max(change);
particles[i].velocity = new_vel;
}
if max_change < CONVERGENCE_THRESHOLD * CONVERGENCE_THRESHOLD {
break;
}
}
}
#[must_use]
pub fn split_particle(
parent: &FluidParticle,
n_children: usize,
offset: f64,
) -> Vec<FluidParticle> {
let _span = trace_span!("sph::split_particle").entered();
if n_children == 0 {
return vec![];
}
let child_mass = parent.mass / n_children as f64;
let mut children = Vec::with_capacity(n_children);
for k in 0..n_children {
let angle = 2.0 * std::f64::consts::PI * k as f64 / n_children as f64;
let mut child = *parent;
child.mass = child_mass;
child.position.x += offset * angle.cos();
child.position.y += offset * angle.sin();
children.push(child);
}
children
}
#[must_use]
pub fn merge_particles(a: &FluidParticle, b: &FluidParticle) -> FluidParticle {
let _span = trace_span!("sph::merge_particles").entered();
let total_mass = a.mass + b.mass;
let inv_mass = 1.0 / total_mass.max(1e-20);
FluidParticle {
position: (a.position * a.mass + b.position * b.mass) * inv_mass,
velocity: (a.velocity * a.mass + b.velocity * b.mass) * inv_mass,
acceleration: DVec3::ZERO,
density: (a.density * a.mass + b.density * b.mass) * inv_mass,
pressure: (a.pressure * a.mass + b.pressure * b.mass) * inv_mass,
mass: total_mass,
phase: a.phase,
conformation: a.conformation,
temperature: (a.temperature * a.mass + b.temperature * b.mass) * inv_mass,
fuel: (a.fuel * a.mass + b.fuel * b.mass) * inv_mass,
}
}
#[inline]
fn spread_bits(x: u32) -> u64 {
let mut v = x as u64;
v = (v | (v << 16)) & 0x0000_FFFF_0000_FFFF;
v = (v | (v << 8)) & 0x00FF_00FF_00FF_00FF;
v = (v | (v << 4)) & 0x0F0F_0F0F_0F0F_0F0F;
v = (v | (v << 2)) & 0x3333_3333_3333_3333;
v = (v | (v << 1)) & 0x5555_5555_5555_5555;
v
}
#[inline]
#[must_use]
fn morton_code_2d(x: f64, y: f64, inv_cell: f64) -> u64 {
let ix = (x * inv_cell).max(0.0) as u32;
let iy = (y * inv_cell).max(0.0) as u32;
spread_bits(ix) | (spread_bits(iy) << 1)
}
pub fn sort_by_zorder(particles: &mut [FluidParticle], cell_size: f64) {
let _span = trace_span!("sph::sort_by_zorder", n = particles.len()).entered();
if particles.len() < 2 || cell_size <= 0.0 {
return;
}
let inv_cell = 1.0 / cell_size;
particles.sort_unstable_by_key(|p| morton_code_2d(p.position[0], p.position[1], inv_cell));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SecondaryType {
Foam,
Spray,
Bubble,
}
#[derive(Debug, Clone, Copy)]
pub struct SecondaryParticle {
pub position: DVec3,
pub velocity: DVec3,
pub lifetime: f64,
pub kind: SecondaryType,
}
#[must_use]
pub fn generate_secondary_particles(
particles: &[FluidParticle],
accel_threshold: f64,
max_lifetime: f64,
) -> Vec<SecondaryParticle> {
let _span = trace_span!("sph::secondary", n = particles.len()).entered();
let mut secondary = Vec::new();
for p in particles {
let accel_mag = p.acceleration.length();
if accel_mag > accel_threshold {
secondary.push(SecondaryParticle {
position: p.position,
velocity: p.velocity * 1.2,
lifetime: max_lifetime * 0.5,
kind: SecondaryType::Spray,
});
} else if accel_mag > accel_threshold * 0.3 {
secondary.push(SecondaryParticle {
position: p.position,
velocity: p.velocity * 0.1,
lifetime: max_lifetime,
kind: SecondaryType::Foam,
});
}
}
secondary
}
pub fn update_secondary_particles(
secondaries: &mut Vec<SecondaryParticle>,
gravity: DVec3,
dt: f64,
) {
for s in secondaries.iter_mut() {
match s.kind {
SecondaryType::Spray => s.velocity += gravity * dt,
SecondaryType::Foam => s.velocity *= 0.98,
SecondaryType::Bubble => {
s.velocity.y += 0.5 * dt;
s.velocity *= 0.95;
}
}
s.position += s.velocity * dt;
s.lifetime -= dt;
}
secondaries.retain(|s| s.lifetime > 0.0);
}
#[must_use]
pub fn batch_poly6(r2_values: &[f64], h: f64) -> Vec<f64> {
let kc = KernelCoeffs::new(h);
r2_values
.iter()
.map(|&r2| {
if r2 > kc.h2 {
0.0
} else {
let diff = kc.h2 - r2;
kc.poly6 * diff * diff * diff
}
})
.collect()
}
#[must_use]
pub fn batch_distance_squared(pos: DVec3, positions: &[DVec3]) -> Vec<f64> {
positions
.iter()
.map(|&other| pos.distance_squared(other))
.collect()
}
#[must_use]
pub fn total_kinetic_energy(particles: &[FluidParticle]) -> f64 {
particles.iter().map(|p| p.kinetic_energy()).sum()
}
#[must_use]
pub fn max_speed(particles: &[FluidParticle]) -> f64 {
particles
.iter()
.map(|p| p.speed_squared())
.fold(0.0f64, f64::max)
.sqrt()
}
#[must_use]
pub fn create_particle_block(
origin: [f64; 2],
size: [f64; 2],
spacing: f64,
particle_mass: f64,
) -> Vec<FluidParticle> {
let nx = (size[0] / spacing).ceil() as usize;
let ny = (size[1] / spacing).ceil() as usize;
let mut particles = Vec::with_capacity(nx * ny);
for iy in 0..ny {
for ix in 0..nx {
let x = origin[0] + ix as f64 * spacing;
let y = origin[1] + iy as f64 * spacing;
particles.push(FluidParticle::new_2d(x, y, particle_mass));
}
}
particles
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-10;
#[test]
fn test_kernel_poly6_at_zero() {
let w = kernel_poly6(0.0, 1.0);
assert!(w > 0.0);
}
#[test]
fn test_kernel_poly6_at_boundary() {
let w = kernel_poly6(1.0, 1.0);
assert!(w.abs() < EPS);
}
#[test]
fn test_kernel_poly6_outside() {
let w = kernel_poly6(1.5, 1.0);
assert!(w.abs() < EPS);
}
#[test]
fn test_kernel_poly6_decreasing() {
let w1 = kernel_poly6(0.1, 1.0);
let w2 = kernel_poly6(0.5, 1.0);
assert!(w1 > w2);
}
#[test]
fn test_kernel_spiky_grad() {
let g = kernel_spiky_grad(0.5, 1.0);
assert!(g < 0.0);
}
#[test]
fn test_kernel_spiky_grad_outside() {
let g = kernel_spiky_grad(1.5, 1.0);
assert!(g.abs() < EPS);
}
#[test]
fn test_kernel_viscosity_laplacian() {
let l = kernel_viscosity_laplacian(0.5, 1.0);
assert!(l > 0.0);
}
#[test]
fn test_equation_of_state() {
let p = equation_of_state(1100.0, 1000.0, 2000.0);
assert!((p - 200000.0).abs() < EPS);
}
#[test]
fn test_equation_of_state_at_rest() {
let p = equation_of_state(1000.0, 1000.0, 2000.0);
assert!(p.abs() < EPS);
}
#[test]
fn test_compute_density_self() {
let particles = vec![FluidParticle::new_2d(0.0, 0.0, 1.0)];
let d = compute_density(0, &particles, 1.0);
assert!(d > 0.0);
}
#[test]
fn test_create_particle_block() {
let particles = create_particle_block([0.0, 0.0], [0.1, 0.1], 0.05, 0.01);
assert_eq!(particles.len(), 4);
}
#[test]
fn test_create_particle_block_positions() {
let particles = create_particle_block([1.0, 2.0], [0.1, 0.1], 0.05, 0.01);
assert!((particles[0].position[0] - 1.0).abs() < EPS);
assert!((particles[0].position[1] - 2.0).abs() < EPS);
}
#[test]
fn test_create_particle_block_preallocated() {
let particles = create_particle_block([0.0, 0.0], [1.0, 1.0], 0.1, 0.01);
assert!(particles.capacity() >= particles.len());
assert!(particles.capacity() <= particles.len() + 10);
}
#[test]
fn test_step_empty() {
let mut particles = vec![];
let config = FluidConfig::water_2d();
assert!(step(&mut particles, &config, 0.001).is_ok());
}
#[test]
fn test_step_single_particle_falls() {
let mut particles = vec![FluidParticle::new_2d(0.5, 0.5, 0.01)];
particles[0].density = 1000.0;
let config = FluidConfig::water_2d();
step(&mut particles, &config, 0.001).unwrap();
assert!(particles[0].velocity[1] < 0.0);
}
#[test]
fn test_step_boundary_enforcement() {
let mut particles = vec![FluidParticle::new_2d(0.5, -0.1, 0.01)];
particles[0].density = 1000.0;
let config = FluidConfig::water_2d();
step(&mut particles, &config, 0.001).unwrap();
assert!(particles[0].position[1] >= 0.0);
}
#[test]
fn test_total_kinetic_energy() {
let mut p = FluidParticle::new_2d(0.0, 0.0, 2.0);
p.velocity = DVec3::new(3.0, 4.0, 0.0);
let ke = total_kinetic_energy(&[p]);
assert!((ke - 25.0).abs() < EPS);
}
#[test]
fn test_max_speed() {
let mut p1 = FluidParticle::new_2d(0.0, 0.0, 1.0);
let mut p2 = FluidParticle::new_2d(1.0, 0.0, 1.0);
p1.velocity = DVec3::new(1.0, 0.0, 0.0);
p2.velocity = DVec3::new(3.0, 4.0, 0.0);
assert!((max_speed(&[p1, p2]) - 5.0).abs() < EPS);
}
#[test]
fn test_step_invalid_config() {
let mut particles = vec![FluidParticle::new_2d(0.5, 0.5, 0.01)];
let mut config = FluidConfig::water_2d();
config.dt = -1.0;
assert!(step(&mut particles, &config, 0.001).is_err());
}
#[test]
fn test_kernel_negative_r() {
assert!(kernel_poly6(-1.0, 1.0).abs() < EPS);
assert!(kernel_spiky_grad(-1.0, 1.0).abs() < EPS);
assert!(kernel_viscosity_laplacian(-1.0, 1.0).abs() < EPS);
}
#[test]
fn test_solver_empty() {
let mut particles = vec![];
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
assert!(solver.step(&mut particles, &config, 0.001).is_ok());
}
#[test]
fn test_solver_single_particle_falls() {
let mut particles = vec![FluidParticle::new_2d(0.5, 0.5, 0.01)];
particles[0].density = 1000.0;
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
assert!(particles[0].velocity[1] < 0.0);
}
#[test]
fn test_solver_particles_stay_in_bounds() {
let mut particles = create_particle_block([0.1, 0.5], [0.3, 0.3], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
let viscosity = 0.001;
for _ in 0..100 {
solver.step(&mut particles, &config, viscosity).unwrap();
}
let lo = config.bounds_min;
let hi = config.bounds_max;
for p in &particles {
assert!(p.position.x >= lo.x && p.position.x <= hi.x);
assert!(p.position.y >= lo.y && p.position.y <= hi.y);
}
}
#[test]
fn test_solver_surface_tension() {
let mut particles = create_particle_block([0.3, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::with_surface_tension(0.072);
for _ in 0..10 {
solver.step(&mut particles, &config, 0.001).unwrap();
}
for p in &particles {
assert!(p.position[0].is_finite());
assert!(p.position[1].is_finite());
}
}
#[test]
fn test_solver_reuses_buffers() {
let mut particles = create_particle_block([0.1, 0.1], [0.2, 0.2], 0.05, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
let cap_d = solver.densities.capacity();
let cap_s = solver.snapshot.capacity();
solver.step(&mut particles, &config, 0.001).unwrap();
assert_eq!(solver.densities.capacity(), cap_d);
assert_eq!(solver.snapshot.capacity(), cap_s);
}
#[test]
fn test_solver_neighbor_cache_used() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
assert_eq!(solver.neighbor_offsets.len(), particles.len() + 1);
for i in 0..particles.len() {
assert!(!solver.neighbors(i).is_empty());
}
}
#[test]
fn test_solver_grid_clear_reuse() {
let mut particles = create_particle_block([0.1, 0.1], [0.2, 0.2], 0.05, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
assert!((solver.cell_size - config.smoothing_radius as f32).abs() < f32::EPSILON);
}
#[test]
fn test_poly6_kernels_consistent() {
let kc = KernelCoeffs::new(1.0);
assert!(kc.poly6_grad_scalar(0.0).abs() > 0.0); let lap = kc.poly6_laplacian(0.0);
assert!(lap.is_finite());
assert!(kc.poly6(1.1).abs() < EPS);
assert!(kc.poly6_grad_scalar(1.1).abs() < EPS);
assert!(kc.poly6_laplacian(1.1).abs() < EPS);
}
#[test]
fn test_solver_step_deterministic() {
let config = FluidConfig::water_2d();
let particles_init = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let run = || -> Vec<FluidParticle> {
let mut p = particles_init.clone();
let mut solver = SphSolver::new();
for _ in 0..5 {
solver.step(&mut p, &config, 0.001).unwrap();
}
p
};
let r1 = run();
let r2 = run();
for (a, b) in r1.iter().zip(r2.iter()) {
assert!(
(a.position[0] - b.position[0]).abs() < 1e-10,
"runs should be deterministic"
);
}
}
#[test]
fn test_solver_large_particle_count() {
let mut particles = create_particle_block([0.1, 0.1], [0.5, 0.5], 0.01, 0.0001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
assert!(
particles.iter().all(|p| p.position[0].is_finite()),
"large particle simulation should stay finite"
);
}
#[test]
fn test_multiphase_single_phase_matches_standard() {
let config = FluidConfig::water_2d();
let phase_config =
MultiPhaseConfig::single(config.rest_density, config.gas_constant, 0.001);
let particles_init = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let mut p_standard = particles_init.clone();
let mut p_multi = particles_init;
let mut solver_s = SphSolver::new();
let mut solver_m = SphSolver::new();
solver_s.step(&mut p_standard, &config, 0.001).unwrap();
solver_m
.step_multiphase(&mut p_multi, &config, &phase_config)
.unwrap();
for (a, b) in p_standard.iter().zip(p_multi.iter()) {
assert!(
(a.position[0] - b.position[0]).abs() < 1e-10,
"single-phase multiphase should match standard"
);
}
}
#[test]
fn test_multiphase_two_phase_stable() {
let config = FluidConfig::water_2d();
let phase_config = MultiPhaseConfig::two_phase(
PhaseProperties {
rest_density: 1000.0,
gas_constant: 2000.0,
viscosity: 0.001,
},
PhaseProperties {
rest_density: 1.225,
gas_constant: 2000.0,
viscosity: 0.00001,
},
0.072,
);
let mut particles = create_particle_block([0.1, 0.1], [0.3, 0.3], 0.02, 0.001);
let mid_y = 0.25;
for p in particles.iter_mut() {
if p.position[1] > mid_y {
p.phase = 1;
p.mass = 0.0001; }
}
let mut solver = SphSolver::new();
for _ in 0..10 {
solver
.step_multiphase(&mut particles, &config, &phase_config)
.unwrap();
}
assert!(
particles.iter().all(|p| p.position[0].is_finite()),
"two-phase sim should stay finite"
);
}
#[test]
fn test_multiphase_phases_use_correct_density() {
let config = FluidConfig::water_2d();
let phase_config = MultiPhaseConfig::two_phase(
PhaseProperties {
rest_density: 1000.0,
gas_constant: 2000.0,
viscosity: 0.001,
},
PhaseProperties {
rest_density: 500.0,
gas_constant: 1000.0,
viscosity: 0.01,
},
0.0,
);
let mut particles = vec![
FluidParticle::new_2d(0.5, 0.5, 0.01),
FluidParticle::new_2d(0.52, 0.5, 0.01),
];
particles[1].phase = 1;
let mut solver = SphSolver::new();
solver
.step_multiphase(&mut particles, &config, &phase_config)
.unwrap();
assert!(particles[0].density > 0.0);
assert!(particles[1].density > 0.0);
assert!(
(particles[0].pressure - particles[1].pressure).abs() > 1e-6,
"different phases should produce different pressures: p0={}, p1={}",
particles[0].pressure,
particles[1].pressure
);
}
#[test]
fn test_multiphase_interface_tension() {
let config = FluidConfig::water_2d();
let phase_no_tension = MultiPhaseConfig::two_phase(
PhaseProperties {
rest_density: 1000.0,
gas_constant: 2000.0,
viscosity: 0.001,
},
PhaseProperties {
rest_density: 1000.0,
gas_constant: 2000.0,
viscosity: 0.001,
},
0.0,
);
let phase_with_tension = MultiPhaseConfig::two_phase(
PhaseProperties {
rest_density: 1000.0,
gas_constant: 2000.0,
viscosity: 0.001,
},
PhaseProperties {
rest_density: 1000.0,
gas_constant: 2000.0,
viscosity: 0.001,
},
0.5,
);
let make_particles = || {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.1], 0.02, 0.001);
let mut p2 = create_particle_block([0.2, 0.4], [0.2, 0.1], 0.02, 0.001);
for p in p2.iter_mut() {
p.phase = 1;
}
particles.extend(p2);
particles
};
let mut p_no = make_particles();
let mut p_with = make_particles();
let mut solver_no = SphSolver::new();
let mut solver_with = SphSolver::with_surface_tension(0.0);
for _ in 0..5 {
solver_no
.step_multiphase(&mut p_no, &config, &phase_no_tension)
.unwrap();
solver_with
.step_multiphase(&mut p_with, &config, &phase_with_tension)
.unwrap();
}
assert!(p_no.iter().all(|p| p.position[0].is_finite()));
assert!(p_with.iter().all(|p| p.position[0].is_finite()));
}
#[test]
fn test_combustion_below_ignition_no_reaction() {
let mut particles = vec![FluidParticle::new_2d(0.0, 0.0, 1.0)];
particles[0].fuel = 1.0;
particles[0].temperature = 300.0;
let config = CombustionConfig::default();
update_combustion(&mut particles, &config, 0.01);
assert!((particles[0].fuel - 1.0).abs() < 1e-10);
assert!((particles[0].temperature - 300.0).abs() < 1e-10);
}
#[test]
fn test_combustion_above_ignition_consumes_fuel() {
let mut particles = vec![FluidParticle::new_2d(0.0, 0.0, 1.0)];
particles[0].fuel = 1.0;
particles[0].temperature = 600.0;
let config = CombustionConfig::default();
update_combustion(&mut particles, &config, 0.01);
assert!(
particles[0].fuel < 1.0,
"fuel should deplete: {}",
particles[0].fuel
);
assert!(
particles[0].temperature > 600.0,
"should heat up: {}",
particles[0].temperature
);
}
#[test]
fn test_combustion_fuel_never_negative() {
let mut particles = vec![FluidParticle::new_2d(0.0, 0.0, 1.0)];
particles[0].fuel = 0.001;
particles[0].temperature = 1000.0;
let config = CombustionConfig {
reaction_rate: 1000.0,
..CombustionConfig::default()
};
update_combustion(&mut particles, &config, 1.0);
assert!(particles[0].fuel >= 0.0, "fuel should not go negative");
}
#[test]
fn test_combustion_no_fuel_no_reaction() {
let mut particles = vec![FluidParticle::new_2d(0.0, 0.0, 1.0)];
particles[0].fuel = 0.0;
particles[0].temperature = 800.0;
let config = CombustionConfig::default();
let t_before = particles[0].temperature;
update_combustion(&mut particles, &config, 0.01);
assert!((particles[0].temperature - t_before).abs() < 1e-10);
}
#[test]
fn test_heat_uniform_no_change() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
let heat = HeatConfig { diffusivity: 1e-7 };
let t_before: Vec<f64> = particles.iter().map(|p| p.temperature).collect();
update_heat(
&mut particles,
&solver.neighbor_offsets,
&solver.neighbor_indices,
config.smoothing_radius,
&heat,
0.001,
);
for (i, p) in particles.iter().enumerate() {
assert!(
(p.temperature - t_before[i]).abs() < 1e-10,
"uniform temperature should not change"
);
}
}
#[test]
fn test_heat_conducts_from_hot_to_cold() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let mid = particles.len() / 2;
particles[mid].temperature = 500.0;
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
let heat = HeatConfig { diffusivity: 1.0 };
let t_hot_before = particles[mid].temperature;
for _ in 0..50 {
update_heat(
&mut particles,
&solver.neighbor_offsets,
&solver.neighbor_indices,
config.smoothing_radius,
&heat,
0.001,
);
}
assert!(
particles[mid].temperature < t_hot_before,
"hot particle should cool: before={t_hot_before}, after={}",
particles[mid].temperature
);
}
#[test]
fn test_viscoelastic_relaxes_to_identity() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
for p in particles.iter_mut() {
p.density = 1000.0;
p.conformation = [2.0, 0.5, 1.5];
}
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
let ve = ViscoelasticConfig {
elastic_modulus: 100.0,
relaxation_time: 0.01,
};
for _ in 0..50 {
update_viscoelastic(
&mut particles,
&solver.neighbor_offsets,
&solver.neighbor_indices,
config.smoothing_radius,
&ve,
0.001,
);
}
let p = &particles[particles.len() / 2];
assert!(
(p.conformation[0] - 1.0).abs() < 0.5,
"c_xx should relax toward 1: {}",
p.conformation[0]
);
}
#[test]
fn test_viscoelastic_elastic_stress_finite() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
let ve = ViscoelasticConfig {
elastic_modulus: 500.0,
relaxation_time: 0.1,
};
update_viscoelastic(
&mut particles,
&solver.neighbor_offsets,
&solver.neighbor_indices,
config.smoothing_radius,
&ve,
0.001,
);
assert!(particles.iter().all(|p| p.acceleration[0].is_finite()));
assert!(particles.iter().all(|p| p.conformation[0].is_finite()));
}
#[test]
fn test_pcisph_empty() {
let mut particles = vec![];
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
assert!(
solver
.step_pcisph(&mut particles, &config, 0.001, 5, 0.01)
.is_ok()
);
}
#[test]
fn test_pcisph_single_particle_falls() {
let mut particles = vec![FluidParticle::new_2d(0.5, 0.5, 0.01)];
particles[0].density = 1000.0;
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver
.step_pcisph(&mut particles, &config, 0.001, 5, 0.01)
.unwrap();
assert!(particles[0].velocity[1] < 0.0);
}
#[test]
fn test_pcisph_density_bounded() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
for _ in 0..20 {
solver
.step_pcisph(&mut particles, &config, 0.001, 5, 0.01)
.unwrap();
}
for p in &particles {
assert!(p.density.is_finite() && p.density >= 0.0);
assert!(p.position[0].is_finite());
}
}
#[test]
fn test_adaptive_dt_stationary() {
let particles = create_particle_block([0.1, 0.1], [0.2, 0.2], 0.05, 0.001);
let dt = SphSolver::adaptive_dt(&particles, 0.05, 0.001, 0.4, 0.0001, 0.01);
assert!((dt - 0.01).abs() < EPS);
}
#[test]
fn test_adaptive_dt_fast_particles() {
let mut particles = create_particle_block([0.1, 0.1], [0.2, 0.2], 0.05, 0.001);
particles[0].velocity = DVec3::new(100.0, 0.0, 0.0);
let dt = SphSolver::adaptive_dt(&particles, 0.05, 0.001, 0.4, 0.0001, 0.01);
assert!(dt < 0.001);
assert!(dt >= 0.0001);
}
#[test]
fn test_wendland_c2_at_zero() {
let w = kernel_wendland_c2(0.0, 1.0);
assert!(w > 0.0);
}
#[test]
fn test_wendland_c2_at_boundary() {
let w = kernel_wendland_c2(2.0, 1.0);
assert!(w.abs() < EPS);
}
#[test]
fn test_wendland_c4_grad_numerical() {
let h = 1.0;
let r = 0.7;
let dr = 1e-6;
let numerical =
(kernel_wendland_c4(r + dr, h) - kernel_wendland_c4(r - dr, h)) / (2.0 * dr);
let analytical = kernel_wendland_c4_grad(r, h);
assert!(
(numerical - analytical).abs() < 1e-4,
"C4 gradient mismatch: numerical={numerical}, analytical={analytical}"
);
}
#[test]
fn test_wendland_c2_grad_numerical() {
let h = 1.0;
let r = 0.7;
let dr = 1e-6;
let numerical =
(kernel_wendland_c2(r + dr, h) - kernel_wendland_c2(r - dr, h)) / (2.0 * dr);
let analytical = kernel_wendland_c2_grad(r, h);
assert!(
(numerical - analytical).abs() < 1e-4,
"C2 gradient mismatch: numerical={numerical}, analytical={analytical}"
);
}
#[test]
fn test_wendland_c2_decreasing() {
let w1 = kernel_wendland_c2(0.1, 1.0);
let w2 = kernel_wendland_c2(0.5, 1.0);
assert!(w1 > w2);
}
#[test]
fn test_wendland_c4_at_zero() {
assert!(kernel_wendland_c4(0.0, 1.0) > 0.0);
}
#[test]
fn test_wendland_c2_grad_sign() {
let g = kernel_wendland_c2_grad(0.5, 1.0);
assert!(g < 0.0, "gradient should be negative (decreasing)");
}
#[test]
fn test_power_law_shear_thinning() {
let nn = NonNewtonianViscosity::PowerLaw {
consistency: 1.0,
power_index: 0.5,
};
let low_rate = nn.effective_viscosity(0.1);
let high_rate = nn.effective_viscosity(10.0);
assert!(
high_rate < low_rate,
"shear-thinning: viscosity should decrease with rate"
);
}
#[test]
fn test_bingham_yield_stress() {
let nn = NonNewtonianViscosity::Bingham {
yield_stress: 100.0,
plastic_viscosity: 0.1,
};
let low_rate = nn.effective_viscosity(0.001);
assert!(
low_rate > 1000.0,
"low strain rate should give very high effective viscosity"
);
}
#[test]
fn test_herschel_bulkley() {
let nn = NonNewtonianViscosity::HerschelBulkley {
yield_stress: 50.0,
consistency: 1.0,
power_index: 0.5,
};
let visc = nn.effective_viscosity(1.0);
assert!(visc > 50.0, "should include yield stress contribution");
assert!(visc.is_finite());
}
#[test]
fn test_sort_by_zorder() {
let mut particles = create_particle_block([0.0, 0.0], [1.0, 1.0], 0.1, 0.001);
let n = particles.len();
sort_by_zorder(&mut particles, 0.1);
assert_eq!(particles.len(), n);
assert!(particles.iter().all(|p| p.position[0].is_finite()));
}
#[test]
fn test_sort_by_zorder_preserves_data() {
let mut particles = create_particle_block([0.0, 0.0], [0.5, 0.5], 0.1, 0.001);
particles[0].velocity = DVec3::new(99.0, 0.0, 0.0);
let total_mass_before: f64 = particles.iter().map(|p| p.mass).sum();
sort_by_zorder(&mut particles, 0.1);
let total_mass_after: f64 = particles.iter().map(|p| p.mass).sum();
assert!((total_mass_before - total_mass_after).abs() < EPS);
assert!(particles.iter().any(|p| (p.velocity[0] - 99.0).abs() < EPS));
}
#[test]
fn test_grid_cfl_dt() {
use crate::grid::FluidGrid;
let mut g = FluidGrid::new(10, 10, 0.1).unwrap();
let i = g.idx(5, 5);
g.vx[i] = 1.0;
let dt = g.cfl_dt(0.001, 0.5);
assert!(dt > 0.0 && dt.is_finite());
assert!(dt <= 0.5 * 0.1 / 1.0); }
#[test]
fn test_shallow_cfl_dt() {
use crate::shallow::ShallowWater;
let sw = ShallowWater::new(10, 10, 0.1, 1.0).unwrap();
let dt = sw.cfl_dt(0.5);
assert!(dt > 0.0 && dt.is_finite());
assert!(dt < 0.1);
}
#[test]
fn test_dfsph_empty() {
let mut particles = vec![];
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
assert!(
solver
.step_dfsph(&mut particles, &config, 0.001, 3, 0.01)
.is_ok()
);
}
#[test]
fn test_dfsph_single_particle_falls() {
let mut particles = vec![FluidParticle::new_2d(0.5, 0.5, 0.01)];
particles[0].density = 1000.0;
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver
.step_dfsph(&mut particles, &config, 0.001, 3, 0.01)
.unwrap();
assert!(particles[0].velocity.y < 0.0, "should fall under gravity");
}
#[test]
fn test_dfsph_particles_finite() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
for _ in 0..5 {
solver
.step_dfsph(&mut particles, &config, 0.001, 3, 0.01)
.unwrap();
}
assert!(particles.iter().all(|p| p.position.x.is_finite()));
}
#[test]
fn test_mls_correction_interior() {
let mut particles = create_particle_block([0.2, 0.3], [0.2, 0.2], 0.02, 0.001);
let config = FluidConfig::water_2d();
let mut solver = SphSolver::new();
solver.step(&mut particles, &config, 0.001).unwrap();
let corrections = compute_gradient_corrections(
&particles,
&solver.neighbor_offsets,
&solver.neighbor_indices,
config.smoothing_radius,
);
assert_eq!(corrections.len(), particles.len());
let mid = corrections.len() / 2;
let c = corrections[mid];
assert!(c[0].is_finite() && c[4].is_finite() && c[8].is_finite());
}
#[test]
fn test_apply_gradient_correction_identity() {
let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
let result = apply_gradient_correction(&identity, DVec3::new(2.0, 3.0, 4.0));
assert!((result.x - 2.0).abs() < 1e-10);
assert!((result.y - 3.0).abs() < 1e-10);
assert!((result.z - 4.0).abs() < 1e-10);
}
}