diff --git a/.github/workflows/pull_request_template.md b/.github/workflows/pull_request_template.md new file mode 100644 index 0000000..d1973ab --- /dev/null +++ b/.github/workflows/pull_request_template.md @@ -0,0 +1,41 @@ +## Summary + +- + +
+Why this change? + + + +
+ +--- + +## What changed + +- + +--- + +## Impact + +- [ ] Bug fix +- [ ] Behavior change +- [ ] Performance improvement +- [ ] Refactor / cleanup +- [ ] Documentation + +--- + +## Testing + +- [ ] Unit tests +- [ ] Manual testing +- [ ] Not tested (explain why) + +--- + +## Notes for reviewers + +- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37f2ca2..8844d8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +--- name: Release on: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c45445b..b92a273 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -125,7 +125,9 @@ jobs: - run: zip simulation-engine-${CARGO_RELEASE_TAG}-linux-x64.zip target/release/simulation_engine - run: | if ! gh release view $CARGO_RELEASE_TAG > /dev/null 2>&1; then - gh release create $CARGO_RELEASE_TAG simulation-engine-${CARGO_RELEASE_TAG}-linux-x64.zip --generate-notes --verify-tag + gh release create $CARGO_RELEASE_TAG \ + simulation-engine-${CARGO_RELEASE_TAG}-linux-x64.zip \ + --generate-notes --verify-tag fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 396a9b1..a73f92f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer diff --git a/engine/constraints/mod.rs b/engine/constraints/mod.rs new file mode 100644 index 0000000..9dc04df --- /dev/null +++ b/engine/constraints/mod.rs @@ -0,0 +1,2 @@ +/// Constraints module for physics engine constraints and limitations +pub mod physics_constraints; diff --git a/engine/constraints/physics_constraints.rs b/engine/constraints/physics_constraints.rs new file mode 100644 index 0000000..07471f7 --- /dev/null +++ b/engine/constraints/physics_constraints.rs @@ -0,0 +1,88 @@ +/// Physics constraints and boundary conditions +/// Handles various types of constraints that can be applied to physics objects +use nalgebra::Vector2; + +/// Represents different types of constraints that can be applied to physics objects +#[derive(Debug, Clone)] +pub enum Constraint { + /// Fixed position constraint - object cannot move from a specific point + FixedPosition(Vector2), + /// Distance constraint - maintains a fixed distance between two objects + Distance { target_distance: f32 }, + /// Axis constraint - restricts movement to a specific axis + Axis { axis: Vector2, position: f32 }, + /// Boundary constraint - keeps object within rectangular bounds + Boundary { + min: Vector2, + max: Vector2, + }, +} + +/// A constraint that can be applied to a physics object +#[derive(Debug, Clone)] +pub struct PhysicsConstraint { + pub constraint_type: Constraint, + pub stiffness: f32, // How strongly the constraint is enforced (0.0 to 1.0) + pub damping: f32, // Damping factor for constraint forces +} + +impl Default for PhysicsConstraint { + fn default() -> Self { + Self { + constraint_type: Constraint::Boundary { + min: Vector2::new(-100.0, -100.0), + max: Vector2::new(100.0, 100.0), + }, + stiffness: 1.0, + damping: 0.1, + } + } +} + +impl PhysicsConstraint { + /// Creates a new boundary constraint with the given bounds + pub fn boundary(min: Vector2, max: Vector2) -> Self { + Self { + constraint_type: Constraint::Boundary { min, max }, + stiffness: 1.0, + damping: 0.1, + } + } + + /// Applies the constraint to a position and velocity, returning the constrained values + pub fn apply(&self, position: &mut Vector2, velocity: &mut Vector2, delta_time: f32) { + match &self.constraint_type { + Constraint::Boundary { min, max } => { + self.apply_boundary_constraint(position, velocity, *min, *max, delta_time); + } + Constraint::FixedPosition(fixed_pos) => { + *position = *fixed_pos; + *velocity = Vector2::zeros(); + } + _ => { + // Other constraint types not yet implemented + } + } + } + + fn apply_boundary_constraint( + &self, + position: &mut Vector2, + velocity: &mut Vector2, + min: Vector2, + max: Vector2, + _delta_time: f32, + ) { + // Clamp position to bounds + position.x = position.x.max(min.x).min(max.x); + position.y = position.y.max(min.y).min(max.y); + + // Apply damping when hitting boundaries + if position.x <= min.x || position.x >= max.x { + velocity.x *= 1.0 - self.damping; + } + if position.y <= min.y || position.y >= max.y { + velocity.y *= 1.0 - self.damping; + } + } +} diff --git a/engine/lib.rs b/engine/lib.rs index 7f6cd46..b52016c 100644 --- a/engine/lib.rs +++ b/engine/lib.rs @@ -1,4 +1,6 @@ +pub mod constraints; pub mod managers; +pub mod numerics; pub mod physics; #[cfg(feature = "python")] diff --git a/engine/managers/simulation_manager.rs b/engine/managers/simulation_manager.rs index 1c9ee7d..41e502b 100644 --- a/engine/managers/simulation_manager.rs +++ b/engine/managers/simulation_manager.rs @@ -26,7 +26,7 @@ impl Simulation { state: SimulationState::Stopped, time_step, duration, - physics_engine: PhysicsEngine::new(), + physics_engine: PhysicsEngine::new().expect("Failed to create physics engine"), } } @@ -35,7 +35,9 @@ impl Simulation { println!("Simulation {} started", self.id); while self.state == SimulationState::Running { self.update(); - self.physics_engine.simulate(self.time_step); + self.physics_engine + .simulate(self.time_step) + .expect("Simulation step failed"); thread::sleep(Duration::from_millis((self.time_step * 1000.0) as u64)); } } diff --git a/engine/numerics/constants.rs b/engine/numerics/constants.rs new file mode 100644 index 0000000..4ba6945 --- /dev/null +++ b/engine/numerics/constants.rs @@ -0,0 +1,21 @@ +//! Numerical constants for physics and general calculations +//! Centralizes all magic numbers and thresholds used throughout the engine + +/// Minimum distance threshold to avoid division by very small values in collision calculations +pub const MIN_DISTANCE_EPSILON: f32 = 1e-6; + +/// Maximum velocity magnitude to prevent numerical explosions +pub const MAX_VELOCITY: f32 = 1000.0; + +/// Minimum mass threshold (objects below this are considered invalid) +pub const MIN_MASS: f32 = 1e-3; + +/// Maximum mass threshold to prevent numerical issues +pub const MAX_MASS: f32 = 1e6; + +/// Gravity constant (standard Earth gravity) +pub const EARTH_GRAVITY: f32 = 9.81; + +/// Time step epsilon for floating point comparisons +#[allow(dead_code)] +pub const TIME_STEP_EPSILON: f32 = 1e-7; diff --git a/engine/numerics/mod.rs b/engine/numerics/mod.rs new file mode 100644 index 0000000..52fd404 --- /dev/null +++ b/engine/numerics/mod.rs @@ -0,0 +1,7 @@ +/// Numerics module providing constants and utilities for numerical computations +pub mod constants; +pub mod utilities; + +// Re-export commonly used items for convenience +pub use constants::*; +pub use utilities::*; diff --git a/engine/numerics/utilities.rs b/engine/numerics/utilities.rs new file mode 100644 index 0000000..3fc14dd --- /dev/null +++ b/engine/numerics/utilities.rs @@ -0,0 +1,69 @@ +use crate::numerics::constants::*; +/// Numerical utilities and helper functions +/// Provides safe mathematical operations and validation functions +use nalgebra::Vector2; + +/// Clamps a value between min and max +#[inline] +pub fn clamp(value: T, min: T, max: T) -> T { + if value < min { + min + } else if value > max { + max + } else { + value + } +} + +/// Safely divides two floats, returning a default value if denominator is too small +#[inline] +pub fn safe_divide(numerator: f32, denominator: f32, default: f32) -> f32 { + if denominator.abs() < MIN_DISTANCE_EPSILON { + default + } else { + numerator / denominator + } +} + +/// Clamps velocity components to prevent numerical instability +#[inline] +pub fn clamp_velocity(velocity: &mut Vector2) { + velocity.x = clamp(velocity.x, -MAX_VELOCITY, MAX_VELOCITY); + velocity.y = clamp(velocity.y, -MAX_VELOCITY, MAX_VELOCITY); +} + +/// Validates that mass is within acceptable bounds +#[inline] +pub fn validate_mass(mass: f32) -> Result { + if mass < MIN_MASS { + Err(format!( + "Mass {} is below minimum threshold {}", + mass, MIN_MASS + )) + } else if mass > MAX_MASS { + Err(format!( + "Mass {} exceeds maximum threshold {}", + mass, MAX_MASS + )) + } else if !mass.is_finite() { + Err("Mass must be finite".to_string()) + } else { + Ok(mass) + } +} + +/// Validates that a vector contains only finite values +#[inline] +pub fn validate_vector(vec: &Vector2) -> Result<(), String> { + if !vec.x.is_finite() || !vec.y.is_finite() { + Err(format!("Vector contains non-finite values: {:?}", vec)) + } else { + Ok(()) + } +} + +/// Checks if two floats are approximately equal within epsilon +#[inline] +pub fn approx_eq(a: f32, b: f32, epsilon: f32) -> bool { + (a - b).abs() < epsilon +} diff --git a/engine/physics/data_cleaner.rs b/engine/physics/data_cleaner.rs index fe7822d..f682067 100644 --- a/engine/physics/data_cleaner.rs +++ b/engine/physics/data_cleaner.rs @@ -190,9 +190,13 @@ mod tests { #[test] fn test_cleanup_stats() { - let mut engine = PhysicsEngine::new(); - engine.add_object(Vector2::new(0.0, 0.0), Vector2::new(1.0, 0.0), 1.0); - engine.add_object(Vector2::new(100.0, 100.0), Vector2::new(0.0, 0.0), 1.0); // Stationary, far away + let mut engine = PhysicsEngine::new().unwrap(); + engine + .add_object(Vector2::new(0.0, 0.0), Vector2::new(1.0, 0.0), 1.0) + .unwrap(); + engine + .add_object(Vector2::new(100.0, 100.0), Vector2::new(0.0, 0.0), 1.0) + .unwrap(); // Stationary, far away let cleaner = DataCleaner::new().with_cleanup_threshold(10); let stats = cleaner.get_stats(&engine); @@ -206,9 +210,13 @@ mod tests { #[test] fn test_cleanup_inactive_objects() { - let mut engine = PhysicsEngine::new(); - engine.add_object(Vector2::new(0.0, 0.0), Vector2::new(1.0, 0.0), 1.0); // Active - engine.add_object(Vector2::new(100.0, 100.0), Vector2::new(0.0, 0.0), 1.0); // Inactive, far away + let mut engine = PhysicsEngine::new().unwrap(); + engine + .add_object(Vector2::new(0.0, 0.0), Vector2::new(1.0, 0.0), 1.0) + .unwrap(); // Active + engine + .add_object(Vector2::new(100.0, 100.0), Vector2::new(0.0, 0.0), 1.0) + .unwrap(); // Inactive, far away let cleaner = DataCleaner::new(); let removed_count = cleaner.cleanup_inactive_objects(&mut engine, 0.0); @@ -222,11 +230,13 @@ mod tests { #[test] fn test_needs_cleanup() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); // Add many objects to trigger cleanup threshold for i in 0..1500 { - engine.add_object(Vector2::new(i as f32, 0.0), Vector2::new(0.0, 0.0), 1.0); + engine + .add_object(Vector2::new(i as f32, 0.0), Vector2::new(0.0, 0.0), 1.0) + .unwrap(); } let cleaner = DataCleaner::new().with_cleanup_threshold(1000); diff --git a/engine/physics/physics_engine.rs b/engine/physics/physics_engine.rs index 34e436a..25af506 100644 --- a/engine/physics/physics_engine.rs +++ b/engine/physics/physics_engine.rs @@ -1,7 +1,47 @@ use nalgebra::Vector2; -/// Minimum distance threshold to avoid division by very small values in collision calculations +// TODO: These are duplicated from the numerics module. +// The proper solution is to import from crate::numerics::*, +// but there's currently a module resolution issue preventing this. +// Once resolved, these inline implementations should be removed +// and replaced with: use crate::numerics::*; const MIN_DISTANCE_EPSILON: f32 = 1e-6; +const MAX_VELOCITY: f32 = 1000.0; + +fn clamp(value: T, min: T, max: T) -> T { + if value < min { + min + } else if value > max { + max + } else { + value + } +} + +fn safe_divide(numerator: f32, denominator: f32, default: f32) -> f32 { + if denominator.abs() < MIN_DISTANCE_EPSILON { + default + } else { + numerator / denominator + } +} + +fn clamp_velocity(velocity: &mut Vector2) { + velocity.x = clamp(velocity.x, -MAX_VELOCITY, MAX_VELOCITY); + velocity.y = clamp(velocity.y, -MAX_VELOCITY, MAX_VELOCITY); +} + +fn validate_vector(vec: &Vector2) -> Result<(), String> { + if !vec.x.is_finite() || !vec.y.is_finite() { + Err(format!("Vector contains non-finite values: {:?}", vec)) + } else { + Ok(()) + } +} + +fn approx_eq(a: f32, b: f32, epsilon: f32) -> bool { + (a - b).abs() < epsilon +} #[derive(Clone, Debug)] pub struct Object { @@ -12,13 +52,36 @@ pub struct Object { } impl Object { - pub fn new(position: Vector2, velocity: Vector2, mass: f32) -> Self { - Object { + pub fn new(position: Vector2, velocity: Vector2, mass: f32) -> Result { + // Temporary inline validation + if mass < 1e-3 { + return Err(format!("Mass {} is below minimum threshold {}", mass, 1e-3)); + } + if mass > 1e6 { + return Err(format!("Mass {} exceeds maximum threshold {}", mass, 1e6)); + } + if !mass.is_finite() { + return Err("Mass must be finite".to_string()); + } + if !position.x.is_finite() || !position.y.is_finite() { + return Err(format!( + "Position contains non-finite values: {:?}", + position + )); + } + if !velocity.x.is_finite() || !velocity.y.is_finite() { + return Err(format!( + "Velocity contains non-finite values: {:?}", + velocity + )); + } + + Ok(Object { position, velocity, mass, radius: 0.5, - } + }) } } @@ -31,26 +94,31 @@ pub struct PhysicsEngine { impl Default for PhysicsEngine { fn default() -> Self { - Self::new() + Self::new().expect("Failed to create default PhysicsEngine") } } impl PhysicsEngine { - pub fn new() -> Self { - PhysicsEngine { - objects: vec![Object::new( - Vector2::new(0.0, 0.0), - Vector2::new(0.0, 0.0), - 1.0, - )], // default object + pub fn new() -> Result { + let default_object = Object::new(Vector2::new(0.0, 0.0), Vector2::new(0.0, 0.0), 1.0)?; + + Ok(PhysicsEngine { + objects: vec![default_object], // default object gravity: Vector2::new(0.0, -9.81), // downward acceleration bounds: (Vector2::new(-100.0, -100.0), Vector2::new(100.0, 100.0)), - } + }) } #[allow(dead_code)] - pub fn add_object(&mut self, position: Vector2, velocity: Vector2, mass: f32) { - self.objects.push(Object::new(position, velocity, mass)); + pub fn add_object( + &mut self, + position: Vector2, + velocity: Vector2, + mass: f32, + ) -> Result<(), String> { + let object = Object::new(position, velocity, mass)?; + self.objects.push(object); + Ok(()) } #[allow(dead_code)] @@ -63,77 +131,153 @@ impl PhysicsEngine { let len = self.objects.len(); for i in 0..len { for j in (i + 1)..len { - let pos1 = self.objects[i].position; - let pos2 = self.objects[j].position; - let rad1 = self.objects[i].radius; - let rad2 = self.objects[j].radius; - let diff = pos1 - pos2; - let distance = diff.norm(); - if distance < rad1 + rad2 && distance > MIN_DISTANCE_EPSILON { - // Elastic collision in 2D + if let Some((impulse, separation)) = self.detect_and_resolve_collision(i, j) { + // Apply impulse to velocities let m1 = self.objects[i].mass; let m2 = self.objects[j].mass; - let v1 = self.objects[i].velocity; - let v2 = self.objects[j].velocity; - let normal = diff / distance; // unit vector from 2 to 1 - let relative_velocity = v1 - v2; - let velocity_along_normal = relative_velocity.dot(&normal); - if velocity_along_normal > 0.0 { - continue; // objects separating - } - let restitution = 1.0; // elastic - let impulse_scalar = - -(1.0 + restitution) * velocity_along_normal / (1.0 / m1 + 1.0 / m2); - let impulse = impulse_scalar * normal; self.objects[i].velocity += impulse / m1; self.objects[j].velocity -= impulse / m2; - // Separate them to avoid sticking - let overlap = (rad1 + rad2) - distance; - let separation = normal * (overlap / 2.0); + + // Separate positions to avoid sticking self.objects[i].position += separation; self.objects[j].position -= separation; + + // Clamp velocities after collision + clamp_velocity(&mut self.objects[i].velocity); + clamp_velocity(&mut self.objects[j].velocity); } } } } - pub fn simulate(&mut self, time_step: f32) { + /// Detect collision between two objects and compute resolution impulse and separation + fn detect_and_resolve_collision( + &self, + i: usize, + j: usize, + ) -> Option<(Vector2, Vector2)> { + let pos1 = self.objects[i].position; + let pos2 = self.objects[j].position; + let rad1 = self.objects[i].radius; + let rad2 = self.objects[j].radius; + let diff = pos1 - pos2; + let distance = diff.norm(); + + // Check if objects are colliding + if distance >= rad1 + rad2 || distance <= MIN_DISTANCE_EPSILON { + return None; + } + + // Unit normal vector from object j to i + let normal = safe_divide(1.0, distance, 1.0) * diff; + + // Relative velocity + let v1 = self.objects[i].velocity; + let v2 = self.objects[j].velocity; + let relative_velocity = v1 - v2; + let velocity_along_normal = relative_velocity.dot(&normal); + + // Don't resolve if objects are separating + if velocity_along_normal > 0.0 { + return None; + } + + // Elastic collision impulse calculation + let m1 = self.objects[i].mass; + let m2 = self.objects[j].mass; + let restitution = 1.0; // perfectly elastic + let impulse_scalar = -(1.0 + restitution) * velocity_along_normal * (m1 * m2) / (m1 + m2); + let impulse = impulse_scalar * normal; + + // Calculate separation to prevent overlap + let overlap = (rad1 + rad2) - distance; + let separation = normal * (overlap / 2.0); + + Some((impulse, separation)) + } + + pub fn simulate(&mut self, time_step: f32) -> Result<(), String> { + if time_step <= 0.0 || !time_step.is_finite() { + return Err(format!("Invalid time step: {}", time_step)); + } + log::info!("Running physics simulation..."); + + // Phase 1: Integration (apply forces and update positions) + self.integrate(time_step)?; + + // Phase 2: Collision Detection and Resolution + self.handle_wall_collisions(); + self.handle_collisions(); + + // Phase 3: Validation and clamping + self.validate_and_clamp(); + + for obj in &self.objects { + log::info!( + "Object position: {:?}, velocity: {:?}", + obj.position, + obj.velocity + ); + } + + Ok(()) + } + + /// Phase 1: Integration - apply forces and update positions/velocities + fn integrate(&mut self, time_step: f32) -> Result<(), String> { for obj in &mut self.objects { // Apply gravity obj.velocity += self.gravity * time_step; - // Update position + + // Clamp velocity before integration to prevent explosions + clamp_velocity(&mut obj.velocity); + + // Update position using symplectic Euler integration obj.position += obj.velocity * time_step; - // Wall collisions (clamp to bounds) - if obj.position.x < self.bounds.0.x { - obj.position.x = self.bounds.0.x; - } else if obj.position.x > self.bounds.1.x { - obj.position.x = self.bounds.1.x; - } - if obj.position.y < self.bounds.0.y { - obj.position.y = self.bounds.0.y; - } else if obj.position.y > self.bounds.1.y { - obj.position.y = self.bounds.1.y; - } - // If hit wall, reverse velocity component and dampen - if obj.position.x <= self.bounds.0.x || obj.position.x >= self.bounds.1.x { + // Validate position is finite + validate_vector(&obj.position)?; + } + Ok(()) + } + + /// Phase 2: Handle wall boundary collisions + fn handle_wall_collisions(&mut self) { + for obj in &mut self.objects { + let mut bounced = false; + + // Clamp position to bounds + obj.position.x = clamp(obj.position.x, self.bounds.0.x, self.bounds.1.x); + obj.position.y = clamp(obj.position.y, self.bounds.0.y, self.bounds.1.y); + + // Bounce off walls with damping + if approx_eq(obj.position.x, self.bounds.0.x, MIN_DISTANCE_EPSILON) + || approx_eq(obj.position.x, self.bounds.1.x, MIN_DISTANCE_EPSILON) + { obj.velocity.x = -obj.velocity.x * 0.8; + bounced = true; } - if obj.position.y <= self.bounds.0.y || obj.position.y >= self.bounds.1.y { + if approx_eq(obj.position.y, self.bounds.0.y, MIN_DISTANCE_EPSILON) + || approx_eq(obj.position.y, self.bounds.1.y, MIN_DISTANCE_EPSILON) + { obj.velocity.y = -obj.velocity.y * 0.8; + bounced = true; } - } - // Handle object-object collisions - self.handle_collisions(); + if bounced { + clamp_velocity(&mut obj.velocity); + } + } + } - for obj in &self.objects { - log::info!( - "Object position: {:?}, velocity: {:?}", - obj.position, - obj.velocity - ); + /// Phase 3: Validate and clamp all values to maintain numerical stability + fn validate_and_clamp(&mut self) { + for obj in &mut self.objects { + clamp_velocity(&mut obj.velocity); + // Ensure position stays within reasonable bounds (expand if needed) + obj.position.x = clamp(obj.position.x, -1e6, 1e6); + obj.position.y = clamp(obj.position.y, -1e6, 1e6); } } @@ -154,9 +298,9 @@ mod tests { #[test] fn test_gravity() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); let time_step = 0.1; - engine.simulate(time_step); + engine.simulate(time_step).unwrap(); // After one step, velocity.y should be gravity.y * time_step let expected_velocity_y = -9.81 * time_step; assert!((engine.objects[0].velocity.y - expected_velocity_y).abs() < 0.01); @@ -169,11 +313,11 @@ mod tests { #[test] fn test_wall_collision() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); engine.objects[0].position.x = 99.5; engine.objects[0].velocity.x = 10.0; // towards upper bound let time_step = 0.1; - engine.simulate(time_step); + engine.simulate(time_step).unwrap(); // Should bounce off upper bound assert!((engine.objects[0].position.x - 100.0).abs() < 0.01); assert!(engine.objects[0].velocity.x < 0.0); // reversed and dampened @@ -181,9 +325,11 @@ mod tests { #[test] fn test_add_object() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); let initial_count = engine.objects.len(); - engine.add_object(Vector2::new(1.0, 2.0), Vector2::new(3.0, 4.0), 5.0); + engine + .add_object(Vector2::new(1.0, 2.0), Vector2::new(3.0, 4.0), 5.0) + .unwrap(); assert_eq!(engine.objects.len(), initial_count + 1); let obj = &engine.objects[1]; assert_eq!(obj.position, Vector2::new(1.0, 2.0)); @@ -193,9 +339,79 @@ mod tests { #[test] fn test_set_gravity() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); let new_gravity = Vector2::new(0.0, -5.0); engine.set_gravity(new_gravity); assert_eq!(engine.gravity, new_gravity); } + + #[test] + fn test_invalid_mass() { + assert!(Object::new(Vector2::new(0.0, 0.0), Vector2::new(0.0, 0.0), 0.0).is_err()); + assert!(Object::new(Vector2::new(0.0, 0.0), Vector2::new(0.0, 0.0), -1.0).is_err()); + assert!(Object::new( + Vector2::new(0.0, 0.0), + Vector2::new(0.0, 0.0), + f32::INFINITY + ) + .is_err()); + } + + #[test] + fn test_invalid_time_step() { + let mut engine = PhysicsEngine::new().unwrap(); + assert!(engine.simulate(0.0).is_err()); + assert!(engine.simulate(-1.0).is_err()); + assert!(engine.simulate(f32::INFINITY).is_err()); + } + + #[test] + fn test_velocity_clamping() { + let mut engine = PhysicsEngine::new().unwrap(); + engine.objects[0].velocity = Vector2::new(2000.0, -1500.0); + engine.simulate(0.1).unwrap(); + // Velocity should be clamped to MAX_VELOCITY + assert!(engine.objects[0].velocity.x.abs() <= MAX_VELOCITY); + assert!(engine.objects[0].velocity.y.abs() <= MAX_VELOCITY); + } + + #[test] + fn test_stress_large_timestep() { + let mut engine = PhysicsEngine::new().unwrap(); + // Test with a very large time step + engine.simulate(10.0).unwrap(); + // Should not have NaN or infinite values + for obj in &engine.objects { + assert!(obj.position.x.is_finite()); + assert!(obj.position.y.is_finite()); + assert!(obj.velocity.x.is_finite()); + assert!(obj.velocity.y.is_finite()); + } + } + + #[test] + fn test_collision_resolution() { + let mut engine = PhysicsEngine::new().unwrap(); + // Add two objects that will collide + engine + .add_object(Vector2::new(1.0, 0.0), Vector2::new(-1.0, 0.0), 1.0) + .unwrap(); + // Position them to overlap slightly + engine.objects[0].position = Vector2::new(0.0, 0.0); + engine.objects[1].position = Vector2::new(0.9, 0.0); // Close enough to collide + + let initial_vel_0 = engine.objects[0].velocity; + let initial_vel_1 = engine.objects[1].velocity; + + engine.simulate(0.1).unwrap(); + + // Velocities should have changed due to collision + assert_ne!(engine.objects[0].velocity, initial_vel_0); + assert_ne!(engine.objects[1].velocity, initial_vel_1); + + // Objects should be separated + let distance = (engine.objects[0].position - engine.objects[1].position).norm(); + assert!(distance >= engine.objects[0].radius + engine.objects[1].radius - 0.1); + // Allow small tolerance + } } diff --git a/examples/custom_physics.rs b/examples/custom_physics.rs index f61d75e..19a0d80 100644 --- a/examples/custom_physics.rs +++ b/examples/custom_physics.rs @@ -4,18 +4,22 @@ use simulation_engine::physics::physics_engine::PhysicsEngine; fn main() { println!("Starting custom physics example..."); - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().expect("Failed to create physics engine"); // Add multiple objects - engine.add_object(Vector2::new(0.0, 0.0), Vector2::new(1.0, 0.0), 1.0); - engine.add_object(Vector2::new(5.0, 0.0), Vector2::new(-0.5, 0.0), 1.5); + engine + .add_object(Vector2::new(0.0, 0.0), Vector2::new(1.0, 0.0), 1.0) + .expect("Failed to add object"); + engine + .add_object(Vector2::new(5.0, 0.0), Vector2::new(-0.5, 0.0), 1.5) + .expect("Failed to add object"); // Set custom gravity engine.set_gravity(Vector2::new(0.0, -5.0)); // Run simulation for 10 steps for step in 0..10 { - engine.simulate(0.1); + engine.simulate(0.1).expect("Simulation failed"); let objects = engine.get_objects(); println!("Step {}:", step); for (i, obj) in objects.iter().enumerate() { diff --git a/examples/data_cleaner.rs b/examples/data_cleaner.rs index bf449fb..231d27e 100644 --- a/examples/data_cleaner.rs +++ b/examples/data_cleaner.rs @@ -4,7 +4,7 @@ use simulation_engine::physics::{data_cleaner::DataCleaner, physics_engine::Phys fn main() { println!("Starting data cleaner example..."); - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().expect("Failed to create physics engine"); // Add many objects to simulate a busy simulation println!("Adding 2000 objects to simulation..."); @@ -18,7 +18,9 @@ fn main() { // Most objects are stationary (inactive) Vector2::new(0.0, 0.0) }; - engine.add_object(position, velocity, 1.0); + engine + .add_object(position, velocity, 1.0) + .expect("Failed to add object"); } // Create data cleaner @@ -37,7 +39,7 @@ fn main() { // Run simulation for a bit to let objects move println!("\nRunning simulation..."); for _ in 0..50 { - engine.simulate(0.1); + engine.simulate(0.1).expect("Simulation failed"); } // Check cleanup status diff --git a/target/debug/simulation_engine b/target/debug/simulation_engine index ea9c4d0..376d15d 100755 Binary files a/target/debug/simulation_engine and b/target/debug/simulation_engine differ diff --git a/tests/sdk_tests.rs b/tests/sdk_tests.rs index 925f604..8d9c229 100644 --- a/tests/sdk_tests.rs +++ b/tests/sdk_tests.rs @@ -11,24 +11,26 @@ fn test_simulation_manager_creation() { #[test] fn test_physics_engine_creation() { - let engine = PhysicsEngine::new(); + let engine = PhysicsEngine::new().unwrap(); let objects = engine.get_objects(); assert_eq!(objects.len(), 1); // Default object } #[test] fn test_physics_engine_add_object() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); let initial_count = engine.get_objects().len(); - engine.add_object(Vector2::new(1.0, 2.0), Vector2::new(3.0, 4.0), 5.0); + engine + .add_object(Vector2::new(1.0, 2.0), Vector2::new(3.0, 4.0), 5.0) + .unwrap(); assert_eq!(engine.get_objects().len(), initial_count + 1); } #[test] fn test_physics_engine_simulate() { - let mut engine = PhysicsEngine::new(); + let mut engine = PhysicsEngine::new().unwrap(); let initial_pos = engine.get_objects()[0].position; - engine.simulate(0.1); + engine.simulate(0.1).unwrap(); let new_pos = engine.get_objects()[0].position; assert_ne!(initial_pos, new_pos); // Position should change due to gravity }