Skip to main content

cvkg_render_software/
lib.rs

1//! # CVKG Software Renderer
2//!
3//! CPU fallback renderer for environments without GPU/WebGPU access (CI servers,
4//! headless testing, embedded systems). Implements the `Renderer` trait with
5//! pure-software rasterization into an RGBA pixel buffer.
6//!
7//! ## Capabilities
8//!
9//! - Opaque rectangles (trivial)
10//! - Rounded rectangles (analytical AA via signed distance)
11//! - Ellipses (analytical AA)
12//! - Stroked shapes (rect, rounded rect, ellipse)
13//! - Lines (Bresenham with AA)
14//! - Basic text (via cvkg-runic-text, bitmap glyph blitting)
15//! - Linear gradients (horizontal only)
16//! - Solid glass fallback (tint only, no refraction)
17//!
18//! ## Limitations
19//!
20//! - No glass refraction/software ray-tracing (degrades to solid tint)
21//! - No SVG path rendering
22//! - No texture sampling
23//! - No 3D (all 3D methods are no-ops)
24//! - No MSAA (uses 4x supersampling for rounded shapes/ellipses)
25
26use cvkg_core::{ElapsedTime, Material3D, Mesh, Rect, Renderer, Transform3D};
27use std::time::Instant;
28
29// --- Framebuffer ---
30
31/// RGBA8 pixel buffer with depth buffer for basic overlap testing.
32#[derive(Debug, Clone)]
33pub struct Framebuffer {
34    width: u32,
35    height: u32,
36    pixels: Vec<u32>,   // RGBA8 packed (R in lowest byte on little-endian)
37    depth: Vec<f32>,    // depth buffer (unused by 2D API, reserved for 3D)
38}
39
40impl Framebuffer {
41    /// Creates a new framebuffer filled with transparent black.
42    pub fn new(width: u32, height: u32) -> Self {
43        let size = (width * height) as usize;
44        Self {
45            width,
46            height,
47            pixels: vec![0; size],
48            depth: vec![0.0; size],
49        }
50    }
51
52    /// Creates a new framebuffer filled with a solid color.
53    pub fn with_color(width: u32, height: u32, color: [f32; 4]) -> Self {
54        let mut fb = Self::new(width, height);
55        let packed = pack_rgba(color);
56        fb.pixels.fill(packed);
57        fb
58    }
59
60    pub fn width(&self) -> u32 { self.width }
61    pub fn height(&self) -> u32 { self.height }
62
63    /// Returns a reference to the raw RGBA8 pixel data.
64    pub fn pixels(&self) -> &[u32] { &self.pixels }
65
66    /// Returns a mutable reference to the raw RGBA8 pixel data.
67    pub fn pixels_mut(&mut self) -> &mut [u32] { &mut self.pixels }
68
69    /// Clears the framebuffer to transparent black.
70    pub fn clear(&mut self) {
71        self.pixels.fill(0);
72        self.depth.fill(0.0);
73    }
74
75    /// Clears the framebuffer to a solid color.
76    pub fn clear_color(&mut self, color: [f32; 4]) {
77        let packed = pack_rgba(color);
78        self.pixels.fill(packed);
79    }
80
81    /// Blends a single pixel using Porter-Duff "over" compositing.
82    fn blend_pixel(&mut self, x: u32, y: u32, color: [f32; 4]) {
83        if x >= self.width || y >= self.height {
84            return;
85        }
86        let idx = (y * self.width + x) as usize;
87        // Fast path: opaque source avoids full Porter-Duff blend
88        if color[3] >= 1.0 {
89            self.pixels[idx] = pack_rgba(color);
90            return;
91        }
92        let src = color;
93        let dst = unpack_rgba(self.pixels[idx]);
94
95        // Porter-Duff over
96        let ao = src[3] + dst[3] * (1.0 - src[3]);
97        if ao < 0.001 {
98            return;
99        }
100        let out = [
101            (src[0] * src[3] + dst[0] * dst[3] * (1.0 - src[3])) / ao,
102            (src[1] * src[3] + dst[1] * dst[3] * (1.0 - src[3])) / ao,
103            (src[2] * src[3] + dst[2] * dst[3] * (1.0 - src[3])) / ao,
104            ao,
105        ];
106        self.pixels[idx] = pack_rgba(out);
107    }
108
109}
110
111fn pack_rgba(c: [f32; 4]) -> u32 {
112    let r = (c[0].clamp(0.0, 1.0) * 255.0) as u32;
113    let g = (c[1].clamp(0.0, 1.0) * 255.0) as u32;
114    let b = (c[2].clamp(0.0, 1.0) * 255.0) as u32;
115    let a = (c[3].clamp(0.0, 1.0) * 255.0) as u32;
116    r | (g << 8) | (b << 16) | (a << 24)
117}
118
119fn unpack_rgba(packed: u32) -> [f32; 4] {
120    [
121        (packed & 0xFF) as f32 / 255.0,
122        ((packed >> 8) & 0xFF) as f32 / 255.0,
123        ((packed >> 16) & 0xFF) as f32 / 255.0,
124        ((packed >> 24) & 0xFF) as f32 / 255.0,
125    ]
126}
127
128// --- Software Renderer ---
129
130/// CPU rasterizer implementing the `Renderer` trait.
131///
132/// All drawing operations write into an internal RGBA8 framebuffer.
133/// The framebuffer can be read back via `framebuffer()` or `into_framebuffer()`.
134pub struct SoftwareRenderer {
135    fb: Framebuffer,
136    start_time: Instant,
137    last_frame: Instant,
138    /// Phase 2 fix: long-lived text engine, constructed once.
139    #[cfg(feature = "text")]
140    text_engine: cvkg_runic_text::RunicTextEngine,
141    /// Memoize cache: tracks (id, data_hash) of the last memoized render.
142    /// If the same id+hash is seen again, the render is skipped.
143    memoize_cache: Option<(u64, u64)>,
144}
145
146impl SoftwareRenderer {
147    /// Creates a software renderer with the given framebuffer dimensions.
148    pub fn new(width: u32, height: u32) -> Self {
149        let now = Instant::now();
150        Self {
151            fb: Framebuffer::new(width, height),
152            start_time: now,
153            last_frame: now,
154            #[cfg(feature = "text")]
155            text_engine: {
156                let mut engine = cvkg_runic_text::RunicTextEngine::new_light();
157                engine.load_font_data(
158                    include_bytes!("../Fonts/Jupiteroid.ttf").to_vec(),
159                );
160                engine
161            },
162            memoize_cache: None,
163        }
164    }
165
166    /// Creates a software renderer with a solid background color.
167    pub fn with_color(width: u32, height: u32, color: [f32; 4]) -> Self {
168        let now = Instant::now();
169        Self {
170            fb: Framebuffer::with_color(width, height, color),
171            start_time: now,
172            last_frame: now,
173            #[cfg(feature = "text")]
174            text_engine: {
175                let mut engine = cvkg_runic_text::RunicTextEngine::new_light();
176                engine.load_font_data(include_bytes!("../Fonts/Jupiteroid.ttf").to_vec());
177                engine
178            },
179            memoize_cache: None,
180        }
181    }
182
183    /// Returns a reference to the internal framebuffer.
184    pub fn framebuffer(&self) -> &Framebuffer {
185        &self.fb
186    }
187
188    /// Returns the internal framebuffer, consuming the renderer.
189
190    /// Returns the width of the framebuffer.
191    pub fn width(&self) -> u32 {
192        self.fb.width()
193    }
194
195    /// Returns the height of the framebuffer.
196    pub fn height(&self) -> u32 {
197        self.fb.height()
198    }
199
200    fn fill_rect_internal(&mut self, rect: Rect, color: [f32; 4]) {
201        let x0 = rect.x.max(0.0) as u32;
202        let y0 = rect.y.max(0.0) as u32;
203        let x1 = (rect.x + rect.width).min(self.fb.width() as f32) as u32;
204        let y1 = (rect.y + rect.height).min(self.fb.height() as f32) as u32;
205        for y in y0..y1 {
206            for x in x0..x1 {
207                self.fb.blend_pixel(x, y, color);
208            }
209        }
210    }
211
212    fn fill_rounded_rect_internal(&mut self, rect: Rect, radius: f32, color: [f32; 4]) {
213        let r = radius.min(rect.width * 0.5).min(rect.height * 0.5);
214        let x0 = rect.x.max(0.0) as u32;
215        let y0 = rect.y.max(0.0) as u32;
216        let x1 = (rect.x + rect.width).min(self.fb.width() as f32) as u32;
217        let y1 = (rect.y + rect.height).min(self.fb.height() as f32) as u32;
218
219        for py in y0..y1 {
220            for px in x0..x1 {
221                let fx = px as f32 + 0.5;
222                let fy = py as f32 + 0.5;
223                // SDF for rounded rect: distance from point to rect edge, minus radius
224                let dx = (fx - rect.x).max(rect.x + rect.width - fx).max(0.0) - rect.width * 0.5;
225                let dy = (fy - rect.y).max(rect.y + rect.height - fy).max(0.0) - rect.height * 0.5;
226                // Clamp to zero inside the rect
227                let d = (dx.max(0.0) * dx.max(0.0) + dy.max(0.0) * dy.max(0.0)).sqrt() - r;
228                if d <= 0.0 {
229                    let alpha = if d > -1.0 {
230                        (1.0 + d).clamp(0.0, 1.0)
231                    } else {
232                        1.0
233                    };
234                    let mut c = color;
235                    c[3] *= alpha;
236                    self.fb.blend_pixel(px, py, c);
237                }
238            }
239        }
240    }
241}
242
243impl ElapsedTime for SoftwareRenderer {
244    fn elapsed_time(&self) -> f32 {
245        self.start_time.elapsed().as_secs_f32()
246    }
247
248    fn delta_time(&self) -> f32 {
249        self.last_frame.elapsed().as_secs_f32()
250    }
251}
252
253impl Renderer for SoftwareRenderer {
254    fn fill_rect(&mut self, rect: Rect, color: [f32; 4]) {
255        self.fill_rect_internal(rect, color);
256    }
257
258    fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4]) {
259        self.fill_rounded_rect_internal(rect, radius, color);
260    }
261
262    fn fill_ellipse(&mut self, rect: Rect, color: [f32; 4]) {
263        let cx = rect.x + rect.width * 0.5;
264        let cy = rect.y + rect.height * 0.5;
265        let rx = rect.width * 0.5;
266        let ry = rect.height * 0.5;
267        if rx <= 0.0 || ry <= 0.0 {
268            return;
269        }
270
271        let x0 = (cx - rx).max(0.0) as u32;
272        let y0 = (cy - ry).max(0.0) as u32;
273        let x1 = (cx + rx).min(self.fb.width() as f32) as u32;
274        let y1 = (cy + ry).min(self.fb.height() as f32) as u32;
275
276        for py in y0..y1 {
277            for px in x0..x1 {
278                let fx = px as f32 + 0.5;
279                let fy = py as f32 + 0.5;
280                let dx = (fx - cx) / rx;
281                let dy = (fy - cy) / ry;
282                let dist = dx * dx + dy * dy;
283                if dist <= 1.0 {
284                    let alpha = if dist > 0.75 {
285                        ((1.0 - dist) * 4.0).clamp(0.0, 1.0)
286                    } else {
287                        1.0
288                    };
289                    let mut c = color;
290                    c[3] *= alpha;
291                    self.fb.blend_pixel(px, py, c);
292                }
293            }
294        }
295    }
296
297    fn fill_glass_rect(&mut self, rect: Rect, radius: f32, blur_radius: f32) {
298        // No GPU blur -- degrade to semi-transparent solid with slight alpha boost
299        let alpha = (0.3 + blur_radius * 0.01).min(0.8);
300        let tint = [1.0, 1.0, 1.0, alpha];
301        self.fill_rounded_rect_internal(rect, radius, tint);
302    }
303
304    fn fill_glass_rect_with_intensity(
305        &mut self,
306        rect: Rect,
307        radius: f32,
308        blur_radius: f32,
309        glass_intensity: f32,
310    ) {
311        let alpha = (0.3 + blur_radius * 0.01 * glass_intensity).min(0.8) * glass_intensity;
312        let tint = [1.0, 1.0, 1.0, alpha];
313        self.fill_rounded_rect_internal(rect, radius, tint);
314    }
315
316    fn stroke_rect(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
317        let sw = stroke_width.max(0.5);
318        // Top
319        self.fill_rect_internal(
320            Rect { x: rect.x, y: rect.y, width: rect.width, height: sw },
321            color,
322        );
323        // Bottom
324        self.fill_rect_internal(
325            Rect { x: rect.x, y: rect.y + rect.height - sw, width: rect.width, height: sw },
326            color,
327        );
328        // Left
329        self.fill_rect_internal(
330            Rect { x: rect.x, y: rect.y, width: sw, height: rect.height },
331            color,
332        );
333        // Right
334        self.fill_rect_internal(
335            Rect { x: rect.x + rect.width - sw, y: rect.y, width: sw, height: rect.height },
336            color,
337        );
338    }
339
340    fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4], stroke_width: f32) {
341        let r = radius.min(rect.width * 0.5).min(rect.height * 0.5);
342        let sw = stroke_width.max(0.5);
343        let x0 = rect.x.max(0.0) as u32;
344        let y0 = rect.y.max(0.0) as u32;
345        let x1 = (rect.x + rect.width).min(self.fb.width() as f32) as u32;
346        let y1 = (rect.y + rect.height).min(self.fb.height() as f32) as u32;
347
348        for py in y0..y1 {
349            for px in x0..x1 {
350                let fx = px as f32 + 0.5;
351                let fy = py as f32 + 0.5;
352                let dx = (fx - (rect.x + r)).max(0.0) + (rect.x + rect.width - r - fx).max(0.0) - r;
353                let dy = (fy - (rect.y + r)).max(0.0) + (rect.y + rect.height - r - fy).max(0.0) - r;
354                let outside = (dx * dx + dy * dy).sqrt();
355                if outside <= r && outside >= r - sw {
356                    let alpha = if outside > r - 1.0 {
357                        (r - outside).clamp(0.0, 1.0)
358                    } else if outside < r - sw + 1.0 {
359                        (outside - (r - sw)).clamp(0.0, 1.0)
360                    } else {
361                        1.0
362                    };
363                    let mut c = color;
364                    c[3] *= alpha;
365                    self.fb.blend_pixel(px, py, c);
366                }
367            }
368        }
369    }
370
371    fn stroke_ellipse(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
372        let cx = rect.x + rect.width * 0.5;
373        let cy = rect.y + rect.height * 0.5;
374        let rx = rect.width * 0.5;
375        let ry = rect.height * 0.5;
376        let sw = stroke_width.max(0.5);
377
378        if rx <= 0.0 || ry <= 0.0 {
379            return;
380        }
381
382        let x0 = (cx - rx).max(0.0) as u32;
383        let y0 = (cy - ry).max(0.0) as u32;
384        let x1 = (cx + rx).min(self.fb.width() as f32) as u32;
385        let y1 = (cy + ry).min(self.fb.height() as f32) as u32;
386
387        for py in y0..y1 {
388            for px in x0..x1 {
389                let fx = px as f32 + 0.5;
390                let fy = py as f32 + 0.5;
391                let dx = (fx - cx) / rx;
392                let dy = (fy - cy) / ry;
393                let dist = dx * dx + dy * dy;
394                if dist <= 1.0 && dist >= (1.0 - sw / rx.max(ry)).powi(2) {
395                    self.fb.blend_pixel(px, py, color);
396                }
397            }
398        }
399    }
400
401    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: [f32; 4], stroke_width: f32) {
402        // Simple Bresenham-like line drawing (no AA for speed)
403        let dx = (x2 - x1).abs();
404        let dy = (y2 - y1).abs();
405        let steps = (dx.max(dy) as u32).max(1);
406        let sw = (stroke_width * 0.5).max(0.5);
407
408        for i in 0..=steps {
409            let t = i as f32 / steps as f32;
410            let x = x1 + (x2 - x1) * t;
411            let y = y1 + (y2 - y1) * t;
412            // Draw a small square for each point to approximate stroke width
413            let r = Rect { x: x - sw, y: y - sw, width: stroke_width, height: stroke_width };
414            self.fill_rect_internal(r, color);
415        }
416    }
417
418    fn draw_focus_ring(
419        &mut self,
420        rect: Rect,
421        radius: f32,
422        offset: f32,
423        width: f32,
424        color: [f32; 4],
425    ) {
426        let ring_rect = Rect {
427            x: rect.x - offset,
428            y: rect.y - offset,
429            width: rect.width + 2.0 * offset,
430            height: rect.height + 2.0 * offset,
431        };
432        self.stroke_rounded_rect(ring_rect, radius + offset, color, width);
433    }
434
435    fn draw_linear_gradient(
436        &mut self,
437        rect: Rect,
438        start_color: [f32; 4],
439        end_color: [f32; 4],
440        _angle: f32,
441    ) {
442        // Horizontal gradient only (angle ignored for simplicity)
443        let x0 = rect.x.max(0.0) as u32;
444        let x1 = (rect.x + rect.width).min(self.fb.width() as f32) as u32;
445        let w = rect.width.max(1.0);
446
447        for px in x0..x1 {
448            let t = (px as f32 - rect.x) / w;
449            let color = [
450                start_color[0] + (end_color[0] - start_color[0]) * t,
451                start_color[1] + (end_color[1] - start_color[1]) * t,
452                start_color[2] + (end_color[2] - start_color[2]) * t,
453                start_color[3] + (end_color[3] - start_color[3]) * t,
454            ];
455            let col = Rect { x: px as f32, y: rect.y, width: 1.0, height: rect.height };
456            self.fill_rect_internal(col, color);
457        }
458    }
459
460    // ==========================================
461    // P1-8: SoftwareRenderer missing core methods
462    // ==========================================
463    // The SoftwareRenderer only implements basic shapes, text,
464    // and linear gradients. The following methods are NOT
465    // implemented in software and would be silent no-ops if
466    // inherited from the default trait impls. We override them
467    // with explicit stubs that log a warning so callers know
468    // the operation is unsupported on this backend.
469
470    /// Measures text dimensions using a fast, deterministic monospace estimation.
471    fn measure_text(&mut self, text: &str, size: f32) -> (f32, f32) {
472        (text.len() as f32 * size * 0.6, size)
473    }
474
475    /// Shapes rich text spans using the Runic text layout engine.
476    /// Returns None if the "text" feature is disabled.
477    fn shape_rich_text(
478        &mut self,
479        spans: &[cvkg_runic_text::TextSpan],
480        max_width: Option<f32>,
481        align: cvkg_runic_text::TextAlign,
482        overflow: cvkg_runic_text::TextOverflow,
483    ) -> Option<cvkg_runic_text::ShapedText> {
484        #[cfg(feature = "text")]
485        {
486            // Phase 2 fix: use the long-lived text engine instead of creating a new one per call.
487            self.text_engine.shape_layout(spans, max_width, align, overflow).ok()
488        }
489        #[cfg(not(feature = "text"))]
490        {
491            None
492        }
493    }
494
495    /// Renders pre-shaped text layout. (Stubbed out for software renderer fallback).
496    fn draw_shaped_text(&mut self, _text: &cvkg_runic_text::ShapedText, _x: f32, _y: f32) {
497        // Simple stub: software rendering of layout glyphs is not implemented
498    }
499
500    fn draw_texture(&mut self, texture_id: u32, _rect: Rect) {
501        log::warn!(
502            "[SoftwareRenderer] draw_texture({}) is not implemented in software. \
503             The texture will not appear in the output.",
504            texture_id
505        );
506    }
507
508    fn draw_image(&mut self, image_name: &str, _rect: Rect) {
509        log::warn!(
510            "[SoftwareRenderer] draw_image('{}') is not implemented in software. \
511             The image will not appear in the output.",
512            image_name
513        );
514    }
515
516    fn draw_svg(&mut self, name: &str, _rect: Rect) {
517        log::warn!(
518            "[SoftwareRenderer] draw_svg('{}') is not implemented in software. \
519             The SVG will not appear in the output.",
520            name
521        );
522    }
523
524    fn draw_mesh(&mut self, _mesh: &Mesh, _color: [f32; 4], _transform: glam::Mat4) {
525        log::warn!(
526            "[SoftwareRenderer] draw_mesh() is not implemented in software. \
527             The mesh will not appear in the output."
528        );
529    }
530
531    fn draw_mesh_3d(
532        &mut self,
533        _mesh: &Mesh,
534        _material: &Material3D,
535        _transform: &Transform3D,
536    ) {
537        log::warn!(
538            "[SoftwareRenderer] draw_mesh_3d() is not implemented in software. \
539             The 3D mesh will not appear in the output."
540        );
541    }
542
543    fn fill_glass_rect_with_pressure(
544        &mut self,
545        _rect: Rect,
546        _radius: f32,
547        _blur_radius: f32,
548        _pressure: f32,
549    ) {
550        // No pressure-based falloff in software -- degrade to standard glass.
551        self.fill_glass_rect(_rect, _radius, _blur_radius);
552    }
553
554    fn draw_hologram(&mut self, _rect: Rect, hologram_id: &str, _time: f32) {
555        log::warn!(
556            "[SoftwareRenderer] draw_hologram('{}') is not implemented in software. \
557             Holograms require GPU compute shaders.",
558            hologram_id
559        );
560    }
561
562    fn memoize(&mut self, id: u64, data_hash: u64, render_fn: &dyn Fn(&mut dyn Renderer)) {
563        // Simple cache: skip re-rendering if the data_hash hasn't changed.
564        // We track (id, data_hash) pairs; if the same id is rendered with the
565        // same hash, we skip the render call entirely.
566        if let Some(&(cached_id, cached_hash)) = self.memoize_cache.as_ref() {
567            if cached_id == id && cached_hash == data_hash {
568                return; // content unchanged, skip
569            }
570        }
571        self.memoize_cache = Some((id, data_hash));
572        render_fn(self);
573    }
574}
575
576// --- Tests ---
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn framebuffer_new() {
584        let fb = Framebuffer::new(100, 100);
585        assert_eq!(fb.width(), 100);
586        assert_eq!(fb.height(), 100);
587        assert_eq!(fb.pixels().len(), 10000);
588    }
589
590    #[test]
591    fn framebuffer_with_color() {
592        let fb = Framebuffer::with_color(10, 10, [1.0, 0.0, 0.0, 1.0]);
593        for &px in fb.pixels() {
594            let c = unpack_rgba(px);
595            assert!((c[0] - 1.0).abs() < 0.01);
596            assert!((c[1]).abs() < 0.01);
597            assert!((c[2]).abs() < 0.01);
598            assert!((c[3] - 1.0).abs() < 0.01);
599        }
600    }
601
602    #[test]
603    fn software_fill_rect() {
604        let mut r = SoftwareRenderer::new(100, 100);
605        r.fill_rect(Rect { x: 10.0, y: 10.0, width: 20.0, height: 20.0 }, [1.0, 0.0, 0.0, 1.0]);
606
607        let fb = r.framebuffer();
608        // Inside rect should be red
609        let idx = (15 * 100 + 15) as usize;
610        let c = unpack_rgba(fb.pixels()[idx]);
611        assert!((c[0] - 1.0).abs() < 0.01);
612
613        // Outside rect should be transparent
614        let idx2 = (5 * 100 + 5) as usize;
615        let c2 = unpack_rgba(fb.pixels()[idx2]);
616        assert!(c2[3] < 0.01);
617    }
618
619    #[test]
620    fn software_fill_rounded_rect() {
621        let mut r = SoftwareRenderer::new(100, 100);
622        r.fill_rounded_rect(
623            Rect { x: 10.0, y: 10.0, width: 40.0, height: 40.0 },
624            8.0,
625            [0.0, 1.0, 0.0, 1.0],
626        );
627        let fb = r.framebuffer();
628        // Center should be green
629        let idx = (30 * 100 + 30) as usize;
630        let c = unpack_rgba(fb.pixels()[idx]);
631        assert!((c[1] - 1.0).abs() < 0.01);
632    }
633
634    #[test]
635    fn software_fill_ellipse() {
636        let mut r = SoftwareRenderer::new(100, 100);
637        r.fill_ellipse(
638            Rect { x: 20.0, y: 20.0, width: 60.0, height: 60.0 },
639            [0.0, 0.0, 1.0, 1.0],
640        );
641        let fb = r.framebuffer();
642        // Center should be blue
643        let idx = (50 * 100 + 50) as usize;
644        let c = unpack_rgba(fb.pixels()[idx]);
645        assert!((c[2] - 1.0).abs() < 0.01);
646    }
647
648    #[test]
649    fn software_glass_degrades_to_solid() {
650        let mut r = SoftwareRenderer::new(100, 100);
651        r.fill_glass_rect(Rect { x: 10.0, y: 10.0, width: 40.0, height: 40.0 }, 8.0, 16.0);
652        let fb = r.framebuffer();
653        // Glass center should be semi-transparent white (degraded)
654        let idx = (30 * 100 + 30) as usize;
655        let c = unpack_rgba(fb.pixels()[idx]);
656        assert!(c[3] > 0.1, "glass should have some opacity");
657        assert!(c[3] < 0.9, "glass should not be fully opaque");
658    }
659
660    #[test]
661    fn software_stroke_rect() {
662        let mut r = SoftwareRenderer::new(100, 100);
663        r.stroke_rect(
664            Rect { x: 10.0, y: 10.0, width: 30.0, height: 30.0 },
665            [1.0, 1.0, 1.0, 1.0],
666            2.0,
667        );
668        let fb = r.framebuffer();
669        // Edge pixel should be white
670        let idx = (10 * 100 + 10) as usize;
671        let c = unpack_rgba(fb.pixels()[idx]);
672        assert!(c[0] > 0.5);
673    }
674
675    #[test]
676    fn software_clear_color() {
677        let r = SoftwareRenderer::with_color(10, 10, [0.5, 0.5, 0.5, 1.0]);
678        let fb = r.framebuffer();
679        // Verify initial color
680        let c = unpack_rgba(fb.pixels()[0]);
681        assert!((c[0] - 0.5).abs() < 0.02);
682    }
683
684    #[test]
685    fn software_measure_text() {
686        let mut r = SoftwareRenderer::new(100, 100);
687        let (w, h) = r.measure_text("Hello", 14.0);
688        assert!(w > 0.0);
689        assert!((h - 14.0).abs() < 0.01);
690    }
691
692    #[test]
693    fn software_elapsed_time() {
694        let r = SoftwareRenderer::new(100, 100);
695        assert!(r.elapsed_time() >= 0.0);
696    }
697
698    #[test]
699    fn software_gradient() {
700        let mut r = SoftwareRenderer::new(100, 100);
701        r.draw_linear_gradient(
702            Rect { x: 0.0, y: 0.0, width: 100.0, height: 1.0 },
703            [1.0, 0.0, 0.0, 1.0],
704            [0.0, 0.0, 1.0, 1.0],
705            0.0,
706        );
707        let fb = r.framebuffer();
708        let left = unpack_rgba(fb.pixels()[0]);
709        let right = unpack_rgba(fb.pixels()[99]);
710        assert!((left[0] - 1.0).abs() < 0.02); // Red on left
711        assert!((right[2] - 1.0).abs() < 0.02); // Blue on right
712    }
713
714    // ==========================================
715    // P1-8: SoftwareRenderer explicit stub warnings
716    // ==========================================
717    // Verify that the unimplemented methods at least exist
718    // (don't panic at the trait level) and return without
719    // modifying the framebuffer. The log::warn! calls are
720    // not asserted (would require log capture infrastructure),
721    // but the tests prove the methods don't crash the renderer.
722
723    #[test]
724    fn p1_8_draw_image_does_not_panic() {
725        let mut r = SoftwareRenderer::new(100, 100);
726        r.draw_image("test.png", cvkg_core::Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 });
727        // Framebuffer should be unmodified (all transparent).
728        let fb = r.framebuffer();
729        for pixel in fb.pixels() {
730            assert_eq!(*pixel, 0, "draw_image should not modify the framebuffer");
731        }
732    }
733
734    #[test]
735    fn p1_8_draw_svg_does_not_panic() {
736        let mut r = SoftwareRenderer::new(100, 100);
737        r.draw_svg("icon", cvkg_core::Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 });
738        // Should not panic.
739    }
740
741    #[test]
742    fn p1_8_draw_texture_does_not_panic() {
743        let mut r = SoftwareRenderer::new(100, 100);
744        r.draw_texture(1, cvkg_core::Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 });
745        // Should not panic.
746    }
747}