350 lines
16 KiB
Zig
350 lines
16 KiB
Zig
const std = @import("std");
|
||
const dvui = @import("dvui");
|
||
const basic_models = @import("../../models/basic_models.zig");
|
||
const Document = @import("../../models/Document.zig");
|
||
const Point2_f = basic_models.Point2_f;
|
||
const Point2_i = basic_models.Point2_i;
|
||
const Scale2_f = basic_models.Scale2_f;
|
||
const Rect_i = basic_models.Rect_i;
|
||
const Color = dvui.Color;
|
||
|
||
/// Трансформ объекта: позиция, угол, масштаб, непрозрачность.
|
||
pub const Transform = struct {
|
||
position: Point2_f = .{},
|
||
angle: f32 = 0,
|
||
scale: Scale2_f = .{},
|
||
opacity: f32 = 1.0,
|
||
|
||
pub fn init(obj: *const Document.Object) Transform {
|
||
const pos = obj.getProperty(.position) orelse Point2_f{ .x = 0, .y = 0 };
|
||
const angle = obj.getProperty(.angle) orelse 0;
|
||
const scale = obj.getProperty(.scale) orelse Scale2_f{ .scale_x = 1, .scale_y = 1 };
|
||
const opacity = obj.getProperty(.opacity) orelse 1.0;
|
||
return .{
|
||
.position = pos,
|
||
.angle = angle,
|
||
.scale = scale,
|
||
.opacity = opacity,
|
||
};
|
||
}
|
||
|
||
/// Композиция: world = parent * 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 fn worldToLocalTransform(t: Transform, wx: f32, wy: f32) Point2_f {
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// Конвейер отрисовки: локальные координаты -> трансформ -> буфер.
|
||
pub const DrawContext = struct {
|
||
pixels: []Color.PMA,
|
||
buf_width: u32,
|
||
buf_height: u32,
|
||
visible_rect: Rect_i,
|
||
scale_x: f32,
|
||
scale_y: f32,
|
||
transform: Transform = .{},
|
||
/// Если true, blendPixelAtBuffer перезаписывает пиксель без бленда
|
||
replace_mode: bool = false,
|
||
_fill_canvas: ?*FillCanvas = null,
|
||
|
||
pub fn setTransform(self: *DrawContext, t: Transform) void {
|
||
self.transform = t;
|
||
}
|
||
|
||
/// Локальные -> мировые.
|
||
pub fn localToWorld(self: *const DrawContext, local_x: f32, local_y: f32) Point2_f {
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// Мировые -> буфер (float).
|
||
pub fn worldToBufferF(self: *const DrawContext, wx: f32, wy: f32) Point2_f {
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// Мировые -> буфер (целые).
|
||
pub fn worldToBuffer(self: *const DrawContext, wx: f32, wy: f32) Point2_i {
|
||
const b = self.worldToBufferF(wx, wy);
|
||
return .{
|
||
.x = @intFromFloat(std.math.round(b.x)),
|
||
.y = @intFromFloat(std.math.round(b.y)),
|
||
};
|
||
}
|
||
|
||
/// Буфер -> мировые.
|
||
pub fn bufferToWorld(self: *const DrawContext, buf_x: f32, buf_y: f32) Point2_f {
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// Мировые -> локальные.
|
||
pub fn worldToLocal(self: *const DrawContext, wx: f32, wy: f32) Point2_f {
|
||
return worldToLocalTransform(self.transform, wx, wy);
|
||
}
|
||
|
||
/// Смешивает цвет в пикселе буфера с учётом opacity трансформа. В replace_mode просто перезаписывает пиксель.
|
||
/// Если активен fill canvas, каждый записанный пиксель помечается как граница для заливки.
|
||
pub fn blendPixelAtBuffer(self: *DrawContext, bx_i32: i32, by_i32: i32, color: Color.PMA) void {
|
||
if (self._fill_canvas) |fc| fc.setBorder(bx_i32, by_i32);
|
||
|
||
if (bx_i32 < 0 or by_i32 < 0 or bx_i32 >= self.buf_width or by_i32 >= self.buf_height) return;
|
||
const bx: u32 = @intCast(bx_i32);
|
||
const by: u32 = @intCast(by_i32);
|
||
const idx = by * self.buf_width + bx;
|
||
const dst = &self.pixels[idx];
|
||
if (self.replace_mode) {
|
||
dst.* = color;
|
||
return;
|
||
}
|
||
const t = &self.transform;
|
||
const a = @as(f32, @floatFromInt(color.a)) / 255.0 * t.opacity;
|
||
const src_r = @as(f32, @floatFromInt(color.r)) * t.opacity;
|
||
const src_g = @as(f32, @floatFromInt(color.g)) * t.opacity;
|
||
const src_b = @as(f32, @floatFromInt(color.b)) * t.opacity;
|
||
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));
|
||
}
|
||
|
||
/// Накладывает буфер другого контекста на этот с заданной прозрачностью (один бленд на пиксель). Размеры буферов должны совпадать.
|
||
pub fn compositeDrawerContext(self: *DrawContext, other: *const DrawContext, opacity: f32) void {
|
||
if (self.buf_width != other.buf_width or self.buf_height != other.buf_height) return;
|
||
const n = self.buf_width * self.buf_height;
|
||
for (0..n) |i| {
|
||
const src = other.pixels[i];
|
||
if (src.a == 0) continue;
|
||
const dst = &self.pixels[i];
|
||
const a = @as(f32, @floatFromInt(src.a)) / 255.0 * opacity;
|
||
const src_r = @as(f32, @floatFromInt(src.r)) * opacity;
|
||
const src_g = @as(f32, @floatFromInt(src.g)) * opacity;
|
||
const src_b = @as(f32, @floatFromInt(src.b)) * opacity;
|
||
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).
|
||
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(bx, by, color);
|
||
}
|
||
|
||
/// Начинает сбор границ для заливки: создаёт FillCanvas и при последующих вызовах blendPixelAtBuffer помечает пиксели как границу.
|
||
pub fn startFill(self: *DrawContext, allocator: std.mem.Allocator) !void {
|
||
const fc = try FillCanvas.init(allocator, self.buf_width, self.buf_height);
|
||
const ptr = try allocator.create(FillCanvas);
|
||
ptr.* = fc;
|
||
self._fill_canvas = ptr;
|
||
}
|
||
|
||
/// Рисует заливку по собранным границам цветом color, освобождает FillCanvas и сбрасывает режим.
|
||
pub fn stopFill(self: *DrawContext, allocator: std.mem.Allocator, color: Color.PMA) void {
|
||
const fc = self._fill_canvas orelse return;
|
||
self._fill_canvas = null;
|
||
fc.fillColor(self, allocator, color);
|
||
fc.deinit();
|
||
allocator.destroy(fc);
|
||
}
|
||
};
|
||
|
||
/// Конвертирует u32 0xRRGGBBAA в Color.PMA.
|
||
pub fn rgbaToPma(rgba: u32) Color.PMA {
|
||
const r: u8 = @intCast((rgba >> 24) & 0xFF);
|
||
const g: u8 = @intCast((rgba >> 16) & 0xFF);
|
||
const b: u8 = @intCast((rgba >> 8) & 0xFF);
|
||
const a: u8 = @intCast((rgba >> 0) & 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,
|
||
};
|
||
}
|
||
|
||
/// Контекст для заполнения фигур цветом. Границы хранятся в set — по x и y можно добавлять произвольные точки.
|
||
const FillCanvas = struct {
|
||
/// Множество пикселей границы (x, y) — без ограничения по размеру буфера.
|
||
border_set: std.AutoHashMap(Point2_i, void),
|
||
buf_width: u32,
|
||
buf_height: u32,
|
||
|
||
pub fn init(allocator: std.mem.Allocator, width: u32, height: u32) !FillCanvas {
|
||
const border_set = std.AutoHashMap(Point2_i, void).init(allocator);
|
||
return .{
|
||
.border_set = border_set,
|
||
.buf_width = width,
|
||
.buf_height = height,
|
||
};
|
||
}
|
||
|
||
pub fn deinit(self: *FillCanvas) void {
|
||
self.border_set.deinit();
|
||
}
|
||
|
||
/// Добавляет точку границы; координаты x, y могут быть любыми (условно бесконечное поле).
|
||
pub fn setBorder(self: *FillCanvas, x: i32, y: i32) void {
|
||
self.border_set.put(.{ .x = x, .y = y }, {}) catch {};
|
||
}
|
||
|
||
/// Заливка четырёхсвязным стековым алгоритмом от первой найденной внутренней точки.
|
||
pub fn fillColor(self: *FillCanvas, draw_ctx: *DrawContext, allocator: std.mem.Allocator, color: Color.PMA) void {
|
||
const n = self.border_set.count();
|
||
if (n == 0) return;
|
||
|
||
const buf_w_i: i32 = @intCast(self.buf_width);
|
||
const buf_h_i: i32 = @intCast(self.buf_height);
|
||
|
||
// Ключи один раз по (y, x) — по строкам x уже будут отсортированы.
|
||
var keys_buf = std.ArrayList(Point2_i).empty;
|
||
defer keys_buf.deinit(allocator);
|
||
keys_buf.ensureTotalCapacity(allocator, n) catch return;
|
||
var iter = self.border_set.keyIterator();
|
||
while (iter.next()) |k| {
|
||
keys_buf.appendAssumeCapacity(k.*);
|
||
}
|
||
std.mem.sort(Point2_i, keys_buf.items, {}, struct {
|
||
fn lessThan(_: void, a: Point2_i, b: Point2_i) bool {
|
||
if (a.y != b.y) return a.y < b.y;
|
||
return a.x < b.x;
|
||
}
|
||
}.lessThan);
|
||
|
||
// Семена: по строкам находим сегменты (пары x), пересекаем с окном буфера, берём середину сегмента.
|
||
var seeds = findFillSeeds(keys_buf.items, buf_w_i, buf_h_i, allocator) catch return;
|
||
defer seeds.deinit(allocator);
|
||
|
||
var stack = std.ArrayList(Point2_i).empty;
|
||
defer stack.deinit(allocator);
|
||
var filled = std.AutoHashMap(Point2_i, void).init(allocator);
|
||
defer filled.deinit();
|
||
|
||
for (seeds.items) |s| {
|
||
if (self.border_set.contains(s)) continue;
|
||
if (filled.contains(s)) continue;
|
||
stack.clearRetainingCapacity();
|
||
stack.append(allocator, s) catch return;
|
||
while (stack.pop()) |cell| {
|
||
if (self.border_set.contains(cell)) continue;
|
||
const gop = filled.getOrPut(cell) catch return;
|
||
if (gop.found_existing) continue;
|
||
|
||
if (cell.x >= 0 and cell.x < buf_w_i and cell.y >= 0 and cell.y < buf_h_i) {
|
||
draw_ctx.blendPixelAtBuffer(cell.x, cell.y, color);
|
||
}
|
||
if (cell.x > 0) stack.append(allocator, .{ .x = cell.x - 1, .y = cell.y }) catch return;
|
||
if (cell.x < buf_w_i - 1) stack.append(allocator, .{ .x = cell.x + 1, .y = cell.y }) catch return;
|
||
if (cell.y > 0) stack.append(allocator, .{ .x = cell.x, .y = cell.y - 1 }) catch return;
|
||
if (cell.y < buf_h_i - 1) stack.append(allocator, .{ .x = cell.x, .y = cell.y + 1 }) catch return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// По строкам: рёбра (подряд идущие x) → сегменты между ними. Семена — середины чётных сегментов (при чётном числе границ).
|
||
fn findFillSeeds(
|
||
keys: []const Point2_i,
|
||
buf_w_i: i32,
|
||
buf_h_i: i32,
|
||
allocator: std.mem.Allocator,
|
||
) !std.ArrayList(Point2_i) {
|
||
var list = std.ArrayList(Point2_i).empty;
|
||
errdefer list.deinit(allocator);
|
||
var segments = std.ArrayList(struct { left: i32, right: i32 }).empty;
|
||
defer segments.deinit(allocator);
|
||
|
||
var i: usize = 0;
|
||
while (i < keys.len) {
|
||
const y = keys[i].y;
|
||
const row_start = i;
|
||
while (i < keys.len and keys[i].y == y) : (i += 1) {}
|
||
const row = keys[row_start..i];
|
||
if (row.len < 2 or y < 0 or y >= buf_h_i) continue;
|
||
|
||
segments.clearRetainingCapacity();
|
||
var run_end_x: i32 = row[0].x;
|
||
for (row[1..]) |p| {
|
||
if (p.x != run_end_x + 1) {
|
||
try segments.append(allocator, .{ .left = run_end_x + 1, .right = p.x - 1 });
|
||
run_end_x = p.x;
|
||
} else {
|
||
run_end_x = p.x;
|
||
}
|
||
}
|
||
// Семена только при чётном числе границ
|
||
if ((segments.items.len + 1) % 2 != 0) continue;
|
||
|
||
for (segments.items, 0..) |seg, gi| {
|
||
if (gi % 2 != 0 or seg.left > seg.right) continue;
|
||
const left = @max(seg.left, 0);
|
||
const right = @min(seg.right, buf_w_i - 1);
|
||
if (left <= right) {
|
||
try list.append(allocator, .{ .x = left + @divTrunc(right - left, 2), .y = y });
|
||
}
|
||
}
|
||
}
|
||
return list;
|
||
}
|
||
};
|