1use 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#[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#[derive(Clone, Debug)]
83pub struct BrushStroke {
84 config: Config,
85 progress: f64,
86 full_material: Arc<OnceLock<Result<Vec<Prepared>, ValidationError>>>,
87}
88
89#[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 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 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 pub fn write(mut self, progress: f64) -> Self {
145 self.progress = progress;
146 self
147 }
148}
149
150impl BrushStrokeBuilder {
151 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 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 pub fn paint(mut self, paint: impl Into<Paint>) -> Self {
168 self.config.paint = paint.into();
169 self
170 }
171
172 pub fn ink(mut self, ink: Ink) -> Self {
174 self.config.ink = Some(ink);
175 self
176 }
177
178 pub fn material_work_limit(mut self, work: u64) -> Self {
183 self.config.material_work_limit = work;
184 self
185 }
186
187 pub fn ink_load(mut self, start: f64, end: f64) -> Self {
190 self.config.load = [start, end];
191 self
192 }
193
194 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#[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 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}