Skip to main content

dualis_core/
motion.rs

1//! How a scene changes with time, where it changes in closed form.
2//!
3//! Everything here is a function of a time: ask for the world at `t` and you get
4//! it, with no state carried between calls. That is what lets a video frame be
5//! sampled several times across its own exposure — which is where motion blur
6//! comes from — and it keeps a recording reproducible, since frame 7 does not
7//! depend on having rendered frame 6.
8//!
9//! A scene as authored is the scene at `t = 0`.
10//!
11//! # Where this stops
12//!
13//! Drift, oscillation and spin have closed forms. Three bodies under gravity do
14//! not, and neither do contact, heat or a stiff reaction. Those go through
15//! [`Integrator`](crate::integrator::Integrator), which gives up the
16//! frame-independence in exchange for being able to express them at all. This
17//! module is the fast path, not the general one — and a domain built on it can
18//! declare itself [`Kind::QuasiStatic`](crate::sim::Kind::QuasiStatic), because
19//! there is no state for a scheduler to march.
20
21use dualis_units::{Frequency, LengthVec, Time, VelocityVec};
22use glam::{DQuat, DVec3};
23use serde::{Deserialize, Serialize};
24
25/// Rigid motion of one element, assembly or source.
26#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum Motion {
29    /// Constant velocity: a conveyor belt, a translation stage, or the drift of a
30    /// star field across an untracked telescope.
31    Drift {
32        /// How fast and which way.
33        velocity: VelocityVec,
34    },
35    /// Sinusoidal displacement about the authored position, which is the centre of
36    /// the swing. `amplitude` carries both size and direction, so a floor
37    /// vibration and an axial focus dither are the same variant.
38    Oscillate {
39        /// Peak displacement, carrying its direction. Half the peak-to-peak swing.
40        amplitude: LengthVec,
41        /// Cycles per second.
42        frequency: Frequency,
43        /// Where in the cycle `t = 0` falls, in degrees. Zero starts at the centre moving
44        /// towards `amplitude`.
45        #[serde(default)]
46        phase_deg: f64,
47    },
48    /// Rotation at `rate_deg_per_s` about `axis` through `pivot`. A rotating stage
49    /// or a scanning mirror: it turns what it carries as well as moving it, so an
50    /// element's own axis tilts too.
51    Spin {
52        /// Rotation axis, normalised internally. Right-handed about this direction.
53        axis: DVec3,
54        /// A point the axis passes through. Defaults to the origin, so a spin about the
55        /// authored position needs nothing said.
56        #[serde(default)]
57        pivot: LengthVec,
58        /// Degrees per second, positive being right-handed about `axis`.
59        rate_deg_per_s: f64,
60    },
61}
62
63impl Motion {
64    /// The rotation this motion has accumulated by time `t`. Identity for the
65    /// purely translational variants.
66    pub fn rotation_at(&self, t: Time) -> DQuat {
67        match self {
68            Motion::Spin {
69                axis,
70                rate_deg_per_s,
71                ..
72            } => DQuat::from_axis_angle(
73                axis.normalize_or(DVec3::Z),
74                (rate_deg_per_s * t.to_si()).to_radians(),
75            ),
76            _ => DQuat::IDENTITY,
77        }
78    }
79
80    /// Where a point authored at `p` sits at time `t`.
81    pub fn move_point(&self, p: LengthVec, t: Time) -> LengthVec {
82        match self {
83            Motion::Drift { velocity } => p + *velocity * t,
84            Motion::Oscillate {
85                amplitude,
86                frequency,
87                phase_deg,
88            } => {
89                let phase =
90                    std::f64::consts::TAU * frequency.to_si() * t.to_si() + phase_deg.to_radians();
91                p + *amplitude * phase.sin()
92            }
93            Motion::Spin { pivot, .. } => {
94                let offset = (p - *pivot).to_si();
95                *pivot + LengthVec::from_si(self.rotation_at(t) * offset)
96            }
97        }
98    }
99
100    /// Where a direction authored as `d` points at time `t`. A direction is
101    /// dimensionless, and translation does not change it.
102    pub fn turn(&self, d: DVec3, t: Time) -> DVec3 {
103        match self {
104            Motion::Spin { .. } => self.rotation_at(t) * d,
105            _ => d,
106        }
107    }
108
109    /// Velocity of the point authored at `p`, at time `t`.
110    ///
111    /// Differentiated in closed form rather than by finite difference, so it is
112    /// exact — which matters because this is what sets a motion-blur streak length
113    /// and what a Doppler shift would be computed from.
114    pub fn velocity_at(&self, p: LengthVec, t: Time) -> VelocityVec {
115        match self {
116            Motion::Drift { velocity } => *velocity,
117            Motion::Oscillate {
118                amplitude,
119                frequency,
120                phase_deg,
121            } => {
122                let omega = std::f64::consts::TAU * frequency.to_si();
123                let phase = omega * t.to_si() + phase_deg.to_radians();
124                VelocityVec::from_si(amplitude.to_si() * omega * phase.cos())
125            }
126            Motion::Spin {
127                axis,
128                pivot,
129                rate_deg_per_s,
130            } => {
131                // v = ω × r, with ω along the axis.
132                let omega = axis.normalize_or(DVec3::Z) * rate_deg_per_s.to_radians();
133                let r = (self.move_point(p, t) - *pivot).to_si();
134                VelocityVec::from_si(omega.cross(r))
135            }
136        }
137    }
138}
139
140/// How a light is gated in time.
141///
142/// This is how machine vision freezes a moving part: fire the light for a small
143/// fraction of the frame and the object barely moves while it is lit. The trade is
144/// brightness — gating away nine tenths of the time delivers a tenth of the energy
145/// — and nothing here hides that. Watch out for auto-exposure, which will happily
146/// lengthen the integration to win the light back and undo the whole point; a
147/// strobed station wants a fixed exposure.
148#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
149pub struct Strobe {
150    /// Time between pulses. Match it to the frame period to fire once per frame.
151    pub period: Time,
152    /// Fraction of each period the light is on, 0..1.
153    pub duty: f64,
154    /// Where the pulse sits inside the period.
155    #[serde(default)]
156    pub phase: Time,
157}
158
159impl Strobe {
160    /// A strobe of the given period, open for `duty` of each cycle.
161    ///
162    /// `duty` is a fraction rather than a time, so changing the rate of a strobe leaves its
163    /// exposure *ratio* alone — which is how a camera behaves and not how a shutter does.
164    pub fn new(period: Time, duty: f64) -> Strobe {
165        Strobe {
166            period,
167            duty,
168            phase: Time::ZERO,
169        }
170    }
171
172    /// Whether the light is on at time `t`.
173    pub fn is_on(&self, t: Time) -> bool {
174        if self.period.to_si() <= 0.0 {
175            return true;
176        }
177        let duty = self.duty.clamp(0.0, 1.0);
178        if duty >= 1.0 {
179            return true;
180        }
181        (((t - self.phase).to_si()) / self.period.to_si()).rem_euclid(1.0) < duty
182    }
183
184    /// Total on-time from the first pulse edge up to `x`. Whole periods each
185    /// contribute `duty`, and the part-period at the end contributes whatever of
186    /// the pulse it reaches.
187    fn on_time_to(&self, x: Time) -> f64 {
188        let duty = self.duty.clamp(0.0, 1.0);
189        let u = (x - self.phase).to_si() / self.period.to_si();
190        let whole = u.floor();
191        (whole * duty + (u - whole).min(duty)) * self.period.to_si()
192    }
193
194    /// Fraction of a window of `length` opening at `start` for which the light is
195    /// on. Exact, so it can be checked against the duty cycle — and it is what
196    /// says how much of an exposure's light a strobe actually delivers.
197    pub fn on_fraction(&self, start: Time, length: Time) -> f64 {
198        if self.period.to_si() <= 0.0 || self.duty >= 1.0 {
199            return 1.0;
200        }
201        if length.to_si() <= 0.0 {
202            return f64::from(self.is_on(start));
203        }
204        ((self.on_time_to(start + length) - self.on_time_to(start)) / length.to_si())
205            .clamp(0.0, 1.0)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use dualis_units::Length;
213
214    /// Drift is exactly velocity x time, and it does not turn anything.
215    #[test]
216    fn drift_moves_at_its_velocity() {
217        let m = Motion::Drift {
218            velocity: VelocityVec::mm_per_s(120.0, 0.0, -5.0),
219        };
220        assert_eq!(m.move_point(LengthVec::ZERO, Time::ZERO), LengthVec::ZERO);
221        let after = m.move_point(LengthVec::mm(1.0, 2.0, 3.0), Time::s(0.25));
222        assert!((after - LengthVec::mm(31.0, 2.0, 1.75)).length().to_si() < 1e-12);
223        assert_eq!(m.turn(DVec3::Z, Time::s(10.0)), DVec3::Z);
224        // A drift's velocity is the same everywhere and always.
225        assert_eq!(
226            m.velocity_at(LengthVec::mm(5.0, 0.0, 0.0), Time::s(3.0)),
227            VelocityVec::mm_per_s(120.0, 0.0, -5.0)
228        );
229    }
230
231    /// A sine wave, checked at the four points where its value is exact.
232    #[test]
233    fn oscillation_swings_about_where_it_was_authored() {
234        let amplitude = LengthVec::mm(0.0, 0.02, 0.0);
235        let m = Motion::Oscillate {
236            amplitude,
237            frequency: Frequency::hz(50.0),
238            phase_deg: 0.0,
239        };
240        let p = LengthVec::mm(1.0, 1.0, 1.0);
241        let period = Time::s(1.0 / 50.0);
242        let at = |t: Time| m.move_point(p, t);
243        assert!((at(Time::ZERO) - p).length().to_si() < 1e-12);
244        assert!((at(period / 4.0) - (p + amplitude)).length().to_si() < 1e-12);
245        assert!((at(period / 2.0) - p).length().to_si() < 1e-12);
246        assert!((at(period * 0.75) - (p - amplitude)).length().to_si() < 1e-12);
247        // And it is periodic, which a drift is not.
248        assert!((at(period) - p).length().to_si() < 1e-12);
249    }
250
251    /// The oscillator's velocity is fastest through the centre and zero at the
252    /// extremes, with a peak of exactly `2 pi f A` — a closed form, so this checks
253    /// the derivative rather than assuming it.
254    #[test]
255    fn oscillation_is_fastest_through_the_centre() {
256        let amplitude = LengthVec::mm(0.0, 0.02, 0.0);
257        let f = 50.0;
258        let m = Motion::Oscillate {
259            amplitude,
260            frequency: Frequency::hz(f),
261            phase_deg: 0.0,
262        };
263        let p = LengthVec::ZERO;
264        let period = Time::s(1.0 / f);
265        let peak = std::f64::consts::TAU * f * amplitude.length().to_si();
266        assert!((m.velocity_at(p, Time::ZERO).length().to_si() - peak).abs() < 1e-12);
267        // At the top of the swing it has stopped.
268        assert!(m.velocity_at(p, period / 4.0).length().to_si() < 1e-12);
269        // 6283 micrometres per second for a 20 micrometre swing at 50 Hz — a small
270        // motion moving fast enough to smear a millisecond exposure by six
271        // micrometres, which is what decides whether the image is sharp.
272        let peak_um_per_s = peak * 1e6;
273        assert!((peak_um_per_s - 6283.2).abs() < 0.1, "got {peak_um_per_s}");
274    }
275
276    /// A quarter turn about +z takes +x to +y, and keeps every point at its own
277    /// radius from the pivot.
278    #[test]
279    fn a_spin_turns_both_position_and_axis() {
280        let pivot = LengthVec::mm(5.0, 0.0, 0.0);
281        let m = Motion::Spin {
282            axis: DVec3::Z,
283            pivot,
284            rate_deg_per_s: 90.0,
285        };
286        let p = LengthVec::mm(7.0, 0.0, 0.0);
287        let after = m.move_point(p, Time::s(1.0));
288        assert!(
289            (after - LengthVec::mm(5.0, 2.0, 0.0)).length().to_si() < 1e-12,
290            "got {after:?}"
291        );
292        assert!(
293            ((after - pivot).length() - (p - pivot).length())
294                .abs()
295                .to_si()
296                < 1e-12
297        );
298        let axis = m.turn(DVec3::X, Time::s(1.0));
299        assert!((axis - DVec3::Y).length() < 1e-12, "got {axis}");
300    }
301
302    /// Rotational velocity is `omega x r`: perpendicular to the radius, and
303    /// proportional to it. A point on the pivot does not move at all.
304    #[test]
305    fn spin_velocity_grows_with_the_radius() {
306        let pivot = LengthVec::ZERO;
307        let m = Motion::Spin {
308            axis: DVec3::Z,
309            pivot,
310            rate_deg_per_s: 90.0,
311        };
312        let omega = 90f64.to_radians();
313        for radius_mm in [1.0, 7.0, 100.0] {
314            let p = LengthVec::mm(radius_mm, 0.0, 0.0);
315            let v = m.velocity_at(p, Time::ZERO);
316            let expected = omega * Length::mm(radius_mm).to_si();
317            assert!((v.length().to_si() - expected).abs() < 1e-12, "{v:?}");
318            // Perpendicular to the radius, as circular motion must be.
319            assert!(v.along(p.normalize()).to_si().abs() < 1e-12);
320        }
321        assert!(m.velocity_at(pivot, Time::ZERO).length().to_si() < 1e-12);
322    }
323
324    /// A strobe is on for its duty cycle and nothing more, and averaged over a
325    /// whole period the fraction it is on *is* the duty cycle.
326    #[test]
327    fn a_strobe_is_on_for_its_duty_cycle() {
328        let s = Strobe::new(Time::s(1.0 / 60.0), 0.1);
329        assert!(s.is_on(Time::ZERO));
330        assert!(s.is_on(Time::s(0.9 * 0.1 / 60.0)));
331        assert!(!s.is_on(Time::s(0.5 / 60.0)));
332        // Periodic: the same instant one period later.
333        assert!(s.is_on(Time::s(1.0 / 60.0)));
334
335        for window in [1.0 / 60.0, 3.0 / 60.0, 1.0] {
336            let f = s.on_fraction(Time::ZERO, Time::s(window));
337            assert!(
338                (f - 0.1).abs() < 1e-9,
339                "over {window} s the light should be on a tenth of the time, got {f}"
340            );
341        }
342        // A window that opens inside the pulse and closes before the next one.
343        let f = s.on_fraction(Time::ZERO, Time::s(0.05 / 60.0));
344        assert!((f - 1.0).abs() < 1e-9, "still inside the pulse, got {f}");
345        // Duty 1 and a zero period are both "always on".
346        assert_eq!(
347            Strobe::new(Time::s(1.0), 1.0).on_fraction(Time::s(0.3), Time::s(0.4)),
348            1.0
349        );
350        assert_eq!(
351            Strobe::new(Time::ZERO, 0.1).on_fraction(Time::s(0.3), Time::s(0.4)),
352            1.0
353        );
354    }
355}