Skip to main content

rustsim_core/
avoidance.rs

1//! Physical avoidance force primitives for pedestrian and agent dynamics.
2//!
3//! Provides reusable, pure-function force calculations based on the Social Force
4//! Model (Helbing & Molnar 1995; Helbing, Farkas & Vicsek 2000). These primitives
5//! can be composed by any agent step function that needs collision-avoidance behavior.
6//!
7//! # Unit contract
8//!
9//! All functions in this module (`desired_force_*`, `social_repulsion_*`,
10//! `wall_repulsion_*`) return **accelerations in m/s²**, not Newtons. The
11//! Helbing-calibrated parameters (`a_social`, `k_body`, `kappa_friction`,
12//! `a_wall`, `k_wall`, `kappa_wall`) are still declared in Newton-scale units
13//! matching the literature (e.g. Helbing, Farkas & Vicsek 2000 Table 1), but
14//! are divided internally by [`SocialForceParams::mass`] (default 80 kg per
15//! Helbing 2000). This keeps the parameter dashboard literature-compatible
16//! while making all force outputs dimensionally consistent with the
17//! `v += a * dt` integrator used by [`integrate_euler_2d`] /
18//! [`integrate_euler_3d`].
19//!
20//! For mixed populations (children ≈ 30 kg, adults ≈ 80 kg, equipped
21//! responders > 100 kg) override `mass` on the [`SocialForceParams`] struct;
22//! the returned accelerations scale as `1/mass` automatically.
23//!
24//! # Force components
25//!
26//! | Force | Description |
27//! |-------|-------------|
28//! | Desired | Drives agent toward its destination at desired speed |
29//! | Social repulsion | Exponential repulsion from nearby agents (psychological) |
30//! | Physical contact | Body compression + sliding friction when agents overlap |
31//! | Wall repulsion | Exponential repulsion from wall/obstacle segments |
32//! | Wall contact | Body compression + sliding friction when agent overlaps a wall |
33//!
34//! # References
35//!
36//! - Helbing, D. & Molnar, P. (1995). "Social force model for pedestrian dynamics."
37//!   Physical Review E, 51(5), 4282.
38//! - Helbing, D., Farkas, I. & Vicsek, T. (2000). "Simulating dynamical features
39//!   of escape panic." Nature, 407(6803), 487-490.
40
41// ---------------------------------------------------------------------------
42// Parameters
43// ---------------------------------------------------------------------------
44
45/// Parameters for the Social Force Model with physical contact forces.
46///
47/// Default values are from Helbing, Farkas & Vicsek (2000) Table 1.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct SocialForceParams {
50    /// Relaxation time tau (seconds). Controls how quickly the agent adjusts
51    /// its velocity toward the desired velocity. Smaller = more responsive.
52    pub tau: f64,
53
54    /// Social repulsion strength A (Newtons).
55    pub a_social: f64,
56
57    /// Social repulsion range B (meters). The exponential decay length.
58    pub b_social: f64,
59
60    /// Body compression coefficient k (kg/s^2). Activated on physical overlap.
61    pub k_body: f64,
62
63    /// Sliding friction coefficient kappa (kg/(m*s)). Tangential force on overlap.
64    pub kappa_friction: f64,
65
66    /// Wall repulsion strength A_wall (Newtons).
67    pub a_wall: f64,
68
69    /// Wall repulsion range B_wall (meters).
70    pub b_wall: f64,
71
72    /// Wall body compression coefficient (kg/s^2).
73    pub k_wall: f64,
74
75    /// Wall sliding friction coefficient (kg/(m*s)).
76    pub kappa_wall: f64,
77
78    /// Maximum allowed speed (m/s). Integration clamps speed to this value.
79    pub max_speed: f64,
80
81    /// Agent mass (kg). Used internally to convert the Helbing-calibrated
82    /// Newton-scale coefficients (`a_social`, `k_body`, `kappa_friction`,
83    /// `a_wall`, `k_wall`, `kappa_wall`) into accelerations (m/s²) so that
84    /// the unit contract of this module (see module docs) holds.
85    ///
86    /// Default: 80 kg, the value used by Helbing, Farkas & Vicsek (2000).
87    pub mass: f64,
88}
89
90impl Default for SocialForceParams {
91    /// Default parameters from Helbing, Farkas & Vicsek (2000) Table 1.
92    ///
93    /// `mass` defaults to 80 kg (same reference). All coefficients are
94    /// published in Newton-scale units and divided by `mass` internally
95    /// when force functions are evaluated, so outputs are in m/s².
96    fn default() -> Self {
97        Self {
98            tau: 0.5,
99            a_social: 2000.0,
100            b_social: 0.08,
101            k_body: 1.2e5,
102            kappa_friction: 2.4e5,
103            a_wall: 2000.0,
104            b_wall: 0.08,
105            k_wall: 1.2e5,
106            kappa_wall: 2.4e5,
107            max_speed: 2.5,
108            mass: 80.0,
109        }
110    }
111}
112
113/// A wall segment defined by two endpoints.
114///
115/// Wall segments are used for wall-repulsion force calculations. The closest
116/// point on the segment to the queried position is computed analytically.
117#[derive(Debug, Clone, Copy, PartialEq)]
118pub struct WallSegment {
119    /// Start point x.
120    pub x1: f64,
121    /// Start point y.
122    pub y1: f64,
123    /// End point x.
124    pub x2: f64,
125    /// End point y.
126    pub y2: f64,
127}
128
129impl WallSegment {
130    /// Create a new wall segment from `(x1, y1)` to `(x2, y2)`.
131    pub fn new(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
132        Self { x1, y1, x2, y2 }
133    }
134
135    /// Closest point on this wall segment to point `(px, py)`.
136    ///
137    /// Uses the standard parametric line clamped to `[0, 1]`.
138    pub fn closest_point(&self, px: f64, py: f64) -> (f64, f64) {
139        let dx = self.x2 - self.x1;
140        let dy = self.y2 - self.y1;
141        let len_sq = dx * dx + dy * dy;
142        if len_sq < 1e-12 {
143            return (self.x1, self.y1);
144        }
145        let t = ((px - self.x1) * dx + (py - self.y1) * dy) / len_sq;
146        let t = t.clamp(0.0, 1.0);
147        (self.x1 + t * dx, self.y1 + t * dy)
148    }
149}
150
151// ---------------------------------------------------------------------------
152// 2D force functions
153// ---------------------------------------------------------------------------
154
155/// Desired force driving an agent toward its destination in 2D.
156///
157/// `f_desired = (v0 * e_hat - v) / tau`
158///
159/// where `e_hat` is the unit vector toward the destination, `v0` is the desired
160/// speed, `v = (vx, vy)` is the current velocity, and `tau` is the relaxation
161/// time.
162///
163/// Returns `(fx, fy)`. If the agent is already at the destination (within
164/// `1e-12`), returns `(0, 0)`.
165#[allow(clippy::too_many_arguments)]
166pub fn desired_force_2d(
167    x: f64,
168    y: f64,
169    vx: f64,
170    vy: f64,
171    dest_x: f64,
172    dest_y: f64,
173    desired_speed: f64,
174    tau: f64,
175) -> (f64, f64) {
176    let dx = dest_x - x;
177    let dy = dest_y - y;
178    let dist = (dx * dx + dy * dy).sqrt();
179    if dist < 1e-12 {
180        return (0.0, 0.0);
181    }
182    let ex = dx / dist;
183    let ey = dy / dist;
184    let fx = (desired_speed * ex - vx) / tau;
185    let fy = (desired_speed * ey - vy) / tau;
186    (fx, fy)
187}
188
189/// Social repulsion + physical contact **acceleration** from a single neighbor in 2D.
190///
191/// Implements Helbing, Farkas & Vicsek (2000) Eq. 3, returning the
192/// mass-normalized result (m/s²) so it can be added directly to
193/// [`desired_force_2d`] and fed to [`integrate_euler_2d`]:
194///
195/// - Exponential social repulsion: `A * exp((r_ij - d) / B) * n_hat`
196/// - Body compression (overlap only): `k * g(r_ij - d) * n_hat`
197/// - Sliding friction (overlap only): `kappa * g(r_ij - d) * dv_t * t_hat`
198///
199/// where `r_ij = r_i + r_j` (sum of body radii), `d` is center-to-center
200/// distance, `n_hat` is the unit normal pointing from the neighbor to self,
201/// `g(x) = max(0, x)`, and `dv_t` is the tangential velocity difference.
202/// The Newton-scale coefficients are divided by `params.mass` before
203/// returning.
204///
205/// # Arguments
206///
207/// - `(x, y)` - current agent position
208/// - `(vx, vy)` - current agent velocity
209/// - `radius` - current agent body radius
210/// - `(nx, ny)` - neighbor position
211/// - `(nvx, nvy)` - neighbor velocity
212/// - `n_radius` - neighbor body radius
213/// - `params` - [`SocialForceParams`] (uses `mass` for normalization)
214///
215/// Returns `(ax, ay)` - acceleration (m/s²) on the current agent.
216#[allow(clippy::too_many_arguments)]
217pub fn social_repulsion_2d(
218    x: f64,
219    y: f64,
220    vx: f64,
221    vy: f64,
222    radius: f64,
223    nx: f64,
224    ny: f64,
225    nvx: f64,
226    nvy: f64,
227    n_radius: f64,
228    params: &SocialForceParams,
229) -> (f64, f64) {
230    let dx = x - nx;
231    let dy = y - ny;
232    let dist = (dx * dx + dy * dy).sqrt();
233    if dist < 1e-12 {
234        return (0.0, 0.0);
235    }
236
237    let r_ij = radius + n_radius;
238    // Positive when bodies overlap.
239    let overlap = r_ij - dist;
240
241    // Unit normal: neighbor -> self.
242    let ux = dx / dist;
243    let uy = dy / dist;
244
245    // Tangent (perpendicular to normal, 90 degrees counterclockwise).
246    let tx = -uy;
247    let ty = ux;
248
249    // Scale all Newton-scale coefficients by 1/mass so the accumulated
250    // output is an acceleration in m/s² (see module-level unit contract).
251    let inv_m = 1.0 / params.mass;
252
253    // Social repulsion (always active).
254    let f_social = params.a_social * (overlap / params.b_social).exp() * inv_m;
255    let mut fx = f_social * ux;
256    let mut fy = f_social * uy;
257
258    // Physical contact forces (only when overlapping).
259    if overlap > 0.0 {
260        // Body compression: k * overlap * n_hat.
261        fx += params.k_body * inv_m * overlap * ux;
262        fy += params.k_body * inv_m * overlap * uy;
263
264        // Sliding friction: kappa * overlap * dv_t * t_hat.
265        let dvx = nvx - vx;
266        let dvy = nvy - vy;
267        let dv_tangential = dvx * tx + dvy * ty;
268        fx += params.kappa_friction * inv_m * overlap * dv_tangential * tx;
269        fy += params.kappa_friction * inv_m * overlap * dv_tangential * ty;
270    }
271
272    (fx, fy)
273}
274
275/// Wall repulsion + physical contact **acceleration** from a single wall segment in 2D.
276///
277/// Same structure as agent-agent repulsion but uses the closest point on
278/// the wall segment instead of a neighbor center. Coefficients are divided
279/// by `params.mass` before returning, so the result is in m/s².
280///
281/// # Arguments
282///
283/// - `(x, y)` - agent position
284/// - `(vx, vy)` - agent velocity
285/// - `radius` - agent body radius
286/// - `wall` - wall segment
287/// - `params` - [`SocialForceParams`] (uses `mass` for normalization)
288///
289/// Returns `(ax, ay)` - acceleration (m/s²) on the agent.
290pub fn wall_repulsion_2d(
291    x: f64,
292    y: f64,
293    vx: f64,
294    vy: f64,
295    radius: f64,
296    wall: &WallSegment,
297    params: &SocialForceParams,
298) -> (f64, f64) {
299    let (cx, cy) = wall.closest_point(x, y);
300    let dx = x - cx;
301    let dy = y - cy;
302    let dist = (dx * dx + dy * dy).sqrt();
303    if dist < 1e-12 {
304        return (0.0, 0.0);
305    }
306
307    // Positive when agent overlaps wall.
308    let overlap = radius - dist;
309
310    // Unit normal: wall -> agent.
311    let ux = dx / dist;
312    let uy = dy / dist;
313
314    // Tangent (perpendicular to normal).
315    let tx = -uy;
316    let ty = ux;
317
318    let inv_m = 1.0 / params.mass;
319
320    // Exponential repulsion (always active).
321    let f_rep = params.a_wall * (overlap / params.b_wall).exp() * inv_m;
322    let mut fx = f_rep * ux;
323    let mut fy = f_rep * uy;
324
325    // Physical contact (only when overlapping).
326    if overlap > 0.0 {
327        // Body compression.
328        fx += params.k_wall * inv_m * overlap * ux;
329        fy += params.k_wall * inv_m * overlap * uy;
330
331        // Sliding friction: tangential velocity projected.
332        let v_tangential = vx * tx + vy * ty;
333        fx -= params.kappa_wall * inv_m * overlap * v_tangential * tx;
334        fy -= params.kappa_wall * inv_m * overlap * v_tangential * ty;
335    }
336
337    (fx, fy)
338}
339
340/// Euler integration with speed clamping in 2D.
341///
342/// Updates velocity with the given **acceleration** (m/s²), clamps the
343/// speed to `params.max_speed`, then advances position. All force primitives
344/// in this module already return accelerations (they divide Newton-scale
345/// coefficients by `params.mass` internally), so the sum of their outputs
346/// can be passed directly as `(fx, fy)` here.
347///
348/// Returns `(new_x, new_y, new_vx, new_vy)`.
349#[allow(clippy::too_many_arguments)]
350pub fn integrate_euler_2d(
351    x: f64,
352    y: f64,
353    vx: f64,
354    vy: f64,
355    fx: f64,
356    fy: f64,
357    dt: f64,
358    params: &SocialForceParams,
359) -> (f64, f64, f64, f64) {
360    let mut new_vx = vx + fx * dt;
361    let mut new_vy = vy + fy * dt;
362
363    // Speed clamping.
364    let speed = (new_vx * new_vx + new_vy * new_vy).sqrt();
365    if speed > params.max_speed {
366        let scale = params.max_speed / speed;
367        new_vx *= scale;
368        new_vy *= scale;
369    }
370
371    let new_x = x + new_vx * dt;
372    let new_y = y + new_vy * dt;
373    (new_x, new_y, new_vx, new_vy)
374}
375
376// ---------------------------------------------------------------------------
377// 3D force functions
378// ---------------------------------------------------------------------------
379
380/// Desired force driving an agent toward its destination in 3D.
381///
382/// Same formulation as [`desired_force_2d`] extended to three dimensions.
383///
384/// Returns `(fx, fy, fz)`.
385#[allow(clippy::too_many_arguments)]
386pub fn desired_force_3d(
387    x: f64,
388    y: f64,
389    z: f64,
390    vx: f64,
391    vy: f64,
392    vz: f64,
393    dest_x: f64,
394    dest_y: f64,
395    dest_z: f64,
396    desired_speed: f64,
397    tau: f64,
398) -> (f64, f64, f64) {
399    let dx = dest_x - x;
400    let dy = dest_y - y;
401    let dz = dest_z - z;
402    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
403    if dist < 1e-12 {
404        return (0.0, 0.0, 0.0);
405    }
406    let ex = dx / dist;
407    let ey = dy / dist;
408    let ez = dz / dist;
409    let fx = (desired_speed * ex - vx) / tau;
410    let fy = (desired_speed * ey - vy) / tau;
411    let fz = (desired_speed * ez - vz) / tau;
412    (fx, fy, fz)
413}
414
415/// Social repulsion + physical contact **acceleration** from a single neighbor in 3D.
416///
417/// The tangential sliding-friction direction is computed by subtracting the
418/// normal component from the relative velocity difference vector.
419/// Coefficients are divided by `params.mass` before returning, so the result
420/// is in m/s².
421///
422/// Returns `(ax, ay, az)` - acceleration (m/s²) on the current agent.
423#[allow(clippy::too_many_arguments)]
424pub fn social_repulsion_3d(
425    x: f64,
426    y: f64,
427    z: f64,
428    vx: f64,
429    vy: f64,
430    vz: f64,
431    radius: f64,
432    nx: f64,
433    ny: f64,
434    nz: f64,
435    nvx: f64,
436    nvy: f64,
437    nvz: f64,
438    n_radius: f64,
439    params: &SocialForceParams,
440) -> (f64, f64, f64) {
441    let dx = x - nx;
442    let dy = y - ny;
443    let dz = z - nz;
444    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
445    if dist < 1e-12 {
446        return (0.0, 0.0, 0.0);
447    }
448
449    let r_ij = radius + n_radius;
450    let overlap = r_ij - dist;
451
452    // Unit normal: neighbor -> self.
453    let ux = dx / dist;
454    let uy = dy / dist;
455    let uz = dz / dist;
456
457    let inv_m = 1.0 / params.mass;
458
459    // Social repulsion.
460    let f_social = params.a_social * (overlap / params.b_social).exp() * inv_m;
461    let mut fx = f_social * ux;
462    let mut fy = f_social * uy;
463    let mut fz = f_social * uz;
464
465    // Physical contact.
466    if overlap > 0.0 {
467        // Body compression.
468        fx += params.k_body * inv_m * overlap * ux;
469        fy += params.k_body * inv_m * overlap * uy;
470        fz += params.k_body * inv_m * overlap * uz;
471
472        // Sliding friction: tangential component of delta-v.
473        let dvx = nvx - vx;
474        let dvy = nvy - vy;
475        let dvz = nvz - vz;
476        let dv_normal = dvx * ux + dvy * uy + dvz * uz;
477        let tvx = dvx - dv_normal * ux;
478        let tvy = dvy - dv_normal * uy;
479        let tvz = dvz - dv_normal * uz;
480        fx += params.kappa_friction * inv_m * overlap * tvx;
481        fy += params.kappa_friction * inv_m * overlap * tvy;
482        fz += params.kappa_friction * inv_m * overlap * tvz;
483    }
484
485    (fx, fy, fz)
486}
487
488/// A 3D wall segment defined by two endpoints.
489///
490/// The closest point on the segment to a query position is computed
491/// analytically via parametric projection.
492#[derive(Debug, Clone, Copy, PartialEq)]
493pub struct WallSegment3D {
494    /// Start point x.
495    pub x1: f64,
496    /// Start point y.
497    pub y1: f64,
498    /// Start point z.
499    pub z1: f64,
500    /// End point x.
501    pub x2: f64,
502    /// End point y.
503    pub y2: f64,
504    /// End point z.
505    pub z2: f64,
506}
507
508impl WallSegment3D {
509    /// Create a new 3D wall segment.
510    pub fn new(x1: f64, y1: f64, z1: f64, x2: f64, y2: f64, z2: f64) -> Self {
511        Self {
512            x1,
513            y1,
514            z1,
515            x2,
516            y2,
517            z2,
518        }
519    }
520
521    /// Closest point on this wall segment to `(px, py, pz)`.
522    pub fn closest_point(&self, px: f64, py: f64, pz: f64) -> (f64, f64, f64) {
523        let dx = self.x2 - self.x1;
524        let dy = self.y2 - self.y1;
525        let dz = self.z2 - self.z1;
526        let len_sq = dx * dx + dy * dy + dz * dz;
527        if len_sq < 1e-12 {
528            return (self.x1, self.y1, self.z1);
529        }
530        let t = ((px - self.x1) * dx + (py - self.y1) * dy + (pz - self.z1) * dz) / len_sq;
531        let t = t.clamp(0.0, 1.0);
532        (self.x1 + t * dx, self.y1 + t * dy, self.z1 + t * dz)
533    }
534}
535
536/// Wall repulsion + physical contact **acceleration** from a 3D wall segment.
537///
538/// Coefficients are divided by `params.mass` before returning.
539///
540/// Returns `(ax, ay, az)` - acceleration (m/s²) on the agent.
541#[allow(clippy::too_many_arguments)]
542pub fn wall_repulsion_3d(
543    x: f64,
544    y: f64,
545    z: f64,
546    vx: f64,
547    vy: f64,
548    vz: f64,
549    radius: f64,
550    wall: &WallSegment3D,
551    params: &SocialForceParams,
552) -> (f64, f64, f64) {
553    let (cx, cy, cz) = wall.closest_point(x, y, z);
554    let dx = x - cx;
555    let dy = y - cy;
556    let dz = z - cz;
557    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
558    if dist < 1e-12 {
559        return (0.0, 0.0, 0.0);
560    }
561
562    let overlap = radius - dist;
563
564    // Unit normal: wall -> agent.
565    let ux = dx / dist;
566    let uy = dy / dist;
567    let uz = dz / dist;
568
569    let inv_m = 1.0 / params.mass;
570
571    // Exponential repulsion.
572    let f_rep = params.a_wall * (overlap / params.b_wall).exp() * inv_m;
573    let mut fx = f_rep * ux;
574    let mut fy = f_rep * uy;
575    let mut fz = f_rep * uz;
576
577    // Physical contact.
578    if overlap > 0.0 {
579        // Body compression.
580        fx += params.k_wall * inv_m * overlap * ux;
581        fy += params.k_wall * inv_m * overlap * uy;
582        fz += params.k_wall * inv_m * overlap * uz;
583
584        // Sliding friction: tangential component of velocity.
585        let v_normal = vx * ux + vy * uy + vz * uz;
586        let tvx = vx - v_normal * ux;
587        let tvy = vy - v_normal * uy;
588        let tvz = vz - v_normal * uz;
589        fx -= params.kappa_wall * inv_m * overlap * tvx;
590        fy -= params.kappa_wall * inv_m * overlap * tvy;
591        fz -= params.kappa_wall * inv_m * overlap * tvz;
592    }
593
594    (fx, fy, fz)
595}
596
597/// Euler integration with speed clamping in 3D.
598///
599/// Takes an **acceleration** (m/s²), updates velocity, clamps speed,
600/// advances position.
601///
602/// Returns `(new_x, new_y, new_z, new_vx, new_vy, new_vz)`.
603#[allow(clippy::too_many_arguments)]
604pub fn integrate_euler_3d(
605    x: f64,
606    y: f64,
607    z: f64,
608    vx: f64,
609    vy: f64,
610    vz: f64,
611    fx: f64,
612    fy: f64,
613    fz: f64,
614    dt: f64,
615    params: &SocialForceParams,
616) -> (f64, f64, f64, f64, f64, f64) {
617    let mut new_vx = vx + fx * dt;
618    let mut new_vy = vy + fy * dt;
619    let mut new_vz = vz + fz * dt;
620
621    let speed = (new_vx * new_vx + new_vy * new_vy + new_vz * new_vz).sqrt();
622    if speed > params.max_speed {
623        let scale = params.max_speed / speed;
624        new_vx *= scale;
625        new_vy *= scale;
626        new_vz *= scale;
627    }
628
629    let new_x = x + new_vx * dt;
630    let new_y = y + new_vy * dt;
631    let new_z = z + new_vz * dt;
632    (new_x, new_y, new_z, new_vx, new_vy, new_vz)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn desired_force_points_toward_destination() {
641        let (fx, fy) = desired_force_2d(0.0, 0.0, 0.0, 0.0, 10.0, 0.0, 1.3, 0.5);
642        assert!(fx > 0.0, "force should point toward destination (+x)");
643        assert!(
644            fy.abs() < 1e-12,
645            "no y component when destination is on x axis"
646        );
647    }
648
649    #[test]
650    fn desired_force_zero_at_destination() {
651        let (fx, fy) = desired_force_2d(5.0, 5.0, 1.0, 1.0, 5.0, 5.0, 1.3, 0.5);
652        assert!(fx.abs() < 1e-10);
653        assert!(fy.abs() < 1e-10);
654    }
655
656    #[test]
657    fn social_repulsion_pushes_agents_apart() {
658        let params = SocialForceParams::default();
659        let (fx, _fy) =
660            social_repulsion_2d(1.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 0.0, 0.0, 0.25, &params);
661        assert!(fx > 0.0, "repulsion should push agent in +x direction");
662    }
663
664    #[test]
665    fn physical_contact_activates_on_overlap() {
666        let params = SocialForceParams::default();
667        // Two agents close together (overlap).
668        let (fx_overlap, _) =
669            social_repulsion_2d(0.3, 0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 0.0, 0.0, 0.25, &params);
670        // Two agents far apart (no overlap).
671        let (fx_far, _) =
672            social_repulsion_2d(5.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 0.0, 0.0, 0.25, &params);
673        assert!(
674            fx_overlap > fx_far,
675            "overlapping agents should have stronger force"
676        );
677    }
678
679    #[test]
680    fn wall_repulsion_pushes_away() {
681        let params = SocialForceParams::default();
682        let wall = WallSegment::new(0.0, 0.0, 10.0, 0.0);
683        // Agent above wall, close.
684        let (fx, fy) = wall_repulsion_2d(5.0, 0.1, 0.0, 0.0, 0.25, &wall, &params);
685        assert!(
686            fx.abs() < fy.abs(),
687            "force should be mostly in +y (away from wall)"
688        );
689        assert!(fy > 0.0, "should push agent away from wall in +y direction");
690    }
691
692    #[test]
693    fn integration_clamps_speed() {
694        let params = SocialForceParams {
695            max_speed: 2.0,
696            ..Default::default()
697        };
698        // Apply huge force.
699        let (_, _, new_vx, new_vy) = integrate_euler_2d(0.0, 0.0, 0.0, 0.0, 1e6, 0.0, 0.1, &params);
700        let speed = (new_vx * new_vx + new_vy * new_vy).sqrt();
701        assert!(
702            (speed - 2.0).abs() < 1e-10,
703            "speed should be clamped to max_speed"
704        );
705    }
706
707    #[test]
708    fn wall_segment_closest_point() {
709        let wall = WallSegment::new(0.0, 0.0, 10.0, 0.0);
710        let (cx, cy) = wall.closest_point(5.0, 3.0);
711        assert!((cx - 5.0).abs() < 1e-12);
712        assert!(cy.abs() < 1e-12);
713
714        // Past the end.
715        let (cx, cy) = wall.closest_point(15.0, 3.0);
716        assert!((cx - 10.0).abs() < 1e-12);
717        assert!(cy.abs() < 1e-12);
718
719        // Before the start.
720        let (cx, cy) = wall.closest_point(-5.0, 3.0);
721        assert!(cx.abs() < 1e-12);
722        assert!(cy.abs() < 1e-12);
723    }
724
725    #[test]
726    fn wall_segment_3d_closest_point() {
727        let wall = WallSegment3D::new(0.0, 0.0, 0.0, 10.0, 0.0, 0.0);
728        let (cx, cy, cz) = wall.closest_point(5.0, 3.0, 4.0);
729        assert!((cx - 5.0).abs() < 1e-12);
730        assert!(cy.abs() < 1e-12);
731        assert!(cz.abs() < 1e-12);
732    }
733
734    #[test]
735    fn desired_force_3d_points_toward_destination() {
736        let (fx, fy, fz) = desired_force_3d(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 1.3, 0.5);
737        assert!(fx > 0.0);
738        assert!(fy.abs() < 1e-12);
739        assert!(fz.abs() < 1e-12);
740    }
741
742    #[test]
743    fn social_repulsion_3d_pushes_apart() {
744        let params = SocialForceParams::default();
745        let (fx, _, _) = social_repulsion_3d(
746            1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, &params,
747        );
748        assert!(fx > 0.0);
749    }
750
751    #[test]
752    fn integration_3d_clamps_speed() {
753        let params = SocialForceParams {
754            max_speed: 2.0,
755            ..Default::default()
756        };
757        let (_, _, _, nvx, nvy, nvz) =
758            integrate_euler_3d(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e6, 1e6, 1e6, 0.1, &params);
759        let speed = (nvx * nvx + nvy * nvy + nvz * nvz).sqrt();
760        assert!((speed - 2.0).abs() < 1e-10);
761    }
762}