1use 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#[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 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 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 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 pub fn ink(mut self, ink: Ink) -> Self {
93 self.ink = Some(ink);
94 self.reset();
95 self
96 }
97
98 pub fn material_work_limit(mut self, work: u64) -> Self {
103 self.material_work_limit = work;
104 self.reset();
105 self
106 }
107
108 pub fn ink_load(mut self, start: f64, end: f64) -> Self {
110 self.load = [start, end];
111 self.reset();
112 self
113 }
114
115 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#[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 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}