Skip to content

Commit d9baf73

Browse files
committed
feat(physics): wire spatial index and LP-0 systems
Add unified spatial index, SI units helpers, Coulomb/thermal updates, and smoke tests.
1 parent 9309fc5 commit d9baf73

33 files changed

Lines changed: 1265 additions & 202 deletions

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ concurrency:
1616
env:
1717
CARGO_TERM_COLOR: always
1818
DEBIAN_FRONTEND: noninteractive
19+
RUSTFLAGS: "-Ctarget-cpu=generic"
1920

2021
jobs:
2122
test:
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
}

crates/energy/src/electromagnetism/interactions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use bevy::prelude::*;
33
use super::fields::{ElectricField, MagneticField};
44

55
// Speed of light (in m/s) constant physical value
6-
//TODO: Making this cleaner later on to make units of measure dynamic rather than admiting 1 meter = 1 meter, same for seconds and much more
6+
// TODO: Units are fixed to SI for now; make unit scaling configurable.
77
const C: f32 = 299_792_458.0;
88

99
/// Represents an electromagnetic wave component
Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,57 @@
1+
pub mod charges;
12
pub mod fields;
23
pub mod interactions;
34

45
use bevy::prelude::*;
6+
use forces::PhysicsSet;
7+
use forces::core::gravity::GravitySet;
8+
use utils::SpatialIndexSet;
59

610
// NOTE: Charge is NOT conserved; EM is quasi-static (no charge continuity equation).
711
// Ref: .claude/skills/lp-physics-chem-invariants/references/em.md
12+
13+
// **LP-0 APPROXIMATION**: Quasi-static EM (v << c, no wave propagation).
14+
// Future: Grid-based Poisson solve + wave equation if needed.
815
pub struct ElectromagnetismPlugin;
916

1017
impl Plugin for ElectromagnetismPlugin {
1118
fn build(&self, app: &mut App) {
12-
app.register_type::<fields::ElectricField>()
19+
app
20+
// LP-0: Coulomb forces between point charges (using UnifiedSpatialIndex)
21+
.init_resource::<charges::CoulombConfig>()
22+
.register_type::<charges::Charge>()
23+
.register_type::<charges::SofteningLength>()
24+
// Marker injection in PreUpdate
25+
.add_systems(
26+
PreUpdate,
27+
charges::mark_charged_entities_spatially_indexed
28+
.in_set(SpatialIndexSet::InjectMarkers),
29+
)
30+
// Coulomb forces in Update, in force accumulation, after gravity
31+
.add_systems(
32+
Update,
33+
charges::apply_coulomb_pairwise_forces
34+
.in_set(PhysicsSet::AccumulateForces)
35+
.after(GravitySet::NBodyGravity),
36+
)
37+
// Keep field components for visualization
38+
.register_type::<fields::ElectricField>()
1339
.register_type::<fields::MagneticField>()
1440
.register_type::<interactions::ElectromagneticWave>()
1541
.register_type::<interactions::MaterialProperties>()
16-
.add_message::<fields::ElectromagneticFieldInteractionEvent>()
17-
.add_systems(Update, fields::calculate_field_interactions);
42+
.add_message::<fields::ElectromagneticFieldInteractionEvent>();
43+
44+
// **DISABLED**: Field-field interactions violate Maxwell's equations.
45+
// Fields don't interact with each other (they superpose linearly).
46+
// .add_systems(Update, fields::calculate_field_interactions);
1847
}
1948
}
2049

2150
/// The electromagnetism prelude.
2251
///
2352
/// This includes the most common types for electromagnetic systems.
2453
pub mod prelude {
54+
pub use crate::electromagnetism::charges::{Charge, CoulombConfig, SofteningLength};
2555
pub use crate::electromagnetism::fields::{ElectricField, MagneticField};
2656
pub use crate::electromagnetism::interactions::{ElectromagneticWave, MaterialProperties};
2757
}

crates/energy/src/thermodynamics/equilibrium.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub struct ThermalEquilibrium {
1111

1212
/// Component for phase state of matter that will use the matter crate later once implemented, soon once PBMPM will be in place
1313
/// and the matter crate is implemented
14-
/// This is a placeholder for the actual phase state representation
14+
/// Stub phase state representation until matter integration is ready.
1515
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Reflect)]
1616
#[reflect(Component)]
1717
pub enum PhaseState {

crates/energy/src/thermodynamics/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub mod prelude {
3535
validate_equilibrium_group_consistency,
3636
};
3737
pub use super::thermal::{
38-
Temperature, ThermalConductivity, ThermalDiffusivity, HeatCapacity,
38+
HeatCapacity, Temperature, ThermalConductivity, ThermalDiffusivity,
3939
thermal_utils::heat_conduction,
4040
};
4141
}

0 commit comments

Comments
 (0)