Skip to main content

motion_content/vector/ink/brush/
advanced.rs

1//! Advanced brush strokes: nib modes, recorded input, extended options, validation and drawing.
2
3use super::{BrushStroke, BrushStrokeBuilder, Config, invalid};
4use motion_core::{
5    Canvas, Duration, FillRule, NodeModifiers, Point, Rect, Time, Transform, ValidationError,
6    Vector2,
7};
8use std::sync::Arc;
9
10mod geometry;
11mod material;
12mod pigment;
13mod presets;
14#[cfg(test)]
15mod tests;
16mod timing;
17
18pub use pigment::{PigmentMix, PigmentPainting};
19pub use presets::BrushPreset;
20pub use timing::BrushTiming;
21
22use geometry::{outline, samples};
23use material::material;
24
25/// Per-sample nib orientation for explicitly extended strokes.
26#[derive(Clone, Copy, Debug)]
27pub enum NibMode {
28    /// Fixed clockwise radians.
29    Fixed(f64),
30    /// Tangent angle plus a fixed offset.
31    FollowPath(f64),
32    /// Recorded nib azimuth, interpolated over the input samples.
33    Tilt,
34}
35
36/// Analytic pressure treatment at the endpoints of a variable-width [`BrushStroke`].
37///
38/// This is not [`LineCap`](motion_core::LineCap): `LineCap` picks the end geometry (butt, round
39/// or square) of a uniform-width [`StrokeStyle`](motion_core::StrokeStyle) outline, whereas
40/// `BrushCap` shapes how the nib imprint and pressure end a brush stroke. `Round` resembles
41/// `LineCap::Round`, `Flat` resembles `Butt`, and `Tapered` has no `LineCap` counterpart.
42#[derive(Clone, Copy, Debug)]
43pub enum BrushCap {
44    /// Full rounded nib imprint.
45    Round,
46    /// Clips the nib and connector geometry at an endpoint tangent plane.
47    Flat,
48    /// Smoothly tapers over the specified arc length.
49    Tapered(f64),
50}
51
52/// One recorded pen sample. Time is relative to the first input sample.
53#[derive(Clone, Copy, Debug)]
54pub struct InputSample {
55    /// Finite local position.
56    pub position: Point,
57    /// Normalized pressure in 0..=4.
58    pub pressure: f64,
59    /// Nib azimuth in clockwise radians, not an altitude angle.
60    pub tilt: f64,
61    /// Strictly increasing nonnegative recording time.
62    pub time: Time,
63}
64
65#[derive(Clone)]
66struct PressureFunction(Arc<dyn Fn(f64) -> f64 + Send + Sync>);
67
68impl std::fmt::Debug for PressureFunction {
69    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        formatter
71            .debug_struct("PressureFunction")
72            .finish_non_exhaustive()
73    }
74}
75
76#[derive(Clone, Debug)]
77pub(super) struct Options {
78    pressure: Vec<(f64, f64)>,
79    function: Option<PressureFunction>,
80    nib: NibMode,
81    caps: [BrushCap; 2],
82    samples: usize,
83    input: Vec<InputSample>,
84    input_arcs: Vec<f64>,
85    speed_response: f64,
86    wobble: Option<(f64, f64, u32)>,
87    bristles: usize,
88    spread: f64,
89    jitter: f64,
90    seed: u32,
91    tile_size: u32,
92    parallel: bool,
93}
94
95impl Default for Options {
96    fn default() -> Self {
97        Self {
98            pressure: Vec::new(),
99            function: None,
100            nib: NibMode::Fixed(0.0),
101            caps: [BrushCap::Round; 2],
102            samples: 256,
103            input: Vec::new(),
104            input_arcs: Vec::new(),
105            speed_response: 0.0,
106            wobble: None,
107            bristles: 1,
108            spread: 0.0,
109            jitter: 0.0,
110            seed: 0,
111            tile_size: 2048,
112            parallel: false,
113        }
114    }
115}
116
117impl Options {
118    pub(super) fn reset_pressure(&mut self) {
119        self.pressure.clear();
120        self.function = None;
121    }
122
123    pub(super) fn set_fixed_angle(&mut self, angle: f64) {
124        if matches!(self.nib, NibMode::Fixed(_)) {
125            self.nib = NibMode::Fixed(angle);
126        }
127    }
128}
129
130impl BrushStrokeBuilder {
131    pub(super) fn configure(mut self, configure: impl FnOnce(&mut Options)) -> Self {
132        configure(&mut self.config.advanced);
133        self
134    }
135
136    /// Sets endpoint-inclusive ordered pressure control points, interpolated smoothly.
137    pub fn pressure_curve(self, curve: Vec<(f64, f64)>) -> Self {
138        self.configure(|options| {
139            options.pressure = curve;
140            options.function = None;
141        })
142    }
143
144    /// Sets a pure, finite pressure function in 0..=4, evaluated at bounded arc samples.
145    pub fn pressure_from(self, function: impl Fn(f64) -> f64 + Send + Sync + 'static) -> Self {
146        self.configure(|options| {
147            options.pressure.clear();
148            options.function = Some(PressureFunction(Arc::new(function)));
149        })
150    }
151
152    /// Sets fixed, tangent-following or recorded nib orientation.
153    pub fn nib_mode(self, mode: NibMode) -> Self {
154        self.configure(|options| options.nib = mode)
155    }
156
157    /// Sets analytic start/end cap pressure treatment.
158    pub fn caps(self, start: BrushCap, end: BrushCap) -> Self {
159        self.configure(|options| options.caps = [start, end])
160    }
161
162    /// Maximum samples per path; at most 512 and global outline verbs remain bounded.
163    pub fn sample_limit(self, samples: usize) -> Self {
164        self.configure(|options| options.samples = samples)
165    }
166
167    /// Multiplies pressure by 1/(1+response*speed); unrecorded paths use unit-duration speed.
168    pub fn speed_response(self, response: f64) -> Self {
169        self.configure(|options| options.speed_response = response)
170    }
171
172    /// Splits the stroke into 1..=64 seeded offset bristles.
173    pub fn bristles(self, count: usize, spread: f64, jitter: f64, seed: u32) -> Self {
174        self.configure(|options| {
175            options.bristles = count;
176            options.spread = spread;
177            options.jitter = jitter;
178            options.seed = seed;
179        })
180    }
181
182    /// Sets the material tile edge in 16..=2048 texels; the default bakes most strokes as one tile.
183    pub fn tiled_material(self, tile_size: u32) -> Self {
184        self.configure(|options| options.tile_size = tile_size)
185    }
186
187    /// Evaluates independent texel rows in scoped CPU threads, with identical arithmetic.
188    pub fn parallel_material(self, enabled: bool) -> Self {
189        self.configure(|options| options.parallel = enabled)
190    }
191
192    /// Seeded analytic path displacement. Reuse seed for stable texture; change it for boil.
193    pub fn wobble(self, amplitude: f64, frequency: f64, seed: u32) -> Self {
194        self.configure(|options| options.wobble = Some((amplitude, frequency, seed)))
195    }
196
197    /// Seeded two-frame boil; frame order never contributes history.
198    pub fn boil(self, amplitude: f64, frequency: f64, seed: u32, frame: u64) -> Self {
199        self.wobble(
200            amplitude,
201            frequency,
202            seed ^ motion_core::noise::hash32((frame / 2) as u32),
203        )
204    }
205
206    /// Analytic absorption: the material depends on age, not previous frames.
207    pub fn try_wetness(
208        self,
209        age: Duration,
210        radius: f32,
211        drying: Duration,
212    ) -> Result<Self, ValidationError> {
213        if age < Duration::ZERO || drying <= Duration::ZERO || !radius.is_finite() || radius < 0.0 {
214            return Err(invalid(
215                "wetness",
216                "Invalid age, wash radius or drying duration",
217            ));
218        }
219        let extent = f64::from(radius) * (1.0 - libm::exp(-age.as_seconds() / drying.as_seconds()));
220        let ink = self
221            .config
222            .ink
223            .clone()
224            .unwrap_or_default()
225            .bleed(extent as f32);
226        Ok(self.ink(ink))
227    }
228}
229
230impl BrushStroke {
231    /// Evaluates the reveal endpoint and tangent, suitable for a user-authored cursor or emitter.
232    pub fn tip(&self) -> Result<(Point, Vector2), ValidationError> {
233        let options = &self.config.advanced;
234        if self.progress > 0.0 {
235            self.validate()?;
236            let groups = samples(&self.config, self.progress)?;
237            let tips: Vec<_> = groups
238                .iter()
239                .rev()
240                .take(options.bristles)
241                .filter_map(|group| group.last())
242                .collect();
243            if let Some(sample) = tips.first() {
244                let mut point = Point::new(0.0, 0.0);
245                for tip in &tips {
246                    point.x += tip.point.x / tips.len() as f64;
247                    point.y += tip.point.y / tips.len() as f64;
248                }
249                return Ok((point, sample.tangent));
250            }
251        }
252        self.config.path.try_point_at(self.progress)
253    }
254
255    /// Positions a user-authored cursor at the deterministic reveal endpoint.
256    pub fn tip_node(
257        &self,
258        node: impl Into<motion_core::Node>,
259    ) -> Result<motion_core::Node, ValidationError> {
260        let (point, tangent) = self.tip()?;
261        Ok(node
262            .into()
263            .pivot(motion_core::Pivot::Point(Point::new(0.0, 0.0)))
264            .rotate(libm::atan2(tangent.y, tangent.x))
265            .translate(point.x, point.y))
266    }
267
268    /// Uses this stroke as an inverted-alpha erase mask; no backend blend changes.
269    pub fn erase(self, content: impl Into<motion_core::Node>) -> motion_core::Node {
270        content.into().mask(
271            motion_core::Node::from(self),
272            motion_core::MaskMode::InvertedAlpha,
273        )
274    }
275
276    fn validate(&self) -> Result<(), ValidationError> {
277        validate(&self.config)?;
278        if !self.progress.is_finite() || !(0.0..=1.0).contains(&self.progress) {
279            return Err(invalid("progress", "Require reveal progress in 0..=1"));
280        }
281        Ok(())
282    }
283}
284
285fn validate_input(samples: &[InputSample]) -> Result<(), ValidationError> {
286    if !(2..=512).contains(&samples.len())
287        || samples
288            .first()
289            .is_some_and(|sample| sample.time != Time::ZERO)
290        || samples.iter().any(|sample| {
291            !sample.position.x.is_finite()
292                || !sample.position.y.is_finite()
293                || !sample.pressure.is_finite()
294                || !(0.0..=4.0).contains(&sample.pressure)
295                || !sample.tilt.is_finite()
296                || sample.time < Time::ZERO
297                || sample.time.as_seconds() > 86400.0
298        })
299        || samples.windows(2).any(|pair| pair[0].time >= pair[1].time)
300    {
301        return Err(invalid(
302            "input",
303            "Require 2..=512 finite pen samples with strictly increasing time starting at zero",
304        ));
305    }
306    Ok(())
307}
308
309pub(super) fn validate(stroke: &Config) -> Result<(), ValidationError> {
310    let options = &stroke.advanced;
311    if !stroke.width.is_finite()
312        || !(0.0..=512.0).contains(&stroke.width)
313        || !stroke.aspect.is_finite()
314        || !(f64::MIN_POSITIVE..=1.0).contains(&stroke.aspect)
315        || !stroke.angle.is_finite()
316        || stroke.material_work_limit == 0
317        || stroke
318            .pressure
319            .iter()
320            .any(|value| !value.is_finite() || !(0.0..=4.0).contains(value))
321        || stroke
322            .load
323            .iter()
324            .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))
325        || !(2..=512).contains(&options.samples)
326        || !(1..=64).contains(&options.bristles)
327        || !(16..=2048).contains(&options.tile_size)
328        || !options.spread.is_finite()
329        || !(0.0..=512.0).contains(&options.spread)
330        || !options.jitter.is_finite()
331        || !(0.0..=1.0).contains(&options.jitter)
332        || !options.speed_response.is_finite()
333        || !(0.0..=1.0).contains(&options.speed_response)
334    {
335        return Err(invalid(
336            "extended",
337            "Invalid bounded geometry or material configuration",
338        ));
339    }
340    if !options.pressure.is_empty()
341        && (options.pressure.len() > 512
342            || options.pressure.first().map(|key| key.0) != Some(0.0)
343            || options.pressure.last().map(|key| key.0) != Some(1.0)
344            || options.pressure.iter().any(|(time, pressure)| {
345                !time.is_finite()
346                    || !(0.0..=1.0).contains(time)
347                    || !pressure.is_finite()
348                    || !(0.0..=4.0).contains(pressure)
349            })
350            || options
351                .pressure
352                .windows(2)
353                .any(|pair| pair[0].0 >= pair[1].0))
354    {
355        return Err(invalid(
356            "pressure_curve",
357            "Require ordered finite controls spanning 0..=1",
358        ));
359    }
360    if !options.input.is_empty() {
361        validate_input(&options.input)?;
362    }
363    match options.nib {
364        NibMode::Fixed(angle) | NibMode::FollowPath(angle) if !angle.is_finite() => {
365            return Err(invalid("nib", "Nib angle must be finite"));
366        }
367        NibMode::Tilt if options.input.is_empty() => {
368            return Err(invalid("nib", "Tilt requires recorded input"));
369        }
370        _ => {}
371    }
372    if options
373        .caps
374        .iter()
375        .any(|cap| matches!(cap,BrushCap::Tapered(length) if !length.is_finite() || *length<=0.0))
376    {
377        return Err(invalid("caps", "Taper length must be positive and finite"));
378    }
379    if options.wobble.is_some_and(|(amount, frequency, _)| {
380        !amount.is_finite()
381            || !(0.0..=512.0).contains(&amount)
382            || !frequency.is_finite()
383            || !(f64::MIN_POSITIVE..=100.0).contains(&frequency)
384    }) {
385        return Err(invalid("wobble", "Invalid wobble amplitude or frequency"));
386    }
387    stroke.paint.validate()?;
388    if let Some(ink) = &stroke.ink {
389        ink.padding()
390            .map_err(|error| invalid("ink", &error.to_string()))?;
391    }
392    Ok(())
393}
394
395pub(super) fn bounds(stroke: &BrushStroke) -> Result<Rect, ValidationError> {
396    stroke.validate()?;
397    let config = &stroke.config;
398    let options = &config.advanced;
399    let padding = config
400        .ink
401        .as_ref()
402        .map_or(Ok(0.0), |ink| ink.padding().map(f64::from))
403        .map_err(|error| invalid("ink", &error.to_string()))?;
404    Ok(config.path.bounding_box().inset(
405        -(config.width * 2.0
406            + padding
407            + options.spread
408            + options.wobble.map_or(0.0, |value| value.0)
409            + 1.0),
410    ))
411}
412
413pub(super) fn draw(stroke: &BrushStroke, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
414    let bounds = bounds(stroke)?;
415    let config = &stroke.config;
416    let groups = samples(config, stroke.progress)?;
417    let path = Arc::new(outline(config, &groups)?);
418    if stroke.progress == 0.0 || config.width == 0.0 {
419        return Ok(());
420    }
421    if config.ink.is_none() {
422        return canvas.fill_path(
423            path,
424            Transform::IDENTITY,
425            config.paint.clone(),
426            FillRule::NonZero,
427        );
428    }
429    let full = stroke
430        .full_material
431        .get_or_init(|| material(config, bounds))
432        .as_ref()
433        .map_err(Clone::clone)?;
434    for tile in full {
435        canvas.fill_path(
436            path.clone(),
437            Transform::IDENTITY,
438            tile.paint.clone(),
439            FillRule::NonZero,
440        )?;
441    }
442    Ok(())
443}