Skip to main content

oxiui_render_soft/
draw.rs

1//! Drawing primitives operating on a [`Framebuffer`] through a clip stack.
2//!
3//! [`Canvas`] borrows a framebuffer and owns a [`ClipStack`]; all drawing is
4//! clipped to the current effective clip. Shapes use straight-alpha source-over
5//! compositing; circles and rounded rectangles use analytical coverage-based
6//! anti-aliasing along their edges.
7
8use crate::clip::{ClipRect, ClipStack};
9use crate::framebuffer::Framebuffer;
10use oxiui_core::Color;
11
12/// Dash pattern for [`Canvas::draw_line_dashed`].
13#[derive(Clone, Copy, Debug)]
14pub struct DashPattern {
15    /// Length of each dash in pixels (minimum 1.0).
16    pub dash_len: f32,
17    /// Length of each gap in pixels.
18    pub gap_len: f32,
19}
20
21impl DashPattern {
22    /// Construct a dash pattern.
23    pub fn new(dash_len: f32, gap_len: f32) -> Self {
24        Self {
25            dash_len: dash_len.max(1.0),
26            gap_len: gap_len.max(0.0),
27        }
28    }
29}
30
31/// A borrowed RGBA source image for [`Canvas::blit_rgba`].
32///
33/// `data` holds `width * height * 4` bytes in R, G, B, A order, row-major.
34#[derive(Clone, Copy, Debug)]
35pub struct SrcImage<'a> {
36    /// Pixel bytes (`width * height * 4`, R,G,B,A per pixel, row-major).
37    pub data: &'a [u8],
38    /// Source width in pixels.
39    pub width: u32,
40    /// Source height in pixels.
41    pub height: u32,
42}
43
44impl<'a> SrcImage<'a> {
45    /// Construct a source image view from raw RGBA bytes.
46    pub fn new(data: &'a [u8], width: u32, height: u32) -> Self {
47        Self {
48            data,
49            width,
50            height,
51        }
52    }
53
54    /// Returns `true` if `data` is large enough for `width * height` RGBA pixels.
55    pub fn is_valid(&self) -> bool {
56        self.width > 0
57            && self.height > 0
58            && self.data.len() >= (self.width * self.height * 4) as usize
59    }
60}
61
62/// A clipped drawing surface over a [`Framebuffer`].
63pub struct Canvas<'fb> {
64    fb: &'fb mut Framebuffer,
65    clip: ClipStack,
66    /// When `true`, polygon / path fill operations use sub-pixel AA (vertical
67    /// supersample coverage). Set via [`Canvas::set_aa`].
68    pub aa: bool,
69}
70
71impl<'fb> Canvas<'fb> {
72    /// Create a canvas over `fb` with a base clip covering the whole buffer.
73    /// Anti-aliasing is **enabled** by default.
74    pub fn new(fb: &'fb mut Framebuffer) -> Self {
75        let clip = ClipStack::new(fb.width(), fb.height());
76        Self { fb, clip, aa: true }
77    }
78
79    /// Set whether polygon / path fill operations use sub-pixel AA.
80    ///
81    /// When `false`, the `aa` flag passed to `fill_polygon_clipped` is `false`,
82    /// producing aliased edges (faster).
83    pub fn set_aa(&mut self, enabled: bool) {
84        self.aa = enabled;
85    }
86
87    /// Borrow the underlying framebuffer.
88    pub fn framebuffer(&self) -> &Framebuffer {
89        self.fb
90    }
91
92    /// Push a rectangular clip (intersected with the current clip).
93    pub fn push_clip(&mut self, x: f32, y: f32, w: f32, h: f32) {
94        let rect = ClipRect::from_rect(
95            x.floor() as i64,
96            y.floor() as i64,
97            w.ceil() as i64,
98            h.ceil() as i64,
99        );
100        self.clip.push(rect);
101    }
102
103    /// Pop the most recently pushed clip.
104    pub fn pop_clip(&mut self) {
105        self.clip.pop();
106    }
107
108    #[inline]
109    fn put(&mut self, x: i64, y: i64, color: &Color, coverage: f32) {
110        if !self.clip.current().contains(x, y) {
111            return;
112        }
113        if x < 0 || y < 0 {
114            return;
115        }
116        self.fb.blend_coverage(x as u32, y as u32, color, coverage);
117    }
118
119    /// Fill an axis-aligned rectangle with a solid colour (no AA; pixel-snapped).
120    pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
121        if w <= 0.0 || h <= 0.0 {
122            return;
123        }
124        let x0 = x.floor() as i64;
125        let y0 = y.floor() as i64;
126        let x1 = (x + w).ceil() as i64;
127        let y1 = (y + h).ceil() as i64;
128        let clip = self.clip.current();
129        let cx0 = x0.max(clip.x0);
130        let cy0 = y0.max(clip.y0);
131        let cx1 = x1.min(clip.x1);
132        let cy1 = y1.min(clip.y1);
133        for py in cy0..cy1 {
134            for px in cx0..cx1 {
135                if px >= 0 && py >= 0 {
136                    self.fb
137                        .blend(px as u32, py as u32, crate::framebuffer::pack(&color));
138                }
139            }
140        }
141    }
142
143    /// Stroke a rectangle outline of the given `thickness` (drawn inward).
144    pub fn stroke_rect(&mut self, x: f32, y: f32, w: f32, h: f32, thickness: f32, color: Color) {
145        let t = thickness.max(1.0);
146        // Top, bottom, left, right bars.
147        self.fill_rect(x, y, w, t, color);
148        self.fill_rect(x, y + h - t, w, t, color);
149        self.fill_rect(x, y, t, h, color);
150        self.fill_rect(x + w - t, y, t, h, color);
151    }
152
153    /// Draw a 1-pixel line from `(x0, y0)` to `(x1, y1)` using Bresenham's
154    /// algorithm (no anti-aliasing).
155    pub fn draw_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color) {
156        let mut x0 = x0.round() as i64;
157        let mut y0 = y0.round() as i64;
158        let x1 = x1.round() as i64;
159        let y1 = y1.round() as i64;
160        let dx = (x1 - x0).abs();
161        let dy = -(y1 - y0).abs();
162        let sx = if x0 < x1 { 1 } else { -1 };
163        let sy = if y0 < y1 { 1 } else { -1 };
164        let mut err = dx + dy;
165        loop {
166            self.put(x0, y0, &color, 1.0);
167            if x0 == x1 && y0 == y1 {
168                break;
169            }
170            let e2 = 2 * err;
171            if e2 >= dy {
172                err += dy;
173                x0 += sx;
174            }
175            if e2 <= dx {
176                err += dx;
177                y0 += sy;
178            }
179        }
180    }
181
182    /// Fill an anti-aliased circle centred at `(cx, cy)` with `radius`.
183    ///
184    /// Coverage is estimated from the signed distance to the circle edge,
185    /// giving a smooth one-pixel-wide anti-aliased boundary.
186    pub fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color) {
187        if radius <= 0.0 {
188            return;
189        }
190        let x0 = (cx - radius - 1.0).floor() as i64;
191        let y0 = (cy - radius - 1.0).floor() as i64;
192        let x1 = (cx + radius + 1.0).ceil() as i64;
193        let y1 = (cy + radius + 1.0).ceil() as i64;
194        for py in y0..y1 {
195            for px in x0..x1 {
196                // Sample at pixel centre.
197                let sx = px as f32 + 0.5;
198                let sy = py as f32 + 0.5;
199                let dist = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt();
200                let coverage = (radius - dist + 0.5).clamp(0.0, 1.0);
201                if coverage > 0.0 {
202                    self.put(px, py, &color, coverage);
203                }
204            }
205        }
206    }
207
208    /// Fill a rounded rectangle with a uniform corner `radius` and AA edges.
209    pub fn fill_rounded_rect(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, color: Color) {
210        if w <= 0.0 || h <= 0.0 {
211            return;
212        }
213        let r = radius.clamp(0.0, (w.min(h)) * 0.5);
214        if r <= 0.0 {
215            self.fill_rect(x, y, w, h, color);
216            return;
217        }
218        let x0 = x.floor() as i64;
219        let y0 = y.floor() as i64;
220        let x1 = (x + w).ceil() as i64;
221        let y1 = (y + h).ceil() as i64;
222        // Corner circle centres.
223        let left = x + r;
224        let right = x + w - r;
225        let top = y + r;
226        let bottom = y + h - r;
227        for py in y0..y1 {
228            for px in x0..x1 {
229                let sx = px as f32 + 0.5;
230                let sy = py as f32 + 0.5;
231                // Distance into the nearest corner region, if any.
232                let dx = if sx < left {
233                    left - sx
234                } else if sx > right {
235                    sx - right
236                } else {
237                    0.0
238                };
239                let dy = if sy < top {
240                    top - sy
241                } else if sy > bottom {
242                    sy - bottom
243                } else {
244                    0.0
245                };
246                let coverage = if dx == 0.0 || dy == 0.0 {
247                    // Straight edge or interior: inside rect bounds fully covered.
248                    if sx >= x && sx <= x + w && sy >= y && sy <= y + h {
249                        1.0
250                    } else {
251                        0.0
252                    }
253                } else {
254                    let dist = (dx * dx + dy * dy).sqrt();
255                    (r - dist + 0.5).clamp(0.0, 1.0)
256                };
257                if coverage > 0.0 {
258                    self.put(px, py, &color, coverage);
259                }
260            }
261        }
262    }
263
264    // -----------------------------------------------------------------------
265    // Wu's anti-aliased line
266    // -----------------------------------------------------------------------
267
268    /// Draw an anti-aliased line from `(x0, y0)` to `(x1, y1)` using Wu's
269    /// algorithm (float pixel coverage).
270    pub fn draw_line_wu(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color) {
271        wu_line(self.fb, &self.clip, x0, y0, x1, y1, &color);
272    }
273
274    /// Draw a thick anti-aliased line of the given `width` using a parallel-offset
275    /// rectangle polygon fill.
276    pub fn draw_line_thick(
277        &mut self,
278        x0: f32,
279        y0: f32,
280        x1: f32,
281        y1: f32,
282        width: f32,
283        color: Color,
284    ) {
285        if width <= 1.0 {
286            self.draw_line_wu(x0, y0, x1, y1, color);
287            return;
288        }
289        let half = width * 0.5;
290        let dx = x1 - x0;
291        let dy = y1 - y0;
292        let len = (dx * dx + dy * dy).sqrt().max(f32::EPSILON);
293        let nx = -dy / len * half;
294        let ny = dx / len * half;
295        let pts = [
296            (x0 + nx, y0 + ny),
297            (x1 + nx, y1 + ny),
298            (x1 - nx, y1 - ny),
299            (x0 - nx, y0 - ny),
300        ];
301        // Clip-aware fill using scanline.
302        let clip = self.clip.current();
303        crate::scanline::fill_polygon_clipped(
304            self.fb,
305            &pts,
306            color,
307            crate::scanline::FillRule::NonZero,
308            true,
309            clip,
310        );
311    }
312
313    /// Draw a dashed line from `(x0, y0)` to `(x1, y1)` using `pattern`.
314    ///
315    /// Dashes and gaps alternate until the endpoint is reached.
316    pub fn draw_line_dashed(
317        &mut self,
318        x0: f32,
319        y0: f32,
320        x1: f32,
321        y1: f32,
322        color: Color,
323        pattern: DashPattern,
324    ) {
325        let dx = x1 - x0;
326        let dy = y1 - y0;
327        let total_len = (dx * dx + dy * dy).sqrt();
328        if total_len < f32::EPSILON {
329            return;
330        }
331        let ux = dx / total_len;
332        let uy = dy / total_len;
333        let mut t = 0.0f32;
334        let mut drawing = true;
335        while t < total_len {
336            let seg_len = if drawing {
337                pattern.dash_len
338            } else {
339                pattern.gap_len
340            };
341            let end_t = (t + seg_len).min(total_len);
342            if drawing && end_t > t {
343                let sx = x0 + ux * t;
344                let sy = y0 + uy * t;
345                let ex = x0 + ux * end_t;
346                let ey = y0 + uy * end_t;
347                wu_line(self.fb, &self.clip, sx, sy, ex, ey, &color);
348            }
349            t += seg_len;
350            if t >= total_len {
351                break;
352            }
353            drawing = !drawing;
354        }
355    }
356
357    // -----------------------------------------------------------------------
358    // Ellipse
359    // -----------------------------------------------------------------------
360
361    /// Fill an axis-aligned anti-aliased ellipse centred at `(cx, cy)` with
362    /// semi-axes `rx` (horizontal) and `ry` (vertical).
363    pub fn fill_ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, color: Color) {
364        if rx <= 0.0 || ry <= 0.0 {
365            return;
366        }
367        let x0 = (cx - rx - 1.0).floor() as i64;
368        let y0 = (cy - ry - 1.0).floor() as i64;
369        let x1 = (cx + rx + 1.0).ceil() as i64;
370        let y1 = (cy + ry + 1.0).ceil() as i64;
371        let clip = self.clip.current();
372        for py in y0..y1 {
373            for px in x0..x1 {
374                if !clip.contains(px, py) {
375                    continue;
376                }
377                let sx = px as f32 + 0.5;
378                let sy = py as f32 + 0.5;
379                let ex = (sx - cx) / rx;
380                let ey = (sy - cy) / ry;
381                let d = (ex * ex + ey * ey).sqrt();
382                // Signed-distance approximation: distance to ellipse boundary ≈
383                // (d - 1.0) * effective_radius, where effective_radius is the
384                // minor/major axis harmonic mean in that direction.
385                let eff_r = 1.0 / (ex.abs() / rx + ey.abs() / ry + f32::EPSILON);
386                let coverage = (1.0 - d + 0.5 / eff_r.max(1.0)).clamp(0.0, 1.0);
387                if coverage > 0.0 && px >= 0 && py >= 0 {
388                    self.fb
389                        .blend_coverage(px as u32, py as u32, &color, coverage);
390                }
391            }
392        }
393    }
394
395    // -----------------------------------------------------------------------
396    // Per-corner rounded rect
397    // -----------------------------------------------------------------------
398
399    /// Fill a rounded rectangle where each corner may have a different radius.
400    ///
401    /// `radii` = `[top_left, top_right, bottom_right, bottom_left]`.
402    pub fn fill_rounded_rect_per_corner(
403        &mut self,
404        x: f32,
405        y: f32,
406        w: f32,
407        h: f32,
408        radii: [f32; 4],
409        color: Color,
410    ) {
411        if w <= 0.0 || h <= 0.0 {
412            return;
413        }
414        // Clamp radii so they don't exceed half the corresponding dimension.
415        let half_w = w * 0.5;
416        let half_h = h * 0.5;
417        let r_tl = radii[0].clamp(0.0, half_w.min(half_h));
418        let r_tr = radii[1].clamp(0.0, half_w.min(half_h));
419        let r_br = radii[2].clamp(0.0, half_w.min(half_h));
420        let r_bl = radii[3].clamp(0.0, half_w.min(half_h));
421
422        let x0 = x.floor() as i64;
423        let y0 = y.floor() as i64;
424        let x1 = (x + w).ceil() as i64;
425        let y1 = (y + h).ceil() as i64;
426        let clip = self.clip.current();
427
428        // Corner circle centres.
429        let c_tl = (x + r_tl, y + r_tl);
430        let c_tr = (x + w - r_tr, y + r_tr);
431        let c_br = (x + w - r_br, y + h - r_br);
432        let c_bl = (x + r_bl, y + h - r_bl);
433
434        let corner_params = CornerCoverageParams {
435            left: x,
436            top: y,
437            right: x + w,
438            bottom: y + h,
439            c_tl,
440            c_tr,
441            c_br,
442            c_bl,
443            r_tl,
444            r_tr,
445            r_br,
446            r_bl,
447        };
448        for py in y0..y1 {
449            for px in x0..x1 {
450                if !clip.contains(px, py) {
451                    continue;
452                }
453                let sx = px as f32 + 0.5;
454                let sy = py as f32 + 0.5;
455                let coverage = per_corner_coverage(sx, sy, &corner_params);
456                if coverage > 0.0 && px >= 0 && py >= 0 {
457                    self.fb
458                        .blend_coverage(px as u32, py as u32, &color, coverage);
459                }
460            }
461        }
462    }
463
464    // -----------------------------------------------------------------------
465    // Bilinear image scaling
466    // -----------------------------------------------------------------------
467
468    /// Blit an RGBA source image at destination position `(dx, dy)` with
469    /// source-over blending and nearest-neighbour scaling to `dst_w × dst_h`.
470    pub fn blit_rgba(&mut self, src: SrcImage<'_>, dx: f32, dy: f32, dst_w: u32, dst_h: u32) {
471        if !src.is_valid() || dst_w == 0 || dst_h == 0 {
472            return;
473        }
474        let src_w = src.width;
475        let src_h = src.height;
476        let ox = dx.round() as i64;
477        let oy = dy.round() as i64;
478        for j in 0..dst_h {
479            for i in 0..dst_w {
480                // Map destination pixel back to a source sample.
481                let su = (i * src_w) / dst_w;
482                let sv = (j * src_h) / dst_h;
483                let si = ((sv * src_w + su) * 4) as usize;
484                let r = src.data[si];
485                let g = src.data[si + 1];
486                let b = src.data[si + 2];
487                let a = src.data[si + 3];
488                if a == 0 {
489                    continue;
490                }
491                let px = ox + i as i64;
492                let py = oy + j as i64;
493                if px < 0 || py < 0 || !self.clip.current().contains(px, py) {
494                    continue;
495                }
496                self.fb.blend(
497                    px as u32,
498                    py as u32,
499                    crate::framebuffer::pack_rgba(r, g, b, a),
500                );
501            }
502        }
503    }
504    /// Blit an RGBA source image at destination position `(dx, dy)` with
505    /// source-over blending and bilinear interpolation scaled to `dst_w × dst_h`.
506    pub fn blit_bilinear(&mut self, src: SrcImage<'_>, dx: f32, dy: f32, dst_w: u32, dst_h: u32) {
507        if !src.is_valid() || dst_w == 0 || dst_h == 0 {
508            return;
509        }
510        let src_w = src.width;
511        let src_h = src.height;
512        let ox = dx.round() as i64;
513        let oy = dy.round() as i64;
514        let clip = self.clip.current();
515
516        for j in 0..dst_h {
517            for i in 0..dst_w {
518                let px = ox + i as i64;
519                let py = oy + j as i64;
520                if !clip.contains(px, py) || px < 0 || py < 0 {
521                    continue;
522                }
523                // Bilinear sample position in source space.
524                let u = (i as f32 + 0.5) * src_w as f32 / dst_w as f32 - 0.5;
525                let v = (j as f32 + 0.5) * src_h as f32 / dst_h as f32 - 0.5;
526                let x0 = u.floor() as i32;
527                let y0 = v.floor() as i32;
528                let fx = u - x0 as f32;
529                let fy = v - y0 as f32;
530
531                let sample = |sx: i32, sy: i32| -> [f32; 4] {
532                    let sx = sx.clamp(0, src_w as i32 - 1) as u32;
533                    let sy = sy.clamp(0, src_h as i32 - 1) as u32;
534                    let idx = ((sy * src_w + sx) * 4) as usize;
535                    [
536                        src.data[idx] as f32,
537                        src.data[idx + 1] as f32,
538                        src.data[idx + 2] as f32,
539                        src.data[idx + 3] as f32,
540                    ]
541                };
542
543                let s00 = sample(x0, y0);
544                let s10 = sample(x0 + 1, y0);
545                let s01 = sample(x0, y0 + 1);
546                let s11 = sample(x0 + 1, y0 + 1);
547
548                let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
549                let r = lerp(lerp(s00[0], s10[0], fx), lerp(s01[0], s11[0], fx), fy);
550                let g = lerp(lerp(s00[1], s10[1], fx), lerp(s01[1], s11[1], fx), fy);
551                let b = lerp(lerp(s00[2], s10[2], fx), lerp(s01[2], s11[2], fx), fy);
552                let a = lerp(lerp(s00[3], s10[3], fx), lerp(s01[3], s11[3], fx), fy);
553
554                if a <= 0.0 {
555                    continue;
556                }
557                self.fb.blend(
558                    px as u32,
559                    py as u32,
560                    crate::framebuffer::pack_rgba(
561                        r.round().clamp(0.0, 255.0) as u8,
562                        g.round().clamp(0.0, 255.0) as u8,
563                        b.round().clamp(0.0, 255.0) as u8,
564                        a.round().clamp(0.0, 255.0) as u8,
565                    ),
566                );
567            }
568        }
569    }
570
571    /// Nine-slice blit: corners are copied unscaled; edges stretch in one axis;
572    /// centre stretches in both axes.
573    ///
574    /// `insets` = `[top, right, bottom, left]` in source pixels.
575    pub fn blit_nine_slice(
576        &mut self,
577        src: SrcImage<'_>,
578        dx: f32,
579        dy: f32,
580        dst_w: u32,
581        dst_h: u32,
582        insets: [u32; 4],
583    ) {
584        if !src.is_valid() || dst_w == 0 || dst_h == 0 {
585            return;
586        }
587        let [ins_t, ins_r, ins_b, ins_l] = insets;
588        let sw = src.width;
589        let sh = src.height;
590
591        // Ensure insets don't exceed source dimensions.
592        let ins_l = ins_l.min(sw / 2);
593        let ins_r = ins_r.min(sw / 2);
594        let ins_t = ins_t.min(sh / 2);
595        let ins_b = ins_b.min(sh / 2);
596
597        // Destination insets (same as source if dst is large enough).
598        let dst_l = ins_l.min(dst_w / 2);
599        let dst_r = ins_r.min(dst_w / 2);
600        let dst_t = ins_t.min(dst_h / 2);
601        let dst_b = ins_b.min(dst_h / 2);
602
603        let ox = dx.round() as i64;
604        let oy = dy.round() as i64;
605
606        // Source slice boundaries.
607        let sx_segs = [0, ins_l, sw - ins_r, sw];
608        let sy_segs = [0, ins_t, sh - ins_b, sh];
609
610        // Destination slice boundaries.
611        let dx_segs = [0u32, dst_l, dst_w - dst_r, dst_w];
612        let dy_segs = [0u32, dst_t, dst_h - dst_b, dst_h];
613
614        for row in 0..3usize {
615            for col in 0..3usize {
616                let src_x0 = sx_segs[col];
617                let src_x1 = sx_segs[col + 1];
618                let src_y0 = sy_segs[row];
619                let src_y1 = sy_segs[row + 1];
620                let src_patch_w = src_x1.saturating_sub(src_x0);
621                let src_patch_h = src_y1.saturating_sub(src_y0);
622
623                let dst_x0 = dx_segs[col];
624                let dst_x1 = dx_segs[col + 1];
625                let dst_y0 = dy_segs[row];
626                let dst_y1 = dy_segs[row + 1];
627                let dst_patch_w = dst_x1.saturating_sub(dst_x0);
628                let dst_patch_h = dst_y1.saturating_sub(dst_y0);
629
630                if src_patch_w == 0 || src_patch_h == 0 || dst_patch_w == 0 || dst_patch_h == 0 {
631                    continue;
632                }
633
634                // Build a sub-image for this patch and blit it bilinearly.
635                let mut patch = Vec::with_capacity((src_patch_w * src_patch_h * 4) as usize);
636                for sy in src_y0..src_y1 {
637                    for sxi in src_x0..src_x1 {
638                        let idx = ((sy * sw + sxi) * 4) as usize;
639                        patch.extend_from_slice(&src.data[idx..idx + 4]);
640                    }
641                }
642                let patch_img = SrcImage::new(&patch, src_patch_w, src_patch_h);
643                let dst_abs_x = ox + dst_x0 as i64;
644                let dst_abs_y = oy + dst_y0 as i64;
645                self.blit_bilinear(
646                    patch_img,
647                    dst_abs_x as f32,
648                    dst_abs_y as f32,
649                    dst_patch_w,
650                    dst_patch_h,
651                );
652            }
653        }
654    }
655
656    // -----------------------------------------------------------------------
657    // DrawList command dispatch helpers (clip-aware)
658    // -----------------------------------------------------------------------
659
660    /// Fill a [`oxiui_core::paint::PathData`] using the current clip. Converts verbs to a soft [`crate::path::Path`].
661    pub fn fill_path(&mut self, path: &oxiui_core::paint::PathData, color: Color) {
662        use crate::path::Path;
663        use crate::scanline::FillRule as ScanFillRule;
664        let fill_rule = match path.fill_rule {
665            oxiui_core::paint::FillRule::EvenOdd => ScanFillRule::EvenOdd,
666            oxiui_core::paint::FillRule::NonZero => ScanFillRule::NonZero,
667        };
668        let mut p = Path::new().with_fill_rule(fill_rule);
669        replay_path_verbs(&mut p, path);
670        p.fill_clipped_aa(self.fb, color, self.clip.current(), self.aa);
671    }
672
673    /// Stroke a [`oxiui_core::paint::PathData`] using the current clip and stroke style.
674    pub fn stroke_path(
675        &mut self,
676        path: &oxiui_core::paint::PathData,
677        style: &oxiui_core::paint::StrokeStyle,
678        color: Color,
679    ) {
680        use crate::path::{Cap, Join, Path, StrokeStyle as PathStroke};
681        let mut p = Path::new();
682        replay_path_verbs(&mut p, path);
683        let ss = PathStroke {
684            width: style.width,
685            join: match style.join {
686                oxiui_core::paint::LineJoin::Miter => Join::Miter,
687                oxiui_core::paint::LineJoin::Bevel => Join::Bevel,
688                oxiui_core::paint::LineJoin::Round => Join::Round,
689            },
690            cap: match style.cap {
691                oxiui_core::paint::LineCap::Butt => Cap::Butt,
692                oxiui_core::paint::LineCap::Round => Cap::Round,
693                oxiui_core::paint::LineCap::Square => Cap::Square,
694            },
695            miter_limit: style.miter_limit,
696        };
697        p.stroke_clipped_aa(self.fb, &ss, color, self.clip.current(), self.aa);
698    }
699
700    /// Fill a rectangle with a linear gradient, respecting the current clip.
701    pub fn fill_linear_gradient_cmd(
702        &mut self,
703        rect: oxiui_core::geometry::Rect,
704        start: oxiui_core::geometry::Point,
705        end: oxiui_core::geometry::Point,
706        stops: &[oxiui_core::paint::GradientStop],
707    ) {
708        use crate::gradient::{GradientStop as SoftStop, LinearGradient};
709        let soft_stops: Vec<SoftStop> = stops
710            .iter()
711            .map(|s| SoftStop {
712                offset: s.offset,
713                color: s.color,
714            })
715            .collect();
716        let g = LinearGradient::new((start.x, start.y), (end.x, end.y), soft_stops);
717        let clip = self.clip.current();
718        g.fill_rect(
719            self.fb,
720            &clip,
721            rect.left(),
722            rect.top(),
723            rect.width(),
724            rect.height(),
725        );
726    }
727
728    /// Fill a rectangle with a radial gradient, respecting the current clip.
729    pub fn fill_radial_gradient_cmd(
730        &mut self,
731        rect: oxiui_core::geometry::Rect,
732        center: oxiui_core::geometry::Point,
733        radius: f32,
734        stops: &[oxiui_core::paint::GradientStop],
735    ) {
736        use crate::gradient::{GradientStop as SoftStop, RadialGradient};
737        let soft_stops: Vec<SoftStop> = stops
738            .iter()
739            .map(|s| SoftStop {
740                offset: s.offset,
741                color: s.color,
742            })
743            .collect();
744        let g = RadialGradient::new((center.x, center.y), radius, soft_stops);
745        let clip = self.clip.current();
746        g.fill_rect(
747            self.fb,
748            &clip,
749            rect.left(),
750            rect.top(),
751            rect.width(),
752            rect.height(),
753        );
754    }
755
756    /// Draw a box shadow. The shadow composites into the full framebuffer
757    /// (no clip applied — consistent with the existing `box_shadow` function).
758    pub fn box_shadow_cmd(
759        &mut self,
760        rect: oxiui_core::geometry::Rect,
761        offset: oxiui_core::geometry::Point,
762        blur_radius: f32,
763        color: Color,
764        cache: &mut crate::shadow::GaussianCache,
765    ) {
766        crate::shadow::box_shadow(
767            self.fb,
768            (rect.left(), rect.top(), rect.width(), rect.height()),
769            offset.x,
770            offset.y,
771            blur_radius,
772            color,
773            cache,
774        );
775    }
776
777    // -----------------------------------------------------------------------
778    // Bézier convenience methods (delegate to Path)
779    // -----------------------------------------------------------------------
780
781    /// Draw a quadratic Bézier curve from `p0` via `ctrl` to `p2`.
782    pub fn draw_quad_bezier(
783        &mut self,
784        p0: (f32, f32),
785        ctrl: (f32, f32),
786        p2: (f32, f32),
787        color: Color,
788    ) {
789        let pts = crate::path::flatten_quad_bezier(p0, ctrl, p2, 0.25);
790        for i in 1..pts.len() {
791            wu_line(
792                self.fb,
793                &self.clip,
794                pts[i - 1].0,
795                pts[i - 1].1,
796                pts[i].0,
797                pts[i].1,
798                &color,
799            );
800        }
801    }
802
803    /// Draw a cubic Bézier curve from `p0` via `c1`, `c2` to `p3`.
804    pub fn draw_cubic_bezier(
805        &mut self,
806        p0: (f32, f32),
807        c1: (f32, f32),
808        c2: (f32, f32),
809        p3: (f32, f32),
810        color: Color,
811    ) {
812        let pts = crate::path::flatten_cubic_bezier(p0, c1, c2, p3, 0.25);
813        for i in 1..pts.len() {
814            wu_line(
815                self.fb,
816                &self.clip,
817                pts[i - 1].0,
818                pts[i - 1].1,
819                pts[i].0,
820                pts[i].1,
821                &color,
822            );
823        }
824    }
825}
826
827// ---------------------------------------------------------------------------
828// Path replay helper
829// ---------------------------------------------------------------------------
830
831/// Replay [`oxiui_core::paint::PathVerb`] commands into a soft-render [`crate::path::Path`].
832fn replay_path_verbs(p: &mut crate::path::Path, path: &oxiui_core::paint::PathData) {
833    use oxiui_core::paint::PathVerb;
834    for verb in &path.verbs {
835        match verb {
836            PathVerb::MoveTo(pt) => {
837                p.move_to((pt.x, pt.y));
838            }
839            PathVerb::LineTo(pt) => {
840                p.line_to((pt.x, pt.y));
841            }
842            PathVerb::QuadTo { ctrl, end } => {
843                p.quad_to((ctrl.x, ctrl.y), (end.x, end.y));
844            }
845            PathVerb::CubicTo { c1, c2, end } => {
846                p.cubic_to((c1.x, c1.y), (c2.x, c2.y), (end.x, end.y));
847            }
848            PathVerb::Close => {
849                p.close();
850            }
851        }
852    }
853}
854
855// ---------------------------------------------------------------------------
856// Module-level helpers (private)
857// ---------------------------------------------------------------------------
858
859/// Wu's anti-aliased line algorithm (float pixel coverage).
860fn wu_line(
861    fb: &mut crate::framebuffer::Framebuffer,
862    clip: &crate::clip::ClipStack,
863    mut x0: f32,
864    mut y0: f32,
865    mut x1: f32,
866    mut y1: f32,
867    color: &Color,
868) {
869    let steep = (y1 - y0).abs() > (x1 - x0).abs();
870    if steep {
871        core::mem::swap(&mut x0, &mut y0);
872        core::mem::swap(&mut x1, &mut y1);
873    }
874    if x0 > x1 {
875        core::mem::swap(&mut x0, &mut x1);
876        core::mem::swap(&mut y0, &mut y1);
877    }
878    let dx = x1 - x0;
879    let dy = y1 - y0;
880    let gradient = if dx.abs() < f32::EPSILON {
881        1.0
882    } else {
883        dy / dx
884    };
885
886    let put_wu = |fb: &mut crate::framebuffer::Framebuffer,
887                  clip: &crate::clip::ClipStack,
888                  x: i64,
889                  y: i64,
890                  c: &Color,
891                  alpha: f32| {
892        if !clip.current().contains(x, y) || x < 0 || y < 0 {
893            return;
894        }
895        fb.blend_coverage(x as u32, y as u32, c, alpha);
896    };
897
898    // First endpoint.
899    let xend = x0.round();
900    let yend = y0 + gradient * (xend - x0);
901    let xgap = 1.0 - (x0 + 0.5).fract();
902    let xpxl1 = xend as i64;
903    let ypxl1 = yend.floor() as i64;
904    if steep {
905        put_wu(fb, clip, ypxl1, xpxl1, color, (1.0 - yend.fract()) * xgap);
906        put_wu(fb, clip, ypxl1 + 1, xpxl1, color, yend.fract() * xgap);
907    } else {
908        put_wu(fb, clip, xpxl1, ypxl1, color, (1.0 - yend.fract()) * xgap);
909        put_wu(fb, clip, xpxl1, ypxl1 + 1, color, yend.fract() * xgap);
910    }
911    let mut intery = yend + gradient;
912
913    // Second endpoint.
914    let xend = x1.round();
915    let yend = y1 + gradient * (xend - x1);
916    let xgap = (x1 + 0.5).fract();
917    let xpxl2 = xend as i64;
918    let ypxl2 = yend.floor() as i64;
919    if steep {
920        put_wu(fb, clip, ypxl2, xpxl2, color, (1.0 - yend.fract()) * xgap);
921        put_wu(fb, clip, ypxl2 + 1, xpxl2, color, yend.fract() * xgap);
922    } else {
923        put_wu(fb, clip, xpxl2, ypxl2, color, (1.0 - yend.fract()) * xgap);
924        put_wu(fb, clip, xpxl2, ypxl2 + 1, color, yend.fract() * xgap);
925    }
926
927    // Interior.
928    for x in (xpxl1 + 1)..xpxl2 {
929        let iy = intery.floor() as i64;
930        let frac = intery.fract();
931        if steep {
932            put_wu(fb, clip, iy, x, color, 1.0 - frac);
933            put_wu(fb, clip, iy + 1, x, color, frac);
934        } else {
935            put_wu(fb, clip, x, iy, color, 1.0 - frac);
936            put_wu(fb, clip, x, iy + 1, color, frac);
937        }
938        intery += gradient;
939    }
940}
941
942/// Parameters for per-corner rounded-rect coverage computation.
943struct CornerCoverageParams {
944    left: f32,
945    top: f32,
946    right: f32,
947    bottom: f32,
948    c_tl: (f32, f32),
949    c_tr: (f32, f32),
950    c_br: (f32, f32),
951    c_bl: (f32, f32),
952    r_tl: f32,
953    r_tr: f32,
954    r_br: f32,
955    r_bl: f32,
956}
957
958/// Compute pixel coverage for per-corner rounded-rect at sample position `(sx, sy)`.
959fn per_corner_coverage(sx: f32, sy: f32, p: &CornerCoverageParams) -> f32 {
960    let CornerCoverageParams {
961        left,
962        top,
963        right,
964        bottom,
965        c_tl,
966        c_tr,
967        c_br,
968        c_bl,
969        r_tl,
970        r_tr,
971        r_br,
972        r_bl,
973    } = p;
974    // Determine which quadrant of the rect the sample is in.
975    let in_left_half = sx < (left + right) * 0.5;
976    let in_top_half = sy < (top + bottom) * 0.5;
977
978    // Check corner regions.
979    if in_left_half && in_top_half && *r_tl > 0.0 && sx < c_tl.0 && sy < c_tl.1 {
980        // Top-left corner.
981        let dx = c_tl.0 - sx;
982        let dy = c_tl.1 - sy;
983        let dist = (dx * dx + dy * dy).sqrt();
984        return (r_tl - dist + 0.5).clamp(0.0, 1.0);
985    }
986    if !in_left_half && in_top_half && *r_tr > 0.0 && sx > c_tr.0 && sy < c_tr.1 {
987        // Top-right corner.
988        let dx = sx - c_tr.0;
989        let dy = c_tr.1 - sy;
990        let dist = (dx * dx + dy * dy).sqrt();
991        return (r_tr - dist + 0.5).clamp(0.0, 1.0);
992    }
993    if !in_left_half && !in_top_half && *r_br > 0.0 && sx > c_br.0 && sy > c_br.1 {
994        // Bottom-right corner.
995        let dx = sx - c_br.0;
996        let dy = sy - c_br.1;
997        let dist = (dx * dx + dy * dy).sqrt();
998        return (r_br - dist + 0.5).clamp(0.0, 1.0);
999    }
1000    if in_left_half && !in_top_half && *r_bl > 0.0 && sx < c_bl.0 && sy > c_bl.1 {
1001        // Bottom-left corner.
1002        let dx = c_bl.0 - sx;
1003        let dy = sy - c_bl.1;
1004        let dist = (dx * dx + dy * dy).sqrt();
1005        return (r_bl - dist + 0.5).clamp(0.0, 1.0);
1006    }
1007
1008    // Interior or edge — inside if within the bounding rect.
1009    if sx >= *left && sx <= *right && sy >= *top && sy <= *bottom {
1010        1.0
1011    } else {
1012        0.0
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use crate::framebuffer::Framebuffer;
1020
1021    fn fresh(w: u32, h: u32) -> Framebuffer {
1022        Framebuffer::with_fill(w, h, Color(0, 0, 0, 255))
1023    }
1024
1025    #[test]
1026    fn fill_rect_paints_inside_only() {
1027        let mut fb = fresh(10, 10);
1028        {
1029            let mut c = Canvas::new(&mut fb);
1030            c.fill_rect(2.0, 2.0, 4.0, 4.0, Color(255, 0, 0, 255));
1031        }
1032        assert_eq!(fb.get_rgba(3, 3), Some((255, 0, 0, 255)));
1033        assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
1034        assert_eq!(fb.get_rgba(6, 6), Some((0, 0, 0, 255)));
1035    }
1036
1037    #[test]
1038    fn clip_prevents_drawing() {
1039        let mut fb = fresh(10, 10);
1040        {
1041            let mut c = Canvas::new(&mut fb);
1042            c.push_clip(0.0, 0.0, 3.0, 3.0);
1043            // Try to paint the whole buffer; only the 3x3 clip region changes.
1044            c.fill_rect(0.0, 0.0, 10.0, 10.0, Color(0, 255, 0, 255));
1045            c.pop_clip();
1046        }
1047        assert_eq!(fb.get_rgba(1, 1), Some((0, 255, 0, 255)));
1048        assert_eq!(fb.get_rgba(5, 5), Some((0, 0, 0, 255)));
1049    }
1050
1051    #[test]
1052    fn draw_line_horizontal_and_diagonal() {
1053        let mut fb = fresh(10, 10);
1054        {
1055            let mut c = Canvas::new(&mut fb);
1056            c.draw_line(0.0, 0.0, 9.0, 0.0, Color(255, 255, 255, 255));
1057            c.draw_line(0.0, 0.0, 9.0, 9.0, Color(255, 0, 0, 255));
1058        }
1059        assert_eq!(fb.get_rgba(5, 0), Some((255, 255, 255, 255)));
1060        assert_eq!(fb.get_rgba(5, 5), Some((255, 0, 0, 255)));
1061        assert_eq!(fb.get_rgba(9, 9), Some((255, 0, 0, 255)));
1062    }
1063
1064    #[test]
1065    fn circle_center_filled_edge_antialiased() {
1066        let mut fb = fresh(20, 20);
1067        {
1068            let mut c = Canvas::new(&mut fb);
1069            c.fill_circle(10.0, 10.0, 6.0, Color(255, 255, 255, 255));
1070        }
1071        // Centre is fully covered (white).
1072        assert_eq!(fb.get_rgba(10, 10), Some((255, 255, 255, 255)));
1073        // Far corner untouched (black).
1074        assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
1075        // An edge pixel should be a partial blend (grey, not pure black/white).
1076        let (r, _, _, _) = fb.get_rgba(10, 4).expect("edge pixel near top of circle");
1077        assert!(r > 0, "edge pixel should have received some coverage");
1078    }
1079
1080    #[test]
1081    fn rounded_rect_corner_softer_than_center() {
1082        let mut fb = fresh(40, 40);
1083        {
1084            let mut c = Canvas::new(&mut fb);
1085            c.fill_rounded_rect(5.0, 5.0, 30.0, 30.0, 8.0, Color(255, 255, 255, 255));
1086        }
1087        // Centre filled.
1088        assert_eq!(fb.get_rgba(20, 20), Some((255, 255, 255, 255)));
1089        // The extreme corner of the bounding box is outside the rounded corner.
1090        assert_eq!(fb.get_rgba(5, 5), Some((0, 0, 0, 255)));
1091    }
1092
1093    #[test]
1094    fn fill_rect_color_matches() {
1095        let mut fb = fresh(10, 10);
1096        {
1097            let mut c = Canvas::new(&mut fb);
1098            c.fill_rect(0.0, 0.0, 10.0, 10.0, Color(200, 100, 50, 255));
1099        }
1100        // Sample the centre pixel.
1101        let (r, g, b, a) = fb.get_rgba(5, 5).expect("center pixel");
1102        assert_eq!((r, g, b, a), (200, 100, 50, 255));
1103    }
1104
1105    #[test]
1106    fn rounded_rect_corners_aa() {
1107        let mut fb = fresh(30, 30);
1108        {
1109            let mut c = Canvas::new(&mut fb);
1110            c.fill_rounded_rect(2.0, 2.0, 26.0, 26.0, 8.0, Color(255, 255, 255, 255));
1111        }
1112        // Corner pixel at (2, 2) should be partially blended (alpha < 255 from background black).
1113        let (r, _, _, _) = fb.get_rgba(2, 2).expect("corner pixel");
1114        // The corner (2, 2) is at the extreme top-left; it should have less than full coverage
1115        // since the circle centre is at (10, 10) and r=8.
1116        assert!(r < 255, "corner should be anti-aliased (r={r})");
1117    }
1118
1119    #[test]
1120    fn wu_line_edge_alpha() {
1121        // A 45-degree line should produce pixels with intermediate alpha.
1122        let mut fb = fresh(20, 20);
1123        {
1124            let mut c = Canvas::new(&mut fb);
1125            c.draw_line_wu(0.0, 0.3, 10.0, 10.3, Color(255, 255, 255, 255));
1126        }
1127        // Check that at least one pixel has non-full, non-zero alpha.
1128        let mut found_partial = false;
1129        for y in 0..20 {
1130            for x in 0..20 {
1131                let (r, _, _, _) = fb.get_rgba(x, y).unwrap_or((0, 0, 0, 0));
1132                if r > 0 && r < 255 {
1133                    found_partial = true;
1134                }
1135            }
1136        }
1137        assert!(
1138            found_partial,
1139            "Wu line should produce partially-alpha pixels"
1140        );
1141    }
1142
1143    #[test]
1144    fn thick_line_covers_area() {
1145        let mut fb = fresh(30, 30);
1146        {
1147            let mut c = Canvas::new(&mut fb);
1148            // Horizontal line at y=15, width=8 → half=4, covers y∈[11,19).
1149            c.draw_line_thick(0.0, 15.0, 29.0, 15.0, 8.0, Color(255, 0, 0, 255));
1150        }
1151        // Pixels well within the band should be red.
1152        let (r12, _, _, _) = fb.get_rgba(14, 12).unwrap_or((0, 0, 0, 0));
1153        let (r18, _, _, _) = fb.get_rgba(14, 18).unwrap_or((0, 0, 0, 0));
1154        assert!(
1155            r12 > 0,
1156            "pixel at y=12 should be inside thick line (r={r12})"
1157        );
1158        assert!(
1159            r18 > 0,
1160            "pixel at y=18 should be inside thick line (r={r18})"
1161        );
1162    }
1163
1164    #[test]
1165    fn dashed_line_has_gaps() {
1166        let mut fb = fresh(40, 5);
1167        {
1168            let mut c = Canvas::new(&mut fb);
1169            c.draw_line_dashed(
1170                0.0,
1171                2.0,
1172                39.0,
1173                2.0,
1174                Color(255, 255, 255, 255),
1175                DashPattern::new(5.0, 5.0),
1176            );
1177        }
1178        // Count painted and unpainted pixels.
1179        let mut painted = 0u32;
1180        let mut unpainted = 0u32;
1181        for x in 0..40 {
1182            let (r, _, _, _) = fb.get_rgba(x, 2).unwrap_or((0, 0, 0, 0));
1183            if r > 0 {
1184                painted += 1;
1185            } else {
1186                unpainted += 1;
1187            }
1188        }
1189        assert!(painted > 0, "should have some painted pixels");
1190        assert!(unpainted > 0, "should have some gap pixels");
1191    }
1192
1193    #[test]
1194    fn ellipse_fill() {
1195        let mut fb = fresh(30, 30);
1196        {
1197            let mut c = Canvas::new(&mut fb);
1198            c.fill_ellipse(15.0, 15.0, 10.0, 5.0, Color(0, 255, 0, 255));
1199        }
1200        // Centre should be painted.
1201        let (_, g, _, _) = fb.get_rgba(15, 15).expect("centre");
1202        assert!(g > 0, "ellipse centre should be green");
1203        // Far corner should not.
1204        let (_, g2, _, _) = fb.get_rgba(0, 0).expect("corner");
1205        assert_eq!(g2, 0, "corner should not be painted");
1206    }
1207
1208    #[test]
1209    fn per_corner_rounded_rect() {
1210        let mut fb = fresh(30, 30);
1211        {
1212            let mut c = Canvas::new(&mut fb);
1213            // Large corner radii — corners should be anti-aliased.
1214            c.fill_rounded_rect_per_corner(
1215                2.0,
1216                2.0,
1217                26.0,
1218                26.0,
1219                [10.0, 10.0, 10.0, 10.0],
1220                Color(255, 255, 255, 255),
1221            );
1222        }
1223        // Centre should be fully painted.
1224        let (r, _, _, _) = fb.get_rgba(15, 15).expect("centre");
1225        assert_eq!(r, 255, "centre should be fully painted");
1226    }
1227
1228    #[test]
1229    fn bilinear_2x2_to_4x4() {
1230        // A 2×2 source with distinct quadrant colours scaled to 4×4.
1231        // Top-left: red, top-right: green, bottom-left: blue, bottom-right: white.
1232        let src_data = [
1233            255u8, 0, 0, 255, 0, 255, 0, 255, // row 0: red, green
1234            0, 0, 255, 255, 255, 255, 255, 255, // row 1: blue, white
1235        ];
1236        let mut fb = fresh(4, 4);
1237        {
1238            let mut c = Canvas::new(&mut fb);
1239            c.blit_bilinear(SrcImage::new(&src_data, 2, 2), 0.0, 0.0, 4, 4);
1240        }
1241        // Top-left corner should be reddish.
1242        let (r, g, b, _) = fb.get_rgba(0, 0).expect("(0,0)");
1243        assert!(
1244            r > g && r > b,
1245            "top-left should be reddish (r={r}, g={g}, b={b})"
1246        );
1247        // Top-right corner should be greenish.
1248        let (r2, g2, b2, _) = fb.get_rgba(3, 0).expect("(3,0)");
1249        assert!(
1250            g2 > r2 && g2 > b2,
1251            "top-right should be greenish (r={r2}, g={g2}, b={b2})"
1252        );
1253    }
1254
1255    #[test]
1256    fn draw_quad_bezier_produces_pixels() {
1257        let mut fb = fresh(30, 30);
1258        {
1259            let mut c = Canvas::new(&mut fb);
1260            // Quadratic bezier: p0=(0,15), ctrl=(15,0), p2=(30,15)
1261            c.draw_quad_bezier(
1262                (0.0, 15.0),
1263                (15.0, 0.0),
1264                (29.0, 15.0),
1265                Color(255, 255, 255, 255),
1266            );
1267        }
1268        // The curve should produce some painted pixels.
1269        let mut painted = 0u32;
1270        for y in 0..30 {
1271            for x in 0..30 {
1272                let (r, _, _, _) = fb.get_rgba(x, y).unwrap_or((0, 0, 0, 0));
1273                if r > 0 {
1274                    painted += 1;
1275                }
1276            }
1277        }
1278        assert!(
1279            painted >= 5,
1280            "quad bezier should produce >= 5 painted pixels, got {painted}"
1281        );
1282    }
1283
1284    #[test]
1285    fn draw_cubic_bezier_produces_pixels() {
1286        let mut fb = fresh(40, 40);
1287        {
1288            let mut c = Canvas::new(&mut fb);
1289            // Cubic bezier: s=(0,20), c1=(10,0), c2=(30,40), e=(39,20)
1290            c.draw_cubic_bezier(
1291                (0.0, 20.0),
1292                (10.0, 0.0),
1293                (30.0, 40.0),
1294                (39.0, 20.0),
1295                Color(0, 255, 0, 255),
1296            );
1297        }
1298        let mut painted = 0u32;
1299        for y in 0..40 {
1300            for x in 0..40 {
1301                let (_, g, _, _) = fb.get_rgba(x, y).unwrap_or((0, 0, 0, 0));
1302                if g > 0 {
1303                    painted += 1;
1304                }
1305            }
1306        }
1307        assert!(
1308            painted >= 5,
1309            "cubic bezier should produce >= 5 painted pixels, got {painted}"
1310        );
1311    }
1312
1313    #[test]
1314    fn blit_rgba_nearest_scale() {
1315        let mut fb = fresh(8, 8);
1316        // 2x2 source: red, green / blue, white.
1317        let src = [
1318            255, 0, 0, 255, 0, 255, 0, 255, // row 0
1319            0, 0, 255, 255, 255, 255, 255, 255, // row 1
1320        ];
1321        {
1322            let mut c = Canvas::new(&mut fb);
1323            c.blit_rgba(SrcImage::new(&src, 2, 2), 0.0, 0.0, 4, 4);
1324        }
1325        // Top-left quadrant red, scaled 2x.
1326        assert_eq!(fb.get_rgba(0, 0), Some((255, 0, 0, 255)));
1327        assert_eq!(fb.get_rgba(1, 1), Some((255, 0, 0, 255)));
1328        // Bottom-right quadrant white.
1329        assert_eq!(fb.get_rgba(3, 3), Some((255, 255, 255, 255)));
1330    }
1331}