Skip to main content

oxiphysics_collision/
fluid_collision.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Fluid-solid collision and coupling for the OxiPhysics collision crate.
5//!
6//! Covers:
7//! - Fluid-solid coupling (SPH-rigid, LBM-rigid bounce-back)
8//! - Buoyancy force computation (Archimedes principle + displaced volume)
9//! - Drag force on submerged bodies (viscous + pressure drag)
10//! - Water entry / exit detection (free-surface crossing)
11//! - Fluid boundary collision handling
12//! - Splash particle generation at collision impact
13//! - Underwater contact friction
14//! - Free-surface collision detection
15//! - Two-way coupling impulse exchange
16//! - Fluid-accelerated projectile dynamics
17
18use std::f64::consts::PI;
19
20// ─────────────────────────────────────────────────────────────────────────────
21// Internal vector helpers — no nalgebra
22// ─────────────────────────────────────────────────────────────────────────────
23
24#[inline]
25fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
26    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
27}
28
29#[inline]
30fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
31    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
32}
33
34#[inline]
35fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
36    [a[0] * s, a[1] * s, a[2] * s]
37}
38
39#[inline]
40fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
41    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
42}
43
44#[inline]
45fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
46    [
47        a[1] * b[2] - a[2] * b[1],
48        a[2] * b[0] - a[0] * b[2],
49        a[0] * b[1] - a[1] * b[0],
50    ]
51}
52
53#[inline]
54fn len3(a: [f64; 3]) -> f64 {
55    dot3(a, a).sqrt()
56}
57
58#[inline]
59fn normalize3(a: [f64; 3]) -> [f64; 3] {
60    let l = len3(a);
61    if l < 1e-14 {
62        [0.0, 0.0, 1.0]
63    } else {
64        scale3(a, 1.0 / l)
65    }
66}
67
68#[inline]
69fn neg3(a: [f64; 3]) -> [f64; 3] {
70    [-a[0], -a[1], -a[2]]
71}
72
73// ─────────────────────────────────────────────────────────────────────────────
74// 1. Fluid Properties
75// ─────────────────────────────────────────────────────────────────────────────
76
77/// Physical properties of a fluid.
78#[derive(Debug, Clone, Copy)]
79pub struct FluidProperties {
80    /// Fluid density ρ \[kg/m³\].
81    pub density: f64,
82    /// Dynamic viscosity μ \[Pa·s\].
83    pub viscosity: f64,
84    /// Kinematic viscosity ν = μ/ρ \[m²/s\].
85    pub kinematic_viscosity: f64,
86    /// Surface tension coefficient σ \[N/m\].
87    pub surface_tension: f64,
88    /// Speed of sound in the fluid \[m/s\].
89    pub speed_of_sound: f64,
90}
91
92impl FluidProperties {
93    /// Create fluid properties and derive kinematic viscosity automatically.
94    ///
95    /// # Arguments
96    /// * `density` - Mass density \[kg/m³\].
97    /// * `viscosity` - Dynamic viscosity \[Pa·s\].
98    /// * `surface_tension` - Surface tension \[N/m\].
99    /// * `speed_of_sound` - Speed of sound \[m/s\].
100    pub fn new(density: f64, viscosity: f64, surface_tension: f64, speed_of_sound: f64) -> Self {
101        Self {
102            density,
103            viscosity,
104            kinematic_viscosity: viscosity / density,
105            surface_tension,
106            speed_of_sound,
107        }
108    }
109
110    /// Return water-like properties at ~20°C.
111    pub fn water() -> Self {
112        Self::new(998.2, 1.002e-3, 0.0728, 1482.0)
113    }
114
115    /// Return air-like properties at sea level, ~20°C.
116    pub fn air() -> Self {
117        Self::new(1.204, 1.81e-5, 0.0, 343.0)
118    }
119}
120
121// ─────────────────────────────────────────────────────────────────────────────
122// 2. Rigid Body State for Fluid Coupling
123// ─────────────────────────────────────────────────────────────────────────────
124
125/// Minimal rigid body state used for fluid-solid coupling computations.
126#[derive(Debug, Clone)]
127pub struct RigidBodyFluidState {
128    /// Centre-of-mass position \[m\].
129    pub position: [f64; 3],
130    /// Linear velocity \[m/s\].
131    pub velocity: [f64; 3],
132    /// Angular velocity \[rad/s\].
133    pub angular_velocity: [f64; 3],
134    /// Total mass \[kg\].
135    pub mass: f64,
136    /// Inertia tensor diagonal \[kg·m²\] (principal axes assumed).
137    pub inertia: [f64; 3],
138    /// Representative radius for sphere approximation \[m\].
139    pub radius: f64,
140    /// Cross-sectional area for drag computation \[m²\].
141    pub cross_section_area: f64,
142}
143
144impl RigidBodyFluidState {
145    /// Create a new rigid body fluid state.
146    pub fn new(position: [f64; 3], velocity: [f64; 3], mass: f64, radius: f64) -> Self {
147        let i = 0.4 * mass * radius * radius; // sphere moment of inertia
148        Self {
149            position,
150            velocity,
151            angular_velocity: [0.0; 3],
152            mass,
153            inertia: [i, i, i],
154            radius,
155            cross_section_area: PI * radius * radius,
156        }
157    }
158}
159
160// ─────────────────────────────────────────────────────────────────────────────
161// 3. Reynolds Number
162// ─────────────────────────────────────────────────────────────────────────────
163
164/// Compute the Reynolds number Re = U·L / ν.
165///
166/// # Arguments
167/// * `velocity` - Characteristic velocity \[m/s\].
168/// * `length` - Characteristic length \[m\].
169/// * `kinematic_viscosity` - ν = μ/ρ \[m²/s\].
170pub fn reynolds_number(velocity: f64, length: f64, kinematic_viscosity: f64) -> f64 {
171    if kinematic_viscosity < 1e-30 {
172        return f64::INFINITY;
173    }
174    velocity.abs() * length / kinematic_viscosity
175}
176
177// ─────────────────────────────────────────────────────────────────────────────
178// 4. Buoyancy Force
179// ─────────────────────────────────────────────────────────────────────────────
180
181/// Buoyancy result containing force vector and submerged volume.
182#[derive(Debug, Clone, Copy)]
183pub struct BuoyancyResult {
184    /// Buoyancy force vector \[N\] (upward for positive y convention).
185    pub force: [f64; 3],
186    /// Volume of body submerged in fluid \[m³\].
187    pub submerged_volume: f64,
188    /// Centre of buoyancy (centroid of submerged volume).
189    pub centre_of_buoyancy: [f64; 3],
190}
191
192/// Compute buoyancy force on a sphere partially or fully submerged.
193///
194/// Uses the spherical cap volume formula for partial submersion.
195///
196/// # Arguments
197/// * `body` - Rigid body state (position = centre of sphere).
198/// * `fluid` - Fluid properties.
199/// * `free_surface_y` - Y-coordinate of the free surface \[m\].
200/// * `gravity` - Gravitational acceleration magnitude \[m/s²\].
201///
202/// The gravity vector is assumed to act in the −y direction.
203pub fn buoyancy_sphere(
204    body: &RigidBodyFluidState,
205    fluid: &FluidProperties,
206    free_surface_y: f64,
207    gravity: f64,
208) -> BuoyancyResult {
209    let r = body.radius;
210    let cy = body.position[1];
211    // Depth of sphere centre below free surface
212    let depth = free_surface_y - cy;
213
214    let (vol_sub, cob_y) = if depth >= r {
215        // Fully submerged
216        let vol = 4.0 / 3.0 * PI * r * r * r;
217        (vol, cy) // centre of buoyancy = sphere centre when fully submerged
218    } else if depth <= -r {
219        // Fully above surface
220        (0.0, cy)
221    } else {
222        // Partially submerged — spherical cap volume
223        // h = depth of cap immersion = r + depth (from bottom of sphere)
224        let h = (r + depth).max(0.0).min(2.0 * r);
225        let vol = PI * h * h * (r - h / 3.0);
226        // Centre of buoyancy y within the submerged cap (measured from sphere centre)
227        let cob_y_local = if vol > 1e-30 {
228            // z-coord of centroid of spherical cap: (3*(2r-h)^2) / (4*(3r-h)) below top
229            let cob_from_top = 3.0 * (2.0 * r - h).powi(2) / (4.0 * (3.0 * r - h).max(1e-15));
230            cy - r + h - cob_from_top
231        } else {
232            cy - r
233        };
234        (vol, cob_y_local)
235    };
236
237    let force_mag = fluid.density * gravity * vol_sub;
238    let force = [0.0, force_mag, 0.0]; // upward
239    let cob = [body.position[0], cob_y, body.position[2]];
240    BuoyancyResult {
241        force,
242        submerged_volume: vol_sub,
243        centre_of_buoyancy: cob,
244    }
245}
246
247/// Compute buoyancy force for an arbitrary body given its submerged volume.
248///
249/// F_b = ρ_fluid · g · V_sub (upward).
250pub fn buoyancy_force(fluid_density: f64, gravity: f64, submerged_volume: f64) -> [f64; 3] {
251    let mag = fluid_density * gravity * submerged_volume;
252    [0.0, mag, 0.0]
253}
254
255// ─────────────────────────────────────────────────────────────────────────────
256// 5. Drag Force
257// ─────────────────────────────────────────────────────────────────────────────
258
259/// Drag coefficient as a function of Reynolds number (sphere model).
260///
261/// Uses the Schiller-Naumann correlation:
262/// - Stokes: C_d = 24/Re for Re < 1
263/// - Schiller-Naumann: C_d = (24/Re)(1 + 0.15 Re^0.687) for 1 ≤ Re ≤ 1000
264/// - Newton: C_d = 0.44 for Re > 1000
265pub fn drag_coefficient_sphere(re: f64) -> f64 {
266    if re < 1e-10 {
267        return 24.0 / 1e-10; // avoid division by zero
268    }
269    if re < 1.0 {
270        24.0 / re
271    } else if re <= 1000.0 {
272        (24.0 / re) * (1.0 + 0.15 * re.powf(0.687))
273    } else {
274        0.44
275    }
276}
277
278/// Compute the hydrodynamic drag force on a body moving through a fluid.
279///
280/// F_drag = -0.5 · ρ · C_d · A · |v_rel|² · v̂_rel
281///
282/// # Arguments
283/// * `body_velocity` - Velocity of the body \[m/s\].
284/// * `fluid_velocity` - Local fluid velocity \[m/s\].
285/// * `fluid` - Fluid properties.
286/// * `cd` - Drag coefficient (use [`drag_coefficient_sphere`] if unknown).
287/// * `reference_area` - Body reference area \[m²\].
288pub fn drag_force(
289    body_velocity: [f64; 3],
290    fluid_velocity: [f64; 3],
291    fluid: &FluidProperties,
292    cd: f64,
293    reference_area: f64,
294) -> [f64; 3] {
295    let v_rel = sub3(body_velocity, fluid_velocity);
296    let speed = len3(v_rel);
297    if speed < 1e-15 {
298        return [0.0; 3];
299    }
300    let mag = 0.5 * fluid.density * cd * reference_area * speed * speed;
301    let dir = normalize3(neg3(v_rel));
302    scale3(dir, mag)
303}
304
305/// Compute drag with automatic Reynolds-number-based C_d for a sphere.
306pub fn drag_force_sphere(
307    body: &RigidBodyFluidState,
308    fluid_velocity: [f64; 3],
309    fluid: &FluidProperties,
310) -> [f64; 3] {
311    let v_rel = sub3(body.velocity, fluid_velocity);
312    let speed = len3(v_rel);
313    let re = reynolds_number(speed, 2.0 * body.radius, fluid.kinematic_viscosity);
314    let cd = drag_coefficient_sphere(re);
315    drag_force(
316        body.velocity,
317        fluid_velocity,
318        fluid,
319        cd,
320        body.cross_section_area,
321    )
322}
323
324/// Compute the lift force using a simplified cross-flow model.
325///
326/// F_lift = 0.5 · ρ · C_l · A · v_rel² · n̂_lift
327///
328/// where n̂_lift is perpendicular to v_rel in the vertical plane.
329pub fn lift_force(
330    body_velocity: [f64; 3],
331    fluid_velocity: [f64; 3],
332    fluid: &FluidProperties,
333    cl: f64,
334    reference_area: f64,
335) -> [f64; 3] {
336    let v_rel = sub3(body_velocity, fluid_velocity);
337    let speed = len3(v_rel);
338    if speed < 1e-15 {
339        return [0.0; 3];
340    }
341    let up = [0.0_f64, 1.0, 0.0];
342    let lift_dir = normalize3(cross3(v_rel, cross3(v_rel, up)));
343    let mag = 0.5 * fluid.density * cl * reference_area * speed * speed;
344    scale3(lift_dir, mag)
345}
346
347// ─────────────────────────────────────────────────────────────────────────────
348// 6. Water Entry / Exit Detection
349// ─────────────────────────────────────────────────────────────────────────────
350
351/// State of a body with respect to the free surface.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum SubmersionState {
354    /// Body is entirely above the free surface.
355    Airborne,
356    /// Body is crossing the free surface (partially submerged).
357    Crossing,
358    /// Body is entirely below the free surface.
359    Submerged,
360}
361
362/// Detect the submersion state of a sphere relative to the free surface.
363///
364/// # Arguments
365/// * `body` - Rigid body (position = sphere centre).
366/// * `free_surface_y` - Y-coordinate of the flat free surface.
367pub fn submersion_state_sphere(body: &RigidBodyFluidState, free_surface_y: f64) -> SubmersionState {
368    let cy = body.position[1];
369    let r = body.radius;
370    if cy - r >= free_surface_y {
371        SubmersionState::Airborne
372    } else if cy + r <= free_surface_y {
373        SubmersionState::Submerged
374    } else {
375        SubmersionState::Crossing
376    }
377}
378
379/// Water entry event data.
380#[derive(Debug, Clone, Copy)]
381pub struct WaterEntryEvent {
382    /// Position of the body centre at entry.
383    pub position: [f64; 3],
384    /// Velocity at entry \[m/s\].
385    pub velocity: [f64; 3],
386    /// Entry speed \[m/s\].
387    pub entry_speed: f64,
388    /// Vertical component of velocity at entry \[m/s\].
389    pub vertical_speed: f64,
390    /// Whether this is an entry (true) or exit (false).
391    pub is_entry: bool,
392}
393
394/// Detect water entry or exit by comparing previous and current states.
395///
396/// Returns `Some(WaterEntryEvent)` if a crossing of the free surface occurred.
397pub fn detect_water_crossing(
398    prev: &RigidBodyFluidState,
399    curr: &RigidBodyFluidState,
400    free_surface_y: f64,
401) -> Option<WaterEntryEvent> {
402    let prev_state = submersion_state_sphere(prev, free_surface_y);
403    let curr_state = submersion_state_sphere(curr, free_surface_y);
404    if prev_state == curr_state {
405        return None;
406    }
407    let is_entry = matches!(
408        (prev_state, curr_state),
409        (SubmersionState::Airborne, SubmersionState::Crossing)
410            | (SubmersionState::Airborne, SubmersionState::Submerged)
411            | (SubmersionState::Crossing, SubmersionState::Submerged)
412    );
413    let speed = len3(curr.velocity);
414    Some(WaterEntryEvent {
415        position: curr.position,
416        velocity: curr.velocity,
417        entry_speed: speed,
418        vertical_speed: curr.velocity[1].abs(),
419        is_entry,
420    })
421}
422
423/// Compute the von Karman water impact load (slamming pressure) on entry.
424///
425/// Uses the simplified von Karman model:
426/// F_slam ≈ ρ_f · π · r_c² · (dz/dt)² · (1 + α)
427/// where r_c is the contact radius and α is an added-mass correction factor.
428///
429/// # Arguments
430/// * `fluid_density` - Fluid density \[kg/m³\].
431/// * `entry_speed` - Vertical entry speed \[m/s\].
432/// * `contact_radius` - Instantaneous water-plane contact radius \[m\].
433/// * `added_mass_coeff` - Added mass coefficient α (≈ 1.0 for sphere).
434pub fn water_entry_slamming_force(
435    fluid_density: f64,
436    entry_speed: f64,
437    contact_radius: f64,
438    added_mass_coeff: f64,
439) -> f64 {
440    fluid_density
441        * PI
442        * contact_radius
443        * contact_radius
444        * entry_speed
445        * entry_speed
446        * (1.0 + added_mass_coeff)
447}
448
449/// Estimate the contact radius for a sphere entering the water.
450///
451/// r_c ≈ sqrt(2 R d) where R is sphere radius and d is penetration depth.
452pub fn sphere_water_contact_radius(sphere_radius: f64, penetration_depth: f64) -> f64 {
453    if penetration_depth <= 0.0 {
454        return 0.0;
455    }
456    (2.0 * sphere_radius * penetration_depth.min(2.0 * sphere_radius)).sqrt()
457}
458
459// ─────────────────────────────────────────────────────────────────────────────
460// 7. Splash Particle Generation
461// ─────────────────────────────────────────────────────────────────────────────
462
463/// A single splash particle spawned at water impact.
464#[derive(Debug, Clone, Copy)]
465pub struct SplashParticle {
466    /// Initial position \[m\].
467    pub position: [f64; 3],
468    /// Initial velocity \[m/s\].
469    pub velocity: [f64; 3],
470    /// Particle mass \[kg\].
471    pub mass: f64,
472    /// Lifetime \[s\].
473    pub lifetime: f64,
474}
475
476/// Generate splash particles at a water entry event.
477///
478/// Particles are distributed on a cone around the impact normal with
479/// speeds scaled by entry speed.
480///
481/// # Arguments
482/// * `event` - Water entry event.
483/// * `n_particles` - Number of splash particles to generate.
484/// * `fluid` - Fluid properties.
485/// * `particle_mass` - Mass of each splash particle \[kg\].
486/// * `seed` - RNG seed.
487pub fn generate_splash_particles(
488    event: &WaterEntryEvent,
489    n_particles: usize,
490    fluid: &FluidProperties,
491    particle_mass: f64,
492    seed: u64,
493) -> Vec<SplashParticle> {
494    let mut rng = FcRng::new(seed);
495    let mut particles = Vec::with_capacity(n_particles);
496    let base_speed = event.entry_speed * 0.6;
497    let cone_half_angle = PI / 4.0; // 45° cone
498
499    for i in 0..n_particles {
500        let azimuth = (i as f64 / n_particles as f64) * 2.0 * PI
501            + rng.next_f64() * (2.0 * PI / n_particles as f64);
502        let polar = rng.next_f64() * cone_half_angle;
503        let speed = base_speed * (0.5 + rng.next_f64() * 0.5);
504        let vx = speed * polar.sin() * azimuth.cos();
505        let vy = speed * polar.cos();
506        let vz = speed * polar.sin() * azimuth.sin();
507        let lifetime = 2.0 * speed / 9.81 + 0.1; // rough estimate
508        // Use surface tension to compute critical diameter
509        let _surface_tension_ref = fluid.surface_tension;
510        particles.push(SplashParticle {
511            position: event.position,
512            velocity: [vx, vy, vz],
513            mass: particle_mass,
514            lifetime,
515        });
516    }
517    particles
518}
519
520/// Integrate a splash particle forward by time step `dt` under gravity.
521///
522/// Simple Euler step: ignores drag and surface tension for splash particles.
523pub fn integrate_splash_particle(particle: &mut SplashParticle, dt: f64, gravity: f64) {
524    particle.velocity[1] -= gravity * dt;
525    particle.position = add3(particle.position, scale3(particle.velocity, dt));
526    particle.lifetime -= dt;
527}
528
529// ─────────────────────────────────────────────────────────────────────────────
530// 8. Free-Surface Collision Detection
531// ─────────────────────────────────────────────────────────────────────────────
532
533/// Axis-aligned bounding box of the fluid domain.
534#[derive(Debug, Clone, Copy)]
535pub struct FluidDomain {
536    /// Minimum corner \[m\].
537    pub min: [f64; 3],
538    /// Maximum corner \[m\].
539    pub max: [f64; 3],
540    /// Free surface Y-coordinate \[m\].
541    pub free_surface_y: f64,
542}
543
544impl FluidDomain {
545    /// Create a new fluid domain.
546    pub fn new(min: [f64; 3], max: [f64; 3], free_surface_y: f64) -> Self {
547        Self {
548            min,
549            max,
550            free_surface_y,
551        }
552    }
553
554    /// Return true if point `p` is within the fluid domain and below the free surface.
555    pub fn contains_fluid_point(&self, p: [f64; 3]) -> bool {
556        p[0] >= self.min[0]
557            && p[0] <= self.max[0]
558            && p[1] >= self.min[1]
559            && p[1] <= self.free_surface_y
560            && p[2] >= self.min[2]
561            && p[2] <= self.max[2]
562    }
563
564    /// Return true if point `p` is within the domain bounding box.
565    pub fn in_bounds(&self, p: [f64; 3]) -> bool {
566        p[0] >= self.min[0]
567            && p[0] <= self.max[0]
568            && p[1] >= self.min[1]
569            && p[1] <= self.max[1]
570            && p[2] >= self.min[2]
571            && p[2] <= self.max[2]
572    }
573}
574
575/// Detect whether a ray from `origin` in direction `dir` hits the flat free surface.
576///
577/// Returns the time of intersection `t` such that `origin + t * dir` lies on the surface,
578/// or `None` if no intersection exists within `[0, max_t]`.
579pub fn ray_free_surface(
580    origin: [f64; 3],
581    dir: [f64; 3],
582    free_surface_y: f64,
583    max_t: f64,
584) -> Option<f64> {
585    // Surface: y = free_surface_y => origin.y + t * dir.y = free_surface_y
586    if dir[1].abs() < 1e-14 {
587        return None; // ray is parallel to surface
588    }
589    let t = (free_surface_y - origin[1]) / dir[1];
590    if t >= 0.0 && t <= max_t {
591        Some(t)
592    } else {
593        None
594    }
595}
596
597/// Compute the fraction of a sphere submerged below the free surface (0..=1).
598pub fn sphere_submersion_fraction(cy: f64, r: f64, free_surface_y: f64) -> f64 {
599    let depth = free_surface_y - cy; // positive = centre below surface
600    if depth >= r {
601        1.0
602    } else if depth <= -r {
603        0.0
604    } else {
605        let h = (r + depth).max(0.0).min(2.0 * r);
606        // Volume of spherical cap / total sphere volume
607        let vol_cap = PI * h * h * (r - h / 3.0);
608        let vol_sphere = 4.0 / 3.0 * PI * r * r * r;
609        (vol_cap / vol_sphere).clamp(0.0, 1.0)
610    }
611}
612
613// ─────────────────────────────────────────────────────────────────────────────
614// 9. Fluid Boundary Collision Handling
615// ─────────────────────────────────────────────────────────────────────────────
616
617/// Collision response for a rigid body hitting the fluid domain boundary.
618///
619/// Reflects velocity component normal to the boundary with a restitution coefficient.
620///
621/// # Arguments
622/// * `body` - Mutable rigid body state.
623/// * `domain` - Fluid domain bounds.
624/// * `restitution` - Coefficient of restitution \[0, 1\].
625pub fn handle_domain_boundary_collision(
626    body: &mut RigidBodyFluidState,
627    domain: &FluidDomain,
628    restitution: f64,
629) -> bool {
630    let mut hit = false;
631    let r = body.radius;
632    // X boundaries
633    if body.position[0] - r < domain.min[0] {
634        body.position[0] = domain.min[0] + r;
635        body.velocity[0] = body.velocity[0].abs() * restitution;
636        hit = true;
637    } else if body.position[0] + r > domain.max[0] {
638        body.position[0] = domain.max[0] - r;
639        body.velocity[0] = -body.velocity[0].abs() * restitution;
640        hit = true;
641    }
642    // Y boundaries (bottom)
643    if body.position[1] - r < domain.min[1] {
644        body.position[1] = domain.min[1] + r;
645        body.velocity[1] = body.velocity[1].abs() * restitution;
646        hit = true;
647    }
648    // Z boundaries
649    if body.position[2] - r < domain.min[2] {
650        body.position[2] = domain.min[2] + r;
651        body.velocity[2] = body.velocity[2].abs() * restitution;
652        hit = true;
653    } else if body.position[2] + r > domain.max[2] {
654        body.position[2] = domain.max[2] - r;
655        body.velocity[2] = -body.velocity[2].abs() * restitution;
656        hit = true;
657    }
658    hit
659}
660
661// ─────────────────────────────────────────────────────────────────────────────
662// 10. SPH-Rigid Body Coupling
663// ─────────────────────────────────────────────────────────────────────────────
664
665/// An SPH fluid particle.
666#[derive(Debug, Clone)]
667pub struct SphParticle {
668    /// Position \[m\].
669    pub position: [f64; 3],
670    /// Velocity \[m/s\].
671    pub velocity: [f64; 3],
672    /// Density \[kg/m³\].
673    pub density: f64,
674    /// Pressure \[Pa\].
675    pub pressure: f64,
676    /// Particle mass \[kg\].
677    pub mass: f64,
678}
679
680/// Smoothing kernel — cubic spline W(r, h).
681///
682/// Returns W(r, h) where h is the smoothing length.
683pub fn sph_cubic_kernel(r: f64, h: f64) -> f64 {
684    let sigma = 1.0 / (PI * h * h * h); // 3D normalisation
685    let q = r / h;
686    if q < 1.0 {
687        sigma * (1.0 - 1.5 * q * q * (1.0 - 0.5 * q))
688    } else if q < 2.0 {
689        let f = 2.0 - q;
690        sigma * 0.25 * f * f * f
691    } else {
692        0.0
693    }
694}
695
696/// Gradient of the cubic spline kernel ∇W (pointing away from x_j toward x_i).
697///
698/// Returns the gradient vector dW/dr * (x_i - x_j) / r.
699pub fn sph_cubic_kernel_gradient(r_vec: [f64; 3], h: f64) -> [f64; 3] {
700    let r = len3(r_vec);
701    if r < 1e-15 {
702        return [0.0; 3];
703    }
704    let sigma = 1.0 / (PI * h * h * h);
705    let q = r / h;
706    let dw_dr = if q < 1.0 {
707        sigma * (-3.0 * q + 2.25 * q * q) / h
708    } else if q < 2.0 {
709        let f = 2.0 - q;
710        sigma * (-0.75 * f * f) / h
711    } else {
712        0.0
713    };
714    scale3(r_vec, dw_dr / r)
715}
716
717/// Compute the SPH force on a rigid body due to fluid pressure from neighbouring particles.
718///
719/// The boundary force is: F_b = Σ_j m_j (p_j / ρ_j²) ∇W(r_ij, h)
720///
721/// # Arguments
722/// * `body_center` - Centre of the rigid body \[m\].
723/// * `body_radius` - Radius of the rigid body \[m\].
724/// * `particles` - SPH particle array.
725/// * `h` - Smoothing length \[m\].
726pub fn sph_rigid_coupling_force(
727    body_center: [f64; 3],
728    body_radius: f64,
729    particles: &[SphParticle],
730    h: f64,
731) -> [f64; 3] {
732    let mut force = [0.0_f64; 3];
733    for p in particles {
734        let r_vec = sub3(body_center, p.position);
735        let r = len3(r_vec);
736        if r > h || r < body_radius * 0.5 {
737            continue; // outside kernel support or inside body
738        }
739        let grad_w = sph_cubic_kernel_gradient(r_vec, h);
740        // Contribution: m_j (p_j / ρ_j²) ∇W
741        if p.density > 1e-10 {
742            let coeff = p.mass * p.pressure / (p.density * p.density);
743            force = add3(force, scale3(grad_w, coeff));
744        }
745    }
746    force
747}
748
749/// Reflect SPH particle off a rigid sphere surface using the boundary repulsion method.
750///
751/// Applies a penalty velocity correction to particles inside the rigid body.
752///
753/// # Arguments
754/// * `particle` - Mutable SPH particle.
755/// * `body` - Rigid body state.
756/// * `stiffness` - Repulsion stiffness coefficient.
757/// * `dt` - Time step \[s\].
758pub fn sph_rigid_boundary_reflect(
759    particle: &mut SphParticle,
760    body: &RigidBodyFluidState,
761    stiffness: f64,
762    dt: f64,
763) -> bool {
764    let r_vec = sub3(particle.position, body.position);
765    let r = len3(r_vec);
766    if r >= body.radius {
767        return false; // particle outside body
768    }
769    let penetration = body.radius - r;
770    let normal = normalize3(r_vec);
771    // Apply penalty force: a_pen = stiffness * penetration * normal
772    let dv = scale3(normal, stiffness * penetration * dt);
773    particle.velocity = add3(particle.velocity, dv);
774    // Push particle to surface
775    particle.position = add3(body.position, scale3(normal, body.radius + 1e-6));
776    true
777}
778
779// ─────────────────────────────────────────────────────────────────────────────
780// 11. LBM-Rigid Body Coupling (Bounce-Back)
781// ─────────────────────────────────────────────────────────────────────────────
782
783/// D3Q19 lattice velocity directions (velocities in lattice units).
784///
785/// Standard D3Q19 model: 19 directions including rest.
786pub const D3Q19_VELOCITIES: [[i32; 3]; 19] = [
787    [0, 0, 0],
788    [1, 0, 0],
789    [-1, 0, 0],
790    [0, 1, 0],
791    [0, -1, 0],
792    [0, 0, 1],
793    [0, 0, -1],
794    [1, 1, 0],
795    [-1, -1, 0],
796    [1, -1, 0],
797    [-1, 1, 0],
798    [1, 0, 1],
799    [-1, 0, -1],
800    [1, 0, -1],
801    [-1, 0, 1],
802    [0, 1, 1],
803    [0, -1, -1],
804    [0, 1, -1],
805    [0, -1, 1],
806];
807
808/// D3Q19 weights for the equilibrium distribution.
809pub const D3Q19_WEIGHTS: [f64; 19] = [
810    1.0 / 3.0,
811    1.0 / 18.0,
812    1.0 / 18.0,
813    1.0 / 18.0,
814    1.0 / 18.0,
815    1.0 / 18.0,
816    1.0 / 18.0,
817    1.0 / 36.0,
818    1.0 / 36.0,
819    1.0 / 36.0,
820    1.0 / 36.0,
821    1.0 / 36.0,
822    1.0 / 36.0,
823    1.0 / 36.0,
824    1.0 / 36.0,
825    1.0 / 36.0,
826    1.0 / 36.0,
827    1.0 / 36.0,
828    1.0 / 36.0,
829];
830
831/// Compute the LBM equilibrium distribution function f^eq.
832///
833/// f^eq_i = w_i ρ \[1 + (e_i·u)/cs² + (e_i·u)²/(2cs⁴) − u²/(2cs²)\]
834///
835/// with cs² = 1/3 in lattice units.
836///
837/// # Arguments
838/// * `rho` - Local density.
839/// * `u` - Local velocity vector.
840/// * `i` - Lattice direction index (0..19).
841pub fn lbm_equilibrium(rho: f64, u: [f64; 3], i: usize) -> f64 {
842    let e = D3Q19_VELOCITIES[i];
843    let ei = [e[0] as f64, e[1] as f64, e[2] as f64];
844    let wi = D3Q19_WEIGHTS[i];
845    let cs2 = 1.0 / 3.0;
846    let eu = dot3(ei, u);
847    let u2 = dot3(u, u);
848    wi * rho * (1.0 + eu / cs2 + eu * eu / (2.0 * cs2 * cs2) - u2 / (2.0 * cs2))
849}
850
851/// Apply the LBM bounce-back boundary condition for a rigid body.
852///
853/// For each distribution function that would stream into the solid node,
854/// the distribution is reversed (half-way bounce-back).
855///
856/// Returns the momentum transferred to the body from the fluid \[lattice units\].
857pub fn lbm_bounce_back_force(
858    f_in: &[f64; 19],
859    f_in_opposite: &[f64; 19],
860    body_velocity: [f64; 3],
861    rho: f64,
862) -> [f64; 3] {
863    let mut momentum = [0.0_f64; 3];
864    for i in 1..19usize {
865        let e = D3Q19_VELOCITIES[i];
866        let ei = [e[0] as f64, e[1] as f64, e[2] as f64];
867        // Moving wall correction: add 2 w_i ρ (e_i · u_wall) / cs²
868        let eu_wall = dot3(ei, body_velocity);
869        let cs2 = 1.0 / 3.0;
870        let f_bb = f_in_opposite[i] + 2.0 * D3Q19_WEIGHTS[i] * rho * eu_wall / cs2;
871        // Force = 2 * (f_in - f_bb) * e_i (Newton 3rd law)
872        let df = f_in[i] - f_bb;
873        momentum = add3(momentum, scale3(ei, 2.0 * df));
874    }
875    momentum
876}
877
878// ─────────────────────────────────────────────────────────────────────────────
879// 12. Two-Way Coupling Impulse
880// ─────────────────────────────────────────────────────────────────────────────
881
882/// Compute the two-way coupling impulse between a rigid body and the fluid.
883///
884/// The impulse J is distributed between the body and the fluid parcel to conserve
885/// linear momentum.
886///
887/// # Arguments
888/// * `body` - Mutable rigid body.
889/// * `fluid_mass` - Effective fluid mass in contact \[kg\].
890/// * `fluid_vel` - Fluid velocity at contact point \[m/s\].
891/// * `contact_normal` - Unit normal from fluid to body.
892/// * `restitution` - Coefficient of restitution.
893/// * `dt` - Time step \[s\] (used for impulse limiting).
894///
895/// Returns the impulse vector applied to the body.
896pub fn two_way_coupling_impulse(
897    body: &mut RigidBodyFluidState,
898    fluid_mass: f64,
899    fluid_vel: [f64; 3],
900    contact_normal: [f64; 3],
901    restitution: f64,
902    _dt: f64,
903) -> [f64; 3] {
904    let v_rel = sub3(body.velocity, fluid_vel);
905    let vn = dot3(v_rel, contact_normal);
906    if vn >= 0.0 {
907        // Separating — no impulse needed
908        return [0.0; 3];
909    }
910    // Impulse magnitude
911    let j_mag = -(1.0 + restitution) * vn / (1.0 / body.mass + 1.0 / fluid_mass.max(1e-10));
912    let impulse = scale3(contact_normal, j_mag);
913    // Apply impulse to body
914    body.velocity = add3(body.velocity, scale3(impulse, 1.0 / body.mass));
915    impulse
916}
917
918// ─────────────────────────────────────────────────────────────────────────────
919// 13. Underwater Contact Friction
920// ─────────────────────────────────────────────────────────────────────────────
921
922/// Friction result for underwater contact.
923#[derive(Debug, Clone, Copy)]
924pub struct UnderwaterFrictionResult {
925    /// Friction force \[N\].
926    pub friction_force: [f64; 3],
927    /// Whether the contact is sliding (kinetic) or sticking (static).
928    pub is_sliding: bool,
929}
930
931/// Compute underwater contact friction force.
932///
933/// Applies a modified Coulomb friction model with a viscous correction:
934/// F_fric = −min(μ_eff |F_n|, μ_visc |v_t|) * v̂_t
935///
936/// where μ_eff = μ_coulomb (dry) reduced by the lubrication factor.
937///
938/// # Arguments
939/// * `normal_force` - Magnitude of normal contact force \[N\].
940/// * `tangential_velocity` - Relative tangential velocity \[m/s\].
941/// * `mu_coulomb` - Dry Coulomb friction coefficient.
942/// * `lubrication_factor` - Reduction factor for fluid lubrication \[0,1\].
943/// * `viscous_coeff` - Viscous contribution coefficient \[N·s/m\].
944pub fn underwater_contact_friction(
945    normal_force: f64,
946    tangential_velocity: [f64; 3],
947    mu_coulomb: f64,
948    lubrication_factor: f64,
949    viscous_coeff: f64,
950) -> UnderwaterFrictionResult {
951    let vt_len = len3(tangential_velocity);
952    let mu_eff = mu_coulomb * (1.0 - lubrication_factor.clamp(0.0, 0.99));
953    let f_coulomb = mu_eff * normal_force.abs();
954    let f_viscous = viscous_coeff * vt_len;
955    let f_limit = f_coulomb.min(f_viscous + f_coulomb);
956
957    if vt_len < 1e-12 {
958        return UnderwaterFrictionResult {
959            friction_force: [0.0; 3],
960            is_sliding: false,
961        };
962    }
963    let vt_dir = normalize3(neg3(tangential_velocity));
964    let f_applied = f_limit.min(f_coulomb + f_viscous);
965    let is_sliding = f_applied >= f_coulomb;
966    UnderwaterFrictionResult {
967        friction_force: scale3(vt_dir, f_applied),
968        is_sliding,
969    }
970}
971
972// ─────────────────────────────────────────────────────────────────────────────
973// 14. Fluid-Accelerated Projectile Dynamics
974// ─────────────────────────────────────────────────────────────────────────────
975
976/// State of a fluid-accelerated projectile (e.g., a supercavitating torpedo).
977#[derive(Debug, Clone)]
978pub struct FluidProjectile {
979    /// Body state.
980    pub body: RigidBodyFluidState,
981    /// Whether the projectile is currently submerged.
982    pub submerged: bool,
983    /// Cavitation number σ = (p_inf - p_v) / (0.5 ρ v²).
984    pub cavitation_number: f64,
985    /// Accumulated impulse from thrust \[N·s\].
986    pub thrust_impulse: f64,
987}
988
989impl FluidProjectile {
990    /// Create a new fluid-accelerated projectile.
991    pub fn new(position: [f64; 3], velocity: [f64; 3], mass: f64, radius: f64) -> Self {
992        Self {
993            body: RigidBodyFluidState::new(position, velocity, mass, radius),
994            submerged: false,
995            cavitation_number: f64::INFINITY,
996            thrust_impulse: 0.0,
997        }
998    }
999
1000    /// Update the cavitation number based on ambient pressure and vapour pressure.
1001    ///
1002    /// σ = (p_inf − p_v) / (0.5 ρ v²)
1003    pub fn update_cavitation_number(&mut self, p_inf: f64, p_vapour: f64, fluid_density: f64) {
1004        let v = len3(self.body.velocity);
1005        let q = 0.5 * fluid_density * v * v;
1006        if q < 1e-12 {
1007            self.cavitation_number = f64::INFINITY;
1008        } else {
1009            self.cavitation_number = (p_inf - p_vapour) / q;
1010        }
1011    }
1012
1013    /// Check whether the projectile is in a supercavitating regime (σ < σ_crit).
1014    pub fn is_supercavitating(&self, sigma_crit: f64) -> bool {
1015        self.cavitation_number < sigma_crit
1016    }
1017}
1018
1019/// Compute the thrust force on a fluid-accelerated projectile (rocket/jet propulsion).
1020///
1021/// F_thrust = ṁ_exhaust · v_exhaust + (p_exhaust − p_ambient) · A_nozzle
1022///
1023/// # Arguments
1024/// * `mass_flow_rate` - Exhaust mass flow rate \[kg/s\].
1025/// * `exhaust_velocity` - Exhaust velocity relative to body \[m/s\].
1026/// * `pressure_diff` - Exhaust pressure minus ambient pressure \[Pa\].
1027/// * `nozzle_area` - Nozzle exit area \[m²\].
1028/// * `thrust_direction` - Unit vector of thrust (body frame).
1029pub fn projectile_thrust(
1030    mass_flow_rate: f64,
1031    exhaust_velocity: f64,
1032    pressure_diff: f64,
1033    nozzle_area: f64,
1034    thrust_direction: [f64; 3],
1035) -> [f64; 3] {
1036    let f_mag = mass_flow_rate * exhaust_velocity + pressure_diff * nozzle_area;
1037    scale3(normalize3(thrust_direction), f_mag.max(0.0))
1038}
1039
1040/// Integrate a fluid projectile forward by one time step.
1041///
1042/// Applies buoyancy, drag, gravity, and thrust forces.
1043///
1044/// # Arguments
1045/// * `proj` - Mutable projectile state.
1046/// * `fluid` - Fluid properties (used when submerged).
1047/// * `thrust` - External thrust force \[N\].
1048/// * `gravity` - Gravitational acceleration \[m/s²\].
1049/// * `free_surface_y` - Y-coordinate of free surface \[m\].
1050/// * `dt` - Time step \[s\].
1051pub fn integrate_fluid_projectile(
1052    proj: &mut FluidProjectile,
1053    fluid: &FluidProperties,
1054    thrust: [f64; 3],
1055    gravity: f64,
1056    free_surface_y: f64,
1057    dt: f64,
1058) {
1059    let submerged_frac =
1060        sphere_submersion_fraction(proj.body.position[1], proj.body.radius, free_surface_y);
1061    proj.submerged = submerged_frac > 0.01;
1062
1063    let gravity_force = [0.0, -proj.body.mass * gravity, 0.0];
1064
1065    let buoyancy = if proj.submerged {
1066        let vol_sub = submerged_frac * (4.0 / 3.0 * PI * proj.body.radius.powi(3));
1067        buoyancy_force(fluid.density, gravity, vol_sub)
1068    } else {
1069        [0.0; 3]
1070    };
1071
1072    let fluid_vel = [0.0_f64; 3]; // assume quiescent fluid
1073    let drag = if proj.submerged {
1074        drag_force_sphere(&proj.body, fluid_vel, fluid)
1075    } else {
1076        // Air drag
1077        let air = FluidProperties::air();
1078        drag_force_sphere(&proj.body, fluid_vel, &air)
1079    };
1080
1081    let total_force = add3(add3(add3(gravity_force, buoyancy), drag), thrust);
1082    let accel = scale3(total_force, 1.0 / proj.body.mass);
1083
1084    proj.body.velocity = add3(proj.body.velocity, scale3(accel, dt));
1085    proj.body.position = add3(proj.body.position, scale3(proj.body.velocity, dt));
1086    proj.thrust_impulse += len3(thrust) * dt;
1087}
1088
1089// ─────────────────────────────────────────────────────────────────────────────
1090// 15. Added Mass Effect
1091// ─────────────────────────────────────────────────────────────────────────────
1092
1093/// Compute the added mass coefficient C_m for a sphere.
1094///
1095/// For a sphere, C_m = 0.5 (theoretical inviscid potential flow result).
1096/// Returns the added mass m_a = C_m · ρ_f · V_body.
1097///
1098/// # Arguments
1099/// * `fluid_density` - Fluid density \[kg/m³\].
1100/// * `body_volume` - Volume of the body \[m³\].
1101/// * `cm` - Added mass coefficient (0.5 for sphere).
1102pub fn added_mass(fluid_density: f64, body_volume: f64, cm: f64) -> f64 {
1103    cm * fluid_density * body_volume
1104}
1105
1106/// Effective mass of a body including added mass.
1107pub fn effective_mass(body_mass: f64, added_mass_val: f64) -> f64 {
1108    body_mass + added_mass_val
1109}
1110
1111/// Compute added-mass force on an accelerating body.
1112///
1113/// F_added = −m_a · a_body
1114pub fn added_mass_force(added_mass_val: f64, body_acceleration: [f64; 3]) -> [f64; 3] {
1115    scale3(body_acceleration, -added_mass_val)
1116}
1117
1118// ─────────────────────────────────────────────────────────────────────────────
1119// 16. Froude Number and Wave Drag
1120// ─────────────────────────────────────────────────────────────────────────────
1121
1122/// Froude number Fr = V / sqrt(g · L).
1123pub fn froude_number(velocity: f64, length: f64, gravity: f64) -> f64 {
1124    velocity / (gravity * length).sqrt().max(1e-15)
1125}
1126
1127/// Simplified wave drag coefficient as function of Froude number.
1128///
1129/// Uses a Kelvin wave drag model approximation.
1130/// Returns C_w(Fr) ≈ exp(−1/(Fr²)) / Fr² for Fr > 0.
1131pub fn wave_drag_coefficient(fr: f64) -> f64 {
1132    if fr < 1e-6 {
1133        return 0.0;
1134    }
1135    (-1.0 / (fr * fr)).exp() / (fr * fr)
1136}
1137
1138/// Compute wave drag force on a surface vessel.
1139///
1140/// F_wave = 0.5 · ρ · V² · L² · C_w(Fr)
1141///
1142/// # Arguments
1143/// * `speed` - Forward speed \[m/s\].
1144/// * `waterline_length` - Waterline length \[m\].
1145/// * `fluid` - Fluid properties.
1146/// * `gravity` - Gravitational acceleration \[m/s²\].
1147pub fn wave_drag_force(
1148    speed: f64,
1149    waterline_length: f64,
1150    fluid: &FluidProperties,
1151    gravity: f64,
1152) -> f64 {
1153    let fr = froude_number(speed, waterline_length, gravity);
1154    let cw = wave_drag_coefficient(fr);
1155    0.5 * fluid.density * speed * speed * waterline_length * waterline_length * cw
1156}
1157
1158// ─────────────────────────────────────────────────────────────────────────────
1159// Internal LCG RNG helper
1160// ─────────────────────────────────────────────────────────────────────────────
1161
1162struct FcRng {
1163    state: u64,
1164}
1165
1166impl FcRng {
1167    fn new(seed: u64) -> Self {
1168        Self { state: seed.max(1) }
1169    }
1170
1171    fn next_u64(&mut self) -> u64 {
1172        self.state = self
1173            .state
1174            .wrapping_mul(6_364_136_223_846_793_005)
1175            .wrapping_add(1_442_695_040_888_963_407);
1176        self.state
1177    }
1178
1179    fn next_f64(&mut self) -> f64 {
1180        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
1181    }
1182}
1183
1184// ─────────────────────────────────────────────────────────────────────────────
1185// Tests
1186// ─────────────────────────────────────────────────────────────────────────────
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    // ── vector helpers ────────────────────────────────────────────────────────
1193
1194    #[test]
1195    fn test_add3_basic() {
1196        let a = [1.0, 2.0, 3.0];
1197        let b = [4.0, 5.0, 6.0];
1198        let c = add3(a, b);
1199        assert!((c[0] - 5.0).abs() < 1e-12);
1200        assert!((c[1] - 7.0).abs() < 1e-12);
1201        assert!((c[2] - 9.0).abs() < 1e-12);
1202    }
1203
1204    #[test]
1205    fn test_normalize3_unit() {
1206        let v = normalize3([3.0, 4.0, 0.0]);
1207        let len = len3(v);
1208        assert!((len - 1.0).abs() < 1e-12);
1209    }
1210
1211    #[test]
1212    fn test_normalize3_zero_vector() {
1213        let v = normalize3([0.0, 0.0, 0.0]);
1214        assert!((v[2] - 1.0).abs() < 1e-12); // fallback to z-axis
1215    }
1216
1217    // ── FluidProperties ───────────────────────────────────────────────────────
1218
1219    #[test]
1220    fn test_fluid_properties_water() {
1221        let w = FluidProperties::water();
1222        assert!((w.density - 998.2).abs() < 0.1);
1223        assert!(w.kinematic_viscosity > 0.0);
1224    }
1225
1226    #[test]
1227    fn test_fluid_properties_air() {
1228        let a = FluidProperties::air();
1229        assert!(a.density < 2.0);
1230        assert!(a.viscosity > 0.0);
1231    }
1232
1233    #[test]
1234    fn test_kinematic_viscosity_derived() {
1235        let f = FluidProperties::new(1000.0, 0.001, 0.07, 1500.0);
1236        assert!((f.kinematic_viscosity - 1e-6).abs() < 1e-10);
1237    }
1238
1239    // ── Reynolds number ───────────────────────────────────────────────────────
1240
1241    #[test]
1242    fn test_reynolds_number_basic() {
1243        // Water at 20°C: ν ≈ 1e-6; U = 1 m/s, L = 0.01 m => Re = 10000
1244        let re = reynolds_number(1.0, 0.01, 1e-6);
1245        assert!((re - 10_000.0).abs() < 1.0);
1246    }
1247
1248    #[test]
1249    fn test_reynolds_number_zero_velocity() {
1250        assert_eq!(reynolds_number(0.0, 1.0, 1e-6), 0.0);
1251    }
1252
1253    // ── Drag coefficient ──────────────────────────────────────────────────────
1254
1255    #[test]
1256    fn test_drag_coefficient_stokes() {
1257        // Re << 1: C_d ≈ 24/Re
1258        let cd = drag_coefficient_sphere(0.1);
1259        assert!((cd - 240.0).abs() < 1.0);
1260    }
1261
1262    #[test]
1263    fn test_drag_coefficient_newton() {
1264        // Re >> 1000: C_d = 0.44
1265        let cd = drag_coefficient_sphere(1e6);
1266        assert!((cd - 0.44).abs() < 1e-10);
1267    }
1268
1269    #[test]
1270    fn test_drag_coefficient_positive() {
1271        for re in [0.01, 1.0, 10.0, 100.0, 1000.0, 1e5] {
1272            assert!(drag_coefficient_sphere(re) > 0.0, "re={re}");
1273        }
1274    }
1275
1276    // ── Drag force ────────────────────────────────────────────────────────────
1277
1278    #[test]
1279    fn test_drag_force_opposing() {
1280        // Body moving in +x, drag should be in -x
1281        let body_vel = [10.0, 0.0, 0.0];
1282        let fluid_vel = [0.0, 0.0, 0.0];
1283        let fluid = FluidProperties::water();
1284        let f = drag_force(body_vel, fluid_vel, &fluid, 0.44, 0.01);
1285        assert!(f[0] < 0.0, "drag should oppose motion, f={f:?}");
1286    }
1287
1288    #[test]
1289    fn test_drag_force_zero_velocity() {
1290        let fluid = FluidProperties::water();
1291        let f = drag_force([0.0; 3], [0.0; 3], &fluid, 0.44, 0.01);
1292        assert_eq!(f, [0.0; 3]);
1293    }
1294
1295    // ── Buoyancy ──────────────────────────────────────────────────────────────
1296
1297    #[test]
1298    fn test_buoyancy_fully_submerged() {
1299        let body = RigidBodyFluidState::new([0.0, -2.0, 0.0], [0.0; 3], 1.0, 0.5);
1300        let fluid = FluidProperties::water();
1301        let result = buoyancy_sphere(&body, &fluid, 0.0, 9.81);
1302        let vol = 4.0 / 3.0 * PI * 0.5_f64.powi(3);
1303        let expected = fluid.density * 9.81 * vol;
1304        assert!(
1305            (result.force[1] - expected).abs() < 1.0,
1306            "force={}",
1307            result.force[1]
1308        );
1309        assert!((result.submerged_volume - vol).abs() < 1e-6);
1310    }
1311
1312    #[test]
1313    fn test_buoyancy_above_surface() {
1314        let body = RigidBodyFluidState::new([0.0, 5.0, 0.0], [0.0; 3], 1.0, 0.5);
1315        let fluid = FluidProperties::water();
1316        let result = buoyancy_sphere(&body, &fluid, 0.0, 9.81);
1317        assert!((result.force[1]).abs() < 1e-10, "force={}", result.force[1]);
1318        assert_eq!(result.submerged_volume, 0.0);
1319    }
1320
1321    #[test]
1322    fn test_buoyancy_partial_submersion() {
1323        // Centre at free surface => half submerged
1324        let r = 1.0;
1325        let body = RigidBodyFluidState::new([0.0, 0.0, 0.0], [0.0; 3], 1.0, r);
1326        let fluid = FluidProperties::water();
1327        let result = buoyancy_sphere(&body, &fluid, 0.0, 9.81);
1328        let half_vol = 2.0 / 3.0 * PI * r * r * r;
1329        assert!(
1330            (result.submerged_volume - half_vol).abs() < 0.05,
1331            "vol={} expected≈{half_vol}",
1332            result.submerged_volume
1333        );
1334    }
1335
1336    // ── Submersion state ──────────────────────────────────────────────────────
1337
1338    #[test]
1339    fn test_submersion_state_airborne() {
1340        let body = RigidBodyFluidState::new([0.0, 2.0, 0.0], [0.0; 3], 1.0, 0.5);
1341        assert_eq!(
1342            submersion_state_sphere(&body, 0.0),
1343            SubmersionState::Airborne
1344        );
1345    }
1346
1347    #[test]
1348    fn test_submersion_state_submerged() {
1349        let body = RigidBodyFluidState::new([0.0, -2.0, 0.0], [0.0; 3], 1.0, 0.5);
1350        assert_eq!(
1351            submersion_state_sphere(&body, 0.0),
1352            SubmersionState::Submerged
1353        );
1354    }
1355
1356    #[test]
1357    fn test_submersion_state_crossing() {
1358        let body = RigidBodyFluidState::new([0.0, 0.0, 0.0], [0.0; 3], 1.0, 0.5);
1359        assert_eq!(
1360            submersion_state_sphere(&body, 0.0),
1361            SubmersionState::Crossing
1362        );
1363    }
1364
1365    // ── Water entry / exit ────────────────────────────────────────────────────
1366
1367    #[test]
1368    fn test_water_crossing_detection_entry() {
1369        let prev = RigidBodyFluidState::new([0.0, 1.0, 0.0], [0.0, -5.0, 0.0], 1.0, 0.3);
1370        let curr = RigidBodyFluidState::new([0.0, 0.0, 0.0], [0.0, -5.0, 0.0], 1.0, 0.3);
1371        let ev = detect_water_crossing(&prev, &curr, 0.0);
1372        assert!(ev.is_some());
1373        assert!(ev.unwrap().is_entry);
1374    }
1375
1376    #[test]
1377    fn test_water_crossing_none_when_stable() {
1378        let b1 = RigidBodyFluidState::new([0.0, -2.0, 0.0], [0.0; 3], 1.0, 0.3);
1379        let b2 = RigidBodyFluidState::new([0.0, -2.1, 0.0], [0.0; 3], 1.0, 0.3);
1380        let ev = detect_water_crossing(&b1, &b2, 0.0);
1381        assert!(ev.is_none());
1382    }
1383
1384    // ── Splash particles ──────────────────────────────────────────────────────
1385
1386    #[test]
1387    fn test_splash_particle_count() {
1388        let event = WaterEntryEvent {
1389            position: [0.0; 3],
1390            velocity: [0.0, -5.0, 0.0],
1391            entry_speed: 5.0,
1392            vertical_speed: 5.0,
1393            is_entry: true,
1394        };
1395        let particles = generate_splash_particles(&event, 20, &FluidProperties::water(), 1e-4, 42);
1396        assert_eq!(particles.len(), 20);
1397    }
1398
1399    #[test]
1400    fn test_splash_particle_lifetime_positive() {
1401        let event = WaterEntryEvent {
1402            position: [0.0; 3],
1403            velocity: [0.0, -3.0, 0.0],
1404            entry_speed: 3.0,
1405            vertical_speed: 3.0,
1406            is_entry: true,
1407        };
1408        let particles = generate_splash_particles(&event, 5, &FluidProperties::water(), 1e-4, 1);
1409        for p in &particles {
1410            assert!(p.lifetime > 0.0, "lifetime={}", p.lifetime);
1411        }
1412    }
1413
1414    // ── Ray vs free surface ───────────────────────────────────────────────────
1415
1416    #[test]
1417    fn test_ray_free_surface_hit() {
1418        let t = ray_free_surface([0.0, 2.0, 0.0], [0.0, -1.0, 0.0], 0.0, 10.0);
1419        assert!(t.is_some());
1420        assert!((t.unwrap() - 2.0).abs() < 1e-12);
1421    }
1422
1423    #[test]
1424    fn test_ray_free_surface_parallel_miss() {
1425        let t = ray_free_surface([0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 0.0, 10.0);
1426        assert!(t.is_none());
1427    }
1428
1429    #[test]
1430    fn test_ray_free_surface_behind_miss() {
1431        let t = ray_free_surface([0.0, -1.0, 0.0], [0.0, -1.0, 0.0], 0.0, 10.0);
1432        assert!(t.is_none());
1433    }
1434
1435    // ── Sphere submersion fraction ────────────────────────────────────────────
1436
1437    #[test]
1438    fn test_submersion_fraction_full() {
1439        let frac = sphere_submersion_fraction(-2.0, 0.5, 0.0);
1440        assert!((frac - 1.0).abs() < 1e-10);
1441    }
1442
1443    #[test]
1444    fn test_submersion_fraction_zero() {
1445        let frac = sphere_submersion_fraction(2.0, 0.5, 0.0);
1446        assert!((frac).abs() < 1e-10);
1447    }
1448
1449    #[test]
1450    fn test_submersion_fraction_half() {
1451        let frac = sphere_submersion_fraction(0.0, 1.0, 0.0);
1452        assert!((frac - 0.5).abs() < 0.05, "frac={frac}");
1453    }
1454
1455    // ── Domain boundary collision ─────────────────────────────────────────────
1456
1457    #[test]
1458    fn test_domain_boundary_collision_x() {
1459        let domain = FluidDomain::new([-5.0, -5.0, -5.0], [5.0, 5.0, 5.0], 0.0);
1460        let mut body = RigidBodyFluidState::new([4.8, 0.0, 0.0], [2.0, 0.0, 0.0], 1.0, 0.3);
1461        let hit = handle_domain_boundary_collision(&mut body, &domain, 0.8);
1462        assert!(hit);
1463        assert!(body.velocity[0] < 0.0, "vx should be reflected");
1464    }
1465
1466    #[test]
1467    fn test_domain_boundary_no_collision() {
1468        let domain = FluidDomain::new([-5.0, -5.0, -5.0], [5.0, 5.0, 5.0], 0.0);
1469        let mut body = RigidBodyFluidState::new([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0, 0.3);
1470        let hit = handle_domain_boundary_collision(&mut body, &domain, 0.8);
1471        assert!(!hit);
1472    }
1473
1474    // ── SPH coupling ──────────────────────────────────────────────────────────
1475
1476    #[test]
1477    fn test_sph_cubic_kernel_positive() {
1478        assert!(sph_cubic_kernel(0.0, 1.0) > 0.0);
1479        assert!(sph_cubic_kernel(0.5, 1.0) > 0.0);
1480    }
1481
1482    #[test]
1483    fn test_sph_cubic_kernel_outside_support() {
1484        assert_eq!(sph_cubic_kernel(3.0, 1.0), 0.0);
1485    }
1486
1487    #[test]
1488    fn test_sph_rigid_coupling_zero_particles() {
1489        let f = sph_rigid_coupling_force([0.0; 3], 0.5, &[], 1.0);
1490        assert_eq!(f, [0.0; 3]);
1491    }
1492
1493    // ── LBM ──────────────────────────────────────────────────────────────────
1494
1495    #[test]
1496    fn test_lbm_equilibrium_sum_to_rho() {
1497        let rho = 1.0;
1498        let u = [0.0_f64; 3];
1499        let sum: f64 = (0..19).map(|i| lbm_equilibrium(rho, u, i)).sum();
1500        assert!((sum - rho).abs() < 1e-10, "sum={sum}");
1501    }
1502
1503    #[test]
1504    fn test_lbm_equilibrium_rest_node() {
1505        // At rest node (i=0), f^eq = w_0 * rho
1506        let rho = 1.5;
1507        let u = [0.0_f64; 3];
1508        let feq = lbm_equilibrium(rho, u, 0);
1509        let expected = D3Q19_WEIGHTS[0] * rho;
1510        assert!((feq - expected).abs() < 1e-10);
1511    }
1512
1513    // ── Two-way coupling ──────────────────────────────────────────────────────
1514
1515    #[test]
1516    fn test_two_way_coupling_separating() {
1517        let mut body = RigidBodyFluidState::new([0.0; 3], [1.0, 0.0, 0.0], 1.0, 0.5);
1518        let impulse =
1519            two_way_coupling_impulse(&mut body, 100.0, [0.0; 3], [1.0, 0.0, 0.0], 0.5, 0.01);
1520        // Separating — zero impulse
1521        assert_eq!(impulse, [0.0; 3]);
1522    }
1523
1524    #[test]
1525    fn test_two_way_coupling_approaching() {
1526        let mut body = RigidBodyFluidState::new([0.0; 3], [-1.0, 0.0, 0.0], 1.0, 0.5);
1527        let impulse =
1528            two_way_coupling_impulse(&mut body, 100.0, [0.0; 3], [1.0, 0.0, 0.0], 0.8, 0.01);
1529        // Impulse should be in +x direction
1530        assert!(impulse[0] > 0.0, "impulse={impulse:?}");
1531    }
1532
1533    // ── Underwater friction ───────────────────────────────────────────────────
1534
1535    #[test]
1536    fn test_underwater_friction_no_tangential_velocity() {
1537        let result = underwater_contact_friction(100.0, [0.0; 3], 0.5, 0.3, 0.1);
1538        assert_eq!(result.friction_force, [0.0; 3]);
1539        assert!(!result.is_sliding);
1540    }
1541
1542    #[test]
1543    fn test_underwater_friction_opposing() {
1544        let result = underwater_contact_friction(100.0, [1.0, 0.0, 0.0], 0.5, 0.0, 0.0);
1545        // Friction should oppose motion (negative x)
1546        assert!(
1547            result.friction_force[0] < 0.0,
1548            "f={:?}",
1549            result.friction_force
1550        );
1551    }
1552
1553    // ── Added mass ────────────────────────────────────────────────────────────
1554
1555    #[test]
1556    fn test_added_mass_sphere() {
1557        let r = 0.1;
1558        let vol = 4.0 / 3.0 * PI * r * r * r;
1559        let rho_f = 1000.0;
1560        let ma = added_mass(rho_f, vol, 0.5);
1561        let expected = 0.5 * rho_f * vol;
1562        assert!((ma - expected).abs() < 1e-12);
1563    }
1564
1565    #[test]
1566    fn test_effective_mass_gt_body_mass() {
1567        let ma = 0.3;
1568        let mb = 1.0;
1569        assert!(effective_mass(mb, ma) > mb);
1570    }
1571
1572    // ── Froude number ─────────────────────────────────────────────────────────
1573
1574    #[test]
1575    fn test_froude_number_known() {
1576        // Fr = V / sqrt(g L)
1577        let fr = froude_number(3.132, 1.0, 9.81);
1578        assert!((fr - 1.0).abs() < 0.01, "fr={fr}");
1579    }
1580
1581    // ── Projectile integration ────────────────────────────────────────────────
1582
1583    #[test]
1584    fn test_projectile_falls_under_gravity() {
1585        let mut proj = FluidProjectile::new([0.0, 10.0, 0.0], [0.0; 3], 1.0, 0.05);
1586        let fluid = FluidProperties::water();
1587        let y0 = proj.body.position[1];
1588        integrate_fluid_projectile(&mut proj, &fluid, [0.0; 3], 9.81, 0.0, 0.1);
1589        assert!(proj.body.position[1] < y0, "should fall under gravity");
1590    }
1591
1592    #[test]
1593    fn test_projectile_thrust_impulse_accumulates() {
1594        let mut proj = FluidProjectile::new([0.0; 3], [0.0; 3], 1.0, 0.05);
1595        let fluid = FluidProperties::water();
1596        let thrust = [10.0, 0.0, 0.0];
1597        integrate_fluid_projectile(&mut proj, &fluid, thrust, 9.81, -10.0, 0.01);
1598        assert!(proj.thrust_impulse > 0.0);
1599    }
1600
1601    // ── Slamming force ────────────────────────────────────────────────────────
1602
1603    #[test]
1604    fn test_slamming_force_positive() {
1605        let f = water_entry_slamming_force(1000.0, 10.0, 0.1, 1.0);
1606        assert!(f > 0.0, "slamming force={f}");
1607    }
1608
1609    #[test]
1610    fn test_slamming_force_scales_with_speed_squared() {
1611        let f1 = water_entry_slamming_force(1000.0, 5.0, 0.1, 1.0);
1612        let f2 = water_entry_slamming_force(1000.0, 10.0, 0.1, 1.0);
1613        assert!((f2 / f1 - 4.0).abs() < 1e-10, "ratio={}", f2 / f1);
1614    }
1615
1616    // ── Contact radius ────────────────────────────────────────────────────────
1617
1618    #[test]
1619    fn test_contact_radius_zero_penetration() {
1620        assert_eq!(sphere_water_contact_radius(1.0, 0.0), 0.0);
1621    }
1622
1623    #[test]
1624    fn test_contact_radius_increases_with_depth() {
1625        let r1 = sphere_water_contact_radius(1.0, 0.1);
1626        let r2 = sphere_water_contact_radius(1.0, 0.5);
1627        assert!(r2 > r1, "r1={r1} r2={r2}");
1628    }
1629
1630    // ── Wave drag ─────────────────────────────────────────────────────────────
1631
1632    #[test]
1633    fn test_wave_drag_force_positive() {
1634        let fluid = FluidProperties::water();
1635        let f = wave_drag_force(5.0, 10.0, &fluid, 9.81);
1636        assert!(f >= 0.0, "wave drag={f}");
1637    }
1638
1639    // ── FluidDomain ───────────────────────────────────────────────────────────
1640
1641    #[test]
1642    fn test_fluid_domain_contains_fluid_point() {
1643        let domain = FluidDomain::new([-5.0, -5.0, -5.0], [5.0, 5.0, 5.0], 0.0);
1644        assert!(domain.contains_fluid_point([1.0, -1.0, 0.0]));
1645        assert!(!domain.contains_fluid_point([1.0, 1.0, 0.0])); // above surface
1646    }
1647}