Skip to main content

oxiphysics_gpu/kernels/
sph.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! SPH (Smoothed Particle Hydrodynamics) compute kernels.
5
6use crate::compute::ComputeKernel;
7use std::f64::consts::PI;
8
9// ─────────────────────────────────────────────────────────────────────────────
10// High-level SPH kernel API
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Selects the smoothing kernel function to use for SPH computations.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SphKernel {
16    /// Cubic B-spline kernel (C2 continuity, widely used in SPH).
17    CubicSpline,
18    /// Wendland C2 kernel (positive definite, no negative lobes).
19    Wendland,
20    /// Poly6 kernel (efficient for density, but poor gradient).
21    Poly6,
22    /// Spiky kernel (non-zero gradient at origin, good for pressure).
23    Spiky,
24}
25
26/// Pre-computed parameters for a smoothing kernel with smoothing length `h`.
27#[derive(Debug, Clone, Copy)]
28pub struct SphKernelParams {
29    /// Smoothing length.
30    pub h: f64,
31    /// 1 / h.
32    pub d_inv: f64,
33    /// 1 / h^3.
34    pub d3_inv: f64,
35}
36
37impl SphKernelParams {
38    /// Construct `SphKernelParams` from smoothing length `h`.
39    pub fn new(h: f64) -> Self {
40        Self {
41            h,
42            d_inv: 1.0 / h,
43            d3_inv: 1.0 / (h * h * h),
44        }
45    }
46}
47
48/// Evaluate the smoothing kernel W(r, h) for a given kernel type.
49///
50/// Returns 0 when `r >= h` (compact support).
51pub fn kernel_value(r: f64, params: &SphKernelParams, kernel: SphKernel) -> f64 {
52    let q = r * params.d_inv; // dimensionless distance
53    match kernel {
54        SphKernel::CubicSpline => {
55            // 3-D cubic spline, alpha = 8/(pi h^3)
56            let alpha = 8.0 / (PI * params.h.powi(3));
57            if q >= 1.0 {
58                0.0
59            } else if q >= 0.5 {
60                let t = 1.0 - q;
61                alpha * 2.0 * t.powi(3)
62            } else {
63                alpha * (6.0 * q.powi(3) - 6.0 * q * q + 1.0)
64            }
65        }
66        SphKernel::Wendland => {
67            // Wendland C2 in 3-D, alpha = 21/(2 pi h^3)
68            let alpha = 21.0 / (2.0 * PI * params.h.powi(3));
69            if q >= 1.0 {
70                0.0
71            } else {
72                let t = 1.0 - q;
73                alpha * t.powi(4) * (4.0 * q + 1.0)
74            }
75        }
76        SphKernel::Poly6 => {
77            let h2 = params.h * params.h;
78            let r2 = r * r;
79            if r2 >= h2 {
80                return 0.0;
81            }
82            let coeff = 315.0 / (64.0 * PI * params.h.powi(9));
83            coeff * (h2 - r2).powi(3)
84        }
85        SphKernel::Spiky => {
86            if q >= 1.0 {
87                return 0.0;
88            }
89            let coeff = 15.0 / (PI * params.h.powi(6));
90            coeff * (params.h - r).powi(3)
91        }
92    }
93}
94
95/// Evaluate the gradient nabla W(r_vec, h) of the smoothing kernel.
96///
97/// `r_vec` is the vector from particle j to particle i (r_i - r_j).
98/// `r` is its magnitude.  Returns the zero vector when `r < 1e-12` or `r >= h`.
99pub fn kernel_gradient(
100    r_vec: [f64; 3],
101    r: f64,
102    params: &SphKernelParams,
103    kernel: SphKernel,
104) -> [f64; 3] {
105    if r < 1e-12 || r * params.d_inv >= 1.0 {
106        return [0.0; 3];
107    }
108    let dw_dr = kernel_gradient_mag(r, params, kernel);
109    let scale = dw_dr / r;
110    [r_vec[0] * scale, r_vec[1] * scale, r_vec[2] * scale]
111}
112
113/// Scalar dW/dr (negative for most kernels beyond their core).
114fn kernel_gradient_mag(r: f64, params: &SphKernelParams, kernel: SphKernel) -> f64 {
115    let q = r * params.d_inv;
116    match kernel {
117        SphKernel::CubicSpline => {
118            let alpha = 8.0 / (PI * params.h.powi(3));
119            if q >= 1.0 {
120                0.0
121            } else if q >= 0.5 {
122                let t = 1.0 - q;
123                alpha * (-6.0 * t * t) * params.d_inv
124            } else {
125                alpha * (18.0 * q * q - 12.0 * q) * params.d_inv
126            }
127        }
128        SphKernel::Wendland => {
129            let alpha = 21.0 / (2.0 * PI * params.h.powi(3));
130            if q >= 1.0 {
131                0.0
132            } else {
133                let t = 1.0 - q;
134                // d/dr [(1-q)^4 (4q+1)] * alpha
135                // = alpha * d_inv * [-4(1-q)^3(4q+1) + (1-q)^4 * 4]
136                alpha * params.d_inv * t.powi(3) * (-4.0 * (4.0 * q + 1.0) + 4.0 * t)
137            }
138        }
139        SphKernel::Poly6 => {
140            let h2 = params.h * params.h;
141            let r2 = r * r;
142            if r2 >= h2 {
143                return 0.0;
144            }
145            let coeff = 315.0 / (64.0 * PI * params.h.powi(9));
146            // d/dr [(h^2-r^2)^3] = -6r(h^2-r^2)^2
147            coeff * (-6.0 * r) * (h2 - r2).powi(2)
148        }
149        SphKernel::Spiky => {
150            if q >= 1.0 {
151                return 0.0;
152            }
153            let coeff = 15.0 / (PI * params.h.powi(6));
154            // d/dr [(h-r)^3] = -3(h-r)^2
155            coeff * (-3.0) * (params.h - r).powi(2)
156        }
157    }
158}
159
160/// Compute per-particle densities using SPH summation.
161///
162/// `rho_i = sum_j m_j * W(|r_i - r_j|, h)`
163pub fn density_summation(positions: &[[f64; 3]], masses: &[f64], h: f64) -> Vec<f64> {
164    let params = SphKernelParams::new(h);
165    let n = positions.len();
166    let mut densities = vec![0.0f64; n];
167    for i in 0..n {
168        let mut rho = 0.0;
169        for j in 0..n {
170            let dx = positions[i][0] - positions[j][0];
171            let dy = positions[i][1] - positions[j][1];
172            let dz = positions[i][2] - positions[j][2];
173            let r = (dx * dx + dy * dy + dz * dz).sqrt();
174            rho += masses[j] * kernel_value(r, &params, SphKernel::CubicSpline);
175        }
176        densities[i] = rho;
177    }
178    densities
179}
180
181/// Compute per-particle densities with a specified kernel type.
182pub fn density_summation_kernel(
183    positions: &[[f64; 3]],
184    masses: &[f64],
185    h: f64,
186    kernel: SphKernel,
187) -> Vec<f64> {
188    let params = SphKernelParams::new(h);
189    let n = positions.len();
190    let mut densities = vec![0.0f64; n];
191    for i in 0..n {
192        let mut rho = 0.0;
193        for j in 0..n {
194            let dx = positions[i][0] - positions[j][0];
195            let dy = positions[i][1] - positions[j][1];
196            let dz = positions[i][2] - positions[j][2];
197            let r = (dx * dx + dy * dy + dz * dz).sqrt();
198            rho += masses[j] * kernel_value(r, &params, kernel);
199        }
200        densities[i] = rho;
201    }
202    densities
203}
204
205/// Compute pressure forces using the SPH symmetric pressure gradient formulation.
206///
207/// `F_i^pressure = -sum_j m_j (p_i/rho_i^2 + p_j/rho_j^2) nabla W(r_ij, h)`
208///
209/// Returns a `Vec<[f64;3]>` of forces, one per particle.
210pub fn pressure_force(
211    positions: &[[f64; 3]],
212    _velocities: &[[f64; 3]],
213    densities: &[f64],
214    pressures: &[f64],
215    masses: &[f64],
216    h: f64,
217) -> Vec<[f64; 3]> {
218    let params = SphKernelParams::new(h);
219    let n = positions.len();
220    let mut forces = vec![[0.0f64; 3]; n];
221    for i in 0..n {
222        let mut fx = 0.0f64;
223        let mut fy = 0.0f64;
224        let mut fz = 0.0f64;
225        let pi_over_rhoi2 = if densities[i].abs() > 1e-30 {
226            pressures[i] / (densities[i] * densities[i])
227        } else {
228            0.0
229        };
230        for j in 0..n {
231            if i == j {
232                continue;
233            }
234            let r_vec = [
235                positions[i][0] - positions[j][0],
236                positions[i][1] - positions[j][1],
237                positions[i][2] - positions[j][2],
238            ];
239            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
240            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
241            let pj_over_rhoj2 = if densities[j].abs() > 1e-30 {
242                pressures[j] / (densities[j] * densities[j])
243            } else {
244                0.0
245            };
246            let coeff = -masses[j] * (pi_over_rhoi2 + pj_over_rhoj2);
247            fx += coeff * grad[0];
248            fy += coeff * grad[1];
249            fz += coeff * grad[2];
250        }
251        forces[i] = [fx, fy, fz];
252    }
253    forces
254}
255
256/// Compute viscosity forces using the SPH viscosity formulation.
257///
258/// `F_i^visc = mu * sum_j m_j (v_j - v_i) / rho_j * laplacian_W(r_ij, h)`
259pub fn viscosity_force(
260    positions: &[[f64; 3]],
261    velocities: &[[f64; 3]],
262    densities: &[f64],
263    masses: &[f64],
264    h: f64,
265    mu: f64,
266) -> Vec<[f64; 3]> {
267    let n = positions.len();
268    let mut forces = vec![[0.0f64; 3]; n];
269    for i in 0..n {
270        let mut fx = 0.0f64;
271        let mut fy = 0.0f64;
272        let mut fz = 0.0f64;
273        for j in 0..n {
274            if i == j {
275                continue;
276            }
277            let dx = positions[i][0] - positions[j][0];
278            let dy = positions[i][1] - positions[j][1];
279            let dz = positions[i][2] - positions[j][2];
280            let r = (dx * dx + dy * dy + dz * dz).sqrt();
281            if r >= h || r < 1e-12 {
282                continue;
283            }
284            let lap = viscosity_laplacian(r, h);
285            let rho_j = if densities[j].abs() > 1e-30 {
286                densities[j]
287            } else {
288                1.0
289            };
290            fx += mu * masses[j] * (velocities[j][0] - velocities[i][0]) / rho_j * lap;
291            fy += mu * masses[j] * (velocities[j][1] - velocities[i][1]) / rho_j * lap;
292            fz += mu * masses[j] * (velocities[j][2] - velocities[i][2]) / rho_j * lap;
293        }
294        forces[i] = [fx, fy, fz];
295    }
296    forces
297}
298
299// ─────────────────────────────────────────────────────────────────────────────
300// Neighbor list
301// ─────────────────────────────────────────────────────────────────────────────
302
303/// A simple grid-based neighbor list for SPH.
304///
305/// Divides the domain into cells of size `h` and assigns each particle to a cell.
306/// Finding neighbors then only requires checking the local cell and its 26 neighbors.
307pub struct NeighborList {
308    /// Cell size (= smoothing length h).
309    cell_size: f64,
310    /// Number of cells in x, y, z.
311    grid_dims: [usize; 3],
312    /// Domain origin.
313    origin: [f64; 3],
314    /// Cell -> list of particle indices.
315    cells: Vec<Vec<usize>>,
316}
317
318impl NeighborList {
319    /// Create a new neighbor list for the given domain.
320    pub fn new(origin: [f64; 3], domain_size: [f64; 3], cell_size: f64) -> Self {
321        let nx = (domain_size[0] / cell_size).ceil() as usize;
322        let ny = (domain_size[1] / cell_size).ceil() as usize;
323        let nz = (domain_size[2] / cell_size).ceil() as usize;
324        let total = nx.max(1) * ny.max(1) * nz.max(1);
325        Self {
326            cell_size,
327            grid_dims: [nx.max(1), ny.max(1), nz.max(1)],
328            origin,
329            cells: vec![Vec::new(); total],
330        }
331    }
332
333    /// Clear all cells and re-assign particles.
334    pub fn build(&mut self, positions: &[[f64; 3]]) {
335        for cell in &mut self.cells {
336            cell.clear();
337        }
338        for (idx, pos) in positions.iter().enumerate() {
339            let ci = self.cell_index(pos);
340            self.cells[ci].push(idx);
341        }
342    }
343
344    /// Get the cell index for a position.
345    fn cell_index(&self, pos: &[f64; 3]) -> usize {
346        let ix = ((pos[0] - self.origin[0]) / self.cell_size).floor() as usize;
347        let iy = ((pos[1] - self.origin[1]) / self.cell_size).floor() as usize;
348        let iz = ((pos[2] - self.origin[2]) / self.cell_size).floor() as usize;
349        let ix = ix.min(self.grid_dims[0] - 1);
350        let iy = iy.min(self.grid_dims[1] - 1);
351        let iz = iz.min(self.grid_dims[2] - 1);
352        iz * self.grid_dims[1] * self.grid_dims[0] + iy * self.grid_dims[0] + ix
353    }
354
355    /// Return the neighbor particle indices for a given particle.
356    /// Checks the particle's cell and all 26 neighboring cells.
357    pub fn neighbors(&self, pos: &[f64; 3]) -> Vec<usize> {
358        let ix = ((pos[0] - self.origin[0]) / self.cell_size).floor() as i64;
359        let iy = ((pos[1] - self.origin[1]) / self.cell_size).floor() as i64;
360        let iz = ((pos[2] - self.origin[2]) / self.cell_size).floor() as i64;
361
362        let mut result = Vec::new();
363        let dims = self.grid_dims;
364
365        for dz in -1i64..=1 {
366            for dy in -1i64..=1 {
367                for dx in -1i64..=1 {
368                    let cx = ix + dx;
369                    let cy = iy + dy;
370                    let cz = iz + dz;
371                    if cx < 0 || cy < 0 || cz < 0 {
372                        continue;
373                    }
374                    let cx = cx as usize;
375                    let cy = cy as usize;
376                    let cz = cz as usize;
377                    if cx >= dims[0] || cy >= dims[1] || cz >= dims[2] {
378                        continue;
379                    }
380                    let ci = cz * dims[1] * dims[0] + cy * dims[0] + cx;
381                    result.extend_from_slice(&self.cells[ci]);
382                }
383            }
384        }
385        result
386    }
387
388    /// Total number of cells in the grid.
389    pub fn num_cells(&self) -> usize {
390        self.grid_dims[0] * self.grid_dims[1] * self.grid_dims[2]
391    }
392
393    /// Grid dimensions \[nx, ny, nz\].
394    pub fn grid_dims(&self) -> [usize; 3] {
395        self.grid_dims
396    }
397}
398
399// ─────────────────────────────────────────────────────────────────────────────
400// Kernel dispatch configuration
401// ─────────────────────────────────────────────────────────────────────────────
402
403/// Configuration for dispatching SPH compute kernels.
404pub struct SphDispatchConfig {
405    /// Number of particles.
406    pub n_particles: usize,
407    /// Smoothing length.
408    pub h: f64,
409    /// Viscosity coefficient.
410    pub mu: f64,
411    /// Equation of state stiffness (for pressure from density).
412    pub k_eos: f64,
413    /// Rest density.
414    pub rho0: f64,
415    /// Workgroup size for GPU dispatch.
416    pub workgroup_size: u32,
417}
418
419impl SphDispatchConfig {
420    /// Create a new dispatch config with default viscosity and EOS parameters.
421    pub fn new(n_particles: usize, h: f64) -> Self {
422        Self {
423            n_particles,
424            h,
425            mu: 0.1,
426            k_eos: 1000.0,
427            rho0: 1000.0,
428            workgroup_size: 64,
429        }
430    }
431
432    /// Compute pressure from density using a simple EOS: p = k * (rho - rho0).
433    pub fn pressure_from_density(&self, rho: f64) -> f64 {
434        self.k_eos * (rho - self.rho0).max(0.0)
435    }
436
437    /// Number of workgroups needed.
438    pub fn num_workgroups(&self) -> u32 {
439        (self.n_particles as u32).div_ceil(self.workgroup_size)
440    }
441}
442
443// ─────────────────────────────────────────────────────────────────────────────
444// SPH buffer layout descriptor
445// ─────────────────────────────────────────────────────────────────────────────
446
447/// Describes the buffer layout for SPH simulation data.
448pub struct SphBufferLayout {
449    /// Number of particles.
450    pub n_particles: usize,
451    /// Size of position buffer (3 * n_particles).
452    pub position_size: usize,
453    /// Size of velocity buffer (3 * n_particles).
454    pub velocity_size: usize,
455    /// Size of mass buffer (n_particles).
456    pub mass_size: usize,
457    /// Size of density buffer (n_particles).
458    pub density_size: usize,
459    /// Size of pressure buffer (n_particles).
460    pub pressure_size: usize,
461    /// Size of force buffer (3 * n_particles).
462    pub force_size: usize,
463}
464
465impl SphBufferLayout {
466    /// Create a buffer layout for the given number of particles.
467    pub fn new(n_particles: usize) -> Self {
468        Self {
469            n_particles,
470            position_size: 3 * n_particles,
471            velocity_size: 3 * n_particles,
472            mass_size: n_particles,
473            density_size: n_particles,
474            pressure_size: n_particles,
475            force_size: 3 * n_particles,
476        }
477    }
478
479    /// Total number of f64 elements needed across all buffers.
480    pub fn total_elements(&self) -> usize {
481        self.position_size
482            + self.velocity_size
483            + self.mass_size
484            + self.density_size
485            + self.pressure_size
486            + self.force_size
487    }
488
489    /// Total memory in bytes (f64 = 8 bytes).
490    pub fn total_bytes(&self) -> usize {
491        self.total_elements() * 8
492    }
493}
494
495/// Kernel that computes SPH particle densities.
496///
497/// **Inputs:**
498///   - `inputs[0]`: positions, flat `[x, y, z, ...]` (3 * n values)
499///   - `inputs[1]`: masses, `[m0, m1, ...]` (n values)
500///   - `inputs[2]`: `[smoothing_length]` (single value)
501///
502/// **Outputs:**
503///   - `outputs[0]`: densities, one per particle
504pub struct SphDensityKernel;
505
506/// Poly6 smoothing kernel value.
507#[inline]
508fn poly6(r2: f64, h: f64) -> f64 {
509    let h2 = h * h;
510    if r2 >= h2 {
511        return 0.0;
512    }
513    let coeff = 315.0 / (64.0 * PI * h.powi(9));
514    coeff * (h2 - r2).powi(3)
515}
516
517/// Spiky kernel gradient magnitude (scalar, multiply by direction).
518#[inline]
519fn spiky_grad(r: f64, h: f64) -> f64 {
520    if r >= h || r < 1e-12 {
521        return 0.0;
522    }
523    let coeff = -45.0 / (PI * h.powi(6));
524    coeff * (h - r).powi(2)
525}
526
527/// Viscosity Laplacian kernel.
528#[inline]
529fn viscosity_laplacian(r: f64, h: f64) -> f64 {
530    if r >= h || r < 1e-12 {
531        return 0.0;
532    }
533    45.0 / (PI * h.powi(6)) * (h - r)
534}
535
536impl ComputeKernel for SphDensityKernel {
537    fn name(&self) -> &str {
538        "SphDensityKernel"
539    }
540
541    fn execute(&self, inputs: &[&[f64]], outputs: &mut [Vec<f64>], work_size: usize) {
542        if inputs.len() < 3 || outputs.is_empty() {
543            return;
544        }
545        let positions = inputs[0];
546        let masses = inputs[1];
547        let h = inputs[2][0];
548        let n = work_size;
549
550        let mut densities = vec![0.0; n];
551        for i in 0..n {
552            let xi = [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
553            let mut rho = 0.0;
554            for j in 0..n {
555                let xj = [positions[j * 3], positions[j * 3 + 1], positions[j * 3 + 2]];
556                let dx = xi[0] - xj[0];
557                let dy = xi[1] - xj[1];
558                let dz = xi[2] - xj[2];
559                let r2 = dx * dx + dy * dy + dz * dz;
560                rho += masses[j] * poly6(r2, h);
561            }
562            densities[i] = rho;
563        }
564        outputs[0] = densities;
565    }
566}
567
568/// Kernel that computes SPH pressure and viscosity forces.
569///
570/// **Inputs:**
571///   - `inputs[0]`: positions `[x, y, z, ...]` (3n)
572///   - `inputs[1]`: velocities `[vx, vy, vz, ...]` (3n)
573///   - `inputs[2]`: densities `[rho0, rho1, ...]` (n)
574///   - `inputs[3]`: pressures `[p0, p1, ...]` (n)
575///   - `inputs[4]`: masses `[m0, m1, ...]` (n)
576///   - `inputs[5]`: `[smoothing_length, viscosity_coeff]` (2 values)
577///
578/// **Outputs:**
579///   - `outputs[0]`: forces `[fx, fy, fz, ...]` (3n)
580pub struct SphForceKernel;
581
582impl ComputeKernel for SphForceKernel {
583    fn name(&self) -> &str {
584        "SphForceKernel"
585    }
586
587    fn execute(&self, inputs: &[&[f64]], outputs: &mut [Vec<f64>], work_size: usize) {
588        if inputs.len() < 6 || outputs.is_empty() {
589            return;
590        }
591        let pos = inputs[0];
592        let vel = inputs[1];
593        let density = inputs[2];
594        let pressure = inputs[3];
595        let mass = inputs[4];
596        let h = inputs[5][0];
597        let mu = inputs[5][1]; // viscosity coefficient
598        let n = work_size;
599
600        let mut forces = vec![0.0; n * 3];
601        for i in 0..n {
602            let xi = [pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]];
603            let vi = [vel[i * 3], vel[i * 3 + 1], vel[i * 3 + 2]];
604            let mut fx = 0.0;
605            let mut fy = 0.0;
606            let mut fz = 0.0;
607            for j in 0..n {
608                if i == j {
609                    continue;
610                }
611                let xj = [pos[j * 3], pos[j * 3 + 1], pos[j * 3 + 2]];
612                let vj = [vel[j * 3], vel[j * 3 + 1], vel[j * 3 + 2]];
613                let dx = xi[0] - xj[0];
614                let dy = xi[1] - xj[1];
615                let dz = xi[2] - xj[2];
616                let r = (dx * dx + dy * dy + dz * dz).sqrt();
617                if r < 1e-12 || r >= h {
618                    continue;
619                }
620                // Pressure force (Navier-Stokes)
621                let p_term =
622                    -mass[j] * (pressure[i] + pressure[j]) / (2.0 * density[j]) * spiky_grad(r, h);
623                fx += p_term * dx / r;
624                fy += p_term * dy / r;
625                fz += p_term * dz / r;
626                // Viscosity force
627                let v_lap = viscosity_laplacian(r, h);
628                fx += mu * mass[j] * (vj[0] - vi[0]) / density[j] * v_lap;
629                fy += mu * mass[j] * (vj[1] - vi[1]) / density[j] * v_lap;
630                fz += mu * mass[j] * (vj[2] - vi[2]) / density[j] * v_lap;
631            }
632            forces[i * 3] = fx;
633            forces[i * 3 + 1] = fy;
634            forces[i * 3 + 2] = fz;
635        }
636        outputs[0] = forces;
637    }
638}
639
640/// Kernel that constructs a neighbor list on the CPU.
641///
642/// **Inputs:**
643///   - `inputs[0]`: positions `[x, y, z, ...]` (3n)
644///   - `inputs[1]`: `[h, origin_x, origin_y, origin_z, domain_x, domain_y, domain_z]`
645///
646/// **Outputs:**
647///   - `outputs[0]`: cell indices (one per particle)
648pub struct SphNeighborListKernel;
649
650impl ComputeKernel for SphNeighborListKernel {
651    fn name(&self) -> &str {
652        "SphNeighborListKernel"
653    }
654
655    fn execute(&self, inputs: &[&[f64]], outputs: &mut [Vec<f64>], work_size: usize) {
656        if inputs.len() < 2 || outputs.is_empty() {
657            return;
658        }
659        let positions = inputs[0];
660        let params = inputs[1];
661        if params.len() < 7 {
662            return;
663        }
664        let h = params[0];
665        let origin = [params[1], params[2], params[3]];
666        let _domain = [params[4], params[5], params[6]];
667        let n = work_size;
668
669        // Compute cell index for each particle
670        let nx = (_domain[0] / h).ceil() as usize;
671        let ny = (_domain[1] / h).ceil() as usize;
672        let nx = nx.max(1);
673        let ny = ny.max(1);
674
675        let mut cell_indices = vec![0.0f64; n];
676        for i in 0..n {
677            let px = positions[i * 3] - origin[0];
678            let py = positions[i * 3 + 1] - origin[1];
679            let pz = positions[i * 3 + 2] - origin[2];
680            let ix = (px / h).floor() as usize;
681            let iy = (py / h).floor() as usize;
682            let iz = (pz / h).floor() as usize;
683            cell_indices[i] = (iz * ny * nx + iy * nx + ix) as f64;
684        }
685        outputs[0] = cell_indices;
686    }
687}
688
689// ─────────────────────────────────────────────────────────────────────────────
690// Surface tension kernel (color function gradient method)
691// ─────────────────────────────────────────────────────────────────────────────
692
693/// Compute surface tension forces using the color function gradient (CSF) method.
694///
695/// The color function gradient approximates the interface normal.
696/// The curvature κ is estimated from the divergence of the unit normal.
697/// The surface tension force on particle `i` is:
698///
699/// `F_i^surf = σ · m_i / ρ_i · ∇(c_i)`
700///
701/// where `∇c_i = sum_j m_j/ρ_j * (c_j - c_i) * ∇W(r_ij, h)`.
702///
703/// # Arguments
704/// * `positions`  - Particle positions.
705/// * `color_fn`   - Color function value per particle (1 = fluid, 0 = void).
706/// * `masses`     - Per-particle masses.
707/// * `densities`  - Per-particle densities.
708/// * `h`          - Smoothing length.
709/// * `sigma`      - Surface tension coefficient.
710pub fn surface_tension_force(
711    positions: &[[f64; 3]],
712    color_fn: &[f64],
713    masses: &[f64],
714    densities: &[f64],
715    h: f64,
716    sigma: f64,
717) -> Vec<[f64; 3]> {
718    let params = SphKernelParams::new(h);
719    let n = positions.len();
720    let mut forces = vec![[0.0f64; 3]; n];
721
722    // Step 1: compute color function gradient ∇c_i for each particle
723    let mut grad_c = vec![[0.0f64; 3]; n];
724    for i in 0..n {
725        let rho_i = if densities[i].abs() > 1e-30 {
726            densities[i]
727        } else {
728            1.0
729        };
730        let mut gx = 0.0f64;
731        let mut gy = 0.0f64;
732        let mut gz = 0.0f64;
733        for j in 0..n {
734            if i == j {
735                continue;
736            }
737            let r_vec = [
738                positions[i][0] - positions[j][0],
739                positions[i][1] - positions[j][1],
740                positions[i][2] - positions[j][2],
741            ];
742            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
743            let grad_w = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
744            let rho_j = if densities[j].abs() > 1e-30 {
745                densities[j]
746            } else {
747                1.0
748            };
749            let dc = masses[j] / rho_j * (color_fn[j] - color_fn[i]);
750            gx += dc * grad_w[0];
751            gy += dc * grad_w[1];
752            gz += dc * grad_w[2];
753        }
754        grad_c[i] = [gx, gy, gz];
755
756        // Step 2: surface tension force F_i = sigma * m_i / rho_i * grad_c_i
757        let prefactor = sigma * masses[i] / rho_i;
758        forces[i] = [prefactor * gx, prefactor * gy, prefactor * gz];
759    }
760    forces
761}
762
763// ─────────────────────────────────────────────────────────────────────────────
764// CFL time step reduction
765// ─────────────────────────────────────────────────────────────────────────────
766
767/// Compute the CFL-limited time step for a particle system.
768///
769/// The CFL condition restricts the time step to:
770/// `Δt = cfl_factor · h / max(|v_i| + c_sound)`
771///
772/// where the maximum is taken over all particles.
773///
774/// # Arguments
775/// * `velocities` - Per-particle velocity vectors.
776/// * `h`          - Smoothing length (characteristic length scale).
777/// * `c_sound`    - Speed of sound (used as minimum wave speed).
778/// * `cfl_factor` - CFL safety factor (typically 0.1 – 0.4).
779pub fn cfl_timestep(velocities: &[[f64; 3]], h: f64, c_sound: f64, cfl_factor: f64) -> f64 {
780    let max_signal = velocities
781        .iter()
782        .map(|v| {
783            let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
784            speed + c_sound
785        })
786        .fold(0.0f64, f64::max);
787
788    let denominator = if max_signal > 1e-30 {
789        max_signal
790    } else {
791        c_sound.max(1e-30)
792    };
793    cfl_factor * h / denominator
794}
795
796// ─────────────────────────────────────────────────────────────────────────────
797// Radix sort by density (for load balancing)
798// ─────────────────────────────────────────────────────────────────────────────
799
800/// Sort particle indices in ascending order of density using a reference sort.
801///
802/// This CPU reference implementation mirrors what a GPU radix sort would do
803/// for load-balancing purposes: denser regions can be binned together for
804/// more uniform workloads.
805///
806/// Returns a permutation `indices` such that `densities[indices[0\]] <= densities[indices[1\]] ...`.
807pub fn radix_sort_by_density(densities: &[f64]) -> Vec<usize> {
808    let mut indices: Vec<usize> = (0..densities.len()).collect();
809    indices.sort_by(|&a, &b| {
810        densities[a]
811            .partial_cmp(&densities[b])
812            .unwrap_or(std::cmp::Ordering::Equal)
813    });
814    indices
815}
816
817// ─────────────────────────────────────────────────────────────────────────────
818// SPH density accumulation kernel (GPU-style parallel)
819// ─────────────────────────────────────────────────────────────────────────────
820
821/// Accumulate density contributions from a neighbour list.
822///
823/// For each particle `i`, sums contributions from its neighbours `j`:
824/// `rho_i += m_j * W(|r_i - r_j|, h)`
825///
826/// Only pairs within distance `h` contribute.  The neighbour list is expected
827/// to already be built for the given positions.
828pub fn density_accumulation(
829    positions: &[[f64; 3]],
830    masses: &[f64],
831    h: f64,
832    neighbor_list: &NeighborList,
833) -> Vec<f64> {
834    let params = SphKernelParams::new(h);
835    let n = positions.len();
836    let mut densities = vec![0.0f64; n];
837    for i in 0..n {
838        let neighbors = neighbor_list.neighbors(&positions[i]);
839        let mut rho = 0.0;
840        for j in neighbors {
841            let dx = positions[i][0] - positions[j][0];
842            let dy = positions[i][1] - positions[j][1];
843            let dz = positions[i][2] - positions[j][2];
844            let r = (dx * dx + dy * dy + dz * dz).sqrt();
845            rho += masses[j] * kernel_value(r, &params, SphKernel::CubicSpline);
846        }
847        densities[i] = rho;
848    }
849    densities
850}
851
852// ─────────────────────────────────────────────────────────────────────────────
853// SPH pressure force kernel
854// ─────────────────────────────────────────────────────────────────────────────
855
856/// Symmetric SPH pressure force kernel using neighbour lists.
857///
858/// `F_i^press = -m_i sum_j m_j (p_i/rho_i^2 + p_j/rho_j^2) nabla W(r_ij, h)`
859pub fn pressure_force_kernel(
860    positions: &[[f64; 3]],
861    densities: &[f64],
862    pressures: &[f64],
863    masses: &[f64],
864    h: f64,
865    neighbor_list: &NeighborList,
866) -> Vec<[f64; 3]> {
867    let params = SphKernelParams::new(h);
868    let n = positions.len();
869    let mut forces = vec![[0.0f64; 3]; n];
870    for i in 0..n {
871        let pi_over_rho2 = if densities[i].abs() > 1e-30 {
872            pressures[i] / (densities[i] * densities[i])
873        } else {
874            0.0
875        };
876        let neighbors = neighbor_list.neighbors(&positions[i]);
877        let mut fx = 0.0;
878        let mut fy = 0.0;
879        let mut fz = 0.0;
880        for j in neighbors {
881            if i == j {
882                continue;
883            }
884            let r_vec = [
885                positions[i][0] - positions[j][0],
886                positions[i][1] - positions[j][1],
887                positions[i][2] - positions[j][2],
888            ];
889            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
890            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
891            let pj_over_rho2 = if densities[j].abs() > 1e-30 {
892                pressures[j] / (densities[j] * densities[j])
893            } else {
894                0.0
895            };
896            let coeff = -masses[j] * (pi_over_rho2 + pj_over_rho2);
897            fx += coeff * grad[0];
898            fy += coeff * grad[1];
899            fz += coeff * grad[2];
900        }
901        forces[i] = [fx, fy, fz];
902    }
903    forces
904}
905
906// ─────────────────────────────────────────────────────────────────────────────
907// SPH viscosity kernel
908// ─────────────────────────────────────────────────────────────────────────────
909
910/// Artificial viscosity for SPH (Monaghan 1992 formulation).
911///
912/// Adds dissipation to prevent particle interpenetration.
913/// `F_i^visc = -sum_j m_j PI_ij nabla W(r_ij, h)`
914///
915/// where `PI_ij = (-alpha * mu_ij * c_s + beta * mu_ij^2) / rho_ij`
916/// and `mu_ij = h * v_ij . r_ij / (|r_ij|^2 + 0.01 h^2)`.
917pub fn artificial_viscosity_force(
918    positions: &[[f64; 3]],
919    velocities: &[[f64; 3]],
920    densities: &[f64],
921    masses: &[f64],
922    h: f64,
923    c_s: f64,
924    alpha: f64,
925    beta: f64,
926) -> Vec<[f64; 3]> {
927    let params = SphKernelParams::new(h);
928    let n = positions.len();
929    let mut forces = vec![[0.0f64; 3]; n];
930    for i in 0..n {
931        let mut fx = 0.0;
932        let mut fy = 0.0;
933        let mut fz = 0.0;
934        for j in 0..n {
935            if i == j {
936                continue;
937            }
938            let r_vec = [
939                positions[i][0] - positions[j][0],
940                positions[i][1] - positions[j][1],
941                positions[i][2] - positions[j][2],
942            ];
943            let v_vec = [
944                velocities[i][0] - velocities[j][0],
945                velocities[i][1] - velocities[j][1],
946                velocities[i][2] - velocities[j][2],
947            ];
948            let r2 = r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2];
949            let r = r2.sqrt();
950            if r >= h || r < 1e-12 {
951                continue;
952            }
953            let vr = v_vec[0] * r_vec[0] + v_vec[1] * r_vec[1] + v_vec[2] * r_vec[2];
954            if vr >= 0.0 {
955                continue;
956            } // only when approaching
957            let mu_ij = h * vr / (r2 + 0.01 * h * h);
958            let rho_ij = 0.5 * (densities[i] + densities[j]).max(1e-30);
959            let pi_ij = (-alpha * c_s * mu_ij + beta * mu_ij * mu_ij) / rho_ij;
960            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
961            let coeff = -masses[j] * pi_ij;
962            fx += coeff * grad[0];
963            fy += coeff * grad[1];
964            fz += coeff * grad[2];
965        }
966        forces[i] = [fx, fy, fz];
967    }
968    forces
969}
970
971// ─────────────────────────────────────────────────────────────────────────────
972// WCSPH (Weakly Compressible SPH) step kernel
973// ─────────────────────────────────────────────────────────────────────────────
974
975/// Equation of state for WCSPH: `p = B * ((rho/rho0)^gamma - 1)`.
976///
977/// Typical values: `gamma = 7`, `B = rho0 * c_s^2 / gamma`.
978pub fn wcsph_pressure(rho: f64, rho0: f64, b: f64, gamma: f64) -> f64 {
979    b * ((rho / rho0).powf(gamma) - 1.0)
980}
981
982/// Apply one explicit WCSPH Euler step.
983///
984/// Updates positions and velocities using the computed forces.
985/// Returns (new_positions, new_velocities).
986pub fn wcsph_euler_step(
987    positions: &[[f64; 3]],
988    velocities: &[[f64; 3]],
989    forces: &[[f64; 3]],
990    masses: &[f64],
991    dt: f64,
992) -> (Vec<[f64; 3]>, Vec<[f64; 3]>) {
993    let n = positions.len();
994    let mut new_pos = positions.to_vec();
995    let mut new_vel = velocities.to_vec();
996    for i in 0..n {
997        let m = masses[i].max(1e-30);
998        let ax = forces[i][0] / m;
999        let ay = forces[i][1] / m;
1000        let az = forces[i][2] / m;
1001        new_vel[i] = [
1002            velocities[i][0] + dt * ax,
1003            velocities[i][1] + dt * ay,
1004            velocities[i][2] + dt * az,
1005        ];
1006        new_pos[i] = [
1007            positions[i][0] + dt * new_vel[i][0],
1008            positions[i][1] + dt * new_vel[i][1],
1009            positions[i][2] + dt * new_vel[i][2],
1010        ];
1011    }
1012    (new_pos, new_vel)
1013}
1014
1015/// Apply one leap-frog WCSPH half-step (velocity update only).
1016///
1017/// `v_{n+1/2} = v_{n-1/2} + a_n * dt`
1018pub fn wcsph_leapfrog_velocity_half(
1019    velocities: &[[f64; 3]],
1020    forces: &[[f64; 3]],
1021    masses: &[f64],
1022    dt: f64,
1023) -> Vec<[f64; 3]> {
1024    let n = velocities.len();
1025    let mut new_vel = velocities.to_vec();
1026    for i in 0..n {
1027        let m = masses[i].max(1e-30);
1028        new_vel[i][0] += dt * forces[i][0] / m;
1029        new_vel[i][1] += dt * forces[i][1] / m;
1030        new_vel[i][2] += dt * forces[i][2] / m;
1031    }
1032    new_vel
1033}
1034
1035// ─────────────────────────────────────────────────────────────────────────────
1036// SPH surface normal kernel
1037// ─────────────────────────────────────────────────────────────────────────────
1038
1039/// Compute approximate surface normals using the color function gradient method.
1040///
1041/// `n_i = sum_j (m_j / rho_j) * nabla W(r_ij, h)`
1042///
1043/// The magnitude of `n_i` is proportional to the interface curvature.
1044pub fn surface_normal_kernel(
1045    positions: &[[f64; 3]],
1046    densities: &[f64],
1047    masses: &[f64],
1048    h: f64,
1049) -> Vec<[f64; 3]> {
1050    let params = SphKernelParams::new(h);
1051    let n = positions.len();
1052    let mut normals = vec![[0.0f64; 3]; n];
1053    for i in 0..n {
1054        let mut nx = 0.0;
1055        let mut ny = 0.0;
1056        let mut nz = 0.0;
1057        for j in 0..n {
1058            if i == j {
1059                continue;
1060            }
1061            let r_vec = [
1062                positions[i][0] - positions[j][0],
1063                positions[i][1] - positions[j][1],
1064                positions[i][2] - positions[j][2],
1065            ];
1066            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
1067            let rho_j = densities[j].max(1e-30);
1068            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
1069            let coeff = masses[j] / rho_j;
1070            nx += coeff * grad[0];
1071            ny += coeff * grad[1];
1072            nz += coeff * grad[2];
1073        }
1074        normals[i] = [nx, ny, nz];
1075    }
1076    normals
1077}
1078
1079/// Normalize a surface normal vector.  Returns zero vector if magnitude is too small.
1080pub fn normalize_normal(n: [f64; 3]) -> [f64; 3] {
1081    let mag = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1082    if mag < 1e-30 {
1083        [0.0; 3]
1084    } else {
1085        [n[0] / mag, n[1] / mag, n[2] / mag]
1086    }
1087}
1088
1089// ─────────────────────────────────────────────────────────────────────────────
1090// SPH neighbour list build (explicit grid-based)
1091// ─────────────────────────────────────────────────────────────────────────────
1092
1093/// Build an explicit neighbour list: for each particle, store all particle
1094/// indices within distance `h`.
1095///
1096/// Returns `Vec<Vec`usize`>` where `neighbours[i]` is the list of neighbour indices for particle `i`.
1097pub fn build_neighbor_list_explicit(positions: &[[f64; 3]], h: f64) -> Vec<Vec<usize>> {
1098    let n = positions.len();
1099    let mut neighbors = vec![Vec::new(); n];
1100    for i in 0..n {
1101        for j in 0..n {
1102            let dx = positions[i][0] - positions[j][0];
1103            let dy = positions[i][1] - positions[j][1];
1104            let dz = positions[i][2] - positions[j][2];
1105            if dx * dx + dy * dy + dz * dz < h * h {
1106                neighbors[i].push(j);
1107            }
1108        }
1109    }
1110    neighbors
1111}
1112
1113/// Compute average number of neighbours per particle.
1114pub fn mean_neighbor_count(neighbors: &[Vec<usize>]) -> f64 {
1115    if neighbors.is_empty() {
1116        return 0.0;
1117    }
1118    let total: usize = neighbors.iter().map(|v| v.len()).sum();
1119    total as f64 / neighbors.len() as f64
1120}
1121
1122// ─────────────────────────────────────────────────────────────────────────────
1123// SPH kernel normalization check
1124// ─────────────────────────────────────────────────────────────────────────────
1125
1126/// Numerically integrate the kernel over a sphere of radius `h` using Monte Carlo.
1127///
1128/// Useful for verifying kernel normalization (should integrate to ~1 in 3D).
1129pub fn integrate_kernel_sphere(h: f64, kernel: SphKernel, n_samples: usize) -> f64 {
1130    let params = SphKernelParams::new(h);
1131    // Uniform sampling in [0, h] with spherical volume element 4*pi*r^2
1132    let dr = h / n_samples as f64;
1133    let mut integral = 0.0;
1134    for k in 0..n_samples {
1135        let r = (k as f64 + 0.5) * dr;
1136        let w = kernel_value(r, &params, kernel);
1137        integral += w * 4.0 * PI * r * r * dr;
1138    }
1139    integral
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145
1146    #[test]
1147    fn test_sph_kernel_density_sum() {
1148        // Central particle at origin surrounded by 8 neighbors at corners of
1149        // a unit cube with side 0.4.  All 9 particles have unit mass.
1150        // The smoothing length h = 1.0 covers all neighbors (max dist ~ 0.693 < 1).
1151        let h_val = 1.0_f64;
1152        let offsets: &[(f64, f64, f64)] = &[
1153            (0.4, 0.4, 0.4),
1154            (-0.4, 0.4, 0.4),
1155            (0.4, -0.4, 0.4),
1156            (0.4, 0.4, -0.4),
1157            (-0.4, -0.4, 0.4),
1158            (-0.4, 0.4, -0.4),
1159            (0.4, -0.4, -0.4),
1160            (-0.4, -0.4, -0.4),
1161        ];
1162        // First particle is the central one.
1163        let mut positions: Vec<f64> = vec![0.0, 0.0, 0.0];
1164        for &(x, y, z) in offsets {
1165            positions.extend_from_slice(&[x, y, z]);
1166        }
1167        let n = 9_usize;
1168        let masses = vec![1.0_f64; n];
1169        let h_slice = vec![h_val];
1170
1171        let mut outputs = vec![Vec::new()];
1172        SphDensityKernel.execute(&[&positions, &masses, &h_slice], &mut outputs, n);
1173
1174        assert_eq!(outputs[0].len(), n, "density output length should equal n");
1175        // The central particle should have a positive density (it sees all neighbors).
1176        let central_density = outputs[0][0];
1177        assert!(
1178            central_density > 0.0,
1179            "central particle density should be > 0, got {central_density}"
1180        );
1181    }
1182
1183    #[test]
1184    fn sph_density_uniform_distribution() {
1185        // 2 particles at the same position should give non-zero density
1186        let positions = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1187        let masses = vec![1.0, 1.0];
1188        let h = vec![1.0];
1189        let mut outputs = vec![Vec::new()];
1190        SphDensityKernel.execute(&[&positions, &masses, &h], &mut outputs, 2);
1191        assert_eq!(outputs[0].len(), 2);
1192        // Both particles see each other at r=0, so density should be positive
1193        assert!(outputs[0][0] > 0.0);
1194        assert!((outputs[0][0] - outputs[0][1]).abs() < 1e-12);
1195    }
1196
1197    #[test]
1198    fn sph_force_produces_finite_forces() {
1199        let n = 3;
1200        // Place particles in a line along x
1201        let positions = vec![0.0, 0.0, 0.0, 0.3, 0.0, 0.0, 0.6, 0.0, 0.0];
1202        let velocities = vec![0.0; 9];
1203        let densities = vec![1000.0; 3];
1204        let pressures = vec![100.0, 200.0, 100.0];
1205        let masses = vec![1.0; 3];
1206        let params = vec![1.0, 0.1]; // h=1.0, mu=0.1
1207
1208        let mut outputs = vec![Vec::new()];
1209        SphForceKernel.execute(
1210            &[
1211                &positions,
1212                &velocities,
1213                &densities,
1214                &pressures,
1215                &masses,
1216                &params,
1217            ],
1218            &mut outputs,
1219            n,
1220        );
1221        assert_eq!(outputs[0].len(), 9);
1222        // Forces should be finite
1223        for &f in &outputs[0] {
1224            assert!(f.is_finite(), "force component is not finite: {f}");
1225        }
1226    }
1227
1228    // ── High-level SPH kernel API tests ──────────────────────────────────────
1229
1230    /// Verify kernel_value integrates numerically to approximately 1 for each
1231    /// kernel type (compact support, unit normalization).
1232    #[test]
1233    fn kernel_value_positive_within_support() {
1234        let h = 1.0_f64;
1235        let params = SphKernelParams::new(h);
1236        for &k in &[
1237            SphKernel::CubicSpline,
1238            SphKernel::Wendland,
1239            SphKernel::Poly6,
1240            SphKernel::Spiky,
1241        ] {
1242            // W(0) should be positive (self-contribution)
1243            let w0 = kernel_value(0.0, &params, k);
1244            assert!(w0 > 0.0, "{k:?}: W(0) should be > 0, got {w0}");
1245            // W at r >= h should be exactly 0
1246            let wh = kernel_value(h, &params, k);
1247            assert_eq!(wh, 0.0, "{k:?}: W(h) should be 0, got {wh}");
1248        }
1249    }
1250
1251    /// Kernel must be symmetric: W(-r) == W(r) (evaluated via magnitude).
1252    #[test]
1253    fn kernel_value_symmetric() {
1254        let h = 2.0_f64;
1255        let params = SphKernelParams::new(h);
1256        let r = 0.7 * h;
1257        for &k in &[
1258            SphKernel::CubicSpline,
1259            SphKernel::Wendland,
1260            SphKernel::Poly6,
1261            SphKernel::Spiky,
1262        ] {
1263            let w_pos = kernel_value(r, &params, k);
1264            // Symmetry: W depends only on |r|, so same value at same magnitude.
1265            let w_same = kernel_value(r, &params, k);
1266            assert!(
1267                (w_pos - w_same).abs() < 1e-15,
1268                "{k:?}: kernel not symmetric at r={r}"
1269            );
1270        }
1271    }
1272
1273    /// density_summation: self-contribution gives positive density.
1274    #[test]
1275    fn density_summation_self_contribution() {
1276        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
1277        let masses = vec![1.0, 1.0];
1278        let h = 1.0;
1279        let densities = density_summation(&positions, &masses, h);
1280        assert_eq!(densities.len(), 2);
1281        assert!(
1282            densities[0] > 0.0,
1283            "density[0] should be > 0, got {}",
1284            densities[0]
1285        );
1286        assert!(
1287            densities[1] > 0.0,
1288            "density[1] should be > 0, got {}",
1289            densities[1]
1290        );
1291    }
1292
1293    /// pressure_force: forces are finite and Newton's third law holds.
1294    #[test]
1295    fn pressure_force_finite_and_newtons_third_law() {
1296        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1297        let velocities = vec![[0.0; 3]; 3];
1298        let densities = vec![1000.0, 1000.0, 1000.0];
1299        let pressures = vec![100.0, 200.0, 100.0];
1300        let masses = vec![1.0, 1.0, 1.0];
1301        let h = 1.0;
1302
1303        let forces = pressure_force(&positions, &velocities, &densities, &pressures, &masses, h);
1304        assert_eq!(forces.len(), 3);
1305        for (i, f) in forces.iter().enumerate() {
1306            for &c in f.iter() {
1307                assert!(c.is_finite(), "force[{i}] component not finite: {c}");
1308            }
1309        }
1310        // Total momentum change should be near zero (action-reaction).
1311        let total_fx: f64 = forces.iter().map(|f| f[0]).sum();
1312        assert!(
1313            total_fx.abs() < 1e-8,
1314            "total x-force should be ~0 (Newton III), got {total_fx}"
1315        );
1316    }
1317
1318    // ── New tests ────────────────────────────────────────────────────────────
1319
1320    #[test]
1321    fn test_density_summation_kernel_poly6() {
1322        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1323        let masses = vec![1.0, 1.0];
1324        let h = 1.0;
1325        let densities = density_summation_kernel(&positions, &masses, h, SphKernel::Poly6);
1326        assert_eq!(densities.len(), 2);
1327        assert!(densities[0] > 0.0);
1328        assert!(densities[1] > 0.0);
1329    }
1330
1331    #[test]
1332    fn test_density_summation_kernel_wendland() {
1333        let positions = vec![[0.0, 0.0, 0.0]];
1334        let masses = vec![1.0];
1335        let h = 1.0;
1336        let densities = density_summation_kernel(&positions, &masses, h, SphKernel::Wendland);
1337        assert_eq!(densities.len(), 1);
1338        assert!(densities[0] > 0.0);
1339    }
1340
1341    #[test]
1342    fn test_density_summation_kernel_spiky() {
1343        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
1344        let masses = vec![1.0, 1.0];
1345        let h = 1.0;
1346        let densities = density_summation_kernel(&positions, &masses, h, SphKernel::Spiky);
1347        assert!(densities[0] > 0.0);
1348        assert!(densities[1] > 0.0);
1349    }
1350
1351    #[test]
1352    fn test_viscosity_force_finite() {
1353        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1354        let velocities = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]];
1355        let densities = vec![1000.0, 1000.0];
1356        let masses = vec![1.0, 1.0];
1357        let h = 1.0;
1358        let mu = 0.1;
1359        let forces = viscosity_force(&positions, &velocities, &densities, &masses, h, mu);
1360        assert_eq!(forces.len(), 2);
1361        for f in &forces {
1362            for &c in f {
1363                assert!(c.is_finite(), "viscosity force not finite: {c}");
1364            }
1365        }
1366    }
1367
1368    #[test]
1369    fn test_viscosity_force_zero_for_same_velocity() {
1370        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1371        let velocities = vec![[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
1372        let densities = vec![1000.0, 1000.0];
1373        let masses = vec![1.0, 1.0];
1374        let h = 1.0;
1375        let mu = 0.1;
1376        let forces = viscosity_force(&positions, &velocities, &densities, &masses, h, mu);
1377        for f in &forces {
1378            for &c in f {
1379                assert!(
1380                    c.abs() < 1e-12,
1381                    "viscosity should be zero for uniform velocity"
1382                );
1383            }
1384        }
1385    }
1386
1387    #[test]
1388    fn test_neighbor_list_build_and_query() {
1389        let positions = vec![
1390            [0.5, 0.5, 0.5],
1391            [0.6, 0.5, 0.5],
1392            [5.0, 5.0, 5.0], // far away
1393        ];
1394        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0], 1.0);
1395        nlist.build(&positions);
1396
1397        let neighbors = nlist.neighbors(&[0.5, 0.5, 0.5]);
1398        // Should find particles 0 and 1 (same or adjacent cells), not 2
1399        assert!(neighbors.contains(&0));
1400        assert!(neighbors.contains(&1));
1401        assert!(!neighbors.contains(&2));
1402    }
1403
1404    #[test]
1405    fn test_neighbor_list_num_cells() {
1406        let nlist = NeighborList::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0], 1.0);
1407        assert_eq!(nlist.num_cells(), 1000); // 10x10x10
1408        assert_eq!(nlist.grid_dims(), [10, 10, 10]);
1409    }
1410
1411    #[test]
1412    fn test_neighbor_list_single_particle() {
1413        let positions = vec![[0.5, 0.5, 0.5]];
1414        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], 1.0);
1415        nlist.build(&positions);
1416        let neighbors = nlist.neighbors(&[0.5, 0.5, 0.5]);
1417        assert!(neighbors.contains(&0));
1418    }
1419
1420    #[test]
1421    fn test_sph_dispatch_config() {
1422        let config = SphDispatchConfig::new(1000, 0.1);
1423        assert_eq!(config.n_particles, 1000);
1424        assert_eq!(config.num_workgroups(), 16); // ceil(1000/64) = 16
1425    }
1426
1427    #[test]
1428    fn test_sph_dispatch_config_pressure() {
1429        let config = SphDispatchConfig::new(100, 0.1);
1430        let p = config.pressure_from_density(1100.0);
1431        assert!((p - 100_000.0).abs() < 1e-6); // k=1000 * (1100-1000) = 100000
1432        let p_zero = config.pressure_from_density(500.0);
1433        assert!((p_zero).abs() < 1e-12); // below rest density -> 0
1434    }
1435
1436    #[test]
1437    fn test_sph_buffer_layout() {
1438        let layout = SphBufferLayout::new(1000);
1439        assert_eq!(layout.position_size, 3000);
1440        assert_eq!(layout.velocity_size, 3000);
1441        assert_eq!(layout.mass_size, 1000);
1442        assert_eq!(layout.density_size, 1000);
1443        assert_eq!(layout.pressure_size, 1000);
1444        assert_eq!(layout.force_size, 3000);
1445        assert_eq!(layout.total_elements(), 12000);
1446        assert_eq!(layout.total_bytes(), 96000);
1447    }
1448
1449    #[test]
1450    fn test_neighbor_list_kernel_executes() {
1451        let positions = vec![0.5, 0.5, 0.5, 1.5, 1.5, 1.5];
1452        let params = vec![1.0, 0.0, 0.0, 0.0, 5.0, 5.0, 5.0];
1453        let mut outputs = vec![Vec::new()];
1454        SphNeighborListKernel.execute(&[&positions, &params], &mut outputs, 2);
1455        assert_eq!(outputs[0].len(), 2);
1456        // cell indices should be non-negative
1457        assert!(outputs[0][0] >= 0.0);
1458        assert!(outputs[0][1] >= 0.0);
1459        // Different positions should generally give different cells
1460        assert!((outputs[0][0] - outputs[0][1]).abs() > 0.5);
1461    }
1462
1463    #[test]
1464    fn test_kernel_gradient_at_origin_is_zero() {
1465        let h = 1.0;
1466        let params = SphKernelParams::new(h);
1467        for &k in &[
1468            SphKernel::CubicSpline,
1469            SphKernel::Wendland,
1470            SphKernel::Poly6,
1471            SphKernel::Spiky,
1472        ] {
1473            let grad = kernel_gradient([0.0, 0.0, 0.0], 0.0, &params, k);
1474            assert_eq!(
1475                grad,
1476                [0.0, 0.0, 0.0],
1477                "{k:?}: gradient at origin should be zero"
1478            );
1479        }
1480    }
1481
1482    #[test]
1483    fn test_kernel_gradient_outside_support_is_zero() {
1484        let h = 1.0;
1485        let params = SphKernelParams::new(h);
1486        for &k in &[
1487            SphKernel::CubicSpline,
1488            SphKernel::Wendland,
1489            SphKernel::Poly6,
1490            SphKernel::Spiky,
1491        ] {
1492            let grad = kernel_gradient([2.0, 0.0, 0.0], 2.0, &params, k);
1493            assert_eq!(
1494                grad,
1495                [0.0, 0.0, 0.0],
1496                "{k:?}: gradient outside support should be zero"
1497            );
1498        }
1499    }
1500
1501    // ── Surface tension tests ─────────────────────────────────────────────
1502
1503    #[test]
1504    fn test_surface_tension_force_finite() {
1505        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1506        let color_fn = vec![1.0, 1.0, 0.0];
1507        let masses = vec![1.0, 1.0, 1.0];
1508        let densities = vec![1000.0, 1000.0, 1000.0];
1509        let h = 1.0;
1510        let sigma = 0.07;
1511        let forces = surface_tension_force(&positions, &color_fn, &masses, &densities, h, sigma);
1512        assert_eq!(forces.len(), 3);
1513        for f in &forces {
1514            for &c in f {
1515                assert!(c.is_finite(), "surface tension force not finite: {c}");
1516            }
1517        }
1518    }
1519
1520    #[test]
1521    fn test_surface_tension_zero_sigma() {
1522        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1523        let color_fn = vec![1.0, 0.0];
1524        let masses = vec![1.0, 1.0];
1525        let densities = vec![1000.0, 1000.0];
1526        let h = 1.0;
1527        let forces = surface_tension_force(&positions, &color_fn, &masses, &densities, h, 0.0);
1528        for f in &forces {
1529            for &c in f {
1530                assert!(
1531                    c.abs() < 1e-30,
1532                    "surface tension with sigma=0 should be zero"
1533                );
1534            }
1535        }
1536    }
1537
1538    // ── CFL time step tests ───────────────────────────────────────────────
1539
1540    #[test]
1541    fn test_cfl_timestep_basic() {
1542        let velocities = vec![[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 0.5]];
1543        let h = 1.0;
1544        let c_sound = 10.0;
1545        let dt = cfl_timestep(&velocities, h, c_sound, 0.3);
1546        assert!(dt > 0.0, "CFL dt should be positive");
1547        assert!(dt.is_finite(), "CFL dt should be finite");
1548        // max velocity magnitude = 2.0; CFL condition: dt <= 0.3*h/(v+c) = 0.3/12 ~ 0.025
1549        assert!(dt <= 0.3 * h / (2.0 + c_sound) + 1e-12);
1550    }
1551
1552    #[test]
1553    fn test_cfl_timestep_zero_velocity() {
1554        let velocities = vec![[0.0; 3]; 5];
1555        let h = 0.5;
1556        let c_sound = 5.0;
1557        let dt = cfl_timestep(&velocities, h, c_sound, 0.25);
1558        // With zero velocity: dt = cfl * h / c_sound
1559        let expected = 0.25 * h / c_sound;
1560        assert!(
1561            (dt - expected).abs() < 1e-10,
1562            "expected {expected}, got {dt}"
1563        );
1564    }
1565
1566    // ── Radix sort by density tests ───────────────────────────────────────
1567
1568    #[test]
1569    fn test_radix_sort_by_density_ordered() {
1570        let densities = vec![3.0, 1.0, 4.0, 1.5, 9.0, 2.6, 5.0, 3.5];
1571        let indices = radix_sort_by_density(&densities);
1572        assert_eq!(indices.len(), densities.len());
1573        // Verify sorted order
1574        for w in indices.windows(2) {
1575            assert!(
1576                densities[w[0]] <= densities[w[1]],
1577                "not sorted: {} > {}",
1578                densities[w[0]],
1579                densities[w[1]]
1580            );
1581        }
1582    }
1583
1584    #[test]
1585    fn test_radix_sort_by_density_single() {
1586        let densities = vec![42.0];
1587        let indices = radix_sort_by_density(&densities);
1588        assert_eq!(indices, vec![0]);
1589    }
1590
1591    #[test]
1592    fn test_radix_sort_by_density_empty() {
1593        let indices = radix_sort_by_density(&[]);
1594        assert!(indices.is_empty());
1595    }
1596
1597    #[test]
1598    fn test_radix_sort_is_permutation() {
1599        let densities = vec![5.0, 3.0, 8.0, 1.0, 2.0];
1600        let indices = radix_sort_by_density(&densities);
1601        let mut check = indices.clone();
1602        check.sort_unstable();
1603        assert_eq!(
1604            check,
1605            vec![0, 1, 2, 3, 4],
1606            "indices should be a permutation"
1607        );
1608    }
1609
1610    // ── density_accumulation tests ───────────────────────────────────────────
1611
1612    #[test]
1613    fn test_density_accumulation_matches_direct() {
1614        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1615        let masses = vec![1.0, 1.0, 1.0];
1616        let h = 1.0;
1617        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1618        nlist.build(&positions);
1619        let rho_nl = density_accumulation(&positions, &masses, h, &nlist);
1620        let rho_direct = density_summation(&positions, &masses, h);
1621        // Results should agree (neighbour list finds all within h)
1622        for i in 0..3 {
1623            assert!(
1624                (rho_nl[i] - rho_direct[i]).abs() < 1e-10,
1625                "density_accumulation vs direct at {i}: {} vs {}",
1626                rho_nl[i],
1627                rho_direct[i]
1628            );
1629        }
1630    }
1631
1632    #[test]
1633    fn test_density_accumulation_positive() {
1634        let positions = vec![[0.5, 0.5, 0.5], [0.6, 0.5, 0.5]];
1635        let masses = vec![1.0, 1.0];
1636        let h = 1.0;
1637        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1638        nlist.build(&positions);
1639        let rho = density_accumulation(&positions, &masses, h, &nlist);
1640        assert!(rho[0] > 0.0 && rho[1] > 0.0);
1641    }
1642
1643    // ── pressure_force_kernel tests ──────────────────────────────────────────
1644
1645    #[test]
1646    fn test_pressure_force_kernel_finite() {
1647        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1648        let densities = vec![1000.0, 1000.0];
1649        let pressures = vec![100.0, 200.0];
1650        let masses = vec![1.0, 1.0];
1651        let h = 1.0;
1652        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1653        nlist.build(&positions);
1654        let forces = pressure_force_kernel(&positions, &densities, &pressures, &masses, h, &nlist);
1655        assert_eq!(forces.len(), 2);
1656        for f in &forces {
1657            for &c in f {
1658                assert!(c.is_finite(), "pressure_force_kernel not finite: {c}");
1659            }
1660        }
1661    }
1662
1663    #[test]
1664    fn test_pressure_force_kernel_zero_gradient() {
1665        // Uniform pressure → no force
1666        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1667        let densities = vec![1000.0, 1000.0];
1668        let pressures = vec![100.0, 100.0];
1669        let masses = vec![1.0, 1.0];
1670        let h = 1.0;
1671        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1672        nlist.build(&positions);
1673        let forces = pressure_force_kernel(&positions, &densities, &pressures, &masses, h, &nlist);
1674        // For uniform pressure with symmetric formulation the net force should be near zero
1675        let total_fx: f64 = forces.iter().map(|f| f[0]).sum();
1676        assert!(
1677            total_fx.abs() < 1e-6,
1678            "total pressure force with uniform pressure = {total_fx}"
1679        );
1680    }
1681
1682    // ── artificial_viscosity_force tests ─────────────────────────────────────
1683
1684    #[test]
1685    fn test_artificial_viscosity_finite() {
1686        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1687        let velocities = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]]; // approaching
1688        let densities = vec![1000.0, 1000.0];
1689        let masses = vec![1.0, 1.0];
1690        let h = 1.0;
1691        let forces = artificial_viscosity_force(
1692            &positions,
1693            &velocities,
1694            &densities,
1695            &masses,
1696            h,
1697            100.0,
1698            1.0,
1699            2.0,
1700        );
1701        for f in &forces {
1702            for &c in f {
1703                assert!(c.is_finite(), "art. visc. force not finite: {c}");
1704            }
1705        }
1706    }
1707
1708    #[test]
1709    fn test_artificial_viscosity_zero_for_diverging() {
1710        // When particles are moving apart, PI_ij = 0
1711        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1712        let velocities = vec![[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; // diverging
1713        let densities = vec![1000.0, 1000.0];
1714        let masses = vec![1.0, 1.0];
1715        let h = 1.0;
1716        let forces = artificial_viscosity_force(
1717            &positions,
1718            &velocities,
1719            &densities,
1720            &masses,
1721            h,
1722            100.0,
1723            1.0,
1724            2.0,
1725        );
1726        for f in &forces {
1727            for &c in f {
1728                assert!(
1729                    c.abs() < 1e-30,
1730                    "diverging particles: art visc should be 0, got {c}"
1731                );
1732            }
1733        }
1734    }
1735
1736    // ── WCSPH tests ───────────────────────────────────────────────────────────
1737
1738    #[test]
1739    fn test_wcsph_pressure_at_rest_density() {
1740        // At rho = rho0: pressure = 0
1741        let p = wcsph_pressure(1000.0, 1000.0, 100.0, 7.0);
1742        assert!(p.abs() < 1e-8, "pressure at rest density = {p}");
1743    }
1744
1745    #[test]
1746    fn test_wcsph_pressure_above_rest_density() {
1747        // At rho > rho0: pressure > 0
1748        let p = wcsph_pressure(1010.0, 1000.0, 100.0, 7.0);
1749        assert!(
1750            p > 0.0,
1751            "pressure above rest density should be positive: {p}"
1752        );
1753    }
1754
1755    #[test]
1756    fn test_wcsph_euler_step_positions_change() {
1757        let positions = vec![[0.0, 0.0, 0.0]];
1758        let velocities = vec![[1.0, 0.0, 0.0]];
1759        let forces = vec![[0.0, 0.0, 0.0]];
1760        let masses = vec![1.0];
1761        let dt = 0.01;
1762        let (new_pos, _) = wcsph_euler_step(&positions, &velocities, &forces, &masses, dt);
1763        assert!(
1764            (new_pos[0][0] - 0.01).abs() < 1e-12,
1765            "position should advance by v*dt"
1766        );
1767    }
1768
1769    #[test]
1770    fn test_wcsph_euler_step_velocity_changes() {
1771        let positions = vec![[0.0, 0.0, 0.0]];
1772        let velocities = vec![[0.0, 0.0, 0.0]];
1773        let forces = vec![[1.0, 0.0, 0.0]]; // acceleration = 1
1774        let masses = vec![1.0];
1775        let dt = 0.1;
1776        let (_, new_vel) = wcsph_euler_step(&positions, &velocities, &forces, &masses, dt);
1777        assert!(
1778            (new_vel[0][0] - 0.1).abs() < 1e-12,
1779            "velocity should increase by a*dt"
1780        );
1781    }
1782
1783    #[test]
1784    fn test_wcsph_leapfrog_velocity_half() {
1785        let velocities = vec![[1.0, 0.0, 0.0]];
1786        let forces = vec![[2.0, 0.0, 0.0]];
1787        let masses = vec![1.0];
1788        let dt = 0.1;
1789        let new_vel = wcsph_leapfrog_velocity_half(&velocities, &forces, &masses, dt);
1790        // v += a * dt = 1 + 2*0.1 = 1.2
1791        assert!(
1792            (new_vel[0][0] - 1.2).abs() < 1e-12,
1793            "leapfrog v = {}",
1794            new_vel[0][0]
1795        );
1796    }
1797
1798    // ── surface_normal_kernel tests ───────────────────────────────────────────
1799
1800    #[test]
1801    fn test_surface_normal_kernel_finite() {
1802        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1803        let densities = vec![1000.0, 1000.0, 1000.0];
1804        let masses = vec![1.0, 1.0, 1.0];
1805        let h = 1.0;
1806        let normals = surface_normal_kernel(&positions, &densities, &masses, h);
1807        assert_eq!(normals.len(), 3);
1808        for n in &normals {
1809            for &c in n {
1810                assert!(c.is_finite(), "surface normal not finite: {c}");
1811            }
1812        }
1813    }
1814
1815    #[test]
1816    fn test_normalize_normal_unit_vector() {
1817        let n = normalize_normal([3.0, 0.0, 4.0]);
1818        let mag = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1819        assert!((mag - 1.0).abs() < 1e-12, "normalized magnitude = {mag}");
1820    }
1821
1822    #[test]
1823    fn test_normalize_normal_zero_vector() {
1824        let n = normalize_normal([0.0, 0.0, 0.0]);
1825        assert_eq!(n, [0.0, 0.0, 0.0]);
1826    }
1827
1828    // ── build_neighbor_list_explicit tests ───────────────────────────────────
1829
1830    #[test]
1831    fn test_build_neighbor_list_explicit_finds_close() {
1832        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [5.0, 0.0, 0.0]];
1833        let h = 1.0;
1834        let neighbors = build_neighbor_list_explicit(&positions, h);
1835        assert!(
1836            neighbors[0].contains(&1),
1837            "particle 0 should find particle 1"
1838        );
1839        assert!(
1840            !neighbors[0].contains(&2),
1841            "particle 0 should not find particle 2"
1842        );
1843    }
1844
1845    #[test]
1846    fn test_build_neighbor_list_self_included() {
1847        let positions = vec![[0.0, 0.0, 0.0]];
1848        let h = 1.0;
1849        let neighbors = build_neighbor_list_explicit(&positions, h);
1850        assert!(neighbors[0].contains(&0), "particle should find itself");
1851    }
1852
1853    #[test]
1854    fn test_mean_neighbor_count() {
1855        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1856        let h = 1.0;
1857        let neighbors = build_neighbor_list_explicit(&positions, h);
1858        let mean = mean_neighbor_count(&neighbors);
1859        assert!(mean >= 1.0, "mean neighbors should be >= 1: {mean}");
1860    }
1861
1862    #[test]
1863    fn test_mean_neighbor_count_empty() {
1864        let mean = mean_neighbor_count(&[]);
1865        assert_eq!(mean, 0.0);
1866    }
1867
1868    // ── integrate_kernel_sphere tests ────────────────────────────────────────
1869
1870    #[test]
1871    fn test_integrate_kernel_sphere_cubic_spline() {
1872        let h = 1.0;
1873        let integral = integrate_kernel_sphere(h, SphKernel::CubicSpline, 1000);
1874        // Should be approximately 1 for a normalized kernel
1875        assert!(
1876            integral > 0.5 && integral < 2.0,
1877            "CubicSpline integral = {integral} (expected ~1)"
1878        );
1879    }
1880
1881    #[test]
1882    fn test_integrate_kernel_sphere_wendland() {
1883        let h = 1.0;
1884        let integral = integrate_kernel_sphere(h, SphKernel::Wendland, 1000);
1885        assert!(
1886            integral > 0.5 && integral < 2.0,
1887            "Wendland integral = {integral} (expected ~1)"
1888        );
1889    }
1890
1891    #[test]
1892    fn test_integrate_kernel_sphere_zero_at_boundary() {
1893        // Kernel value at r=h should be 0
1894        let h = 1.0;
1895        let params = SphKernelParams::new(h);
1896        for &k in &[SphKernel::CubicSpline, SphKernel::Wendland] {
1897            let w = kernel_value(h, &params, k);
1898            assert_eq!(w, 0.0, "{k:?}: W(h) should be 0");
1899        }
1900    }
1901}