Skip to main content

proof_engine/electromagnetic/
magnetic.rs

1//! Magnetic field computation — Biot-Savart law, current loops, solenoids,
2//! magnetic dipoles, field line tracing, and Ampere's law verification.
3
4use glam::{Vec3, Vec4};
5use std::f32::consts::PI;
6
7/// Permeability of free space / (4*pi) in normalized units.
8const MU0_OVER_4PI: f32 = 1.0;
9
10// ── Current Segment ───────────────────────────────────────────────────────
11
12/// A finite straight current-carrying segment.
13#[derive(Clone, Debug)]
14pub struct CurrentSegment {
15    pub start: Vec3,
16    pub end: Vec3,
17    pub current: f32,
18}
19
20impl CurrentSegment {
21    pub fn new(start: Vec3, end: Vec3, current: f32) -> Self {
22        Self { start, end, current }
23    }
24}
25
26/// Biot-Savart law for a finite current segment.
27/// dB = (mu0 / 4*pi) * I * dl × r_hat / r^2
28/// Integrated analytically for a straight segment.
29pub fn biot_savart(segment: &CurrentSegment, point: Vec3) -> Vec3 {
30    let dl = segment.end - segment.start;
31    let length = dl.length();
32    if length < 1e-10 {
33        return Vec3::ZERO;
34    }
35
36    // Numerical integration along the segment (Simpson-like with many points)
37    let n = 20;
38    let mut b = Vec3::ZERO;
39    let dl_step = dl / n as f32;
40    let dl_mag = dl_step.length();
41
42    for i in 0..n {
43        let t = (i as f32 + 0.5) / n as f32;
44        let src = segment.start + dl * t;
45        let r_vec = point - src;
46        let r2 = r_vec.length_squared();
47        if r2 < 1e-10 {
48            continue;
49        }
50        let r = r2.sqrt();
51        let r_hat = r_vec / r;
52
53        // dB = (mu0/4pi) * I * dl × r_hat / r^2
54        let cross = dl_step.cross(r_hat);
55        b += MU0_OVER_4PI * segment.current * cross / r2;
56    }
57
58    b
59}
60
61/// Compute the total magnetic field at `pos` from multiple current segments (superposition).
62pub fn magnetic_field_at(segments: &[CurrentSegment], pos: Vec3) -> Vec3 {
63    let mut field = Vec3::ZERO;
64    for seg in segments {
65        field += biot_savart(seg, pos);
66    }
67    field
68}
69
70// ── Infinite Wire ─────────────────────────────────────────────────────────
71
72/// An infinite straight wire carrying current.
73#[derive(Clone, Debug)]
74pub struct InfiniteWire {
75    pub position: Vec3,  // a point on the wire
76    pub direction: Vec3, // unit direction of current flow
77    pub current: f32,
78}
79
80impl InfiniteWire {
81    pub fn new(position: Vec3, direction: Vec3, current: f32) -> Self {
82        Self {
83            position,
84            direction: direction.normalize(),
85            current,
86        }
87    }
88
89    /// Analytical magnetic field: B = (mu0 * I) / (2*pi*r) in the azimuthal direction.
90    pub fn field_at(&self, point: Vec3) -> Vec3 {
91        let to_point = point - self.position;
92        // Project out the component along the wire
93        let parallel = to_point.dot(self.direction) * self.direction;
94        let perp = to_point - parallel;
95        let r = perp.length();
96        if r < 1e-10 {
97            return Vec3::ZERO;
98        }
99        // B direction: dl × r_hat (azimuthal)
100        let r_hat = perp / r;
101        let b_dir = self.direction.cross(r_hat);
102        // B magnitude: mu0*I / (2*pi*r), with mu0 = 4*pi*MU0_OVER_4PI
103        let b_mag = 2.0 * MU0_OVER_4PI * self.current / r;
104        b_dir * b_mag
105    }
106}
107
108// ── Circular Loop ─────────────────────────────────────────────────────────
109
110/// A circular current loop.
111#[derive(Clone, Debug)]
112pub struct CircularLoop {
113    pub center: Vec3,
114    pub normal: Vec3, // axis direction
115    pub radius: f32,
116    pub current: f32,
117}
118
119impl CircularLoop {
120    pub fn new(center: Vec3, normal: Vec3, radius: f32, current: f32) -> Self {
121        Self {
122            center,
123            normal: normal.normalize(),
124            radius,
125            current,
126        }
127    }
128
129    /// On-axis magnetic field of a circular loop.
130    /// B = (mu0 * I * R^2) / (2 * (R^2 + z^2)^(3/2))
131    pub fn on_axis_field(&self, distance_along_axis: f32) -> Vec3 {
132        let r2 = self.radius * self.radius;
133        let z2 = distance_along_axis * distance_along_axis;
134        let denom = (r2 + z2).powf(1.5);
135        if denom < 1e-10 {
136            return Vec3::ZERO;
137        }
138        // Factor of 2*pi because MU0_OVER_4PI = mu0/(4*pi)
139        let b_mag = 2.0 * PI * MU0_OVER_4PI * self.current * r2 / denom;
140        self.normal * b_mag
141    }
142
143    /// General field at any point via numerical integration.
144    pub fn field_at(&self, point: Vec3, segments: usize) -> Vec3 {
145        let segs = self.to_segments(segments);
146        magnetic_field_at(&segs, point)
147    }
148
149    /// Convert the loop into a series of current segments.
150    pub fn to_segments(&self, n: usize) -> Vec<CurrentSegment> {
151        let n = n.max(8);
152        // Build orthonormal basis for the loop plane
153        let w = self.normal;
154        let u = if w.x.abs() < 0.9 {
155            Vec3::X.cross(w).normalize()
156        } else {
157            Vec3::Y.cross(w).normalize()
158        };
159        let v = w.cross(u);
160
161        let mut segments = Vec::with_capacity(n);
162        for i in 0..n {
163            let theta0 = 2.0 * PI * i as f32 / n as f32;
164            let theta1 = 2.0 * PI * (i + 1) as f32 / n as f32;
165            let p0 = self.center + self.radius * (u * theta0.cos() + v * theta0.sin());
166            let p1 = self.center + self.radius * (u * theta1.cos() + v * theta1.sin());
167            segments.push(CurrentSegment::new(p0, p1, self.current));
168        }
169        segments
170    }
171}
172
173// ── Solenoid ──────────────────────────────────────────────────────────────
174
175/// A solenoid: many circular loops stacked along an axis.
176#[derive(Clone, Debug)]
177pub struct Solenoid {
178    pub center: Vec3,
179    pub axis: Vec3,
180    pub radius: f32,
181    pub length: f32,
182    pub turns: u32,
183    pub current: f32,
184}
185
186impl Solenoid {
187    pub fn new(center: Vec3, axis: Vec3, radius: f32, length: f32, turns: u32, current: f32) -> Self {
188        Self {
189            center,
190            axis: axis.normalize(),
191            radius,
192            length,
193            turns,
194            current,
195        }
196    }
197
198    /// Interior field of an ideal infinite solenoid: B = mu0 * n * I
199    pub fn interior_field(&self) -> Vec3 {
200        let n = self.turns as f32 / self.length; // turns per unit length
201        // mu0 = 4*pi * MU0_OVER_4PI
202        let b_mag = 4.0 * PI * MU0_OVER_4PI * n * self.current;
203        self.axis * b_mag
204    }
205
206    /// Convert solenoid to a collection of circular loops.
207    pub fn to_loops(&self) -> Vec<CircularLoop> {
208        let mut loops = Vec::with_capacity(self.turns as usize);
209        let start = self.center - self.axis * self.length * 0.5;
210        for i in 0..self.turns {
211            let t = (i as f32 + 0.5) / self.turns as f32;
212            let pos = start + self.axis * self.length * t;
213            loops.push(CircularLoop::new(pos, self.axis, self.radius, self.current));
214        }
215        loops
216    }
217
218    /// Field at any point by summing contributions from all loops.
219    pub fn field_at(&self, point: Vec3, segments_per_loop: usize) -> Vec3 {
220        let loops = self.to_loops();
221        let mut field = Vec3::ZERO;
222        for loop_ in &loops {
223            field += loop_.field_at(point, segments_per_loop);
224        }
225        field
226    }
227
228    /// Check if a point is inside the solenoid (approximately).
229    pub fn is_inside(&self, point: Vec3) -> bool {
230        let to_point = point - self.center;
231        let along_axis = to_point.dot(self.axis);
232        if along_axis.abs() > self.length * 0.5 {
233            return false;
234        }
235        let perp = to_point - along_axis * self.axis;
236        perp.length() < self.radius
237    }
238}
239
240// ── Field Line Tracing ────────────────────────────────────────────────────
241
242/// Trace a magnetic field line from a start point.
243/// Magnetic field lines are closed loops (div B = 0).
244pub fn trace_magnetic_field_line(
245    segments: &[CurrentSegment],
246    start: Vec3,
247    steps: usize,
248) -> Vec<Vec3> {
249    let step_size = 0.1;
250    let mut points = Vec::with_capacity(steps + 1);
251    let mut pos = start;
252    points.push(pos);
253
254    for _ in 0..steps {
255        let b = magnetic_field_at(segments, pos);
256        if b.length_squared() < 1e-14 {
257            break;
258        }
259        let dir = b.normalize();
260
261        // RK4
262        let k1 = dir * step_size;
263
264        let b2 = magnetic_field_at(segments, pos + k1 * 0.5);
265        if b2.length_squared() < 1e-14 { break; }
266        let k2 = b2.normalize() * step_size;
267
268        let b3 = magnetic_field_at(segments, pos + k2 * 0.5);
269        if b3.length_squared() < 1e-14 { break; }
270        let k3 = b3.normalize() * step_size;
271
272        let b4 = magnetic_field_at(segments, pos + k3);
273        if b4.length_squared() < 1e-14 { break; }
274        let k4 = b4.normalize() * step_size;
275
276        pos += (k1 + 2.0 * k2 + 2.0 * k3 + k4) / 6.0;
277        points.push(pos);
278
279        // Check if we've returned close to start (closed loop)
280        if points.len() > 10 && (pos - start).length() < step_size * 2.0 {
281            points.push(start); // close the loop
282            break;
283        }
284    }
285
286    points
287}
288
289// ── Magnetic Dipole ───────────────────────────────────────────────────────
290
291/// Magnetic field of a magnetic dipole at a given position.
292/// B = (mu0/4*pi) * [3(m·r_hat)r_hat - m] / r^3
293pub fn magnetic_dipole_field(moment: Vec3, pos: Vec3) -> Vec3 {
294    let r = pos.length();
295    if r < 1e-10 {
296        return Vec3::ZERO;
297    }
298    let r_hat = pos / r;
299    let r3 = r * r * r;
300    let m_dot_r = moment.dot(r_hat);
301    MU0_OVER_4PI * (3.0 * m_dot_r * r_hat - moment) / r3
302}
303
304// ── Ampere's Law ──────────────────────────────────────────────────────────
305
306/// Verify Ampere's law: the circulation of B around a closed path equals mu0 * I_enc.
307/// Returns the line integral ∮ B · dl.
308pub fn ampere_circulation(segments: &[CurrentSegment], path_points: &[Vec3]) -> f32 {
309    if path_points.len() < 2 {
310        return 0.0;
311    }
312    let mut circulation = 0.0f32;
313    for i in 0..path_points.len() {
314        let next = (i + 1) % path_points.len();
315        let dl = path_points[next] - path_points[i];
316        let midpoint = (path_points[i] + path_points[next]) * 0.5;
317        let b = magnetic_field_at(segments, midpoint);
318        circulation += b.dot(dl);
319    }
320    circulation
321}
322
323// ── Magnetic Field Renderer ───────────────────────────────────────────────
324
325/// Renderer for magnetic field visualization.
326pub struct MagneticFieldRenderer {
327    pub field_line_color: Vec4,
328    pub arrow_color: Vec4,
329    pub flux_density_scale: f32,
330}
331
332impl MagneticFieldRenderer {
333    pub fn new() -> Self {
334        Self {
335            field_line_color: Vec4::new(0.2, 0.8, 0.3, 1.0),
336            arrow_color: Vec4::new(1.0, 1.0, 0.2, 1.0),
337            flux_density_scale: 1.0,
338        }
339    }
340
341    /// Color based on flux density magnitude.
342    pub fn color_for_flux_density(&self, b_magnitude: f32) -> Vec4 {
343        let t = (b_magnitude * self.flux_density_scale).min(1.0);
344        // Gradient from blue (weak) to green to red (strong)
345        let r = (2.0 * t - 1.0).max(0.0);
346        let g = 1.0 - (2.0 * t - 1.0).abs();
347        let b = (1.0 - 2.0 * t).max(0.0);
348        Vec4::new(r, g, b, 0.8)
349    }
350
351    /// Arrow glyph for direction.
352    pub fn direction_arrow(direction: Vec3) -> char {
353        let angle = direction.y.atan2(direction.x);
354        let octant = ((angle / (PI / 4.0)).round() as i32).rem_euclid(8);
355        match octant {
356            0 => '→',
357            1 => '↗',
358            2 => '↑',
359            3 => '↖',
360            4 => '←',
361            5 => '↙',
362            6 => '↓',
363            7 => '↘',
364            _ => '·',
365        }
366    }
367
368    /// Render a set of field line points with direction arrows.
369    pub fn render_field_line(&self, points: &[Vec3], segments: &[CurrentSegment]) -> Vec<(Vec3, char, Vec4)> {
370        let mut result = Vec::new();
371        for i in 0..points.len() {
372            let b = magnetic_field_at(segments, points[i]);
373            let mag = b.length();
374            let color = self.color_for_flux_density(mag);
375            let ch = if i + 1 < points.len() {
376                let dir = points[i + 1] - points[i];
377                Self::direction_arrow(dir)
378            } else {
379                '·'
380            };
381            result.push((points[i], ch, color));
382        }
383        result
384    }
385}
386
387impl Default for MagneticFieldRenderer {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393// ── Tests ─────────────────────────────────────────────────────────────────
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_biot_savart_direction() {
401        // Current along +z, field at +x should be in -y direction (right-hand rule)
402        // Actually: dl × r for dl=(0,0,1) and r=(1,0,0) gives (0,0,1)×(1,0,0)=(0,1,0)
403        // Wait: r_hat points from source to field point.
404        // dl=(0,0,dz), r_hat=(1,0,0), dl×r_hat = (0*0-dz*0, dz*1-0*0, 0*0-0*1) = (0,dz,0)
405        // Hmm, let me just verify the field has a specific direction
406        let seg = CurrentSegment::new(Vec3::new(0.0, 0.0, -5.0), Vec3::new(0.0, 0.0, 5.0), 1.0);
407        let b = biot_savart(&seg, Vec3::new(1.0, 0.0, 0.0));
408        // For a wire along z, field at (1,0,0) should be in the y direction
409        assert!(b.y.abs() > b.x.abs() * 10.0, "B should be primarily in y: {:?}", b);
410        assert!(b.y > 0.0, "B_y should be positive for +z current at +x");
411    }
412
413    #[test]
414    fn test_infinite_wire_inverse_r() {
415        let wire = InfiniteWire::new(Vec3::ZERO, Vec3::Z, 1.0);
416        let b1 = wire.field_at(Vec3::new(1.0, 0.0, 0.0));
417        let b2 = wire.field_at(Vec3::new(2.0, 0.0, 0.0));
418        // B ∝ 1/r, so B(2r) = B(r)/2
419        let ratio = b1.length() / b2.length();
420        assert!((ratio - 2.0).abs() < 0.01, "ratio={}", ratio);
421    }
422
423    #[test]
424    fn test_circular_loop_on_axis() {
425        let loop_ = CircularLoop::new(Vec3::ZERO, Vec3::Z, 1.0, 1.0);
426        // At center of loop
427        let b_center = loop_.on_axis_field(0.0);
428        // B = 2*pi*MU0_OVER_4PI * I / R = 2*pi*1*1/1 = 2*pi
429        let expected = 2.0 * PI * MU0_OVER_4PI * 1.0 / 1.0;
430        assert!((b_center.z - expected).abs() < 0.01, "b_center={:?}, expected={}", b_center, expected);
431
432        // Field should decrease with distance
433        let b_far = loop_.on_axis_field(5.0);
434        assert!(b_far.length() < b_center.length(), "Field should decrease with distance");
435    }
436
437    #[test]
438    fn test_solenoid_interior_field() {
439        let sol = Solenoid::new(Vec3::ZERO, Vec3::Z, 0.5, 10.0, 100, 1.0);
440        let b_interior = sol.interior_field();
441        // B = mu0 * n * I = 4*pi * MU0_OVER_4PI * (100/10) * 1 = 4*pi * 10
442        let n = 100.0 / 10.0;
443        let expected = 4.0 * PI * MU0_OVER_4PI * n * 1.0;
444        assert!((b_interior.z - expected).abs() < 0.01);
445    }
446
447    #[test]
448    fn test_solenoid_uniformity() {
449        // Interior field of a long solenoid should be approximately uniform
450        let sol = Solenoid::new(Vec3::ZERO, Vec3::Z, 1.0, 20.0, 200, 1.0);
451        let b_center = sol.interior_field();
452        // The numerical field near center should match the analytical interior field
453        // (but full numerical computation is expensive, so just verify analytical)
454        let b_mag = b_center.length();
455        assert!(b_mag > 0.0);
456        // Field is along axis
457        assert!(b_center.z.abs() > b_center.x.abs() * 100.0);
458    }
459
460    #[test]
461    fn test_ampere_law() {
462        // A circular path around a straight wire should give mu0 * I
463        let wire_segments: Vec<CurrentSegment> = {
464            // Approximate infinite wire with a long segment along z
465            vec![CurrentSegment::new(
466                Vec3::new(0.0, 0.0, -50.0),
467                Vec3::new(0.0, 0.0, 50.0),
468                1.0,
469            )]
470        };
471
472        // Circular path of radius 2 in the xy-plane
473        let n = 200;
474        let r = 2.0;
475        let path: Vec<Vec3> = (0..n)
476            .map(|i| {
477                let theta = 2.0 * PI * i as f32 / n as f32;
478                Vec3::new(r * theta.cos(), r * theta.sin(), 0.0)
479            })
480            .collect();
481
482        let circulation = ampere_circulation(&wire_segments, &path);
483        // Expected: mu0 * I = 4*pi * MU0_OVER_4PI * I = 4*pi * 1
484        let expected = 4.0 * PI * MU0_OVER_4PI * 1.0;
485        let relative_error = (circulation - expected).abs() / expected;
486        assert!(relative_error < 0.1, "Ampere's law: circ={}, expected={}, error={}", circulation, expected, relative_error);
487    }
488
489    #[test]
490    fn test_magnetic_dipole() {
491        let m = Vec3::new(0.0, 0.0, 1.0);
492        // Along the axis: B = (mu0/4pi) * 2m/r^3
493        let b = magnetic_dipole_field(m, Vec3::new(0.0, 0.0, 5.0));
494        let expected = MU0_OVER_4PI * 2.0 / (5.0_f32.powi(3));
495        assert!((b.z - expected).abs() < 0.001, "dipole field: {}", b.z);
496    }
497
498    #[test]
499    fn test_renderer_colors() {
500        let renderer = MagneticFieldRenderer::new();
501        let weak = renderer.color_for_flux_density(0.0);
502        let strong = renderer.color_for_flux_density(1.0);
503        // Weak field should be blue-ish, strong should be red-ish
504        assert!(weak.z > weak.x, "Weak field should be blue");
505        assert!(strong.x > strong.z, "Strong field should be red");
506    }
507
508    #[test]
509    fn test_direction_arrow() {
510        assert_eq!(MagneticFieldRenderer::direction_arrow(Vec3::new(1.0, 0.0, 0.0)), '→');
511        assert_eq!(MagneticFieldRenderer::direction_arrow(Vec3::new(-1.0, 0.0, 0.0)), '←');
512    }
513}