|
| 1 | +//! Coulomb forces between point charges. |
| 2 | +//! |
| 3 | +//! **LP-0 SCAFFOLDING**: Particle-particle Coulomb interactions. |
| 4 | +//! Future: Will be replaced by grid-based Poisson solve (ρ → φ → E). |
| 5 | +//! |
| 6 | +//! Physics: F = k·q₁·q₂/r² (Coulomb's law) |
| 7 | +//! Complexity: O(N) with SpatialGrid |
| 8 | +//! Conservation: Force-only (EM potential energy = 0 for LP-0) |
| 9 | +
|
| 10 | +use bevy::prelude::*; |
| 11 | +use forces::core::newton_laws::AppliedForce; |
| 12 | +use std::collections::HashMap; |
| 13 | +use utils::{SpatiallyIndexed, UnifiedSpatialIndex, force_switch}; |
| 14 | + |
| 15 | +/// Electric charge component. |
| 16 | +/// |
| 17 | +/// Units: Coulombs (C) |
| 18 | +/// |
| 19 | +/// **LP-0 assumption**: Point charges for validation. |
| 20 | +/// Future: Replace with charge density fields on grid. |
| 21 | +#[derive(Component, Debug, Clone, Copy, Reflect)] |
| 22 | +#[reflect(Component)] |
| 23 | +pub struct Charge { |
| 24 | + /// Charge value in Coulombs. |
| 25 | + /// |
| 26 | + /// Positive = proton-like, Negative = electron-like. |
| 27 | + pub value: f32, |
| 28 | +} |
| 29 | + |
| 30 | +impl Charge { |
| 31 | + pub fn new(value: f32) -> Self { |
| 32 | + Self { value } |
| 33 | + } |
| 34 | + |
| 35 | + pub fn positive(value: f32) -> Self { |
| 36 | + Self { value: value.abs() } |
| 37 | + } |
| 38 | + |
| 39 | + pub fn negative(value: f32) -> Self { |
| 40 | + Self { |
| 41 | + value: -value.abs(), |
| 42 | + } |
| 43 | + } |
| 44 | +} |
| 45 | + |
| 46 | +/// Softening length for singularity avoidance. |
| 47 | +/// |
| 48 | +/// **Property-based**: No hardcoded epsilon values. |
| 49 | +/// When r < softening_length, force is smoothly reduced to avoid 1/r² singularity. |
| 50 | +/// |
| 51 | +/// Units: meters (m) |
| 52 | +#[derive(Component, Debug, Clone, Copy, Reflect)] |
| 53 | +#[reflect(Component)] |
| 54 | +pub struct SofteningLength { |
| 55 | + /// Minimum distance for force calculation. |
| 56 | + /// |
| 57 | + /// Typically ~0.01m for particle-scale simulations. |
| 58 | + pub value: f32, |
| 59 | +} |
| 60 | + |
| 61 | +impl Default for SofteningLength { |
| 62 | + fn default() -> Self { |
| 63 | + Self { value: 0.01 } |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/// Configuration for Coulomb force system. |
| 68 | +#[derive(Resource, Debug, Clone)] |
| 69 | +pub struct CoulombConfig { |
| 70 | + /// Coulomb constant k = 1/(4πε₀). |
| 71 | + /// |
| 72 | + /// **UNITS**: Newtons × meters² / Coulombs² (N·m²/C²) |
| 73 | + /// **Default**: 8.99×10⁹ N·m²/C² (vacuum permittivity ε₀ = 8.854×10⁻¹² F/m) |
| 74 | + /// **IRL PHYSICS**: k = 8.987551792×10⁹ N·m²/C² (exact) |
| 75 | + pub coulomb_constant: f32, |
| 76 | + |
| 77 | + /// Cutoff radius for Coulomb interactions. |
| 78 | + /// |
| 79 | + /// **UNITS**: meters (m) |
| 80 | + /// **PERFORMANCE APPROXIMATION**: IRL Coulomb has infinite range in vacuum. |
| 81 | + /// Cutoff is computational approximation, not physics. |
| 82 | + /// **Default**: 20m (tunable based on system density and interaction scales) |
| 83 | + pub cutoff_radius: f32, |
| 84 | + |
| 85 | + /// Start of force-switch transition. |
| 86 | + /// |
| 87 | + /// **UNITS**: meters (m) |
| 88 | + /// **Default**: 0.8 × cutoff_radius (C¹ smooth cutoff for numerical stability) |
| 89 | + pub switch_on_radius: f32, |
| 90 | +} |
| 91 | + |
| 92 | +impl Default for CoulombConfig { |
| 93 | + fn default() -> Self { |
| 94 | + let cutoff = 20.0; |
| 95 | + Self { |
| 96 | + coulomb_constant: 8.99e9, // N⋅m²/C² |
| 97 | + cutoff_radius: cutoff, |
| 98 | + switch_on_radius: 0.8 * cutoff, |
| 99 | + } |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +/// Mark charged entities for spatial indexing. |
| 104 | +/// |
| 105 | +/// **Phase A2**: Inject SpatiallyIndexed marker for UnifiedSpatialIndex. |
| 106 | +/// This allows utils crate to manage spatial indexing without depending on Charge. |
| 107 | +pub fn mark_charged_entities_spatially_indexed( |
| 108 | + mut commands: Commands, |
| 109 | + q: Query<Entity, (With<Charge>, Without<SpatiallyIndexed>)>, |
| 110 | +) { |
| 111 | + for e in q.iter() { |
| 112 | + commands.entity(e).insert(SpatiallyIndexed); |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +/// Apply Coulomb electrostatic forces between charged particles. |
| 117 | +/// |
| 118 | +/// **LP-0 SCAFFOLDING**: Pairwise particle-particle Coulomb interactions (O(N) via spatial hash grid). |
| 119 | +/// **TEMPORARY**: Will be replaced by grid-based Poisson solve (ρ → φ → E) in LP-1. |
| 120 | +/// |
| 121 | +/// **PHYSICS**: Coulomb's Law F = k·q₁·q₂/r² (Newtons) |
| 122 | +/// - F: Force magnitude (N) |
| 123 | +/// - k: Coulomb constant = 8.99×10⁹ N·m²/C² (vacuum permittivity) |
| 124 | +/// - q₁, q₂: Charges (Coulombs) |
| 125 | +/// - r: Distance (meters) |
| 126 | +/// - Direction: Along line between charges (repulsive if same sign, attractive if opposite) |
| 127 | +/// |
| 128 | +/// **APPROXIMATIONS**: |
| 129 | +/// - Cutoff radius: 20m default (performance hack, IRL Coulomb has infinite range in vacuum) |
| 130 | +/// - Softening: 0.01m default (singularity avoidance for r→0) |
| 131 | +/// - Potential energy: Not tracked (force-only, PE = 0 in LP-0) |
| 132 | +/// - Pair-once guarantee: Only processes pairs where entity_b.index() > entity_a.index() to avoid double-counting |
| 133 | +/// |
| 134 | +/// **CONSERVATION**: Momentum conserved (F_ab = -F_ba, Newton's 3rd law). |
| 135 | +/// Energy NOT conserved (PE missing from accounting). |
| 136 | +pub fn apply_coulomb_pairwise_forces( |
| 137 | + mut charges: Query<( |
| 138 | + Entity, |
| 139 | + &Charge, |
| 140 | + &Transform, |
| 141 | + Option<&SofteningLength>, |
| 142 | + &mut AppliedForce, |
| 143 | + )>, |
| 144 | + index: Res<UnifiedSpatialIndex>, |
| 145 | + config: Res<CoulombConfig>, |
| 146 | +) { |
| 147 | + // **LP-0 SCAFFOLDING**: Pairwise particle-particle Coulomb forces. |
| 148 | + // Future: Grid-based Poisson solve (ρ → φ → E). |
| 149 | + |
| 150 | + // Stage charges into map to avoid nested query |
| 151 | + let mut charge_data: HashMap<Entity, (f32, Vec2, f32)> = HashMap::new(); |
| 152 | + for (entity, charge, trans, softening, _) in charges.iter() { |
| 153 | + let pos = trans.translation.truncate(); |
| 154 | + |
| 155 | + // No silent defaults: require SofteningLength |
| 156 | + let Some(soft) = softening else { |
| 157 | + #[cfg(debug_assertions)] |
| 158 | + panic!( |
| 159 | + "Entity {:?} missing SofteningLength for Coulomb forces", |
| 160 | + entity |
| 161 | + ); |
| 162 | + |
| 163 | + #[cfg(not(debug_assertions))] |
| 164 | + { |
| 165 | + static LOGGED: std::sync::atomic::AtomicBool = |
| 166 | + std::sync::atomic::AtomicBool::new(false); |
| 167 | + if !LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { |
| 168 | + warn!("Skipping charged entities missing SofteningLength (logged once)"); |
| 169 | + } |
| 170 | + continue; |
| 171 | + } |
| 172 | + }; |
| 173 | + |
| 174 | + charge_data.insert(entity, (charge.value, pos, soft.value)); |
| 175 | + } |
| 176 | + |
| 177 | + // Iterate pairs via UnifiedSpatialIndex |
| 178 | + for (entity_a, (charge_a, pos_a, soft_a)) in charge_data.iter() { |
| 179 | + // Find neighbors within cutoff using UnifiedSpatialIndex (O(N) average) |
| 180 | + for entity_b in index.query_radius(*pos_a, config.cutoff_radius) { |
| 181 | + // **Pair-once guarantee**: Only process pairs where B > A |
| 182 | + if entity_b.index() <= entity_a.index() { |
| 183 | + continue; |
| 184 | + } |
| 185 | + |
| 186 | + // Get data for entity B from staged map |
| 187 | + let Some((charge_b, pos_b, soft_b)) = charge_data.get(&entity_b) else { |
| 188 | + continue; |
| 189 | + }; |
| 190 | + |
| 191 | + let r_vec = *pos_b - *pos_a; |
| 192 | + let r = r_vec.length(); |
| 193 | + |
| 194 | + // Property-based softening: use max of the two particles' softening lengths |
| 195 | + let softening = soft_a.max(*soft_b); |
| 196 | + if r < softening || r >= config.cutoff_radius { |
| 197 | + continue; |
| 198 | + } |
| 199 | + |
| 200 | + // Coulomb force: F = k·q₁·q₂/r² |
| 201 | + let k_qq = config.coulomb_constant * charge_a * charge_b; |
| 202 | + let force_magnitude = k_qq / r.powi(2); |
| 203 | + let force_bare = (force_magnitude / r) * r_vec; // F_bare = (k·q₁·q₂/r²) · r̂ |
| 204 | + |
| 205 | + // Apply C¹ force-switch for smooth cutoff |
| 206 | + let switch = force_switch(r, config.switch_on_radius, config.cutoff_radius); |
| 207 | + let force_2d = force_bare * switch; |
| 208 | + let force = force_2d.extend(0.0); // Convert to Vec3 for AppliedForce |
| 209 | + |
| 210 | + // Apply forces symmetrically (Newton's 3rd law) |
| 211 | + if let Ok((_, _, _, _, mut force_a)) = charges.get_mut(*entity_a) { |
| 212 | + force_a.force += force; |
| 213 | + } |
| 214 | + if let Ok((_, _, _, _, mut force_b)) = charges.get_mut(entity_b) { |
| 215 | + force_b.force -= force; // F_ba = -F_ab |
| 216 | + } |
| 217 | + |
| 218 | + // **LP-0**: EM potential energy = 0 (force-only). |
| 219 | + // Future: Track U(r) = integral of switched force for energy conservation. |
| 220 | + } |
| 221 | + } |
| 222 | +} |
0 commit comments