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 super::paint::paint_sample;
5use crate::ImageAsset;
6use motion_core::{
7    Canvas, DrawContext, Drawable, FillRule, ImagePattern, Paint, Path2D, PathVerb, Point, Rect,
8    Transform, ValidationError, Vector2,
9};
10use std::sync::{Arc, OnceLock};
11
12mod advanced;
13
14pub use advanced::{
15    BrushCap, BrushPreset, BrushTiming, InputSample, NibMode, PigmentMix, PigmentPainting,
16};
17
18#[derive(Clone, Debug)]
19struct Prepared {
20    path: Arc<Path2D>,
21    paint: Paint,
22    bounds: Rect,
23}
24
25/// Immutable stroke configuration shared by the builder and the built stroke; the lazily baked
26/// material lives only on [`BrushStroke`].
27#[derive(Clone, Debug)]
28struct Config {
29    path: Path2D,
30    width: f64,
31    paint: Paint,
32    aspect: f64,
33    angle: f64,
34    pressure: [f64; 3],
35    load: [f64; 2],
36    ink: Option<Ink>,
37    material_work_limit: u64,
38    advanced: advanced::Options,
39}
40
41impl Config {
42    fn new(path: Path2D, width: f64, paint: Paint) -> Self {
43        Self {
44            path,
45            width,
46            paint,
47            aspect: 1.0,
48            angle: 0.0,
49            pressure: [1.0; 3],
50            load: [1.0; 2],
51            ink: None,
52            material_work_limit: 32_000_000,
53            advanced: advanced::Options::default(),
54        }
55    }
56}
57
58/// Pressure-sensitive pen stroke, recorded as nonzero path fills. This is the single brush
59/// implementation: [`BrushStroke::new`] captures the defaults of a fixed-angle elliptical nib and
60/// [`BrushStrokeBuilder`] refines them (pressure curves, caps, bristles, ink, timing). A built
61/// stroke is immutable; its baked material is cached on the stroke alone, and
62/// [`write`](Self::write) reveals share that cache.
63///
64/// Curves use bounded arc-length sampling (at most 256 intervals by default); nib caps use
65/// 24-sided outlines.
66/// Optional [`Ink`] reuses the deterministic material kernel in local coordinates,
67/// with at most 262144 texture texels and, by default, 32 million distance evaluations.
68/// Every render quality evaluates the full material.
69/// Explicit workloads may raise the latter through [`BrushStrokeBuilder::material_work_limit`].
70/// This is geometric pen/material rendering, not fluid simulation. The [`Paint`] gives each
71/// point its color before material attenuation, and paper is sampled in paper space (see
72/// [`Ink::paper_transform`]).
73///
74/// # Stroke Terms
75///
76/// `BrushStroke` is a drawable, whereas [`StrokeStyle`](motion_core::StrokeStyle) only
77/// configures a uniform-width outline of a `Styled` shape. [`BrushCap`] shapes the pressure
78/// profile at the ends of a variable-width stroke (round, flat or tapered), while
79/// [`LineCap`](motion_core::LineCap) selects the end geometry of a uniform stroke (butt, round or
80/// square); the two are independent. [`write`](Self::write) reveals from the start by original
81/// arc length, whereas `Styled::trim` clips an arbitrary interval of a stroke outline.
82#[derive(Clone, Debug)]
83pub struct BrushStroke {
84    config: Config,
85    progress: f64,
86    full_material: Arc<OnceLock<Result<Vec<Prepared>, ValidationError>>>,
87}
88
89/// Configures a [`BrushStroke`]; [`build`](Self::build) validates it and returns the immutable
90/// stroke.
91///
92/// Setters only store values. Nothing is baked until the built stroke is drawn.
93///
94/// # Example
95///
96/// ```
97/// use motion_content::prelude::*;
98/// use motion_content::vector::ink::*;
99/// use motion_core::prelude::*;
100/// # fn main() -> motion_core::Result {
101/// let stroke = BrushStroke::builder(Path2D::parse_svg("M0 0L60 0")?, 12.0, Color::BLACK)
102///     .nib(0.5, 0.4)
103///     .pressure(0.2, 1.0, 0.1)
104///     .caps(BrushCap::Round, BrushCap::Tapered(20.0))
105///     .ink(Ink::new(3).dryness(0.2))
106///     .build()?;
107/// # let _ = stroke;
108/// # Ok(())
109/// # }
110/// ```
111#[derive(Clone, Debug)]
112pub struct BrushStrokeBuilder {
113    config: Config,
114}
115
116fn invalid(field: &str, message: &str) -> ValidationError {
117    ValidationError::invalid("brush_stroke", field, message)
118}
119
120impl BrushStroke {
121    /// Captures a stroke with default settings and a color or other [`Paint`]. Validation is
122    /// deferred to recording; use [`builder`](Self::builder) to configure and validate eagerly.
123    pub fn new(path: Path2D, width: f64, paint: impl Into<Paint>) -> Self {
124        Self::from_config(Config::new(path, width, paint.into()))
125    }
126
127    /// Starts configuring a stroke with a color or other [`Paint`].
128    pub fn builder(path: Path2D, width: f64, paint: impl Into<Paint>) -> BrushStrokeBuilder {
129        BrushStrokeBuilder {
130            config: Config::new(path, width, paint.into()),
131        }
132    }
133
134    fn from_config(config: Config) -> Self {
135        Self {
136            config,
137            progress: 1.0,
138            full_material: Arc::new(OnceLock::new()),
139        }
140    }
141
142    /// Reveals `[0,progress]` by the original full path's arc length. The cached material is
143    /// shared with the source stroke, so animating the reveal bakes it only once.
144    pub fn write(mut self, progress: f64) -> Self {
145        self.progress = progress;
146        self
147    }
148}
149
150impl BrushStrokeBuilder {
151    /// Sets the minor/major ratio in (0,1] and fixed clockwise radians.
152    pub fn nib(mut self, aspect: f64, angle: f64) -> Self {
153        self.config.aspect = aspect;
154        self.config.angle = angle;
155        self.config.advanced.set_fixed_angle(angle);
156        self
157    }
158
159    /// Sets start/middle/end nonnegative pressure, each at most four.
160    pub fn pressure(mut self, start: f64, middle: f64, end: f64) -> Self {
161        self.config.pressure = [start, middle, end];
162        self.config.advanced.reset_pressure();
163        self
164    }
165
166    /// Replaces the [`Paint`] that colors the stroke before material attenuation.
167    pub fn paint(mut self, paint: impl Into<Paint>) -> Self {
168        self.config.paint = paint.into();
169        self
170    }
171
172    /// Replaces deterministic local-coordinate ink material.
173    pub fn ink(mut self, ink: Ink) -> Self {
174        self.config.ink = Some(ink);
175        self
176    }
177
178    /// Replaces the positive material distance-evaluation allowance for this stroke.
179    /// Defaults to 32 million. Raising it admits more CPU preparation work, not
180    /// a higher-quality sampler or a larger texture. Frame raster budgets remain independent.
181    /// Zero is rejected by [`build`](Self::build).
182    pub fn material_work_limit(mut self, work: u64) -> Self {
183        self.config.material_work_limit = work;
184        self
185    }
186
187    /// Replaces start/end ink load, each in `[0,1]`. Ink load is the pen's ink supply along the
188    /// path, so it stays a stroke setting rather than part of [`Ink`].
189    pub fn ink_load(mut self, start: f64, end: f64) -> Self {
190        self.config.load = [start, end];
191        self
192    }
193
194    /// Validates the configuration and returns the immutable stroke.
195    pub fn build(self) -> Result<BrushStroke, ValidationError> {
196        advanced::validate(&self.config)?;
197        Ok(BrushStroke::from_config(self.config))
198    }
199}
200
201impl Drawable for BrushStroke {
202    fn bounds(&self, _: &DrawContext) -> Result<Rect, ValidationError> {
203        advanced::bounds(self)
204    }
205
206    fn draw(&self, _: &DrawContext, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
207        advanced::draw(self, canvas)
208    }
209}
210
211struct FillSegment {
212    start: Point,
213    end: Point,
214    delta: Vector2,
215    square: f64,
216}
217
218impl FillSegment {
219    fn new(start: Point, end: Point) -> Self {
220        let delta = end - start;
221        Self {
222            start,
223            end,
224            delta,
225            square: delta.x * delta.x + delta.y * delta.y,
226        }
227    }
228
229    fn squared_distance(&self, point: Point) -> f64 {
230        let offset = point - self.start;
231        let amount = if self.square == 0.0 {
232            0.0
233        } else {
234            ((offset.x * self.delta.x + offset.y * self.delta.y) / self.square).clamp(0.0, 1.0)
235        };
236        let nearest = point - (self.start + self.delta * amount);
237        nearest.x * nearest.x + nearest.y * nearest.y
238    }
239
240    fn crossing(&self, vertical: f64) -> Option<(f64, i32)> {
241        if (self.start.y <= vertical && vertical < self.end.y)
242            || (self.end.y <= vertical && vertical < self.start.y)
243        {
244            Some((
245                self.start.x
246                    + (vertical - self.start.y) * (self.end.x - self.start.x)
247                        / (self.end.y - self.start.y),
248                if self.end.y > self.start.y { 1 } else { -1 },
249            ))
250        } else {
251            None
252        }
253    }
254}
255
256fn fill_distance(segments: &[FillSegment], point: Point) -> f64 {
257    libm::sqrt(segments.iter().fold(f64::INFINITY, |distance, segment| {
258        distance.min(segment.squared_distance(point))
259    }))
260}
261
262/// Bounded ink fill. Kurbo flattens the original contours; signed boundary
263/// distance and the existing [`Ink`] material kernel produce a RGBA texture.
264/// The texture includes feathering and is emitted as one rectangle fill.
265/// The [`Paint`] (a color, gradient or pattern) colors each texel before material attenuation.
266/// At most 4096 edges, 262144 texels and 32 million edge/texel evaluations are
267/// accepted. Paper is sampled in paper space and there is no fluid simulation, as for
268/// [`BrushStroke`]. Stroke-only ink effects are ignored; see [`Ink`].
269#[derive(Clone, Debug)]
270pub struct InkFill {
271    path: Path2D,
272    paint: Paint,
273    ink: Ink,
274    prepared: Arc<OnceLock<Result<Prepared, ValidationError>>>,
275}
276
277impl InkFill {
278    /// Captures an immutable contour and deterministic material.
279    pub fn new(path: Path2D, paint: impl Into<Paint>, ink: Ink) -> Self {
280        Self {
281            path,
282            paint: paint.into(),
283            ink,
284            prepared: Arc::new(OnceLock::new()),
285        }
286    }
287
288    fn prepared(&self) -> Result<&Prepared, ValidationError> {
289        self.prepared
290            .get_or_init(|| self.prepare())
291            .as_ref()
292            .map_err(Clone::clone)
293    }
294
295    fn prepare(&self) -> Result<Prepared, ValidationError> {
296        if self.path.verbs().len() > 512 {
297            return Err(invalid("fill", "Ink fill admits at most 512 input verbs"));
298        }
299        let padding = f64::from(
300            self.ink
301                .padding()
302                .map_err(|error| invalid("ink", &error.to_string()))?,
303        ) + 1.0;
304        let bounds = self.path.bounding_box().inset(-padding);
305        let width = bounds.width().ceil().max(1.0) as u32;
306        let height = bounds.height().ceil().max(1.0) as u32;
307        if width > 2048 || height > 2048 || u64::from(width) * u64::from(height) > 262144 {
308            return Err(invalid("fill", "Ink fill texture exceeds allowance"));
309        }
310        let mut curve = kurbo::BezPath::new();
311        for verb in self.path.verbs() {
312            let point = |point: &Point| (point.x, point.y);
313            match verb {
314                PathVerb::MoveTo(end) => curve.move_to(point(end)),
315                PathVerb::LineTo(end) => curve.line_to(point(end)),
316                PathVerb::QuadraticTo(control, end) => curve.quad_to(point(control), point(end)),
317                PathVerb::CubicTo(first, second, end) => {
318                    curve.curve_to(point(first), point(second), point(end))
319                }
320                PathVerb::Close => curve.close_path(),
321            }
322        }
323        let mut edges = Vec::new();
324        let mut exceeded = false;
325        let mut append = |first, second| {
326            if edges.len() < 4096 {
327                edges.push((first, second));
328            } else {
329                exceeded = true;
330            }
331        };
332        let mut position = Point::ORIGIN;
333        let mut start = position;
334        let mut open = false;
335        kurbo::flatten(
336            curve.elements().iter().copied(),
337            0.05,
338            |element| match element {
339                kurbo::PathEl::MoveTo(end) => {
340                    if open && position != start {
341                        append(position, start);
342                    }
343                    position = Point::new(end.x, end.y);
344                    start = position;
345                    open = true;
346                }
347                kurbo::PathEl::LineTo(end) => {
348                    let end = Point::new(end.x, end.y);
349                    append(position, end);
350                    position = end;
351                }
352                kurbo::PathEl::ClosePath => {
353                    append(position, start);
354                    position = start;
355                    open = false;
356                }
357                _ => unreachable!("flatten returns lines"),
358            },
359        );
360        if open && position != start {
361            append(position, start);
362        }
363        if exceeded || edges.len() as u64 * u64::from(width) * u64::from(height) > 32_000_000 {
364            return Err(invalid(
365                "fill",
366                "Ink fill edge/texel work exceeds allowance",
367            ));
368        }
369        self.paint.validate()?;
370        let paint_bounds = self.path.bounding_box();
371        let solid = match &self.paint {
372            Paint::Solid(color) => Some(color.to_rgba_float()),
373            _ => None,
374        };
375        let paper = self.ink.paper_sampler();
376        let darkening = self.ink.edge_darkening;
377        let segments = edges
378            .into_iter()
379            .map(|(start, end)| FillSegment::new(start, end))
380            .collect::<Vec<_>>();
381        let mut crossings = Vec::with_capacity(segments.len());
382        let mut pixels = Vec::with_capacity(width as usize * height as usize);
383        for row in 0..height {
384            let vertical = bounds.top + f64::from(row) + 0.5;
385            crossings.clear();
386            crossings.extend(
387                segments
388                    .iter()
389                    .filter_map(|segment| segment.crossing(vertical)),
390            );
391            for column in 0..width {
392                let point = Point::new(bounds.left + f64::from(column) + 0.5, vertical);
393                let distance = fill_distance(&segments, point);
394                let mut winding = 0;
395                for &(crossing, direction) in &crossings {
396                    if crossing > point.x {
397                        winding += direction;
398                    }
399                }
400                let signed = if winding == 0 { distance } else { -distance };
401                let paper_point = self.ink.paper_space(point);
402                let material = self
403                    .ink
404                    .sample((point.x, point.y), (paper_point.x, paper_point.y));
405                let mut alpha = material.fill_edge_coverage(signed, 1.0).clamp(0.0, 1.0);
406                if let Some(paper) = &paper {
407                    alpha *= paper.factor(paper_point)?;
408                }
409                let mut color = match solid {
410                    Some(color) => color,
411                    None => paint_sample(&self.paint, point, paint_bounds)?,
412                };
413                if darkening > 0.0 {
414                    let rim = (1.0 - signed.abs() / 2.0).clamp(0.0, 1.0);
415                    for channel in &mut color[..3] {
416                        *channel *= 1.0 - darkening * rim;
417                    }
418                }
419                pixels.push([color[0], color[1], color[2], color[3] * alpha]);
420            }
421        }
422        let image = ImageAsset::from_rgba_float(width, height, pixels)
423            .map_err(|error| invalid("fill", &error.to_string()))?;
424        let paint = ImagePattern::from_asset(Arc::new(image))
425            .transform(Transform::translate(bounds.left, bounds.top))
426            .into();
427        Ok(Prepared {
428            path: Arc::new(Path2D::from_rect(bounds)?),
429            paint,
430            bounds,
431        })
432    }
433}
434
435impl Drawable for InkFill {
436    fn bounds(&self, _: &DrawContext) -> Result<Rect, ValidationError> {
437        Ok(self.prepared()?.bounds)
438    }
439
440    fn draw(&self, _: &DrawContext, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
441        let prepared = self.prepared()?;
442        canvas.fill_path(
443            prepared.path.clone(),
444            Transform::IDENTITY,
445            prepared.paint.clone(),
446            FillRule::NonZero,
447        )
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use motion_core::Color;
455    use motion_render::RenderPrepare;
456
457    fn fill_reference(edges: &[(Point, Point)], point: Point) -> (f64, i32) {
458        let mut distance = f64::INFINITY;
459        let mut winding = 0;
460        for &(first, second) in edges {
461            let delta = second - first;
462            let offset = point - first;
463            let square = delta.x * delta.x + delta.y * delta.y;
464            let amount = if square == 0.0 {
465                0.0
466            } else {
467                ((offset.x * delta.x + offset.y * delta.y) / square).clamp(0.0, 1.0)
468            };
469            let nearest = point - (first + delta * amount);
470            distance = distance.min(libm::sqrt(nearest.x * nearest.x + nearest.y * nearest.y));
471            if (first.y <= point.y && point.y < second.y)
472                || (second.y <= point.y && point.y < first.y)
473            {
474                let crossing =
475                    first.x + (point.y - first.y) * (second.x - first.x) / (second.y - first.y);
476                if crossing > point.x {
477                    winding += if second.y > first.y { 1 } else { -1 };
478                }
479            }
480        }
481        (distance, winding)
482    }
483
484    #[test]
485    fn fill_queries_match_original_distance_and_winding_bits() {
486        let contours = [
487            vec![],
488            vec![(0.0, 0.0), (0.0, 0.0)],
489            vec![
490                (0.0, 0.0),
491                (20.0, 0.0),
492                (20.0, 20.0),
493                (0.0, 20.0),
494                (0.0, 0.0),
495            ],
496            vec![
497                (0.0, 0.0),
498                (20.0, 20.0),
499                (0.0, 20.0),
500                (20.0, 0.0),
501                (0.0, 0.0),
502            ],
503            vec![(0.0, 0.0), (20.0, 0.0), (0.0, 0.0), (0.0, 20.0), (0.0, 0.0)],
504            (0..=96)
505                .map(|index| {
506                    let angle = index as f64 * std::f64::consts::TAU / 96.0;
507                    (libm::cos(angle) * 40.0, libm::sin(angle) * 20.0)
508                })
509                .collect(),
510        ];
511        for (scale, translation) in [(1.0, 0.0), (1e-100, 0.0), (1e-300, 0.0), (1.0, 1e12)] {
512            for contour in &contours {
513                let edges = contour
514                    .windows(2)
515                    .map(|pair| {
516                        let point = |coordinates: (f64, f64)| {
517                            Point::new(
518                                coordinates.0 * scale + translation,
519                                coordinates.1 * scale - translation,
520                            )
521                        };
522                        (point(pair[0]), point(pair[1]))
523                    })
524                    .collect::<Vec<_>>();
525                let segments = edges
526                    .iter()
527                    .map(|&(start, end)| FillSegment::new(start, end))
528                    .collect::<Vec<_>>();
529                for row in -2..=22 {
530                    let vertical = row as f64 * scale - translation;
531                    let crossings = segments
532                        .iter()
533                        .filter_map(|segment| segment.crossing(vertical))
534                        .collect::<Vec<_>>();
535                    for column in -2..=22 {
536                        let point = Point::new(column as f64 * scale + translation, vertical);
537                        let expected = fill_reference(&edges, point);
538                        let winding: i32 = crossings
539                            .iter()
540                            .filter(|(crossing, _)| *crossing > point.x)
541                            .map(|(_, direction)| *direction)
542                            .sum();
543                        assert_eq!(
544                            fill_distance(&segments, point).to_bits(),
545                            expected.0.to_bits()
546                        );
547                        assert_eq!(winding, expected.1);
548                    }
549                }
550            }
551        }
552    }
553
554    #[test]
555    fn material_work_override_preserves_defaults_validation_and_retry()
556    -> Result<(), ValidationError> {
557        let builder = BrushStroke::builder(Path2D::parse_svg("M0 0L16 0")?, 2.0, Color::WHITE)
558            .ink(Ink::new(7).dryness(0.2));
559        let stroke = builder.clone().build()?;
560        let context = DrawContext::default();
561        let limits = motion_core::ValidationLimits::default();
562        let expected = context.record(&stroke, limits)?;
563        assert_eq!(stroke.config.material_work_limit, 32_000_000);
564        assert_eq!(
565            context
566                .record(&builder.clone().material_work_limit(1).build()?, limits)
567                .unwrap_err()
568                .field,
569            "material"
570        );
571        assert_eq!(
572            context.record(
573                &builder.clone().material_work_limit(100_000_000).build()?,
574                limits
575            )?,
576            expected
577        );
578        assert!(builder.material_work_limit(0).build().is_err());
579        assert_eq!(context.record(&stroke, limits)?, expected);
580        Ok(())
581    }
582
583    #[cfg(feature = "gpu")]
584    #[test]
585    #[ignore = "requires native GPU; no fonts/window/encoder"]
586    fn ink_fill_material_matches_cpu_gpu() -> motion_core::Result {
587        let fill = InkFill::new(
588            Path2D::parse_svg("M4 4H28V28H4Z M10 10V22H22V10Z")?,
589            Color::rgb(20, 80, 65),
590            Ink::new(4).dryness(0.2).granulation(0.3).bleed(1.0).paper(
591                crate::vector::ink::Paper::Procedural {
592                    seed: 7,
593                    strength: 0.2,
594                    scale: 1.0,
595                },
596            ),
597        );
598        let limits = motion_core::RasterLimits::default();
599        let prepared = motion_core::Node::from(fill)
600            .compile(
601                motion_core::Size::new(32.0, 32.0),
602                motion_core::PixelSize::new(64, 64),
603                motion_core::ValidationLimits::default(),
604            )?
605            .prepare(motion_core::ValidationLimits::default(), limits)?;
606        let expected = prepared.render_linear(limits)?.to_format(
607            motion_render::PixelFormat::SrgbRgba8 {
608                premultiplied: false,
609            },
610            limits,
611        )?;
612        let mut gpu = motion_render::gpu::Gpu::new()?;
613        let actual = gpu.render_linear(&prepared, limits)?.to_format(
614            motion_render::PixelFormat::SrgbRgba8 {
615                premultiplied: false,
616            },
617            limits,
618        )?;
619        let replay = gpu.render_linear(&prepared, limits)?.to_format(
620            motion_render::PixelFormat::SrgbRgba8 {
621                premultiplied: false,
622            },
623            limits,
624        )?;
625        assert_eq!(actual.bytes(), replay.bytes());
626        assert!(
627            expected
628                .bytes()
629                .iter()
630                .zip(actual.bytes())
631                .all(|(expected, actual)| expected.abs_diff(*actual) <= 2)
632        );
633        Ok(())
634    }
635
636    #[test]
637    fn ink_preserves_holes_and_rejects_excessive_material() -> Result<(), ValidationError> {
638        let fill = InkFill::new(
639            Path2D::parse_svg("M4 4H28V28H4Z M10 10V22H22V10Z")?,
640            Color::BLACK,
641            Ink::new(4).granulation(0.2),
642        );
643        let frame = motion_core::Node::from(fill)
644            .compile(
645                motion_core::Size::new(32.0, 32.0),
646                motion_core::PixelSize::new(32, 32),
647                motion_core::ValidationLimits::default(),
648            )?
649            .prepare(
650                motion_core::ValidationLimits::default(),
651                motion_core::RasterLimits::default(),
652            )?
653            .render_linear(motion_core::RasterLimits::default())?;
654        assert_eq!(frame.linear_pixel(16, 16).unwrap()[3], 0.0);
655        assert!(frame.linear_pixel(6, 6).unwrap()[3] > 0.5);
656        assert_eq!(frame.linear_pixel(0, 0).unwrap()[3], 0.0);
657        let oversized = InkFill::new(
658            Path2D::from_rect(Rect::new(0.0, 0.0, 2000.0, 2000.0))?,
659            Color::BLACK,
660            Ink::new(1),
661        );
662        assert!(
663            DrawContext::default()
664                .record(&oversized, motion_core::ValidationLimits::default())
665                .is_err()
666        );
667        Ok(())
668    }
669
670    #[test]
671    fn brush_is_single_fill_bounded_and_material_replays() -> Result<(), ValidationError> {
672        let path = Path2D::parse_svg("M4 16Q16 2 28 16")?;
673        let brush = BrushStroke::builder(path.clone(), 6.0, Color::BLACK)
674            .pressure(0.1, 1.0, 0.1)
675            .nib(0.4, 0.5)
676            .ink(Ink::new(7).dryness(0.3).granulation(0.2))
677            .build()?;
678        let context = DrawContext::default();
679        let commands = context.record(&brush, motion_core::ValidationLimits::default())?;
680        assert_eq!(commands.len(), 1);
681        assert_eq!(
682            commands,
683            context.record(&brush, motion_core::ValidationLimits::default())?
684        );
685        assert!(
686            BrushStroke::builder(path, 6.0, Color::BLACK)
687                .nib(0.0, 0.0)
688                .build()
689                .is_err()
690        );
691        Ok(())
692    }
693
694    #[test]
695    fn ink_fill_uses_one_bounded_command_and_replays() -> Result<(), ValidationError> {
696        let fill = InkFill::new(
697            Path2D::parse_svg("M4 4H28V28H4Z M10 10V22H22V10Z")?,
698            Color::BLACK,
699            Ink::new(4).granulation(0.2).bleed(1.0),
700        );
701        let context = DrawContext::default();
702        let first = context.record(&fill, motion_core::ValidationLimits::default())?;
703        assert_eq!(first.len(), 1);
704        assert_eq!(
705            first,
706            context.record(&fill, motion_core::ValidationLimits::default())?
707        );
708        Ok(())
709    }
710
711    #[test]
712    fn ink_fill_takes_gradients_texture_paper_and_edge_darkening() -> Result<(), ValidationError> {
713        let path = Path2D::parse_svg("M4 4H28V28H4Z")?;
714        let gradient = motion_core::LinearGradient::new(Point::new(0.0, 0.0), Point::new(1.0, 0.0))
715            .stop(0.0, Color::rgb(255, 0, 0))
716            .stop(1.0, Color::rgb(0, 0, 255));
717        let paper = ImageAsset::from_rgba8(1, 1, vec![255, 255, 255, 255])
718            .map_err(|error| invalid("paper", &error.to_string()))?;
719        let ink = Ink::new(1)
720            .paper(crate::vector::ink::Paper::Texture {
721                texture: Arc::new(paper),
722                grain_scale: 4.0,
723                absorbency: 0.5,
724            })
725            .edge_darkening(0.5);
726        let render = |paint: Paint, ink: Ink| -> Result<_, motion_core::Error> {
727            let limits = motion_core::RasterLimits::default();
728            Ok(
729                motion_core::Node::from(InkFill::new(path.clone(), paint, ink))
730                    .compile(
731                        motion_core::Size::new(32.0, 32.0),
732                        motion_core::PixelSize::new(32, 32),
733                        motion_core::ValidationLimits::default(),
734                    )?
735                    .prepare(motion_core::ValidationLimits::default(), limits)?
736                    .render_linear(limits)?,
737            )
738        };
739        let solid = render(Color::rgb(255, 0, 0).into(), Ink::new(1)).unwrap();
740        let graded = render(gradient.into(), Ink::new(1)).unwrap();
741        assert!(graded.linear_pixel(6, 16).unwrap()[0] > graded.linear_pixel(26, 16).unwrap()[0]);
742        assert!(graded.linear_pixel(6, 16).unwrap()[2] < graded.linear_pixel(26, 16).unwrap()[2]);
743        assert_eq!(solid.linear_pixel(6, 16).unwrap()[3], 1.0);
744        let papered = render(Color::rgb(255, 0, 0).into(), ink).unwrap();
745        assert!(papered.linear_pixel(16, 16).unwrap()[3] < 0.6);
746        Ok(())
747    }
748}