Skip to main content

ferroui_controls_inking/
ink_point.rs

1use ferroui_base::Point;
2use std::fmt;
3
4/// Validation and persistence failures in the portable ink engine.
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub enum InkError {
7    Platform(String),
8    InvalidOperation(&'static str),
9    InvalidInput(&'static str),
10    InvalidPoint,
11    InvalidDrawingAttributes,
12    InvalidTransform,
13    EmptyStroke,
14    DuplicateStrokeId(u32),
15    StrokeIdExhausted,
16    InvalidData(&'static str),
17    UnsupportedFormat,
18    LimitExceeded,
19}
20
21impl fmt::Display for InkError {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            Self::Platform(reason) => f.write_str(reason),
25            Self::InvalidOperation(reason) | Self::InvalidInput(reason) => f.write_str(reason),
26            Self::InvalidPoint => f.write_str(
27                "Ink coordinates and device measurements must be finite and within range",
28            ),
29            Self::InvalidDrawingAttributes => f.write_str("Invalid ink drawing attributes"),
30            Self::InvalidTransform => f.write_str("Ink transforms must be finite affine matrices"),
31            Self::EmptyStroke => f.write_str("An ink stroke must contain at least one point"),
32            Self::DuplicateStrokeId(id) => write!(f, "Duplicate ink stroke ID {id}"),
33            Self::StrokeIdExhausted => f.write_str("Ink stroke IDs are exhausted"),
34            Self::InvalidData(reason) => write!(f, "Invalid ink document: {reason}"),
35            Self::UnsupportedFormat => f.write_str("Unsupported ink persistence format"),
36            Self::LimitExceeded => {
37                f.write_str("Ink document exceeds the configured resource limits")
38            }
39        }
40    }
41}
42
43impl std::error::Error for InkError {}
44
45/// An immutable device sample. Positions are in device-independent pixels,
46/// pressure is normalized to 0..=1, angles are degrees and timestamps are
47/// monotonically increasing microseconds supplied by the input adapter.
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct InkPoint {
50    pub position: Point,
51    pub pressure: f32,
52    pub tilt_x: f32,
53    pub tilt_y: f32,
54    pub twist: f32,
55    pub timestamp: u64,
56}
57
58impl InkPoint {
59    pub fn new(position: Point, pressure: f32) -> Result<Self, InkError> {
60        let point = Self {
61            position,
62            pressure,
63            tilt_x: 0.0,
64            tilt_y: 0.0,
65            twist: 0.0,
66            timestamp: 0,
67        };
68        point.validate()?;
69        Ok(point)
70    }
71
72    pub fn validate(&self) -> Result<(), InkError> {
73        if !valid_position(self.position)
74            || !self.pressure.is_finite()
75            || !(0.0..=1.0).contains(&self.pressure)
76            || !self.tilt_x.is_finite()
77            || !(-90.0..=90.0).contains(&self.tilt_x)
78            || !self.tilt_y.is_finite()
79            || !(-90.0..=90.0).contains(&self.tilt_y)
80            || !self.twist.is_finite()
81            || !(0.0..360.0).contains(&self.twist)
82        {
83            return Err(InkError::InvalidPoint);
84        }
85        Ok(())
86    }
87
88    pub(crate) fn interpolate(a: Self, b: Self, t: f64) -> Self {
89        // Keep actual device samples bit-for-bit at segment boundaries. In
90        // particular a floating-point timestamp interpolation cannot exactly
91        // represent every u64, and f32 subtraction can lose small pressures.
92        if t <= 0.0 {
93            return a;
94        }
95        if t >= 1.0 {
96            return b;
97        }
98        let mix = |a: f32, b: f32| (f64::from(a) + (f64::from(b) - f64::from(a)) * t) as f32;
99        // Twist follows the shortest arc, including the 359 -> 0 boundary.
100        let twist_delta =
101            (f64::from(b.twist) - f64::from(a.twist) + 540.0).rem_euclid(360.0) - 180.0;
102        let twist = (f64::from(a.twist) + twist_delta * t).rem_euclid(360.0) as f32;
103        Self {
104            position: Point::new(
105                a.position.x + (b.position.x - a.position.x) * t,
106                a.position.y + (b.position.y - a.position.y) * t,
107            ),
108            pressure: mix(a.pressure, b.pressure),
109            tilt_x: mix(a.tilt_x, b.tilt_x),
110            tilt_y: mix(a.tilt_y, b.tilt_y),
111            // Rounding a value just below 360 to f32 can produce 360, which
112            // is outside the public sample range and equivalent to zero.
113            twist: if twist >= 360.0 { 0.0 } else { twist },
114            timestamp: a
115                .timestamp
116                .saturating_add(((b.timestamp.saturating_sub(a.timestamp)) as f64 * t) as u64)
117                .min(b.timestamp),
118        }
119    }
120}
121
122pub(crate) fn valid_position(value: Point) -> bool {
123    // A finite working range also prevents intermediate geometry overflow.
124    value.x.is_finite() && value.y.is_finite() && value.x.abs() <= 1.0e12 && value.y.abs() <= 1.0e12
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    #[test]
131    fn validates_device_measurements() {
132        assert!(InkPoint::new(Point::new(f64::NAN, 0.0), 0.5).is_err());
133        assert!(InkPoint::new(Point::default(), -0.1).is_err());
134        assert!(InkPoint::new(Point::default(), 0.0).is_ok());
135        let mut p = InkPoint::new(Point::default(), 0.5).unwrap();
136        p.tilt_y = 91.0;
137        assert!(p.validate().is_err());
138    }
139    #[test]
140    fn twist_interpolates_across_zero() {
141        let mut a = InkPoint::new(Point::default(), 0.5).unwrap();
142        let mut b = a;
143        a.twist = 359.0;
144        b.twist = 1.0;
145        assert_eq!(InkPoint::interpolate(a, b, 0.5).twist, 0.0);
146    }
147    #[test]
148    fn interpolation_preserves_original_endpoints_and_large_timestamps() {
149        let mut a = InkPoint::new(Point::new(1.0e12, 1.0e12), 1.0).unwrap();
150        a.timestamp = 1;
151        let mut b = InkPoint::new(Point::new(0.001, 0.001), 1.0e-8).unwrap();
152        b.timestamp = (1u64 << 54) + 3;
153        assert_eq!(InkPoint::interpolate(a, b, 0.0), a);
154        assert_eq!(InkPoint::interpolate(a, b, 1.0), b);
155        let near_end = InkPoint::interpolate(a, b, 1.0 - f64::EPSILON);
156        assert!(near_end.timestamp <= b.timestamp);
157        near_end.validate().unwrap();
158    }
159    #[test]
160    fn twist_rounding_never_generates_an_invalid_360_degree_sample() {
161        let a = InkPoint::new(Point::default(), 0.5).unwrap();
162        let mut b = a;
163        b.twist = 359.99;
164        let sample = InkPoint::interpolate(a, b, 0.001);
165        sample.validate().unwrap();
166        assert_eq!(sample.twist, 0.0);
167    }
168}