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