Skip to main content

kernelvex/motion/
trajectory.rs

1//! Trajectory representation and sampling utilities.
2//!
3//! This module provides data structures for representing time-parameterized
4//! trajectories and utilities for generating them from curves like Bezier splines.
5//!
6//! # Overview
7//!
8//! A trajectory is a sequence of time-indexed points, each containing:
9//! - **Pose**: Position (x, y) and heading
10//! - **Linear velocity**: Forward speed in m/s
11//! - **Angular velocity**: Rotational speed in rad/s
12//! - **Time**: Timestamp from trajectory start
13//!
14//! Trajectories are used by controllers like RAMSETE and Pure Pursuit to guide
15//! the robot along curved paths.
16//!
17//! # Creating Trajectories
18//!
19//! ## From Bézier curves
20//!
21//! ```no_run
22//! use kernelvex::{Trajectory, Vector2, QTime};
23//!
24//! let trajectory = Trajectory::from_cubic_bezier(
25//!     Vector2::new(0.0, 0.0),   // Start
26//!     Vector2::new(0.5, 0.0),   // Control 1
27//!     Vector2::new(0.5, 1.0),   // Control 2
28//!     Vector2::new(1.0, 1.0),   // End
29//!     QTime::from_sec(3.0),     // Total time
30//!     100,                       // Sample count
31//!     0.5,                       // Linear velocity
32//! );
33//! ```
34//!
35//! ## Manually
36//!
37//! ```no_run
38//! let mut trajectory = Trajectory::new();
39//! trajectory.push(TrajectoryPoint::new(pose1, 0.5, 0.0, QTime::from_sec(0.0)));
40//! trajectory.push(TrajectoryPoint::new(pose2, 0.5, 0.1, QTime::from_sec(1.0)));
41//! ```
42
43// TODO: add QTime instead of normal f64 type
44use crate::odom::pose::Pose;
45use crate::util::si::{QAngle, QTime, Vector2};
46
47/// A single time-indexed point along a trajectory.
48///
49/// Contains the desired robot state (pose and velocities) at a specific time
50/// during trajectory execution.
51///
52/// # Fields
53///
54/// - `pose`: Desired position and heading
55/// - `linear_velocity`: Desired forward speed (m/s)
56/// - `angular_velocity`: Desired rotation rate (rad/s)
57/// - `time`: Time from trajectory start
58#[derive(Debug, Clone, Copy)]
59pub struct TrajectoryPoint {
60    /// Desired pose (position and heading) at this point.
61    pub pose: Pose,
62    /// Desired linear velocity in meters per second.
63    pub linear_velocity: f64,
64    /// Desired angular velocity in radians per second.
65    pub angular_velocity: f64,
66    /// Time from trajectory start.
67    pub time: QTime,
68}
69
70impl TrajectoryPoint {
71    /// Creates a new trajectory point.
72    ///
73    /// # Arguments
74    ///
75    /// * `pose` - Desired position and heading
76    /// * `linear_velocity` - Forward speed in m/s
77    /// * `angular_velocity` - Rotation rate in rad/s
78    /// * `time` - Time from trajectory start
79    #[inline]
80    pub const fn new(pose: Pose, linear_velocity: f64, angular_velocity: f64, time: QTime) -> Self {
81        Self {
82            pose,
83            linear_velocity,
84            angular_velocity,
85            time,
86        }
87    }
88}
89
90/// A time-parameterized trajectory with sampling support.
91///
92/// Stores a sequence of [`TrajectoryPoint`]s and provides methods for
93/// sampling the trajectory at arbitrary times with interpolation.
94///
95/// # Sampling
96///
97/// Use [`sample`](Self::sample) to get the trajectory state at any time.
98/// The method interpolates between stored points for smooth tracking.
99#[derive(Debug, Clone)]
100pub struct Trajectory {
101    /// Time-ordered trajectory points.
102    points: Vec<TrajectoryPoint>,
103}
104
105impl Trajectory {
106    /// Creates an empty trajectory.
107    #[inline]
108    pub const fn new() -> Self {
109        Self { points: Vec::new() }
110    }
111
112    /// Creates a trajectory from time-ordered points.
113    ///
114    /// # Arguments
115    ///
116    /// * `points` - Vector of trajectory points, must be in ascending time order
117    #[inline]
118    pub const fn from_points(points: Vec<TrajectoryPoint>) -> Self {
119        Self { points }
120    }
121
122    /// Returns a read-only view of trajectory points.
123    #[inline]
124    pub fn points(&self) -> &[TrajectoryPoint] {
125        &self.points
126    }
127
128    /// Returns the total trajectory duration.
129    ///
130    /// # Returns
131    ///
132    /// The time of the last point, or `None` if the trajectory is empty.
133    pub fn total_time(&self) -> Option<QTime> {
134        self.points.last().map(|p| p.time)
135    }
136
137    /// Adds a point to the end of the trajectory.
138    ///
139    /// Points should be added in ascending time order.
140    pub fn push(&mut self, point: TrajectoryPoint) {
141        self.points.push(point);
142    }
143
144    /// Samples the trajectory at the given time with interpolation.
145    ///
146    /// Returns the interpolated trajectory state at the specified time.
147    /// If the time is outside the trajectory bounds, the nearest endpoint
148    /// is returned.
149    ///
150    /// # Arguments
151    ///
152    /// * `time` - Time from trajectory start
153    ///
154    /// # Returns
155    ///
156    /// The interpolated trajectory point, or `None` if the trajectory is empty.
157    ///
158    /// # Interpolation
159    ///
160    /// Position and heading are linearly interpolated. Heading interpolation
161    /// takes the shortest angular path.
162    pub fn sample(&self, time: QTime) -> Option<TrajectoryPoint> {
163        let first = self.points.first()?;
164        let last = self.points.last()?;
165
166        if time.as_sec() <= first.time.as_sec() {
167            return Some(*first);
168        }
169        if time.as_sec() >= last.time.as_sec() {
170            return Some(*last);
171        }
172
173        for window in self.points.windows(2) {
174            let a = window[0];
175            let b = window[1];
176            if time.as_sec() >= a.time.as_sec() && time.as_sec() <= b.time.as_sec() {
177                let span = b.time.as_sec() - a.time.as_sec();
178                let t = if span <= 0.0 {
179                    0.0
180                } else {
181                    (time.as_sec() - a.time.as_sec()) / span
182                };
183
184                return Some(TrajectoryPoint {
185                    pose: interpolate_pose(a.pose, b.pose, t),
186                    linear_velocity: lerp(a.linear_velocity, b.linear_velocity, t),
187                    angular_velocity: lerp(a.angular_velocity, b.angular_velocity, t),
188                    time,
189                });
190            }
191        }
192
193        Some(*last)
194    }
195
196    /// Creates a trajectory by sampling a cubic Bézier curve.
197    ///
198    /// Generates a trajectory by sampling a cubic Bezier spline at regular
199    /// intervals. The heading is derived from the curve tangent, and angular
200    /// velocity is estimated from heading changes between samples.
201    ///
202    /// # Arguments
203    ///
204    /// * `p0` - Start point (meters)
205    /// * `p1` - First control point (meters)
206    /// * `p2` - Second control point (meters)
207    /// * `p3` - End point (meters)
208    /// * `total_time` - Total trajectory duration
209    /// * `samples` - Number of points to generate
210    /// * `linear_velocity` - Constant linear velocity for all points (m/s)
211    ///
212    /// # Returns
213    ///
214    /// A trajectory with `samples` points along the Bézier curve.
215    ///
216    /// # Example
217    ///
218    /// ```no_run
219    /// // S-curve trajectory
220    /// let traj = Trajectory::from_cubic_bezier(
221    ///     Vector2::new(0.0, 0.0),
222    ///     Vector2::new(1.0, 0.0),
223    ///     Vector2::new(0.0, 1.0),
224    ///     Vector2::new(1.0, 1.0),
225    ///     QTime::from_sec(2.0),
226    ///     50,
227    ///     0.8,
228    /// );
229    /// ```
230    pub fn from_cubic_bezier(
231        p0: Vector2<f64>,
232        p1: Vector2<f64>,
233        p2: Vector2<f64>,
234        p3: Vector2<f64>,
235        total_time: QTime,
236        samples: usize,
237        linear_velocity: f64,
238    ) -> Self {
239        Bezier::new(p0, p1, p2, p3).to_trajectory(total_time, samples, linear_velocity)
240    }
241}
242
243impl Default for Trajectory {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl Default for TrajectoryPoint {
250    fn default() -> Self {
251        Self {
252            pose: Pose::identity(),
253            linear_velocity: 0.,
254            angular_velocity: 0.,
255            time: QTime::default(),
256        }
257    }
258}
259/// Linear interpolation between two values.
260fn lerp(a: f64, b: f64, t: f64) -> f64 {
261    a + (b - a) * t
262}
263
264/// Interpolates between two angles, taking the shortest path.
265fn lerp_angle(a: QAngle, b: QAngle, t: f64) -> QAngle {
266    let delta = (b - a).remainder(QAngle::TAU);
267    a + delta * t
268}
269
270/// Interpolates between two poses.
271fn interpolate_pose(a: Pose, b: Pose, t: f64) -> Pose {
272    let (ax, ay) = (a.position().x, a.position().y);
273    let (bx, by) = (b.position().x, b.position().y);
274    let heading = lerp_angle(a.heading(), b.heading(), t);
275    Pose::new(
276        Vector2::<f64>::new(lerp(ax, bx, t), lerp(ay, by, t)),
277        heading,
278    )
279}
280
281/// A cubic Bézier curve defined by start, end, and two control points.
282///
283/// Bézier curves provide smooth, controllable paths for trajectory generation.
284/// The curve starts at `start`, ends at `end`, and is "pulled" toward the
285/// control points without necessarily passing through them.
286///
287/// # Curve Properties
288///
289/// - Always passes through start and end points
290/// - Tangent at start points toward control1
291/// - Tangent at end points away from control2
292/// - Entire curve lies within convex hull of control points
293///
294/// # Example
295///
296/// ```no_run
297/// let curve = Bezier::new(
298///     Vector2::new(0.0, 0.0),   // Start
299///     Vector2::new(1.0, 0.0),   // Control 1 - pulls curve right
300///     Vector2::new(0.0, 1.0),   // Control 2 - pulls curve up
301///     Vector2::new(1.0, 1.0),   // End
302/// );
303///
304/// // Sample point at t=0.5 (middle of curve)
305/// let midpoint = curve.point(0.5);
306/// let heading = curve.heading(0.5);
307/// ```
308#[derive(Debug, Clone, Copy)]
309pub struct Bezier {
310    /// Starting point of the curve.
311    start: Vector2<f64>,
312    /// First control point (influences curve near start).
313    control1: Vector2<f64>,
314    /// Second control point (influences curve near end).
315    control2: Vector2<f64>,
316    /// Ending point of the curve.
317    end: Vector2<f64>,
318}
319
320impl Bezier {
321    /// Maximum parameter value (curves are parameterized from 0 to 1).
322    const T_MAX: f64 = 1.0;
323
324    /// Creates a new cubic Bézier curve.
325    ///
326    /// # Arguments
327    ///
328    /// * `start` - Starting point
329    /// * `control1` - First control point
330    /// * `control2` - Second control point
331    /// * `end` - Ending point
332    #[inline]
333    pub const fn new(
334        start: Vector2<f64>,
335        control1: Vector2<f64>,
336        control2: Vector2<f64>,
337        end: Vector2<f64>,
338    ) -> Self {
339        Self {
340            start,
341            control1,
342            control2,
343            end,
344        }
345    }
346
347    /// Computes the point on the curve at parameter t.
348    ///
349    /// # Arguments
350    ///
351    /// * `t` - Parameter value in range [0, 1]
352    ///
353    /// # Returns
354    ///
355    /// The (x, y) position on the curve at t.
356    ///
357    /// # Panics
358    ///
359    /// Panics if t > 1.0.
360    #[inline]
361    pub fn point(&self, t: f64) -> Vector2<f64> {
362        {
363            assert!(t <= Self::T_MAX, "time cannot exceed 1");
364        }
365
366        let u = 1.0 - t;
367        let tt = t * t;
368        let uu = u * u;
369
370        self.start * (uu * u)
371            + self.control1 * (3.0 * uu * t)
372            + self.control2 * (3.0 * u * tt)
373            + self.end * (tt * t)
374    }
375
376    /// Computes the tangent vector at parameter t.
377    ///
378    /// The tangent points in the direction of motion along the curve.
379    ///
380    /// # Arguments
381    ///
382    /// * `t` - Parameter value in range [0, 1]
383    ///
384    /// # Returns
385    ///
386    /// The tangent vector (not normalized).
387    ///
388    /// # Panics
389    ///
390    /// Panics if t > 1.0.
391    #[inline]
392    pub fn tangent(&self, t: f64) -> Vector2<f64> {
393        {
394            assert!(t <= Self::T_MAX, "time cannot exceed 1");
395        }
396
397        let u = 1.0 - t;
398        let tt = t * t;
399        let uu = u * u;
400
401        (self.control1 - self.start) * (3.0 * uu)
402            + (self.control2 - self.control1) * (6.0 * u * t)
403            + (self.end - self.control2) * (3.0 * tt)
404    }
405
406    /// Computes the heading (direction of motion) at parameter t.
407    ///
408    /// # Arguments
409    ///
410    /// * `t` - Parameter value in range [0, 1]
411    ///
412    /// # Returns
413    ///
414    /// The heading angle derived from the tangent vector.
415    ///
416    /// # Panics
417    ///
418    /// Panics if t > 1.0.
419    #[inline]
420    pub fn heading(&self, t: f64) -> QAngle {
421        {
422            assert!(t <= Self::T_MAX, "time cannot exceed 1");
423        }
424
425        let tan = self.tangent(t);
426        QAngle::from_radians(libm::atan2(tan.y, tan.x))
427    }
428
429    /// Computes the first derivative (velocity vector) at parameter t.
430    ///
431    /// This is equivalent to the tangent vector and represents the rate of
432    /// change of position with respect to the parameter t.
433    ///
434    /// # Arguments
435    ///
436    /// * `t` - Parameter value in range [0, 1]
437    ///
438    /// # Returns
439    ///
440    /// The derivative vector.
441    ///
442    /// # Panics
443    ///
444    /// Panics if t > 1.0.
445    pub fn derivative(&self, t: f64) -> Vector2<f64> {
446        {
447            assert!(t <= Self::T_MAX, "time cannot exceed 1");
448        }
449
450        let u = 1.0 - t;
451
452        let q0 = (self.control1 - self.start) * 3.0;
453        let q1 = (self.control2 - self.control1) * 3.0;
454        let q2 = (self.end - self.control2) * 3.0;
455
456        let velocity = q0 * (u * u) + q1 * (2.0 * u * t) + q2 * (t * t);
457
458        velocity
459    }
460
461    /// Converts the Bézier curve to a trajectory.
462    ///
463    /// Samples the curve at regular intervals and computes angular velocity
464    /// from heading changes between samples.
465    ///
466    /// # Arguments
467    ///
468    /// * `total_time` - Total trajectory duration
469    /// * `samples` - Number of points to generate (must be >= 2)
470    /// * `linear_velocity` - Constant linear velocity for all points (m/s)
471    ///
472    /// # Returns
473    ///
474    /// A trajectory with the specified number of samples, or an empty
475    /// trajectory if `samples < 2`.
476    pub fn to_trajectory(
477        &self,
478        total_time: QTime,
479        samples: usize,
480        linear_velocity: f64,
481    ) -> Trajectory {
482        if samples < 2 {
483            return Trajectory::new();
484        }
485
486        let dt = total_time.as_sec() / (samples as f64 - 1.0);
487        let mut points = Vec::with_capacity(samples);
488        let mut headings = Vec::with_capacity(samples);
489
490        for i in 0..samples {
491            let t = i as f64 / (samples as f64 - 1.0);
492            let pos = self.point(t);
493            let heading = self.heading(t);
494            headings.push(heading);
495            points.push(TrajectoryPoint::new(
496                Pose::new(pos, heading),
497                linear_velocity,
498                0.0,
499                QTime::from_sec(dt * i as f64),
500            ));
501        }
502
503        for i in 0..samples {
504            let angular_velocity = if i + 1 < samples {
505                let dtheta = (headings[i + 1] - headings[i]).remainder(QAngle::TAU);
506                dtheta.as_radians() / dt
507            } else if i > 0 {
508                let dtheta = (headings[i] - headings[i - 1]).remainder(QAngle::TAU);
509                dtheta.as_radians() / dt
510            } else {
511                0.0
512            };
513            points[i].angular_velocity = angular_velocity;
514        }
515
516        Trajectory::from_points(points)
517    }
518}