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