sph_plugin/
systems.rs

1use crate::components::*;
2use crate::math::*;
3use crate::resources::*;
4use crate::SPHConfig;
5use crate::*;
6
7use bevy::color::palettes::css::RED;
8use rand::Rng;
9use std::{collections::HashMap, sync::Mutex};
10
11pub fn setup(
12    mut commands: Commands,
13    mut meshes: ResMut<Assets<Mesh>>,
14    mut materials: ResMut<Assets<ColorMaterial>>,
15    config: Res<SPHConfig>,
16) {
17    // Spawn the camera
18    commands.spawn((
19        Camera2d,
20        Camera {
21            hdr: true, // Enable HDR rendering for better visual quality
22            ..default()
23        },
24        Transform {
25            scale: Vec3::splat(1.0 / config.render.render_scale), // Apply uniform scaling to zoom in/out
26            ..default()
27        },
28    ));
29
30    let mut rng = rand::thread_rng();
31
32    commands.spawn((
33        Mesh2d(meshes.add(Rectangle::new(
34            config.domain.box_size.x,
35            config.domain.box_size.y,
36        ))),
37        MeshMaterial2d(materials.add(ColorMaterial::from_color(Color::WHITE))),
38        Transform::from_xyz(0.0, 0.0, -1.0),
39    ));
40
41    let airfoil = SolidBoundary::new_airfoil(
42        config.airfoil.chord_length,      // chord length [mm]
43        config.airfoil.position,          // position
44        -config.airfoil.aoa.to_radians(), // angle of attack [rad]
45    );
46
47    let num_points_per_surface = airfoil.points.len() / 2;
48
49    // Draw upper surface
50    for i in 0..num_points_per_surface - 1 {
51        let start = airfoil.points[i * 2]; // Skip odd indices (lower surface points)
52        let end = airfoil.points[(i + 1) * 2];
53        let line_length = start.distance(end);
54        let angle = (end - start).y.atan2((end - start).x);
55
56        commands.spawn((
57            Mesh2d(meshes.add(Rectangle::new(line_length, 0.2))),
58            MeshMaterial2d(materials.add(ColorMaterial::from(Color::BLACK))),
59            Transform::from_translation(((start + end) / 2.0).extend(0.1))
60                .with_rotation(Quat::from_rotation_z(angle)),
61        ));
62    }
63
64    // Draw lower surface
65    for i in 0..num_points_per_surface - 1 {
66        let start = airfoil.points[i * 2 + 1]; // Odd indices for lower surface
67        let end = airfoil.points[(i + 1) * 2 + 1];
68        let line_length = start.distance(end);
69        let angle = (end - start).y.atan2((end - start).x);
70
71        commands.spawn((
72            Mesh2d(meshes.add(Rectangle::new(line_length, 0.2))),
73            MeshMaterial2d(materials.add(ColorMaterial::from(Color::BLACK))),
74            Transform::from_translation(((start + end) / 2.0).extend(0.1))
75                .with_rotation(Quat::from_rotation_z(angle)),
76        ));
77    }
78
79    // Connect trailing edge
80    let last_upper = airfoil.points[(num_points_per_surface - 1) * 2];
81    let last_lower = airfoil.points[(num_points_per_surface - 1) * 2 + 1];
82    let line_length = last_upper.distance(last_lower);
83    let angle = (last_lower - last_upper)
84        .y
85        .atan2((last_lower - last_upper).x);
86
87    commands.spawn((
88        Mesh2d(meshes.add(Rectangle::new(line_length, 0.2))),
89        MeshMaterial2d(materials.add(ColorMaterial::from(Color::BLACK))),
90        Transform::from_translation(((last_upper + last_lower) / 2.0).extend(0.1))
91            .with_rotation(Quat::from_rotation_z(angle)),
92    ));
93
94    commands.spawn(airfoil); // Add airfoil boundary
95    commands.spawn(SolidBoundary::new_wind_tunnel(config.domain.box_size)); // Add wind tunnel walls
96
97    for _ in 0..config.particle.initial_count {
98        let y = rng.gen_range(-config.domain.box_size.y / 2.0..config.domain.box_size.y / 2.0);
99        let position = Vec3::new(-config.domain.box_size.x / 2.0 + 0.0, y, 0.0);
100        let velocity =
101            wind_tunnel_velocity(y, config.domain.freestream_velocity, config.domain.box_size);
102
103        commands.spawn((
104            FluidParticle {
105                velocity,
106                density: config.fluid.rest_density,
107                pressure: 0.0,
108            },
109            Transform::from_translation(position),
110            Mesh2d(meshes.add(Circle::new(config.particle.radius))),
111            MeshMaterial2d(materials.add(ColorMaterial::from_color(RED))),
112        ));
113    }
114}
115
116pub fn update_spatial_grid(
117    mut grid: ResMut<SpatialGrid>,
118    query: Query<(Entity, &Transform)>,
119    config: Res<SPHConfig>,
120) {
121    let mut new_cells: HashMap<(i32, i32), Vec<(Entity, Vec2)>> = HashMap::new();
122
123    // Pre-calculate required cells and initialize them
124    for (_, transform) in query.iter() {
125        let pos = transform.translation.truncate();
126        let cell = get_grid_cell(pos, config.particle.grid_cell_size);
127        new_cells
128            .entry(cell)
129            .or_insert_with(|| Vec::with_capacity(grid.expected_particles_per_cell));
130    }
131
132    // Fill grid
133    for (entity, transform) in query.iter() {
134        let pos = transform.translation.truncate();
135        let cell = get_grid_cell(pos, config.particle.grid_cell_size);
136        if let Some(cell_vec) = new_cells.get_mut(&cell) {
137            cell_vec.push((entity, pos));
138        }
139    }
140
141    grid.cells = new_cells;
142}
143
144pub fn compute_density_pressure(
145    grid: Res<SpatialGrid>,
146    mut query: Query<(Entity, &Transform, &mut FluidParticle)>,
147    config: Res<SPHConfig>,
148) {
149    let particle_data: Vec<_> = query
150        .iter()
151        .map(|(entity, transform, _)| (entity, transform.translation.truncate()))
152        .collect();
153
154    query.par_iter_mut().for_each(|(entity, _, mut particle)| {
155        let pos = particle_data.iter().find(|(e, _)| *e == entity).unwrap().1;
156        let cell = get_grid_cell(pos, config.particle.grid_cell_size);
157
158        let mut density = config.particle.mass * w_poly6(0.0, config.fluid.smoothing_length);
159
160        // Check neighboring cells
161        for &neighbor_cell in &get_neighboring_cells(cell) {
162            if let Some(neighbors) = grid.cells.get(&neighbor_cell) {
163                for &(other_entity, other_pos) in neighbors {
164                    if entity != other_entity {
165                        let r = pos.distance(other_pos);
166                        if r < config.fluid.smoothing_length {
167                            density +=
168                                config.particle.mass * w_poly6(r, config.fluid.smoothing_length);
169                        }
170                    }
171                }
172            }
173        }
174
175        let min_density = config.fluid.rest_density * 0.01;
176        particle.density = density.max(min_density);
177
178        // Calculate pressure using the equation of state for an ideal gas
179        // P = ρRT where R is the gas constant
180        particle.pressure = config.fluid.gas_constant
181            * particle.density
182            * ((particle.density / config.fluid.rest_density).powf(7.0) - 1.0); // Modified equation of state
183
184        if particle.pressure.abs() < 1e-6 {
185            particle.pressure = 0.0; // Clean up numerical noise
186        }
187    });
188}
189
190fn apply_boundary_forces(
191    particle_pos: Vec2,
192    particle_vel: Vec2,
193    particle_density: f32,
194    boundary: &SolidBoundary,
195    config: &SPHConfig,
196) -> Vec2 {
197    let mut force = Vec2::ZERO;
198    let ds = config.particle.radius * 0.25; // Finer sampling
199
200    const BOUNDARY_STIFFNESS: f32 = 500.0; // Increased stiffness
201    const BOUNDARY_DAMPING: f32 = 1.0; // Increased damping
202    let min_distance: f32 = config.particle.radius * 0.5; // Prevent complete penetration
203
204    for i in 0..boundary.points.len() - 1 {
205        let p1 = boundary.points[i];
206        let p2 = boundary.points[i + 1];
207        let segment_length = p1.distance(p2);
208        let steps = (segment_length / ds).ceil() as usize;
209
210        for step in 0..steps {
211            let t = step as f32 / steps as f32;
212            let boundary_point = p1.lerp(p2, t);
213            let normal = boundary.normals[i];
214
215            let r = particle_pos.distance(boundary_point);
216
217            if r < config.fluid.smoothing_length {
218                // Strong repulsion for very close particles
219                let close_repulsion = if r < min_distance {
220                    let penetration = (min_distance - r) / min_distance;
221                    normal * BOUNDARY_STIFFNESS * 10.0 * penetration * penetration
222                } else {
223                    Vec2::ZERO
224                };
225
226                // Normal boundary force
227                let q = r / config.fluid.smoothing_length;
228                let pressure_force =
229                    normal * BOUNDARY_STIFFNESS * (1.0 - q) * (1.0 - q) * (1.0 - q);
230
231                // Enhanced damping near boundary
232                let relative_vel = particle_vel;
233                let damping_force = -relative_vel * BOUNDARY_DAMPING * (1.0 - q);
234
235                // Combine all forces
236                let force_contribution = (pressure_force + damping_force + close_repulsion) * ds;
237                force += force_contribution * (particle_density / config.fluid.rest_density);
238            }
239        }
240    }
241
242    force * config.particle.mass
243}
244
245pub fn apply_sph_forces(
246    mut commands: Commands,
247    grid: Res<SpatialGrid>,
248    boundary_query: Query<&SolidBoundary>,
249    mut query: Query<(Entity, &mut Transform, &mut FluidParticle)>,
250    mut particle_pool: ResMut<ParticlePool>,
251    mesh_query: Query<(&Mesh2d, &MeshMaterial2d<ColorMaterial>)>,
252    config: Res<SPHConfig>,
253) {
254    // Collect all particle data first to avoid borrow checker issues
255    let particle_data: Vec<_> = query
256        .iter()
257        .map(|(entity, transform, particle)| {
258            (
259                entity,
260                transform.translation.truncate(),
261                particle.density,
262                particle.pressure,
263                particle.velocity,
264            )
265        })
266        .collect();
267
268    let forces = Mutex::new(Vec::with_capacity(particle_data.len()));
269    let config = config.clone();
270
271    // Parallel force computation
272    query.par_iter().for_each(|(entity, _, _)| {
273        let (pos, density, pressure, velocity) = particle_data
274            .iter()
275            .find(|(e, ..)| *e == entity)
276            .map(|(_, pos, density, pressure, velocity)| (*pos, *density, *pressure, *velocity))
277            .unwrap();
278
279        let cell = get_grid_cell(pos, config.particle.grid_cell_size);
280        let mut pressure_force = Vec2::ZERO;
281        let mut viscosity_force = Vec2::ZERO;
282        let mut boundary_force = Vec2::ZERO;
283
284        for boundary in boundary_query.iter() {
285            boundary_force += apply_boundary_forces(pos, velocity, density, boundary, &config);
286        }
287
288        // Compute forces with neighbors
289        for &neighbor_cell in &get_neighboring_cells(cell) {
290            if let Some(neighbors) = grid.cells.get(&neighbor_cell) {
291                for &(other_entity, other_pos) in neighbors {
292                    if entity != other_entity {
293                        let r = pos.distance(other_pos);
294                        if r < config.fluid.smoothing_length && r > 0.0 {
295                            if let Some((_, _, other_density, other_pressure, other_velocity)) =
296                                particle_data.iter().find(|(e, ..)| e == &other_entity)
297                            {
298                                let direction = (other_pos - pos) / r;
299
300                                // Pressure force
301                                let pressure_factor =
302                                    (pressure + other_pressure) / (2.0 * density * other_density);
303                                pressure_force -= direction
304                                    * config.particle.mass
305                                    * pressure_factor
306                                    * w_spiky_gradient(r, config.fluid.smoothing_length);
307
308                                // Viscosity force
309                                let velocity_diff = *other_velocity - velocity;
310                                viscosity_force += config.fluid.viscosity
311                                    * config.particle.mass
312                                    * (velocity_diff / other_density)
313                                    * w_viscosity_laplacian(r, config.fluid.smoothing_length);
314                            }
315                        }
316                    }
317                }
318            }
319        }
320
321        let total_force =
322            pressure_force + viscosity_force + boundary_force + config.domain.gravity * density;
323        forces.lock().unwrap().push((entity, total_force));
324    });
325
326    let forces = forces.lock().unwrap();
327    let to_despawn = Mutex::new(Vec::new());
328
329    // Apply forces and update positions in parallel
330    query
331        .par_iter_mut()
332        .for_each(|(entity, mut transform, mut particle)| {
333            if let Some(&(_, force)) = forces.iter().find(|(e, _)| *e == entity) {
334                let dt = config.time.fixed_timestep;
335
336                // Update velocity
337                let density = particle.density;
338                particle.velocity += force * dt / density;
339
340                // Maintain inlet conditions
341                let inlet_zone = -config.domain.box_size.x / 2.0;
342                if transform.translation.x < inlet_zone {
343                    particle.velocity = wind_tunnel_velocity(
344                        transform.translation.y,
345                        config.domain.freestream_velocity,
346                        config.domain.box_size,
347                    );
348                }
349
350                // Update position
351                let mut new_pos = transform.translation.truncate() + particle.velocity * dt;
352
353                let half_height = config.domain.box_size.y / 2.0 - config.particle.radius;
354                new_pos.y = new_pos.y.clamp(-half_height, half_height);
355
356                // Despawn particle if it goes out of bounds
357                if new_pos.x + config.particle.radius > config.domain.box_size.x / 2.0 {
358                    to_despawn.lock().unwrap().push(entity);
359                } else {
360                    transform.translation = new_pos.extend(0.0);
361                }
362            }
363        });
364
365    for entity in to_despawn.lock().unwrap().iter() {
366        if let Ok((mesh, material)) = mesh_query.get(*entity) {
367            particle_pool
368                .available
369                .push((*entity, mesh.0.clone(), material.0.clone()));
370            commands
371                .entity(*entity)
372                .remove::<(Transform, FluidParticle)>();
373        }
374    }
375}
376
377pub fn spawn_particles(
378    mut commands: Commands,
379    mut pool: ResMut<ParticlePool>,
380    mut meshes: ResMut<Assets<Mesh>>,
381    mut materials: ResMut<Assets<ColorMaterial>>,
382    visualization_mode: Res<VisualizationMode>,
383    config: Res<SPHConfig>,
384) {
385    let mut rng = rand::thread_rng();
386
387    for _ in 0..config.particle.spawn_rate {
388        let y = rng.gen_range(-config.domain.box_size.y / 2.0..config.domain.box_size.y / 2.0);
389        let position = Vec3::new(-config.domain.box_size.x / 2.0 + 0.0, y, 0.0);
390        let velocity =
391            wind_tunnel_velocity(y, config.domain.freestream_velocity, config.domain.box_size);
392        let particle = FluidParticle {
393            velocity,
394            density: config.fluid.rest_density,
395            pressure: 0.0,
396        };
397
398        // Calculate initial color based on visualization mode
399        let color = match *visualization_mode {
400            VisualizationMode::Velocity => Color::hsl(240.0, 1.0, 0.5), // Blue
401            VisualizationMode::Pressure => Color::hsl(120.0, 1.0, 0.5), // Green
402            VisualizationMode::Density => Color::hsl(270.0, 1.0, 0.5),  // Purple
403        };
404
405        if let Some((entity, _mesh, _material)) = pool.available.pop() {
406            commands
407                .entity(entity)
408                .insert((particle, Transform::from_translation(position)));
409        } else if pool.available.len() < pool.capacity {
410            let mesh = meshes.add(Circle::new(config.particle.radius));
411            let material = materials.add(ColorMaterial::from_color(color));
412
413            commands.spawn((
414                particle,
415                Transform::from_translation(position),
416                Mesh2d(mesh.clone()),
417                MeshMaterial2d(material.clone()),
418            ));
419        }
420    }
421}
422
423pub fn update_particle_colors(
424    visualization_mode: Res<VisualizationMode>,
425    mut query: Query<(&FluidParticle, &mut MeshMaterial2d<ColorMaterial>)>,
426    mut materials: ResMut<Assets<ColorMaterial>>,
427    config: Res<SPHConfig>,
428) {
429    for (particle, material) in query.iter_mut() {
430        let color = match *visualization_mode {
431            VisualizationMode::Velocity => {
432                let velocity_magnitude = particle.velocity.length();
433                let freestream = config.domain.freestream_velocity.length();
434                // Normalize velocity relative to freestream velocity
435                let normalized = (velocity_magnitude / (3.0 * freestream)).clamp(0.0, 1.0);
436                // HSL: Blue (240°) to Red (0°), full saturation, 50% lightness
437                Color::hsl(240.0 * (1.0 - normalized), 1.0, 0.5)
438            }
439            VisualizationMode::Pressure => {
440                let particle_dynamic_pressure =
441                    0.5 * particle.density * particle.velocity.length_squared();
442                let total_pressure = particle.pressure + particle_dynamic_pressure;
443
444                // Normalize pressure from -1.0 to 1.0 relative to max q
445                let max_q = 0.5
446                    * config.fluid.rest_density
447                    * config.domain.freestream_velocity.length_squared();
448                let normalized = (total_pressure / (max_q * 0.2)).clamp(-1.0, 1.0);
449                // Map -1 to 1 to hue values: blue -> white -> red
450                let hue = if normalized < 0.0 {
451                    // Negative pressure: blue to white
452                    240.0 + (normalized * 240.0)
453                } else {
454                    // Positive pressure: white to red
455                    240.0 - (normalized * 240.0)
456                };
457                Color::hsl(hue.clamp(0.0, 360.0), 1.0, 0.5)
458            }
459            VisualizationMode::Density => {
460                // Normalize density relative to rest density
461                let normalized =
462                    ((particle.density / config.fluid.rest_density) - 1.0).clamp(-0.1, 0.1) * 2.0; // Scale to -1.0 to 1.0
463                let base_hue = 270.0;
464                let hue = base_hue - (normalized * 210.0); // HSL: Purple (270°) to Yellow (60°), full saturation, 50% lightness
465                Color::hsl(hue.clamp(60.0, 270.0), 1.0, 0.5)
466            }
467        };
468        materials.get_mut(&material.0).unwrap().color = color;
469    }
470}
471
472pub fn handle_visualization_toggle(
473    keyboard: Res<ButtonInput<KeyCode>>,
474    mut visualization_mode: ResMut<VisualizationMode>,
475) {
476    if keyboard.just_pressed(KeyCode::Tab) {
477        *visualization_mode = match *visualization_mode {
478            VisualizationMode::Velocity => VisualizationMode::Pressure,
479            VisualizationMode::Pressure => VisualizationMode::Density,
480            VisualizationMode::Density => VisualizationMode::Velocity,
481        };
482    }
483}
484
485pub fn print_fps(
486    time: Res<Time>,
487    query: Query<&FluidParticle>,
488    mut stats: ResMut<SimulationStats>,
489) {
490    if time.elapsed_secs() - stats.last_update >= 1.0 {
491        stats.fps = 1.0 / time.delta_secs();
492        stats.particle_count = query.iter().count();
493        stats.last_update = time.elapsed_secs();
494
495        info!("FPS: {:.0}, Particles: {}", stats.fps, stats.particle_count);
496    }
497}
498
499pub fn pan_camera(
500    time: Res<Time>,
501    keyboard: Res<ButtonInput<KeyCode>>,
502    mut query: Query<(&mut Transform, &mut OrthographicProjection), With<Camera>>,
503) {
504    let (mut transform, mut projection) = query.single_mut();
505    let mut direction = Vec3::ZERO;
506    let speed = 150.0;
507
508    if keyboard.pressed(KeyCode::ArrowRight) {
509        direction.x += 1.0;
510    }
511    if keyboard.pressed(KeyCode::ArrowLeft) {
512        direction.x -= 1.0;
513    }
514    if keyboard.pressed(KeyCode::ArrowUp) {
515        direction.y += 1.0;
516    }
517    if keyboard.pressed(KeyCode::ArrowDown) {
518        direction.y -= 1.0;
519    }
520
521    if direction != Vec3::ZERO {
522        transform.translation +=
523            direction.normalize() * speed * time.delta_secs() * projection.scale;
524    }
525
526    // Zoom controls with limits
527    let min_scale = 0.1;
528    let max_scale = 10.0;
529
530    if keyboard.pressed(KeyCode::Equal) {
531        projection.scale = (projection.scale * 0.95).max(min_scale);
532    }
533    if keyboard.pressed(KeyCode::Minus) {
534        projection.scale = (projection.scale * 1.05).min(max_scale);
535    }
536}