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