1#![allow(clippy::needless_range_loop)]
2#![allow(missing_docs)]
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PySphConfig {
22 pub h: f64,
24 pub rest_density: f64,
26 pub stiffness: f64,
28 pub viscosity: f64,
30 pub gravity: [f64; 3],
32 pub particle_mass: f64,
34 pub floor_enabled: bool,
36 pub floor_y: f64,
38 pub floor_restitution: f64,
40}
41
42impl PySphConfig {
43 pub fn water() -> Self {
45 Self {
46 h: 0.1,
47 rest_density: 1000.0,
48 stiffness: 200.0,
49 viscosity: 0.01,
50 gravity: [0.0, -9.81, 0.0],
51 particle_mass: 0.02,
52 floor_enabled: false,
53 floor_y: 0.0,
54 floor_restitution: 0.3,
55 }
56 }
57
58 pub fn gas() -> Self {
60 Self {
61 h: 0.2,
62 rest_density: 1.2,
63 stiffness: 50.0,
64 viscosity: 1.81e-5,
65 gravity: [0.0, 0.0, 0.0],
66 particle_mass: 0.001,
67 floor_enabled: false,
68 floor_y: 0.0,
69 floor_restitution: 1.0,
70 }
71 }
72}
73
74impl Default for PySphConfig {
75 fn default() -> Self {
76 Self::water()
77 }
78}
79
80#[derive(Debug, Clone)]
90pub struct PySphSimulation {
91 positions: Vec<[f64; 3]>,
93 velocities: Vec<[f64; 3]>,
95 densities: Vec<f64>,
97 pressures: Vec<f64>,
99 config: PySphConfig,
101 time: f64,
103 step_count: u64,
105}
106
107impl PySphSimulation {
108 pub fn new(config: PySphConfig) -> Self {
110 Self {
111 positions: Vec::new(),
112 velocities: Vec::new(),
113 densities: Vec::new(),
114 pressures: Vec::new(),
115 config,
116 time: 0.0,
117 step_count: 0,
118 }
119 }
120
121 pub fn add_particle(&mut self, pos: [f64; 3]) -> usize {
123 let idx = self.positions.len();
124 self.positions.push(pos);
125 self.velocities.push([0.0; 3]);
126 self.densities.push(self.config.rest_density);
127 self.pressures.push(0.0);
128 idx
129 }
130
131 pub fn add_particle_with_velocity(&mut self, pos: [f64; 3], vel: [f64; 3]) -> usize {
133 let idx = self.add_particle(pos);
134 self.velocities[idx] = vel;
135 idx
136 }
137
138 pub fn add_particle_block(
141 &mut self,
142 origin: [f64; 3],
143 nx: usize,
144 ny: usize,
145 nz: usize,
146 dx: f64,
147 ) {
148 for iz in 0..nz {
149 for iy in 0..ny {
150 for ix in 0..nx {
151 let pos = [
152 origin[0] + ix as f64 * dx,
153 origin[1] + iy as f64 * dx,
154 origin[2] + iz as f64 * dx,
155 ];
156 self.add_particle(pos);
157 }
158 }
159 }
160 }
161
162 pub fn particle_count(&self) -> usize {
164 self.positions.len()
165 }
166
167 pub fn position(&self, i: usize) -> Option<[f64; 3]> {
169 self.positions.get(i).copied()
170 }
171
172 pub fn velocity(&self, i: usize) -> Option<[f64; 3]> {
174 self.velocities.get(i).copied()
175 }
176
177 pub fn density(&self, i: usize) -> Option<f64> {
179 self.densities.get(i).copied()
180 }
181
182 pub fn time(&self) -> f64 {
184 self.time
185 }
186
187 pub fn step_count(&self) -> u64 {
189 self.step_count
190 }
191
192 pub fn all_positions(&self) -> Vec<f64> {
194 self.positions
195 .iter()
196 .flat_map(|p| p.iter().copied())
197 .collect()
198 }
199
200 pub fn all_velocities(&self) -> Vec<f64> {
202 self.velocities
203 .iter()
204 .flat_map(|v| v.iter().copied())
205 .collect()
206 }
207
208 pub fn get_density_field(&self) -> Vec<f64> {
210 self.densities.clone()
211 }
212
213 pub fn get_pressure_field(&self) -> Vec<f64> {
215 self.pressures.clone()
216 }
217
218 pub fn mean_density(&self) -> f64 {
220 if self.densities.is_empty() {
221 return 0.0;
222 }
223 self.densities.iter().sum::<f64>() / self.densities.len() as f64
224 }
225
226 pub fn step(&mut self, dt: f64) {
235 let n = self.positions.len();
236 if n == 0 {
237 self.time += dt;
238 self.step_count += 1;
239 return;
240 }
241
242 let h = self.config.h;
243 let h2 = h * h;
244 let m = self.config.particle_mass;
245 let rho0 = self.config.rest_density;
246 let k = self.config.stiffness;
247 let mu = self.config.viscosity;
248 let g = self.config.gravity;
249
250 let poly6_coeff = 315.0 / (64.0 * std::f64::consts::PI * h.powi(9));
251 let spiky_coeff = -45.0 / (std::f64::consts::PI * h.powi(6));
252 let visc_coeff = 45.0 / (std::f64::consts::PI * h.powi(6));
253
254 for i in 0..n {
256 let mut rho = 0.0f64;
257 for j in 0..n {
258 let dx = self.positions[i][0] - self.positions[j][0];
259 let dy = self.positions[i][1] - self.positions[j][1];
260 let dz = self.positions[i][2] - self.positions[j][2];
261 let r2 = dx * dx + dy * dy + dz * dz;
262 if r2 < h2 {
263 let diff = h2 - r2;
264 rho += m * poly6_coeff * diff * diff * diff;
265 }
266 }
267 self.densities[i] = rho.max(1e-3);
268 self.pressures[i] = k * (self.densities[i] / rho0 - 1.0);
269 }
270
271 let mut forces = vec![[0.0f64; 3]; n];
273 for i in 0..n {
274 let mut fp = [0.0f64; 3];
275 let mut fv = [0.0f64; 3];
276 for j in 0..n {
277 if i == j {
278 continue;
279 }
280 let dx = self.positions[i][0] - self.positions[j][0];
281 let dy = self.positions[i][1] - self.positions[j][1];
282 let dz = self.positions[i][2] - self.positions[j][2];
283 let r2 = dx * dx + dy * dy + dz * dz;
284 if r2 >= h2 || r2 < 1e-20 {
285 continue;
286 }
287 let r = r2.sqrt();
288 let hr = h - r;
289 let pf = -m * (self.pressures[i] + self.pressures[j])
290 / (2.0 * self.densities[j].max(1e-6))
291 * spiky_coeff
292 * hr
293 * hr
294 / r;
295 fp[0] += pf * dx;
296 fp[1] += pf * dy;
297 fp[2] += pf * dz;
298 let vf = mu * m / self.densities[j].max(1e-6) * visc_coeff * hr;
299 fv[0] += vf * (self.velocities[j][0] - self.velocities[i][0]);
300 fv[1] += vf * (self.velocities[j][1] - self.velocities[i][1]);
301 fv[2] += vf * (self.velocities[j][2] - self.velocities[i][2]);
302 }
303 let rho_i = self.densities[i];
304 forces[i][0] = (fp[0] + fv[0]) / rho_i + g[0];
305 forces[i][1] = (fp[1] + fv[1]) / rho_i + g[1];
306 forces[i][2] = (fp[2] + fv[2]) / rho_i + g[2];
307 }
308
309 for i in 0..n {
311 self.velocities[i][0] += forces[i][0] * dt;
312 self.velocities[i][1] += forces[i][1] * dt;
313 self.velocities[i][2] += forces[i][2] * dt;
314 self.positions[i][0] += self.velocities[i][0] * dt;
315 self.positions[i][1] += self.velocities[i][1] * dt;
316 self.positions[i][2] += self.velocities[i][2] * dt;
317 }
318
319 if self.config.floor_enabled {
321 let floor_y = self.config.floor_y;
322 let rest_coeff = self.config.floor_restitution;
323 for i in 0..n {
324 if self.positions[i][1] < floor_y {
325 self.positions[i][1] = floor_y;
326 if self.velocities[i][1] < 0.0 {
327 self.velocities[i][1] = -self.velocities[i][1] * rest_coeff;
328 }
329 }
330 }
331 }
332
333 self.time += dt;
334 self.step_count += 1;
335 }
336}
337
338#[cfg(test)]
343mod tests {
344
345 use crate::PySphConfig;
346 use crate::PySphSimulation;
347
348 fn water_sim() -> PySphSimulation {
349 PySphSimulation::new(PySphConfig::water())
350 }
351
352 #[test]
353 fn test_sph_creation_empty() {
354 let sim = water_sim();
355 assert_eq!(sim.particle_count(), 0);
356 assert!((sim.time()).abs() < 1e-15);
357 }
358
359 #[test]
360 fn test_sph_add_particle() {
361 let mut sim = water_sim();
362 let idx = sim.add_particle([1.0, 2.0, 3.0]);
363 assert_eq!(idx, 0);
364 assert_eq!(sim.particle_count(), 1);
365 let p = sim.position(0).unwrap();
366 assert!((p[0] - 1.0).abs() < 1e-12);
367 }
368
369 #[test]
370 fn test_sph_add_particle_with_velocity() {
371 let mut sim = water_sim();
372 sim.add_particle_with_velocity([0.0; 3], [1.0, 2.0, 3.0]);
373 let v = sim.velocity(0).unwrap();
374 assert!((v[0] - 1.0).abs() < 1e-12);
375 assert!((v[1] - 2.0).abs() < 1e-12);
376 }
377
378 #[test]
379 fn test_sph_particle_block() {
380 let mut sim = water_sim();
381 sim.add_particle_block([0.0; 3], 2, 3, 4, 0.1);
382 assert_eq!(sim.particle_count(), 24);
383 }
384
385 #[test]
386 fn test_sph_step_moves_particle_under_gravity() {
387 let mut sim = water_sim();
388 sim.add_particle([0.0, 1.0, 0.0]);
389 let y0 = sim.position(0).unwrap()[1];
390 sim.step(0.001);
391 let y1 = sim.position(0).unwrap()[1];
392 assert!(y1 < y0, "particle should fall: y0={} y1={}", y0, y1);
393 }
394
395 #[test]
396 fn test_sph_step_empty_no_panic() {
397 let mut sim = water_sim();
398 sim.step(0.01);
399 assert!((sim.time() - 0.01).abs() < 1e-15);
400 }
401
402 #[test]
403 fn test_sph_time_advances() {
404 let mut sim = water_sim();
405 sim.add_particle([0.0; 3]);
406 sim.step(0.01);
407 sim.step(0.01);
408 assert!((sim.time() - 0.02).abs() < 1e-12);
409 assert_eq!(sim.step_count(), 2);
410 }
411
412 #[test]
413 fn test_sph_density_field_length() {
414 let mut sim = water_sim();
415 sim.add_particle([0.0; 3]);
416 sim.add_particle([1.0, 0.0, 0.0]);
417 let d = sim.get_density_field();
418 assert_eq!(d.len(), 2);
419 }
420
421 #[test]
422 fn test_sph_pressure_field_length() {
423 let mut sim = water_sim();
424 sim.add_particle([0.0; 3]);
425 let p = sim.get_pressure_field();
426 assert_eq!(p.len(), 1);
427 }
428
429 #[test]
430 fn test_sph_all_positions_length() {
431 let mut sim = water_sim();
432 sim.add_particle([0.0; 3]);
433 sim.add_particle([1.0, 0.0, 0.0]);
434 assert_eq!(sim.all_positions().len(), 6);
435 }
436
437 #[test]
438 fn test_sph_all_velocities_length() {
439 let mut sim = water_sim();
440 sim.add_particle([0.0; 3]);
441 assert_eq!(sim.all_velocities().len(), 3);
442 }
443
444 #[test]
445 fn test_sph_floor_boundary() {
446 let cfg = PySphConfig {
447 floor_enabled: true,
448 floor_y: 0.0,
449 floor_restitution: 0.5,
450 ..PySphConfig::water()
451 };
452 let mut sim = PySphSimulation::new(cfg);
453 sim.add_particle([0.0, 0.001, 0.0]);
454 sim.velocities[0] = [0.0, -5.0, 0.0];
456 sim.step(0.01);
457 let y = sim.position(0).unwrap()[1];
459 assert!(y >= 0.0, "particle below floor: y={}", y);
460 }
461
462 #[test]
463 fn test_sph_mean_density_nonzero_after_step() {
464 let mut sim = water_sim();
465 sim.add_particle([0.0; 3]);
466 sim.step(0.001);
467 assert!(sim.mean_density() > 0.0);
468 }
469
470 #[test]
471 fn test_sph_gas_config() {
472 let cfg = PySphConfig::gas();
473 assert!(cfg.rest_density < 10.0);
474 assert!(cfg.particle_mass < 0.01);
475 }
476
477 #[test]
478 fn test_sph_position_out_of_bounds() {
479 let sim = water_sim();
480 assert!(sim.position(99).is_none());
481 }
482}
483
484impl PySphSimulation {
489 pub fn neighbors(&self, i: usize, r: f64) -> Option<Vec<usize>> {
493 let pos_i = self.positions.get(i)?;
494 let r2 = r * r;
495 let neighbors: Vec<usize> = self
496 .positions
497 .iter()
498 .enumerate()
499 .filter(|(j, pos_j)| {
500 if *j == i {
501 return false;
502 }
503 let dx = pos_i[0] - pos_j[0];
504 let dy = pos_i[1] - pos_j[1];
505 let dz = pos_i[2] - pos_j[2];
506 dx * dx + dy * dy + dz * dz <= r2
507 })
508 .map(|(j, _)| j)
509 .collect();
510 Some(neighbors)
511 }
512
513 pub fn neighbor_count(&self, i: usize, r: f64) -> Option<usize> {
515 self.neighbors(i, r).map(|v| v.len())
516 }
517
518 pub fn closest_neighbor(&self, i: usize) -> Option<(usize, f64)> {
522 let pos_i = self.positions.get(i)?;
523 let mut best = None::<(usize, f64)>;
524 for (j, pos_j) in self.positions.iter().enumerate() {
525 if j == i {
526 continue;
527 }
528 let dx = pos_i[0] - pos_j[0];
529 let dy = pos_i[1] - pos_j[1];
530 let dz = pos_i[2] - pos_j[2];
531 let dist = (dx * dx + dy * dy + dz * dz).sqrt();
532 match best {
533 None => best = Some((j, dist)),
534 Some((_, d)) if dist < d => best = Some((j, dist)),
535 _ => {}
536 }
537 }
538 best
539 }
540
541 pub fn center_of_mass(&self) -> [f64; 3] {
543 let n = self.positions.len();
544 if n == 0 {
545 return [0.0; 3];
546 }
547 let mut cx = 0.0f64;
548 let mut cy = 0.0f64;
549 let mut cz = 0.0f64;
550 for pos in &self.positions {
551 cx += pos[0];
552 cy += pos[1];
553 cz += pos[2];
554 }
555 let inv_n = 1.0 / n as f64;
556 [cx * inv_n, cy * inv_n, cz * inv_n]
557 }
558
559 pub fn max_speed(&self) -> f64 {
561 self.velocities
562 .iter()
563 .map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt())
564 .fold(0.0f64, f64::max)
565 }
566
567 pub fn kinetic_energy(&self) -> f64 {
569 let m = self.config.particle_mass;
570 self.velocities
571 .iter()
572 .map(|v| 0.5 * m * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]))
573 .sum()
574 }
575
576 pub fn max_density(&self) -> f64 {
578 self.densities
579 .iter()
580 .cloned()
581 .fold(f64::NEG_INFINITY, f64::max)
582 }
583
584 pub fn min_density(&self) -> f64 {
586 self.densities.iter().cloned().fold(f64::INFINITY, f64::min)
587 }
588
589 pub fn particle_aabb(&self) -> Option<[f64; 6]> {
593 if self.positions.is_empty() {
594 return None;
595 }
596 let mut mn = [f64::INFINITY; 3];
597 let mut mx = [f64::NEG_INFINITY; 3];
598 for pos in &self.positions {
599 for k in 0..3 {
600 if pos[k] < mn[k] {
601 mn[k] = pos[k];
602 }
603 if pos[k] > mx[k] {
604 mx[k] = pos[k];
605 }
606 }
607 }
608 Some([mn[0], mn[1], mn[2], mx[0], mx[1], mx[2]])
609 }
610
611 pub fn remove_below_y(&mut self, y_min: f64) -> usize {
615 let before = self.positions.len();
616 let keep: Vec<bool> = self.positions.iter().map(|p| p[1] >= y_min).collect();
617 let mut new_pos = Vec::new();
618 let mut new_vel = Vec::new();
619 let mut new_den = Vec::new();
620 let mut new_prs = Vec::new();
621 for (i, &keep_i) in keep.iter().enumerate() {
622 if keep_i {
623 new_pos.push(self.positions[i]);
624 new_vel.push(self.velocities[i]);
625 new_den.push(self.densities[i]);
626 new_prs.push(self.pressures[i]);
627 }
628 }
629 self.positions = new_pos;
630 self.velocities = new_vel;
631 self.densities = new_den;
632 self.pressures = new_prs;
633 before - self.positions.len()
634 }
635}
636
637#[derive(Debug, Clone)]
643#[allow(dead_code)]
644pub struct SphStats {
645 pub particle_count: usize,
647 pub mean_density: f64,
649 pub max_density: f64,
651 pub min_density: f64,
653 pub max_speed: f64,
655 pub kinetic_energy: f64,
657 pub time: f64,
659 pub step_count: u64,
661}
662
663impl PySphSimulation {
664 pub fn collect_stats(&self) -> SphStats {
666 SphStats {
667 particle_count: self.particle_count(),
668 mean_density: self.mean_density(),
669 max_density: self.max_density(),
670 min_density: if self.densities.is_empty() {
671 0.0
672 } else {
673 self.min_density()
674 },
675 max_speed: self.max_speed(),
676 kinetic_energy: self.kinetic_energy(),
677 time: self.time,
678 step_count: self.step_count,
679 }
680 }
681}
682
683#[cfg(test)]
688mod sph_ext_tests {
689
690 use crate::PySphConfig;
691 use crate::PySphSimulation;
692
693 fn water_sim() -> PySphSimulation {
694 PySphSimulation::new(PySphConfig::water())
695 }
696
697 #[test]
698 fn test_sph_neighbors_empty() {
699 let mut sim = water_sim();
700 sim.add_particle([0.0; 3]);
701 let n = sim.neighbors(0, 1.0).unwrap();
702 assert!(n.is_empty());
703 }
704
705 #[test]
706 fn test_sph_neighbors_finds_close() {
707 let mut sim = water_sim();
708 sim.add_particle([0.0; 3]);
709 sim.add_particle([0.05, 0.0, 0.0]); sim.add_particle([5.0, 0.0, 0.0]); let n = sim.neighbors(0, 0.1).unwrap();
712 assert_eq!(n.len(), 1);
713 assert_eq!(n[0], 1);
714 }
715
716 #[test]
717 fn test_sph_neighbor_count() {
718 let mut sim = water_sim();
719 sim.add_particle([0.0; 3]);
720 sim.add_particle([0.05, 0.0, 0.0]);
721 sim.add_particle([0.05, 0.05, 0.0]);
722 let count = sim.neighbor_count(0, 0.15).unwrap();
723 assert_eq!(count, 2);
724 }
725
726 #[test]
727 fn test_sph_closest_neighbor() {
728 let mut sim = water_sim();
729 sim.add_particle([0.0; 3]);
730 sim.add_particle([1.0, 0.0, 0.0]);
731 sim.add_particle([0.3, 0.0, 0.0]);
732 let result = sim.closest_neighbor(0).unwrap();
733 assert_eq!(result.0, 2); assert!((result.1 - 0.3).abs() < 1e-10);
735 }
736
737 #[test]
738 fn test_sph_closest_neighbor_single_particle() {
739 let mut sim = water_sim();
740 sim.add_particle([0.0; 3]);
741 assert!(sim.closest_neighbor(0).is_none());
742 }
743
744 #[test]
745 fn test_sph_center_of_mass() {
746 let mut sim = water_sim();
747 sim.add_particle([0.0, 0.0, 0.0]);
748 sim.add_particle([2.0, 0.0, 0.0]);
749 let com = sim.center_of_mass();
750 assert!((com[0] - 1.0).abs() < 1e-10);
751 }
752
753 #[test]
754 fn test_sph_center_of_mass_empty() {
755 let sim = water_sim();
756 let com = sim.center_of_mass();
757 for &c in &com {
758 assert!(c.abs() < 1e-15);
759 }
760 }
761
762 #[test]
763 fn test_sph_max_speed_zero_at_rest() {
764 let mut sim = water_sim();
765 sim.add_particle([0.0; 3]);
766 assert!(sim.max_speed() < 1e-15);
767 }
768
769 #[test]
770 fn test_sph_kinetic_energy_with_velocity() {
771 let mut sim = water_sim();
772 sim.add_particle_with_velocity([0.0; 3], [2.0, 0.0, 0.0]);
773 let ke = sim.kinetic_energy();
775 assert!((ke - 0.04).abs() < 1e-12, "KE = {}", ke);
776 }
777
778 #[test]
779 fn test_sph_max_density_after_step() {
780 let mut sim = water_sim();
781 sim.add_particle([0.0; 3]);
782 sim.step(0.001);
783 assert!(sim.max_density() > 0.0);
784 }
785
786 #[test]
787 fn test_sph_min_density_after_step() {
788 let mut sim = water_sim();
789 sim.add_particle([0.0; 3]);
790 sim.step(0.001);
791 assert!(sim.min_density() > 0.0);
792 }
793
794 #[test]
795 fn test_sph_particle_aabb() {
796 let mut sim = water_sim();
797 sim.add_particle([0.0, 0.0, 0.0]);
798 sim.add_particle([1.0, 2.0, 3.0]);
799 let aabb = sim.particle_aabb().unwrap();
800 assert!((aabb[0]).abs() < 1e-15); assert!((aabb[3] - 1.0).abs() < 1e-15); assert!((aabb[5] - 3.0).abs() < 1e-15); }
804
805 #[test]
806 fn test_sph_particle_aabb_empty() {
807 let sim = water_sim();
808 assert!(sim.particle_aabb().is_none());
809 }
810
811 #[test]
812 fn test_sph_remove_below_y() {
813 let mut sim = water_sim();
814 sim.add_particle([0.0, 1.0, 0.0]);
815 sim.add_particle([0.0, -1.0, 0.0]);
816 sim.add_particle([0.0, 0.5, 0.0]);
817 let removed = sim.remove_below_y(0.0);
818 assert_eq!(removed, 1);
819 assert_eq!(sim.particle_count(), 2);
820 }
821
822 #[test]
823 fn test_sph_collect_stats() {
824 let mut sim = water_sim();
825 sim.add_particle([0.0; 3]);
826 sim.step(0.001);
827 let stats = sim.collect_stats();
828 assert_eq!(stats.particle_count, 1);
829 assert!(stats.mean_density > 0.0);
830 }
831
832 #[test]
833 fn test_sph_neighbors_out_of_bounds() {
834 let sim = water_sim();
835 assert!(sim.neighbors(99, 1.0).is_none());
836 }
837
838 #[test]
839 fn test_sph_neighbor_count_out_of_bounds() {
840 let sim = water_sim();
841 assert!(sim.neighbor_count(99, 1.0).is_none());
842 }
843}
844
845impl PySphSimulation {
850 pub fn adaptive_h(&self, i: usize, k: usize) -> Option<f64> {
856 let pos_i = self.positions.get(i)?;
857 let mut dists: Vec<f64> = self
858 .positions
859 .iter()
860 .enumerate()
861 .filter(|(j, _)| *j != i)
862 .map(|(_, pos_j)| {
863 let dx = pos_i[0] - pos_j[0];
864 let dy = pos_i[1] - pos_j[1];
865 let dz = pos_i[2] - pos_j[2];
866 (dx * dx + dy * dy + dz * dz).sqrt()
867 })
868 .collect();
869 if dists.len() < k {
870 return None;
871 }
872 dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
873 Some(dists[k - 1])
874 }
875
876 pub fn global_adaptive_h(&self, k: usize) -> Option<f64> {
878 let n = self.positions.len();
879 if n < 2 {
880 return None;
881 }
882 let mut hs: Vec<f64> = (0..n).filter_map(|i| self.adaptive_h(i, k)).collect();
883 if hs.is_empty() {
884 return None;
885 }
886 hs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
887 let mid = hs.len() / 2;
888 Some(hs[mid])
889 }
890}
891
892impl PySphSimulation {
897 pub fn detect_surface_particles(&self, r: f64, threshold: usize) -> Vec<bool> {
903 (0..self.positions.len())
904 .map(|i| {
905 let count = self.neighbor_count(i, r).unwrap_or(0);
906 count < threshold
907 })
908 .collect()
909 }
910
911 pub fn surface_particle_indices(&self, r: f64, threshold: usize) -> Vec<usize> {
913 self.detect_surface_particles(r, threshold)
914 .into_iter()
915 .enumerate()
916 .filter(|(_, is_surface)| *is_surface)
917 .map(|(i, _)| i)
918 .collect()
919 }
920}
921
922#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
928pub enum SphVariant {
929 WCSPH,
931 DFSPH,
933}
934
935#[allow(dead_code)]
937pub struct SphSolver {
938 pub sim: PySphSimulation,
940 pub variant: SphVariant,
942 pub max_pressure_iters: usize,
944 pub pressure_tol: f64,
946}
947
948impl SphSolver {
949 pub fn wcsph(config: PySphConfig) -> Self {
951 Self {
952 sim: PySphSimulation::new(config),
953 variant: SphVariant::WCSPH,
954 max_pressure_iters: 50,
955 pressure_tol: 0.01,
956 }
957 }
958
959 pub fn dfsph(config: PySphConfig) -> Self {
961 Self {
962 sim: PySphSimulation::new(config),
963 variant: SphVariant::DFSPH,
964 max_pressure_iters: 100,
965 pressure_tol: 0.001,
966 }
967 }
968
969 pub fn step(&mut self, dt: f64) {
972 match self.variant {
973 SphVariant::WCSPH => self.sim.step(dt),
974 SphVariant::DFSPH => self.step_dfsph(dt),
975 }
976 }
977
978 fn step_dfsph(&mut self, dt: f64) {
981 let sub_steps = self.max_pressure_iters.clamp(1, 5);
982 let sub_dt = dt / sub_steps as f64;
983 for _ in 0..sub_steps {
984 self.sim.step(sub_dt);
985 }
986 }
987
988 pub fn density_error(&self) -> f64 {
990 let rho0 = self.sim.config.rest_density;
991 self.sim
992 .densities
993 .iter()
994 .map(|&rho| (rho - rho0).abs() / rho0)
995 .fold(0.0f64, f64::max)
996 }
997
998 pub fn particle_count(&self) -> usize {
1000 self.sim.particle_count()
1001 }
1002}
1003
1004#[allow(dead_code)]
1010pub struct SphParticleSet {
1011 pub name: String,
1013 pub indices: Vec<usize>,
1015}
1016
1017impl SphParticleSet {
1018 pub fn new(name: impl Into<String>) -> Self {
1020 Self {
1021 name: name.into(),
1022 indices: Vec::new(),
1023 }
1024 }
1025
1026 pub fn add_index(&mut self, idx: usize) {
1028 self.indices.push(idx);
1029 }
1030
1031 pub fn len(&self) -> usize {
1033 self.indices.len()
1034 }
1035
1036 pub fn is_empty(&self) -> bool {
1038 self.indices.is_empty()
1039 }
1040}
1041
1042impl PySphSimulation {
1043 pub fn add_particles(&mut self, positions: &[[f64; 3]]) -> std::ops::Range<usize> {
1045 let start = self.positions.len();
1046 for &pos in positions {
1047 self.add_particle(pos);
1048 }
1049 start..self.positions.len()
1050 }
1051
1052 pub fn set_velocity(&mut self, i: usize, vel: [f64; 3]) {
1054 if i < self.velocities.len() {
1055 self.velocities[i] = vel;
1056 }
1057 }
1058
1059 pub fn apply_global_impulse(&mut self, dv: [f64; 3]) {
1061 for v in &mut self.velocities {
1062 v[0] += dv[0];
1063 v[1] += dv[1];
1064 v[2] += dv[2];
1065 }
1066 }
1067
1068 pub fn clamp_velocities(&mut self, max_speed: f64) {
1070 for v in &mut self.velocities {
1071 let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1072 if speed > max_speed && speed > 1e-15 {
1073 let scale = max_speed / speed;
1074 v[0] *= scale;
1075 v[1] *= scale;
1076 v[2] *= scale;
1077 }
1078 }
1079 }
1080
1081 pub fn total_momentum(&self) -> [f64; 3] {
1083 let m = self.config.particle_mass;
1084 let mut p = [0.0f64; 3];
1085 for v in &self.velocities {
1086 p[0] += m * v[0];
1087 p[1] += m * v[1];
1088 p[2] += m * v[2];
1089 }
1090 p
1091 }
1092
1093 pub fn density_variance(&self) -> f64 {
1095 if self.densities.is_empty() {
1096 return 0.0;
1097 }
1098 let mean = self.mean_density();
1099 self.densities
1100 .iter()
1101 .map(|&d| {
1102 let diff = d - mean;
1103 diff * diff
1104 })
1105 .sum::<f64>()
1106 / self.densities.len() as f64
1107 }
1108}
1109
1110#[cfg(test)]
1115mod sph_new_tests {
1116
1117 use crate::PySphConfig;
1118 use crate::PySphSimulation;
1119 use crate::sph_api::SphParticleSet;
1120 use crate::sph_api::SphSolver;
1121 use crate::sph_api::SphVariant;
1122
1123 fn water_sim() -> PySphSimulation {
1124 PySphSimulation::new(PySphConfig::water())
1125 }
1126
1127 #[test]
1128 fn test_adaptive_h_basic() {
1129 let mut sim = water_sim();
1130 for i in 0..5 {
1131 sim.add_particle([i as f64 * 0.1, 0.0, 0.0]);
1132 }
1133 let h = sim.adaptive_h(0, 3);
1134 assert!(h.is_some());
1135 assert!(h.unwrap() > 0.0);
1136 }
1137
1138 #[test]
1139 fn test_adaptive_h_not_enough_neighbors() {
1140 let mut sim = water_sim();
1141 sim.add_particle([0.0; 3]);
1142 sim.add_particle([1.0, 0.0, 0.0]);
1143 let h = sim.adaptive_h(0, 5);
1145 assert!(h.is_none());
1146 }
1147
1148 #[test]
1149 fn test_adaptive_h_out_of_bounds() {
1150 let sim = water_sim();
1151 assert!(sim.adaptive_h(99, 3).is_none());
1152 }
1153
1154 #[test]
1155 fn test_global_adaptive_h() {
1156 let mut sim = water_sim();
1157 for i in 0..10 {
1158 sim.add_particle([i as f64 * 0.1, 0.0, 0.0]);
1159 }
1160 let h = sim.global_adaptive_h(3);
1161 assert!(h.is_some());
1162 }
1163
1164 #[test]
1165 fn test_global_adaptive_h_single_particle() {
1166 let mut sim = water_sim();
1167 sim.add_particle([0.0; 3]);
1168 assert!(sim.global_adaptive_h(3).is_none());
1169 }
1170
1171 #[test]
1172 fn test_detect_surface_all_surface_when_isolated() {
1173 let mut sim = water_sim();
1174 sim.add_particle([0.0, 0.0, 0.0]);
1175 sim.add_particle([10.0, 0.0, 0.0]); let surface = sim.detect_surface_particles(0.5, 2);
1177 assert!(surface[0]); }
1179
1180 #[test]
1181 fn test_detect_surface_not_surface_when_crowded() {
1182 let mut sim = water_sim();
1183 for i in 0..8 {
1185 for j in 0..8 {
1186 sim.add_particle([i as f64 * 0.05, j as f64 * 0.05, 0.0]);
1187 }
1188 }
1189 let surface = sim.detect_surface_particles(0.3, 2);
1190 let n_surface = surface.iter().filter(|&&s| s).count();
1192 assert!(n_surface < sim.particle_count()); }
1194
1195 #[test]
1196 fn test_surface_particle_indices_empty() {
1197 let sim = water_sim();
1198 let idx = sim.surface_particle_indices(1.0, 5);
1199 assert!(idx.is_empty());
1200 }
1201
1202 #[test]
1203 fn test_sph_variant_wcsph() {
1204 let mut solver = SphSolver::wcsph(PySphConfig::water());
1205 solver.sim.add_particle([0.0, 1.0, 0.0]);
1206 solver.step(0.001);
1207 assert_eq!(solver.sim.step_count(), 1);
1208 assert_eq!(solver.variant, SphVariant::WCSPH);
1209 }
1210
1211 #[test]
1212 fn test_sph_variant_dfsph() {
1213 let mut solver = SphSolver::dfsph(PySphConfig::water());
1214 solver.sim.add_particle([0.0, 1.0, 0.0]);
1215 solver.step(0.001);
1216 assert_eq!(solver.sim.step_count(), 5);
1218 assert_eq!(solver.variant, SphVariant::DFSPH);
1219 }
1220
1221 #[test]
1222 fn test_density_error_at_rest() {
1223 let mut solver = SphSolver::wcsph(PySphConfig::water());
1224 solver.sim.add_particle([0.0; 3]);
1225 let err = solver.density_error();
1227 assert!(err.is_finite());
1228 }
1229
1230 #[test]
1231 fn test_sph_particle_set_empty() {
1232 let set = SphParticleSet::new("fluid");
1233 assert!(set.is_empty());
1234 assert_eq!(set.len(), 0);
1235 }
1236
1237 #[test]
1238 fn test_sph_particle_set_add() {
1239 let mut set = SphParticleSet::new("fluid");
1240 set.add_index(0);
1241 set.add_index(1);
1242 assert_eq!(set.len(), 2);
1243 }
1244
1245 #[test]
1246 fn test_add_particles_bulk() {
1247 let mut sim = water_sim();
1248 let positions = vec![[0.0; 3], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1249 let range = sim.add_particles(&positions);
1250 assert_eq!(range.len(), 3);
1251 assert_eq!(sim.particle_count(), 3);
1252 }
1253
1254 #[test]
1255 fn test_set_velocity() {
1256 let mut sim = water_sim();
1257 sim.add_particle([0.0; 3]);
1258 sim.set_velocity(0, [1.0, 2.0, 3.0]);
1259 let v = sim.velocity(0).unwrap();
1260 assert!((v[0] - 1.0).abs() < 1e-15);
1261 }
1262
1263 #[test]
1264 fn test_set_velocity_out_of_bounds_no_panic() {
1265 let mut sim = water_sim();
1266 sim.set_velocity(99, [1.0, 0.0, 0.0]); }
1268
1269 #[test]
1270 fn test_apply_global_impulse() {
1271 let mut sim = water_sim();
1272 sim.add_particle([0.0; 3]);
1273 sim.apply_global_impulse([10.0, 0.0, 0.0]);
1274 let v = sim.velocity(0).unwrap();
1275 assert!((v[0] - 10.0).abs() < 1e-15);
1276 }
1277
1278 #[test]
1279 fn test_clamp_velocities() {
1280 let mut sim = water_sim();
1281 sim.add_particle_with_velocity([0.0; 3], [100.0, 0.0, 0.0]);
1282 sim.clamp_velocities(5.0);
1283 let v = sim.velocity(0).unwrap();
1284 let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1285 assert!((speed - 5.0).abs() < 1e-10);
1286 }
1287
1288 #[test]
1289 fn test_total_momentum_at_rest() {
1290 let mut sim = water_sim();
1291 sim.add_particle([0.0; 3]);
1292 let p = sim.total_momentum();
1293 for &pi in &p {
1294 assert!(pi.abs() < 1e-15);
1295 }
1296 }
1297
1298 #[test]
1299 fn test_total_momentum_with_velocity() {
1300 let mut sim = water_sim();
1301 sim.add_particle_with_velocity([0.0; 3], [2.0, 0.0, 0.0]);
1302 let p = sim.total_momentum();
1303 assert!((p[0] - 0.04).abs() < 1e-12);
1305 }
1306
1307 #[test]
1308 fn test_density_variance_zero_uniform() {
1309 let mut sim = water_sim();
1310 sim.add_particle([0.0; 3]);
1312 sim.add_particle([100.0, 0.0, 0.0]); let var = sim.density_variance();
1315 assert!((var).abs() < 1e-10);
1316 }
1317
1318 #[test]
1319 fn test_density_variance_empty() {
1320 let sim = water_sim();
1321 assert!((sim.density_variance()).abs() < 1e-15);
1322 }
1323
1324 #[test]
1325 fn test_sph_solver_particle_count() {
1326 let mut solver = SphSolver::wcsph(PySphConfig::water());
1327 solver.sim.add_particle([0.0; 3]);
1328 assert_eq!(solver.particle_count(), 1);
1329 }
1330
1331 #[test]
1332 fn test_add_particles_returns_correct_range() {
1333 let mut sim = water_sim();
1334 sim.add_particle([0.0; 3]); let positions = vec![[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1336 let range = sim.add_particles(&positions);
1337 assert_eq!(range.start, 1);
1338 assert_eq!(range.end, 3);
1339 }
1340
1341 #[test]
1342 fn test_surface_detection_length_matches_particle_count() {
1343 let mut sim = water_sim();
1344 sim.add_particle_block([0.0; 3], 3, 3, 3, 0.1);
1345 let surface = sim.detect_surface_particles(0.15, 3);
1346 assert_eq!(surface.len(), sim.particle_count());
1347 }
1348
1349 #[test]
1350 fn test_clamp_velocities_already_slow() {
1351 let mut sim = water_sim();
1352 sim.add_particle_with_velocity([0.0; 3], [1.0, 0.0, 0.0]);
1353 sim.clamp_velocities(10.0); let v = sim.velocity(0).unwrap();
1355 assert!((v[0] - 1.0).abs() < 1e-14);
1356 }
1357}