🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-render/src/software.rs main

668 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
//! Backend rysowania na CPU: rasteryzuje [`Scene`] do bufora RGBA8888.
//!
//! Używa `tiny-skia` do kształtów (z antyaliasingiem, zaokrągleniami i clipem)
//! oraz [`TextEngine`] do tekstu. Bufor wyjściowy ma format zgodny z `wl_shm`
//! (ARGB8888 little-endian to to samo ułożenie bajtów co RGBA8888 — do potwierdzenia
//! przy podłączaniu Waylanda).

use libluna_core::{Color, Point, Rect, Scale, Size};
use tiny_skia::{
    FillRule, LineCap, LineJoin, Mask, Paint, Path, PathBuilder, Pixmap, PixmapPaint, PixmapRef,
    Shader, Stroke, Transform,
};

use crate::scene::{DrawCommand, ImageData, Scene};
use crate::text::TextEngine;

/// Renderer software'owy: trzyma własny bufor pikseli.
pub struct SoftwareRenderer {
    pixmap: Pixmap,
    text: TextEngine,
}

impl SoftwareRenderer {
    /// Tworzy renderer o zadanym rozmiarze **fizycznym** (piksele).
    pub fn new(width: u32, height: u32) -> Self {
        Self {
            pixmap: Pixmap::new(width.max(1), height.max(1)).expect("buffer allocation"),
            text: TextEngine::system(),
        }
    }

    /// Zmienia rozmiar bufora (zawartość jest zerowana).
    pub fn resize(&mut self, width: u32, height: u32) {
        if self.pixmap.width() != width.max(1) || self.pixmap.height() != height.max(1) {
            self.pixmap = Pixmap::new(width.max(1), height.max(1)).expect("buffer reallocation");
        }
    }

    pub fn width(&self) -> u32 {
        self.pixmap.width()
    }

    pub fn height(&self) -> u32 {
        self.pixmap.height()
    }

    /// Surowy bufor RGBA8888 (premnożony) — do skopiowania w `wl_shm`.
    pub fn data(&self) -> &[u8] {
        self.pixmap.data()
    }

    /// Koduje bufor do PNG — przydatne do podglądu offline (bez kompozytora).
    pub fn encode_png(&self) -> Option<Vec<u8>> {
        self.pixmap.encode_png().ok()
    }

    pub fn text_engine(&self) -> &TextEngine {
        &self.text
    }

    /// Mierzy tekst w pikselach logicznych.
    pub fn measure_text(&self, text: &str, size: f64, weight: u16) -> Size {
        self.text.measure(text, size, weight)
    }

    /// Rasteryzuje scenę. `scale` przelicza piksele logiczne sceny na fizyczne.
    pub fn draw(&mut self, scene: &Scene, scale: f64, background: Color) {
        let SoftwareRenderer { pixmap, text } = self;
        let scale = Scale::new(scale);
        let transform = Transform::from_scale(scale.factor() as f32, scale.factor() as f32);

        // Gdy scena zaczyna się od tła pokrywającego cały bufor, wypełnianie kolorem
        // jest zbyteczne — tło i tak nadpisze wszystkie piksele.
        let bg_covers = scene.commands().iter().any(|c| {
            matches!(c, DrawCommand::Background { data }
                if data.width == pixmap.width() && data.height == pixmap.height())
        });
        if !bg_covers {
            pixmap.fill(ts_color(background));
        }

        let mut clip_stack: Vec<(Rect, f64)> = Vec::new();
        let mut mask: Option<Mask> = None;

        for cmd in scene.commands() {
            match cmd {
                DrawCommand::FillRect { rect, color } => {
                    if let Some(path) = rect_path(*rect, 0.0) {
                        fill(pixmap, &path, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::FillRoundedRect {
                    rect,
                    radius,
                    color,
                } => {
                    if let Some(path) = rect_path(*rect, *radius) {
                        fill(pixmap, &path, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::StrokeRoundedRect {
                    rect,
                    radius,
                    width,
                    color,
                } => {
                    if let Some(path) = rect_path(*rect, *radius) {
                        stroke(pixmap, &path, *width, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::StrokeLine {
                    from,
                    to,
                    width,
                    color,
                } => {
                    if color.a > 0.0 && *width > 0.0 {
                        if let Some(path) = line_path(*from, *to) {
                            let stroke = Stroke {
                                width: *width as f32,
                                line_cap: LineCap::Round,
                                line_join: LineJoin::Round,
                                ..Default::default()
                            };
                            pixmap.stroke_path(
                                &path,
                                &paint(*color),
                                &stroke,
                                transform,
                                mask.as_ref(),
                            );
                        }
                    }
                }
                DrawCommand::FillCircle {
                    center,
                    radius,
                    color,
                } => {
                    if let Some(path) = circle_path(*center, *radius) {
                        fill(pixmap, &path, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::StrokeCircle {
                    center,
                    radius,
                    width,
                    color,
                } => {
                    if let Some(path) = circle_path(*center, *radius) {
                        stroke(pixmap, &path, *width, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::StrokePolyline {
                    points,
                    width,
                    color,
                } => {
                    if let Some(path) = polyline_path(points) {
                        stroke(pixmap, &path, *width, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::FillPolygon { points, color } => {
                    if let Some(path) = polygon_path(points) {
                        fill(pixmap, &path, *color, transform, mask.as_ref());
                    }
                }
                DrawCommand::PushClip { rect } => {
                    let (effective, radius) = match clip_stack.last() {
                        Some((current, _)) => {
                            (current.intersection(rect).unwrap_or(Rect::ZERO), 0.0)
                        }
                        None => (*rect, 0.0),
                    };
                    clip_stack.push((effective, radius));
                    mask = build_mask(pixmap, effective, radius, transform);
                }
                DrawCommand::PushClipRounded { rect, radius } => {
                    let (effective, effective_radius) = match clip_stack.last() {
                        Some((current, current_radius)) => (
                            current.intersection(rect).unwrap_or(Rect::ZERO),
                            radius.min(*current_radius),
                        ),
                        None => (*rect, *radius),
                    };
                    clip_stack.push((effective, effective_radius));
                    mask = build_mask(pixmap, effective, effective_radius, transform);
                }
                DrawCommand::PopClip => {
                    clip_stack.pop();
                    mask = clip_stack
                        .last()
                        .copied()
                        .and_then(|(r, rad)| build_mask(pixmap, r, rad, transform));
                }
                DrawCommand::Text(run) => {
                    let (w, h) = (pixmap.width() as usize, pixmap.height() as usize);
                    // Tekst nie korzysta z maski kształtów — obcinamy wprost piksele.
                    let k = scale.factor();
                    let clip = clip_stack.last().map(|(r, _)| crate::text::ClipRect {
                        x0: (r.x() * k).floor() as i64,
                        y0: (r.y() * k).floor() as i64,
                        x1: (r.right() * k).ceil() as i64,
                        y1: (r.bottom() * k).ceil() as i64,
                    });
                    text.draw(
                        pixmap.data_mut(),
                        w,
                        h,
                        scale.to_physical(run.origin.x),
                        scale.to_physical(run.origin.y),
                        &run.text,
                        run.size,
                        run.weight,
                        run.color,
                        scale.factor(),
                        clip,
                    );
                }
                DrawCommand::Image { rect, data } => {
                    if rect.width() <= 0.0 || rect.height() <= 0.0 {
                        continue;
                    }
                    // Bez kopiowania: widok na piksele sceny.
                    let Some(src) = PixmapRef::from_bytes(&data.rgba, data.width, data.height)
                    else {
                        continue;
                    };
                    let k = scale.factor() as f32;
                    let is_1to1 = (scale.factor() - 1.0).abs() < 0.001
                        && (rect.width().round() as u32) == data.width
                        && (rect.height().round() as u32) == data.height;

                    if is_1to1 && mask.is_none() && data.sparse {
                        // Rzadki obraz (panel, popup): większość pikseli ma `alpha == 0`.
                        // Blit z pomijaniem zer jest znacznie tańszy niż blendowanie
                        // całego prostokąta 1920×680 (~7 ms/klatkę).
                        blend_sparse(
                            pixmap,
                            data,
                            (rect.x() * k as f64).round() as i32,
                            (rect.y() * k as f64).round() as i32,
                        );
                        continue;
                    }

                    if is_1to1 && mask.is_none() {
                        // Błyskawiczna ścieżka tiny-skia: bezpośredni blit zoptymalizowany pod SIMD
                        // bez narzutu macierzy transformacji i interpolacji.
                        pixmap.draw_pixmap(
                            (rect.x() * k as f64).round() as i32,
                            (rect.y() * k as f64).round() as i32,
                            src,
                            &PixmapPaint::default(),
                            Transform::identity(),
                            None,
                        );
                    } else {
                        let sx = (rect.width() as f32 / data.width as f32) * k;
                        let sy = (rect.height() as f32 / data.height as f32) * k;
                        let t = Transform::from_scale(sx, sy)
                            .post_translate(rect.x() as f32 * k, rect.y() as f32 * k);
                        pixmap.draw_pixmap(0, 0, src, &PixmapPaint::default(), t, mask.as_ref());
                    }
                }
                DrawCommand::Background { data } => {
                    // Tło pulpitu: pełnoekranowy obraz kopiujemy wprost (jeden `memcpy`)
                    // zamiast mieszać 2 mln pikseli. Gdy rozmiar nie zgadza się z buforem
                    // (np. podgląd), rysujemy ze skalowaniem.
                    if data.width == pixmap.width() && data.height == pixmap.height() {
                        pixmap.data_mut().copy_from_slice(&data.rgba);
                    } else if let Some(src) =
                        PixmapRef::from_bytes(&data.rgba, data.width, data.height)
                    {
                        let sx = pixmap.width() as f32 / data.width as f32;
                        let sy = pixmap.height() as f32 / data.height as f32;
                        pixmap.draw_pixmap(
                            0,
                            0,
                            src,
                            &PixmapPaint::default(),
                            Transform::from_scale(sx, sy),
                            None,
                        );
                    }
                }
            }
        }
    }
}

fn ts_color(color: Color) -> tiny_skia::Color {
    let [r, g, b, a] = color.to_rgba8();
    tiny_skia::Color::from_rgba8(r, g, b, a)
}

fn line_path(from: Point, to: Point) -> Option<Path> {
    let mut pb = PathBuilder::new();
    pb.move_to(from.x as f32, from.y as f32);
    pb.line_to(to.x as f32, to.y as f32);
    pb.finish()
}

fn rect_path(rect: Rect, radius: f64) -> Option<Path> {
    if rect.width() <= 0.0 || rect.height() <= 0.0 {
        return None;
    }
    let x = rect.x() as f32;
    let y = rect.y() as f32;
    let w = rect.width() as f32;
    let h = rect.height() as f32;
    let mut pb = PathBuilder::new();
    let r = radius.min(rect.width() / 2.0).min(rect.height() / 2.0) as f32;
    if r > 0.0 {
        pb.move_to(x + r, y);
        pb.line_to(x + w - r, y);
        pb.quad_to(x + w, y, x + w, y + r);
        pb.line_to(x + w, y + h - r);
        pb.quad_to(x + w, y + h, x + w - r, y + h);
        pb.line_to(x + r, y + h);
        pb.quad_to(x, y + h, x, y + h - r);
        pb.line_to(x, y + r);
        pb.quad_to(x, y, x + r, y);
        pb.close();
    } else {
        pb.push_rect(tiny_skia::Rect::from_xywh(x, y, w, h)?);
    }
    pb.finish()
}

fn circle_path(center: Point, radius: f64) -> Option<Path> {
    if radius <= 0.0 {
        return None;
    }
    PathBuilder::from_circle(center.x as f32, center.y as f32, radius as f32)
}

fn polyline_path(points: &[Point]) -> Option<Path> {
    if points.len() < 2 {
        return None;
    }
    let mut pb = PathBuilder::new();
    pb.move_to(points[0].x as f32, points[0].y as f32);
    for p in &points[1..] {
        pb.line_to(p.x as f32, p.y as f32);
    }
    pb.finish()
}

fn polygon_path(points: &[Point]) -> Option<Path> {
    if points.len() < 3 {
        return None;
    }
    let mut pb = PathBuilder::new();
    pb.move_to(points[0].x as f32, points[0].y as f32);
    for p in &points[1..] {
        pb.line_to(p.x as f32, p.y as f32);
    }
    pb.close();
    pb.finish()
}

fn paint(color: Color) -> Paint<'static> {
    Paint {
        shader: Shader::SolidColor(ts_color(color)),
        anti_alias: true,
        ..Default::default()
    }
}

fn fill(pixmap: &mut Pixmap, path: &Path, color: Color, transform: Transform, mask: Option<&Mask>) {
    if color.a <= 0.0 {
        return;
    }
    pixmap.fill_path(path, &paint(color), FillRule::Winding, transform, mask);
}

fn stroke(
    pixmap: &mut Pixmap,
    path: &Path,
    width: f64,
    color: Color,
    transform: Transform,
    mask: Option<&Mask>,
) {
    if color.a <= 0.0 || width <= 0.0 {
        return;
    }
    let stroke = Stroke {
        width: width as f32,
        line_cap: LineCap::Round,
        line_join: LineJoin::Round,
        ..Default::default()
    };
    pixmap.stroke_path(path, &paint(color), &stroke, transform, mask);
}

fn build_mask(pixmap: &Pixmap, rect: Rect, radius: f64, transform: Transform) -> Option<Mask> {
    let mut mask = Mask::new(pixmap.width(), pixmap.height())?;
    if let Some(path) = rect_path(rect, radius) {
        mask.fill_path(&path, FillRule::Winding, true, transform);
    }
    Some(mask)
}

/// Dzielenie przez 255 zaokrąglone w górę (szybkie, bez FP).
fn div255(x: u32) -> u32 {
    let x = x + 128;
    (x + (x >> 8)) >> 8
}

/// Rysuje obraz 1:1 z pominięciem w pełni przezroczystych pikseli.
///
/// Format: premultiplied RGBA8. Dla `alpha == 255` to zwykłe kopiowanie,
/// dla pozostałych — source-over. Piksele z `alpha == 0` są pomijane, co daje
/// dużą oszczędność na panelu/popupach (przezroczyste tło).
fn blend_sparse(pixmap: &mut Pixmap, src: &ImageData, dx: i32, dy: i32) {
    let dw = pixmap.width() as i32;
    let dh = pixmap.height() as i32;
    let sw = src.width as i32;
    let sh = src.height as i32;
    let stride = dw as usize * 4;
    let dst = pixmap.data_mut();
    let x0 = dx.max(0);
    let x1 = (dx + sw).min(dw);
    let y0 = dy.max(0);
    let y1 = (dy + sh).min(dh);
    if x0 >= x1 || y0 >= y1 {
        return;
    }
    for ty in y0..y1 {
        let sy = (ty - dy) as usize;
        let srow = sy * src.width as usize * 4;
        let drow = ty as usize * stride;
        let mut tx = x0;
        while tx < x1 {
            let sx = (tx - dx) as usize;
            let si = srow + sx * 4;
            let a = src.rgba[si + 3];
            if a == 0 {
                tx += 1;
                continue;
            }
            if a == 255 {
                // Ciąg w pełni nieprzezroczystych pikseli → jeden `memcpy` na wiersz.
                let run_start = tx;
                let mut run_end = tx + 1;
                while run_end < x1 {
                    let s2 = (run_end - dx) as usize;
                    if src.rgba[srow + s2 * 4 + 3] != 255 {
                        break;
                    }
                    run_end += 1;
                }
                let ss = srow + (run_start - dx) as usize * 4;
                let se = srow + (run_end - dx) as usize * 4;
                let ds = drow + run_start as usize * 4;
                let de = drow + run_end as usize * 4;
                dst[ds..de].copy_from_slice(&src.rgba[ss..se]);
                tx = run_end;
                continue;
            }
            let inv = 255 - u32::from(a);
            let s = &src.rgba[si..si + 4];
            let d = &mut dst[drow + tx as usize * 4..][..4];
            for c in 0..3 {
                d[c] = (u32::from(s[c]) + div255(u32::from(d[c]) * inv)).min(255) as u8;
            }
            d[3] = (u32::from(a) + div255(u32::from(d[3]) * inv)).min(255) as u8;
            tx += 1;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use libluna_core::{style::weight, Point};

    #[test]
    fn sparse_blend_skips_transparent_and_blends_alpha() {
        let mut pm = Pixmap::new(2, 2).unwrap();
        pm.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255));
        let mut rgba = vec![0u8; 2 * 2 * 4];
        // (1,1): premultiplied biały 50%.
        rgba[(3) * 4..(3) * 4 + 4].copy_from_slice(&[128, 128, 128, 128]);
        let img = ImageData::new(2, 2, rgba).with_sparse(true);
        blend_sparse(&mut pm, &img, 0, 0);
        let d = pm.data();
        assert_eq!(
            &d[0..4],
            &[255, 0, 0, 255],
            "piksel alfa=0 nie może zmienić tła"
        );
        assert_eq!(
            &d[12..16],
            &[255, 128, 128, 255],
            "source-over premultiplied na czerwonym tle"
        );
    }

    fn pixel(renderer: &SoftwareRenderer, x: u32, y: u32) -> [u8; 4] {
        let w = renderer.width() as usize;
        let idx = (y as usize * w + x as usize) * 4;
        let d = renderer.data();
        [d[idx], d[idx + 1], d[idx + 2], d[idx + 3]]
    }

    #[test]
    fn fills_background_opaque() {
        let mut r = SoftwareRenderer::new(8, 8);
        let scene = Scene::new();
        r.draw(&scene, 1.0, Color::WHITE);
        assert_eq!(pixel(&r, 0, 0), [255, 255, 255, 255]);
        assert_eq!(pixel(&r, 7, 7), [255, 255, 255, 255]);
    }

    #[test]
    fn fill_circle_paints_center_only() {
        let mut r = SoftwareRenderer::new(16, 16);
        let mut s = Scene::new();
        s.fill_circle(Point::new(8.0, 8.0), 4.0, Color::from_rgba8(255, 0, 0, 255));
        r.draw(&s, 1.0, Color::WHITE);
        assert_eq!(pixel(&r, 8, 8), [255, 0, 0, 255]);
        assert_eq!(pixel(&r, 0, 0), [255, 255, 255, 255]);
    }

    #[test]
    fn fill_polygon_covers_interior() {
        let mut r = SoftwareRenderer::new(16, 16);
        let mut s = Scene::new();
        s.fill_polygon(
            &[
                Point::new(2.0, 2.0),
                Point::new(14.0, 2.0),
                Point::new(8.0, 14.0),
            ],
            Color::from_rgba8(0, 255, 0, 255),
        );
        r.draw(&s, 1.0, Color::WHITE);
        assert_eq!(pixel(&r, 8, 6), [0, 255, 0, 255]);
        assert_eq!(pixel(&r, 0, 0), [255, 255, 255, 255]);
    }

    #[test]
    fn stroke_polyline_paints_segment() {
        let mut r = SoftwareRenderer::new(16, 16);
        let mut s = Scene::new();
        s.stroke_polyline(
            &[
                Point::new(2.0, 8.0),
                Point::new(8.0, 8.0),
                Point::new(8.0, 14.0),
            ],
            3.0,
            Color::from_rgba8(0, 0, 255, 255),
        );
        r.draw(&s, 1.0, Color::WHITE);
        assert_eq!(pixel(&r, 5, 8), [0, 0, 255, 255]);
        assert_eq!(pixel(&r, 8, 12), [0, 0, 255, 255]);
        assert_eq!(pixel(&r, 14, 14), [255, 255, 255, 255]);
    }

    #[test]
    fn draws_red_rect_center_but_not_corner() {
        let mut r = SoftwareRenderer::new(60, 60);
        let mut scene = Scene::new();
        scene.fill_rounded_rect(
            Rect::from_xywh(10.0, 10.0, 40.0, 40.0),
            10.0,
            Color::from_rgba8(255, 0, 0, 255),
        );
        r.draw(&scene, 1.0, Color::WHITE);

        // Środek prostokąta jest czerwony.
        let c = pixel(&r, 30, 30);
        assert!(c[0] > 200 && c[1] < 40 && c[2] < 40, "center: {c:?}");
        // Narożnik kwadratu (10,10) wypada poza zaokrąglenie → tło.
        let corner = pixel(&r, 10, 10);
        assert!(
            corner[0] > 200 && corner[1] > 200 && corner[2] > 200,
            "corner: {corner:?}"
        );
    }

    #[test]
    fn rounded_clip_cuts_corners() {
        let mut r = SoftwareRenderer::new(40, 40);
        let mut scene = Scene::new();
        scene
            .push_clip_rounded(Rect::from_xywh(0.0, 0.0, 40.0, 40.0), 12.0)
            .fill_rect(
                Rect::from_xywh(0.0, 0.0, 40.0, 40.0),
                Color::from_rgba8(255, 0, 0, 255),
            )
            .pop_clip();
        r.draw(&scene, 1.0, Color::WHITE);

        // Narożnik poza promieniem pozostaje tłem, środek jest wypełniony.
        assert_eq!(pixel(&r, 1, 1), [255, 255, 255, 255]);
        assert_eq!(pixel(&r, 20, 20), [255, 0, 0, 255]);
    }

    #[test]
    fn clip_limits_drawing() {
        let mut r = SoftwareRenderer::new(40, 40);
        let mut scene = Scene::new();
        scene
            .push_clip(Rect::from_xywh(0.0, 0.0, 10.0, 40.0))
            .fill_rect(
                Rect::from_xywh(0.0, 0.0, 40.0, 40.0),
                Color::from_rgba8(0, 0, 255, 255),
            )
            .pop_clip();
        r.draw(&scene, 1.0, Color::WHITE);

        let inside = pixel(&r, 5, 20);
        assert!(inside[2] > 200 && inside[0] < 40, "w clipie: {inside:?}");
        let outside = pixel(&r, 30, 20);
        assert!(
            outside[0] > 200 && outside[2] > 200,
            "poza clipem: {outside:?}"
        );
    }

    #[test]
    fn scale_2x_halves_effective_area() {
        let mut r = SoftwareRenderer::new(20, 20);
        let mut scene = Scene::new();
        // Prostokąt 5x5 w px logicznych przy scale 2 zajmuje 10x10 px fizycznych.
        scene.fill_rect(
            Rect::from_xywh(0.0, 0.0, 5.0, 5.0),
            Color::from_rgba8(0, 0, 0, 255),
        );
        r.draw(&scene, 2.0, Color::WHITE);
        assert_eq!(pixel(&r, 9, 9)[0], 0);
        assert_eq!(pixel(&r, 10, 10)[0], 255);
    }

    #[test]
    fn text_measurement_never_panics() {
        let r = SoftwareRenderer::new(16, 16);
        let s = r.measure_text("Ustawienia", 14.0, weight::NORMAL);
        assert!(s.width > 0.0);
        // Punkt nieużywany wprost — upewnia się, że typ jest w zasięgu.
        let _ = Point::ZERO;
    }

    #[test]
    fn draws_image_into_rect() {
        use crate::scene::ImageData;
        use std::sync::Arc;
        let mut r = SoftwareRenderer::new(20, 20);
        // Czerwony obraz 2×2 wpisany w prostokąt 10×10 w (5,5).
        let data = ImageData::new(
            2,
            2,
            vec![
                255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
            ],
        );
        let mut scene = Scene::new();
        scene.image(Rect::from_xywh(5.0, 5.0, 10.0, 10.0), Arc::new(data));
        r.draw(&scene, 1.0, Color::WHITE);
        assert_eq!(pixel(&r, 10, 10), [255, 0, 0, 255]);
        assert_eq!(pixel(&r, 1, 1), [255, 255, 255, 255]);
    }
}