Skip to main content

proof_engine/vfx/
forces.rs

1//! Force fields applied to particles: gravity well, vortex, turbulence, wind zone,
2//! attractor/repulsor, drag, buoyancy. Supports force composition and tag masking.
3
4use glam::{Vec3, Vec4};
5use super::emitter::{Particle, ParticleTag, lcg_next};
6
7// ─── Force field ID ───────────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub struct ForceFieldId(pub u32);
11
12// ─── Force field influence modes ──────────────────────────────────────────────
13
14/// How a force field's strength falls off over distance.
15#[derive(Debug, Clone, Copy)]
16pub enum FalloffMode {
17    /// Constant — no falloff, field applies uniformly within radius.
18    Constant,
19    /// Linear falloff from max at centre to zero at radius.
20    Linear,
21    /// Quadratic falloff (1 / r²-like).
22    InverseSquare { min_dist: f32 },
23    /// Smooth cubic: 1 - 3t² + 2t³.
24    SmoothStep,
25    /// Field only applies outside a minimum distance and inside max_radius.
26    Annular { inner_radius: f32 },
27}
28
29impl FalloffMode {
30    /// Returns a 0..1 multiplier given normalised t = dist / radius (0 = centre).
31    pub fn factor(&self, t: f32, dist: f32, _radius: f32) -> f32 {
32        match self {
33            FalloffMode::Constant => 1.0,
34            FalloffMode::Linear   => (1.0 - t).max(0.0),
35            FalloffMode::InverseSquare { min_dist } => {
36                let d = dist.max(*min_dist);
37                1.0 / (d * d)
38            }
39            FalloffMode::SmoothStep => {
40                let t = t.clamp(0.0, 1.0);
41                1.0 - (3.0 * t * t - 2.0 * t * t * t)
42            }
43            FalloffMode::Annular { inner_radius } => {
44                if dist < *inner_radius { 0.0 } else { (1.0 - t).max(0.0) }
45            }
46        }
47    }
48}
49
50// ─── Tag mask ─────────────────────────────────────────────────────────────────
51
52/// Determines which particles a force field affects.
53#[derive(Debug, Clone, Copy)]
54pub enum TagMask {
55    /// Affect all particles regardless of tag.
56    All,
57    /// Only particles whose tag intersects this mask.
58    Include(ParticleTag),
59    /// All particles *except* those matching this mask.
60    Exclude(ParticleTag),
61}
62
63impl TagMask {
64    pub fn matches(&self, tag: ParticleTag) -> bool {
65        match self {
66            TagMask::All => true,
67            TagMask::Include(m) => tag.contains(*m) || m.0 == 0,
68            TagMask::Exclude(m) => !tag.contains(*m),
69        }
70    }
71}
72
73// ─── Individual force types ───────────────────────────────────────────────────
74
75/// A gravitational well that pulls (or repels) particles toward a point.
76#[derive(Debug, Clone)]
77pub struct GravityWell {
78    pub position:   Vec3,
79    pub strength:   f32,
80    pub radius:     f32,
81    pub falloff:    FalloffMode,
82    /// If true, field repels instead of attracts.
83    pub repulsive:  bool,
84    /// Kill particles that enter this exclusion sphere.
85    pub absorb_radius: f32,
86}
87
88impl GravityWell {
89    pub fn new(position: Vec3, strength: f32, radius: f32) -> Self {
90        Self { position, strength, radius, falloff: FalloffMode::InverseSquare { min_dist: 0.1 }, repulsive: false, absorb_radius: 0.0 }
91    }
92
93    pub fn repulsor(position: Vec3, strength: f32, radius: f32) -> Self {
94        Self { repulsive: true, ..Self::new(position, strength, radius) }
95    }
96
97    pub fn acceleration(&self, p_pos: Vec3) -> Vec3 {
98        let delta = self.position - p_pos;
99        let dist  = delta.length();
100        if dist > self.radius || dist < 1e-6 { return Vec3::ZERO; }
101        let dir    = delta / dist;
102        let t      = dist / self.radius;
103        let factor = self.falloff.factor(t, dist, self.radius);
104        let sign   = if self.repulsive { -1.0 } else { 1.0 };
105        dir * (self.strength * factor * sign)
106    }
107
108    pub fn should_absorb(&self, p_pos: Vec3) -> bool {
109        self.absorb_radius > 0.0 && (self.position - p_pos).length() <= self.absorb_radius
110    }
111}
112
113// ─── Vortex ───────────────────────────────────────────────────────────────────
114
115/// A spinning vortex that imparts tangential force around an axis.
116#[derive(Debug, Clone)]
117pub struct VortexField {
118    pub position:    Vec3,
119    /// Normalised spin axis.
120    pub axis:        Vec3,
121    pub strength:    f32,
122    pub radius:      f32,
123    pub falloff:     FalloffMode,
124    /// Additional inward/outward radial component (negative = inward).
125    pub radial_pull: f32,
126    /// Upward/downward component along axis.
127    pub axial_pull:  f32,
128}
129
130impl VortexField {
131    pub fn new(position: Vec3, axis: Vec3, strength: f32, radius: f32) -> Self {
132        Self {
133            position, axis: axis.normalize_or_zero(),
134            strength, radius, falloff: FalloffMode::Linear,
135            radial_pull: 0.0, axial_pull: 0.0,
136        }
137    }
138
139    pub fn tornado(position: Vec3, strength: f32, radius: f32) -> Self {
140        Self { radial_pull: -strength * 0.3, axial_pull: strength * 0.5, ..Self::new(position, Vec3::Y, strength, radius) }
141    }
142
143    pub fn acceleration(&self, p_pos: Vec3) -> Vec3 {
144        let to_axis_origin = p_pos - self.position;
145        // Project onto plane perpendicular to axis
146        let along_axis  = self.axis * to_axis_origin.dot(self.axis);
147        let radial_vec  = to_axis_origin - along_axis;
148        let dist        = radial_vec.length();
149
150        if dist > self.radius || dist < 1e-6 { return Vec3::ZERO; }
151
152        let radial_dir  = radial_vec / dist;
153        let tangent_dir = self.axis.cross(radial_dir).normalize_or_zero();
154        let t           = dist / self.radius;
155        let factor      = self.falloff.factor(t, dist, self.radius);
156
157        let tangent  = tangent_dir * (self.strength * factor);
158        let radial   = radial_dir  * (self.radial_pull * factor);
159        let axial    = self.axis   * (self.axial_pull * factor);
160
161        tangent + radial + axial
162    }
163}
164
165// ─── Turbulence (Perlin-like) ─────────────────────────────────────────────────
166
167/// Pseudo-random turbulence using a value-noise approach on a 3D grid.
168#[derive(Debug, Clone)]
169pub struct TurbulenceField {
170    pub strength:      f32,
171    pub frequency:     f32,    // spatial frequency of noise
172    pub time_speed:    f32,    // how fast the noise evolves
173    pub octaves:       u32,    // noise octave count
174    pub lacunarity:    f32,    // frequency multiplier per octave
175    pub persistence:   f32,    // amplitude multiplier per octave
176    pub radius:        f32,    // 0 = infinite
177    pub position:      Vec3,
178    time:              f32,
179}
180
181impl TurbulenceField {
182    pub fn new(strength: f32, frequency: f32) -> Self {
183        Self {
184            strength, frequency, time_speed: 0.5,
185            octaves: 3, lacunarity: 2.0, persistence: 0.5,
186            radius: 0.0, position: Vec3::ZERO, time: 0.0,
187        }
188    }
189
190    pub fn tick(&mut self, dt: f32) { self.time += dt * self.time_speed; }
191
192    pub fn acceleration(&self, p_pos: Vec3) -> Vec3 {
193        if self.radius > 0.0 && (p_pos - self.position).length() > self.radius {
194            return Vec3::ZERO;
195        }
196
197        // Sample noise at 3 offset positions to get a 3D vector
198        let t = self.time;
199        let nx = self.fbm(p_pos + Vec3::new(0.0,   100.5, 300.2), t);
200        let ny = self.fbm(p_pos + Vec3::new(100.1, 0.0,   200.3), t);
201        let nz = self.fbm(p_pos + Vec3::new(200.8, 300.1, 0.0),   t);
202
203        Vec3::new(nx, ny, nz) * self.strength
204    }
205
206    fn fbm(&self, pos: Vec3, time: f32) -> f32 {
207        let mut value = 0.0_f32;
208        let mut amplitude = 1.0_f32;
209        let mut frequency = self.frequency;
210        let mut max_value = 0.0_f32;
211
212        for _ in 0..self.octaves {
213            value     += self.value_noise_3d(pos * frequency, time) * amplitude;
214            max_value += amplitude;
215            amplitude *= self.persistence;
216            frequency *= self.lacunarity;
217        }
218
219        if max_value > 0.0 { value / max_value } else { 0.0 }
220    }
221
222    fn value_noise_3d(&self, pos: Vec3, time: f32) -> f32 {
223        // Integer cell
224        let ix = pos.x.floor() as i32;
225        let iy = pos.y.floor() as i32;
226        let iz = pos.z.floor() as i32;
227        let it = time.floor() as i32;
228
229        // Fractional
230        let fx = pos.x - ix as f32;
231        let fy = pos.y - iy as f32;
232        let fz = pos.z - iz as f32;
233
234        // Smooth interpolation
235        let ux = fx * fx * (3.0 - 2.0 * fx);
236        let uy = fy * fy * (3.0 - 2.0 * fy);
237        let uz = fz * fz * (3.0 - 2.0 * fz);
238
239        // 8-corner hash
240        let h = |x: i32, y: i32, z: i32, t: i32| -> f32 {
241            let mut s = (x as u64).wrapping_mul(1619)
242                .wrapping_add((y as u64).wrapping_mul(31337))
243                .wrapping_add((z as u64).wrapping_mul(6971))
244                .wrapping_add((t as u64).wrapping_mul(1013904223))
245                ^ 0x5851F42D4C957F2D;
246            s ^= s >> 33;
247            s = s.wrapping_mul(0xFF51AFD7ED558CCD);
248            s ^= s >> 33;
249            (s as f32 / u64::MAX as f32) * 2.0 - 1.0
250        };
251
252        let v000 = h(ix,   iy,   iz,   it);
253        let v100 = h(ix+1, iy,   iz,   it);
254        let v010 = h(ix,   iy+1, iz,   it);
255        let v110 = h(ix+1, iy+1, iz,   it);
256        let v001 = h(ix,   iy,   iz+1, it);
257        let v101 = h(ix+1, iy,   iz+1, it);
258        let v011 = h(ix,   iy+1, iz+1, it);
259        let v111 = h(ix+1, iy+1, iz+1, it);
260
261        let lerp = |a: f32, b: f32, t: f32| a + t * (b - a);
262
263        lerp(
264            lerp(lerp(v000, v100, ux), lerp(v010, v110, ux), uy),
265            lerp(lerp(v001, v101, ux), lerp(v011, v111, ux), uy),
266            uz,
267        )
268    }
269}
270
271// ─── Wind zone ────────────────────────────────────────────────────────────────
272
273/// A directional wind zone, optionally bounded to a box region.
274#[derive(Debug, Clone)]
275pub struct WindZone {
276    pub direction:    Vec3,   // normalised
277    pub speed:        f32,
278    pub gust_strength: f32,  // additional random gust amplitude
279    pub gust_frequency: f32, // Hz
280    pub bounds_min:   Option<Vec3>,
281    pub bounds_max:   Option<Vec3>,
282    time:             f32,
283    gust_phase:       f32,
284}
285
286impl WindZone {
287    pub fn new(direction: Vec3, speed: f32) -> Self {
288        Self {
289            direction: direction.normalize_or_zero(), speed,
290            gust_strength: speed * 0.3, gust_frequency: 0.5,
291            bounds_min: None, bounds_max: None,
292            time: 0.0, gust_phase: 0.0,
293        }
294    }
295
296    pub fn global(direction: Vec3, speed: f32) -> Self { Self::new(direction, speed) }
297
298    pub fn bounded(mut self, min: Vec3, max: Vec3) -> Self {
299        self.bounds_min = Some(min);
300        self.bounds_max = Some(max);
301        self
302    }
303
304    pub fn tick(&mut self, dt: f32) {
305        self.time += dt;
306        self.gust_phase = self.time * self.gust_frequency * std::f32::consts::TAU;
307    }
308
309    pub fn acceleration(&self, p_pos: Vec3) -> Vec3 {
310        if let (Some(bmin), Some(bmax)) = (self.bounds_min, self.bounds_max) {
311            if p_pos.x < bmin.x || p_pos.x > bmax.x ||
312               p_pos.y < bmin.y || p_pos.y > bmax.y ||
313               p_pos.z < bmin.z || p_pos.z > bmax.z {
314                return Vec3::ZERO;
315            }
316        }
317        let gust = self.gust_phase.sin() * self.gust_strength;
318        self.direction * (self.speed + gust)
319    }
320}
321
322// ─── Attractor / Repulsor ─────────────────────────────────────────────────────
323
324/// Simple point attractor or repulsor (alias over GravityWell with cleaner API).
325#[derive(Debug, Clone)]
326pub struct AttractorRepulsor {
327    pub position:  Vec3,
328    pub strength:  f32,
329    pub radius:    f32,
330    pub mode:      AttractorMode,
331    pub falloff:   FalloffMode,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq)]
335pub enum AttractorMode {
336    Attract,
337    Repel,
338    Orbit,
339}
340
341impl AttractorRepulsor {
342    pub fn attractor(position: Vec3, strength: f32, radius: f32) -> Self {
343        Self { position, strength, radius, mode: AttractorMode::Attract, falloff: FalloffMode::Linear }
344    }
345
346    pub fn repulsor(position: Vec3, strength: f32, radius: f32) -> Self {
347        Self { position, strength, radius, mode: AttractorMode::Repel, falloff: FalloffMode::Linear }
348    }
349
350    pub fn orbit(position: Vec3, strength: f32, radius: f32) -> Self {
351        Self { position, strength, radius, mode: AttractorMode::Orbit, falloff: FalloffMode::SmoothStep }
352    }
353
354    pub fn acceleration(&self, p_pos: Vec3) -> Vec3 {
355        let delta = self.position - p_pos;
356        let dist  = delta.length();
357        if dist > self.radius || dist < 1e-6 { return Vec3::ZERO; }
358        let dir    = delta / dist;
359        let t      = dist / self.radius;
360        let factor = self.falloff.factor(t, dist, self.radius);
361
362        match self.mode {
363            AttractorMode::Attract => dir * (self.strength * factor),
364            AttractorMode::Repel   => -dir * (self.strength * factor),
365            AttractorMode::Orbit   => {
366                let up = Vec3::Y;
367                let tangent = dir.cross(up).normalize_or_zero();
368                tangent * (self.strength * factor)
369            }
370        }
371    }
372}
373
374// ─── Drag ─────────────────────────────────────────────────────────────────────
375
376/// Linear drag: decelerates particles proportional to their speed.
377#[derive(Debug, Clone)]
378pub struct DragField {
379    pub coefficient:    f32,   // drag force = -velocity * coefficient
380    pub quadratic:      bool,  // if true: force = -velocity * |velocity| * coefficient
381    pub bounds_min:     Option<Vec3>,
382    pub bounds_max:     Option<Vec3>,
383}
384
385impl DragField {
386    pub fn new(coefficient: f32) -> Self {
387        Self { coefficient, quadratic: false, bounds_min: None, bounds_max: None }
388    }
389
390    pub fn quadratic(coefficient: f32) -> Self {
391        Self { quadratic: true, ..Self::new(coefficient) }
392    }
393
394    pub fn acceleration(&self, p_pos: Vec3, velocity: Vec3) -> Vec3 {
395        if let (Some(bmin), Some(bmax)) = (self.bounds_min, self.bounds_max) {
396            if p_pos.x < bmin.x || p_pos.x > bmax.x ||
397               p_pos.y < bmin.y || p_pos.y > bmax.y ||
398               p_pos.z < bmin.z || p_pos.z > bmax.z {
399                return Vec3::ZERO;
400            }
401        }
402        if self.quadratic {
403            -velocity * velocity.length() * self.coefficient
404        } else {
405            -velocity * self.coefficient
406        }
407    }
408}
409
410// ─── Buoyancy ─────────────────────────────────────────────────────────────────
411
412/// Buoyancy force: applies upward lift inversely proportional to particle density.
413#[derive(Debug, Clone)]
414pub struct BuoyancyField {
415    /// Upward direction (usually +Y).
416    pub up:             Vec3,
417    /// Fluid density (e.g. air ~1.2 kg/m³, water ~1000).
418    pub fluid_density:  f32,
419    /// Gravity magnitude used for buoyancy calculation.
420    pub gravity:        f32,
421    /// Only apply below this height.
422    pub surface_height: Option<f32>,
423}
424
425impl BuoyancyField {
426    pub fn air(gravity: f32) -> Self {
427        Self { up: Vec3::Y, fluid_density: 1.2, gravity, surface_height: None }
428    }
429
430    pub fn water(gravity: f32, surface_y: f32) -> Self {
431        Self { up: Vec3::Y, fluid_density: 1000.0, gravity, surface_height: Some(surface_y) }
432    }
433
434    pub fn smoke_in_air() -> Self {
435        // Smoke particles are lighter than air — they rise
436        Self { up: Vec3::Y, fluid_density: 1.8, gravity: 9.81, surface_height: None }
437    }
438
439    /// Buoyant acceleration for a particle with given mass and volume (estimated from size).
440    pub fn acceleration(&self, p_pos: Vec3, mass: f32, size: f32) -> Vec3 {
441        if let Some(sy) = self.surface_height {
442            if p_pos.y > sy { return Vec3::ZERO; }
443        }
444        // Volume ≈ sphere of radius size/2
445        let radius = size * 0.5;
446        let volume = (4.0 / 3.0) * std::f32::consts::PI * radius * radius * radius;
447        let buoyant_force = self.fluid_density * volume * self.gravity;
448        let weight        = mass * self.gravity;
449        let net_force     = buoyant_force - weight;
450        self.up * (net_force / mass.max(1e-6))
451    }
452}
453
454// ─── Composed force field ──────────────────────────────────────────────────────
455
456/// A single force field entry in the world, combining a field type with metadata.
457#[derive(Debug, Clone)]
458pub struct ForceField {
459    pub id:       ForceFieldId,
460    pub enabled:  bool,
461    pub strength_scale: f32,
462    pub tag_mask: TagMask,
463    pub kind:     ForceFieldKind,
464    pub priority: i32,
465}
466
467/// The concrete force implementation.
468#[derive(Debug, Clone)]
469pub enum ForceFieldKind {
470    GravityWell(GravityWell),
471    Vortex(VortexField),
472    Turbulence(TurbulenceField),
473    Wind(WindZone),
474    Attractor(AttractorRepulsor),
475    Drag(DragField),
476    Buoyancy(BuoyancyField),
477    /// Constant global gravity.
478    Gravity { acceleration: Vec3 },
479    /// A custom force defined by a coefficient table sampled over distance.
480    Spline {
481        position: Vec3,
482        axis:     Vec3,
483        radius:   f32,
484        /// (normalised_distance, force_magnitude) keyframes.
485        curve:    Vec<(f32, f32)>,
486    },
487}
488
489impl ForceField {
490    pub fn new(id: ForceFieldId, kind: ForceFieldKind) -> Self {
491        Self { id, enabled: true, strength_scale: 1.0, tag_mask: TagMask::All, kind, priority: 0 }
492    }
493
494    pub fn with_tag_mask(mut self, mask: TagMask)   -> Self { self.tag_mask = mask; self }
495    pub fn with_scale(mut self, s: f32)              -> Self { self.strength_scale = s; self }
496    pub fn with_priority(mut self, p: i32)           -> Self { self.priority = p; self }
497    pub fn disabled(mut self) -> Self { self.enabled = false; self }
498
499    /// Compute the acceleration this field imparts on a particle (per unit mass, i.e. force/mass).
500    pub fn apply(&self, particle: &Particle) -> Vec3 {
501        if !self.enabled { return Vec3::ZERO; }
502        if !self.tag_mask.matches(particle.tag) { return Vec3::ZERO; }
503
504        let raw = match &self.kind {
505            ForceFieldKind::GravityWell(gw)  => gw.acceleration(particle.position),
506            ForceFieldKind::Vortex(vx)       => vx.acceleration(particle.position),
507            ForceFieldKind::Turbulence(tb)   => tb.acceleration(particle.position),
508            ForceFieldKind::Wind(wz)         => wz.acceleration(particle.position),
509            ForceFieldKind::Attractor(at)    => at.acceleration(particle.position),
510            ForceFieldKind::Drag(dr)         => dr.acceleration(particle.position, particle.velocity),
511            ForceFieldKind::Buoyancy(by)     => by.acceleration(particle.position, particle.mass, particle.size),
512            ForceFieldKind::Gravity { acceleration } => *acceleration,
513            ForceFieldKind::Spline { position, axis, radius, curve } => {
514                let delta = particle.position - *position;
515                let dist  = delta.length();
516                if dist > *radius || dist < 1e-6 || curve.is_empty() {
517                    Vec3::ZERO
518                } else {
519                    let t = dist / radius;
520                    let mag = sample_curve(curve, t);
521                    let dir = (*axis).normalize_or_zero();
522                    dir * mag
523                }
524            }
525        };
526
527        raw * self.strength_scale
528    }
529
530    pub fn tick(&mut self, dt: f32) {
531        match &mut self.kind {
532            ForceFieldKind::Turbulence(tb) => tb.tick(dt),
533            ForceFieldKind::Wind(wz)       => wz.tick(dt),
534            _ => {}
535        }
536    }
537
538    pub fn marks_for_death(&self, particle: &Particle) -> bool {
539        if let ForceFieldKind::GravityWell(gw) = &self.kind {
540            return gw.should_absorb(particle.position);
541        }
542        false
543    }
544}
545
546fn sample_curve(curve: &[(f32, f32)], t: f32) -> f32 {
547    if curve.len() == 1 { return curve[0].1; }
548    let i = curve.partition_point(|(ct, _)| *ct <= t);
549    if i == 0 { return curve[0].1; }
550    if i >= curve.len() { return curve.last().unwrap().1; }
551    let (t0, v0) = curve[i - 1];
552    let (t1, v1) = curve[i];
553    let f = (t - t0) / (t1 - t0).max(1e-6);
554    v0 + f * (v1 - v0)
555}
556
557// ─── Force composition ────────────────────────────────────────────────────────
558
559/// Blend mode when combining multiple force fields at the same location.
560#[derive(Debug, Clone, Copy, PartialEq)]
561pub enum ForceBlendMode {
562    /// Sum all forces (default physical behaviour).
563    Additive,
564    /// Use the strongest single force.
565    Override,
566    /// Multiply forces together (chaining effect).
567    Multiply,
568    /// Average all forces.
569    Average,
570}
571
572/// A group of force fields evaluated together with a blend mode.
573#[derive(Debug, Clone)]
574pub struct ForceComposite {
575    pub fields:     Vec<ForceField>,
576    pub blend_mode: ForceBlendMode,
577    pub global_scale: f32,
578}
579
580impl ForceComposite {
581    pub fn new() -> Self {
582        Self { fields: Vec::new(), blend_mode: ForceBlendMode::Additive, global_scale: 1.0 }
583    }
584
585    pub fn with_blend(mut self, mode: ForceBlendMode) -> Self { self.blend_mode = mode; self }
586
587    pub fn add(&mut self, field: ForceField) { self.fields.push(field); }
588    pub fn remove(&mut self, id: ForceFieldId) { self.fields.retain(|f| f.id != id); }
589    pub fn get_mut(&mut self, id: ForceFieldId) -> Option<&mut ForceField> {
590        self.fields.iter_mut().find(|f| f.id == id)
591    }
592
593    pub fn tick(&mut self, dt: f32) {
594        for f in &mut self.fields { f.tick(dt); }
595    }
596
597    /// Compute the net acceleration for a particle from all fields.
598    pub fn net_acceleration(&self, particle: &Particle) -> Vec3 {
599        let enabled: Vec<&ForceField> = self.fields.iter().filter(|f| f.enabled).collect();
600        if enabled.is_empty() { return Vec3::ZERO; }
601
602        let result = match self.blend_mode {
603            ForceBlendMode::Additive => {
604                enabled.iter().map(|f| f.apply(particle)).fold(Vec3::ZERO, |a, b| a + b)
605            }
606            ForceBlendMode::Override => {
607                enabled.iter()
608                    .map(|f| f.apply(particle))
609                    .max_by(|a, b| a.length_squared().partial_cmp(&b.length_squared()).unwrap())
610                    .unwrap_or(Vec3::ZERO)
611            }
612            ForceBlendMode::Multiply => {
613                enabled.iter().map(|f| f.apply(particle))
614                    .fold(Vec3::ONE, |a, b| Vec3::new(a.x * b.x, a.y * b.y, a.z * b.z))
615            }
616            ForceBlendMode::Average => {
617                let sum = enabled.iter().map(|f| f.apply(particle)).fold(Vec3::ZERO, |a, b| a + b);
618                sum / enabled.len() as f32
619            }
620        };
621
622        result * self.global_scale
623    }
624
625    /// Apply all forces to a particle, accumulating into its acceleration.
626    pub fn apply_to_particle(&self, particle: &mut Particle) {
627        particle.acceleration += self.net_acceleration(particle);
628    }
629
630    /// Apply forces to a whole slice of particles and remove absorbed ones.
631    pub fn apply_and_cull(&self, particles: &mut Vec<Particle>) {
632        for p in particles.iter_mut() {
633            p.acceleration += self.net_acceleration(p);
634        }
635        // Remove particles absorbed by a gravity well
636        particles.retain(|p| {
637            !self.fields.iter().any(|f| f.marks_for_death(p))
638        });
639    }
640}
641
642impl Default for ForceComposite {
643    fn default() -> Self { Self::new() }
644}
645
646// ─── Force field world manager ────────────────────────────────────────────────
647
648/// Global registry for all force fields active in the world.
649pub struct ForceFieldWorld {
650    pub composite: ForceComposite,
651    next_id:       u32,
652    /// Gravity constant applied to all particles unless overridden.
653    pub gravity:   Vec3,
654    pub gravity_enabled: bool,
655}
656
657impl ForceFieldWorld {
658    pub fn new() -> Self {
659        Self {
660            composite: ForceComposite::new(),
661            next_id:   1,
662            gravity:   Vec3::new(0.0, -9.81, 0.0),
663            gravity_enabled: true,
664        }
665    }
666
667    fn alloc_id(&mut self) -> ForceFieldId {
668        let id = ForceFieldId(self.next_id);
669        self.next_id += 1;
670        id
671    }
672
673    pub fn add_gravity_well(&mut self, pos: Vec3, strength: f32, radius: f32) -> ForceFieldId {
674        let id = self.alloc_id();
675        self.composite.add(ForceField::new(id, ForceFieldKind::GravityWell(GravityWell::new(pos, strength, radius))));
676        id
677    }
678
679    pub fn add_vortex(&mut self, pos: Vec3, axis: Vec3, strength: f32, radius: f32) -> ForceFieldId {
680        let id = self.alloc_id();
681        self.composite.add(ForceField::new(id, ForceFieldKind::Vortex(VortexField::new(pos, axis, strength, radius))));
682        id
683    }
684
685    pub fn add_turbulence(&mut self, strength: f32, frequency: f32) -> ForceFieldId {
686        let id = self.alloc_id();
687        self.composite.add(ForceField::new(id, ForceFieldKind::Turbulence(TurbulenceField::new(strength, frequency))));
688        id
689    }
690
691    pub fn add_wind(&mut self, direction: Vec3, speed: f32) -> ForceFieldId {
692        let id = self.alloc_id();
693        self.composite.add(ForceField::new(id, ForceFieldKind::Wind(WindZone::new(direction, speed))));
694        id
695    }
696
697    pub fn add_attractor(&mut self, pos: Vec3, strength: f32, radius: f32) -> ForceFieldId {
698        let id = self.alloc_id();
699        self.composite.add(ForceField::new(id, ForceFieldKind::Attractor(AttractorRepulsor::attractor(pos, strength, radius))));
700        id
701    }
702
703    pub fn add_repulsor(&mut self, pos: Vec3, strength: f32, radius: f32) -> ForceFieldId {
704        let id = self.alloc_id();
705        self.composite.add(ForceField::new(id, ForceFieldKind::Attractor(AttractorRepulsor::repulsor(pos, strength, radius))));
706        id
707    }
708
709    pub fn add_drag(&mut self, coefficient: f32) -> ForceFieldId {
710        let id = self.alloc_id();
711        self.composite.add(ForceField::new(id, ForceFieldKind::Drag(DragField::new(coefficient))));
712        id
713    }
714
715    pub fn add_buoyancy(&mut self, gravity: f32) -> ForceFieldId {
716        let id = self.alloc_id();
717        self.composite.add(ForceField::new(id, ForceFieldKind::Buoyancy(BuoyancyField::air(gravity))));
718        id
719    }
720
721    pub fn add_field(&mut self, kind: ForceFieldKind) -> ForceFieldId {
722        let id = self.alloc_id();
723        self.composite.add(ForceField::new(id, kind));
724        id
725    }
726
727    pub fn remove_field(&mut self, id: ForceFieldId) {
728        self.composite.remove(id);
729    }
730
731    pub fn get_mut(&mut self, id: ForceFieldId) -> Option<&mut ForceField> {
732        self.composite.get_mut(id)
733    }
734
735    pub fn tick(&mut self, dt: f32) {
736        self.composite.tick(dt);
737    }
738
739    pub fn apply_to_particles(&self, particles: &mut Vec<Particle>) {
740        // Apply global gravity first
741        if self.gravity_enabled {
742            for p in particles.iter_mut() {
743                p.acceleration += self.gravity;
744            }
745        }
746        self.composite.apply_and_cull(particles);
747    }
748
749    pub fn field_count(&self) -> usize { self.composite.fields.len() }
750}
751
752impl Default for ForceFieldWorld {
753    fn default() -> Self { Self::new() }
754}
755
756// ─── Preset helpers ───────────────────────────────────────────────────────────
757
758/// Convenience constructors for common force-field scenarios.
759pub struct ForcePresets;
760
761impl ForcePresets {
762    /// Standard Earth gravity downward.
763    pub fn earth_gravity() -> ForceFieldKind {
764        ForceFieldKind::Gravity { acceleration: Vec3::new(0.0, -9.81, 0.0) }
765    }
766
767    /// Low gravity (moon-like).
768    pub fn moon_gravity() -> ForceFieldKind {
769        ForceFieldKind::Gravity { acceleration: Vec3::new(0.0, -1.62, 0.0) }
770    }
771
772    /// Anti-gravity / upward lift field.
773    pub fn anti_gravity(strength: f32) -> ForceFieldKind {
774        ForceFieldKind::Gravity { acceleration: Vec3::new(0.0, strength, 0.0) }
775    }
776
777    /// Explosion radial blast: repulsor at centre.
778    pub fn explosion_blast(center: Vec3, strength: f32, radius: f32) -> ForceFieldKind {
779        ForceFieldKind::Attractor(AttractorRepulsor::repulsor(center, strength, radius))
780    }
781
782    /// Black-hole pull.
783    pub fn black_hole(center: Vec3, strength: f32, event_horizon: f32) -> ForceFieldKind {
784        ForceFieldKind::GravityWell(GravityWell {
785            position: center, strength, radius: strength * 5.0,
786            falloff: FalloffMode::InverseSquare { min_dist: 0.01 },
787            repulsive: false,
788            absorb_radius: event_horizon,
789        })
790    }
791
792    /// Gentle ambient turbulence (e.g. heat shimmer).
793    pub fn heat_shimmer() -> ForceFieldKind {
794        ForceFieldKind::Turbulence(TurbulenceField {
795            strength: 0.8, frequency: 0.5, time_speed: 0.3,
796            octaves: 2, lacunarity: 2.0, persistence: 0.4,
797            radius: 0.0, position: Vec3::ZERO, time: 0.0,
798        })
799    }
800
801    /// Outdoor wind with gusts.
802    pub fn outdoor_wind(direction: Vec3, base_speed: f32) -> ForceFieldKind {
803        ForceFieldKind::Wind(WindZone {
804            direction: direction.normalize_or_zero(), speed: base_speed,
805            gust_strength: base_speed * 0.4, gust_frequency: 0.3,
806            bounds_min: None, bounds_max: None,
807            time: 0.0, gust_phase: 0.0,
808        })
809    }
810
811    /// Air resistance for fast-moving projectiles.
812    pub fn air_resistance() -> ForceFieldKind {
813        ForceFieldKind::Drag(DragField::quadratic(0.05))
814    }
815
816    /// Tornado centred at a point.
817    pub fn tornado(center: Vec3, strength: f32, radius: f32) -> ForceFieldKind {
818        ForceFieldKind::Vortex(VortexField::tornado(center, strength, radius))
819    }
820
821    /// Water current pushing in a direction within a volume.
822    pub fn water_current(direction: Vec3, speed: f32, bounds_min: Vec3, bounds_max: Vec3) -> ForceFieldKind {
823        ForceFieldKind::Wind(WindZone {
824            direction: direction.normalize_or_zero(), speed,
825            gust_strength: 0.0, gust_frequency: 0.0,
826            bounds_min: Some(bounds_min), bounds_max: Some(bounds_max),
827            time: 0.0, gust_phase: 0.0,
828        })
829    }
830
831    /// Smoke buoyancy (smoke rises, lighter than air).
832    pub fn smoke_buoyancy() -> ForceFieldKind {
833        ForceFieldKind::Buoyancy(BuoyancyField::smoke_in_air())
834    }
835}
836
837// ─── Force debug info ─────────────────────────────────────────────────────────
838
839/// Debug information about forces acting on a particle at a given point.
840#[derive(Debug, Clone)]
841pub struct ForceDebugSample {
842    pub position:     Vec3,
843    pub total_force:  Vec3,
844    pub per_field:    Vec<(ForceFieldId, Vec3)>,
845}
846
847impl ForceFieldWorld {
848    /// Sample the force at a given position with a synthetic particle.
849    pub fn debug_sample(&self, pos: Vec3) -> ForceDebugSample {
850        let test = Particle {
851            id: 0, position: pos, velocity: Vec3::ZERO, acceleration: Vec3::ZERO,
852            color: Vec4::ONE, size: 0.1, rotation: 0.0, angular_vel: 0.0,
853            age: 0.0, lifetime: 1.0, mass: 1.0,
854            tag: ParticleTag::ALL,
855            emitter_id: 0, custom: [0.0; 4],
856        };
857
858        let mut per_field = Vec::new();
859        let mut total = Vec3::ZERO;
860
861        if self.gravity_enabled {
862            total += self.gravity;
863        }
864
865        for f in &self.composite.fields {
866            let acc = f.apply(&test);
867            per_field.push((f.id, acc));
868            total += acc;
869        }
870
871        ForceDebugSample { position: pos, total_force: total, per_field }
872    }
873}