Skip to main content

proof_engine/math/
fields.rs

1//! Force fields — continuous spatial functions that apply forces to glyphs.
2//!
3//! ## Field taxonomy
4//! - **Simple fields**: gravity, flow, vortex, repulsion, heat, damping
5//! - **Math fields**: apply a `MathFunction` to a glyph property
6//! - **Strange attractors**: drive motion along attractor trajectories
7//! - **Composed fields**: combine multiple fields with blend operators
8//! - **Animated fields**: fields whose parameters change over time
9//!
10//! All fields implement `force_at(pos, mass, charge, t) → Vec3`, making
11//! them interchangeable in the scene graph field manager.
12
13use glam::{Vec2, Vec3};
14use super::attractors::AttractorType;
15use super::MathFunction;
16
17// ── ForceField ────────────────────────────────────────────────────────────────
18
19/// A spatial force that acts on glyphs within its region of influence.
20#[derive(Clone, Debug)]
21pub enum ForceField {
22    /// Pulls glyphs toward a point, proportional to mass.
23    Gravity { center: Vec3, strength: f32, falloff: Falloff },
24    /// Pushes glyphs in a direction.
25    Flow { direction: Vec3, strength: f32, turbulence: f32 },
26    /// Spins glyphs around an axis.
27    Vortex { center: Vec3, axis: Vec3, strength: f32, radius: f32 },
28    /// Pushes glyphs away from a point.
29    Repulsion { center: Vec3, strength: f32, radius: f32 },
30    /// Attracts opposite charges, repels same charges.
31    Electromagnetic { center: Vec3, charge: f32, strength: f32 },
32    /// Increases temperature (and thus motion) of nearby glyphs.
33    HeatSource { center: Vec3, temperature: f32, radius: f32 },
34    /// Applies a math function to a glyph property in a region.
35    MathField { center: Vec3, radius: f32, function: MathFunction, target: FieldTarget },
36    /// Strange attractor dynamics pulling glyphs along attractor paths.
37    StrangeAttractor { attractor_type: AttractorType, scale: f32, strength: f32, center: Vec3 },
38    /// Increases entropy (chaos) of glyphs in a region.
39    EntropyField { center: Vec3, radius: f32, strength: f32 },
40    /// Reduces velocity of glyphs (viscosity).
41    Damping { center: Vec3, radius: f32, strength: f32 },
42    /// Oscillating push-pull field.
43    Pulsing { center: Vec3, frequency: f32, amplitude: f32, radius: f32 },
44    /// Shockwave expanding outward from an origin.
45    Shockwave { center: Vec3, speed: f32, thickness: f32, strength: f32, born_at: f32 },
46    /// Wind with per-layer turbulence (Perlin-driven).
47    Wind { direction: Vec3, base_strength: f32, gust_frequency: f32, gust_amplitude: f32 },
48    /// Portal-style warp field: pulls toward center, ejects on the other side.
49    Warp { center: Vec3, exit: Vec3, radius: f32, strength: f32 },
50    /// Tidal force (stretches along one axis, squishes along others).
51    Tidal { center: Vec3, axis: Vec3, strength: f32, radius: f32 },
52    /// Magnetic dipole (north + south poles).
53    MagneticDipole { center: Vec3, axis: Vec3, moment: f32 },
54    /// Saddle point field (hyperbolic potential).
55    Saddle { center: Vec3, strength_x: f32, strength_y: f32 },
56}
57
58/// How field strength decreases with distance.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub enum Falloff {
61    None,
62    Linear,
63    InverseSquare,
64    Exponential(f32),
65    Gaussian(f32),
66    /// Smooth step: linear to 1.0, then smoothstep to 0.
67    SmoothStep(f32),
68}
69
70/// Which property of a glyph a MathField modifies.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub enum FieldTarget {
73    PositionX, PositionY, PositionZ,
74    ColorR, ColorG, ColorB, ColorA,
75    Scale, Rotation, Emission, Temperature, Entropy,
76}
77
78impl ForceField {
79    /// Compute the force vector applied to a point at `pos` with `mass`,
80    /// `charge`, and world time `t`.
81    pub fn force_at(&self, pos: Vec3, mass: f32, charge: f32, t: f32) -> Vec3 {
82        match self {
83            ForceField::Gravity { center, strength, falloff } => {
84                let delta = *center - pos;
85                let dist = delta.length().max(0.01);
86                let dir = delta / dist;
87                let s = falloff_factor(*falloff, dist, 1.0) * strength * mass;
88                dir * s
89            }
90
91            ForceField::Flow { direction, strength, turbulence: _ } => {
92                direction.normalize_or_zero() * *strength
93            }
94
95            ForceField::Vortex { center, axis, strength, radius } => {
96                let delta = pos - *center;
97                let dist = delta.length();
98                if dist > *radius || dist < 0.001 { return Vec3::ZERO; }
99                let tangent = axis.normalize().cross(delta).normalize_or_zero();
100                tangent * *strength * (1.0 - dist / radius)
101            }
102
103            ForceField::Repulsion { center, strength, radius } => {
104                let delta = pos - *center;
105                let dist = delta.length();
106                if dist > *radius || dist < 0.001 { return Vec3::ZERO; }
107                let dir = delta / dist;
108                dir * *strength * (1.0 - dist / radius)
109            }
110
111            ForceField::Electromagnetic { center, charge: field_charge, strength } => {
112                let delta = pos - *center;
113                let dist = delta.length().max(0.01);
114                let dir = delta / dist;
115                let sign = if charge * field_charge > 0.0 { 1.0 } else { -1.0 };
116                dir * sign * *strength / (dist * dist)
117            }
118
119            ForceField::HeatSource { .. } | ForceField::EntropyField { .. } => Vec3::ZERO,
120
121            ForceField::Damping { center, radius, strength: _ } => {
122                let dist = (pos - *center).length();
123                if dist > *radius { return Vec3::ZERO; }
124                Vec3::ZERO // Damping applied as velocity scale in scene tick
125            }
126
127            ForceField::MathField { .. } => Vec3::ZERO,
128
129            ForceField::StrangeAttractor { attractor_type, scale, strength, center } => {
130                let local = (pos - *center) / scale.max(0.001);
131                let (_next, delta) = super::attractors::step(*attractor_type, local, 0.016);
132                delta * *strength
133            }
134
135            ForceField::Pulsing { center, frequency, amplitude, radius } => {
136                let dist = (pos - *center).length();
137                if dist > *radius || dist < 0.001 { return Vec3::ZERO; }
138                let dir = (pos - *center).normalize_or_zero();
139                let wave = (t * frequency * std::f32::consts::TAU).sin();
140                dir * *amplitude * wave * (1.0 - dist / radius)
141            }
142
143            ForceField::Shockwave { center, speed, thickness, strength, born_at } => {
144                let dist = (pos - *center).length();
145                let wave_r = (t - born_at) * speed;
146                let diff = (dist - wave_r).abs();
147                if diff > *thickness { return Vec3::ZERO; }
148                let dir = (pos - *center).normalize_or_zero();
149                let falloff = 1.0 - diff / thickness;
150                dir * *strength * falloff / (wave_r + 1.0)
151            }
152
153            ForceField::Wind { direction, base_strength, gust_frequency, gust_amplitude } => {
154                let gust = (t * gust_frequency * std::f32::consts::TAU
155                           + pos.x * 0.3 + pos.z * 0.2).sin() * gust_amplitude;
156                direction.normalize_or_zero() * (base_strength + gust)
157            }
158
159            ForceField::Warp { center, exit: _, radius, strength } => {
160                let delta = pos - *center;
161                let dist = delta.length();
162                if dist > *radius || dist < 0.001 { return Vec3::ZERO; }
163                let dir = -delta.normalize_or_zero(); // pull toward center
164                dir * *strength * (1.0 - dist / radius).powi(2)
165            }
166
167            ForceField::Tidal { center, axis, strength, radius } => {
168                let delta = pos - *center;
169                let dist = delta.length();
170                if dist > *radius { return Vec3::ZERO; }
171                let ax = axis.normalize();
172                let along = ax * ax.dot(delta);
173                let perp  = delta - along;
174                // Stretch along axis, compress perpendicular
175                (along * 2.0 - perp) * *strength * (1.0 - dist / radius)
176            }
177
178            ForceField::MagneticDipole { center, axis, moment } => {
179                let r = pos - *center;
180                let dist = r.length().max(0.01);
181                let r_hat = r / dist;
182                let m = axis.normalize() * *moment;
183                let factor = 1.0 / (dist * dist * dist);
184                (3.0 * r_hat * r_hat.dot(m) - m) * factor
185            }
186
187            ForceField::Saddle { center, strength_x, strength_y } => {
188                let d = pos - *center;
189                Vec3::new(d.x * strength_x, -d.y * strength_y, 0.0)
190            }
191        }
192    }
193
194    /// Returns the temperature contribution at `pos` (for heat fields).
195    pub fn temperature_at(&self, pos: Vec3) -> f32 {
196        if let ForceField::HeatSource { center, temperature, radius } = self {
197            let dist = (pos - *center).length();
198            if dist < *radius {
199                return temperature * (1.0 - dist / radius);
200            }
201        }
202        0.0
203    }
204
205    /// Returns the entropy contribution at `pos`.
206    pub fn entropy_at(&self, pos: Vec3) -> f32 {
207        if let ForceField::EntropyField { center, radius, strength } = self {
208            let dist = (pos - *center).length();
209            if dist < *radius {
210                return strength * (1.0 - dist / radius);
211            }
212        }
213        0.0
214    }
215
216    /// Returns the damping multiplier at `pos` (1.0 = no damping, 0.0 = full stop).
217    pub fn damping_at(&self, pos: Vec3) -> f32 {
218        if let ForceField::Damping { center, radius, strength } = self {
219            let dist = (pos - *center).length();
220            if dist < *radius {
221                return 1.0 - strength * (1.0 - dist / radius);
222            }
223        }
224        1.0
225    }
226
227    /// True if this field type is purely visual (no position force).
228    pub fn is_non_positional(&self) -> bool {
229        matches!(self,
230            ForceField::HeatSource { .. }
231          | ForceField::EntropyField { .. }
232          | ForceField::MathField { .. }
233          | ForceField::Damping { .. }
234        )
235    }
236
237    /// Returns a friendly label for debug UI.
238    pub fn label(&self) -> &'static str {
239        match self {
240            ForceField::Gravity { .. }          => "Gravity",
241            ForceField::Flow { .. }             => "Flow",
242            ForceField::Vortex { .. }           => "Vortex",
243            ForceField::Repulsion { .. }        => "Repulsion",
244            ForceField::Electromagnetic { .. }  => "EM",
245            ForceField::HeatSource { .. }       => "Heat",
246            ForceField::MathField { .. }        => "Math",
247            ForceField::StrangeAttractor { .. } => "Attractor",
248            ForceField::EntropyField { .. }     => "Entropy",
249            ForceField::Damping { .. }          => "Damping",
250            ForceField::Pulsing { .. }          => "Pulsing",
251            ForceField::Shockwave { .. }        => "Shockwave",
252            ForceField::Wind { .. }             => "Wind",
253            ForceField::Warp { .. }             => "Warp",
254            ForceField::Tidal { .. }            => "Tidal",
255            ForceField::MagneticDipole { .. }   => "Dipole",
256            ForceField::Saddle { .. }           => "Saddle",
257        }
258    }
259
260    /// Returns the center position if this field has one, else None.
261    pub fn center(&self) -> Option<Vec3> {
262        match self {
263            ForceField::Gravity { center, .. }          => Some(*center),
264            ForceField::Vortex { center, .. }           => Some(*center),
265            ForceField::Repulsion { center, .. }        => Some(*center),
266            ForceField::Electromagnetic { center, .. }  => Some(*center),
267            ForceField::HeatSource { center, .. }       => Some(*center),
268            ForceField::MathField { center, .. }        => Some(*center),
269            ForceField::StrangeAttractor { center, .. } => Some(*center),
270            ForceField::EntropyField { center, .. }     => Some(*center),
271            ForceField::Damping { center, .. }          => Some(*center),
272            ForceField::Pulsing { center, .. }          => Some(*center),
273            ForceField::Shockwave { center, .. }        => Some(*center),
274            ForceField::Warp { center, .. }             => Some(*center),
275            ForceField::Tidal { center, .. }            => Some(*center),
276            ForceField::MagneticDipole { center, .. }   => Some(*center),
277            ForceField::Saddle { center, .. }           => Some(*center),
278            _ => None,
279        }
280    }
281}
282
283// ── Falloff ───────────────────────────────────────────────────────────────────
284
285pub fn falloff_factor(falloff: Falloff, distance: f32, max_distance: f32) -> f32 {
286    match falloff {
287        Falloff::None           => 1.0,
288        Falloff::Linear         => (1.0 - distance / max_distance).max(0.0),
289        Falloff::InverseSquare  => 1.0 / (distance * distance).max(0.0001),
290        Falloff::Exponential(r) => (-distance * r).exp(),
291        Falloff::Gaussian(sig)  => {
292            let x = distance / sig;
293            (-0.5 * x * x).exp()
294        }
295        Falloff::SmoothStep(r) => {
296            let t = (1.0 - distance / r).clamp(0.0, 1.0);
297            t * t * (3.0 - 2.0 * t)
298        }
299    }
300}
301
302// ── FieldComposer ─────────────────────────────────────────────────────────────
303
304/// Combines multiple fields with a blend operator.
305#[derive(Clone, Debug)]
306pub struct FieldComposer {
307    pub layers: Vec<FieldLayer>,
308}
309
310#[derive(Clone, Debug)]
311pub struct FieldLayer {
312    pub field:  ForceField,
313    pub blend:  FieldBlend,
314    pub weight: f32,
315    pub enabled: bool,
316}
317
318#[derive(Clone, Copy, Debug, PartialEq)]
319pub enum FieldBlend {
320    /// Add force vectors.
321    Add,
322    /// Multiply force vectors (modulation).
323    Multiply,
324    /// Take the maximum of each component.
325    Max,
326    /// Override: this layer replaces all previous layers.
327    Override,
328    /// Subtract this layer's force from accumulated.
329    Subtract,
330}
331
332impl FieldComposer {
333    pub fn new() -> Self { Self { layers: Vec::new() } }
334
335    pub fn add(mut self, field: ForceField) -> Self {
336        self.layers.push(FieldLayer { field, blend: FieldBlend::Add, weight: 1.0, enabled: true });
337        self
338    }
339
340    pub fn add_weighted(mut self, field: ForceField, weight: f32) -> Self {
341        self.layers.push(FieldLayer { field, blend: FieldBlend::Add, weight, enabled: true });
342        self
343    }
344
345    pub fn add_blended(mut self, field: ForceField, blend: FieldBlend, weight: f32) -> Self {
346        self.layers.push(FieldLayer { field, blend, weight, enabled: true });
347        self
348    }
349
350    /// Evaluate the composed force at a point.
351    pub fn force_at(&self, pos: Vec3, mass: f32, charge: f32, t: f32) -> Vec3 {
352        let mut acc = Vec3::ZERO;
353        for layer in &self.layers {
354            if !layer.enabled { continue; }
355            let f = layer.field.force_at(pos, mass, charge, t) * layer.weight;
356            acc = match layer.blend {
357                FieldBlend::Add      => acc + f,
358                FieldBlend::Subtract => acc - f,
359                FieldBlend::Multiply => acc * f,
360                FieldBlend::Max      => acc.max(f),
361                FieldBlend::Override => f,
362            };
363        }
364        acc
365    }
366
367    pub fn enable_layer(&mut self, idx: usize, enabled: bool) {
368        if let Some(l) = self.layers.get_mut(idx) { l.enabled = enabled; }
369    }
370
371    pub fn set_weight(&mut self, idx: usize, weight: f32) {
372        if let Some(l) = self.layers.get_mut(idx) { l.weight = weight; }
373    }
374}
375
376// ── FieldSampler ──────────────────────────────────────────────────────────────
377
378/// Samples a field onto a 2D grid for visualization.
379pub struct FieldSampler {
380    pub width:  usize,
381    pub height: usize,
382    pub x_min:  f32,
383    pub x_max:  f32,
384    pub y_min:  f32,
385    pub y_max:  f32,
386    pub z:      f32,
387    /// Sampled force vectors (flat array, row-major).
388    pub forces: Vec<Vec3>,
389    pub magnitudes: Vec<f32>,
390}
391
392impl FieldSampler {
393    pub fn new(width: usize, height: usize, bounds: (f32, f32, f32, f32)) -> Self {
394        let n = width * height;
395        Self {
396            width, height,
397            x_min: bounds.0, x_max: bounds.2,
398            y_min: bounds.1, y_max: bounds.3,
399            z: 0.0,
400            forces:     vec![Vec3::ZERO; n],
401            magnitudes: vec![0.0; n],
402        }
403    }
404
405    /// Sample the field at all grid points.
406    pub fn sample(&mut self, field: &ForceField) {
407        let dx = (self.x_max - self.x_min) / self.width as f32;
408        let dy = (self.y_max - self.y_min) / self.height as f32;
409        for y in 0..self.height {
410            for x in 0..self.width {
411                let wx = self.x_min + (x as f32 + 0.5) * dx;
412                let wy = self.y_min + (y as f32 + 0.5) * dy;
413                let f = field.force_at(Vec3::new(wx, wy, self.z), 1.0, 0.0, 0.0);
414                let i = y * self.width + x;
415                self.forces[i] = f;
416                self.magnitudes[i] = f.length();
417            }
418        }
419    }
420
421    /// Sample a composer at all grid points.
422    pub fn sample_composer(&mut self, composer: &FieldComposer) {
423        let dx = (self.x_max - self.x_min) / self.width as f32;
424        let dy = (self.y_max - self.y_min) / self.height as f32;
425        for y in 0..self.height {
426            for x in 0..self.width {
427                let wx = self.x_min + (x as f32 + 0.5) * dx;
428                let wy = self.y_min + (y as f32 + 0.5) * dy;
429                let f = composer.force_at(Vec3::new(wx, wy, self.z), 1.0, 0.0, 0.0);
430                let i = y * self.width + x;
431                self.forces[i] = f;
432                self.magnitudes[i] = f.length();
433            }
434        }
435    }
436
437    /// Maximum magnitude seen in the last sample.
438    pub fn max_magnitude(&self) -> f32 {
439        self.magnitudes.iter().cloned().fold(0.0_f32, f32::max)
440    }
441
442    /// Get the force at grid cell (x, y).
443    pub fn force_at_cell(&self, x: usize, y: usize) -> Vec3 {
444        self.forces.get(y * self.width + x).copied().unwrap_or(Vec3::ZERO)
445    }
446
447    /// Compute normalized flow lines for visualization (streamline points).
448    pub fn streamline(&self, start: Vec2, steps: usize, dt: f32) -> Vec<Vec2> {
449        let mut pts = Vec::with_capacity(steps);
450        let mut pos = start;
451        for _ in 0..steps {
452            pts.push(pos);
453            let f = self.sample_at_world(pos);
454            if f.length_squared() < 1e-6 { break; }
455            pos += f * dt;
456        }
457        pts
458    }
459
460    fn sample_at_world(&self, pos: Vec2) -> Vec2 {
461        let tx = (pos.x - self.x_min) / (self.x_max - self.x_min);
462        let ty = (pos.y - self.y_min) / (self.y_max - self.y_min);
463        let cx = (tx * self.width as f32).clamp(0.0, self.width as f32 - 1.001);
464        let cy = (ty * self.height as f32).clamp(0.0, self.height as f32 - 1.001);
465        let x0 = cx.floor() as usize;
466        let y0 = cy.floor() as usize;
467        let x1 = (x0 + 1).min(self.width - 1);
468        let y1 = (y0 + 1).min(self.height - 1);
469        let fx = cx.fract();
470        let fy = cy.fract();
471        let f00 = self.forces[y0 * self.width + x0].truncate();
472        let f10 = self.forces[y0 * self.width + x1].truncate();
473        let f01 = self.forces[y1 * self.width + x0].truncate();
474        let f11 = self.forces[y1 * self.width + x1].truncate();
475        let f0 = Vec2::lerp(f00, f10, fx);
476        let f1 = Vec2::lerp(f01, f11, fx);
477        Vec2::lerp(f0, f1, fy)
478    }
479
480    /// Render force vectors as a flat RGBA buffer (for debug textures).
481    pub fn to_rgba(&self) -> Vec<u8> {
482        let n = self.width * self.height;
483        let max_mag = self.max_magnitude().max(0.001);
484        let mut out = vec![0u8; n * 4];
485        for i in 0..n {
486            let f = self.forces[i];
487            let r = (f.x / max_mag * 0.5 + 0.5).clamp(0.0, 1.0);
488            let g = (f.y / max_mag * 0.5 + 0.5).clamp(0.0, 1.0);
489            let b = (self.magnitudes[i] / max_mag).clamp(0.0, 1.0);
490            out[i * 4    ] = (r * 255.0) as u8;
491            out[i * 4 + 1] = (g * 255.0) as u8;
492            out[i * 4 + 2] = (b * 255.0) as u8;
493            out[i * 4 + 3] = 255;
494        }
495        out
496    }
497}
498
499// ── AnimatedField ─────────────────────────────────────────────────────────────
500
501/// A field whose strength or center animates over time.
502#[derive(Clone, Debug)]
503pub struct AnimatedField {
504    pub field:    ForceField,
505    pub timeline: Vec<AnimationKey>,
506}
507
508#[derive(Clone, Debug)]
509pub struct AnimationKey {
510    pub time:     f32,
511    pub strength: f32,
512    pub offset:   Vec3,
513}
514
515impl AnimatedField {
516    pub fn new(field: ForceField) -> Self {
517        Self { field, timeline: Vec::new() }
518    }
519
520    pub fn key(mut self, time: f32, strength: f32, offset: Vec3) -> Self {
521        self.timeline.push(AnimationKey { time, strength, offset });
522        self.timeline.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap());
523        self
524    }
525
526    pub fn force_at(&self, pos: Vec3, mass: f32, charge: f32, t: f32) -> Vec3 {
527        let (strength, offset) = self.eval_at(t);
528        let shifted_pos = pos - offset;
529        self.field.force_at(shifted_pos, mass, charge, t) * strength
530    }
531
532    fn eval_at(&self, t: f32) -> (f32, Vec3) {
533        if self.timeline.is_empty() { return (1.0, Vec3::ZERO); }
534        if self.timeline.len() == 1 {
535            let k = &self.timeline[0];
536            return (k.strength, k.offset);
537        }
538        if t <= self.timeline[0].time {
539            let k = &self.timeline[0];
540            return (k.strength, k.offset);
541        }
542        let last = self.timeline.last().unwrap();
543        if t >= last.time { return (last.strength, last.offset); }
544
545        let i = self.timeline.partition_point(|k| k.time <= t) - 1;
546        let k0 = &self.timeline[i];
547        let k1 = &self.timeline[i + 1];
548        let span = k1.time - k0.time;
549        let ft = if span < 1e-6 { 0.0 } else { (t - k0.time) / span };
550        let strength = k0.strength + (k1.strength - k0.strength) * ft;
551        let offset   = Vec3::lerp(k0.offset, k1.offset, ft);
552        (strength, offset)
553    }
554}
555
556// ── FieldPresets ──────────────────────────────────────────────────────────────
557
558/// Factory methods for common in-game force field configurations.
559pub struct FieldPresets;
560
561impl FieldPresets {
562    /// Planet-like gravity well.
563    pub fn planet(center: Vec3, mass: f32) -> ForceField {
564        ForceField::Gravity {
565            center,
566            strength: mass * 6.674e-3,
567            falloff:  Falloff::InverseSquare,
568        }
569    }
570
571    /// Dust devil / tornado vortex.
572    pub fn tornado(center: Vec3, strength: f32, radius: f32) -> ForceField {
573        ForceField::Vortex {
574            center,
575            axis:     Vec3::Y,
576            strength,
577            radius,
578        }
579    }
580
581    /// Omnidirectional explosion shockwave.
582    pub fn explosion(center: Vec3, strength: f32, born_at: f32) -> ForceField {
583        ForceField::Shockwave {
584            center,
585            speed:     15.0,
586            thickness: 3.0,
587            strength,
588            born_at,
589        }
590    }
591
592    /// River current flowing in a direction.
593    pub fn river(direction: Vec3, speed: f32) -> ForceField {
594        ForceField::Flow { direction, strength: speed, turbulence: 0.1 }
595    }
596
597    /// Bonfire heat column rising upward.
598    pub fn bonfire(center: Vec3, heat: f32) -> ForceField {
599        ForceField::HeatSource { center, temperature: heat, radius: 3.0 }
600    }
601
602    /// Spinning galaxy arm (Lorenz-driven).
603    pub fn galaxy_arm(center: Vec3, scale: f32) -> ForceField {
604        ForceField::StrangeAttractor {
605            attractor_type: AttractorType::Lorenz,
606            scale,
607            strength: 0.5,
608            center,
609        }
610    }
611
612    /// Frost aura: cold damping field.
613    pub fn frost_aura(center: Vec3, radius: f32) -> ForceField {
614        ForceField::Damping { center, radius, strength: 0.7 }
615    }
616
617    /// Chaos zone: pure entropy field.
618    pub fn chaos_zone(center: Vec3, radius: f32) -> ForceField {
619        ForceField::EntropyField { center, radius, strength: 2.0 }
620    }
621
622    /// Pendulum: oscillating gravity with period.
623    pub fn pendulum(center: Vec3, amplitude: f32, frequency: f32, radius: f32) -> ForceField {
624        ForceField::Pulsing { center, frequency, amplitude, radius }
625    }
626}
627
628// ── Unit tests ─────────────────────────────────────────────────────────────────
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn origin() -> Vec3 { Vec3::ZERO }
635
636    #[test]
637    fn test_gravity_pulls_toward_center() {
638        let field = ForceField::Gravity {
639            center:   Vec3::new(5.0, 0.0, 0.0),
640            strength: 1.0,
641            falloff:  Falloff::None,
642        };
643        let f = field.force_at(Vec3::ZERO, 1.0, 0.0, 0.0);
644        assert!(f.x > 0.0); // should pull toward +X
645    }
646
647    #[test]
648    fn test_repulsion_pushes_away() {
649        let field = ForceField::Repulsion { center: origin(), strength: 1.0, radius: 10.0 };
650        let f = field.force_at(Vec3::new(1.0, 0.0, 0.0), 1.0, 0.0, 0.0);
651        assert!(f.x > 0.0); // pushes away from origin
652    }
653
654    #[test]
655    fn test_repulsion_zero_outside_radius() {
656        let field = ForceField::Repulsion { center: origin(), strength: 1.0, radius: 1.0 };
657        let f = field.force_at(Vec3::new(5.0, 0.0, 0.0), 1.0, 0.0, 0.0);
658        assert_eq!(f, Vec3::ZERO);
659    }
660
661    #[test]
662    fn test_flow_direction() {
663        let field = ForceField::Flow {
664            direction: Vec3::X,
665            strength:  2.0,
666            turbulence: 0.0,
667        };
668        let f = field.force_at(origin(), 1.0, 0.0, 0.0);
669        assert!((f.x - 2.0).abs() < 0.01);
670        assert!(f.y.abs() < 0.01);
671    }
672
673    #[test]
674    fn test_vortex_tangential() {
675        let field = ForceField::Vortex {
676            center:   Vec3::ZERO,
677            axis:     Vec3::Z,
678            strength: 1.0,
679            radius:   10.0,
680        };
681        let f = field.force_at(Vec3::new(1.0, 0.0, 0.0), 1.0, 0.0, 0.0);
682        // Tangent to +X should be ±Y
683        assert!(f.y.abs() > 0.1);
684        assert!(f.x.abs() < 0.1);
685    }
686
687    #[test]
688    fn test_pulsing_varies_over_time() {
689        let field = ForceField::Pulsing {
690            center:    origin(),
691            frequency: 1.0,
692            amplitude: 1.0,
693            radius:    10.0,
694        };
695        let f0 = field.force_at(Vec3::X, 1.0, 0.0, 0.0);
696        let f1 = field.force_at(Vec3::X, 1.0, 0.0, 0.25);
697        assert!((f0.x - f1.x).abs() > 0.01);
698    }
699
700    #[test]
701    fn test_composer_add() {
702        let c = FieldComposer::new()
703            .add(ForceField::Flow { direction: Vec3::X, strength: 1.0, turbulence: 0.0 })
704            .add(ForceField::Flow { direction: Vec3::X, strength: 1.0, turbulence: 0.0 });
705        let f = c.force_at(origin(), 1.0, 0.0, 0.0);
706        assert!((f.x - 2.0).abs() < 0.01);
707    }
708
709    #[test]
710    fn test_field_sampler() {
711        let mut sampler = FieldSampler::new(8, 8, (-4.0, -4.0, 4.0, 4.0));
712        let field = ForceField::Flow { direction: Vec3::X, strength: 1.0, turbulence: 0.0 };
713        sampler.sample(&field);
714        let max = sampler.max_magnitude();
715        assert!((max - 1.0).abs() < 0.01);
716    }
717
718    #[test]
719    fn test_animated_field() {
720        let af = AnimatedField::new(
721            ForceField::Flow { direction: Vec3::X, strength: 1.0, turbulence: 0.0 }
722        )
723        .key(0.0, 0.0, Vec3::ZERO)
724        .key(1.0, 2.0, Vec3::ZERO);
725        let f0 = af.force_at(Vec3::ZERO, 1.0, 0.0, 0.0);
726        let f1 = af.force_at(Vec3::ZERO, 1.0, 0.0, 1.0);
727        assert!(f0.x < f1.x); // stronger at t=1
728    }
729
730    #[test]
731    fn test_falloff_factors() {
732        assert!((falloff_factor(Falloff::None, 5.0, 10.0) - 1.0).abs() < 1e-6);
733        assert!((falloff_factor(Falloff::Linear, 5.0, 10.0) - 0.5).abs() < 1e-6);
734        assert!(falloff_factor(Falloff::InverseSquare, 2.0, 10.0) < 1.0);
735        let g = falloff_factor(Falloff::Gaussian(2.0), 0.0, 10.0);
736        assert!((g - 1.0).abs() < 1e-5);
737    }
738
739    #[test]
740    fn test_preset_planet_gravity() {
741        let field = FieldPresets::planet(Vec3::new(10.0, 0.0, 0.0), 100.0);
742        let f = field.force_at(Vec3::ZERO, 1.0, 0.0, 0.0);
743        assert!(f.x > 0.0);
744    }
745
746    #[test]
747    fn test_shockwave_zero_before_wave_arrives() {
748        let field = FieldPresets::explosion(origin(), 10.0, 0.0);
749        let f = field.force_at(Vec3::new(100.0, 0.0, 0.0), 1.0, 0.0, 0.0);
750        assert_eq!(f, Vec3::ZERO);
751    }
752
753    #[test]
754    fn test_field_label() {
755        let field = ForceField::Flow { direction: Vec3::X, strength: 1.0, turbulence: 0.0 };
756        assert_eq!(field.label(), "Flow");
757    }
758}