diff --git a/src/Canvas.zig b/src/Canvas.zig index 08f19f9..5b49a76 100644 --- a/src/Canvas.zig +++ b/src/Canvas.zig @@ -142,9 +142,6 @@ pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: changed |= next.x != vis.x or next.y != vis.y or next.w != vis.w or next.h != vis.h; } self._visible_rect = next; - if (changed) { - std.debug.print("Visible Image Rect: {{ x: {}, y: {}, w: {}, h: {} }}\n", .{ next.x, next.y, next.w, next.h }); - } if (changed or self.texture == null) { return true; } diff --git a/src/render/CpuRenderEngine.zig b/src/render/CpuRenderEngine.zig index 3d14df5..97e11fe 100644 --- a/src/render/CpuRenderEngine.zig +++ b/src/render/CpuRenderEngine.zig @@ -3,8 +3,8 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const RenderEngine = @import("RenderEngine.zig").RenderEngine; const Document = @import("../models/Document.zig"); -const shape = @import("../models/shape/shape.zig"); const basic_models = @import("../models/basic_models.zig"); +const cpu_draw = @import("cpu/draw.zig"); const ImageSize = basic_models.ImageSize; const ImageRect = basic_models.ImageRect; const Allocator = std.mem.Allocator; @@ -174,94 +174,15 @@ pub fn renderEngine(self: *CpuRenderEngine) RenderEngine { return .{ .cpu = self }; } -/// Растеризует документ: фон + контуры AABB всех объектов по getLocalBounds и position. +/// Растеризует документ: фон + рекурсивная отрисовка фигур через конвейер (трансформ, прозрачность, наложение). pub fn renderDocument(self: *CpuRenderEngine, document: *const Document, canvas_size: ImageSize, visible_rect: ImageRect) !?dvui.Texture { const width = visible_rect.w; const height = visible_rect.h; const pixels = try self._allocator.alloc(Color.PMA, @as(usize, width) * height); defer self._allocator.free(pixels); - const scale_x: f32 = if (document.size.width > 0) @as(f32, @floatFromInt(canvas_size.w)) / document.size.width else 0; - const scale_y: f32 = if (document.size.height > 0) @as(f32, @floatFromInt(canvas_size.h)) / document.size.height else 0; - - // Фон for (pixels) |*p| p.* = .{ .r = 255, .g = 255, .b = 255, .a = 255 }; - - const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }; - for (document.objects.items) |*obj| { - const local_bounds = shape.getLocalBounds(obj) catch continue; - const pos = if (obj.getProperty(.position)) |p| p.position else basic_models.Point2{ .x = 0, .y = 0 }; - const world_x = local_bounds.x + pos.x; - const world_y = local_bounds.y + pos.y; - const px_min_x: i32 = @intFromFloat(world_x * scale_x); - const px_min_y: i32 = @intFromFloat(world_y * scale_y); - const px_max_x: i32 = @intFromFloat((world_x + local_bounds.w) * scale_x); - const px_max_y: i32 = @intFromFloat((world_y + local_bounds.h) * scale_y); - - const stroke = if (obj.getProperty(.stroke_rgba)) |s| rgbaToPma(s.stroke_rgba) else default_stroke; - drawRectOutline(pixels, width, height, visible_rect, px_min_x, px_min_y, px_max_x, px_max_y, stroke); - } + cpu_draw.drawDocument(pixels, width, height, visible_rect, document, canvas_size); return try dvui.textureCreate(pixels, width, height, .nearest); } - -fn rgbaToPma(rgba: u32) Color.PMA { - const r: u8 = @intCast((rgba >> 0) & 0xFF); - const g: u8 = @intCast((rgba >> 8) & 0xFF); - const b: u8 = @intCast((rgba >> 16) & 0xFF); - const a: u8 = @intCast((rgba >> 24) & 0xFF); - if (a == 0) return .{ .r = 0, .g = 0, .b = 0, .a = 0 }; - const af: f32 = @as(f32, @floatFromInt(a)) / 255.0; - return .{ - .r = @intFromFloat(@as(f32, @floatFromInt(r)) * af), - .g = @intFromFloat(@as(f32, @floatFromInt(g)) * af), - .b = @intFromFloat(@as(f32, @floatFromInt(b)) * af), - .a = a, - }; -} - -fn drawRectOutline( - pixels: []Color.PMA, - buf_width: u32, - buf_height: u32, - visible_rect: ImageRect, - px_min_x: i32, - px_min_y: i32, - px_max_x: i32, - px_max_y: i32, - color: Color.PMA, -) void { - _ = buf_height; - const vx: i32 = @intCast(visible_rect.x); - const vy: i32 = @intCast(visible_rect.y); - const vw: i32 = @intCast(visible_rect.w); - const vh: i32 = @intCast(visible_rect.h); - - const x0 = std.math.clamp(px_min_x, vx, vx + vw - 1); - const x1 = std.math.clamp(px_max_x, vx, vx + vw - 1); - const y0 = std.math.clamp(px_min_y, vy, vy + vh - 1); - const y1 = std.math.clamp(px_max_y, vy, vy + vh - 1); - - const y_edges = [_]i32{ y0, y1 }; - for (y_edges) |cy| { - if (cy < vy or cy >= vy + vh) continue; - const by: u32 = @intCast(cy - vy); - var cx = x0; - while (cx <= x1) : (cx += 1) { - if (cx < vx or cx >= vx + vw) continue; - const bx: u32 = @intCast(cx - vx); - pixels[by * buf_width + bx] = color; - } - } - const x_edges = [_]i32{ x0, x1 }; - for (x_edges) |cx| { - if (cx < vx or cx >= vx + vw) continue; - const bx: u32 = @intCast(cx - vx); - var cy = y0; - while (cy <= y1) : (cy += 1) { - if (cy < vy or cy >= vy + vh) continue; - const by: u32 = @intCast(cy - vy); - pixels[by * buf_width + bx] = color; - } - } -} diff --git a/src/render/cpu/arc.zig b/src/render/cpu/arc.zig new file mode 100644 index 0000000..e15a25b --- /dev/null +++ b/src/render/cpu/arc.zig @@ -0,0 +1,8 @@ +const Document = @import("../../models/Document.zig"); +const pipeline = @import("pipeline.zig"); +const DrawContext = pipeline.DrawContext; + +const Object = Document.Object; + +/// Рисует дугу (заглушка: пока не реализовано). +pub fn draw(_: *DrawContext, _: *const Object) void {} diff --git a/src/render/cpu/broken.zig b/src/render/cpu/broken.zig new file mode 100644 index 0000000..174e9f3 --- /dev/null +++ b/src/render/cpu/broken.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const Document = @import("../../models/Document.zig"); +const pipeline = @import("pipeline.zig"); +const line = @import("line.zig"); +const DrawContext = pipeline.DrawContext; +const Color = @import("dvui").Color; + +const Object = Document.Object; +const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }; + +/// Рисует ломаную по точкам в локальных координатах. Обводка по stroke_rgba. +pub fn draw(ctx: *DrawContext, obj: *const Object) void { + const p_prop = obj.getProperty(.points) orelse return; + const pts = p_prop.points.items; + if (pts.len < 2) return; + const stroke = if (obj.getProperty(.stroke_rgba)) |s| pipeline.rgbaToPma(s.stroke_rgba) else default_stroke; + + var i: usize = 0; + while (i + 1 < pts.len) : (i += 1) { + line.drawLine(ctx, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, stroke); + } +} diff --git a/src/render/cpu/draw.zig b/src/render/cpu/draw.zig new file mode 100644 index 0000000..93e4351 --- /dev/null +++ b/src/render/cpu/draw.zig @@ -0,0 +1,76 @@ +const std = @import("std"); +const Document = @import("../../models/Document.zig"); +const pipeline = @import("pipeline.zig"); +const line = @import("line.zig"); +const ellipse = @import("ellipse.zig"); +const broken = @import("broken.zig"); +const arc = @import("arc.zig"); +const basic_models = @import("../../models/basic_models.zig"); +const ImageRect = basic_models.ImageRect; +const ImageSize = basic_models.ImageSize; + +const Object = Document.Object; +const DrawContext = pipeline.DrawContext; +const Transform = pipeline.Transform; + +fn getLocalTransform(obj: *const Object) Transform { + const pos = if (obj.getProperty(.position)) |p| p.position else basic_models.Point2{ .x = 0, .y = 0 }; + const angle = if (obj.getProperty(.angle)) |p| p.angle else 0; + const scale = if (obj.getProperty(.scale)) |p| p.scale else basic_models.Scale2{ .scale_x = 1, .scale_y = 1 }; + const opacity = if (obj.getProperty(.opacity)) |p| p.opacity else 1.0; + return .{ + .position = pos, + .angle = angle, + .scale = scale, + .opacity = opacity, + }; +} + +fn isVisible(obj: *const Object) bool { + return if (obj.getProperty(.visible)) |p| p.visible else true; +} + +fn drawObject(ctx: *DrawContext, obj: *const Object, parent_transform: Transform) void { + if (!isVisible(obj)) return; + const local = getLocalTransform(obj); + const world = Transform.compose(parent_transform, local); + ctx.setTransform(world); + + switch (obj.shape) { + .line => line.draw(ctx, obj), + .ellipse => ellipse.draw(ctx, obj), + .broken => broken.draw(ctx, obj), + .arc => arc.draw(ctx, obj), + } + + for (obj.children.items) |*child| { + drawObject(ctx, child, world); + } +} + +/// Рекурсивно рисует документ в буфер: сначала корневые объекты по порядку, затем их потомков (каждый следующий поверх предыдущего). +pub fn drawDocument( + pixels: []@import("dvui").Color.PMA, + buf_width: u32, + buf_height: u32, + visible_rect: ImageRect, + document: *const Document, + canvas_size: ImageSize, +) void { + const scale_x: f32 = if (document.size.width > 0) @as(f32, @floatFromInt(canvas_size.w)) / document.size.width else 0; + const scale_y: f32 = if (document.size.height > 0) @as(f32, @floatFromInt(canvas_size.h)) / document.size.height else 0; + + var ctx = DrawContext{ + .pixels = pixels, + .buf_width = buf_width, + .buf_height = buf_height, + .visible_rect = visible_rect, + .scale_x = scale_x, + .scale_y = scale_y, + }; + + const identity = Transform{}; + for (document.objects.items) |*obj| { + drawObject(&ctx, obj, identity); + } +} diff --git a/src/render/cpu/ellipse.zig b/src/render/cpu/ellipse.zig new file mode 100644 index 0000000..937a535 --- /dev/null +++ b/src/render/cpu/ellipse.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const Document = @import("../../models/Document.zig"); +const pipeline = @import("pipeline.zig"); +const DrawContext = pipeline.DrawContext; +const Color = @import("dvui").Color; + +const Object = Document.Object; +const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }; + +/// Рисует эллипс: центр (0,0), полуоси radii. Растеризация в координатах буфера (обводка кольцом). +pub fn draw(ctx: *DrawContext, obj: *const Object) void { + const r_prop = obj.getProperty(.radii) orelse return; + const rx = r_prop.radii.x; + const ry = r_prop.radii.y; + if (rx <= 0 or ry <= 0) return; + const stroke = if (obj.getProperty(.stroke_rgba)) |s| pipeline.rgbaToPma(s.stroke_rgba) else default_stroke; + + const corners = [_]struct { x: f32, y: f32 }{ + .{ .x = -rx, .y = -ry }, + .{ .x = rx, .y = -ry }, + .{ .x = rx, .y = ry }, + .{ .x = -rx, .y = ry }, + }; + const w0 = ctx.localToWorld(corners[0].x, corners[0].y); + const b0 = ctx.worldToBufferF(w0.x, w0.y); + var min_bx: f32 = b0.x; + var min_by: f32 = b0.y; + var max_bx: f32 = b0.x; + var max_by: f32 = b0.y; + for (corners[1..]) |c| { + const w = ctx.localToWorld(c.x, c.y); + const b = ctx.worldToBufferF(w.x, w.y); + min_bx = @min(min_bx, b.x); + min_by = @min(min_by, b.y); + max_bx = @max(max_bx, b.x); + max_by = @max(max_by, b.y); + } + const buf_w: i32 = @intCast(ctx.buf_width); + const buf_h: i32 = @intCast(ctx.buf_height); + const x0: i32 = @max(0, @as(i32, @intFromFloat(std.math.floor(min_bx)))); + const y0: i32 = @max(0, @as(i32, @intFromFloat(std.math.floor(min_by)))); + const x1: i32 = @min(buf_w, @as(i32, @intFromFloat(std.math.ceil(max_bx))) + 1); + const y1: i32 = @min(buf_h, @as(i32, @intFromFloat(std.math.ceil(max_by))) + 1); + + var by: i32 = y0; + while (by < y1) : (by += 1) { + var bx: i32 = x0; + while (bx < x1) : (bx += 1) { + const w = ctx.bufferToWorld(@as(f32, @floatFromInt(bx)) + 0.5, @as(f32, @floatFromInt(by)) + 0.5); + const loc = ctx.worldToLocal(w.x, w.y); + const nx = loc.x / rx; + const ny = loc.y / ry; + const d = nx * nx + ny * ny; + if (d >= 0.9 and d <= 1.1) { + ctx.blendPixelAtBuffer(@intCast(bx), @intCast(by), stroke); + } + } + } +} diff --git a/src/render/cpu/line.zig b/src/render/cpu/line.zig new file mode 100644 index 0000000..0d9dc4c --- /dev/null +++ b/src/render/cpu/line.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const Document = @import("../../models/Document.zig"); +const pipeline = @import("pipeline.zig"); +const DrawContext = pipeline.DrawContext; +const Color = @import("dvui").Color; + +const Object = Document.Object; +const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }; + +/// Рисует линию в локальных координатах: от (0,0) до end_point. Растеризация в координатах буфера (без пробелов при зуме). +pub fn draw(ctx: *DrawContext, obj: *const Object) void { + const ep_prop = obj.getProperty(.end_point) orelse return; + const end_x = ep_prop.end_point.x; + const end_y = ep_prop.end_point.y; + const stroke = if (obj.getProperty(.stroke_rgba)) |s| pipeline.rgbaToPma(s.stroke_rgba) else default_stroke; + + drawLine(ctx, 0, 0, end_x, end_y, stroke); +} + +/// Линия по локальным координатам фигуры: переводит концы в буфер и рисует в пикселях буфера. +pub fn drawLine(ctx: *DrawContext, x0: f32, y0: f32, x1: f32, y1: f32, color: Color.PMA) void { + const w0 = ctx.localToWorld(x0, y0); + const w1 = ctx.localToWorld(x1, y1); + const b0 = ctx.worldToBufferF(w0.x, w0.y); + const b1 = ctx.worldToBufferF(w1.x, w1.y); + const bx0: i32 = @intFromFloat(std.math.round(b0.x)); + const by0: i32 = @intFromFloat(std.math.round(b0.y)); + const bx1: i32 = @intFromFloat(std.math.round(b1.x)); + const by1: i32 = @intFromFloat(std.math.round(b1.y)); + drawLineInBuffer(ctx, bx0, by0, bx1, by1, color); +} + +/// Брезенхем в координатах буфера; пиксели вне [0, buf_width) x [0, buf_height) пропускаются. +pub fn drawLineInBuffer(ctx: *DrawContext, bx0: i32, by0: i32, bx1: i32, by1: i32, color: Color.PMA) void { + const bw: i32 = @intCast(ctx.buf_width); + const bh: i32 = @intCast(ctx.buf_height); + const dx: i32 = @intCast(@abs(bx1 - bx0)); + const dy: i32 = -@as(i32, @intCast(@abs(by1 - by0))); + const sx: i32 = if (bx0 < bx1) 1 else -1; + const sy: i32 = if (by0 < by1) 1 else -1; + var err = dx + dy; + var x = bx0; + var y = by0; + + while (true) { + if (x >= 0 and x < bw and y >= 0 and y < bh) { + ctx.blendPixelAtBuffer(@intCast(x), @intCast(y), color); + } + if (x == bx1 and y == by1) break; + const e2 = 2 * err; + if (e2 >= dy) { + err += dy; + x += sx; + } + if (e2 <= dx) { + err += dx; + y += sy; + } + } +} diff --git a/src/render/cpu/pipeline.zig b/src/render/cpu/pipeline.zig new file mode 100644 index 0000000..99eb17c --- /dev/null +++ b/src/render/cpu/pipeline.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const dvui = @import("dvui"); +const basic_models = @import("../../models/basic_models.zig"); +const Point2 = basic_models.Point2; +const Scale2 = basic_models.Scale2; +const ImageRect = basic_models.ImageRect; +const Color = dvui.Color; + +/// Трансформ объекта в мировых координатах документа (позиция, угол, масштаб, непрозрачность). +pub const Transform = struct { + position: Point2 = .{}, + angle: f32 = 0, + scale: Scale2 = .{}, + opacity: f32 = 1.0, + + /// Композиция: мировой трансформ = parent * local (local в пространстве родителя). + pub fn compose(parent: Transform, local: Transform) Transform { + const cos_a = std.math.cos(parent.angle); + const sin_a = std.math.sin(parent.angle); + const sx = parent.scale.scale_x * local.scale.scale_x; + const sy = parent.scale.scale_y * local.scale.scale_y; + const local_px = local.position.x * parent.scale.scale_x; + const local_py = local.position.y * parent.scale.scale_y; + const rx = cos_a * local_px - sin_a * local_py; + const ry = sin_a * local_px + cos_a * local_py; + return .{ + .position = .{ + .x = parent.position.x + rx, + .y = parent.position.y + ry, + }, + .angle = parent.angle + local.angle, + .scale = .{ .scale_x = sx, .scale_y = sy }, + .opacity = parent.opacity * local.opacity, + }; + } +}; + +/// Единый конвейер: принимает позицию в локальных координатах фигуры и цвет пикселя, +/// применяет трансформ (вращение, масштаб, перенос) и непрозрачность, накладывает на буфер. +pub const DrawContext = struct { + pixels: []Color.PMA, + buf_width: u32, + buf_height: u32, + visible_rect: ImageRect, + scale_x: f32, + scale_y: f32, + transform: Transform = .{}, + + pub fn setTransform(self: *DrawContext, t: Transform) void { + self.transform = t; + } + + /// Локальные координаты фигуры -> мировые (документ). + pub fn localToWorld(self: *const DrawContext, local_x: f32, local_y: f32) Point2 { + const t = &self.transform; + const cos_a = std.math.cos(t.angle); + const sin_a = std.math.sin(t.angle); + return .{ + .x = t.position.x + (local_x * t.scale.scale_x) * cos_a - (local_y * t.scale.scale_y) * sin_a, + .y = t.position.y + (local_x * t.scale.scale_x) * sin_a + (local_y * t.scale.scale_y) * cos_a, + }; + } + + /// Мировые координаты документа -> координаты в буфере (могут быть вне [0, buf_w] x [0, buf_h]). + pub fn worldToBufferF(self: *const DrawContext, wx: f32, wy: f32) Point2 { + const canvas_x = wx * self.scale_x; + const canvas_y = wy * self.scale_y; + const vx = @as(f32, @floatFromInt(self.visible_rect.x)); + const vy = @as(f32, @floatFromInt(self.visible_rect.y)); + return .{ + .x = canvas_x - vx, + .y = canvas_y - vy, + }; + } + + /// Координаты буфера -> мировые (документ). scale_x/scale_y не должны быть 0. + pub fn bufferToWorld(self: *const DrawContext, buf_x: f32, buf_y: f32) Point2 { + const vx = @as(f32, @floatFromInt(self.visible_rect.x)); + const vy = @as(f32, @floatFromInt(self.visible_rect.y)); + const canvas_x = buf_x + vx; + const canvas_y = buf_y + vy; + const sx = if (self.scale_x != 0) self.scale_x else 1.0; + const sy = if (self.scale_y != 0) self.scale_y else 1.0; + return .{ + .x = canvas_x / sx, + .y = canvas_y / sy, + }; + } + + /// Мировые координаты -> локальные фигуры (обратное к localToWorld). + pub fn worldToLocal(self: *const DrawContext, wx: f32, wy: f32) Point2 { + const t = &self.transform; + const dx = wx - t.position.x; + const dy = wy - t.position.y; + const cos_a = std.math.cos(-t.angle); + const sin_a = std.math.sin(-t.angle); + const sx = if (t.scale.scale_x != 0) t.scale.scale_x else 1.0; + const sy = if (t.scale.scale_y != 0) t.scale.scale_y else 1.0; + return .{ + .x = (dx * cos_a - dy * sin_a) / sx, + .y = (dx * sin_a + dy * cos_a) / sy, + }; + } + + /// Смешивает цвет в пикселе буфера (bx, by) с учётом opacity текущего трансформа. Bounds не проверяются. + pub fn blendPixelAtBuffer(self: *DrawContext, bx: u32, by: u32, color: Color.PMA) void { + if (bx >= self.buf_width or by >= self.buf_height) return; + const t = &self.transform; + const idx = by * self.buf_width + bx; + const dst = &self.pixels[idx]; + const a = @as(f32, @floatFromInt(color.a)) / 255.0 * t.opacity; + const src_r = @as(f32, @floatFromInt(color.r)) * a; + const src_g = @as(f32, @floatFromInt(color.g)) * a; + const src_b = @as(f32, @floatFromInt(color.b)) * a; + const inv_a = 1.0 - a; + dst.r = @intFromFloat(std.math.clamp(src_r + inv_a * @as(f32, @floatFromInt(dst.r)), 0, 255)); + dst.g = @intFromFloat(std.math.clamp(src_g + inv_a * @as(f32, @floatFromInt(dst.g)), 0, 255)); + dst.b = @intFromFloat(std.math.clamp(src_b + inv_a * @as(f32, @floatFromInt(dst.b)), 0, 255)); + dst.a = @intFromFloat(std.math.clamp(a * 255 + inv_a * @as(f32, @floatFromInt(dst.a)), 0, 255)); + } + + /// Записывает пиксель в локальных координатах фигуры с учётом трансформа и прозрачности (PMA blend). + pub fn blendPixelLocal(self: *DrawContext, local_x: f32, local_y: f32, color: Color.PMA) void { + const w = self.localToWorld(local_x, local_y); + const b = self.worldToBufferF(w.x, w.y); + const bx: i32 = @intFromFloat(b.x); + const by: i32 = @intFromFloat(b.y); + const vw = @as(i32, @intCast(self.visible_rect.w)); + const vh = @as(i32, @intCast(self.visible_rect.h)); + if (bx < 0 or bx >= vw or by < 0 or by >= vh) return; + self.blendPixelAtBuffer(@intCast(bx), @intCast(by), color); + } +}; + +pub fn rgbaToPma(rgba: u32) Color.PMA { + const r: u8 = @intCast((rgba >> 0) & 0xFF); + const g: u8 = @intCast((rgba >> 8) & 0xFF); + const b: u8 = @intCast((rgba >> 16) & 0xFF); + const a: u8 = @intCast((rgba >> 24) & 0xFF); + if (a == 0) return .{ .r = 0, .g = 0, .b = 0, .a = 0 }; + const af: f32 = @as(f32, @floatFromInt(a)) / 255.0; + return .{ + .r = @intFromFloat(@as(f32, @floatFromInt(r)) * af), + .g = @intFromFloat(@as(f32, @floatFromInt(g)) * af), + .b = @intFromFloat(@as(f32, @floatFromInt(b)) * af), + .a = a, + }; +}