Skip to main content

record_player/
gesture.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::mechanics::{
5    DeckMechanicalControl, PhysicalDeckConfig, MAXIMUM_DECK_RATE, MAXIMUM_HAND_CONTACT_RADIUS_M,
6    MAXIMUM_HAND_NORMAL_FORCE_N,
7};
8use crate::timed_control::{PlayerControl, TimedPlayerControl};
9
10const SNAPSHOT_VERSION: u32 = 1;
11const NANOSECONDS_PER_SECOND: u128 = 1_000_000_000;
12const MAXIMUM_SAMPLE_RATE_HZ: u32 = 768_000;
13
14/// Maps a normalized pointer-pressure value to a normal hand force.
15#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ScratchPressureCalibration {
18    pub zero_pressure_force_n: f64,
19    pub unit_pressure_force_n: f64,
20    pub unreported_pressure_force_n: f64,
21}
22
23impl ScratchPressureCalibration {
24    pub fn validate(self) -> Result<Self, ScratchGestureError> {
25        validate_force("zeroPressureForceN", self.zero_pressure_force_n)?;
26        validate_force("unitPressureForceN", self.unit_pressure_force_n)?;
27        validate_force("unreportedPressureForceN", self.unreported_pressure_force_n)?;
28        if self.unit_pressure_force_n < self.zero_pressure_force_n {
29            return Err(ScratchGestureError::InvalidConfig {
30                field: "unitPressureForceN",
31            });
32        }
33        Ok(self)
34    }
35
36    pub fn normal_force_n(
37        self,
38        normalized_pressure: Option<f64>,
39    ) -> Result<f64, ScratchGestureError> {
40        self.validate()?;
41        match normalized_pressure {
42            Some(value) if value.is_finite() && (0.0..=1.0).contains(&value) => Ok(self
43                .zero_pressure_force_n
44                + value * (self.unit_pressure_force_n - self.zero_pressure_force_n)),
45            Some(_) => Err(ScratchGestureError::InvalidSample {
46                field: "normalizedPressure",
47            }),
48            None => Ok(self.unreported_pressure_force_n),
49        }
50    }
51}
52
53impl Default for ScratchPressureCalibration {
54    fn default() -> Self {
55        Self {
56            zero_pressure_force_n: 0.5,
57            unit_pressure_force_n: 5.0,
58            unreported_pressure_force_n: 2.5,
59        }
60    }
61}
62
63/// Configures sample-clock scheduling and pointer calibration.
64#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ScratchGestureConfig {
67    pub internal_sample_rate_hz: u32,
68    pub lookahead_frames: u32,
69    pub deck: PhysicalDeckConfig,
70    pub pressure: ScratchPressureCalibration,
71}
72
73impl ScratchGestureConfig {
74    pub fn for_deck(
75        deck: PhysicalDeckConfig,
76        internal_sample_rate_hz: u32,
77        lookahead_frames: u32,
78    ) -> Result<Self, ScratchGestureError> {
79        deck.validate()
80            .map_err(|_| ScratchGestureError::InvalidConfig { field: "deck" })?;
81        Self {
82            internal_sample_rate_hz,
83            lookahead_frames,
84            deck,
85            pressure: ScratchPressureCalibration::default(),
86        }
87        .validate()
88    }
89
90    pub fn validate(self) -> Result<Self, ScratchGestureError> {
91        if self.internal_sample_rate_hz == 0
92            || self.internal_sample_rate_hz > MAXIMUM_SAMPLE_RATE_HZ
93        {
94            return Err(ScratchGestureError::InvalidConfig {
95                field: "internalSampleRateHz",
96            });
97        }
98        self.deck
99            .validate()
100            .map_err(|_| ScratchGestureError::InvalidConfig { field: "deck" })?;
101        self.pressure.validate()?;
102        Ok(self)
103    }
104
105    pub fn maximum_hand_angular_velocity_rad_s(self) -> f64 {
106        MAXIMUM_DECK_RATE * self.deck.nominal_angular_velocity_rad_s()
107    }
108}
109
110/// Describes one pointer sample in the host's monotonic time domain.
111#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ScratchPointerSample {
114    pub pointer_id: u64,
115    pub source_time_ns: u64,
116    pub angle_rad: f64,
117    pub contact_radius_m: f64,
118    pub normalized_pressure: Option<f64>,
119}
120
121impl ScratchPointerSample {
122    fn validate(self, pressure: ScratchPressureCalibration) -> Result<Self, ScratchGestureError> {
123        if !self.angle_rad.is_finite() {
124            return Err(ScratchGestureError::InvalidSample { field: "angleRad" });
125        }
126        if !self.contact_radius_m.is_finite()
127            || self.contact_radius_m <= 0.0
128            || self.contact_radius_m > MAXIMUM_HAND_CONTACT_RADIUS_M
129        {
130            return Err(ScratchGestureError::InvalidSample {
131                field: "contactRadiusM",
132            });
133        }
134        pressure.normal_force_n(self.normalized_pressure)?;
135        Ok(self)
136    }
137}
138
139/// Contains one hand control that is ready for the player timeline.
140#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct ScheduledScratchHandControl {
143    pub absolute_frame: u64,
144    pub pointer_id: u64,
145    pub hand_contact: bool,
146    pub hand_target_angle_rad: Option<f64>,
147    pub hand_target_angular_velocity_rad_s: f64,
148    pub hand_normal_force_n: f64,
149    pub hand_contact_radius_m: f64,
150    pub raw_pointer_angular_velocity_rad_s: f64,
151    pub velocity_was_limited: bool,
152    pub wrap_was_ambiguous: bool,
153    pub added_late_shift_frames: u64,
154    pub total_late_shift_frames: u64,
155}
156
157impl ScheduledScratchHandControl {
158    /// Replaces only the hand fields in a complete player control.
159    pub fn merge(self, sequence: u64, mut control: PlayerControl) -> TimedPlayerControl {
160        control.deck = self.merge_deck(control.deck);
161        TimedPlayerControl::new(self.absolute_frame, sequence, control)
162    }
163
164    /// Replaces only the hand fields in a deck control.
165    pub fn merge_deck(self, mut control: DeckMechanicalControl) -> DeckMechanicalControl {
166        control.hand_contact = self.hand_contact;
167        control.hand_target_angle_rad = self.hand_target_angle_rad;
168        control.hand_target_angular_velocity_rad_s = self.hand_target_angular_velocity_rad_s;
169        control.hand_normal_force_n = self.hand_normal_force_n;
170        control.hand_contact_radius_m = self.hand_contact_radius_m;
171        control
172    }
173}
174
175/// Stores all gesture state that can affect later controls.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct ScratchGestureSnapshot {
179    version: u32,
180    config: ScratchGestureConfig,
181    active_pointer_id: Option<u64>,
182    anchor_source_time_ns: u64,
183    anchor_schedule_frame: u64,
184    last_source_time_ns: u64,
185    last_pointer_angle_rad: f64,
186    unwrapped_pointer_delta_rad: f64,
187    record_angle_at_begin_rad: f64,
188    last_scheduled_frame: u64,
189    total_late_shift_frames: u64,
190}
191
192/// Converts pointer motion to physical hand setpoints.
193#[derive(Debug, Clone, PartialEq)]
194pub struct ScratchGestureMapper {
195    config: ScratchGestureConfig,
196    active_pointer_id: Option<u64>,
197    anchor_source_time_ns: u64,
198    anchor_schedule_frame: u64,
199    last_source_time_ns: u64,
200    last_pointer_angle_rad: f64,
201    unwrapped_pointer_delta_rad: f64,
202    record_angle_at_begin_rad: f64,
203    last_scheduled_frame: u64,
204    total_late_shift_frames: u64,
205}
206
207impl ScratchGestureMapper {
208    pub fn new(config: ScratchGestureConfig) -> Result<Self, ScratchGestureError> {
209        let config = config.validate()?;
210        Ok(Self {
211            config,
212            active_pointer_id: None,
213            anchor_source_time_ns: 0,
214            anchor_schedule_frame: 0,
215            last_source_time_ns: 0,
216            last_pointer_angle_rad: 0.0,
217            unwrapped_pointer_delta_rad: 0.0,
218            record_angle_at_begin_rad: 0.0,
219            last_scheduled_frame: 0,
220            total_late_shift_frames: 0,
221        })
222    }
223
224    pub const fn config(&self) -> ScratchGestureConfig {
225        self.config
226    }
227
228    pub const fn active_pointer_id(&self) -> Option<u64> {
229        self.active_pointer_id
230    }
231
232    pub const fn total_late_shift_frames(&self) -> u64 {
233        self.total_late_shift_frames
234    }
235
236    /// Starts a gesture without moving the record at contact time.
237    pub fn begin(
238        &mut self,
239        sample: ScratchPointerSample,
240        minimum_render_frame: u64,
241        record_angle_rad: f64,
242    ) -> Result<ScheduledScratchHandControl, ScratchGestureError> {
243        if self.active_pointer_id.is_some() {
244            return Err(ScratchGestureError::GestureAlreadyActive);
245        }
246        let sample = sample.validate(self.config.pressure)?;
247        if !record_angle_rad.is_finite() {
248            return Err(ScratchGestureError::InvalidRecordAngle);
249        }
250        let frame = minimum_render_frame
251            .checked_add(u64::from(self.config.lookahead_frames))
252            .ok_or(ScratchGestureError::ScheduleOverflow)?;
253        let normal_force_n = self
254            .config
255            .pressure
256            .normal_force_n(sample.normalized_pressure)?;
257        self.active_pointer_id = Some(sample.pointer_id);
258        self.anchor_source_time_ns = sample.source_time_ns;
259        self.anchor_schedule_frame = frame;
260        self.last_source_time_ns = sample.source_time_ns;
261        self.last_pointer_angle_rad = sample.angle_rad;
262        self.unwrapped_pointer_delta_rad = 0.0;
263        self.record_angle_at_begin_rad = record_angle_rad;
264        self.last_scheduled_frame = frame;
265        self.total_late_shift_frames = 0;
266        Ok(ScheduledScratchHandControl {
267            absolute_frame: frame,
268            pointer_id: sample.pointer_id,
269            hand_contact: true,
270            hand_target_angle_rad: Some(record_angle_rad),
271            hand_target_angular_velocity_rad_s: 0.0,
272            hand_normal_force_n: normal_force_n,
273            hand_contact_radius_m: sample.contact_radius_m,
274            raw_pointer_angular_velocity_rad_s: 0.0,
275            velocity_was_limited: false,
276            wrap_was_ambiguous: false,
277            added_late_shift_frames: 0,
278            total_late_shift_frames: 0,
279        })
280    }
281
282    /// Maps one move without low-pass filtering a reversal.
283    pub fn update(
284        &mut self,
285        sample: ScratchPointerSample,
286        minimum_render_frame: u64,
287    ) -> Result<ScheduledScratchHandControl, ScratchGestureError> {
288        self.validate_active_sample(sample)?;
289        let sample = sample.validate(self.config.pressure)?;
290        if sample.source_time_ns < self.last_source_time_ns {
291            return Err(ScratchGestureError::SourceTimeMovedBackward);
292        }
293
294        let elapsed_ns = sample.source_time_ns - self.last_source_time_ns;
295        let delta_angle_rad = wrapped_delta(sample.angle_rad - self.last_pointer_angle_rad);
296        let raw_velocity = if elapsed_ns == 0 {
297            0.0
298        } else {
299            delta_angle_rad * NANOSECONDS_PER_SECOND as f64 / elapsed_ns as f64
300        };
301        let maximum_velocity = self.config.maximum_hand_angular_velocity_rad_s();
302        let limited_velocity = raw_velocity.clamp(-maximum_velocity, maximum_velocity);
303        let wrap_was_ambiguous = elapsed_ns > 0
304            && maximum_velocity * elapsed_ns as f64 / NANOSECONDS_PER_SECOND as f64
305                >= std::f64::consts::PI;
306        let (absolute_frame, added_late_shift_frames) =
307            self.schedule_frame(sample.source_time_ns, minimum_render_frame)?;
308        let normal_force_n = self
309            .config
310            .pressure
311            .normal_force_n(sample.normalized_pressure)?;
312
313        self.last_source_time_ns = sample.source_time_ns;
314        self.last_pointer_angle_rad = sample.angle_rad;
315        self.unwrapped_pointer_delta_rad += delta_angle_rad;
316        self.last_scheduled_frame = absolute_frame;
317
318        Ok(ScheduledScratchHandControl {
319            absolute_frame,
320            pointer_id: sample.pointer_id,
321            hand_contact: true,
322            hand_target_angle_rad: Some(
323                self.record_angle_at_begin_rad + self.unwrapped_pointer_delta_rad,
324            ),
325            hand_target_angular_velocity_rad_s: limited_velocity,
326            hand_normal_force_n: normal_force_n,
327            hand_contact_radius_m: sample.contact_radius_m,
328            raw_pointer_angular_velocity_rad_s: raw_velocity,
329            velocity_was_limited: limited_velocity != raw_velocity,
330            wrap_was_ambiguous,
331            added_late_shift_frames,
332            total_late_shift_frames: self.total_late_shift_frames,
333        })
334    }
335
336    /// Schedules release and makes the mapper available for another pointer.
337    pub fn finish(
338        &mut self,
339        pointer_id: u64,
340        source_time_ns: u64,
341        minimum_render_frame: u64,
342    ) -> Result<ScheduledScratchHandControl, ScratchGestureError> {
343        self.validate_pointer(pointer_id)?;
344        if source_time_ns < self.last_source_time_ns {
345            return Err(ScratchGestureError::SourceTimeMovedBackward);
346        }
347        let (absolute_frame, added_late_shift_frames) =
348            self.schedule_frame(source_time_ns, minimum_render_frame)?;
349        let contact_radius_m = MAXIMUM_HAND_CONTACT_RADIUS_M.min(0.12);
350        self.active_pointer_id = None;
351        self.last_source_time_ns = source_time_ns;
352        self.last_scheduled_frame = absolute_frame;
353        Ok(ScheduledScratchHandControl {
354            absolute_frame,
355            pointer_id,
356            hand_contact: false,
357            hand_target_angle_rad: None,
358            hand_target_angular_velocity_rad_s: 0.0,
359            hand_normal_force_n: 0.0,
360            hand_contact_radius_m: contact_radius_m,
361            raw_pointer_angular_velocity_rad_s: 0.0,
362            velocity_was_limited: false,
363            wrap_was_ambiguous: false,
364            added_late_shift_frames,
365            total_late_shift_frames: self.total_late_shift_frames,
366        })
367    }
368
369    pub fn snapshot(&self) -> ScratchGestureSnapshot {
370        ScratchGestureSnapshot {
371            version: SNAPSHOT_VERSION,
372            config: self.config,
373            active_pointer_id: self.active_pointer_id,
374            anchor_source_time_ns: self.anchor_source_time_ns,
375            anchor_schedule_frame: self.anchor_schedule_frame,
376            last_source_time_ns: self.last_source_time_ns,
377            last_pointer_angle_rad: self.last_pointer_angle_rad,
378            unwrapped_pointer_delta_rad: self.unwrapped_pointer_delta_rad,
379            record_angle_at_begin_rad: self.record_angle_at_begin_rad,
380            last_scheduled_frame: self.last_scheduled_frame,
381            total_late_shift_frames: self.total_late_shift_frames,
382        }
383    }
384
385    pub fn restore(
386        &mut self,
387        snapshot: &ScratchGestureSnapshot,
388    ) -> Result<(), ScratchGestureError> {
389        validate_snapshot(snapshot, self.config)?;
390        self.active_pointer_id = snapshot.active_pointer_id;
391        self.anchor_source_time_ns = snapshot.anchor_source_time_ns;
392        self.anchor_schedule_frame = snapshot.anchor_schedule_frame;
393        self.last_source_time_ns = snapshot.last_source_time_ns;
394        self.last_pointer_angle_rad = snapshot.last_pointer_angle_rad;
395        self.unwrapped_pointer_delta_rad = snapshot.unwrapped_pointer_delta_rad;
396        self.record_angle_at_begin_rad = snapshot.record_angle_at_begin_rad;
397        self.last_scheduled_frame = snapshot.last_scheduled_frame;
398        self.total_late_shift_frames = snapshot.total_late_shift_frames;
399        Ok(())
400    }
401
402    fn validate_active_sample(
403        &self,
404        sample: ScratchPointerSample,
405    ) -> Result<(), ScratchGestureError> {
406        self.validate_pointer(sample.pointer_id)
407    }
408
409    fn validate_pointer(&self, pointer_id: u64) -> Result<(), ScratchGestureError> {
410        match self.active_pointer_id {
411            None => Err(ScratchGestureError::GestureNotActive),
412            Some(active_pointer_id) if active_pointer_id != pointer_id => {
413                Err(ScratchGestureError::PointerMismatch {
414                    active_pointer_id,
415                    received_pointer_id: pointer_id,
416                })
417            }
418            Some(_) => Ok(()),
419        }
420    }
421
422    fn schedule_frame(
423        &mut self,
424        source_time_ns: u64,
425        minimum_render_frame: u64,
426    ) -> Result<(u64, u64), ScratchGestureError> {
427        let elapsed_ns = source_time_ns
428            .checked_sub(self.anchor_source_time_ns)
429            .ok_or(ScratchGestureError::SourceTimeMovedBackward)?;
430        let elapsed_frames =
431            nanoseconds_to_frames(elapsed_ns, self.config.internal_sample_rate_hz)?;
432        let nominal_frame = self
433            .anchor_schedule_frame
434            .checked_add(elapsed_frames)
435            .and_then(|value| value.checked_add(self.total_late_shift_frames))
436            .ok_or(ScratchGestureError::ScheduleOverflow)?;
437        let minimum_frame = minimum_render_frame
438            .checked_add(u64::from(self.config.lookahead_frames))
439            .ok_or(ScratchGestureError::ScheduleOverflow)?;
440        let added_late_shift_frames = minimum_frame.saturating_sub(nominal_frame);
441        self.total_late_shift_frames = self
442            .total_late_shift_frames
443            .checked_add(added_late_shift_frames)
444            .ok_or(ScratchGestureError::ScheduleOverflow)?;
445        let shifted_frame = nominal_frame
446            .checked_add(added_late_shift_frames)
447            .ok_or(ScratchGestureError::ScheduleOverflow)?;
448        Ok((
449            shifted_frame.max(self.last_scheduled_frame),
450            added_late_shift_frames,
451        ))
452    }
453}
454
455fn nanoseconds_to_frames(
456    nanoseconds: u64,
457    sample_rate_hz: u32,
458) -> Result<u64, ScratchGestureError> {
459    let numerator = u128::from(nanoseconds)
460        .checked_mul(u128::from(sample_rate_hz))
461        .and_then(|value| value.checked_add(NANOSECONDS_PER_SECOND / 2))
462        .ok_or(ScratchGestureError::ScheduleOverflow)?;
463    u64::try_from(numerator / NANOSECONDS_PER_SECOND)
464        .map_err(|_| ScratchGestureError::ScheduleOverflow)
465}
466
467fn wrapped_delta(delta_rad: f64) -> f64 {
468    delta_rad.sin().atan2(delta_rad.cos())
469}
470
471fn validate_force(field: &'static str, value: f64) -> Result<(), ScratchGestureError> {
472    if value.is_finite() && (0.0..=MAXIMUM_HAND_NORMAL_FORCE_N).contains(&value) {
473        Ok(())
474    } else {
475        Err(ScratchGestureError::InvalidConfig { field })
476    }
477}
478
479fn validate_snapshot(
480    snapshot: &ScratchGestureSnapshot,
481    expected_config: ScratchGestureConfig,
482) -> Result<(), ScratchGestureError> {
483    if snapshot.version != SNAPSHOT_VERSION {
484        return Err(ScratchGestureError::UnsupportedSnapshotVersion {
485            version: snapshot.version,
486        });
487    }
488    snapshot.config.validate()?;
489    if snapshot.config != expected_config {
490        return Err(ScratchGestureError::SnapshotConfigMismatch);
491    }
492    for (field, value) in [
493        ("lastPointerAngleRad", snapshot.last_pointer_angle_rad),
494        (
495            "unwrappedPointerDeltaRad",
496            snapshot.unwrapped_pointer_delta_rad,
497        ),
498        ("recordAngleAtBeginRad", snapshot.record_angle_at_begin_rad),
499    ] {
500        if !value.is_finite() {
501            return Err(ScratchGestureError::InvalidSnapshot { field });
502        }
503    }
504    if snapshot.active_pointer_id.is_some()
505        && snapshot.last_source_time_ns < snapshot.anchor_source_time_ns
506    {
507        return Err(ScratchGestureError::InvalidSnapshot {
508            field: "lastSourceTimeNs",
509        });
510    }
511    if snapshot.active_pointer_id.is_some()
512        && snapshot.last_scheduled_frame < snapshot.anchor_schedule_frame
513    {
514        return Err(ScratchGestureError::InvalidSnapshot {
515            field: "lastScheduledFrame",
516        });
517    }
518    Ok(())
519}
520
521#[derive(Debug, Error, PartialEq)]
522pub enum ScratchGestureError {
523    #[error("scratch gesture configuration field {field} is invalid")]
524    InvalidConfig { field: &'static str },
525    #[error("scratch pointer sample field {field} is invalid")]
526    InvalidSample { field: &'static str },
527    #[error("record angle must be finite")]
528    InvalidRecordAngle,
529    #[error("a scratch gesture is already active")]
530    GestureAlreadyActive,
531    #[error("no scratch gesture is active")]
532    GestureNotActive,
533    #[error(
534        "scratch pointer {received_pointer_id} does not match active pointer {active_pointer_id}"
535    )]
536    PointerMismatch {
537        active_pointer_id: u64,
538        received_pointer_id: u64,
539    },
540    #[error("scratch pointer time moved backward")]
541    SourceTimeMovedBackward,
542    #[error("scratch gesture schedule overflowed")]
543    ScheduleOverflow,
544    #[error("scratch gesture snapshot version {version} is not supported")]
545    UnsupportedSnapshotVersion { version: u32 },
546    #[error("scratch gesture snapshot uses a different configuration")]
547    SnapshotConfigMismatch,
548    #[error("scratch gesture snapshot field {field} is invalid")]
549    InvalidSnapshot { field: &'static str },
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::mechanics::MotorMode;
556    use approx::assert_abs_diff_eq;
557
558    fn config(lookahead_frames: u32) -> ScratchGestureConfig {
559        ScratchGestureConfig::for_deck(PhysicalDeckConfig::default(), 192_000, lookahead_frames)
560            .unwrap()
561    }
562
563    fn sample(pointer_id: u64, time_ns: u64, angle_rad: f64) -> ScratchPointerSample {
564        ScratchPointerSample {
565            pointer_id,
566            source_time_ns: time_ns,
567            angle_rad,
568            contact_radius_m: 0.12,
569            normalized_pressure: Some(0.5),
570        }
571    }
572
573    #[test]
574    fn begin_anchors_the_hand_without_moving_the_record() {
575        let mut mapper = ScratchGestureMapper::new(config(1_920)).unwrap();
576        let output = mapper
577            .begin(sample(7, 1_000_000, 2.75), 30_000, -18.0)
578            .unwrap();
579        assert_eq!(output.absolute_frame, 31_920);
580        assert_eq!(output.hand_target_angle_rad, Some(-18.0));
581        assert_eq!(output.hand_target_angular_velocity_rad_s, 0.0);
582        assert!(output.hand_contact);
583    }
584
585    #[test]
586    fn branch_cut_motion_is_unwrapped_in_the_short_direction() {
587        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
588        mapper
589            .begin(sample(1, 0, std::f64::consts::PI - 0.02), 0, 5.0)
590            .unwrap();
591        let output = mapper
592            .update(sample(1, 10_000_000, -std::f64::consts::PI + 0.03), 0)
593            .unwrap();
594        assert_abs_diff_eq!(
595            output.hand_target_angle_rad.unwrap(),
596            5.05,
597            epsilon = 1.0e-12
598        );
599        assert!(output.hand_target_angular_velocity_rad_s > 0.0);
600    }
601
602    #[test]
603    fn rapid_reversals_keep_their_sign_and_sample_order() {
604        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
605        mapper.begin(sample(1, 0, 0.0), 100, 0.0).unwrap();
606        let mut previous_frame = 100;
607        for index in 1..=128_u64 {
608            let angle = if index % 2 == 0 { 0.0 } else { 0.02 };
609            let output = mapper
610                .update(sample(1, index * 1_000_000, angle), 100)
611                .unwrap();
612            assert!(output.absolute_frame > previous_frame);
613            if index % 2 == 0 {
614                assert!(output.hand_target_angular_velocity_rad_s < 0.0);
615            } else {
616                assert!(output.hand_target_angular_velocity_rad_s > 0.0);
617            }
618            previous_frame = output.absolute_frame;
619        }
620    }
621
622    #[test]
623    fn late_shift_preserves_later_event_intervals() {
624        let mut mapper = ScratchGestureMapper::new(config(100)).unwrap();
625        mapper.begin(sample(1, 0, 0.0), 1_000, 0.0).unwrap();
626        let late = mapper.update(sample(1, 1_000_000, 0.1), 10_000).unwrap();
627        let next = mapper.update(sample(1, 2_000_000, 0.0), 10_000).unwrap();
628        assert_eq!(late.absolute_frame, 10_100);
629        assert_eq!(next.absolute_frame - late.absolute_frame, 192);
630        assert!(late.added_late_shift_frames > 0);
631        assert_eq!(next.added_late_shift_frames, 0);
632    }
633
634    #[test]
635    fn excessive_pointer_velocity_is_limited_without_losing_position() {
636        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
637        mapper.begin(sample(1, 0, 0.0), 0, 8.0).unwrap();
638        let output = mapper.update(sample(1, 1_000, 0.5), 0).unwrap();
639        assert!(output.velocity_was_limited);
640        assert_eq!(
641            output.hand_target_angular_velocity_rad_s,
642            mapper.config.maximum_hand_angular_velocity_rad_s()
643        );
644        assert_abs_diff_eq!(
645            output.hand_target_angle_rad.unwrap(),
646            8.5,
647            epsilon = 1.0e-12
648        );
649    }
650
651    #[test]
652    fn long_sampling_gap_marks_turn_count_as_ambiguous() {
653        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
654        mapper.begin(sample(1, 0, 0.0), 0, 0.0).unwrap();
655        let output = mapper.update(sample(1, 1_000_000_000, 0.1), 0).unwrap();
656        assert!(output.wrap_was_ambiguous);
657    }
658
659    #[test]
660    fn finish_schedules_a_complete_release_control() {
661        let mut mapper = ScratchGestureMapper::new(config(200)).unwrap();
662        mapper.begin(sample(4, 0, 0.0), 50, 0.0).unwrap();
663        let release = mapper.finish(4, 5_000_000, 50).unwrap();
664        assert_eq!(release.absolute_frame, 1_210);
665        assert!(!release.hand_contact);
666        assert_eq!(release.hand_target_angle_rad, None);
667        assert_eq!(release.hand_normal_force_n, 0.0);
668        assert_eq!(mapper.active_pointer_id(), None);
669    }
670
671    #[test]
672    fn wrong_pointer_does_not_change_active_state() {
673        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
674        mapper.begin(sample(3, 0, 0.0), 0, 0.0).unwrap();
675        let before = mapper.snapshot();
676        assert_eq!(
677            mapper.update(sample(9, 1_000_000, 0.1), 0),
678            Err(ScratchGestureError::PointerMismatch {
679                active_pointer_id: 3,
680                received_pointer_id: 9,
681            })
682        );
683        assert_eq!(mapper.snapshot(), before);
684    }
685
686    #[test]
687    fn invalid_update_is_transactional() {
688        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
689        mapper.begin(sample(1, 10, 0.0), 0, 0.0).unwrap();
690        let before = mapper.snapshot();
691        let mut invalid = sample(1, 20, 0.1);
692        invalid.normalized_pressure = Some(f64::NAN);
693        assert!(mapper.update(invalid, 0).is_err());
694        assert_eq!(mapper.snapshot(), before);
695    }
696
697    #[test]
698    fn source_time_cannot_move_backward() {
699        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
700        mapper.begin(sample(1, 10, 0.0), 0, 0.0).unwrap();
701        assert_eq!(
702            mapper.update(sample(1, 9, 0.1), 0),
703            Err(ScratchGestureError::SourceTimeMovedBackward)
704        );
705    }
706
707    #[test]
708    fn equal_timestamps_do_not_invent_velocity() {
709        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
710        mapper.begin(sample(1, 10, 0.0), 0, 2.0).unwrap();
711        let output = mapper.update(sample(1, 10, 0.2), 0).unwrap();
712        assert_eq!(output.hand_target_angular_velocity_rad_s, 0.0);
713        assert_abs_diff_eq!(
714            output.hand_target_angle_rad.unwrap(),
715            2.2,
716            epsilon = 1.0e-12
717        );
718    }
719
720    #[test]
721    fn unreported_pressure_uses_the_calibrated_force() {
722        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
723        let mut pointer = sample(1, 0, 0.0);
724        pointer.normalized_pressure = None;
725        let output = mapper.begin(pointer, 0, 0.0).unwrap();
726        assert_eq!(
727            output.hand_normal_force_n,
728            ScratchPressureCalibration::default().unreported_pressure_force_n
729        );
730    }
731
732    #[test]
733    fn merge_changes_only_hand_fields() {
734        let mut mapper = ScratchGestureMapper::new(config(0)).unwrap();
735        let output = mapper.begin(sample(1, 0, 0.0), 100, 0.0).unwrap();
736        let mut base = PlayerControl::default();
737        base.stylus_lowered = true;
738        base.deck.motor_mode = MotorMode::Brake;
739        base.deck.motor_target_angular_velocity_rad_s = -2.0;
740        base.deck.stylus_torque_nm = 0.01;
741        let merged = output.merge(77, base);
742        assert_eq!(merged.absolute_frame, 100);
743        assert_eq!(merged.sequence, 77);
744        assert!(merged.control.stylus_lowered);
745        assert_eq!(merged.control.deck.motor_mode, MotorMode::Brake);
746        assert_eq!(
747            merged.control.deck.motor_target_angular_velocity_rad_s,
748            -2.0
749        );
750        assert_eq!(merged.control.deck.stylus_torque_nm, 0.01);
751        assert!(merged.control.deck.hand_contact);
752    }
753
754    #[test]
755    fn snapshot_restore_repeats_the_next_mapping() {
756        let mut mapper = ScratchGestureMapper::new(config(100)).unwrap();
757        mapper.begin(sample(1, 0, 0.0), 1_000, 3.0).unwrap();
758        mapper.update(sample(1, 1_000_000, 0.1), 1_000).unwrap();
759        let snapshot = mapper.snapshot();
760        let expected = mapper.update(sample(1, 2_000_000, -0.1), 1_000).unwrap();
761        mapper.restore(&snapshot).unwrap();
762        assert_eq!(
763            mapper.update(sample(1, 2_000_000, -0.1), 1_000).unwrap(),
764            expected
765        );
766    }
767
768    #[test]
769    fn restore_rejects_a_different_configuration_transactionally() {
770        let first = ScratchGestureMapper::new(config(0)).unwrap();
771        let snapshot = first.snapshot();
772        let mut second = ScratchGestureMapper::new(config(10)).unwrap();
773        let before = second.snapshot();
774        assert_eq!(
775            second.restore(&snapshot),
776            Err(ScratchGestureError::SnapshotConfigMismatch)
777        );
778        assert_eq!(second.snapshot(), before);
779    }
780
781    #[test]
782    fn invalid_pressure_calibration_is_rejected() {
783        let mut invalid = config(0);
784        invalid.pressure.unit_pressure_force_n = 0.1;
785        invalid.pressure.zero_pressure_force_n = 0.2;
786        assert_eq!(
787            ScratchGestureMapper::new(invalid),
788            Err(ScratchGestureError::InvalidConfig {
789                field: "unitPressureForceN"
790            })
791        );
792    }
793
794    #[test]
795    fn begin_reports_schedule_overflow_without_starting() {
796        let mut mapper = ScratchGestureMapper::new(config(10)).unwrap();
797        assert_eq!(
798            mapper.begin(sample(1, 0, 0.0), u64::MAX - 5, 0.0),
799            Err(ScratchGestureError::ScheduleOverflow)
800        );
801        assert_eq!(mapper.active_pointer_id(), None);
802    }
803}