Skip to main content

alice_physics/
force.rs

1//! Custom Force Fields and Gravity
2//!
3//! Apply custom forces, directional gravity, point gravity, wind, drag,
4//! and other force fields to rigid bodies.
5
6use crate::math::{Fix128, Vec3Fix};
7use crate::solver::RigidBody;
8
9#[cfg(not(feature = "std"))]
10use alloc::vec::Vec;
11
12/// Force field type
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ForceField {
15    /// Constant directional force (e.g., wind)
16    Directional {
17        /// Force direction (normalized)
18        direction: Vec3Fix,
19        /// Force magnitude
20        strength: Fix128,
21    },
22
23    /// Point attractor/repulsor (gravity well)
24    Point {
25        /// Attractor center position
26        center: Vec3Fix,
27        /// Attraction strength
28        strength: Fix128,
29        /// If true, force pushes away from center (explosion)
30        repulsive: bool,
31        /// Maximum force (prevents singularity near center)
32        max_force: Fix128,
33    },
34
35    /// Linear drag (velocity-proportional resistance)
36    Drag {
37        /// Drag coefficient
38        coefficient: Fix128,
39    },
40
41    /// Buoyancy (upward force below a surface level)
42    Buoyancy {
43        /// Water surface Y coordinate
44        surface_y: Fix128,
45        /// Fluid density
46        density: Fix128,
47        /// Fluid drag coefficient
48        drag: Fix128,
49    },
50
51    /// Vortex (rotational force around an axis)
52    Vortex {
53        /// Vortex center position
54        center: Vec3Fix,
55        /// Vortex rotation axis
56        axis: Vec3Fix,
57        /// Rotational strength
58        strength: Fix128,
59        /// Radius beyond which force falls off
60        falloff_radius: Fix128,
61    },
62
63    /// Explosion (radial impulse with distance falloff)
64    ///
65    /// Force: `strength * (1 - dist/radius)^falloff_power` directed away
66    /// from `center`. Zero force beyond `radius`.
67    Explosion {
68        /// Explosion center position
69        center: Vec3Fix,
70        /// Peak force strength at center
71        strength: Fix128,
72        /// Maximum blast radius (zero force beyond this distance)
73        radius: Fix128,
74        /// Falloff exponent (1 = linear, 2 = quadratic, etc.)
75        falloff_power: Fix128,
76    },
77
78    /// Magnetic dipole field approximation
79    ///
80    /// Simplified dipole model: `F = -strength / r^3 * (r_hat . m_hat) * m_hat`,
81    /// with `r_hat` pointing from `position` to the body. The force lies along
82    /// the dipole axis (`moment`); for a positive `strength` a body on the axis
83    /// is pulled toward the dipole from either side, and the force vanishes in
84    /// the equatorial plane. Only the direction of `moment` is used.
85    Magnetic {
86        /// Dipole position in world space
87        position: Vec3Fix,
88        /// Dipole moment direction (normalized direction of the magnetic field)
89        moment: Vec3Fix,
90        /// Overall field strength multiplier
91        strength: Fix128,
92    },
93}
94
95/// A force field with optional body filter
96#[derive(Clone, Debug)]
97pub struct ForceFieldInstance {
98    /// The force field definition
99    pub field: ForceField,
100    /// If Some, only affects bodies in this list. If None, affects all bodies.
101    pub affected_bodies: Option<Vec<usize>>,
102    /// Whether this field is active
103    pub enabled: bool,
104}
105
106impl ForceFieldInstance {
107    /// Create a new force field instance affecting all bodies
108    #[inline]
109    #[must_use]
110    pub const fn new(field: ForceField) -> Self {
111        Self {
112            field,
113            affected_bodies: None,
114            enabled: true,
115        }
116    }
117
118    /// Restrict this field to only affect specific bodies
119    #[must_use]
120    pub fn with_affected_bodies(mut self, bodies: Vec<usize>) -> Self {
121        self.affected_bodies = Some(bodies);
122        self
123    }
124
125    /// Check if this field affects a given body
126    #[inline]
127    fn affects(&self, body_index: usize) -> bool {
128        if !self.enabled {
129            return false;
130        }
131        self.affected_bodies
132            .as_ref()
133            .is_none_or(|list| list.contains(&body_index))
134    }
135}
136
137/// `ForceField::Point` の `|delta|² ≥ 2⁶³` (距離 ≥ 2³¹·⁵ ≈ 3.04e9) の経路
138///
139/// `r²` を作らずに `(strength / r) / r` で大きさを、2 乗を経ない正規化で向きを
140/// 求める 範囲内の式と bit 一致はしないが、範囲内でこの経路は通らない
141///
142/// 距離そのものが表せない (`r ≥ 2⁶³`) 時は半分の長さで計算して 1/4 にする
143/// (`|F| < 2⁻⁶²` なので結果は 0 か数 ulp)
144fn point_force_beyond_square_range(
145    delta: Vec3Fix,
146    strength: Fix128,
147    repulsive: bool,
148    max_force: Fix128,
149) -> Vec3Fix {
150    let Some(direction) = delta.try_normalize_scaled() else {
151        return Vec3Fix::ZERO;
152    };
153    let force_mag = match delta.checked_length_scaled() {
154        Some(r) => (strength / r) / r,
155        None => {
156            let two = Fix128::from_int(2);
157            let half = Vec3Fix::new(delta.x / two, delta.y / two, delta.z / two);
158            match half.checked_length_scaled() {
159                Some(h) => ((strength / h) / h) / Fix128::from_int(4),
160                // |delta| ≤ √3·2⁶³ なので |delta / 2| < 2⁶³ で、ここには来ない
161                None => Fix128::ZERO,
162            }
163        }
164    };
165    // 範囲外では r² ≥ 2⁶³ > strength / max_force なので、近距離側の下限
166    // (`dist_sq_for_force`) は掛からない 上限の比較は範囲内と同じ
167    let clamped = if force_mag > max_force {
168        max_force
169    } else {
170        force_mag
171    };
172    if repulsive {
173        -direction * clamped
174    } else {
175        direction * clamped
176    }
177}
178
179/// Compute force from a force field on a body at a given position
180///
181/// The force depends only on the body's position and velocity; this is
182/// [`field_force_at`] at `body.position` / `body.velocity`.
183#[must_use]
184pub fn compute_force(field: &ForceField, body: &RigidBody) -> Vec3Fix {
185    field_force_at(field, body.position, body.velocity)
186}
187
188/// Force a field exerts on a point at `position` moving with `velocity`.
189///
190/// The single implementation of the [`ForceField`] laws: [`compute_force`]
191/// (rigid bodies) and [`crate::particle::ParticleSystem::apply_force_field`]
192/// (particles) both evaluate it. No law depends on the mass; the caller turns
193/// the force into an acceleration.
194#[must_use]
195pub fn field_force_at(field: &ForceField, position: Vec3Fix, velocity: Vec3Fix) -> Vec3Fix {
196    match field {
197        ForceField::Directional {
198            direction,
199            strength,
200        } => {
201            // `direction` is documented as normalized; a non-unit one is
202            // normalized here rather than scaling the force by its length
203            if direction.length_squared() == Fix128::ONE {
204                *direction * *strength
205            } else {
206                direction.normalize() * *strength
207            }
208        }
209
210        ForceField::Point {
211            center,
212            strength,
213            repulsive,
214            max_force,
215        } => {
216            let delta = *center - position;
217
218            if delta == Vec3Fix::ZERO {
219                return Vec3Fix::ZERO;
220            }
221
222            // |delta| ≥ 2^31.5 では `length_squared` が wrap し、力が 0 か上限値に
223            // 飛んでいた 範囲内は従来の式のまま (決定論の固定値と bit 互換)、
224            // 2 乗が範囲外の時だけ 2 乗を経ない式に切り替える
225            let Some(dist_sq) = delta.checked_length_squared() else {
226                return point_force_beyond_square_range(delta, *strength, *repulsive, *max_force);
227            };
228
229            // Squaring underflows `dist_sq` to exactly zero once
230            // |delta| < ~2.33e-10, even though `delta` itself is not the
231            // zero vector — `delta / dist_sq.sqrt()` would then silently
232            // give the zero direction instead of pointing toward the cap.
233            let direction = if dist_sq < Fix128::ONE {
234                // Below 1.0, `dist_sq` has progressively fewer representable
235                // bits (exactly zero once |delta| < ~2.33e-10, barely a bit
236                // or two of precision just above that), which would hand
237                // `sqrt` a result good to only a digit or so. Doubling delta
238                // (an exact bit shift, no rounding) until its squared length
239                // is comfortably large restores full precision; the scale
240                // factor cancels out exactly in the division below.
241                let mut scaled = delta;
242                let mut scaled_dist_sq = dist_sq;
243                for _ in 0..128 {
244                    if scaled_dist_sq >= Fix128::ONE {
245                        break;
246                    }
247                    scaled = scaled + scaled;
248                    scaled_dist_sq = scaled.length_squared();
249                }
250                scaled / scaled_dist_sq.sqrt()
251            } else {
252                delta / dist_sq.sqrt()
253            };
254
255            // Floor dist_sq at the value where strength / dist_sq == max_force
256            // *before* dividing, so the division itself cannot overflow
257            // Fix128 near the singularity. Computing the unclamped quotient
258            // first and comparing afterwards lets it wrap (e.g. d = 3.2e-9
259            // gives +8.4e18 — the wrong sign and far past the cap — instead
260            // of the cap).
261            let dist_sq_for_force = if max_force.is_zero() {
262                dist_sq
263            } else {
264                let floor = (*strength / *max_force).abs();
265                if dist_sq < floor {
266                    floor
267                } else {
268                    dist_sq
269                }
270            };
271
272            // Inverse-square law: F = strength / r^2
273            let force_mag = *strength / dist_sq_for_force;
274            let clamped = if force_mag > *max_force {
275                *max_force
276            } else {
277                force_mag
278            };
279
280            if *repulsive {
281                -direction * clamped
282            } else {
283                direction * clamped
284            }
285        }
286
287        ForceField::Drag { coefficient } => {
288            let speed_sq = velocity.length_squared();
289            if speed_sq.is_zero() {
290                return Vec3Fix::ZERO;
291            }
292            let speed = speed_sq.sqrt();
293            let drag_dir = velocity / speed;
294            -drag_dir * (*coefficient * speed)
295        }
296
297        ForceField::Buoyancy {
298            surface_y,
299            density,
300            drag,
301        } => {
302            let depth = *surface_y - position.y;
303            if depth <= Fix128::ZERO {
304                return Vec3Fix::ZERO;
305            }
306
307            // Buoyancy force proportional to submerged depth
308            let buoyancy = Vec3Fix::new(Fix128::ZERO, *density * depth, Fix128::ZERO);
309
310            // Water drag
311            let water_drag = if velocity.length_squared().is_zero() {
312                Vec3Fix::ZERO
313            } else {
314                velocity * (-*drag)
315            };
316
317            buoyancy + water_drag
318        }
319
320        ForceField::Vortex {
321            center,
322            axis,
323            strength,
324            falloff_radius,
325        } => {
326            let delta = position - *center;
327
328            // Project delta onto plane perpendicular to axis
329            let axis_norm = axis.normalize();
330            let along_axis = axis_norm * delta.dot(axis_norm);
331            let radial = delta - along_axis;
332            let dist = radial.length();
333
334            if dist.is_zero() || falloff_radius.is_zero() {
335                return Vec3Fix::ZERO;
336            }
337
338            // Tangent direction (cross product of axis and radial)
339            let tangent = axis_norm.cross(radial.normalize());
340
341            // Falloff: linear decrease beyond falloff_radius
342            let falloff = if dist < *falloff_radius {
343                Fix128::ONE
344            } else {
345                *falloff_radius / dist
346            };
347
348            tangent * (*strength * falloff)
349        }
350
351        ForceField::Explosion {
352            center,
353            strength,
354            radius,
355            falloff_power,
356        } => {
357            let delta = position - *center;
358            let dist_sq = delta.length_squared();
359
360            if dist_sq.is_zero() {
361                return Vec3Fix::ZERO;
362            }
363
364            let dist = dist_sq.sqrt();
365
366            // Beyond radius: zero force
367            if dist >= *radius {
368                return Vec3Fix::ZERO;
369            }
370
371            let direction = delta / dist;
372
373            // Falloff: (1 - dist/radius)^falloff_power
374            let ratio = Fix128::ONE - dist / *radius;
375
376            // `ratio^power` by `Fix128::powf_pos`: a whole power up to 64 is
377            // left-to-right multiplication (the same steps as before), a
378            // fractional part follows the documented formula, and a large
379            // power takes O(log n) squarings (the loop used to truncate 0.5 to
380            // 0 and run 2e9 times for 2e9). Power 0 is a constant force within
381            // the radius.
382            let power = if falloff_power.is_negative() {
383                Fix128::ZERO
384            } else {
385                *falloff_power
386            };
387            let falloff = ratio.powf_pos(power);
388
389            direction * (*strength * falloff)
390        }
391
392        ForceField::Magnetic {
393            position: dipole,
394            moment,
395            strength,
396        } => {
397            let delta = position - *dipole;
398            let dist_sq = delta.length_squared();
399
400            if delta == Vec3Fix::ZERO {
401                return Vec3Fix::ZERO;
402            }
403
404            let dist = dist_sq.sqrt();
405
406            // Force magnitude: strength / r^3, floored at the r^3 that would
407            // make the quotient reach a saturation cap well inside Fix128's
408            // range. Computing r^3 directly as `dist_sq * dist` underflows to
409            // exactly zero once d^3 drops below the 2^-64 resolution floor
410            // (unlike the Point field's `strength / r^2`, which has an
411            // explicit `max_force`, Magnetic has no cap at all) — dividing by
412            // that zero then collapsed the force straight back down to zero
413            // right where it should instead saturate near its maximum.
414            //
415            // The cap is the true representable maximum divided down by a
416            // generous margin (2^10), not the maximum itself: `r_cubed_floor`
417            // below is computed by one Fix128 division (which truncates) and
418            // `force_mag` by another, so round-tripping through the exact
419            // maximum can overshoot it by a rounding unit and wrap — turning
420            // a saturated-but-finite force into a huge negative one. The
421            // margin absorbs that without changing the "effectively capped"
422            // behavior the oracle checks for.
423            let r_cubed = dist_sq * dist;
424            let saturation_cap = Fix128::from_raw(i64::MAX, u64::MAX) / Fix128::from_int(1024);
425            let r_cubed_floor = strength.abs() / saturation_cap;
426            let r_cubed = if r_cubed < r_cubed_floor {
427                r_cubed_floor
428            } else {
429                r_cubed
430            };
431
432            let force_mag = *strength / r_cubed;
433
434            // Force direction along dipole moment axis
435            let moment_dir = moment.normalize();
436
437            // cos(theta) between delta and the dipole axis (the "alignment"
438            // below, divided by the true distance). `dist_sq.sqrt()` loses
439            // relative precision once `dist_sq` is close to the 2^-64
440            // resolution floor, and multiplying that imprecision into a
441            // `force_mag` already near Fix128's maximum magnitude turns a
442            // few-ppm ratio error into an absolute swing large enough to
443            // break monotonicity as d shrinks (two capped distances that
444            // should give the identical saturated force instead differ by
445            // ~1e10 out of ~9.2e18). Doubling delta (exact bit shifts, no
446            // rounding) until its squared length is comfortably large
447            // restores full precision before forming the ratio; the scale
448            // factor cancels out exactly between the dot product and the
449            // distance it is divided by.
450            let (delta_p, dist_sq_p) = if dist_sq < Fix128::ONE {
451                let mut scaled = delta;
452                let mut scaled_dist_sq = dist_sq;
453                for _ in 0..128 {
454                    if scaled_dist_sq >= Fix128::ONE {
455                        break;
456                    }
457                    scaled = scaled + scaled;
458                    scaled_dist_sq = scaled.length_squared();
459                }
460                (scaled, scaled_dist_sq)
461            } else {
462                (delta, dist_sq)
463            };
464            let cos_theta = delta_p.dot(moment_dir) / dist_sq_p.sqrt();
465
466            // Simplified dipole: force along moment direction, magnitude ~ 1/r^3
467            // In a full dipole model the force depends on angle; here we project
468            // the displacement onto the dipole axis for a directional bias.
469            // If body is along the dipole axis, it is attracted; perpendicular = weaker.
470            // Simplified: force = -strength / r^3 * dot(r_hat, m_hat) * m_hat
471            // with r_hat pointing from the dipole to the body, so for a positive
472            // strength the force on either side of the dipole points back toward
473            // it, which matches a soft magnetic body drawn into the stronger field
474            // on the axis. Off the axis it is a model, not the dipole field: it is
475            // zero in the equatorial plane (a real dipole pushes a parallel one
476            // outward there) and at 45° its axial part has the opposite sign.
477            let signed_mag = -(force_mag * cos_theta);
478
479            moment_dir * signed_mag
480        }
481    }
482}
483
484/// Apply all force fields to all bodies for one timestep
485pub fn apply_force_fields(fields: &[ForceFieldInstance], bodies: &mut [RigidBody], dt: Fix128) {
486    for (body_idx, body) in bodies.iter_mut().enumerate() {
487        if body.is_static() {
488            continue;
489        }
490
491        body.velocity = force_field_velocity(fields, body_idx, body, dt);
492    }
493}
494
495/// The velocity [`apply_force_fields`] gives body `body_idx` (`body` itself is
496/// not changed): `v + (Σ F · inv_mass) · dt` over the fields that affect it.
497#[must_use]
498pub(crate) fn force_field_velocity(
499    fields: &[ForceFieldInstance],
500    body_idx: usize,
501    body: &RigidBody,
502    dt: Fix128,
503) -> Vec3Fix {
504    let mut total_force = Vec3Fix::ZERO;
505
506    for field_inst in fields {
507        if !field_inst.affects(body_idx) {
508            continue;
509        }
510        total_force = total_force + compute_force(&field_inst.field, body);
511    }
512
513    // F = ma, a = F * inv_mass, v += a * dt
514    let acceleration = total_force * body.inv_mass;
515    body.velocity + acceleration * dt
516}
517
518#[cfg(all(test, feature = "std"))]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn test_directional_force() {
524        let field = ForceField::Directional {
525            direction: Vec3Fix::UNIT_Y,
526            strength: Fix128::from_int(10),
527        };
528        let body = RigidBody::new(Vec3Fix::ZERO, Fix128::ONE);
529        let force = compute_force(&field, &body);
530        assert_eq!(force.y.hi, 10);
531    }
532
533    #[test]
534    fn test_drag_force() {
535        let field = ForceField::Drag {
536            coefficient: Fix128::from_int(1),
537        };
538        let mut body = RigidBody::new(Vec3Fix::ZERO, Fix128::ONE);
539        body.velocity = Vec3Fix::from_int(10, 0, 0);
540
541        let force = compute_force(&field, &body);
542        // Drag opposes velocity
543        assert!(force.x < Fix128::ZERO, "Drag should oppose motion");
544    }
545
546    #[test]
547    fn test_point_gravity() {
548        let field = ForceField::Point {
549            center: Vec3Fix::ZERO,
550            strength: Fix128::from_int(100),
551            repulsive: false,
552            max_force: Fix128::from_int(1000),
553        };
554        let body = RigidBody::new(Vec3Fix::from_int(10, 0, 0), Fix128::ONE);
555        let force = compute_force(&field, &body);
556
557        // Should pull toward center
558        assert!(
559            force.x < Fix128::ZERO,
560            "Point gravity should pull toward center"
561        );
562    }
563
564    #[test]
565    fn test_buoyancy() {
566        let field = ForceField::Buoyancy {
567            surface_y: Fix128::from_int(5),
568            density: Fix128::from_int(10),
569            drag: Fix128::ONE,
570        };
571
572        // Body below surface
573        let body = RigidBody::new(Vec3Fix::from_int(0, 2, 0), Fix128::ONE);
574        let force = compute_force(&field, &body);
575        assert!(force.y > Fix128::ZERO, "Buoyancy should push up");
576
577        // Body above surface
578        let body_above = RigidBody::new(Vec3Fix::from_int(0, 10, 0), Fix128::ONE);
579        let force_above = compute_force(&field, &body_above);
580        assert!(
581            force_above.y.is_zero() && force_above.x.is_zero(),
582            "No buoyancy above surface"
583        );
584    }
585
586    #[test]
587    fn test_apply_force_fields() {
588        let fields = vec![ForceFieldInstance::new(ForceField::Directional {
589            direction: Vec3Fix::UNIT_X,
590            strength: Fix128::from_int(10),
591        })];
592
593        let mut bodies = vec![RigidBody::new(Vec3Fix::ZERO, Fix128::ONE)];
594
595        let dt = Fix128::from_ratio(1, 60);
596        apply_force_fields(&fields, &mut bodies, dt);
597
598        // Velocity should have increased in X
599        assert!(
600            bodies[0].velocity.x > Fix128::ZERO,
601            "Force should accelerate body"
602        );
603    }
604
605    #[test]
606    fn test_affected_bodies_filter() {
607        let field = ForceFieldInstance::new(ForceField::Directional {
608            direction: Vec3Fix::UNIT_X,
609            strength: Fix128::from_int(100),
610        })
611        .with_affected_bodies(vec![0]); // Only affects body 0
612
613        assert!(field.affects(0));
614        assert!(!field.affects(1));
615    }
616
617    #[test]
618    fn test_vortex() {
619        let field = ForceField::Vortex {
620            center: Vec3Fix::ZERO,
621            axis: Vec3Fix::UNIT_Y,
622            strength: Fix128::from_int(10),
623            falloff_radius: Fix128::from_int(100),
624        };
625
626        let body = RigidBody::new(Vec3Fix::from_int(5, 0, 0), Fix128::ONE);
627        let force = compute_force(&field, &body);
628
629        // Vortex should produce tangential force (Z direction for body at +X)
630        assert!(
631            force.z.abs() > Fix128::ZERO,
632            "Vortex should produce tangential force"
633        );
634    }
635
636    // --- Explosion tests ---
637
638    #[test]
639    fn test_explosion_pushes_outward() {
640        let field = ForceField::Explosion {
641            center: Vec3Fix::ZERO,
642            strength: Fix128::from_int(100),
643            radius: Fix128::from_int(20),
644            falloff_power: Fix128::ONE,
645        };
646        let body = RigidBody::new(Vec3Fix::from_int(5, 0, 0), Fix128::ONE);
647        let force = compute_force(&field, &body);
648
649        // Force should push away from center (positive X)
650        assert!(
651            force.x > Fix128::ZERO,
652            "Explosion should push body away from center"
653        );
654    }
655
656    #[test]
657    fn test_explosion_zero_beyond_radius() {
658        let field = ForceField::Explosion {
659            center: Vec3Fix::ZERO,
660            strength: Fix128::from_int(100),
661            radius: Fix128::from_int(10),
662            falloff_power: Fix128::ONE,
663        };
664        // Body at distance 15, radius is 10 => zero force
665        let body = RigidBody::new(Vec3Fix::from_int(15, 0, 0), Fix128::ONE);
666        let force = compute_force(&field, &body);
667
668        assert!(
669            force.x.is_zero() && force.y.is_zero() && force.z.is_zero(),
670            "No explosion force beyond radius"
671        );
672    }
673
674    #[test]
675    fn test_explosion_at_center() {
676        let field = ForceField::Explosion {
677            center: Vec3Fix::ZERO,
678            strength: Fix128::from_int(100),
679            radius: Fix128::from_int(10),
680            falloff_power: Fix128::ONE,
681        };
682        let body = RigidBody::new(Vec3Fix::ZERO, Fix128::ONE);
683        let force = compute_force(&field, &body);
684        assert!(
685            force.x.is_zero() && force.y.is_zero() && force.z.is_zero(),
686            "No force at explosion center (zero distance)"
687        );
688    }
689
690    #[test]
691    fn test_explosion_falloff() {
692        let field_linear = ForceField::Explosion {
693            center: Vec3Fix::ZERO,
694            strength: Fix128::from_int(100),
695            radius: Fix128::from_int(20),
696            falloff_power: Fix128::ONE,
697        };
698        let field_quadratic = ForceField::Explosion {
699            center: Vec3Fix::ZERO,
700            strength: Fix128::from_int(100),
701            radius: Fix128::from_int(20),
702            falloff_power: Fix128::from_int(2),
703        };
704
705        let body = RigidBody::new(Vec3Fix::from_int(10, 0, 0), Fix128::ONE);
706        let force_linear = compute_force(&field_linear, &body);
707        let force_quadratic = compute_force(&field_quadratic, &body);
708
709        // At dist=10, radius=20: ratio = 0.5
710        // linear: 0.5^1 = 0.5, quadratic: 0.5^2 = 0.25
711        // So quadratic force should be smaller
712        assert!(
713            force_quadratic.x < force_linear.x,
714            "Quadratic falloff should produce weaker force than linear at same distance"
715        );
716    }
717
718    #[test]
719    fn test_explosion_apply_to_body() {
720        let fields = vec![ForceFieldInstance::new(ForceField::Explosion {
721            center: Vec3Fix::ZERO,
722            strength: Fix128::from_int(1000),
723            radius: Fix128::from_int(50),
724            falloff_power: Fix128::ONE,
725        })];
726
727        let mut bodies = vec![RigidBody::new(Vec3Fix::from_int(5, 0, 0), Fix128::ONE)];
728        let dt = Fix128::from_ratio(1, 60);
729        apply_force_fields(&fields, &mut bodies, dt);
730
731        assert!(
732            bodies[0].velocity.x > Fix128::ZERO,
733            "Explosion should accelerate body away"
734        );
735    }
736
737    // --- Magnetic tests ---
738
739    #[test]
740    fn test_magnetic_along_dipole_axis() {
741        let field = ForceField::Magnetic {
742            position: Vec3Fix::ZERO,
743            moment: Vec3Fix::UNIT_X,
744            strength: Fix128::from_int(1000),
745        };
746        // Body along the dipole axis (+X)
747        let body = RigidBody::new(Vec3Fix::from_int(2, 0, 0), Fix128::ONE);
748        let force = compute_force(&field, &body);
749
750        // Should produce force along X axis (dipole moment direction)
751        assert!(
752            force.x.abs() > Fix128::ZERO,
753            "Magnetic force should exist along dipole axis"
754        );
755    }
756
757    #[test]
758    fn test_magnetic_perpendicular_to_dipole() {
759        let field = ForceField::Magnetic {
760            position: Vec3Fix::ZERO,
761            moment: Vec3Fix::UNIT_X,
762            strength: Fix128::from_int(1000),
763        };
764        // Body perpendicular to dipole axis (along Y)
765        let body = RigidBody::new(Vec3Fix::from_int(0, 5, 0), Fix128::ONE);
766        let force = compute_force(&field, &body);
767
768        // dot(delta, moment) = 0, so force should be zero
769        assert!(
770            force.x.is_zero() && force.y.is_zero() && force.z.is_zero(),
771            "No magnetic force perpendicular to dipole axis"
772        );
773    }
774
775    #[test]
776    fn test_magnetic_at_dipole_position() {
777        let field = ForceField::Magnetic {
778            position: Vec3Fix::ZERO,
779            moment: Vec3Fix::UNIT_Z,
780            strength: Fix128::from_int(100),
781        };
782        let body = RigidBody::new(Vec3Fix::ZERO, Fix128::ONE);
783        let force = compute_force(&field, &body);
784        assert!(
785            force.x.is_zero() && force.y.is_zero() && force.z.is_zero(),
786            "No force at the dipole location"
787        );
788    }
789
790    #[test]
791    fn test_magnetic_inverse_cube_falloff() {
792        let field = ForceField::Magnetic {
793            position: Vec3Fix::ZERO,
794            moment: Vec3Fix::UNIT_X,
795            strength: Fix128::from_int(10000),
796        };
797
798        let body_near = RigidBody::new(Vec3Fix::from_int(2, 0, 0), Fix128::ONE);
799        let body_far = RigidBody::new(Vec3Fix::from_int(4, 0, 0), Fix128::ONE);
800
801        let force_near = compute_force(&field, &body_near);
802        let force_far = compute_force(&field, &body_far);
803
804        // 1/r^3: at r=2 vs r=4, force ratio should be (4/2)^3 = 8
805        // force_near should be stronger than force_far
806        assert!(
807            force_near.x.abs() > force_far.x.abs(),
808            "Magnetic force should be stronger at closer distance (1/r^3 falloff)"
809        );
810    }
811
812    #[test]
813    fn test_magnetic_apply_to_body() {
814        let fields = vec![ForceFieldInstance::new(ForceField::Magnetic {
815            position: Vec3Fix::ZERO,
816            moment: Vec3Fix::UNIT_X,
817            strength: Fix128::from_int(10000),
818        })];
819
820        let mut bodies = vec![RigidBody::new(Vec3Fix::from_int(3, 0, 0), Fix128::ONE)];
821        let dt = Fix128::from_ratio(1, 60);
822        apply_force_fields(&fields, &mut bodies, dt);
823
824        assert!(
825            bodies[0].velocity.x.abs() > Fix128::ZERO,
826            "Magnetic field should accelerate body"
827        );
828    }
829
830    fn pow2(e: i32) -> Fix128 {
831        if e >= 0 {
832            Fix128::from_int(1_i64 << e)
833        } else {
834            Fix128::ONE / Fix128::from_int(1_i64 << -e)
835        }
836    }
837
838    fn body_at(x: Fix128) -> RigidBody {
839        RigidBody::new(Vec3Fix::new(x, Fix128::ZERO, Fix128::ZERO), Fix128::ONE)
840    }
841
842    fn point(strength: Fix128, repulsive: bool, max_force: Fix128) -> ForceField {
843        ForceField::Point {
844            center: Vec3Fix::ZERO,
845            strength,
846            repulsive,
847            max_force,
848        }
849    }
850
851    /// oracle: past the square range (`r = 2³²`, `r² = 2⁶⁴`) the point force is
852    /// still `s/r²` toward the centre: `2⁶²/2⁶⁴ = 1/4` along `−x` for a body at
853    /// `+x` (`+x` when repulsive), capped at `max_force = 1/8`.
854    #[test]
855    fn point_force_beyond_the_square_range() {
856        let b = body_at(pow2(32));
857        let s = pow2(62);
858        let quarter = Fix128::from_ratio(1, 4);
859        assert_eq!(
860            compute_force(&point(s, false, pow2(10)), &b),
861            Vec3Fix::new(-quarter, Fix128::ZERO, Fix128::ZERO)
862        );
863        assert_eq!(
864            compute_force(&point(s, true, pow2(10)), &b),
865            Vec3Fix::new(quarter, Fix128::ZERO, Fix128::ZERO)
866        );
867        assert_eq!(
868            compute_force(&point(s, false, Fix128::from_ratio(1, 8)), &b),
869            Vec3Fix::new(-Fix128::from_ratio(1, 8), Fix128::ZERO, Fix128::ZERO)
870        );
871    }
872
873    /// oracle: when even the distance does not fit (`|δ| = 3·2⁶¹·√3 > 2⁶³`)
874    /// the force `s/r² < 2⁶²/2¹²⁴` is below `2⁻⁶⁰` and points toward the
875    /// centre (no component away from it).
876    #[test]
877    fn point_force_when_the_distance_does_not_fit() {
878        let c = Fix128::from_int(3_i64 << 61);
879        let field = ForceField::Point {
880            center: Vec3Fix::new(c, c, c),
881            strength: pow2(62),
882            repulsive: false,
883            max_force: pow2(10),
884        };
885        let f = compute_force(&field, &body_at(Fix128::ZERO));
886        for v in [f.x, f.y, f.z] {
887            assert!(v >= Fix128::ZERO && v < pow2(-60), "{f:?}");
888        }
889    }
890
891    /// oracle: near the centre (`r = 2⁻²⁰`, `r² = 2⁻⁴⁰ < 1`) the direction is
892    /// still exactly `−x` and the force `s/r² = 2⁴⁰` for `s = 1` under a cap
893    /// of `2⁵⁰`; with a cap of `2¹⁰` the floor `s/cap = 2⁻¹⁰ > r²` makes it
894    /// exactly the cap; a cap of 0 gives no force; at the centre there is
895    /// none either.
896    #[test]
897    fn point_force_near_the_centre() {
898        let b = body_at(pow2(-20));
899        let on_x = |v: Fix128| Vec3Fix::new(v, Fix128::ZERO, Fix128::ZERO);
900        assert_eq!(
901            compute_force(&point(Fix128::ONE, false, pow2(50)), &b),
902            on_x(-pow2(40))
903        );
904        assert_eq!(
905            compute_force(&point(Fix128::ONE, false, pow2(10)), &b),
906            on_x(-pow2(10))
907        );
908        assert_eq!(
909            compute_force(&point(Fix128::ONE, false, Fix128::ZERO), &b),
910            Vec3Fix::ZERO
911        );
912        assert_eq!(
913            compute_force(&point(Fix128::ONE, false, pow2(10)), &body_at(Fix128::ZERO)),
914            Vec3Fix::ZERO
915        );
916    }
917
918    /// oracle: below the surface `y = 0` at depth 2 with density 3 the
919    /// buoyancy is `(0, 6, 0)`; moving at `(1, 0, 2)` with drag 1/2 adds
920    /// `−v/2`. A body on the vortex axis feels nothing; neither does one at
921    /// the centre of a magnet.
922    #[test]
923    fn buoyancy_drag_vortex_axis_and_magnet_centre() {
924        let mut b = RigidBody::new(Vec3Fix::from_int(0, -2, 0), Fix128::ONE);
925        b.velocity = Vec3Fix::from_int(1, 0, 2);
926        let water = ForceField::Buoyancy {
927            surface_y: Fix128::ZERO,
928            density: Fix128::from_int(3),
929            drag: Fix128::from_ratio(1, 2),
930        };
931        assert_eq!(
932            compute_force(&water, &b),
933            Vec3Fix::new(-Fix128::from_ratio(1, 2), Fix128::from_int(6), -Fix128::ONE)
934        );
935        let vortex = ForceField::Vortex {
936            center: Vec3Fix::ZERO,
937            axis: Vec3Fix::UNIT_Y,
938            strength: Fix128::ONE,
939            falloff_radius: Fix128::ONE,
940        };
941        assert_eq!(compute_force(&vortex, &b), Vec3Fix::ZERO);
942        let magnet = ForceField::Magnetic {
943            position: b.position,
944            moment: Vec3Fix::UNIT_X,
945            strength: Fix128::ONE,
946        };
947        assert_eq!(compute_force(&magnet, &b), Vec3Fix::ZERO);
948    }
949
950    /// oracle: on the dipole axis at `r = 2⁻¹⁰` (`r² < 1`) the magnetic force
951    /// is `-s/r³·cos θ` along the moment with `cos θ = 1`: `-2³⁰` for `s = 1`
952    /// (toward the dipole).
953    #[test]
954    fn magnetic_force_near_the_dipole() {
955        let magnet = ForceField::Magnetic {
956            position: Vec3Fix::ZERO,
957            moment: Vec3Fix::UNIT_X,
958            strength: Fix128::ONE,
959        };
960        let f = compute_force(&magnet, &body_at(pow2(-10)));
961        assert!(
962            (f.x.to_f64() / -1_073_741_824.0 - 1.0).abs() < 1e-12,
963            "{f:?}"
964        );
965        assert!(f.y.is_zero() && f.z.is_zero());
966    }
967}