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#[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#[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#[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#[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 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 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#[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#[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 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 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 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}