Skip to main content

motion_content/vector/ink/brush/advanced/
timing.rs

1//! Brush reveal timing: recorded input fitting, smoothing and timed arc-length reveals.
2
3use super::super::BrushStroke;
4use super::super::invalid;
5use super::geometry::subpaths;
6use super::{InputSample, validate_input};
7use motion_core::{Color, Duration, Path2D, Time, ValidationError};
8
9/// Stateless arc timing with a monotone progress curve and pauses between subpaths.
10#[derive(Clone, Debug)]
11pub struct BrushTiming {
12    /// Total moving duration, excluding lift pauses.
13    pub duration: Duration,
14    /// Ordered normalized time/progress controls, spanning (0,0)..=(1,1).
15    pub curve: Vec<(f64, f64)>,
16    /// Pen-up interval between each adjacent subpath.
17    pub lift_pause: Duration,
18}
19
20impl BrushTiming {
21    /// Creates linear arc timing with no pen-up interval.
22    pub fn new(duration: Duration) -> Self {
23        Self {
24            duration,
25            curve: vec![(0.0, 0.0), (1.0, 1.0)],
26            lift_pause: Time::ZERO,
27        }
28    }
29}
30
31impl BrushStroke {
32    /// Fits recorded positions with Catmull-Rom cubic segments and retains pressure/time.
33    pub fn from_samples(
34        samples: &[InputSample],
35        width: f64,
36        color: Color,
37    ) -> Result<Self, ValidationError> {
38        validate_input(samples)?;
39        let mut path = Path2D::new();
40        path.try_move_to(samples[0].position)?;
41        let mut cumulative = vec![0.0];
42        for index in 0..samples.len() - 1 {
43            let previous = samples[index.saturating_sub(1)].position;
44            let first = samples[index].position;
45            let second = samples[index + 1].position;
46            let next = samples[(index + 2).min(samples.len() - 1)].position;
47            if first == second {
48                cumulative.push(*cumulative.last().unwrap());
49                continue;
50            }
51            let control_first = first + (second - previous) * (1.0 / 6.0);
52            let control_second = second - (next - first) * (1.0 / 6.0);
53            let mut segment = Path2D::new();
54            segment.try_move_to(first)?;
55            segment.try_cubic_to(control_first, control_second, second)?;
56            cumulative.push(cumulative.last().copied().unwrap_or(0.0) + segment.try_length()?);
57            path.try_cubic_to(control_first, control_second, second)?;
58        }
59        let total = cumulative.last().copied().unwrap_or(0.0);
60        if total <= 0.0 {
61            return Err(invalid("input", "Recording must contain nonzero travel"));
62        }
63        let mut curve: Vec<(f64, f64)> = Vec::new();
64        for (length, sample) in cumulative.iter().zip(samples) {
65            let amount = *length / total;
66            if curve.last().is_some_and(|control| control.0 == amount) {
67                curve.last_mut().unwrap().1 = sample.pressure;
68            } else {
69                curve.push((amount, sample.pressure));
70            }
71        }
72        Ok(Self::new(path, width, color).configure(|options| {
73            options.input = samples.to_vec();
74            options.input_arcs = cumulative.iter().map(|arc| arc / total).collect();
75            options.pressure = curve;
76        }))
77    }
78
79    /// Smooths a sampled path by a bounded symmetric kernel; no frame history is retained.
80    pub fn smooth(mut self, strength: f64) -> Result<Self, ValidationError> {
81        if !strength.is_finite() || !(0.0..=1.0).contains(&strength) {
82            return Err(invalid("smooth", "Require strength in 0..=1"));
83        }
84        let paths = subpaths(&self.path)?;
85        let mut output = Path2D::new();
86        for path in paths {
87            let points = (0..=64)
88                .map(|index| path.try_point_at(index as f64 / 64.0).map(|value| value.0))
89                .collect::<Result<Vec<_>, _>>()?;
90            for (index, point) in points.iter().enumerate() {
91                let point = if index == 0 || index == 64 {
92                    *point
93                } else {
94                    *point
95                        + (points[index - 1] + (points[index + 1] - points[index - 1]) * 0.5
96                            - *point)
97                            * (strength * 0.5)
98                };
99                if index == 0 {
100                    output.try_move_to(point)?;
101                } else {
102                    output.try_line_to(point)?;
103                }
104            }
105        }
106        self.path = output;
107        self.reset();
108        Ok(self)
109    }
110
111    /// Reveals a recorded stroke at its original local sample clock, including pauses.
112    pub fn write_with(self, time: Time) -> Result<Self, ValidationError> {
113        let options = &self.advanced;
114        if options.input.is_empty() {
115            return Err(invalid("timing", "Recorded timing requires from_samples"));
116        }
117        validate_input(&options.input)?;
118        if time < Time::ZERO {
119            return Err(invalid("timing", "Reveal time must be nonnegative"));
120        }
121        let input = &options.input;
122        let index = input
123            .partition_point(|sample| sample.time <= time)
124            .saturating_sub(1);
125        let amount = if index + 1 >= input.len() {
126            1.0
127        } else {
128            options.input_arcs[index]
129                + (options.input_arcs[index + 1] - options.input_arcs[index])
130                    * (time - input[index].time).as_seconds()
131                    / (input[index + 1].time - input[index].time).as_seconds()
132        };
133        Ok(self.write(amount.clamp(0.0, 1.0)))
134    }
135
136    /// Reveals with a monotone speed curve, retaining full material across pen-up pauses.
137    pub fn write_timed(self, time: Time, timing: &BrushTiming) -> Result<Self, ValidationError> {
138        let stroke = self;
139        if time < Time::ZERO
140            || timing.duration <= Time::ZERO
141            || timing.duration.as_seconds() > 86400.0
142            || timing.lift_pause < Time::ZERO
143            || timing.lift_pause.as_seconds() > 86400.0
144            || !(2..=512).contains(&timing.curve.len())
145            || timing.curve.first() != Some(&(0.0, 0.0))
146            || timing.curve.last() != Some(&(1.0, 1.0))
147            || timing.curve.iter().any(|(time, arc)| {
148                !time.is_finite()
149                    || !arc.is_finite()
150                    || !(0.0..=1.0).contains(time)
151                    || !(0.0..=1.0).contains(arc)
152            })
153            || timing
154                .curve
155                .windows(2)
156                .any(|pair| pair[0].0 >= pair[1].0 || pair[0].1 > pair[1].1)
157        {
158            return Err(invalid(
159                "timing",
160                "Invalid duration, pause or monotone curve",
161            ));
162        }
163        let paths = subpaths(&stroke.path)?;
164        let lengths = paths
165            .iter()
166            .map(Path2D::try_length)
167            .collect::<Result<Vec<_>, _>>()?;
168        let total: f64 = lengths.iter().sum();
169        if total <= 0.0 {
170            return Err(invalid("timing", "Writing requires nonzero travel"));
171        }
172        let mut elapsed = 0.0;
173        let mut arc = 0.0;
174        let seconds = time.as_seconds();
175        for length in lengths {
176            let duration = timing.duration.as_seconds() * length / total;
177            if seconds <= elapsed {
178                return Ok(stroke.write(arc / total));
179            }
180            if seconds < elapsed + duration {
181                let amount = (seconds - elapsed) / duration;
182                let index = timing
183                    .curve
184                    .partition_point(|control| control.0 <= amount)
185                    .saturating_sub(1)
186                    .min(timing.curve.len() - 2);
187                let first = timing.curve[index];
188                let second = timing.curve[index + 1];
189                let progress =
190                    first.1 + (second.1 - first.1) * (amount - first.0) / (second.0 - first.0);
191                return Ok(stroke.write((arc + length * progress) / total));
192            }
193            arc += length;
194            elapsed += duration + timing.lift_pause.as_seconds();
195        }
196        Ok(stroke.write(1.0))
197    }
198}