- Add FilledCircleCommand in command.zig - Implement drawFilledCircle using Midpoint Circle Algorithm (Bresenham) - Integer-only arithmetic (efficient, no sqrt/trig) - Scanline filling with horizontal symmetry - Add commandBounds for dirty region optimization - Update CHANGELOG with v0.23.0 (FilledTriangle) and v0.24.0 (FilledCircle) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
835 lines
32 KiB
Zig
835 lines
32 KiB
Zig
//! 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.?);
|
|
}
|