Skip to main content

proof_engine/electromagnetic/
electric.rs

1//! Electric field computation — Coulomb's law, field lines, dipoles,
2//! capacitors, line charges, and Gauss's law verification.
3
4use glam::{Vec3, Vec4};
5use std::f32::consts::PI;
6
7/// Coulomb constant k = 1/(4*pi*eps0). Using normalized units: k = 1.
8const K_COULOMB: f32 = 1.0;
9
10// ── Point Charge ──────────────────────────────────────────────────────────
11
12#[derive(Clone, Debug)]
13pub struct PointCharge {
14    pub position: Vec3,
15    pub charge: f32,
16}
17
18impl PointCharge {
19    pub fn new(position: Vec3, charge: f32) -> Self {
20        Self { position, charge }
21    }
22}
23
24/// Compute the total electric field at `pos` from multiple point charges via Coulomb superposition.
25pub fn electric_field_at(charges: &[PointCharge], pos: Vec3) -> Vec3 {
26    let mut field = Vec3::ZERO;
27    for c in charges {
28        let r_vec = pos - c.position;
29        let r2 = r_vec.length_squared();
30        if r2 < 1e-10 {
31            continue; // Skip self-interaction
32        }
33        let r = r2.sqrt();
34        // E = k * q / r^2 * r_hat
35        field += K_COULOMB * c.charge / r2 * (r_vec / r);
36    }
37    field
38}
39
40/// Compute the electric potential at `pos` from multiple point charges.
41pub fn electric_potential_at(charges: &[PointCharge], pos: Vec3) -> f32 {
42    let mut potential = 0.0f32;
43    for c in charges {
44        let r = (pos - c.position).length();
45        if r < 1e-10 {
46            continue;
47        }
48        // V = k * q / r
49        potential += K_COULOMB * c.charge / r;
50    }
51    potential
52}
53
54// ── Electric Field Line ───────────────────────────────────────────────────
55
56#[derive(Clone, Debug)]
57pub struct ElectricFieldLine {
58    pub points: Vec<Vec3>,
59}
60
61/// Trace a field line from `start` using RK4 integration.
62pub fn trace_field_line(
63    charges: &[PointCharge],
64    start: Vec3,
65    steps: usize,
66    step_size: f32,
67) -> ElectricFieldLine {
68    let mut points = Vec::with_capacity(steps + 1);
69    let mut pos = start;
70    points.push(pos);
71
72    for _ in 0..steps {
73        // RK4 integration along the field direction
74        let e1 = electric_field_at(charges, pos);
75        if e1.length_squared() < 1e-12 {
76            break;
77        }
78        let d1 = e1.normalize() * step_size;
79
80        let e2 = electric_field_at(charges, pos + d1 * 0.5);
81        if e2.length_squared() < 1e-12 {
82            break;
83        }
84        let d2 = e2.normalize() * step_size;
85
86        let e3 = electric_field_at(charges, pos + d2 * 0.5);
87        if e3.length_squared() < 1e-12 {
88            break;
89        }
90        let d3 = e3.normalize() * step_size;
91
92        let e4 = electric_field_at(charges, pos + d3);
93        if e4.length_squared() < 1e-12 {
94            break;
95        }
96        let d4 = e4.normalize() * step_size;
97
98        pos += (d1 + 2.0 * d2 + 2.0 * d3 + d4) / 6.0;
99        points.push(pos);
100
101        // Stop if too close to any charge
102        let mut too_close = false;
103        for c in charges {
104            if (pos - c.position).length() < step_size * 0.5 {
105                too_close = true;
106                break;
107            }
108        }
109        if too_close {
110            break;
111        }
112    }
113
114    ElectricFieldLine { points }
115}
116
117// ── Dipole ────────────────────────────────────────────────────────────────
118
119/// Electric dipole: two equal and opposite charges separated by a small distance.
120#[derive(Clone, Debug)]
121pub struct Dipole {
122    pub pos: Vec3,
123    pub moment: Vec3, // p = q*d, direction from - to +
124}
125
126impl Dipole {
127    pub fn new(pos: Vec3, moment: Vec3) -> Self {
128        Self { pos, moment }
129    }
130
131    /// Electric field of a dipole at a given position (far-field approximation).
132    /// E = (1/4*pi*eps0) * [3(p·r_hat)r_hat - p] / r^3
133    pub fn field_at(&self, point: Vec3) -> Vec3 {
134        let r_vec = point - self.pos;
135        let r = r_vec.length();
136        if r < 1e-10 {
137            return Vec3::ZERO;
138        }
139        let r_hat = r_vec / r;
140        let r3 = r * r * r;
141        let p_dot_r = self.moment.dot(r_hat);
142        K_COULOMB * (3.0 * p_dot_r * r_hat - self.moment) / r3
143    }
144
145    /// Electric potential of a dipole at a given position.
146    /// V = (1/4*pi*eps0) * p·r_hat / r^2
147    pub fn potential_at(&self, point: Vec3) -> f32 {
148        let r_vec = point - self.pos;
149        let r = r_vec.length();
150        if r < 1e-10 {
151            return 0.0;
152        }
153        let r_hat = r_vec / r;
154        K_COULOMB * self.moment.dot(r_hat) / (r * r)
155    }
156
157    /// Convert dipole to a pair of point charges for exact computation.
158    pub fn to_charges(&self, separation: f32) -> [PointCharge; 2] {
159        let d = self.moment.normalize() * separation * 0.5;
160        let q = self.moment.length() / separation;
161        [
162            PointCharge::new(self.pos + d, q),
163            PointCharge::new(self.pos - d, -q),
164        ]
165    }
166}
167
168// ── Capacitor ─────────────────────────────────────────────────────────────
169
170/// Parallel plate capacitor with uniform field between plates.
171#[derive(Clone, Debug)]
172pub struct Capacitor {
173    pub plate1: Vec3, // center of plate 1
174    pub plate2: Vec3, // center of plate 2
175    pub charge_density: f32, // surface charge density (sigma)
176}
177
178impl Capacitor {
179    pub fn new(plate1: Vec3, plate2: Vec3, charge_density: f32) -> Self {
180        Self { plate1, plate2, charge_density }
181    }
182
183    /// Uniform electric field between the plates: E = sigma / eps0.
184    /// Direction: from plate1 (+) to plate2 (-).
185    pub fn field(&self) -> Vec3 {
186        let direction = (self.plate2 - self.plate1).normalize();
187        // In normalized units eps0 = 1/(4*pi*k) = 1/(4*pi) with k=1
188        // But for simplicity with k=1: E = sigma / eps0 = 4*pi*k*sigma
189        // Using SI-like: E = sigma (with eps0=1 normalization)
190        direction * self.charge_density
191    }
192
193    /// Check if a point is between the plates (projected along plate normal).
194    pub fn is_between_plates(&self, point: Vec3) -> bool {
195        let axis = self.plate2 - self.plate1;
196        let len = axis.length();
197        if len < 1e-10 {
198            return false;
199        }
200        let n = axis / len;
201        let t = (point - self.plate1).dot(n);
202        t >= 0.0 && t <= len
203    }
204
205    /// Electric field at a point. Returns uniform field between plates, zero outside.
206    pub fn field_at(&self, point: Vec3) -> Vec3 {
207        if self.is_between_plates(point) {
208            self.field()
209        } else {
210            Vec3::ZERO
211        }
212    }
213
214    /// Potential difference between the plates.
215    pub fn voltage(&self) -> f32 {
216        let d = (self.plate2 - self.plate1).length();
217        self.charge_density * d
218    }
219
220    /// Capacitance per unit area: C/A = eps0 / d.
221    pub fn capacitance_per_area(&self) -> f32 {
222        let d = (self.plate2 - self.plate1).length();
223        if d < 1e-10 {
224            return 0.0;
225        }
226        1.0 / d // eps0 = 1 in normalized units
227    }
228}
229
230// ── Line Charge ───────────────────────────────────────────────────────────
231
232/// Finite line charge with uniform charge per unit length.
233#[derive(Clone, Debug)]
234pub struct LineCharge {
235    pub start: Vec3,
236    pub end: Vec3,
237    pub charge_per_length: f32,
238}
239
240impl LineCharge {
241    pub fn new(start: Vec3, end: Vec3, charge_per_length: f32) -> Self {
242        Self { start, end, charge_per_length }
243    }
244
245    /// Compute electric field at a point by numerical integration along the line.
246    /// Divides the line into `segments` pieces and sums contributions.
247    pub fn field_at(&self, point: Vec3, segments: usize) -> Vec3 {
248        let segments = segments.max(1);
249        let dl = (self.end - self.start) / segments as f32;
250        let dq = self.charge_per_length * dl.length();
251        let mut field = Vec3::ZERO;
252
253        for i in 0..segments {
254            let t = (i as f32 + 0.5) / segments as f32;
255            let src = self.start + (self.end - self.start) * t;
256            let r_vec = point - src;
257            let r2 = r_vec.length_squared();
258            if r2 < 1e-10 {
259                continue;
260            }
261            let r = r2.sqrt();
262            field += K_COULOMB * dq / r2 * (r_vec / r);
263        }
264        field
265    }
266
267    /// Compute electric potential at a point by numerical integration.
268    pub fn potential_at(&self, point: Vec3, segments: usize) -> f32 {
269        let segments = segments.max(1);
270        let dl = (self.end - self.start) / segments as f32;
271        let dq = self.charge_per_length * dl.length();
272        let mut potential = 0.0f32;
273
274        for i in 0..segments {
275            let t = (i as f32 + 0.5) / segments as f32;
276            let src = self.start + (self.end - self.start) * t;
277            let r = (point - src).length();
278            if r < 1e-10 {
279                continue;
280            }
281            potential += K_COULOMB * dq / r;
282        }
283        potential
284    }
285
286    /// Total charge on the line.
287    pub fn total_charge(&self) -> f32 {
288        self.charge_per_length * (self.end - self.start).length()
289    }
290}
291
292// ── Gauss's Law ───────────────────────────────────────────────────────────
293
294/// Verify Gauss's law: the electric flux through a closed surface equals Q_enc / eps0.
295/// surface_points and normals define the surface; normals point outward.
296/// Returns the total flux integral E · dA.
297pub fn gauss_flux(
298    charges: &[PointCharge],
299    surface_points: &[Vec3],
300    normals: &[Vec3],
301    area_per_element: f32,
302) -> f32 {
303    assert_eq!(surface_points.len(), normals.len());
304    let mut flux = 0.0f32;
305    for (i, point) in surface_points.iter().enumerate() {
306        let e = electric_field_at(charges, *point);
307        flux += e.dot(normals[i]) * area_per_element;
308    }
309    flux
310}
311
312/// Generate surface points and normals for a sphere centered at `center` with given `radius`.
313pub fn sphere_surface(center: Vec3, radius: f32, theta_steps: usize, phi_steps: usize) -> (Vec<Vec3>, Vec<Vec3>) {
314    let mut points = Vec::new();
315    let mut normals = Vec::new();
316    for i in 0..theta_steps {
317        let theta = PI * (i as f32 + 0.5) / theta_steps as f32;
318        for j in 0..phi_steps {
319            let phi = 2.0 * PI * j as f32 / phi_steps as f32;
320            let normal = Vec3::new(
321                theta.sin() * phi.cos(),
322                theta.sin() * phi.sin(),
323                theta.cos(),
324            );
325            points.push(center + normal * radius);
326            normals.push(normal);
327        }
328    }
329    (points, normals)
330}
331
332// ── Electric Field Renderer ───────────────────────────────────────────────
333
334/// Renderer for electric field visualization.
335pub struct ElectricFieldRenderer {
336    pub field_line_color_positive: Vec4,
337    pub field_line_color_negative: Vec4,
338    pub equipotential_color: Vec4,
339    pub arrow_spacing: f32,
340}
341
342impl ElectricFieldRenderer {
343    pub fn new() -> Self {
344        Self {
345            field_line_color_positive: Vec4::new(1.0, 0.2, 0.1, 1.0),
346            field_line_color_negative: Vec4::new(0.1, 0.3, 1.0, 1.0),
347            equipotential_color: Vec4::new(0.5, 0.5, 0.5, 0.4),
348            arrow_spacing: 2.0,
349        }
350    }
351
352    /// Generate field line start points around each charge.
353    pub fn generate_field_lines(
354        &self,
355        charges: &[PointCharge],
356        lines_per_charge: usize,
357        steps: usize,
358        step_size: f32,
359    ) -> Vec<ElectricFieldLine> {
360        let mut lines = Vec::new();
361        for charge in charges {
362            if charge.charge.abs() < 1e-10 {
363                continue;
364            }
365            for i in 0..lines_per_charge {
366                let angle = 2.0 * PI * i as f32 / lines_per_charge as f32;
367                let offset = Vec3::new(angle.cos(), angle.sin(), 0.0) * step_size * 2.0;
368                let start = charge.position + offset;
369                // Trace in direction of field for positive charges, against for negative
370                if charge.charge > 0.0 {
371                    lines.push(trace_field_line(charges, start, steps, step_size));
372                } else {
373                    lines.push(trace_field_line(charges, start, steps, -step_size));
374                }
375            }
376        }
377        lines
378    }
379
380    /// Color a field line point based on the local field magnitude.
381    pub fn color_for_field(&self, field_magnitude: f32, is_positive_source: bool) -> Vec4 {
382        let brightness = (field_magnitude * 0.1).min(1.0);
383        if is_positive_source {
384            self.field_line_color_positive * brightness
385        } else {
386            self.field_line_color_negative * brightness
387        }
388    }
389
390    /// Generate equipotential contour points in the xy-plane.
391    pub fn equipotential_contour(
392        &self,
393        charges: &[PointCharge],
394        potential_value: f32,
395        x_range: (f32, f32),
396        y_range: (f32, f32),
397        resolution: usize,
398    ) -> Vec<Vec3> {
399        let mut contour_points = Vec::new();
400        let dx = (x_range.1 - x_range.0) / resolution as f32;
401        let dy = (y_range.1 - y_range.0) / resolution as f32;
402        let tolerance = (dx + dy) * 0.5;
403
404        for i in 0..resolution {
405            for j in 0..resolution {
406                let x = x_range.0 + (i as f32 + 0.5) * dx;
407                let y = y_range.0 + (j as f32 + 0.5) * dy;
408                let pos = Vec3::new(x, y, 0.0);
409                let v = electric_potential_at(charges, pos);
410                if (v - potential_value).abs() < tolerance {
411                    contour_points.push(pos);
412                }
413            }
414        }
415        contour_points
416    }
417
418    /// Get the rendering glyph for a field line point.
419    pub fn trail_glyph(direction: Vec3) -> char {
420        let angle = direction.y.atan2(direction.x);
421        let octant = ((angle / (PI / 4.0)).round() as i32).rem_euclid(8);
422        match octant {
423            0 => '→',
424            1 => '↗',
425            2 => '↑',
426            3 => '↖',
427            4 => '←',
428            5 => '↙',
429            6 => '↓',
430            7 => '↘',
431            _ => '·',
432        }
433    }
434}
435
436impl Default for ElectricFieldRenderer {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442// ── Tests ─────────────────────────────────────────────────────────────────
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_inverse_square_law() {
450        let charges = [PointCharge::new(Vec3::ZERO, 1.0)];
451        let e1 = electric_field_at(&charges, Vec3::new(1.0, 0.0, 0.0));
452        let e2 = electric_field_at(&charges, Vec3::new(2.0, 0.0, 0.0));
453        // E ∝ 1/r^2, so E(2r) = E(r)/4
454        let ratio = e1.length() / e2.length();
455        assert!((ratio - 4.0).abs() < 0.01, "ratio={}", ratio);
456    }
457
458    #[test]
459    fn test_superposition() {
460        let q1 = PointCharge::new(Vec3::new(-1.0, 0.0, 0.0), 1.0);
461        let q2 = PointCharge::new(Vec3::new(1.0, 0.0, 0.0), 1.0);
462
463        // At the midpoint between two equal charges, E_x should cancel
464        let e = electric_field_at(&[q1, q2], Vec3::new(0.0, 0.0, 0.0));
465        assert!(e.x.abs() < 1e-6, "x-component should cancel: {}", e.x);
466    }
467
468    #[test]
469    fn test_potential_symmetry() {
470        let charges = [PointCharge::new(Vec3::ZERO, 1.0)];
471        let v1 = electric_potential_at(&charges, Vec3::new(1.0, 0.0, 0.0));
472        let v2 = electric_potential_at(&charges, Vec3::new(0.0, 1.0, 0.0));
473        assert!((v1 - v2).abs() < 1e-6, "Potential should be spherically symmetric");
474    }
475
476    #[test]
477    fn test_dipole_far_field() {
478        let dipole = Dipole::new(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0));
479        // Along the axis (z-axis), field should be ~ 2p / r^3
480        let e_axis = dipole.field_at(Vec3::new(0.0, 0.0, 10.0));
481        let expected = 2.0 * 1.0 / (10.0_f32.powi(3));
482        assert!((e_axis.z - expected).abs() < 0.001, "Axial dipole field: {}", e_axis.z);
483
484        // Perpendicular to axis, field should be ~ -p / r^3
485        let e_perp = dipole.field_at(Vec3::new(10.0, 0.0, 0.0));
486        let expected_perp = -1.0 / (10.0_f32.powi(3));
487        assert!((e_perp.z - expected_perp).abs() < 0.001, "Perpendicular dipole field: {}", e_perp.z);
488    }
489
490    #[test]
491    fn test_gauss_law() {
492        let charges = [PointCharge::new(Vec3::ZERO, 3.0)];
493        let (points, normals) = sphere_surface(Vec3::ZERO, 5.0, 40, 80);
494        let area_element = 4.0 * PI * 25.0 / (40 * 80) as f32;
495        let flux = gauss_flux(&charges, &points, &normals, area_element);
496        // Gauss's law: flux = Q / eps0 = 4*pi*k*Q = 4*pi*Q (with k=1)
497        let expected = 4.0 * PI * 3.0;
498        let relative_error = (flux - expected).abs() / expected;
499        assert!(relative_error < 0.05, "Gauss's law: flux={}, expected={}", flux, expected);
500    }
501
502    #[test]
503    fn test_capacitor_uniform_field() {
504        let cap = Capacitor::new(
505            Vec3::new(0.0, 0.0, 0.0),
506            Vec3::new(0.0, 0.0, 1.0),
507            2.0,
508        );
509        let e = cap.field();
510        assert!((e.z - 2.0).abs() < 1e-6);
511        assert!(e.x.abs() < 1e-6);
512
513        assert!(cap.is_between_plates(Vec3::new(0.0, 0.0, 0.5)));
514        assert!(!cap.is_between_plates(Vec3::new(0.0, 0.0, 1.5)));
515    }
516
517    #[test]
518    fn test_line_charge_symmetry() {
519        // A line charge along z-axis: field should be radially symmetric in xy-plane
520        let lc = LineCharge::new(
521            Vec3::new(0.0, 0.0, -5.0),
522            Vec3::new(0.0, 0.0, 5.0),
523            1.0,
524        );
525        let e1 = lc.field_at(Vec3::new(1.0, 0.0, 0.0), 100);
526        let e2 = lc.field_at(Vec3::new(0.0, 1.0, 0.0), 100);
527        // Magnitudes should be approximately equal
528        let ratio = e1.length() / e2.length();
529        assert!((ratio - 1.0).abs() < 0.05, "Line charge field should be symmetric: ratio={}", ratio);
530    }
531
532    #[test]
533    fn test_field_line_tracing() {
534        let charges = [PointCharge::new(Vec3::ZERO, 1.0)];
535        let line = trace_field_line(&charges, Vec3::new(0.5, 0.0, 0.0), 50, 0.1);
536        assert!(line.points.len() > 1);
537        // Field lines from positive charge should move outward
538        let last = line.points.last().unwrap();
539        assert!(last.length() > 0.5, "Field line should move away from positive charge");
540    }
541
542    #[test]
543    fn test_renderer_trail_glyph() {
544        assert_eq!(ElectricFieldRenderer::trail_glyph(Vec3::new(1.0, 0.0, 0.0)), '→');
545        assert_eq!(ElectricFieldRenderer::trail_glyph(Vec3::new(0.0, 1.0, 0.0)), '↑');
546    }
547
548    #[test]
549    fn test_dipole_to_charges() {
550        let dipole = Dipole::new(Vec3::ZERO, Vec3::new(0.0, 0.0, 2.0));
551        let charges = dipole.to_charges(0.1);
552        assert!((charges[0].charge - 20.0).abs() < 1e-4);
553        assert!((charges[1].charge + 20.0).abs() < 1e-4);
554    }
555}