1use crate::framebuffer::Framebuffer;
9use crate::scanline::{FillRule, Rasterizer};
10use oxiui_core::Color;
11
12pub type Point = (f32, f32);
18
19#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum Join {
22 Miter,
24 Bevel,
26 Round,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq)]
32pub enum Cap {
33 Butt,
35 Round,
37 Square,
39}
40
41#[derive(Clone, Debug)]
43pub struct StrokeStyle {
44 pub width: f32,
46 pub join: Join,
48 pub cap: Cap,
50 pub miter_limit: f32,
52}
53
54impl Default for StrokeStyle {
55 fn default() -> Self {
56 Self {
57 width: 1.0,
58 join: Join::Miter,
59 cap: Cap::Butt,
60 miter_limit: 4.0,
61 }
62 }
63}
64
65#[derive(Clone, Debug)]
67enum PathCmd {
68 MoveTo(Point),
69 LineTo(Point),
70 QuadTo(Point, Point), CubicTo(Point, Point, Point), Close,
73}
74
75#[derive(Clone, Debug, Default)]
84pub struct Path {
85 cmds: Vec<PathCmd>,
86 fill_rule: FillRule,
87}
88
89impl Path {
90 pub fn new() -> Self {
92 Self {
93 cmds: Vec::new(),
94 fill_rule: FillRule::default(),
95 }
96 }
97
98 pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
100 self.fill_rule = rule;
101 self
102 }
103
104 pub fn move_to(&mut self, p: Point) -> &mut Self {
106 self.cmds.push(PathCmd::MoveTo(p));
107 self
108 }
109
110 pub fn line_to(&mut self, p: Point) -> &mut Self {
112 self.cmds.push(PathCmd::LineTo(p));
113 self
114 }
115
116 pub fn quad_to(&mut self, ctrl: Point, end: Point) -> &mut Self {
118 self.cmds.push(PathCmd::QuadTo(ctrl, end));
119 self
120 }
121
122 pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
124 self.cmds.push(PathCmd::CubicTo(c1, c2, end));
125 self
126 }
127
128 pub fn close(&mut self) -> &mut Self {
130 self.cmds.push(PathCmd::Close);
131 self
132 }
133
134 pub fn flatten(&self, tolerance: f32) -> Vec<Vec<Point>> {
147 let tol = tolerance.max(0.01);
148 let mut contours: Vec<Vec<Point>> = Vec::new();
149 let mut current: Vec<Point> = Vec::new();
150 let mut start: Option<Point> = None;
151 let mut cursor: Point = (0.0, 0.0);
152
153 for cmd in &self.cmds {
154 match *cmd {
155 PathCmd::MoveTo(p) => {
156 if !current.is_empty() {
157 contours.push(core::mem::take(&mut current));
158 }
159 start = Some(p);
160 cursor = p;
161 current.push(p);
162 }
163 PathCmd::LineTo(p) => {
164 current.push(p);
165 cursor = p;
166 }
167 PathCmd::QuadTo(ctrl, end) => {
168 flatten_quad(cursor, ctrl, end, tol, &mut current);
169 cursor = end;
170 }
171 PathCmd::CubicTo(c1, c2, end) => {
172 flatten_cubic(cursor, c1, c2, end, tol, &mut current);
173 cursor = end;
174 }
175 PathCmd::Close => {
176 if let Some(s) = start {
177 if let Some(&last) = current.last() {
178 if dist2(last, s) > f32::EPSILON {
179 current.push(s);
180 }
181 }
182 }
183 if !current.is_empty() {
184 contours.push(core::mem::take(&mut current));
185 }
186 if let Some(s) = start {
187 cursor = s;
188 }
189 }
190 }
191 }
192 if !current.is_empty() {
193 contours.push(current);
194 }
195 contours
196 }
197
198 pub fn fill(&self, fb: &mut Framebuffer, color: Color) {
207 let tolerance = 0.25_f32;
208 let contours = self.flatten(tolerance);
209 let mut ras = Rasterizer::new();
210 for contour in contours {
211 if contour.len() >= 3 {
212 ras.fill_polygon(fb, &contour, color, self.fill_rule, true);
213 }
214 }
215 }
216
217 pub fn stroke(&self, fb: &mut Framebuffer, style: &StrokeStyle, color: Color) {
229 let half = (style.width * 0.5).max(0.5);
230 let tolerance = 0.25_f32;
231 let contours = self.flatten(tolerance);
232 let mut ras = Rasterizer::new();
233 for contour in &contours {
234 if contour.len() < 2 {
235 continue;
236 }
237 stroke_contour_with_ras(fb, contour, half, style, color, &mut ras);
238 }
239 }
240
241 pub fn fill_clipped(&self, fb: &mut Framebuffer, color: Color, clip: crate::clip::ClipRect) {
246 self.fill_clipped_aa(fb, color, clip, true);
247 }
248
249 pub fn stroke_clipped(
253 &self,
254 fb: &mut Framebuffer,
255 style: &StrokeStyle,
256 color: Color,
257 clip: crate::clip::ClipRect,
258 ) {
259 self.stroke_clipped_aa(fb, style, color, clip, true);
260 }
261
262 pub fn fill_clipped_aa(
269 &self,
270 fb: &mut Framebuffer,
271 color: Color,
272 clip: crate::clip::ClipRect,
273 aa: bool,
274 ) {
275 let tolerance = 0.25_f32;
276 let contours = self.flatten(tolerance);
277 let mut ras = Rasterizer::new();
278 for contour in contours {
279 if contour.len() >= 3 {
280 ras.fill_polygon_clipped(fb, &contour, color, self.fill_rule, aa, clip);
281 }
282 }
283 }
284
285 pub fn stroke_clipped_aa(
292 &self,
293 fb: &mut Framebuffer,
294 style: &StrokeStyle,
295 color: Color,
296 clip: crate::clip::ClipRect,
297 aa: bool,
298 ) {
299 let half = (style.width * 0.5).max(0.5);
300 let tolerance = 0.25_f32;
301 let contours = self.flatten(tolerance);
302 let mut ras = Rasterizer::new();
303 for contour in &contours {
304 if contour.len() < 2 {
305 continue;
306 }
307 stroke_contour_clipped_inner_with_ras(
308 fb, contour, half, style, color, clip, aa, &mut ras,
309 );
310 }
311 }
312}
313
314#[derive(Clone, Debug, Default)]
332pub struct PathBuilder {
333 path: Path,
334}
335
336impl PathBuilder {
337 pub fn new() -> Self {
339 Self::default()
340 }
341
342 pub fn move_to(mut self, p: Point) -> Self {
344 self.path.move_to(p);
345 self
346 }
347
348 pub fn line_to(mut self, p: Point) -> Self {
350 self.path.line_to(p);
351 self
352 }
353
354 pub fn quad_to(mut self, ctrl: Point, end: Point) -> Self {
356 self.path.quad_to(ctrl, end);
357 self
358 }
359
360 pub fn cubic_to(mut self, c1: Point, c2: Point, end: Point) -> Self {
362 self.path.cubic_to(c1, c2, end);
363 self
364 }
365
366 pub fn close(mut self) -> Self {
368 self.path.close();
369 self
370 }
371
372 pub fn fill_rule(mut self, rule: FillRule) -> Self {
374 self.path.fill_rule = rule;
375 self
376 }
377
378 pub fn build(self) -> Path {
380 self.path
381 }
382}
383
384#[inline]
390fn dist2(a: Point, b: Point) -> f32 {
391 let dx = b.0 - a.0;
392 let dy = b.1 - a.1;
393 dx * dx + dy * dy
394}
395
396#[inline]
398fn mid(a: Point, b: Point) -> Point {
399 ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
400}
401
402pub fn flatten_quad(p0: Point, p1: Point, p2: Point, tol: f32, out: &mut Vec<Point>) {
406 let chord_dev = {
408 let mx = (p0.0 + p2.0) * 0.5;
409 let my = (p0.1 + p2.1) * 0.5;
410 let dx = p1.0 - mx;
411 let dy = p1.1 - my;
412 dx * dx + dy * dy
413 };
414 if chord_dev <= tol * tol * 4.0 {
415 out.push(p2);
417 return;
418 }
419 let q0 = mid(p0, p1);
421 let q1 = mid(p1, p2);
422 let r0 = mid(q0, q1);
423 flatten_quad(p0, q0, r0, tol, out);
424 flatten_quad(r0, q1, p2, tol, out);
425}
426
427pub fn flatten_cubic(p0: Point, p1: Point, p2: Point, p3: Point, tol: f32, out: &mut Vec<Point>) {
431 let chord_dev = {
433 let d1 = {
437 let mx = (2.0 * p0.0 + p3.0) / 3.0;
438 let my = (2.0 * p0.1 + p3.1) / 3.0;
439 let dx = p1.0 - mx;
440 let dy = p1.1 - my;
441 dx * dx + dy * dy
442 };
443 let d2 = {
444 let mx = (p0.0 + 2.0 * p3.0) / 3.0;
445 let my = (p0.1 + 2.0 * p3.1) / 3.0;
446 let dx = p2.0 - mx;
447 let dy = p2.1 - my;
448 dx * dx + dy * dy
449 };
450 d1.max(d2)
451 };
452 if chord_dev <= tol * tol {
453 out.push(p3);
454 return;
455 }
456 let q0 = mid(p0, p1);
458 let q1 = mid(p1, p2);
459 let q2 = mid(p2, p3);
460 let r0 = mid(q0, q1);
461 let r1 = mid(q1, q2);
462 let s0 = mid(r0, r1);
463 flatten_cubic(p0, q0, r0, s0, tol, out);
464 flatten_cubic(s0, r1, q2, p3, tol, out);
465}
466
467#[inline]
473fn normal(a: Point, b: Point, half_w: f32) -> (f32, f32) {
474 let dx = b.0 - a.0;
475 let dy = b.1 - a.1;
476 let len = (dx * dx + dy * dy).sqrt();
477 if len < f32::EPSILON {
478 return (0.0, half_w);
479 }
480 let nx = -dy / len * half_w;
481 let ny = dx / len * half_w;
482 (nx, ny)
483}
484
485#[inline]
487fn offset(p: Point, n: (f32, f32)) -> Point {
488 (p.0 + n.0, p.1 + n.1)
489}
490
491#[inline]
493fn offset_neg(p: Point, n: (f32, f32)) -> Point {
494 (p.0 - n.0, p.1 - n.1)
495}
496
497fn stroke_contour_with_ras(
500 fb: &mut Framebuffer,
501 pts: &[Point],
502 half_w: f32,
503 style: &StrokeStyle,
504 color: Color,
505 ras: &mut Rasterizer,
506) {
507 let poly = build_stroke_poly(pts, half_w, style);
508 if poly.len() >= 3 {
509 ras.fill_polygon(fb, &poly, color, FillRule::NonZero, true);
510 }
511}
512
513fn build_stroke_poly(pts: &[Point], half_w: f32, style: &StrokeStyle) -> Vec<Point> {
518 let n = pts.len();
519 if n < 2 {
520 return Vec::new();
521 }
522 let closed = dist2(pts[0], pts[n - 1]) < 1e-4;
523 let effective_n = if closed { n - 1 } else { n };
524 if effective_n < 2 {
525 return Vec::new();
526 }
527
528 let seg_count = effective_n - 1;
529 let mut normals: Vec<(f32, f32)> = Vec::with_capacity(seg_count);
530 for i in 0..seg_count {
531 normals.push(normal(pts[i], pts[i + 1], half_w));
532 }
533
534 let mut left: Vec<Point> = Vec::with_capacity(effective_n + 8);
535 let mut right: Vec<Point> = Vec::with_capacity(effective_n + 8);
536
537 if !closed {
539 let n0 = normals[0];
540 match style.cap {
541 Cap::Butt => {
542 left.push(offset(pts[0], n0));
543 right.push(offset_neg(pts[0], n0));
544 }
545 Cap::Square => {
546 let dir = direction(pts[0], pts[1], half_w);
547 left.push(offset(offset_neg(pts[0], dir), n0));
548 right.push(offset_neg(offset_neg(pts[0], dir), n0));
549 }
550 Cap::Round => {
551 add_round_cap(&mut left, pts[0], n0, true);
552 right.push(offset_neg(pts[0], n0));
553 }
554 }
555 } else {
556 let n_last = normals[seg_count - 1];
557 let n_first = normals[0];
558 if style.join == Join::Round {
559 add_round_join(&mut left, &mut right, pts[0], n_last, n_first);
560 } else {
561 let (jl, jr) = compute_join(pts[0], n_last, n_first, style.join, style.miter_limit);
562 left.push(jl);
563 right.push(jr);
564 }
565 }
566
567 for i in 1..effective_n - 1 {
569 let n_prev = normals[i - 1];
570 let n_next = normals[i];
571 match style.join {
572 Join::Round => {
573 add_round_join(&mut left, &mut right, pts[i], n_prev, n_next);
574 }
575 _ => {
576 let (jl, jr) = compute_join(pts[i], n_prev, n_next, style.join, style.miter_limit);
577 left.push(jl);
578 right.push(jr);
579 }
580 }
581 }
582
583 if !closed {
585 let n_last = normals[seg_count - 1];
586 let end = pts[effective_n - 1];
587 match style.cap {
588 Cap::Butt => {
589 left.push(offset(end, n_last));
590 right.push(offset_neg(end, n_last));
591 }
592 Cap::Square => {
593 let dir = direction(pts[effective_n - 2], end, half_w);
594 left.push(offset(offset(end, dir), n_last));
595 right.push(offset_neg(offset(end, dir), n_last));
596 }
597 Cap::Round => {
598 left.push(offset(end, n_last));
599 add_round_cap(&mut right, end, n_last, false);
600 }
601 }
602 } else {
603 let n_prev = normals[seg_count - 1];
604 let n_first = normals[0];
605 if style.join == Join::Round {
606 add_round_join(&mut left, &mut right, pts[effective_n - 1], n_prev, n_first);
607 } else {
608 let (jl, jr) = compute_join(
609 pts[effective_n - 1],
610 n_prev,
611 n_first,
612 style.join,
613 style.miter_limit,
614 );
615 left.push(jl);
616 right.push(jr);
617 }
618 }
619
620 let mut poly: Vec<Point> = Vec::with_capacity(left.len() + right.len());
622 poly.extend_from_slice(&left);
623 for &p in right.iter().rev() {
624 poly.push(p);
625 }
626 poly
627}
628
629#[allow(clippy::too_many_arguments)]
632fn stroke_contour_clipped_inner_with_ras(
633 fb: &mut Framebuffer,
634 pts: &[Point],
635 half_w: f32,
636 style: &StrokeStyle,
637 color: Color,
638 clip: crate::clip::ClipRect,
639 aa: bool,
640 ras: &mut Rasterizer,
641) {
642 let poly = build_stroke_poly(pts, half_w, style);
643 if poly.len() >= 3 {
644 ras.fill_polygon_clipped(fb, &poly, color, FillRule::NonZero, aa, clip);
645 }
646}
647
648#[inline]
650fn direction(a: Point, b: Point, scale: f32) -> (f32, f32) {
651 let dx = b.0 - a.0;
652 let dy = b.1 - a.1;
653 let len = (dx * dx + dy * dy).sqrt();
654 if len < f32::EPSILON {
655 return (scale, 0.0);
656 }
657 (dx / len * scale, dy / len * scale)
658}
659
660fn compute_join(
670 pt: Point,
671 n_prev: (f32, f32),
672 n_next: (f32, f32),
673 join: Join,
674 miter_limit: f32,
675) -> (Point, Point) {
676 match join {
677 Join::Bevel | Join::Round => (offset(pt, n_next), offset_neg(pt, n_next)),
678 Join::Miter => miter_join(pt, n_prev, n_next, miter_limit),
679 }
680}
681
682fn miter_join(
684 pt: Point,
685 n_prev: (f32, f32),
686 n_next: (f32, f32),
687 miter_limit: f32,
688) -> (Point, Point) {
689 let avg_x = (n_prev.0 + n_next.0) * 0.5;
691 let avg_y = (n_prev.1 + n_next.1) * 0.5;
692 let len_sq = avg_x * avg_x + avg_y * avg_y;
693 if len_sq < f32::EPSILON {
694 return (offset(pt, n_next), offset_neg(pt, n_next));
696 }
697 let scale = 1.0 / len_sq;
701 let half_w_sq = n_prev.0 * n_prev.0 + n_prev.1 * n_prev.1;
703 let miter_len_sq = half_w_sq * scale;
704 if miter_len_sq > miter_limit * miter_limit * half_w_sq {
706 return (offset(pt, n_next), offset_neg(pt, n_next));
707 }
708 let mx = avg_x * scale * half_w_sq;
709 let my = avg_y * scale * half_w_sq;
710 let left = (pt.0 + mx, pt.1 + my);
711 let right = (pt.0 - mx, pt.1 - my);
712 (left, right)
713}
714
715fn add_round_join(
721 left: &mut Vec<Point>,
722 right: &mut Vec<Point>,
723 pt: Point,
724 n_prev: (f32, f32),
725 n_next: (f32, f32),
726) {
727 let half_w = (n_prev.0 * n_prev.0 + n_prev.1 * n_prev.1).sqrt();
728 if half_w < f32::EPSILON {
729 return;
730 }
731 let cross = n_prev.0 * n_next.1 - n_prev.1 * n_next.0;
735
736 let start_angle = n_prev.1.atan2(n_prev.0);
738 let end_angle = n_next.1.atan2(n_next.0);
739
740 let mut sweep = end_angle - start_angle;
742 while sweep > std::f32::consts::PI {
744 sweep -= 2.0 * std::f32::consts::PI;
745 }
746 while sweep < -std::f32::consts::PI {
747 sweep += 2.0 * std::f32::consts::PI;
748 }
749
750 const STEPS: usize = 8;
751 let fan_pts: Vec<Point> = (0..=STEPS)
752 .map(|step| {
753 let a = start_angle + sweep * (step as f32 / STEPS as f32);
754 (pt.0 + a.cos() * half_w, pt.1 + a.sin() * half_w)
755 })
756 .collect();
757
758 if cross >= 0.0 {
759 for &p in &fan_pts {
761 left.push(p);
762 }
763 right.push(offset_neg(pt, n_next));
765 } else {
766 left.push(offset(pt, n_next));
768 for &p in fan_pts.iter().rev() {
769 let dx = p.0 - pt.0;
771 let dy = p.1 - pt.1;
772 right.push((pt.0 - dx, pt.1 - dy));
773 }
774 }
775}
776
777fn add_round_cap(target: &mut Vec<Point>, center: Point, n: (f32, f32), forward: bool) {
779 const STEPS: usize = 8;
781 let (nx, ny) = n;
782 let half_w = (nx * nx + ny * ny).sqrt();
783 if half_w < f32::EPSILON {
784 return;
785 }
786 let ux = nx / half_w;
787 let uy = ny / half_w;
788 let start_angle = uy.atan2(ux);
791 let sign = if forward { 1.0_f32 } else { -1.0_f32 };
792 for i in 0..=STEPS {
793 let a = start_angle + sign * std::f32::consts::PI * (i as f32 / STEPS as f32);
794 let px = center.0 + a.cos() * half_w;
795 let py = center.1 + a.sin() * half_w;
796 target.push((px, py));
797 }
798}
799
800pub fn flatten_quad_bezier(p0: Point, p1: Point, p2: Point, tolerance: f32) -> Vec<Point> {
806 let mut pts = vec![p0];
807 flatten_quad(p0, p1, p2, tolerance.max(0.01), &mut pts);
808 pts
809}
810
811pub fn flatten_cubic_bezier(
813 p0: Point,
814 p1: Point,
815 p2: Point,
816 p3: Point,
817 tolerance: f32,
818) -> Vec<Point> {
819 let mut pts = vec![p0];
820 flatten_cubic(p0, p1, p2, p3, tolerance.max(0.01), &mut pts);
821 pts
822}
823
824#[cfg(test)]
829mod tests {
830 use super::*;
831 use crate::framebuffer::Framebuffer;
832
833 fn fresh(w: u32, h: u32) -> Framebuffer {
834 Framebuffer::with_fill(w, h, Color(0, 0, 0, 255))
835 }
836
837 #[test]
838 fn bezier_flatten_endpoints() {
839 let p0 = (0.0f32, 0.0);
841 let p1 = (10.0, 20.0);
842 let p2 = (20.0, -10.0);
843 let p3 = (30.0, 0.0);
844 let pts = flatten_cubic_bezier(p0, p1, p2, p3, 0.25);
845 assert!(pts.len() >= 2);
846 let first = pts[0];
847 let last = *pts.last().expect("at least one point");
848 assert!((first.0 - p0.0).abs() < 0.01 && (first.1 - p0.1).abs() < 0.01);
849 assert!((last.0 - p3.0).abs() < 0.01 && (last.1 - p3.1).abs() < 0.01);
850 }
851
852 #[test]
853 fn quad_flatten_endpoints() {
854 let p0 = (0.0f32, 0.0);
855 let p1 = (5.0, 10.0);
856 let p2 = (10.0, 0.0);
857 let pts = flatten_quad_bezier(p0, p1, p2, 0.25);
858 let last = *pts.last().expect("at least one point");
859 assert!((last.0 - p2.0).abs() < 0.01 && (last.1 - p2.1).abs() < 0.01);
860 }
861
862 #[test]
863 fn path_fill_triangle() {
864 let mut fb = fresh(20, 20);
865 let mut path = Path::new();
866 path.move_to((0.0, 0.0))
867 .line_to((20.0, 0.0))
868 .line_to((10.0, 20.0))
869 .close();
870 path.fill(&mut fb, Color(255, 0, 0, 255));
871 let (r, _, _, _) = fb.get_rgba(10, 10).unwrap_or((0, 0, 0, 0));
873 assert!(r > 0, "centre should be painted");
874 }
875
876 #[test]
877 fn path_fill_rule_cases() {
878 let cx = 35.0f32;
882 let cy = 35.0f32;
883 let r = 30.0f32;
884 let star_pts: Vec<(f32, f32)> = (0..5)
885 .map(|i| {
886 let angle = std::f32::consts::PI * (2.0 * (2 * i) as f32 / 5.0 - 0.5);
887 (cx + r * angle.cos(), cy + r * angle.sin())
888 })
889 .collect();
890
891 let build_star = |rule: FillRule| {
892 let mut p = Path::new().with_fill_rule(rule);
893 p.move_to(star_pts[0]);
894 for &pt in &star_pts[1..] {
895 p.line_to(pt);
896 }
897 p.close();
898 p
899 };
900
901 let path_eo = build_star(FillRule::EvenOdd);
902 let path_nz = build_star(FillRule::NonZero);
903
904 let mut fb_eo = fresh(70, 70);
905 let mut fb_nz = fresh(70, 70);
906 path_eo.fill(&mut fb_eo, Color(255, 0, 0, 255));
907 path_nz.fill(&mut fb_nz, Color(255, 0, 0, 255));
908
909 let (r_eo_tip, _, _, _) = fb_eo.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
911 assert!(r_eo_tip > 0, "EvenOdd: star arm should be painted");
912
913 let (r_nz_ctr, _, _, _) = fb_nz.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
915 assert!(r_nz_ctr > 0, "NonZero: star center should be filled");
916
917 let (r_eo_ctr, _, _, _) = fb_eo.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
919 assert_eq!(
920 r_eo_ctr, 0,
921 "EvenOdd: star center should be a hole (r={r_eo_ctr})"
922 );
923 }
924
925 #[test]
926 fn path_builder_works() {
927 let path = PathBuilder::new()
928 .move_to((0.0, 0.0))
929 .line_to((10.0, 0.0))
930 .line_to((5.0, 10.0))
931 .close()
932 .build();
933 let mut fb = fresh(15, 15);
934 path.fill(&mut fb, Color(0, 255, 0, 255));
935 let (_, g, _, _) = fb.get_rgba(5, 3).unwrap_or((0, 0, 0, 0));
936 assert!(g > 0, "builder path: interior should be green");
937 }
938
939 #[test]
940 fn path_stroke_produces_pixels() {
941 let mut fb = fresh(30, 30);
942 let mut path = Path::new();
943 path.move_to((5.0, 15.0)).line_to((25.0, 15.0));
944 let style = StrokeStyle {
945 width: 4.0,
946 join: Join::Miter,
947 cap: Cap::Butt,
948 miter_limit: 4.0,
949 };
950 path.stroke(&mut fb, &style, Color(0, 0, 255, 255));
951 let mut found = false;
953 for x in 0..30 {
954 let (_, _, b, _) = fb.get_rgba(x, 15).unwrap_or((0, 0, 0, 0));
955 if b > 0 {
956 found = true;
957 break;
958 }
959 }
960 assert!(found, "stroke should produce blue pixels along y=15");
961 }
962
963 #[test]
964 fn round_join_differs_from_bevel() {
965 let mut fb_round = fresh(40, 40);
969 let mut fb_bevel = fresh(40, 40);
970
971 let mut path_r = Path::new();
972 path_r
973 .move_to((5.0, 20.0))
974 .line_to((20.0, 20.0))
975 .line_to((20.0, 5.0));
976
977 let mut path_b = Path::new();
978 path_b
979 .move_to((5.0, 20.0))
980 .line_to((20.0, 20.0))
981 .line_to((20.0, 5.0));
982
983 let style_round = StrokeStyle {
984 width: 6.0,
985 join: Join::Round,
986 cap: Cap::Butt,
987 miter_limit: 4.0,
988 };
989 let style_bevel = StrokeStyle {
990 width: 6.0,
991 join: Join::Bevel,
992 cap: Cap::Butt,
993 miter_limit: 4.0,
994 };
995
996 path_r.stroke(&mut fb_round, &style_round, Color(255, 0, 0, 255));
997 path_b.stroke(&mut fb_bevel, &style_bevel, Color(255, 0, 0, 255));
998
999 let count = |fb: &Framebuffer| -> u32 {
1001 (0..40u32)
1002 .flat_map(|y| (0..40u32).map(move |x| (x, y)))
1003 .filter(|&(x, y)| fb.get_rgba(x, y).is_some_and(|(r, _, _, _)| r > 0))
1004 .count() as u32
1005 };
1006 let round_px = count(&fb_round);
1007 let bevel_px = count(&fb_bevel);
1008 assert!(
1009 round_px > 0,
1010 "Round join should produce some pixels (got {round_px})"
1011 );
1012 assert!(
1013 bevel_px > 0,
1014 "Bevel join should produce some pixels (got {bevel_px})"
1015 );
1016 assert_ne!(
1018 round_px, bevel_px,
1019 "Round and Bevel joins should differ in pixel count (both={round_px})"
1020 );
1021 }
1022
1023 #[test]
1024 fn miter_limit_prevents_spike() {
1025 let pt = (10.0f32, 10.0);
1028 let n_prev = (0.0f32, 2.0); let n_next = (0.0f32, -2.0); let (left, right) = miter_join(pt, n_prev, n_next, 4.0);
1031 assert!(left.0.is_finite() && left.1.is_finite());
1032 assert!(right.0.is_finite() && right.1.is_finite());
1033 }
1034}