Skip to main content

harmonica/
projectile.rs

1//! Projectile motion simulation for particles and projectiles.
2//!
3//! This module provides simple physics-based projectile motion in 3D space.
4//!
5//! # Example
6//!
7//! ```rust
8//! use harmonica::{fps, Point, Vector, Projectile, TERMINAL_GRAVITY};
9//!
10//! let mut projectile = Projectile::new(
11//!     fps(60),
12//!     Point::new(0.0, 0.0, 0.0),
13//!     Vector::new(10.0, -5.0, 0.0),
14//!     TERMINAL_GRAVITY,
15//! );
16//!
17//! for _ in 0..60 {
18//!     let pos = projectile.update();
19//!     println!("Position: ({}, {}, {})", pos.x, pos.y, pos.z);
20//! }
21//! ```
22
23use core::ops::{Add, AddAssign, Mul, Sub};
24
25/// A point in 3D space.
26///
27/// # Example
28///
29/// ```rust
30/// use harmonica::Point;
31///
32/// let origin = Point::default();
33/// let p = Point::new(1.0, 2.0, 3.0);
34///
35/// // Points support arithmetic operations
36/// let p2 = Point::new(4.0, 5.0, 6.0);
37/// ```
38#[derive(Debug, Clone, Copy, Default, PartialEq)]
39pub struct Point {
40    /// X coordinate.
41    pub x: f64,
42    /// Y coordinate.
43    pub y: f64,
44    /// Z coordinate.
45    pub z: f64,
46}
47
48impl Point {
49    /// Creates a new point with the given coordinates.
50    #[inline]
51    pub const fn new(x: f64, y: f64, z: f64) -> Self {
52        Self { x, y, z }
53    }
54
55    /// Creates a new 2D point (z = 0).
56    #[inline]
57    pub const fn new_2d(x: f64, y: f64) -> Self {
58        Self { x, y, z: 0.0 }
59    }
60
61    /// Returns the origin point (0, 0, 0).
62    #[inline]
63    pub const fn origin() -> Self {
64        Self {
65            x: 0.0,
66            y: 0.0,
67            z: 0.0,
68        }
69    }
70}
71
72impl Add<Vector> for Point {
73    type Output = Point;
74
75    #[inline]
76    fn add(self, v: Vector) -> Point {
77        Point {
78            x: self.x + v.x,
79            y: self.y + v.y,
80            z: self.z + v.z,
81        }
82    }
83}
84
85impl AddAssign<Vector> for Point {
86    #[inline]
87    fn add_assign(&mut self, v: Vector) {
88        self.x += v.x;
89        self.y += v.y;
90        self.z += v.z;
91    }
92}
93
94impl Sub for Point {
95    type Output = Vector;
96
97    #[inline]
98    fn sub(self, other: Point) -> Vector {
99        Vector {
100            x: self.x - other.x,
101            y: self.y - other.y,
102            z: self.z - other.z,
103        }
104    }
105}
106
107/// A vector in 3D space representing magnitude and direction.
108///
109/// Vectors are represented as displacement from the origin, where the
110/// magnitude is the Euclidean distance and the direction points toward
111/// the coordinates.
112///
113/// # Example
114///
115/// ```rust
116/// use harmonica::Vector;
117///
118/// let v = Vector::new(1.0, 2.0, 3.0);
119/// let scaled = v * 2.0;
120/// assert_eq!(scaled.x, 2.0);
121/// ```
122#[derive(Debug, Clone, Copy, Default, PartialEq)]
123pub struct Vector {
124    /// X component.
125    pub x: f64,
126    /// Y component.
127    pub y: f64,
128    /// Z component.
129    pub z: f64,
130}
131
132impl Vector {
133    /// Creates a new vector with the given components.
134    #[inline]
135    pub const fn new(x: f64, y: f64, z: f64) -> Self {
136        Self { x, y, z }
137    }
138
139    /// Creates a new 2D vector (z = 0).
140    #[inline]
141    pub const fn new_2d(x: f64, y: f64) -> Self {
142        Self { x, y, z: 0.0 }
143    }
144
145    /// Returns the zero vector.
146    #[inline]
147    pub const fn zero() -> Self {
148        Self {
149            x: 0.0,
150            y: 0.0,
151            z: 0.0,
152        }
153    }
154
155    /// Returns the magnitude (length) of the vector.
156    #[inline]
157    pub fn magnitude(&self) -> f64 {
158        sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
159    }
160
161    /// Returns a normalized (unit) vector with magnitude 1.
162    #[inline]
163    pub fn normalized(&self) -> Self {
164        let mag = self.magnitude();
165        if mag == 0.0 {
166            return *self;
167        }
168        Self {
169            x: self.x / mag,
170            y: self.y / mag,
171            z: self.z / mag,
172        }
173    }
174}
175
176#[cfg(feature = "std")]
177#[inline]
178fn sqrt(x: f64) -> f64 {
179    x.sqrt()
180}
181
182#[cfg(not(feature = "std"))]
183#[inline]
184fn sqrt(x: f64) -> f64 {
185    libm::sqrt(x)
186}
187
188impl Add for Vector {
189    type Output = Vector;
190
191    #[inline]
192    fn add(self, other: Vector) -> Vector {
193        Vector {
194            x: self.x + other.x,
195            y: self.y + other.y,
196            z: self.z + other.z,
197        }
198    }
199}
200
201impl AddAssign for Vector {
202    #[inline]
203    fn add_assign(&mut self, other: Vector) {
204        self.x += other.x;
205        self.y += other.y;
206        self.z += other.z;
207    }
208}
209
210impl Sub for Vector {
211    type Output = Vector;
212
213    #[inline]
214    fn sub(self, other: Vector) -> Vector {
215        Vector {
216            x: self.x - other.x,
217            y: self.y - other.y,
218            z: self.z - other.z,
219        }
220    }
221}
222
223impl Mul<f64> for Vector {
224    type Output = Vector;
225
226    #[inline]
227    fn mul(self, scalar: f64) -> Vector {
228        Vector {
229            x: self.x * scalar,
230            y: self.y * scalar,
231            z: self.z * scalar,
232        }
233    }
234}
235
236impl Mul<Vector> for f64 {
237    type Output = Vector;
238
239    #[inline]
240    fn mul(self, v: Vector) -> Vector {
241        v * self
242    }
243}
244
245/// Standard gravity vector for traditional coordinate systems.
246///
247/// This assumes a coordinate plane where:
248/// - Origin is at the bottom-left corner
249/// - Y increases upward
250/// - Gravity pulls downward (negative Y)
251///
252/// ```text
253///   y             y ±z
254///   │             │ /
255///   │             │/
256///   └───── ±x     └───── ±x
257/// ```
258pub const GRAVITY: Vector = Vector {
259    x: 0.0,
260    y: -9.81,
261    z: 0.0,
262};
263
264/// Gravity vector for terminal coordinate systems.
265///
266/// This assumes a coordinate plane where:
267/// - Origin is at the top-left corner
268/// - Y increases downward (typical for terminals)
269/// - Gravity pulls downward (positive Y)
270pub const TERMINAL_GRAVITY: Vector = Vector {
271    x: 0.0,
272    y: 9.81,
273    z: 0.0,
274};
275
276/// A projectile with position, velocity, and acceleration.
277///
278/// Projectiles simulate simple physics-based motion, updating position
279/// based on velocity and velocity based on acceleration each frame.
280///
281/// # Example
282///
283/// ```rust
284/// use harmonica::{fps, Point, Vector, Projectile, GRAVITY};
285///
286/// // Create a ball thrown upward
287/// let mut ball = Projectile::new(
288///     fps(60),
289///     Point::new(0.0, 0.0, 0.0),
290///     Vector::new(5.0, 20.0, 0.0),  // Initial velocity
291///     GRAVITY,
292/// );
293///
294/// // Simulate for 1 second
295/// for _ in 0..60 {
296///     let pos = ball.update();
297///     println!("Ball at y={}", pos.y);
298/// }
299/// ```
300#[derive(Debug, Clone, Copy, PartialEq)]
301pub struct Projectile {
302    pos: Point,
303    vel: Vector,
304    acc: Vector,
305    delta_time: f64,
306}
307
308impl Projectile {
309    /// Creates a new projectile with the given parameters.
310    ///
311    /// # Arguments
312    ///
313    /// * `delta_time` - Time step per update (use [`fps`](crate::fps) to compute)
314    /// * `position` - Initial position
315    /// * `velocity` - Initial velocity
316    /// * `acceleration` - Constant acceleration (e.g., gravity)
317    ///
318    /// # Example
319    ///
320    /// ```rust
321    /// use harmonica::{fps, Point, Vector, Projectile, TERMINAL_GRAVITY};
322    ///
323    /// let projectile = Projectile::new(
324    ///     fps(60),
325    ///     Point::new(10.0, 0.0, 0.0),
326    ///     Vector::new(5.0, 2.0, 0.0),
327    ///     TERMINAL_GRAVITY,
328    /// );
329    /// ```
330    #[inline]
331    pub const fn new(
332        delta_time: f64,
333        position: Point,
334        velocity: Vector,
335        acceleration: Vector,
336    ) -> Self {
337        Self {
338            pos: position,
339            vel: velocity,
340            acc: acceleration,
341            delta_time,
342        }
343    }
344
345    /// Updates the projectile's position and velocity for one frame.
346    ///
347    /// Returns the new position after the update.
348    ///
349    /// # Example
350    ///
351    /// ```rust
352    /// use harmonica::{fps, Point, Vector, Projectile, GRAVITY};
353    ///
354    /// let mut p = Projectile::new(
355    ///     fps(60),
356    ///     Point::origin(),
357    ///     Vector::new(10.0, 0.0, 0.0),
358    ///     GRAVITY,
359    /// );
360    ///
361    /// // Update returns the new position
362    /// let new_pos = p.update();
363    /// ```
364    #[inline]
365    pub fn update(&mut self) -> Point {
366        // Update position based on current velocity (Explicit Euler)
367        // This matches Go's harmonica behavior: position is updated first,
368        // then velocity is updated for the next frame
369        self.pos.x += self.vel.x * self.delta_time;
370        self.pos.y += self.vel.y * self.delta_time;
371        self.pos.z += self.vel.z * self.delta_time;
372
373        // Update velocity based on acceleration
374        self.vel.x += self.acc.x * self.delta_time;
375        self.vel.y += self.acc.y * self.delta_time;
376        self.vel.z += self.acc.z * self.delta_time;
377
378        self.pos
379    }
380
381    /// Returns the current position of the projectile.
382    #[inline]
383    pub const fn position(&self) -> Point {
384        self.pos
385    }
386
387    /// Returns the current velocity of the projectile.
388    #[inline]
389    pub const fn velocity(&self) -> Vector {
390        self.vel
391    }
392
393    /// Returns the acceleration of the projectile.
394    #[inline]
395    pub const fn acceleration(&self) -> Vector {
396        self.acc
397    }
398
399    /// Sets the position of the projectile.
400    #[inline]
401    pub fn set_position(&mut self, pos: Point) {
402        self.pos = pos;
403    }
404
405    /// Sets the velocity of the projectile.
406    #[inline]
407    pub fn set_velocity(&mut self, vel: Vector) {
408        self.vel = vel;
409    }
410
411    /// Sets the acceleration of the projectile.
412    #[inline]
413    pub fn set_acceleration(&mut self, acc: Vector) {
414        self.acc = acc;
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::fps;
422
423    const TOLERANCE: f64 = 1e-10;
424
425    fn approx_eq(a: f64, b: f64) -> bool {
426        (a - b).abs() < TOLERANCE
427    }
428
429    #[test]
430    fn test_point_new() {
431        let p = Point::new(1.0, 2.0, 3.0);
432        assert!(approx_eq(p.x, 1.0));
433        assert!(approx_eq(p.y, 2.0));
434        assert!(approx_eq(p.z, 3.0));
435    }
436
437    #[test]
438    fn test_point_2d() {
439        let p = Point::new_2d(1.0, 2.0);
440        assert!(approx_eq(p.z, 0.0));
441    }
442
443    #[test]
444    fn test_point_add_vector() {
445        let p = Point::new(1.0, 2.0, 3.0);
446        let v = Vector::new(4.0, 5.0, 6.0);
447        let result = p + v;
448
449        assert!(approx_eq(result.x, 5.0));
450        assert!(approx_eq(result.y, 7.0));
451        assert!(approx_eq(result.z, 9.0));
452    }
453
454    #[test]
455    fn test_point_sub_point() {
456        let p1 = Point::new(5.0, 7.0, 9.0);
457        let p2 = Point::new(1.0, 2.0, 3.0);
458        let v = p1 - p2;
459
460        assert!(approx_eq(v.x, 4.0));
461        assert!(approx_eq(v.y, 5.0));
462        assert!(approx_eq(v.z, 6.0));
463    }
464
465    #[test]
466    fn test_vector_mul_scalar() {
467        let v = Vector::new(1.0, 2.0, 3.0);
468        let scaled = v * 2.0;
469
470        assert!(approx_eq(scaled.x, 2.0));
471        assert!(approx_eq(scaled.y, 4.0));
472        assert!(approx_eq(scaled.z, 6.0));
473    }
474
475    #[test]
476    fn test_scalar_mul_vector() {
477        let v = Vector::new(1.0, 2.0, 3.0);
478        let scaled = 2.0 * v;
479
480        assert!(approx_eq(scaled.x, 2.0));
481        assert!(approx_eq(scaled.y, 4.0));
482        assert!(approx_eq(scaled.z, 6.0));
483    }
484
485    #[test]
486    fn test_vector_magnitude() {
487        let v = Vector::new(3.0, 4.0, 0.0);
488        assert!(approx_eq(v.magnitude(), 5.0));
489    }
490
491    #[test]
492    fn test_vector_normalized() {
493        let v = Vector::new(3.0, 4.0, 0.0);
494        let n = v.normalized();
495
496        assert!(approx_eq(n.magnitude(), 1.0));
497        assert!(approx_eq(n.x, 0.6));
498        assert!(approx_eq(n.y, 0.8));
499    }
500
501    #[test]
502    fn test_gravity_constants() {
503        assert!(approx_eq(GRAVITY.y, -9.81));
504        assert!(approx_eq(TERMINAL_GRAVITY.y, 9.81));
505    }
506
507    #[test]
508    fn test_projectile_constant_velocity() {
509        let dt = fps(60);
510        let mut p = Projectile::new(
511            dt,
512            Point::origin(),
513            Vector::new(60.0, 0.0, 0.0), // 60 units/sec
514            Vector::zero(),
515        );
516
517        // After 1 second (60 frames), should move 60 units
518        for _ in 0..60 {
519            p.update();
520        }
521
522        // Allow for small floating point error
523        assert!(
524            (p.position().x - 60.0).abs() < 0.1,
525            "Expected x ≈ 60, got {}",
526            p.position().x
527        );
528    }
529
530    #[test]
531    fn test_projectile_with_gravity() {
532        let dt = fps(60);
533        let mut p = Projectile::new(
534            dt,
535            Point::new(0.0, 100.0, 0.0),
536            Vector::zero(),
537            TERMINAL_GRAVITY,
538        );
539
540        // After 1 second, should have fallen due to gravity
541        for _ in 0..60 {
542            p.update();
543        }
544
545        // With terminal gravity (positive y), y should increase
546        assert!(p.position().y > 100.0, "Should have fallen (y increased)");
547    }
548
549    #[test]
550    fn test_projectile_parabolic_motion() {
551        let dt = fps(60);
552        let mut p = Projectile::new(
553            dt,
554            Point::origin(),
555            Vector::new(10.0, -10.0, 0.0), // Throw up and right
556            TERMINAL_GRAVITY,
557        );
558
559        let mut max_height = f64::MAX;
560
561        // Find the apex
562        for _ in 0..120 {
563            p.update();
564            if p.position().y < max_height {
565                max_height = p.position().y;
566            }
567        }
568
569        // Should have reached a minimum y (apex) and then fallen back
570        assert!(
571            max_height < 0.0,
572            "Should have gone up (negative y in terminal coords)"
573        );
574    }
575
576    #[test]
577    fn test_projectile_accessors() {
578        let p = Projectile::new(
579            fps(60),
580            Point::new(1.0, 2.0, 3.0),
581            Vector::new(4.0, 5.0, 6.0),
582            Vector::new(7.0, 8.0, 9.0),
583        );
584
585        assert_eq!(p.position(), Point::new(1.0, 2.0, 3.0));
586        assert_eq!(p.velocity(), Vector::new(4.0, 5.0, 6.0));
587        assert_eq!(p.acceleration(), Vector::new(7.0, 8.0, 9.0));
588    }
589
590    // =========================================================================
591    // bd-228s: Additional projectile and vector tests
592    // =========================================================================
593
594    #[test]
595    fn test_projectile_setters() {
596        let mut p = Projectile::new(fps(60), Point::origin(), Vector::zero(), Vector::zero());
597
598        p.set_position(Point::new(10.0, 20.0, 30.0));
599        assert_eq!(p.position(), Point::new(10.0, 20.0, 30.0));
600
601        p.set_velocity(Vector::new(1.0, 2.0, 3.0));
602        assert_eq!(p.velocity(), Vector::new(1.0, 2.0, 3.0));
603
604        p.set_acceleration(Vector::new(0.0, -9.81, 0.0));
605        assert_eq!(p.acceleration(), Vector::new(0.0, -9.81, 0.0));
606    }
607
608    #[test]
609    fn test_vector_add() {
610        let v1 = Vector::new(1.0, 2.0, 3.0);
611        let v2 = Vector::new(4.0, 5.0, 6.0);
612        let result = v1 + v2;
613
614        assert!(approx_eq(result.x, 5.0));
615        assert!(approx_eq(result.y, 7.0));
616        assert!(approx_eq(result.z, 9.0));
617    }
618
619    #[test]
620    fn test_vector_sub() {
621        let v1 = Vector::new(5.0, 7.0, 9.0);
622        let v2 = Vector::new(1.0, 2.0, 3.0);
623        let result = v1 - v2;
624
625        assert!(approx_eq(result.x, 4.0));
626        assert!(approx_eq(result.y, 5.0));
627        assert!(approx_eq(result.z, 6.0));
628    }
629
630    #[test]
631    fn test_vector_zero() {
632        let v = Vector::zero();
633        assert!(approx_eq(v.x, 0.0));
634        assert!(approx_eq(v.y, 0.0));
635        assert!(approx_eq(v.z, 0.0));
636    }
637
638    #[test]
639    fn test_point_origin() {
640        let p = Point::origin();
641        assert!(approx_eq(p.x, 0.0));
642        assert!(approx_eq(p.y, 0.0));
643        assert!(approx_eq(p.z, 0.0));
644    }
645
646    #[test]
647    fn test_vector_add_assign() {
648        let mut v1 = Vector::new(1.0, 2.0, 3.0);
649        v1 += Vector::new(4.0, 5.0, 6.0);
650
651        assert!(approx_eq(v1.x, 5.0));
652        assert!(approx_eq(v1.y, 7.0));
653        assert!(approx_eq(v1.z, 9.0));
654    }
655
656    #[test]
657    fn test_point_add_assign() {
658        let mut p = Point::new(1.0, 2.0, 3.0);
659        p += Vector::new(4.0, 5.0, 6.0);
660
661        assert!(approx_eq(p.x, 5.0));
662        assert!(approx_eq(p.y, 7.0));
663        assert!(approx_eq(p.z, 9.0));
664    }
665
666    #[test]
667    fn test_vector_normalized_zero() {
668        // Normalizing zero vector should return zero vector
669        let v = Vector::zero();
670        let n = v.normalized();
671
672        assert!(approx_eq(n.x, 0.0));
673        assert!(approx_eq(n.y, 0.0));
674        assert!(approx_eq(n.z, 0.0));
675    }
676
677    #[test]
678    fn test_vector_2d() {
679        let v = Vector::new_2d(3.0, 4.0);
680        assert!(approx_eq(v.z, 0.0));
681        assert!(approx_eq(v.magnitude(), 5.0));
682    }
683
684    #[test]
685    fn test_point_default() {
686        let p = Point::default();
687        assert!(approx_eq(p.x, 0.0));
688        assert!(approx_eq(p.y, 0.0));
689        assert!(approx_eq(p.z, 0.0));
690    }
691
692    #[test]
693    fn test_vector_default() {
694        let v = Vector::default();
695        assert!(approx_eq(v.x, 0.0));
696        assert!(approx_eq(v.y, 0.0));
697        assert!(approx_eq(v.z, 0.0));
698    }
699
700    #[test]
701    fn test_projectile_3d_motion() {
702        // Test full 3D projectile motion
703        let dt = fps(60);
704        let mut p = Projectile::new(
705            dt,
706            Point::origin(),
707            Vector::new(10.0, 10.0, 10.0),
708            Vector::new(0.0, 0.0, 0.0),
709        );
710
711        // After 1 second, should move 10 units in each direction
712        for _ in 0..60 {
713            p.update();
714        }
715
716        let pos = p.position();
717        assert!((pos.x - 10.0).abs() < 0.2);
718        assert!((pos.y - 10.0).abs() < 0.2);
719        assert!((pos.z - 10.0).abs() < 0.2);
720    }
721
722    #[test]
723    fn test_projectile_zero_delta_time() {
724        // With zero delta time, nothing should change
725        let mut p = Projectile::new(
726            0.0,
727            Point::new(1.0, 2.0, 3.0),
728            Vector::new(100.0, 100.0, 100.0),
729            GRAVITY,
730        );
731
732        p.update();
733
734        // Position should not change
735        assert!(approx_eq(p.position().x, 1.0));
736        assert!(approx_eq(p.position().y, 2.0));
737        assert!(approx_eq(p.position().z, 3.0));
738    }
739
740    #[test]
741    fn test_projectile_is_copy() {
742        let p1 = Projectile::new(
743            fps(60),
744            Point::origin(),
745            Vector::new(1.0, 0.0, 0.0),
746            Vector::zero(),
747        );
748        let mut p2 = p1; // Copy
749
750        // Original should be unchanged after modifying copy
751        p2.update();
752        assert!(approx_eq(p1.position().x, 0.0));
753        assert!(p2.position().x > 0.0);
754    }
755
756    #[test]
757    fn test_gravity_acceleration() {
758        let dt = fps(60);
759        let mut p = Projectile::new(dt, Point::new(0.0, 100.0, 0.0), Vector::zero(), GRAVITY);
760
761        // After 1 second, velocity should be -9.81 m/s
762        for _ in 0..60 {
763            p.update();
764        }
765
766        // v = v0 + a*t = 0 + (-9.81)(1) = -9.81
767        assert!(
768            (p.velocity().y - GRAVITY.y).abs() < 0.2,
769            "Velocity should be ~-9.81, got {}",
770            p.velocity().y
771        );
772    }
773
774    #[test]
775    #[allow(clippy::assertions_on_constants)]
776    fn test_terminal_gravity_direction() {
777        // Terminal gravity points in positive y (down in terminal coords)
778        assert!(TERMINAL_GRAVITY.y > 0.0);
779        assert!(TERMINAL_GRAVITY.x == 0.0);
780        assert!(TERMINAL_GRAVITY.z == 0.0);
781    }
782
783    #[test]
784    #[allow(clippy::assertions_on_constants)]
785    fn test_standard_gravity_direction() {
786        // Standard gravity points in negative y (down in traditional coords)
787        assert!(GRAVITY.y < 0.0);
788        assert!(GRAVITY.x == 0.0);
789        assert!(GRAVITY.z == 0.0);
790    }
791}