use dualis_units::{Frequency, LengthVec, Time, VelocityVec};
use glam::{DQuat, DVec3};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Motion {
Drift {
velocity: VelocityVec,
},
Oscillate {
amplitude: LengthVec,
frequency: Frequency,
#[serde(default)]
phase_deg: f64,
},
Spin {
axis: DVec3,
#[serde(default)]
pivot: LengthVec,
rate_deg_per_s: f64,
},
}
impl Motion {
pub fn rotation_at(&self, t: Time) -> DQuat {
match self {
Motion::Spin {
axis,
rate_deg_per_s,
..
} => DQuat::from_axis_angle(
axis.normalize_or(DVec3::Z),
(rate_deg_per_s * t.to_si()).to_radians(),
),
_ => DQuat::IDENTITY,
}
}
pub fn move_point(&self, p: LengthVec, t: Time) -> LengthVec {
match self {
Motion::Drift { velocity } => p + *velocity * t,
Motion::Oscillate {
amplitude,
frequency,
phase_deg,
} => {
let phase =
std::f64::consts::TAU * frequency.to_si() * t.to_si() + phase_deg.to_radians();
p + *amplitude * phase.sin()
}
Motion::Spin { pivot, .. } => {
let offset = (p - *pivot).to_si();
*pivot + LengthVec::from_si(self.rotation_at(t) * offset)
}
}
}
pub fn turn(&self, d: DVec3, t: Time) -> DVec3 {
match self {
Motion::Spin { .. } => self.rotation_at(t) * d,
_ => d,
}
}
pub fn velocity_at(&self, p: LengthVec, t: Time) -> VelocityVec {
match self {
Motion::Drift { velocity } => *velocity,
Motion::Oscillate {
amplitude,
frequency,
phase_deg,
} => {
let omega = std::f64::consts::TAU * frequency.to_si();
let phase = omega * t.to_si() + phase_deg.to_radians();
VelocityVec::from_si(amplitude.to_si() * omega * phase.cos())
}
Motion::Spin {
axis,
pivot,
rate_deg_per_s,
} => {
let omega = axis.normalize_or(DVec3::Z) * rate_deg_per_s.to_radians();
let r = (self.move_point(p, t) - *pivot).to_si();
VelocityVec::from_si(omega.cross(r))
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Strobe {
pub period: Time,
pub duty: f64,
#[serde(default)]
pub phase: Time,
}
impl Strobe {
pub fn new(period: Time, duty: f64) -> Strobe {
Strobe {
period,
duty,
phase: Time::ZERO,
}
}
pub fn is_on(&self, t: Time) -> bool {
if self.period.to_si() <= 0.0 {
return true;
}
let duty = self.duty.clamp(0.0, 1.0);
if duty >= 1.0 {
return true;
}
(((t - self.phase).to_si()) / self.period.to_si()).rem_euclid(1.0) < duty
}
fn on_time_to(&self, x: Time) -> f64 {
let duty = self.duty.clamp(0.0, 1.0);
let u = (x - self.phase).to_si() / self.period.to_si();
let whole = u.floor();
(whole * duty + (u - whole).min(duty)) * self.period.to_si()
}
pub fn on_fraction(&self, start: Time, length: Time) -> f64 {
if self.period.to_si() <= 0.0 || self.duty >= 1.0 {
return 1.0;
}
if length.to_si() <= 0.0 {
return f64::from(self.is_on(start));
}
((self.on_time_to(start + length) - self.on_time_to(start)) / length.to_si())
.clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use dualis_units::Length;
#[test]
fn drift_moves_at_its_velocity() {
let m = Motion::Drift {
velocity: VelocityVec::mm_per_s(120.0, 0.0, -5.0),
};
assert_eq!(m.move_point(LengthVec::ZERO, Time::ZERO), LengthVec::ZERO);
let after = m.move_point(LengthVec::mm(1.0, 2.0, 3.0), Time::s(0.25));
assert!((after - LengthVec::mm(31.0, 2.0, 1.75)).length().to_si() < 1e-12);
assert_eq!(m.turn(DVec3::Z, Time::s(10.0)), DVec3::Z);
assert_eq!(
m.velocity_at(LengthVec::mm(5.0, 0.0, 0.0), Time::s(3.0)),
VelocityVec::mm_per_s(120.0, 0.0, -5.0)
);
}
#[test]
fn oscillation_swings_about_where_it_was_authored() {
let amplitude = LengthVec::mm(0.0, 0.02, 0.0);
let m = Motion::Oscillate {
amplitude,
frequency: Frequency::hz(50.0),
phase_deg: 0.0,
};
let p = LengthVec::mm(1.0, 1.0, 1.0);
let period = Time::s(1.0 / 50.0);
let at = |t: Time| m.move_point(p, t);
assert!((at(Time::ZERO) - p).length().to_si() < 1e-12);
assert!((at(period / 4.0) - (p + amplitude)).length().to_si() < 1e-12);
assert!((at(period / 2.0) - p).length().to_si() < 1e-12);
assert!((at(period * 0.75) - (p - amplitude)).length().to_si() < 1e-12);
assert!((at(period) - p).length().to_si() < 1e-12);
}
#[test]
fn oscillation_is_fastest_through_the_centre() {
let amplitude = LengthVec::mm(0.0, 0.02, 0.0);
let f = 50.0;
let m = Motion::Oscillate {
amplitude,
frequency: Frequency::hz(f),
phase_deg: 0.0,
};
let p = LengthVec::ZERO;
let period = Time::s(1.0 / f);
let peak = std::f64::consts::TAU * f * amplitude.length().to_si();
assert!((m.velocity_at(p, Time::ZERO).length().to_si() - peak).abs() < 1e-12);
assert!(m.velocity_at(p, period / 4.0).length().to_si() < 1e-12);
let peak_um_per_s = peak * 1e6;
assert!((peak_um_per_s - 6283.2).abs() < 0.1, "got {peak_um_per_s}");
}
#[test]
fn a_spin_turns_both_position_and_axis() {
let pivot = LengthVec::mm(5.0, 0.0, 0.0);
let m = Motion::Spin {
axis: DVec3::Z,
pivot,
rate_deg_per_s: 90.0,
};
let p = LengthVec::mm(7.0, 0.0, 0.0);
let after = m.move_point(p, Time::s(1.0));
assert!(
(after - LengthVec::mm(5.0, 2.0, 0.0)).length().to_si() < 1e-12,
"got {after:?}"
);
assert!(
((after - pivot).length() - (p - pivot).length())
.abs()
.to_si()
< 1e-12
);
let axis = m.turn(DVec3::X, Time::s(1.0));
assert!((axis - DVec3::Y).length() < 1e-12, "got {axis}");
}
#[test]
fn spin_velocity_grows_with_the_radius() {
let pivot = LengthVec::ZERO;
let m = Motion::Spin {
axis: DVec3::Z,
pivot,
rate_deg_per_s: 90.0,
};
let omega = 90f64.to_radians();
for radius_mm in [1.0, 7.0, 100.0] {
let p = LengthVec::mm(radius_mm, 0.0, 0.0);
let v = m.velocity_at(p, Time::ZERO);
let expected = omega * Length::mm(radius_mm).to_si();
assert!((v.length().to_si() - expected).abs() < 1e-12, "{v:?}");
assert!(v.along(p.normalize()).to_si().abs() < 1e-12);
}
assert!(m.velocity_at(pivot, Time::ZERO).length().to_si() < 1e-12);
}
#[test]
fn a_strobe_is_on_for_its_duty_cycle() {
let s = Strobe::new(Time::s(1.0 / 60.0), 0.1);
assert!(s.is_on(Time::ZERO));
assert!(s.is_on(Time::s(0.9 * 0.1 / 60.0)));
assert!(!s.is_on(Time::s(0.5 / 60.0)));
assert!(s.is_on(Time::s(1.0 / 60.0)));
for window in [1.0 / 60.0, 3.0 / 60.0, 1.0] {
let f = s.on_fraction(Time::ZERO, Time::s(window));
assert!(
(f - 0.1).abs() < 1e-9,
"over {window} s the light should be on a tenth of the time, got {f}"
);
}
let f = s.on_fraction(Time::ZERO, Time::s(0.05 / 60.0));
assert!((f - 1.0).abs() < 1e-9, "still inside the pulse, got {f}");
assert_eq!(
Strobe::new(Time::s(1.0), 1.0).on_fraction(Time::s(0.3), Time::s(0.4)),
1.0
);
assert_eq!(
Strobe::new(Time::ZERO, 0.1).on_fraction(Time::s(0.3), Time::s(0.4)),
1.0
);
}
}