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}