Compare commits

...

5 Commits

7 changed files with 265 additions and 81 deletions

View File

@@ -8,7 +8,7 @@ pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{ const exe = b.addExecutable(.{
.name = "Zivro", .name = "Zivro",
.use_llvm = true, .use_llvm = optimize == .Debug,
.root_module = b.createModule(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,

View File

@@ -12,6 +12,8 @@ pub const OpenDocument = struct {
document: Document, document: Document,
cpu_render: CpuRenderEngine, cpu_render: CpuRenderEngine,
canvas: Canvas, canvas: Canvas,
/// Выбранный объект в дереве (указатель, не индекс).
selected_object: ?*Document.Object = null,
pub fn init(allocator: std.mem.Allocator, self: *OpenDocument) void { pub fn init(allocator: std.mem.Allocator, self: *OpenDocument) void {
const default_size = basic_models.Size_f{ .w = 800, .h = 600 }; const default_size = basic_models.Size_f{ .w = 800, .h = 600 };

View File

@@ -4,3 +4,4 @@ pub const line = dvui.entypo.line_graph;
pub const ellipse = dvui.entypo.circle; pub const ellipse = dvui.entypo.circle;
pub const arc = dvui.entypo.loop; pub const arc = dvui.entypo.loop;
pub const broken = dvui.entypo.flow_line; pub const broken = dvui.entypo.flow_line;
pub const trash = dvui.entypo.trash;

View File

@@ -36,3 +36,28 @@ pub fn addShape(self: *Document, parent: ?*Object, shape_kind: Object.ShapeKind)
try self.addObject(obj); try self.addObject(obj);
} }
} }
/// Удаляет объект из документа (из корня или из детей родителя). Возвращает true, если объект был найден и удалён.
pub fn removeObject(self: *Document, obj: *Object) bool {
for (self.objects.items, 0..) |*item, i| {
if (item == obj) {
var removed = self.objects.orderedRemove(i);
removed.deinit(self.allocator);
return true;
}
if (removeFromChildren(self.allocator, &item.children, obj)) return true;
}
return false;
}
fn removeFromChildren(allocator: std.mem.Allocator, children: *std.ArrayList(Object), obj: *Object) bool {
for (children.items, 0..) |*item, i| {
if (item == obj) {
var removed = children.orderedRemove(i);
removed.deinit(allocator);
return true;
}
if (removeFromChildren(allocator, &item.children, obj)) return true;
}
return false;
}

View File

@@ -7,7 +7,7 @@ const shape_mod = @import("shape.zig");
/// Свойства фигуры по умолчанию. /// Свойства фигуры по умолчанию.
pub const default_shape_properties = [_]Property{ pub const default_shape_properties = [_]Property{
.{ .data = .{ .end_point = .{ .x = 100, .y = 0 } } }, .{ .data = .{ .end_point = .{ .x = 100, .y = 200 } } },
}; };
/// Теги обязательных свойств. /// Теги обязательных свойств.

View File

@@ -3,6 +3,8 @@ const Document = @import("../../models/Document.zig");
const pipeline = @import("pipeline.zig"); const pipeline = @import("pipeline.zig");
const DrawContext = pipeline.DrawContext; const DrawContext = pipeline.DrawContext;
const Color = @import("dvui").Color; const Color = @import("dvui").Color;
const base_models = @import("../../models/basic_models.zig");
const Point2_i = base_models.Point2_i;
const Object = Document.Object; const Object = Document.Object;
const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }; const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 };
@@ -30,89 +32,157 @@ pub fn drawLine(ctx: *DrawContext, x0: f32, y0: f32, x1: f32, y1: f32, color: Co
drawLineInBuffer(ctx, b0.x, b0.y, b1.x, b1.y, color, thickness_px); drawLineInBuffer(ctx, b0.x, b0.y, b1.x, b1.y, color, thickness_px);
} }
/// Точка (px, py) лежит строго внутри круга с центром (cx, cy) и радиусом в квадрате r_sq (граница не включается). inline fn clip(p: f32, q: f32, t0: *f32, t1: *f32) bool {
fn insideCircle(px: i32, py: i32, cx: i32, cy: i32, r_sq: i32) bool { if (p == 0) {
const dx = px - cx; return q >= 0;
const dy = py - cy; }
return dx * dx + dy * dy < r_sq; const r = q / p;
if (p < 0) {
if (r > t1.*) return false;
if (r > t0.*) t0.* = r;
} else {
if (r < t0.*) return false;
if (r < t1.*) t1.* = r;
}
return true;
} }
/// Заливает круг в буфере (для скруглённых концов отрезка). /// LiangBarsky отсечение отрезка (x0,y0)-(x1,y1) прямоугольником [left,right]x[top,bottom].
fn fillCircleAtBuffer(ctx: *DrawContext, cx: i32, cy: i32, radius: i32, color: Color.PMA) void { /// Координаты концов модифицируются по месту. Возвращает false, если отрезок целиком вне прямоугольника.
const bw: i32 = @intCast(ctx.buf_width); fn liangBarskyClip(
const bh: i32 = @intCast(ctx.buf_height); x0: *i32,
const r_sq = radius * radius; y0: *i32,
var dy: i32 = -radius; x1: *i32,
while (dy <= radius) : (dy += 1) { y1: *i32,
var dx: i32 = -radius; left: i32,
while (dx <= radius) : (dx += 1) { top: i32,
const px = cx + dx; right: i32,
const py = cy + dy; bottom: i32,
if (!insideCircle(px, py, cx, cy, r_sq)) continue; ) bool {
if (px >= 0 and px < bw and py >= 0 and py < bh) { const fx0: f32 = @floatFromInt(x0.*);
ctx.blendPixelAtBuffer(@intCast(px), @intCast(py), color); const fy0: f32 = @floatFromInt(y0.*);
} const fx1: f32 = @floatFromInt(x1.*);
} const fy1: f32 = @floatFromInt(y1.*);
const dx = fx1 - fx0;
const dy = fy1 - fy0;
var t0: f32 = 0.0;
var t1: f32 = 1.0;
const fl: f32 = @floatFromInt(left);
const ft: f32 = @floatFromInt(top);
const fr: f32 = @floatFromInt(right);
const fb: f32 = @floatFromInt(bottom);
if (!clip(-dx, fx0 - fl, &t0, &t1)) return false; // x >= left
if (!clip(dx, fr - fx0, &t0, &t1)) return false; // x <= right
if (!clip(-dy, fy0 - ft, &t0, &t1)) return false; // y >= top
if (!clip(dy, fb - fy0, &t0, &t1)) return false; // y <= bottom
const nx0 = fx0 + dx * t0;
const ny0 = fy0 + dy * t0;
const nx1 = fx0 + dx * t1;
const ny1 = fy0 + dy * t1;
x0.* = @intFromFloat(std.math.round(nx0));
y0.* = @intFromFloat(std.math.round(ny0));
x1.* = @intFromFloat(std.math.round(nx1));
y1.* = @intFromFloat(std.math.round(ny1));
return true;
}
/// Отсекает отрезок буфером ctx (0..buf_width-1, 0..buf_height-1).
fn clipLineToBuffer(ctx: *DrawContext, a: *Point2_i, b: *Point2_i, thickness: i32) bool {
var x0 = a.x;
var y0 = a.y;
var x1 = b.x;
var y1 = b.y;
const left: i32 = -thickness;
const top: i32 = -thickness;
const right: i32 = @as(i32, @intCast(ctx.buf_width - 1)) + thickness;
const bottom: i32 = @as(i32, @intCast(ctx.buf_height - 1)) + thickness;
if (!liangBarskyClip(&x0, &y0, &x1, &y1, left, top, right, bottom)) {
return false;
} }
a.* = .{ .x = x0, .y = y0 };
b.* = .{ .x = x1, .y = y1 };
return true;
} }
fn drawLineInBuffer(ctx: *DrawContext, bx0: i32, by0: i32, bx1: i32, by1: i32, color: Color.PMA, thickness_px: u32) void { fn drawLineInBuffer(ctx: *DrawContext, bx0: i32, by0: i32, bx1: i32, by1: i32, color: Color.PMA, thickness_px: u32) void {
const bw = ctx.buf_width; // Коррекция толщины в зависимости от угла линии.
const bh = ctx.buf_height; var thickness_corrected: u32 = thickness_px;
const dx: i32 = @intCast(@abs(bx1 - bx0)); var use_vertical: bool = undefined;
const dy: i32 = @intCast(@abs(by1 - by0)); const dx_f: f32 = @floatFromInt(bx1 - bx0);
const sx: i32 = if (bx0 < bx1) 1 else -1; const dy_f: f32 = @floatFromInt(by1 - by0);
const sy: i32 = if (by0 < by1) 1 else -1; const len: f32 = @sqrt(dx_f * dx_f + dy_f * dy_f);
var err = dx - dy; if (len > 0) {
var x = bx0; const cos_theta = @abs(dx_f) / len;
var y = by0; const sin_theta = @abs(dy_f) / len;
const half: i32 = @intCast(thickness_px / 2); const desired: f32 = @floatFromInt(thickness_px);
const half_sq = half * half; const eps: f32 = 1e-3;
const thickness_i: i32 = @intCast(thickness_px);
// Брезенхем + штамп по доминирующей оси: горизонтальная линия — вертикальный штамп, вертикальная — горизонтальный. // Если будем рисовать «вертикальными» полосами (смещение по X),
const more_horizontal = dx >= dy; // перпендикулярное смещение на 1 пиксель X равно |sin(theta)|.
const vertical_based = desired / @max(sin_theta, eps);
// Если будем рисовать «горизонтальными» полосами (смещение по Y),
// перпендикулярное смещение на 1 пиксель Y равно |cos(theta)|.
const horizontal_based = desired / @max(cos_theta, eps);
// Предпочитаем тот вариант, где проекция больше (меньше разброс по пикселям).
use_vertical = sin_theta >= cos_theta;
const corrected_f = if (use_vertical) vertical_based else horizontal_based;
thickness_corrected = @max(@as(u32, 1), @as(u32, @intFromFloat(std.math.round(corrected_f))));
}
const half_thickness: i32 = @intCast(thickness_corrected / 2);
var p0 = Point2_i{ .x = bx0, .y = by0 };
var p1 = Point2_i{ .x = bx1, .y = by1 };
// Отсечение отрезка буфером. Если он целиком вне — рисовать нечего.
if (!clipLineToBuffer(ctx, &p0, &p1, @as(i32, @intCast(thickness_corrected)))) return;
var x0 = p0.x;
var y0 = p0.y;
const ex = p1.x;
const ey = p1.y;
const dx: i32 = @intCast(@abs(ex - x0));
const sx: i32 = if (x0 < ex) 1 else -1;
const dy_abs: i32 = @intCast(@abs(ey - y0));
const dy: i32 = -dy_abs;
const sy: i32 = if (y0 < ey) 1 else -1;
var err: i32 = dx + dy;
while (true) { while (true) {
const near_start = @abs(x - bx0) + @abs(y - by0) <= thickness_i; var thick: i32 = -half_thickness;
const near_end = @abs(x - bx1) + @abs(y - by1) <= thickness_i; while (thick <= half_thickness) {
const x = if (use_vertical) x0 + thick else x0;
if (more_horizontal) { const y = if (use_vertical) y0 else y0 + thick;
var yo: i32 = -half; if (x >= 0 and y >= 0) {
while (yo <= half) : (yo += 1) { ctx.blendPixelAtBuffer(@intCast(x), @intCast(y), color);
const py = y + yo;
if (near_start and insideCircle(x, py, bx0, by0, half_sq)) continue;
if (near_end and insideCircle(x, py, bx1, by1, half_sq)) continue;
if (x >= 0 and x < @as(i32, @intCast(bw)) and py >= 0 and py < @as(i32, @intCast(bh))) {
ctx.blendPixelAtBuffer(@intCast(x), @intCast(py), color);
}
}
} else {
var xo: i32 = -half;
while (xo <= half) : (xo += 1) {
const px = x + xo;
if (near_start and insideCircle(px, y, bx0, by0, half_sq)) continue;
if (near_end and insideCircle(px, y, bx1, by1, half_sq)) continue;
if (px >= 0 and px < @as(i32, @intCast(bw)) and y >= 0 and y < @as(i32, @intCast(bh))) {
ctx.blendPixelAtBuffer(@intCast(px), @intCast(y), color);
}
} }
thick += 1;
} }
if (x == bx1 and y == by1) break;
const e2 = 2 * err; if (x0 == ex and y0 == ey) break;
if (e2 >= -dy) {
err -= dy; const e2: i32 = 2 * err;
x += sx; if (e2 >= dy) {
err += dy;
x0 += sx;
} }
if (e2 <= dx) { if (e2 <= dx) {
err += dx; err += dx;
y += sy; y0 += sy;
} }
} }
// Скруглённые концы: круги в крайних точках.
if (half > 0) {
fillCircleAtBuffer(ctx, bx0, by0, half, color);
fillCircleAtBuffer(ctx, bx1, by1, half, color);
}
} }

View File

@@ -1,6 +1,8 @@
const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");
const Document = @import("../models/Document.zig"); const Document = @import("../models/Document.zig");
const icons = @import("../icons.zig");
const Object = Document.Object; const Object = Document.Object;
const panel_gap: f32 = 12; const panel_gap: f32 = 12;
@@ -8,6 +10,11 @@ const panel_padding: f32 = 5;
const panel_radius: f32 = 24; const panel_radius: f32 = 24;
const fill_color = dvui.Color.black.opacity(0.2); const fill_color = dvui.Color.black.opacity(0.2);
const ObjectTreeCallback = union(enum) {
select: *Object,
delete: *Object,
};
fn shapeLabel(shape: Object.ShapeKind) []const u8 { fn shapeLabel(shape: Object.ShapeKind) []const u8 {
return switch (shape) { return switch (shape) {
.line => "Line", .line => "Line",
@@ -17,34 +24,107 @@ fn shapeLabel(shape: Object.ShapeKind) []const u8 {
}; };
} }
fn objectTreeRow(obj: *const Object, depth: u32, row_id: *usize) void { fn objectTreeRow(open_doc: *WindowContext.OpenDocument, obj: *Object, depth: u32, object_callback: *?ObjectTreeCallback) void {
const id = row_id.*;
row_id.* += 1;
const indent_px = depth * 18; const indent_px = depth * 18;
const is_selected: bool = open_doc.selected_object == obj;
const row_id = @intFromPtr(obj);
const focus_color = dvui.themeGet().focus;
var row = dvui.box( var row = dvui.box(
@src(), @src(),
.{ .dir = .horizontal }, .{ .dir = .horizontal },
.{ .padding = dvui.Rect{ .x = @floatFromInt(indent_px) }, .id_extra = id }, .{
.id_extra = row_id,
.expand = .horizontal,
},
); );
{ {
dvui.labelNoFmt(@src(), shapeLabel(obj.shape), .{}, .{ .id_extra = id }); var hovered: bool = false;
const row_data = row.data();
var select_row: bool = false;
// Ручная обработка hover/click по строке без пометки события как handled,
// чтобы кнопка удаления могла нормально получать свои события.
for (dvui.events()) |*e| {
switch (e.evt) {
.mouse => |*mouse| {
if (!dvui.eventMatchSimple(e, row_data)) continue;
hovered = true;
if (mouse.action == .press and mouse.button == .left) {
select_row = true;
}
},
else => {},
}
}
const background = is_selected or hovered;
var content = dvui.box(@src(), .{ .dir = .horizontal }, .{
.expand = .horizontal,
.margin = dvui.Rect{ .x = @floatFromInt(indent_px) },
.background = background,
.color_fill = if (is_selected) focus_color.opacity(0.35) else if (hovered) focus_color.opacity(0.18) else null,
});
{
dvui.labelNoFmt(
@src(),
shapeLabel(obj.shape),
.{},
.{ .id_extra = row_id, .expand = .horizontal },
);
if (hovered) {
const delete_opts: dvui.Options = .{
.id_extra = row_id +% 1,
.margin = dvui.Rect{ .x = 4 },
.padding = dvui.Rect.all(2),
.gravity_y = 0.5,
.gravity_x = 1.0,
};
if (dvui.buttonIcon(@src(), "Delete object", icons.trash, .{}, .{}, delete_opts)) {
object_callback.* = .{ .delete = obj };
}
}
}
content.deinit();
if (select_row) {
object_callback.* = .{ .select = obj };
}
} }
row.deinit(); row.deinit();
for (obj.children.items) |*child| { for (obj.children.items) |*child| {
objectTreeRow(child, depth + 1, row_id); objectTreeRow(open_doc, child, depth + 1, object_callback);
} }
} }
fn objectTree(ctx: *WindowContext) void { fn objectTree(ctx: *WindowContext) void {
const active_doc = ctx.activeDocument(); const active_doc = ctx.activeDocument();
var object_callback: ?ObjectTreeCallback = null;
if (active_doc) |open_doc| { if (active_doc) |open_doc| {
const doc = &open_doc.document; const doc = &open_doc.document;
if (doc.objects.items.len == 0) { if (doc.objects.items.len == 0) {
dvui.label(@src(), "No objects", .{}, .{}); dvui.label(@src(), "No objects", .{}, .{});
} else { } else {
var row_id: usize = 0;
for (doc.objects.items) |*obj| { for (doc.objects.items) |*obj| {
objectTreeRow(obj, 0, &row_id); objectTreeRow(open_doc, obj, 0, &object_callback);
}
}
if (object_callback) |callback| {
switch (callback) {
.select => |obj| {
if (open_doc.selected_object == obj) {
open_doc.selected_object = null;
} else {
open_doc.selected_object = obj;
}
},
.delete => |obj| {
_ = doc.removeObject(obj);
open_doc.selected_object = null;
open_doc.canvas.requestRedraw();
},
} }
} }
} else { } else {
@@ -60,7 +140,9 @@ pub fn leftPanel(ctx: *WindowContext) void {
.{ .dir = .vertical }, .{ .dir = .vertical },
.{ .{
.expand = .vertical, .expand = .vertical,
// Фиксированная ширина левой панели
.min_size_content = .{ .w = 220 }, .min_size_content = .{ .w = 220 },
.max_size_content = .{ .h = undefined, .w = 220 },
.background = true, .background = true,
.padding = padding, .padding = padding,
}, },
@@ -123,10 +205,14 @@ pub fn leftPanel(ctx: *WindowContext) void {
}, },
); );
{ {
dvui.label(@src(), "Objects", .{}, .{}); dvui.label(@src(), "Objects", .{}, .{ .font = .{
.id = dvui.themeGet().font_heading.id,
.line_height_factor = dvui.themeGet().font_heading.line_height_factor,
.size = dvui.themeGet().font_heading.size + 8,
}, .gravity_x = 0.5 });
var scroll = dvui.scrollArea( var scroll = dvui.scrollArea(
@src(), @src(),
.{ .vertical = .auto }, .{ .vertical = .auto, .horizontal = .auto },
.{ .expand = .both, .background = false }, .{ .expand = .both, .background = false },
); );
{ {