Skip to main content

motion_content/vector/ink/
brush.rs

1//! Brush strokes and ink fills rendered with ink materials.
2
3use super::Ink;
4use crate::ImageAsset;
5use motion_core::{
6    Canvas, Color, DrawContext, Drawable, FillRule, ImagePattern, Paint, Path2D, PathVerb, Point,
7    Rect, Transform, ValidationError, Vector2,
8};
9use std::sync::{Arc, OnceLock};
10
11mod advanced;
12
13pub use advanced::{
14    BrushCap, BrushPreset, BrushTiming, InputSample, NibMode, Paper, PigmentMix, PigmentPainting,
15};
16
17#[derive(Clone, Debug)]
18struct Prepared {
19    path: Arc<Path2D>,
20    paint: Paint,
21    bounds: Rect,
22}
23
24/// Pressure-sensitive pen stroke, recorded as nonzero path fills. This is the single brush
25/// implementation: `new` captures the defaults of a fixed-angle elliptical nib and the builder
26/// methods in `brush::advanced` (pressure curves, caps, bristles, paper, timing) refine it.
27/// Curves use bounded arc-length sampling (at most 256 intervals by default); nib caps use
28/// 24-sided outlines.
29/// Optional Ink reuses the deterministic material kernel in local coordinates,
30/// with at most 262144 texture texels and, by default, 32 million distance evaluations.
31/// Every render quality evaluates the full material.
32/// Explicit workloads may raise the latter through [`Self::material_work_limit`].
33/// This is geometric pen/material rendering, not fluid simulation. Paper here is
34/// stroke-local; use untransformed design coordinates for canvas-fixed paper.
35#[derive(Clone, Debug)]
36pub struct BrushStroke {
37    path: Path2D,
38    width: f64,
39    color: Color,
40    aspect: f64,
41    angle: f64,
42    pressure: [f64; 3],
43    load: [f64; 2],
44    progress: f64,
45    ink: Option<Ink>,
46    material_work_limit: u64,
47    advanced: advanced::Options,
48    full_material: Arc<OnceLock<Result<Vec<Prepared>, ValidationError>>>,
49}
50
51fn invalid(field: &str, message: &str) -> ValidationError {
52    ValidationError::invalid("brush_stroke", field, message)
53}
54
55impl BrushStroke {
56    /// Captures finite geometry; validation is deferred to recording.
57    pub fn new(path: Path2D, width: f64, color: Color) -> Self {
58        Self {
59            path,
60            width,
61            color,
62            aspect: 1.0,
63            angle: 0.0,
64            pressure: [1.0; 3],
65            load: [1.0; 2],
66            progress: 1.0,
67            ink: None,
68            material_work_limit: 32_000_000,
69            advanced: advanced::Options::default(),
70            full_material: Arc::new(OnceLock::new()),
71        }
72    }
73
74    /// Sets the minor/major ratio in (0,1] and fixed clockwise radians.
75    pub fn nib(mut self, aspect: f64, angle: f64) -> Self {
76        self.aspect = aspect;
77        self.angle = angle;
78        self.advanced.set_fixed_angle(angle);
79        self.reset();
80        self
81    }
82
83    /// Sets start/middle/end nonnegative pressure, each at most four.
84    pub fn pressure(mut self, start: f64, middle: f64, end: f64) -> Self {
85        self.pressure = [start, middle, end];
86        self.advanced.reset_pressure();
87        self.reset();
88        self
89    }
90
91    /// Replaces deterministic local-coordinate ink material.
92    pub fn ink(mut self, ink: Ink) -> Self {
93        self.ink = Some(ink);
94        self.reset();
95        self
96    }
97
98    /// Replaces the positive material distance-evaluation allowance for this stroke.
99    /// Defaults to 32 million. Raising it admits more CPU preparation work, not
100    /// a higher-quality sampler or a larger texture. Frame raster budgets remain independent.
101    /// Zero is rejected during validation, including hidden or unrevealed strokes.
102    pub fn material_work_limit(mut self, work: u64) -> Self {
103        self.material_work_limit = work;
104        self.reset();
105        self
106    }
107
108    /// Replaces start/end ink load, each in `[0,1]`.
109    pub fn ink_load(mut self, start: f64, end: f64) -> Self {
110        self.load = [start, end];
111        self.reset();
112        self
113    }
114
115    /// Reveals `[0,progress]` by the original full path's arc length.
116    pub fn write(mut self, progress: f64) -> Self {
117        self.progress = progress;
118        self
119    }
120
121    fn reset(&mut self) {
122        self.full_material = Arc::new(OnceLock::new());
123    }
124}
125
126impl Drawable for BrushStroke {
127    fn bounds(&self, context: &DrawContext) -> Rect {
128        self.try_bounds(context)
129            .unwrap_or(Rect::new(f64::NAN, 0.0, 0.0, 0.0))
130    }
131
132    fn try_bounds(&self, _: &DrawContext) -> Result<Rect, ValidationError> {
133        advanced::bounds(self)
134    }
135
136    fn draw(&self, _: &DrawContext, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
137        advanced::draw(self, canvas)
138    }
139}
140
141struct FillSegment {
142    start: Point,
143    end: Point,
144    delta: Vector2,
145    square: f64,
146}
147
148impl FillSegment {
149    fn new(start: Point, end: Point) -> Self {
150        let delta = end - start;
151        Self {
152            start,
153            end,
154            delta,
155            square: delta.x * delta.x + delta.y * delta.y,
156        }
157    }
158
159    fn squared_distance(&self, point: Point) -> f64 {
160        let offset = point - self.start;
161        let amount = if self.square == 0.0 {
162            0.0
163        } else {
164            ((offset.x * self.delta.x + offset.y * self.delta.y) / self.square).clamp(0.0, 1.0)
165        };
166        let nearest = point - (self.start + self.delta * amount);
167        nearest.x * nearest.x + nearest.y * nearest.y
168    }
169
170    fn crossing(&self, vertical: f64) -> Option<(f64, i32)> {
171        if (self.start.y <= vertical && vertical < self.end.y)
172            || (self.end.y <= vertical && vertical < self.start.y)
173        {
174            Some((
175                self.start.x
176                    + (vertical - self.start.y) * (self.end.x - self.start.x)
177                        / (self.end.y - self.start.y),
178                if self.end.y > self.start.y { 1 } else { -1 },
179            ))
180        } else {
181            None
182        }
183    }
184}
185
186fn fill_distance(segments: &[FillSegment], point: Point) -> f64 {
187    libm::sqrt(segments.iter().fold(f64::INFINITY, |distance, segment| {
188        distance.min(segment.squared_distance(point))
189    }))
190}
191
192/// bounded ink fill. Kurbo flattens the original contours; signed boundary
193/// distance and the existing Ink material kernel produce a RGBA texture.
194/// The texture includes feathering and is emitted as one rectangle fill.
195/// At most 4096 edges, 262144 texels and 32 million edge/texel evaluations are
196/// accepted. Local paper coordinates and no fluid simulation, as for BrushStroke.
197#[derive(Clone, Debug)]
198pub struct InkFill {
199    path: Path2D,
200    color: Color,
201    ink: Ink,
202    prepared: Arc<OnceLock<Result<Prepared, ValidationError>>>,
203}
204
205impl InkFill {
206    /// Captures an immutable contour and deterministic material.
207    pub fn new(path: Path2D, color: Color, ink: Ink) -> Self {
208        Self {
209            path,
210            color,
211            ink,
212            prepared: Arc::new(OnceLock::new()),
213        }
214    }
215
216    fn prepared(&self) -> Result<&Prepared, ValidationError> {
217        self.prepared
218            .get_or_init(|| self.prepare())
219            .as_ref()
220            .map_err(Clone::clone)
221    }
222
223    fn prepare(&self) -> Result<Prepared, ValidationError> {
224        if self.path.verbs().len() > 512 {
225            return Err(invalid("fill", "Ink fill admits at most 512 input verbs"));
226        }
227        let padding = f64::from(
228            self.ink
229                .padding()
230                .map_err(|error| invalid("ink", &error.to_string()))?,
231        ) + 1.0;
232        let bounds = self.path.bounding_box().inset(-padding);
233        let width = bounds.width().ceil().max(1.0) as u32;
234        let height = bounds.height().ceil().max(1.0) as u32;
235        if width > 2048 || height > 2048 || u64::from(width) * u64::from(height) > 262144 {
236            return Err(invalid("fill", "Ink fill texture exceeds allowance"));
237        }
238        let mut curve = kurbo::BezPath::new();
239        for verb in self.path.verbs() {
240            let point = |point: &Point| (point.x, point.y);
241            match verb {
242                PathVerb::MoveTo(end) => curve.move_to(point(end)),
243                PathVerb::LineTo(end) => curve.line_to(point(end)),
244                PathVerb::QuadraticTo(control, end) => curve.quad_to(point(control), point(end)),
245                PathVerb::CubicTo(first, second, end) => {
246                    curve.curve_to(point(first), point(second), point(end))
247                }
248                PathVerb::Close => curve.close_path(),
249            }
250        }
251        let mut edges = Vec::new();
252        let mut exceeded = false;
253        let mut append = |first, second| {
254            if edges.len() < 4096 {
255                edges.push((first, second));
256            } else {
257                exceeded = true;
258            }
259        };
260        let mut position = Point::ORIGIN;
261        let mut start = position;
262        let mut open = false;
263        kurbo::flatten(
264            curve.elements().iter().copied(),
265            0.05,
266            |element| match element {
267                kurbo::PathEl::MoveTo(end) => {
268                    if open && position != start {
269                        append(position, start);
270                    }
271                    position = Point::new(end.x, end.y);
272                    start = position;
273                    open = true;
274                }
275                kurbo::PathEl::LineTo(end) => {
276                    let end = Point::new(end.x, end.y);
277                    append(position, end);
278                    position = end;
279                }
280                kurbo::PathEl::ClosePath => {
281                    append(position, start);
282                    position = start;
283                    open = false;
284                }
285                _ => unreachable!("flatten returns lines"),
286            },
287        );
288        if open && position != start {
289            append(position, start);
290        }
291        if exceeded || edges.len() as u64 * u64::from(width) * u64::from(height) > 32_000_000 {
292            return Err(invalid(
293                "fill",
294                "Ink fill edge/texel work exceeds allowance",
295            ));
296        }
297        let base = self.color.to_rgba_float();
298        let segments = edges
299            .into_iter()
300            .map(|(start, end)| FillSegment::new(start, end))
301            .collect::<Vec<_>>();
302        let mut crossings = Vec::with_capacity(segments.len());
303        let mut pixels = Vec::with_capacity(width as usize * height as usize);
304        for row in 0..height {
305            let vertical = bounds.top + f64::from(row) + 0.5;
306            crossings.clear();
307            crossings.extend(
308                segments
309                    .iter()
310                    .filter_map(|segment| segment.crossing(vertical)),
311            );
312            for column in 0..width {
313                let point = Point::new(bounds.left + f64::from(column) + 0.5, vertical);
314                let distance = fill_distance(&segments, point);
315                let mut winding = 0;
316                for &(crossing, direction) in &crossings {
317                    if crossing > point.x {
318                        winding += direction;
319                    }
320                }
321                let signed = if winding == 0 { distance } else { -distance };
322                let material = self.ink.sample((point.x, point.y), (point.x, point.y));
323                let alpha = material.fill_edge_coverage(signed, 1.0).clamp(0.0, 1.0);
324                pixels.push([base[0], base[1], base[2], base[3] * alpha]);
325            }
326        }
327        let image = ImageAsset::from_rgba_float(width, height, pixels)
328            .map_err(|error| invalid("fill", &error.to_string()))?;
329        let paint = ImagePattern::from_asset(Arc::new(image))
330            .transform(Transform::translate(bounds.left, bounds.top))
331            .into();
332        Ok(Prepared {
333            path: Arc::new(Path2D::from_rect(bounds)?),
334            paint,
335            bounds,
336        })
337    }
338}
339
340impl Drawable for InkFill {
341    fn bounds(&self, context: &DrawContext) -> Rect {
342        self.try_bounds(context)
343            .unwrap_or(Rect::new(f64::NAN, 0.0, 0.0, 0.0))
344    }
345
346    fn try_bounds(&self, _: &DrawContext) -> Result<Rect, ValidationError> {
347        Ok(self.prepared()?.bounds)
348    }
349
350    fn draw(&self, _: &DrawContext, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
351        let prepared = self.prepared()?;
352        canvas.fill_path(
353            prepared.path.clone(),
354            Transform::IDENTITY,
355            prepared.paint.clone(),
356            FillRule::NonZero,
357        )
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use motion_render::RenderPrepare;
365
366    fn fill_reference(edges: &[(Point, Point)], point: Point) -> (f64, i32) {
367        let mut distance = f64::INFINITY;
368        let mut winding = 0;
369        for &(first, second) in edges {
370            let delta = second - first;
371            let offset = point - first;
372            let square = delta.x * delta.x + delta.y * delta.y;
373            let amount = if square == 0.0 {
374                0.0
375            } else {
376                ((offset.x * delta.x + offset.y * delta.y) / square).clamp(0.0, 1.0)
377            };
378            let nearest = point - (first + delta * amount);
379            distance = distance.min(libm::sqrt(nearest.x * nearest.x + nearest.y * nearest.y));
380            if (first.y <= point.y && point.y < second.y)
381                || (second.y <= point.y && point.y < first.y)
382            {
383                let crossing =
384                    first.x + (point.y - first.y) * (second.x - first.x) / (second.y - first.y);
385                if crossing > point.x {
386                    winding += if second.y > first.y { 1 } else { -1 };
387                }
388            }
389        }
390        (distance, winding)
391    }
392
393    #[test]
394    fn fill_queries_match_original_distance_and_winding_bits() {
395        let contours = [
396            vec![],
397            vec![(0.0, 0.0), (0.0, 0.0)],
398            vec![
399                (0.0, 0.0),
400                (20.0, 0.0),
401                (20.0, 20.0),
402                (0.0, 20.0),
403                (0.0, 0.0),
404            ],
405            vec![
406                (0.0, 0.0),
407                (20.0, 20.0),
408                (0.0, 20.0),
409                (20.0, 0.0),
410                (0.0, 0.0),
411            ],
412            vec![(0.0, 0.0), (20.0, 0.0), (0.0, 0.0), (0.0, 20.0), (0.0, 0.0)],
413            (0..=96)
414                .map(|index| {
415                    let angle = index as f64 * std::f64::consts::TAU / 96.0;
416                    (libm::cos(angle) * 40.0, libm::sin(angle) * 20.0)
417                })
418                .collect(),
419        ];
420        for (scale, translation) in [(1.0, 0.0), (1e-100, 0.0), (1e-300, 0.0), (1.0, 1e12)] {
421            for contour in &contours {
422                let edges = contour
423                    .windows(2)
424                    .map(|pair| {
425                        let point = |coordinates: (f64, f64)| {
426                            Point::new(
427                                coordinates.0 * scale + translation,
428                                coordinates.1 * scale - translation,
429                            )
430                        };
431                        (point(pair[0]), point(pair[1]))
432                    })
433                    .collect::<Vec<_>>();
434                let segments = edges
435                    .iter()
436                    .map(|&(start, end)| FillSegment::new(start, end))
437                    .collect::<Vec<_>>();
438                for row in -2..=22 {
439                    let vertical = row as f64 * scale - translation;
440                    let crossings = segments
441                        .iter()
442                        .filter_map(|segment| segment.crossing(vertical))
443                        .collect::<Vec<_>>();
444                    for column in -2..=22 {
445                        let point = Point::new(column as f64 * scale + translation, vertical);
446                        let expected = fill_reference(&edges, point);
447                        let winding: i32 = crossings
448                            .iter()
449                            .filter(|(crossing, _)| *crossing > point.x)
450                            .map(|(_, direction)| *direction)
451                            .sum();
452                        assert_eq!(
453                            fill_distance(&segments, point).to_bits(),
454                            expected.0.to_bits()
455                        );
456                        assert_eq!(winding, expected.1);
457                    }
458                }
459            }
460        }
461    }
462
463    #[test]
464    fn material_work_override_preserves_defaults_validation_and_retry()
465    -> Result<(), ValidationError> {
466        let stroke = BrushStroke::new(Path2D::parse_svg("M0 0L16 0")?, 2.0, Color::WHITE)
467            .ink(Ink::new(7).dryness(0.2));
468        let context = DrawContext::default();
469        let limits = motion_core::ValidationLimits::default();
470        let expected = context.record(&stroke, limits)?;
471        assert_eq!(stroke.material_work_limit, 32_000_000);
472        assert_eq!(
473            context
474                .record(&stroke.clone().material_work_limit(1), limits)
475                .unwrap_err()
476                .field,
477            "material"
478        );
479        assert_eq!(
480            context.record(&stroke.clone().material_work_limit(100_000_000), limits)?,
481            expected
482        );
483        assert!(
484            context
485                .record(&stroke.clone().material_work_limit(0).write(0.0), limits)
486                .is_err()
487        );
488        assert_eq!(context.record(&stroke, limits)?, expected);
489        Ok(())
490    }
491
492    #[cfg(feature = "gpu")]
493    #[test]
494    #[ignore = "requires native GPU; no fonts/window/encoder"]
495    fn ink_fill_material_matches_cpu_gpu() -> motion_core::Result {
496        let fill = InkFill::new(
497            Path2D::parse_svg("M4 4H28V28H4Z M10 10V22H22V10Z")?,
498            Color::rgb(20, 80, 65),
499            Ink::new(4)
500                .dryness(0.2)
501                .granulation(0.3)
502                .bleed(1.0)
503                .paper(7, 0.2, 1.0),
504        );
505        let limits = motion_core::RasterLimits::default();
506        let prepared = motion_core::Node::from(fill)
507            .compile(
508                motion_core::Size::new(32.0, 32.0),
509                motion_core::PixelSize::new(64, 64),
510                motion_core::ValidationLimits::default(),
511            )?
512            .prepare(motion_core::ValidationLimits::default(), limits)?;
513        let expected = prepared
514            .render_linear(limits)?
515            .to_srgb_rgba8(false, limits)?;
516        let mut gpu = motion_render::gpu::Gpu::new()?;
517        let actual = gpu
518            .render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?
519            .to_srgb_rgba8(false, limits)?;
520        let replay = gpu
521            .render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?
522            .to_srgb_rgba8(false, limits)?;
523        assert_eq!(actual.bytes(), replay.bytes());
524        assert!(
525            expected
526                .bytes()
527                .iter()
528                .zip(actual.bytes())
529                .all(|(expected, actual)| expected.abs_diff(*actual) <= 2)
530        );
531        Ok(())
532    }
533
534    #[test]
535    fn ink_preserves_holes_and_rejects_excessive_material() -> Result<(), ValidationError> {
536        let fill = InkFill::new(
537            Path2D::parse_svg("M4 4H28V28H4Z M10 10V22H22V10Z")?,
538            Color::BLACK,
539            Ink::new(4).granulation(0.2),
540        );
541        let frame = motion_core::Node::from(fill)
542            .compile(
543                motion_core::Size::new(32.0, 32.0),
544                motion_core::PixelSize::new(32, 32),
545                motion_core::ValidationLimits::default(),
546            )?
547            .prepare(
548                motion_core::ValidationLimits::default(),
549                motion_core::RasterLimits::default(),
550            )?
551            .render_linear(motion_core::RasterLimits::default())?;
552        assert_eq!(frame.pixel(16, 16).unwrap()[3], 0.0);
553        assert!(frame.pixel(6, 6).unwrap()[3] > 0.5);
554        assert_eq!(frame.pixel(0, 0).unwrap()[3], 0.0);
555        let oversized = InkFill::new(
556            Path2D::from_rect(Rect::new(0.0, 0.0, 2000.0, 2000.0))?,
557            Color::BLACK,
558            Ink::new(1),
559        );
560        assert!(
561            DrawContext::default()
562                .record(&oversized, motion_core::ValidationLimits::default())
563                .is_err()
564        );
565        Ok(())
566    }
567
568    #[test]
569    fn brush_is_single_fill_bounded_and_material_replays() -> Result<(), ValidationError> {
570        let path = Path2D::parse_svg("M4 16Q16 2 28 16")?;
571        let brush = BrushStroke::new(path.clone(), 6.0, Color::BLACK)
572            .pressure(0.1, 1.0, 0.1)
573            .nib(0.4, 0.5)
574            .ink(Ink::new(7).dryness(0.3).granulation(0.2));
575        let context = DrawContext::default();
576        let commands = context.record(&brush, motion_core::ValidationLimits::default())?;
577        assert_eq!(commands.len(), 1);
578        assert_eq!(
579            commands,
580            context.record(&brush, motion_core::ValidationLimits::default())?
581        );
582        assert!(
583            context
584                .record(
585                    &BrushStroke::new(path, 6.0, Color::BLACK).nib(0.0, 0.0),
586                    motion_core::ValidationLimits::default()
587                )
588                .is_err()
589        );
590        Ok(())
591    }
592
593    #[test]
594    fn ink_fill_uses_one_bounded_command_and_replays() -> Result<(), ValidationError> {
595        let fill = InkFill::new(
596            Path2D::parse_svg("M4 4H28V28H4Z M10 10V22H22V10Z")?,
597            Color::BLACK,
598            Ink::new(4).granulation(0.2).bleed(1.0),
599        );
600        let context = DrawContext::default();
601        let first = context.record(&fill, motion_core::ValidationLimits::default())?;
602        assert_eq!(first.len(), 1);
603        assert_eq!(
604            first,
605            context.record(&fill, motion_core::ValidationLimits::default())?
606        );
607        Ok(())
608    }
609}