use std::fmt;
use nalgebra::{DMatrix, DVector};
pub(crate) struct DisplayTrajectory<const D: usize> {
pub(crate) points: DMatrix<f32>,
pub(crate) origin: [f64; D],
pub(crate) units_per_display_unit: f64,
}
pub(crate) fn normalize_for_display<const D: usize>(points: &DMatrix<f64>) -> DisplayTrajectory<D> {
let mut origin = [0.0; D];
let mut min = [f64::INFINITY; D];
let mut max = [f64::NEG_INFINITY; D];
for column in 0..points.ncols() {
for row in 0..D {
let value = points[(row, column)];
min[row] = min[row].min(value);
max[row] = max[row].max(value);
}
}
let mut range = 0.0_f64;
for axis in 0..D {
origin[axis] = min[axis] * 0.5 + max[axis] * 0.5;
range = range.max(max[axis] - min[axis]);
}
let units_per_display_unit = if range > 0.0 { range / 4.0 } else { 1.0 };
let mut display_points = DMatrix::zeros(D, points.ncols());
if range > 0.0 {
for column in 0..points.ncols() {
for row in 0..D {
display_points[(row, column)] =
(((points[(row, column)] - origin[row]) / range) * 4.0) as f32;
}
}
}
DisplayTrajectory {
points: display_points,
origin,
units_per_display_unit,
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AnimationInputError {
InvalidDimension { expected: usize, actual: usize },
EmptyTrajectory,
SampleCountMismatch { points: usize, times: usize },
InvalidCoordinate { row: usize, column: usize },
InvalidCoordinateRange { axis: usize },
NonFiniteTime { index: usize },
NonMonotoneTime { index: usize },
UnrepresentableTimeInterval { index: usize },
InvalidSpeed,
}
impl fmt::Display for AnimationInputError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidDimension { expected, actual } => {
write!(
f,
"trajectory must have {expected} coordinate rows, got {actual}"
)
}
Self::EmptyTrajectory => write!(f, "trajectory must contain at least one point"),
Self::SampleCountMismatch { points, times } => {
write!(f, "trajectory has {points} points but {times} timestamps")
}
Self::InvalidCoordinate { row, column } => write!(
f,
"coordinate at row {row}, sample {column} is non-finite or outside f32 range"
),
Self::InvalidCoordinateRange { axis } => write!(
f,
"coordinate range on axis {axis} is outside finite f32 range"
),
Self::NonFiniteTime { index } => {
write!(f, "timestamp at index {index} is not finite")
}
Self::NonMonotoneTime { index } => write!(
f,
"timestamps must be strictly monotone; order changes or repeats at index {index}"
),
Self::UnrepresentableTimeInterval { index } => write!(
f,
"time interval ending at index {index} is too large for playback"
),
Self::InvalidSpeed => write!(f, "animation speed must be finite and greater than zero"),
}
}
}
impl std::error::Error for AnimationInputError {}
pub fn validate_trajectory<const D: usize>(
points: &DMatrix<f64>,
times: &DVector<f64>,
speed: f32,
) -> Result<Vec<f32>, AnimationInputError> {
if points.nrows() != D {
return Err(AnimationInputError::InvalidDimension {
expected: D,
actual: points.nrows(),
});
}
if points.ncols() == 0 {
return Err(AnimationInputError::EmptyTrajectory);
}
if points.ncols() != times.len() {
return Err(AnimationInputError::SampleCountMismatch {
points: points.ncols(),
times: times.len(),
});
}
if !speed.is_finite() || speed <= 0.0 {
return Err(AnimationInputError::InvalidSpeed);
}
for column in 0..points.ncols() {
for row in 0..D {
let value = points[(row, column)];
if !value.is_finite() || !(value as f32).is_finite() {
return Err(AnimationInputError::InvalidCoordinate { row, column });
}
}
}
for row in 0..D {
let (min, max) =
(0..points.ncols()).fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), column| {
let value = points[(row, column)];
(min.min(value), max.max(value))
});
if !((max - min) as f32).is_finite() {
return Err(AnimationInputError::InvalidCoordinateRange { axis: row });
}
}
for (index, time) in times.iter().enumerate() {
if !time.is_finite() {
return Err(AnimationInputError::NonFiniteTime { index });
}
}
let n = times.len();
if n > 1 {
let first_delta = times[1] - times[0];
if first_delta == 0.0 {
return Err(AnimationInputError::NonMonotoneTime { index: 1 });
}
let forward = first_delta > 0.0;
for index in 1..n {
let delta = times[index] - times[index - 1];
if (forward && delta <= 0.0) || (!forward && delta >= 0.0) {
return Err(AnimationInputError::NonMonotoneTime { index });
}
}
}
let mut intervals = Vec::with_capacity(n);
for index in 0..n {
let raw_delta = if n == 1 {
0.05
} else if index + 1 < n {
(times[index + 1] - times[index]).abs()
} else {
(times[index] - times[index - 1]).abs()
};
let seconds = raw_delta / f64::from(speed);
if !seconds.is_finite() || seconds > 1.0e9 {
return Err(AnimationInputError::UnrepresentableTimeInterval { index });
}
intervals.push((seconds as f32).max(1.0e-6));
}
Ok(intervals)
}
pub(crate) fn advance_animation_clock(
current_index: usize,
elapsed_in_interval: f64,
frame_delta: f64,
intervals: &[f32],
) -> (usize, f64) {
debug_assert!(!intervals.is_empty());
debug_assert!(current_index < intervals.len());
debug_assert!(elapsed_in_interval.is_finite() && elapsed_in_interval >= 0.0);
debug_assert!(frame_delta.is_finite() && frame_delta >= 0.0);
let cycle_seconds: f64 = intervals.iter().map(|&value| f64::from(value)).sum();
let mut remaining = (elapsed_in_interval + frame_delta) % cycle_seconds;
let mut index = current_index;
for _ in 0..intervals.len() {
let interval = f64::from(intervals[index]);
if remaining < interval {
return (index, remaining);
}
remaining -= interval;
index = (index + 1) % intervals.len();
}
(index, 0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_forward_backward_and_single_sample_timing() {
let points = DMatrix::from_row_slice(2, 3, &[0.0, 1.0, 2.0, 0.0, 1.0, 0.0]);
let forward = DVector::from_vec(vec![0.0, 0.25, 1.0]);
assert_eq!(
validate_trajectory::<2>(&points, &forward, 2.0).unwrap(),
vec![0.125, 0.375, 0.375]
);
let backward = DVector::from_vec(vec![1.0, 0.25, 0.0]);
assert_eq!(
validate_trajectory::<2>(&points, &backward, 1.0).unwrap(),
vec![0.75, 0.25, 0.25]
);
let one_point = DMatrix::from_row_slice(2, 1, &[1.0, 2.0]);
assert_eq!(
validate_trajectory::<2>(&one_point, &DVector::from_vec(vec![3.0]), 1.0).unwrap(),
vec![0.05]
);
}
#[test]
fn playback_clock_preserves_overshoot_and_wraps_irregular_samples() {
let intervals = [0.125, 0.25, 0.125];
assert_eq!(
advance_animation_clock(0, 0.0, 0.1875, &intervals),
(1, 0.0625)
);
assert_eq!(advance_animation_clock(2, 0.0, 0.25, &intervals), (1, 0.0));
assert_eq!(
advance_animation_clock(0, 0.0, 0.6875, &intervals),
(1, 0.0625)
);
}
#[test]
fn display_normalization_fits_tiny_offset_trajectory_and_retains_units() {
let points = DMatrix::from_row_slice(
2,
3,
&[1.0e6, 1.0e6 + 0.002, 1.0e6 + 0.004, -3.0, -2.998, -2.996],
);
let display = normalize_for_display::<2>(&points);
assert!((display.points[(0, 0)] + 2.0).abs() < 1.0e-5);
assert!((display.points[(0, 2)] - 2.0).abs() < 1.0e-5);
assert!((display.points[(1, 0)] + 2.0).abs() < 1.0e-5);
assert!((display.points[(1, 2)] - 2.0).abs() < 1.0e-5);
for axis in 0..2 {
let restored = f64::from(display.points[(axis, 1)]) * display.units_per_display_unit
+ display.origin[axis];
assert!((restored - points[(axis, 1)]).abs() < 1.0e-9);
}
}
#[test]
fn rejects_unsafe_shapes_times_coordinates_and_speed() {
let points = DMatrix::from_row_slice(2, 2, &[0.0, 1.0, 0.0, 1.0]);
let times = DVector::from_vec(vec![0.0, 1.0]);
assert!(matches!(
validate_trajectory::<3>(&points, ×, 1.0),
Err(AnimationInputError::InvalidDimension { .. })
));
assert!(matches!(
validate_trajectory::<2>(&points, &DVector::from_vec(vec![0.0]), 1.0),
Err(AnimationInputError::SampleCountMismatch { .. })
));
assert!(matches!(
validate_trajectory::<2>(&points, ×, f32::NAN),
Err(AnimationInputError::InvalidSpeed)
));
assert!(matches!(
validate_trajectory::<2>(
&DMatrix::from_row_slice(2, 1, &[f64::INFINITY, 0.0]),
&DVector::from_vec(vec![0.0]),
1.0
),
Err(AnimationInputError::InvalidCoordinate { .. })
));
assert!(matches!(
validate_trajectory::<2>(&points, &DVector::from_vec(vec![0.0, 0.0]), 1.0),
Err(AnimationInputError::NonMonotoneTime { .. })
));
assert!(matches!(
validate_trajectory::<2>(&points, &DVector::from_vec(vec![0.0, 2.0e9]), 1.0),
Err(AnimationInputError::UnrepresentableTimeInterval { .. })
));
assert!(matches!(
validate_trajectory::<2>(
&DMatrix::from_row_slice(
2,
2,
&[f64::from(f32::MAX), -f64::from(f32::MAX), 0.0, 0.0]
),
×,
1.0
),
Err(AnimationInputError::InvalidCoordinateRange { axis: 0 })
));
}
}