//! SoftwareRenderer - Executes draw commands on a framebuffer //! //! This is the core of our rendering system. //! It takes DrawCommands and turns them into pixels. //! //! ## UTF-8 Text Rendering //! //! The `drawText` function automatically decodes UTF-8 encoded strings and maps //! the resulting Unicode codepoints to the font's character set (typically Latin-1). //! //! This allows seamless handling of text from various sources: //! - SQLite databases (UTF-8 by default) //! - Source code string literals (UTF-8 in Zig) //! - User input (typically UTF-8 on modern systems) //! - JSON/API responses (UTF-8 standard) //! //! Characters outside the font's range (Latin-1: 0x00-0xFF) are displayed as '?'. //! //! ## Example //! ```zig //! // Spanish text with accents - works automatically //! ctx.pushCommand(Command.text(x, y, "¿Hola, España!", color)); //! ``` const std = @import("std"); const Command = @import("../core/command.zig"); const Style = @import("../core/style.zig"); const Layout = @import("../core/layout.zig"); const Framebuffer = @import("framebuffer.zig").Framebuffer; const Font = @import("font.zig").Font; const TtfFont = @import("ttf.zig").TtfFont; const Color = Style.Color; const Rect = Layout.Rect; const DrawCommand = Command.DrawCommand; /// Software renderer state pub const SoftwareRenderer = struct { framebuffer: *Framebuffer, default_font: ?*Font, /// TTF font (takes priority over bitmap if set) ttf_font: ?*TtfFont = null, /// Clipping stack clip_stack: [16]Rect, clip_depth: usize, const Self = @This(); /// Initialize the renderer pub fn init(framebuffer: *Framebuffer) Self { return .{ .framebuffer = framebuffer, .default_font = null, .ttf_font = null, .clip_stack = undefined, .clip_depth = 0, }; } /// Set the default bitmap font pub fn setDefaultFont(self: *Self, font: *Font) void { self.default_font = font; } /// Set the TTF font (takes priority over bitmap font) pub fn setTtfFont(self: *Self, font: *TtfFont) void { self.ttf_font = font; } /// Clear the TTF font (revert to bitmap) pub fn clearTtfFont(self: *Self) void { self.ttf_font = null; } /// Get the current clip rectangle pub fn getClip(self: Self) Rect { if (self.clip_depth == 0) { return Rect.init(0, 0, self.framebuffer.width, self.framebuffer.height); } return self.clip_stack[self.clip_depth - 1]; } /// Execute a single draw command pub fn execute(self: *Self, cmd: DrawCommand) void { switch (cmd) { .rect => |r| self.drawRect(r), .rounded_rect => |r| self.drawRoundedRect(r), .text => |t| self.drawText(t), .line => |l| self.drawLine(l), .rect_outline => |r| self.drawRectOutline(r), .rounded_rect_outline => |r| self.drawRoundedRectOutline(r), .shadow => |s| self.drawShadow(s), .gradient => |g| self.drawGradient(g), .filled_triangle => |tri| self.drawFilledTriangle(tri), .filled_circle => |cir| self.drawFilledCircle(cir), .clip => |c| self.pushClip(c), .clip_end => self.popClip(), .nop => {}, } } /// Execute all commands in a list pub fn executeAll(self: *Self, commands: []const DrawCommand) void { for (commands) |cmd| { self.execute(cmd); } } /// Execute commands only within dirty regions (partial redraw) /// This is an optimization that skips rendering commands outside dirty areas. /// For full redraw, pass a single Rect covering the entire screen. pub fn executeWithDirtyRegions( self: *Self, commands: []const DrawCommand, dirty_regions: []const Rect, clear_color: Color, ) void { // First, clear only the dirty regions for (dirty_regions) |dirty| { self.framebuffer.clearRect(dirty.x, dirty.y, dirty.w, dirty.h, clear_color); } // Then render commands, but only if they intersect dirty regions for (commands) |cmd| { const cmd_rect = commandBounds(cmd); if (cmd_rect) |bounds| { // Check if command intersects any dirty region var needs_render = false; for (dirty_regions) |dirty| { if (rectsIntersect(bounds, dirty)) { needs_render = true; break; } } if (!needs_render) continue; } // Commands without bounds (clip, clip_end, nop) always execute self.execute(cmd); } } /// Clear the framebuffer pub fn clear(self: *Self, color: Color) void { self.framebuffer.clear(color); } /// Clear a rectangular region (for partial redraw) pub fn clearRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void { self.framebuffer.clearRect(x, y, w, h, color); } // ========================================================================= // Private drawing functions // ========================================================================= fn drawRect(self: *Self, r: Command.RectCommand) void { const clip = self.getClip(); // Clip the rectangle const clipped = Rect.init(r.x, r.y, r.w, r.h).intersection(clip); if (clipped.isEmpty()) return; self.framebuffer.fillRect( clipped.x, clipped.y, clipped.w, clipped.h, r.color, ); } fn drawText(self: *Self, t: Command.TextCommand) void { const clip = self.getClip(); // Use TTF font if available (takes priority) if (self.ttf_font) |ttf| { ttf.drawText(self.framebuffer, t.x, t.y, t.text, t.color, clip); return; } // Fall back to bitmap font const font = if (t.font) |f| @as(*Font, @ptrCast(@alignCast(f))) else self.default_font orelse return; // UTF-8 text rendering - decode codepoints properly var x = t.x; var i: usize = 0; while (i < t.text.len) { // Check if character is visible if (x >= clip.right()) break; // Decode UTF-8 codepoint const byte = t.text[i]; var codepoint: u21 = undefined; var bytes_consumed: usize = 1; if (byte < 0x80) { // ASCII (0x00-0x7F) codepoint = byte; } else if (byte < 0xC0) { // Invalid start byte (continuation byte), skip i += 1; continue; } else if (byte < 0xE0) { // 2-byte sequence (0xC0-0xDF) if (i + 1 < t.text.len) { codepoint = (@as(u21, byte & 0x1F) << 6) | @as(u21, t.text[i + 1] & 0x3F); bytes_consumed = 2; } else { i += 1; continue; } } else if (byte < 0xF0) { // 3-byte sequence (0xE0-0xEF) if (i + 2 < t.text.len) { codepoint = (@as(u21, byte & 0x0F) << 12) | (@as(u21, t.text[i + 1] & 0x3F) << 6) | @as(u21, t.text[i + 2] & 0x3F); bytes_consumed = 3; } else { i += 1; continue; } } else { // 4-byte sequence (0xF0-0xF7) - beyond Latin-1, skip bytes_consumed = 4; i += bytes_consumed; x += @as(i32, @intCast(font.charWidth())); // placeholder space continue; } i += bytes_consumed; // Handle newlines if (codepoint == '\n') { continue; } // Convert codepoint to Latin-1 for rendering // Latin-1 covers 0x00-0xFF directly const char_to_render: u8 = if (codepoint <= 0xFF) @intCast(codepoint) else '?'; // Replacement for chars outside Latin-1 // Render character font.drawChar(self.framebuffer, x, t.y, char_to_render, t.color, clip); x += @as(i32, @intCast(font.charWidth())); } } fn drawLine(self: *Self, l: Command.LineCommand) void { // TODO: Clip line to clip rectangle self.framebuffer.drawLine(l.x1, l.y1, l.x2, l.y2, l.color); } fn drawRectOutline(self: *Self, r: Command.RectOutlineCommand) void { const clip = self.getClip(); // Draw each edge as a filled rect // Top const top_clipped = Rect.init(r.x, r.y, r.w, r.thickness).intersection(clip); if (!top_clipped.isEmpty()) { self.framebuffer.fillRect(top_clipped.x, top_clipped.y, top_clipped.w, top_clipped.h, r.color); } // Bottom const bottom_y = r.y + @as(i32, @intCast(r.h)) - @as(i32, @intCast(r.thickness)); const bottom_clipped = Rect.init(r.x, bottom_y, r.w, r.thickness).intersection(clip); if (!bottom_clipped.isEmpty()) { self.framebuffer.fillRect(bottom_clipped.x, bottom_clipped.y, bottom_clipped.w, bottom_clipped.h, r.color); } // Left const inner_y = r.y + @as(i32, @intCast(r.thickness)); const inner_h = r.h -| (r.thickness * 2); const left_clipped = Rect.init(r.x, inner_y, r.thickness, inner_h).intersection(clip); if (!left_clipped.isEmpty()) { self.framebuffer.fillRect(left_clipped.x, left_clipped.y, left_clipped.w, left_clipped.h, r.color); } // Right const right_x = r.x + @as(i32, @intCast(r.w)) - @as(i32, @intCast(r.thickness)); const right_clipped = Rect.init(right_x, inner_y, r.thickness, inner_h).intersection(clip); if (!right_clipped.isEmpty()) { self.framebuffer.fillRect(right_clipped.x, right_clipped.y, right_clipped.w, right_clipped.h, r.color); } } fn drawRoundedRect(self: *Self, r: Command.RoundedRectCommand) void { // TODO: Apply clipping (for now, draw directly) // The fillRoundedRect function handles bounds checking internally self.framebuffer.fillRoundedRect(r.x, r.y, r.w, r.h, r.color, r.radius, r.aa); } fn drawRoundedRectOutline(self: *Self, r: Command.RoundedRectOutlineCommand) void { // TODO: Apply clipping self.framebuffer.drawRoundedRect(r.x, r.y, r.w, r.h, r.color, r.radius, r.thickness, r.aa); } /// Draw a multi-layer shadow to simulate blur effect /// Draws expanding layers with decreasing alpha, creating soft edges fn drawShadow(self: *Self, s: Command.ShadowCommand) void { if (s.blur == 0) { // Hard shadow - single solid rect const shadow_x = s.x + @as(i32, s.offset_x) - @as(i32, s.spread); const shadow_y = s.y + @as(i32, s.offset_y) - @as(i32, s.spread); const shadow_w = s.w +| @as(u32, @intCast(@abs(s.spread) * 2)); const shadow_h = s.h +| @as(u32, @intCast(@abs(s.spread) * 2)); if (s.radius > 0) { self.framebuffer.fillRoundedRect(shadow_x, shadow_y, shadow_w, shadow_h, s.color, s.radius, false); } else { self.framebuffer.fillRect(shadow_x, shadow_y, shadow_w, shadow_h, s.color); } return; } // Soft shadow - draw multiple expanding layers with decreasing alpha // Each layer is larger and more transparent, creating a blur effect const layers: u8 = s.blur; const base_alpha = s.color.a; // Calculate base shadow position const base_x = s.x + @as(i32, s.offset_x) - @as(i32, s.spread); const base_y = s.y + @as(i32, s.offset_y) - @as(i32, s.spread); const base_w = s.w +| @as(u32, @intCast(@abs(s.spread) * 2)); const base_h = s.h +| @as(u32, @intCast(@abs(s.spread) * 2)); // Draw from outermost (most transparent) to innermost (most opaque) var layer: u8 = layers; while (layer > 0) { layer -= 1; // Calculate alpha for this layer (quadratic falloff for softer edges) const t = @as(f32, @floatFromInt(layer)) / @as(f32, @floatFromInt(layers)); const alpha_factor = (1.0 - t) * (1.0 - t); // Quadratic falloff const layer_alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(base_alpha)) * alpha_factor * 0.5)); if (layer_alpha == 0) continue; // Expand layer outward const expand = @as(i32, @intCast(layers - layer)); const layer_x = base_x - expand; const layer_y = base_y - expand; const layer_w = base_w +| @as(u32, @intCast(expand * 2)); const layer_h = base_h +| @as(u32, @intCast(expand * 2)); const layer_radius = if (s.radius > 0) s.radius +| @as(u8, @intCast(@min(255 - s.radius, expand))) else 0; const layer_color = Color.rgba(s.color.r, s.color.g, s.color.b, layer_alpha); if (layer_radius > 0) { self.framebuffer.fillRoundedRect(layer_x, layer_y, layer_w, layer_h, layer_color, layer_radius, false); } else { self.framebuffer.fillRect(layer_x, layer_y, layer_w, layer_h, layer_color); } } // Draw core shadow (innermost, full opacity relative to input) const core_alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(base_alpha)) * 0.7)); if (core_alpha > 0) { const core_color = Color.rgba(s.color.r, s.color.g, s.color.b, core_alpha); if (s.radius > 0) { self.framebuffer.fillRoundedRect(base_x, base_y, base_w, base_h, core_color, s.radius, false); } else { self.framebuffer.fillRect(base_x, base_y, base_w, base_h, core_color); } } } /// Draw a gradient-filled rectangle fn drawGradient(self: *Self, g: Command.GradientCommand) void { if (g.w == 0 or g.h == 0) return; const clip = self.getClip(); switch (g.direction) { .vertical => { // Draw horizontal bands from top to bottom var y: u32 = 0; while (y < g.h) : (y += 1) { const t = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(g.h -| 1)); const color = interpolateColor(g.start_color, g.end_color, t); const line_y = g.y + @as(i32, @intCast(y)); const line_rect = Rect.init(g.x, line_y, g.w, 1).intersection(clip); if (!line_rect.isEmpty()) { if (g.radius > 0 and (y < g.radius or y >= g.h - g.radius)) { // For rounded corners, calculate horizontal inset const corner_y = if (y < g.radius) g.radius - y else y - (g.h - g.radius); const r = @as(f32, @floatFromInt(g.radius)); const cy = @as(f32, @floatFromInt(corner_y)); const inset_f = r - @sqrt(r * r - cy * cy); const inset = @as(u32, @intFromFloat(@max(0, inset_f))); if (g.w > inset * 2) { const inner_x = g.x + @as(i32, @intCast(inset)); const inner_w = g.w - inset * 2; const inner_rect = Rect.init(inner_x, line_y, inner_w, 1).intersection(clip); if (!inner_rect.isEmpty()) { self.framebuffer.fillRect(inner_rect.x, inner_rect.y, inner_rect.w, inner_rect.h, color); } } } else { self.framebuffer.fillRect(line_rect.x, line_rect.y, line_rect.w, line_rect.h, color); } } } }, .horizontal => { // Draw vertical bands from left to right var x: u32 = 0; while (x < g.w) : (x += 1) { const t = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(g.w -| 1)); const color = interpolateColor(g.start_color, g.end_color, t); const line_x = g.x + @as(i32, @intCast(x)); const line_rect = Rect.init(line_x, g.y, 1, g.h).intersection(clip); if (!line_rect.isEmpty()) { if (g.radius > 0 and (x < g.radius or x >= g.w - g.radius)) { // For rounded corners, calculate vertical inset const corner_x = if (x < g.radius) g.radius - x else x - (g.w - g.radius); const r = @as(f32, @floatFromInt(g.radius)); const cx = @as(f32, @floatFromInt(corner_x)); const inset_f = r - @sqrt(r * r - cx * cx); const inset = @as(u32, @intFromFloat(@max(0, inset_f))); if (g.h > inset * 2) { const inner_y = g.y + @as(i32, @intCast(inset)); const inner_h = g.h - inset * 2; const inner_rect = Rect.init(line_x, inner_y, 1, inner_h).intersection(clip); if (!inner_rect.isEmpty()) { self.framebuffer.fillRect(inner_rect.x, inner_rect.y, inner_rect.w, inner_rect.h, color); } } } else { self.framebuffer.fillRect(line_rect.x, line_rect.y, line_rect.w, line_rect.h, color); } } } }, .diagonal_down, .diagonal_up => { // Draw pixel by pixel for diagonal gradients const max_dist = @as(f32, @floatFromInt(g.w + g.h -| 2)); var y: u32 = 0; while (y < g.h) : (y += 1) { var x: u32 = 0; while (x < g.w) : (x += 1) { const px = g.x + @as(i32, @intCast(x)); const py = g.y + @as(i32, @intCast(y)); if (!clip.contains(px, py)) continue; // Check rounded corners if (g.radius > 0) { if (!isInsideRoundedCorner(x, y, g.w, g.h, g.radius)) continue; } const dist: f32 = if (g.direction == .diagonal_down) @as(f32, @floatFromInt(x + y)) else @as(f32, @floatFromInt(x + (g.h -| 1 - y))); const t = dist / max_dist; const color = interpolateColor(g.start_color, g.end_color, t); self.framebuffer.setPixel(px, py, color); } } }, } } /// Draw a filled triangle using scanline algorithm fn drawFilledTriangle(self: *Self, tri: Command.FilledTriangleCommand) void { const clip = self.getClip(); // Sort vertices by Y coordinate (p0.y <= p1.y <= p2.y) var p0 = [2]i32{ tri.x1, tri.y1 }; var p1 = [2]i32{ tri.x2, tri.y2 }; var p2 = [2]i32{ tri.x3, tri.y3 }; // Bubble sort by Y if (p0[1] > p1[1]) { const tmp = p0; p0 = p1; p1 = tmp; } if (p1[1] > p2[1]) { const tmp = p1; p1 = p2; p2 = tmp; } if (p0[1] > p1[1]) { const tmp = p0; p0 = p1; p1 = tmp; } // Early exit if triangle is degenerate (all same Y) if (p0[1] == p2[1]) return; // Calculate inverse slopes for edge interpolation const total_height = p2[1] - p0[1]; const top_height = p1[1] - p0[1]; const bottom_height = p2[1] - p1[1]; // Draw scanlines from top to bottom var y = p0[1]; while (y <= p2[1]) : (y += 1) { // Skip if outside clip region if (y < clip.y or y >= clip.y + @as(i32, @intCast(clip.h))) { continue; } // Calculate X coordinates for this scanline var x_left: i32 = undefined; var x_right: i32 = undefined; // Progress along the long edge (p0 to p2) const t_long: f32 = @as(f32, @floatFromInt(y - p0[1])) / @as(f32, @floatFromInt(total_height)); const x_long = p0[0] + @as(i32, @intFromFloat(@as(f32, @floatFromInt(p2[0] - p0[0])) * t_long)); // Progress along the short edges var x_short: i32 = undefined; if (y < p1[1]) { // Upper half: interpolate p0 to p1 if (top_height == 0) { x_short = p0[0]; } else { const t_short: f32 = @as(f32, @floatFromInt(y - p0[1])) / @as(f32, @floatFromInt(top_height)); x_short = p0[0] + @as(i32, @intFromFloat(@as(f32, @floatFromInt(p1[0] - p0[0])) * t_short)); } } else { // Lower half: interpolate p1 to p2 if (bottom_height == 0) { x_short = p1[0]; } else { const t_short: f32 = @as(f32, @floatFromInt(y - p1[1])) / @as(f32, @floatFromInt(bottom_height)); x_short = p1[0] + @as(i32, @intFromFloat(@as(f32, @floatFromInt(p2[0] - p1[0])) * t_short)); } } // Ensure left < right if (x_long < x_short) { x_left = x_long; x_right = x_short; } else { x_left = x_short; x_right = x_long; } // Clip X to clip region if (x_left < clip.x) x_left = clip.x; if (x_right >= clip.x + @as(i32, @intCast(clip.w))) x_right = clip.x + @as(i32, @intCast(clip.w)) - 1; // Draw horizontal line if (x_left <= x_right) { const w: u32 = @intCast(x_right - x_left + 1); self.framebuffer.fillRect(x_left, y, w, 1, tri.color); } } } /// Draw a filled circle using Midpoint Circle Algorithm (Bresenham) /// Efficient: uses only integer arithmetic (no sqrt, no trig) /// Fills by drawing horizontal scanlines between symmetric octants fn drawFilledCircle(self: *Self, cir: Command.FilledCircleCommand) void { const clip = self.getClip(); const cx = cir.cx; const cy = cir.cy; const radius: i32 = @intCast(cir.radius); if (radius <= 0) return; // Midpoint Circle Algorithm var x: i32 = 0; var y: i32 = radius; var d: i32 = 3 - 2 * radius; // Helper to draw a horizontal line with clipping const drawHLine = struct { fn draw(fb: *Framebuffer, clip_rect: Rect, y_pos: i32, x_start: i32, x_end: i32, color: Color) void { // Skip if outside vertical clip if (y_pos < clip_rect.y or y_pos >= clip_rect.y + @as(i32, @intCast(clip_rect.h))) return; // Clip horizontally var x1 = x_start; var x2 = x_end; if (x1 > x2) { const tmp = x1; x1 = x2; x2 = tmp; } if (x1 < clip_rect.x) x1 = clip_rect.x; if (x2 >= clip_rect.x + @as(i32, @intCast(clip_rect.w))) x2 = clip_rect.x + @as(i32, @intCast(clip_rect.w)) - 1; if (x1 <= x2) { const w: u32 = @intCast(x2 - x1 + 1); fb.fillRect(x1, y_pos, w, 1, color); } } }.draw; while (y >= x) { // Draw horizontal lines for all 8 octants (4 lines cover all) // Top and bottom (wide) drawHLine(self.framebuffer, clip, cy - y, cx - x, cx + x, cir.color); drawHLine(self.framebuffer, clip, cy + y, cx - x, cx + x, cir.color); // Middle (tall) drawHLine(self.framebuffer, clip, cy - x, cx - y, cx + y, cir.color); drawHLine(self.framebuffer, clip, cy + x, cx - y, cx + y, cir.color); // Update decision variable if (d < 0) { d = d + 4 * x + 6; } else { d = d + 4 * (x - y) + 10; y -= 1; } x += 1; } } fn pushClip(self: *Self, c: Command.ClipCommand) void { if (self.clip_depth >= self.clip_stack.len) return; const new_clip = Rect.init(c.x, c.y, c.w, c.h); const current = self.getClip(); const clipped = new_clip.intersection(current); self.clip_stack[self.clip_depth] = clipped; self.clip_depth += 1; } fn popClip(self: *Self) void { if (self.clip_depth > 0) { self.clip_depth -= 1; } } }; // ============================================================================= // Helper functions for partial redraw // ============================================================================= /// Get bounding rectangle of a draw command (null for commands without bounds) fn commandBounds(cmd: DrawCommand) ?Rect { return switch (cmd) { .rect => |r| Rect.init(r.x, r.y, r.w, r.h), .rounded_rect => |r| Rect.init(r.x, r.y, r.w, r.h), .text => |t| blk: { // Estimate text bounds (width based on text length, height based on font) // This is approximate; actual font metrics would be better const char_width = 8; // Default font width const char_height = 16; // Default font height const text_width = @as(u32, @intCast(t.text.len)) * char_width; break :blk Rect.init(t.x, t.y, text_width, char_height); }, .line => |l| blk: { const min_x = @min(l.x1, l.x2); const min_y = @min(l.y1, l.y2); const max_x = @max(l.x1, l.x2); const max_y = @max(l.y1, l.y2); break :blk Rect.init( min_x, min_y, @intCast(@as(u32, @intCast(max_x - min_x)) + 1), @intCast(@as(u32, @intCast(max_y - min_y)) + 1), ); }, .rect_outline => |r| Rect.init(r.x, r.y, r.w, r.h), .rounded_rect_outline => |r| Rect.init(r.x, r.y, r.w, r.h), .shadow => |s| blk: { // Shadow bounds include offset, blur, and spread expansion const blur_expand = @as(i32, s.blur); const spread_expand = @as(i32, @abs(s.spread)); const total_expand = blur_expand + spread_expand; const min_offset_x = @min(0, @as(i32, s.offset_x)); const min_offset_y = @min(0, @as(i32, s.offset_y)); const max_offset_x = @max(0, @as(i32, s.offset_x)); const max_offset_y = @max(0, @as(i32, s.offset_y)); break :blk Rect.init( s.x + min_offset_x - total_expand, s.y + min_offset_y - total_expand, s.w +| @as(u32, @intCast(max_offset_x - min_offset_x + total_expand * 2)), s.h +| @as(u32, @intCast(max_offset_y - min_offset_y + total_expand * 2)), ); }, .gradient => |g| Rect.init(g.x, g.y, g.w, g.h), .filled_triangle => |tri| blk: { const min_x = @min(@min(tri.x1, tri.x2), tri.x3); const min_y = @min(@min(tri.y1, tri.y2), tri.y3); const max_x = @max(@max(tri.x1, tri.x2), tri.x3); const max_y = @max(@max(tri.y1, tri.y2), tri.y3); break :blk Rect.init( min_x, min_y, @intCast(@max(1, max_x - min_x + 1)), @intCast(@max(1, max_y - min_y + 1)), ); }, .filled_circle => |cir| blk: { const r: i32 = @intCast(cir.radius); break :blk Rect.init( cir.cx - r, cir.cy - r, @intCast(r * 2 + 1), @intCast(r * 2 + 1), ); }, .clip, .clip_end, .nop => null, }; } /// Check if two rectangles intersect fn rectsIntersect(a: Rect, b: Rect) bool { const a_right = a.x + @as(i32, @intCast(a.w)); const a_bottom = a.y + @as(i32, @intCast(a.h)); const b_right = b.x + @as(i32, @intCast(b.w)); const b_bottom = b.y + @as(i32, @intCast(b.h)); return a.x < b_right and a_right > b.x and a.y < b_bottom and a_bottom > b.y; } /// Interpolate between two colors fn interpolateColor(a: Color, b: Color, t: f32) Color { const t_clamped = @max(0.0, @min(1.0, t)); const inv_t = 1.0 - t_clamped; return Color.rgba( @intFromFloat(@as(f32, @floatFromInt(a.r)) * inv_t + @as(f32, @floatFromInt(b.r)) * t_clamped), @intFromFloat(@as(f32, @floatFromInt(a.g)) * inv_t + @as(f32, @floatFromInt(b.g)) * t_clamped), @intFromFloat(@as(f32, @floatFromInt(a.b)) * inv_t + @as(f32, @floatFromInt(b.b)) * t_clamped), @intFromFloat(@as(f32, @floatFromInt(a.a)) * inv_t + @as(f32, @floatFromInt(b.a)) * t_clamped), ); } /// Check if a point is inside the rounded corner area of a rectangle fn isInsideRoundedCorner(x: u32, y: u32, w: u32, h: u32, radius: u8) bool { const r = @as(u32, radius); // Check if in corner region const in_left_corner = x < r; const in_right_corner = x >= w - r; const in_top_corner = y < r; const in_bottom_corner = y >= h - r; // If not in any corner region, definitely inside if (!in_left_corner and !in_right_corner and !in_top_corner and !in_bottom_corner) { return true; } // Check each corner if (in_left_corner and in_top_corner) { // Top-left corner const dx = @as(f32, @floatFromInt(r - x)); const dy = @as(f32, @floatFromInt(r - y)); return dx * dx + dy * dy <= @as(f32, @floatFromInt(r * r)); } if (in_right_corner and in_top_corner) { // Top-right corner const dx = @as(f32, @floatFromInt(x - (w - r - 1))); const dy = @as(f32, @floatFromInt(r - y)); return dx * dx + dy * dy <= @as(f32, @floatFromInt(r * r)); } if (in_left_corner and in_bottom_corner) { // Bottom-left corner const dx = @as(f32, @floatFromInt(r - x)); const dy = @as(f32, @floatFromInt(y - (h - r - 1))); return dx * dx + dy * dy <= @as(f32, @floatFromInt(r * r)); } if (in_right_corner and in_bottom_corner) { // Bottom-right corner const dx = @as(f32, @floatFromInt(x - (w - r - 1))); const dy = @as(f32, @floatFromInt(y - (h - r - 1))); return dx * dx + dy * dy <= @as(f32, @floatFromInt(r * r)); } // In an edge region but not a corner return true; } // ============================================================================= // Tests // ============================================================================= test "SoftwareRenderer basic" { var fb = try Framebuffer.init(std.testing.allocator, 100, 100); defer fb.deinit(); var renderer = SoftwareRenderer.init(&fb); renderer.clear(Color.black); renderer.execute(Command.rect(10, 10, 20, 20, Color.red)); const pixel = fb.getPixel(15, 15); try std.testing.expect(pixel != null); try std.testing.expectEqual(Color.red.toABGR(), pixel.?); } test "SoftwareRenderer clipping" { var fb = try Framebuffer.init(std.testing.allocator, 100, 100); defer fb.deinit(); var renderer = SoftwareRenderer.init(&fb); renderer.clear(Color.black); // Set clip to 50x50 renderer.execute(Command.clip(0, 0, 50, 50)); // Draw rect that extends beyond clip renderer.execute(Command.rect(40, 40, 30, 30, Color.red)); renderer.execute(Command.clipEnd()); // Check inside clip (should be red) const inside = fb.getPixel(45, 45); try std.testing.expect(inside != null); try std.testing.expectEqual(Color.red.toABGR(), inside.?); // Check outside clip (should be black) const outside = fb.getPixel(55, 55); try std.testing.expect(outside != null); try std.testing.expectEqual(Color.black.toABGR(), outside.?); }