1use glam::{Vec3, Vec4};
5use std::f32::consts::PI;
6
7const K_COULOMB: f32 = 1.0;
9
10#[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
24pub 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; }
33 let r = r2.sqrt();
34 field += K_COULOMB * c.charge / r2 * (r_vec / r);
36 }
37 field
38}
39
40pub 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 potential += K_COULOMB * c.charge / r;
50 }
51 potential
52}
53
54#[derive(Clone, Debug)]
57pub struct ElectricFieldLine {
58 pub points: Vec<Vec3>,
59}
60
61pub 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 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 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#[derive(Clone, Debug)]
121pub struct Dipole {
122 pub pos: Vec3,
123 pub moment: Vec3, }
125
126impl Dipole {
127 pub fn new(pos: Vec3, moment: Vec3) -> Self {
128 Self { pos, moment }
129 }
130
131 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 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 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#[derive(Clone, Debug)]
172pub struct Capacitor {
173 pub plate1: Vec3, pub plate2: Vec3, pub charge_density: f32, }
177
178impl Capacitor {
179 pub fn new(plate1: Vec3, plate2: Vec3, charge_density: f32) -> Self {
180 Self { plate1, plate2, charge_density }
181 }
182
183 pub fn field(&self) -> Vec3 {
186 let direction = (self.plate2 - self.plate1).normalize();
187 direction * self.charge_density
191 }
192
193 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 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 pub fn voltage(&self) -> f32 {
216 let d = (self.plate2 - self.plate1).length();
217 self.charge_density * d
218 }
219
220 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 }
228}
229
230#[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 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 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 pub fn total_charge(&self) -> f32 {
288 self.charge_per_length * (self.end - self.start).length()
289 }
290}
291
292pub 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
312pub 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
332pub 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 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 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 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 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 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#[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 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 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 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 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 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 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 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 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}