Compare commits

77 Commits

Author SHA1 Message Date
6fc311139f Перегенерирован отчёт 2026-03-19 19:35:23 +03:00
97c19381d9 Фикс цветов 2026-03-19 19:26:08 +03:00
d73d378194 обозначения в отчёте 2026-03-19 18:08:18 +03:00
aea57a1901 мелкие правки в отчёте 2026-03-19 17:57:45 +03:00
d09ab72a38 более верный отчёт 2026-03-19 16:25:20 +03:00
d6825592b1 скрипт генерации pdf 2026-03-19 15:43:14 +03:00
37036f8f75 Report 2026-03-19 14:54:09 +03:00
7145b66e0e Merge remote-tracking branch 'codeberg/review' 2026-03-18 23:39:28 +03:00
andrew.kraevskii
d6c8a062cd Fix memory leaks found by DebugAllocator. 2026-03-04 02:00:40 +03:00
andrew.kraevskii
045624beac Use leak detection of DebugAllocator.
When allocating memory it remebers where memory was allocated but it only
for leaks in .deinit(). So not calling it is a sure way to miss a bunch of leaks.

deinit() returns if it found leaks but we don't really care. Its usefull for tests but not here.
2026-03-04 02:00:40 +03:00
andrew.kraevskii
b0259c5788 Stuff. 2026-03-04 02:00:40 +03:00
andrew.kraevskii
0a47ea1e43 If function doesn't do anything related to object just don't pass object to it 2026-03-04 02:00:40 +03:00
andrew.kraevskii
cc10d806fe Return type of field of property instead of property. 2026-03-04 02:00:40 +03:00
andrew.kraevskii
e3a4506194 Use std.mem.Allocator.dupe instead of @memcpy. 2026-03-04 02:00:40 +03:00
andrew.kraevskii
3348b2e91c Update to latest dvui. 2026-03-04 02:00:40 +03:00
andrew.kraevskii
9ca360c6b3 Im trying not to die of old age waiting on llvm :( 2026-03-04 02:00:21 +03:00
2e2c140d5b applyPropertyPatch 2026-03-03 20:46:23 +03:00
129206ce4f Очистка 2026-03-03 20:39:01 +03:00
446cd80616 points теперь слайс 2026-03-03 20:38:57 +03:00
9a795c22f1 Изменён лимит частоты перерисовки 2026-03-03 20:10:30 +03:00
84c9a55ee5 refactor: Удалена нереализованная фигура "Дуга"
Полностью удалены модель, инструменты, рендеринг и связанные UI-элементы для фигуры "Дуга", поскольку она не была реализована в системе.
Также обновлены иконки для инструментов "Линия" и "Ломаная линия".
2026-03-03 20:07:03 +03:00
4bb98f1f41 заливка круга и closed 2026-03-03 19:59:50 +03:00
d6d41388b3 Небольшое упрощение 2026-03-03 19:46:50 +03:00
4bf92356af Первый крутой круг 2026-03-03 19:07:53 +03:00
b1177265ea Универсальная растровая заливка 2026-03-03 18:30:24 +03:00
5b1b3a8c5e Переход на i32 2026-03-03 15:26:01 +03:00
7aa9673b44 Более красивая панель 2026-03-03 14:32:20 +03:00
32cffb757d Заливка и замкнутая фигура 2026-03-03 14:21:55 +03:00
e5b8e6735d Render Quality 2026-03-02 22:43:09 +03:00
c399d285fb Кнопки для точек на кривой 2026-03-02 22:22:47 +03:00
d1722e3b6b работающее сохранение 2026-03-02 17:50:05 +03:00
37eea6ed89 Нормальный json 2026-03-02 17:37:00 +03:00
8ea5d97c2d Начало фикса json 2026-03-02 17:19:55 +03:00
b692539a30 формат 0.00 для времени кадра 2026-03-01 17:57:58 +03:00
9fa13fa913 статистика времени кадра 2026-03-01 17:19:52 +03:00
8ca31bf479 убраны комменты лишние 2026-02-27 02:59:52 +03:00
e527cc4999 Улучшены табы 2026-02-27 02:44:26 +03:00
0119e51a27 Немного упрощено 2026-02-27 01:59:30 +03:00
b7f7108f63 json сохранение 2026-02-27 01:49:40 +03:00
50821c8046 Класные таббары 2026-02-27 01:07:00 +03:00
98c93a3780 улучшенный зум 2026-02-27 00:12:58 +03:00
0ef835b019 Отказ от слайдеров 2026-02-27 00:04:47 +03:00
a0aae5da14 улучшеие сборки 2026-02-26 22:36:03 +03:00
19e0daffbd color picker 2026-02-26 22:15:23 +03:00
78d586ce3d простой зум 2026-02-26 22:08:05 +03:00
2ab6bcd408 Определение позиции дочернего объекта 2026-02-26 21:33:09 +03:00
77604e7b2b Добавлять объекты в родителя 2026-02-26 21:18:37 +03:00
291dbd6f85 Переход на object_id 2026-02-26 20:57:35 +03:00
f55a756132 правильный скролл для свойств 2026-02-26 20:41:09 +03:00
7923e37818 Красота 2026-02-26 20:03:04 +03:00
9e2bb23c8a Классный зум 2026-02-26 19:06:36 +03:00
a1f31d76c7 Тулзы перенесены в свой каталог 2026-02-26 19:02:36 +03:00
bf63729124 убраны круги из линий 2026-02-26 18:49:15 +03:00
40b3b0ad10 Правильная толщина 2026-02-26 18:16:33 +03:00
d070c1660f Улучшайки 2026-02-26 15:58:26 +03:00
6212d8d6aa Дерево объектов хорошее 2026-02-26 15:45:54 +03:00
93f7f3d814 начало дерева объектов 2026-02-26 15:16:43 +03:00
05f5481a42 Дополнительный буфер для отрисовки broken line 2026-02-26 13:48:42 +03:00
0eee436150 llvm 2026-02-26 12:42:45 +03:00
01b76ede64 Оптимизация круга 2026-02-26 00:39:11 +03:00
faf79367f6 Толщина круга 2026-02-26 00:32:59 +03:00
f8731bde87 Толщина линии 2026-02-26 00:23:10 +03:00
563c2a7535 Кнопка для добавления случайных фигур 2026-02-26 00:03:22 +03:00
b6e14cd74d Теперь сквозь тулбар не кликается на холст 2026-02-25 23:59:46 +03:00
790200be2a Добавлены остальные инструменты 2026-02-25 23:20:30 +03:00
65ca468bfb Убраны лишние комментарии 2026-02-25 22:46:45 +03:00
317ebb958b Убрано лишнее умножение на Alpha 2026-02-25 22:37:52 +03:00
31ba2c57cb Поправлены цвета 2026-02-25 22:31:50 +03:00
5c9ec3167a рефакторинг небольшой 2026-02-25 21:27:02 +03:00
9202b527e4 Первая версия тулбара 2026-02-25 21:13:38 +03:00
0d546782bb Поправлена панель настроек 2026-02-25 19:14:06 +03:00
0ee7be2002 Зум к курсору 2026-02-25 18:26:08 +03:00
23111172d8 правка 2026-02-25 01:22:42 +03:00
0114db1f48 feat: Добавил толщину линий, унифицировал геометрию
Переименовал основные геометрические модели (Point, Size, Rect, Scale, Radii), явно разделив их на типы с плавающей точкой (_f) и целочисленные (_i). Обновил использование этих типов во всем проекте для улучшения типобезопасности и ясности.

Ввел новое свойство thickness для объектов и реализовал его применение при отрисовке линий и ломаных. Добавил Point2_i для целочисленных координат буфера в конвейере отрисовки.
2026-02-25 00:57:55 +03:00
1d995995e7 Рефакторинг ещё... 2026-02-24 23:52:13 +03:00
e5dd455d14 Первая растеризация 2026-02-24 21:56:15 +03:00
ef768e9fe7 Первые попытки рисовать 2026-02-24 20:51:40 +03:00
68 changed files with 8104 additions and 426 deletions

4
.vscode/launch.json vendored
View File

@@ -12,9 +12,11 @@
}, },
{ {
"name": "Zig: Debug (gdb)", "name": "Zig: Debug (gdb)",
"type": "gdb", "type": "cppdbg",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/zig-out/bin/Zivro", "program": "${workspaceFolder}/zig-out/bin/Zivro",
"cwd": "${workspaceFolder}",
"MIMode": "gdb",
"preLaunchTask": "zig: build" "preLaunchTask": "zig: build"
}, },
] ]

1
.vscode/tasks.json vendored
View File

@@ -9,7 +9,6 @@
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
}, },
"problemMatcher": ["$gcc"],
} }
] ]
} }

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Append source files to a markdown report and save as a new file.
Example:
python3 Report/append_sources_to_report.py \
--input Report/zivro-open-project-report.md \
--output Report/zivro-open-project-report-with-code.md \
--base .
"""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Iterable
DEFAULT_EXTENSIONS = {
".zig",
".zon",
".json",
".toml",
".yaml",
".yml",
".md",
".txt",
".py",
".puml",
}
DEFAULT_EXCLUDE_DIRS = {
".git",
"zig-out",
"zig-cache",
".zig-cache",
".cursor",
"mcps",
}
DEFAULT_EXCLUDE_FILES = {
".DS_Store",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Adds source code listings to the end of a markdown report and writes "
"the result to a new markdown file."
)
)
parser.add_argument("--input", required=True, help="Path to source markdown report")
parser.add_argument("--output", required=True, help="Path to output markdown report")
parser.add_argument(
"--base",
default=".",
help="Project root to scan for source files (default: current directory)",
)
parser.add_argument(
"--include",
nargs="*",
default=["src", "build.zig", "build.zig.zon"],
help=(
"Files/directories (relative to --base) to include in appendix scan. "
"Default: src build.zig build.zig.zon"
),
)
parser.add_argument(
"--extensions",
nargs="*",
default=sorted(DEFAULT_EXTENSIONS),
help=(
"Allowed file extensions (e.g. .zig .md). "
"If empty, all file extensions are allowed."
),
)
parser.add_argument(
"--exclude-dir",
nargs="*",
default=sorted(DEFAULT_EXCLUDE_DIRS),
help="Directory names to exclude recursively",
)
parser.add_argument(
"--max-bytes",
type=int,
default=1_000_000,
help="Skip files larger than this size in bytes (default: 1_000_000)",
)
return parser.parse_args()
def is_text_file(path: Path) -> bool:
try:
data = path.read_bytes()
except OSError:
return False
if b"\x00" in data:
return False
return True
def iter_files(
base: Path,
include_paths: Iterable[str],
extensions: set[str],
exclude_dirs: set[str],
max_bytes: int,
) -> list[Path]:
files: list[Path] = []
def add_file(path: Path) -> None:
if not path.is_file():
return
if path.name in DEFAULT_EXCLUDE_FILES:
return
if extensions and path.suffix.lower() not in extensions:
return
try:
size = path.stat().st_size
except OSError:
return
if size > max_bytes:
return
if not is_text_file(path):
return
files.append(path)
for rel in include_paths:
item = (base / rel).resolve()
if not item.exists():
continue
if item.is_file():
add_file(item)
continue
for path in item.rglob("*"):
if any(part in exclude_dirs for part in path.parts):
continue
add_file(path)
return sorted(set(files), key=lambda p: p.relative_to(base).as_posix())
def language_for(path: Path) -> str:
ext = path.suffix.lower()
if ext == ".zig":
return "zig"
if ext == ".py":
return "python"
if ext in {".yaml", ".yml"}:
return "yaml"
if ext == ".json":
return "json"
if ext == ".toml":
return "toml"
if ext == ".md":
return "markdown"
return ""
def main() -> int:
args = parse_args()
input_path = Path(args.input).resolve()
output_path = Path(args.output).resolve()
base_path = Path(args.base).resolve()
if not input_path.exists():
raise FileNotFoundError(f"Input report not found: {input_path}")
if input_path == output_path:
raise ValueError("--input and --output must be different files")
report_text = input_path.read_text(encoding="utf-8")
extensions = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in args.extensions}
exclude_dirs = set(args.exclude_dir)
files = iter_files(
base=base_path,
include_paths=args.include,
extensions=extensions,
exclude_dirs=exclude_dirs,
max_bytes=args.max_bytes,
)
appendix_lines: list[str] = []
appendix_lines.append("")
appendix_lines.append("---")
appendix_lines.append("")
appendix_lines.append("## Приложение A. Исходные тексты")
appendix_lines.append("")
appendix_lines.append(
f"Сформировано автоматически скриптом `Report/append_sources_to_report.py` "
f"(файлов: {len(files)})."
)
appendix_lines.append("")
for idx, path in enumerate(files, start=1):
rel = path.relative_to(base_path).as_posix()
lang = language_for(path)
code = path.read_text(encoding="utf-8", errors="replace")
appendix_lines.append(f"### A.{idx}. `{rel}`")
appendix_lines.append("")
appendix_lines.append(f"```{lang}")
appendix_lines.append(code.rstrip("\n"))
appendix_lines.append("```")
appendix_lines.append("")
output_text = report_text.rstrip() + "\n" + "\n".join(appendix_lines)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(output_text, encoding="utf-8")
print(f"Created: {output_path}")
print(f"Input report preserved: {input_path}")
print(f"Attached files: {len(files)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

11
Report/generate-pdf.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
pandoc zivro-open-project-report-with-code.md \
-f markdown+implicit_figures+link_attributes+tex_math_dollars \
-o sample_1.pdf \
--pdf-engine=xelatex \
-V geometry:left=1.2cm,right=1.2cm,top=1.2cm,bottom=1.2cm \
-V mainfont="DejaVu Serif" \
-V monofont="DejaVu Sans Mono" \
-H header.tex

16
Report/header.tex Normal file
View File

@@ -0,0 +1,16 @@
\usepackage{fvextra}
\DefineVerbatimEnvironment{Highlighting}{Verbatim}{
breaklines,
breakanywhere,
commandchars=\\\{\},
fontsize=\small
}
\usepackage{xurl}
\urlstyle{same}
\setlength{\emergencystretch}{3em}
\usepackage{graphicx}
\setkeys{Gin}{width=\linewidth,height=0.80\textheight,keepaspectratio}
\usepackage{float}
\floatplacement{figure}{H}

182
Report/render_uml_png.py Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
Render PlantUML diagrams (.puml) to high-resolution PNG files via Kroki.
Why this script:
- local `plantuml` may require X11 in some environments;
- this script uses Kroki HTTP API and can raise output quality via `skinparam dpi`.
Examples:
python3 Report/render_uml_png.py
python3 Report/render_uml_png.py --dpi 360 --glob "*.puml"
python3 Report/render_uml_png.py --input-dir Report/uml --timeout 120
"""
from __future__ import annotations
import argparse
import io
from pathlib import Path
import re
from typing import Iterable
import requests
from PIL import Image
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render .puml diagrams to PNG with configurable DPI."
)
parser.add_argument(
"--input-dir",
default="Report/uml",
help="Directory with .puml files (default: Report/uml)",
)
parser.add_argument(
"--glob",
default="*.puml",
help="Glob pattern inside input-dir (default: *.puml)",
)
parser.add_argument(
"--dpi",
type=int,
default=300,
help="Output DPI via PlantUML skinparam (default: 300)",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="HTTP timeout in seconds (default: 120)",
)
parser.add_argument(
"--kroki-url",
default="https://kroki.io/plantuml/png",
help="Kroki PlantUML PNG endpoint",
)
parser.add_argument(
"--min-dpi",
type=int,
default=96,
help="Lower bound for auto DPI fallback (default: 96)",
)
parser.add_argument(
"--max-dim",
type=int,
default=3900,
help=(
"Target max PNG dimension during auto-fit. "
"Keep below backend hard limits (default: 3900)"
),
)
return parser.parse_args()
def iter_puml_files(input_dir: Path, pattern: str) -> Iterable[Path]:
return sorted(p for p in input_dir.glob(pattern) if p.is_file())
def inject_or_replace_skinparam(plantuml_text: str, key: str, value: int) -> str:
"""
Ensure diagram has the requested `skinparam <key>`.
- If skinparam exists, replace it.
- Otherwise insert after @startuml line.
"""
line_value = f"skinparam {key} {value}"
pattern = rf"(?im)^\s*skinparam\s+{re.escape(key)}\s+\d+\s*$"
if re.search(pattern, plantuml_text):
return re.sub(pattern, line_value, plantuml_text)
lines = plantuml_text.splitlines()
for i, line in enumerate(lines):
if line.strip().lower().startswith("@startuml"):
lines.insert(i + 1, line_value)
return "\n".join(lines) + ("\n" if plantuml_text.endswith("\n") else "")
# Fallback: if @startuml is missing, prepend anyway.
return line_value + "\n" + plantuml_text
def render_one(
puml_file: Path,
kroki_url: str,
dpi: int,
min_dpi: int,
max_dim: int,
timeout: int,
) -> Path:
source = puml_file.read_text(encoding="utf-8")
source = inject_or_replace_skinparam(source, "padding", 24)
current_dpi = dpi
png_bytes: bytes | None = None
while True:
payload = inject_or_replace_skinparam(source, "dpi", current_dpi)
response = requests.post(
kroki_url,
data=payload.encode("utf-8"),
timeout=timeout,
)
response.raise_for_status()
candidate = response.content
with Image.open(io.BytesIO(candidate)) as im:
width, height = im.size
# Kroki/PlantUML PNG responses may hit hard canvas limits.
# If we are close to that ceiling, lower DPI and retry.
if (width > max_dim or height > max_dim) and current_dpi > min_dpi:
next_dpi = max(min_dpi, int(current_dpi * 0.82))
if next_dpi == current_dpi:
next_dpi = current_dpi - 1
current_dpi = max(min_dpi, next_dpi)
continue
png_bytes = candidate
break
if png_bytes is None:
raise RuntimeError(f"Failed to render diagram: {puml_file}")
out_file = puml_file.with_suffix(".png")
out_file.write_bytes(png_bytes)
return out_file
def main() -> int:
args = parse_args()
input_dir = Path(args.input_dir).resolve()
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
files = list(iter_puml_files(input_dir, args.glob))
if not files:
print(f"No files matched in {input_dir} with pattern {args.glob}")
return 0
print(f"Rendering {len(files)} diagrams from: {input_dir}")
print(f"DPI: {args.dpi}")
success = 0
for puml in files:
out = render_one(
puml_file=puml,
kroki_url=args.kroki_url,
dpi=args.dpi,
min_dpi=args.min_dpi,
max_dim=args.max_dim,
timeout=args.timeout,
)
print(f"created {out}")
success += 1
print(f"Done: {success}/{len(files)} PNG files generated.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

BIN
Report/sample_1.pdf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -0,0 +1,35 @@
@startuml
title Canvas/ViewPort: алгоритм вычисления видимой области и редроу
start
:Get zoomed document rect\n(getZoomedImageSize);
:Read viewport rect + scroll offset;
:Compute visible width/height\nmin(viewport, image size);
:Compute raw visible x/y\nfrom scroll - image offset;
:Clamp x/y into image bounds;
:Store Rect_i{x,y,w,h} as _visible_rect;
if (visible rect changed\nOR texture is null?) then (yes)
:request redraw;
else (no)
:no redraw needed;
endif
if (redraw pending?) then (yes)
:Read render quality %;
:Convert area-quality to side-scale\nscale = sqrt(q/100);
:Scale full canvas size;
:Scale visible rect;
:Clamp scaled rect to scaled canvas;
if (scaled visible rect valid?) then (yes)
:Render only scaled visible area\nthrough RenderEngine;
:Update texture + stats + throttle;
else (no)
:Drop texture / skip rendering;
endif
else (no)
:Keep previous frame;
endif
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -0,0 +1,28 @@
@startuml
title Конвейер координат: Local -> World -> Canvas -> Buffer
skinparam rectangle {
RoundCorner 10
}
rectangle "Local coords\n(object space)" as L
rectangle "World coords\n(document space)" as W
rectangle "Canvas coords\n(scaled document)" as C
rectangle "Buffer coords\n(visible rect origin)" as B
L --> W : localToWorld()\nrotate + scale + translate
W --> C : world * scale_x/scale_y
C --> B : subtract visible_rect.(x,y)
B --> C : + visible_rect.(x,y)
C --> W : divide by scale_x/scale_y
W --> L : worldToLocal()\ntranslate^-1 + rotate^-1 + scale^-1
note right of B
All raster tests are done
in buffer coords (integer pixels).
Shape analytics often done
in local coords after inverse map.
end note
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,43 @@
@startuml
title Эллипс/дуга: алгоритм растризации
start
:Read rx, ry, thickness,\narc_percent, closed, filled;
if (rx<=0 or ry<=0?) then (yes)
stop
endif
:Build local bbox corners with margin;
:Transform corners to buffer\nand compute pixel scan bounds;
:Create temporary transparent buffer;
if (do_fill?) then (yes)
:startFill() collect border pixels;
endif
while (for each pixel in bbox)
:Map pixel center Buffer -> World -> Local;
:nx = x/rx, ny = y/ry,\nd = nx^2 + ny^2;
if (d in stroke ring?) then (yes)
if (arc_percent < 100?) then (yes)
:Compute angular position\nvia atan2 and normalized diff;
if (inside arc sector?) then (yes)
:plot stroke pixel;
endif
else (full ellipse)
:plot stroke pixel;
endif
endif
endwhile
if (closed and arc_percent<100?) then (yes)
:Draw two radial segments\n(center->start and center->end);
endif
if (do_fill?) then (yes)
:stopFill(fill_color);
endif
:Composite temp buffer to target\nonce with object opacity;
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,34 @@
@startuml
title Liang-Barsky: отсечение отрезка прямоугольником
start
:Input segment P0,P1 and clip rect;
:dx = x1-x0, dy = y1-y0;
:t0 = 0, t1 = 1;
:Process boundary x >= left\np=-dx, q=x0-left;
if (clip test fails?) then (yes)
stop
endif
:Process boundary x <= right\np=dx, q=right-x0;
if (clip test fails?) then (yes)
stop
endif
:Process boundary y >= top\np=-dy, q=y0-top;
if (clip test fails?) then (yes)
stop
endif
:Process boundary y <= bottom\np=dy, q=bottom-y0;
if (clip test fails?) then (yes)
stop
endif
:Compute clipped points:\nP0' = P0 + t0*(P1-P0)\nP1' = P0 + t1*(P1-P0);
:Round to integer pixels;
:Return accepted clipped segment;
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,30 @@
@startuml
title Линия: полная схема растризации
start
:Input local endpoints (x0,y0),(x1,y1)\n+ color + thickness;
:Map endpoints Local -> World -> Buffer;
:Compute pixel thickness from transform scale;
if (thickness_px <= 0?) then (yes)
stop
endif
if (draw_when_outside == false?) then (yes)
:Clip segment to expanded buffer bounds\n(Liang-Barsky);
if (segment rejected?) then (yes)
stop
endif
endif
:Compute angle-based thickness correction\n(using |dx|/len and |dy|/len);
:Initialize Bresenham state\ndx,dy,sx,sy,err;
while (not reached end point?)
:Choose effective half-thickness:\nfull in viewport, 0 outside if draw_when_outside;
:Draw stripe around center pixel\n(along normal axis);
:Advance Bresenham step by error update;
endwhile
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,27 @@
@startuml
title PMA alpha blending в blendPixelAtBuffer
start
:Input dst pixel, src PMA pixel,\ntransform opacity;
if (pixel outside buffer?) then (yes)
stop
endif
if (replace_mode?) then (yes)
:dst = src;
stop
endif
:a = (src.a / 255) * opacity;
:inv_a = 1 - a;
:src_rgb = src.rgb * opacity;
:dst.r = clamp(src_r + inv_a * dst.r);
:dst.g = clamp(src_g + inv_a * dst.g);
:dst.b = clamp(src_b + inv_a * dst.b);
:dst.a = clamp(a*255 + inv_a * dst.a);
:Store dst pixel;
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,29 @@
@startuml
title Ломаная + заливка: 3-фазный алгоритм
start
:Input points[], closed, filled;
if (points < 2?) then (yes)
stop
endif
:Create temporary buffer + copy context;
if (closed and filled?) then (yes)
:Enable FillCanvas (startFill);
endif
:Draw all segments Pi->Pi+1;
if (closed?) then (yes)
:Draw segment Pn->P0;
endif
if (fill enabled?) then (yes)
:Phase 1:\nBorder pixels already collected in hash-set;
:Phase 2:\nSort border keys by (y,x),\nfind row segments,\nchoose interior seeds;
:Phase 3:\nStack flood fill (4-neighbors),\nskip border/visited,\npaint fill color;
endif
:Composite temporary buffer to target once;
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,19 @@
@startuml
title Алгоритм композиции трансформаций (parent * local)
start
:Input parent transform:\nP(tx,ty,angle,sx,sy,opacity);
:Input local transform:\nL(tx,ty,angle,sx,sy,opacity);
:Scale local position by parent scale:\nlx' = L.tx * P.sx\nly' = L.ty * P.sy;
:Rotate by parent angle:\nrx = cos(P.a)*lx' - sin(P.a)*ly'\nry = sin(P.a)*lx' + cos(P.a)*ly';
:Translate by parent position:\nW.tx = P.tx + rx\nW.ty = P.ty + ry;
:Compose angle:\nW.angle = P.angle + L.angle;
:Compose scale:\nW.sx = P.sx * L.sx\nW.sy = P.sy * L.sy;
:Compose opacity:\nW.opacity = P.opacity * L.opacity;
:Return world transform W;
stop
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,49 @@
@startuml
title Zivro: компонентная архитектура
skinparam componentStyle rectangle
skinparam shadowing false
package "UI Layer" {
[main.zig\nEvent Loop]
[ui/*\nMenu/Tab/Panels]
[Canvas.zig\nViewport + Redraw]
}
package "Application State" {
[WindowContext.zig\nOpenDocument tabs]
[models/Document.zig\nObject tree]
[models/Object.zig\nProperties + Children]
}
package "Render Layer" {
[RenderEngine.zig\nInterface]
[CpuRenderEngine.zig\nCPU backend]
[cpu/draw.zig\nRecursive draw]
[cpu/pipeline.zig\nTransforms + blend]
[cpu/line.zig]
[cpu/ellipse.zig]
[cpu/broken.zig]
}
package "Persistence" {
[persistence/json_io.zig]
}
[main.zig\nEvent Loop] --> [WindowContext.zig\nOpenDocument tabs]
[ui/*\nMenu/Tab/Panels] --> [WindowContext.zig\nOpenDocument tabs]
[Canvas.zig\nViewport + Redraw] --> [RenderEngine.zig\nInterface]
[Canvas.zig\nViewport + Redraw] --> [models/Document.zig\nObject tree]
[WindowContext.zig\nOpenDocument tabs] --> [Canvas.zig\nViewport + Redraw]
[WindowContext.zig\nOpenDocument tabs] --> [CpuRenderEngine.zig\nCPU backend]
[RenderEngine.zig\nInterface] <|.. [CpuRenderEngine.zig\nCPU backend]
[CpuRenderEngine.zig\nCPU backend] --> [cpu/draw.zig\nRecursive draw]
[cpu/draw.zig\nRecursive draw] --> [cpu/pipeline.zig\nTransforms + blend]
[cpu/draw.zig\nRecursive draw] --> [cpu/line.zig]
[cpu/draw.zig\nRecursive draw] --> [cpu/ellipse.zig]
[cpu/draw.zig\nRecursive draw] --> [cpu/broken.zig]
[models/Document.zig\nObject tree] --> [models/Object.zig\nProperties + Children]
[persistence/json_io.zig] ..> [models/Document.zig\nObject tree] : save/load
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,33 @@
@startuml
title Zivro: последовательность рендера кадра (CPU)
actor User
participant "UI Frame\n(frame.zig)" as UI
participant "Canvas\n(Canvas.zig)" as Canvas
participant "RenderEngine" as RE
participant "CpuRenderEngine" as CPU
participant "drawDocument\n(cpu/draw.zig)" as Draw
participant "Shape modules\nline/ellipse/broken" as Shapes
User -> UI : input/events
UI -> Canvas : processPendingRedraw()
alt redraw pending and throttle passed
Canvas -> Canvas : computeVisibleImageRect()\ncompute quality scale
Canvas -> RE : render(document, canvas_size, visible_rect)
RE -> CPU : renderDocument(...)
CPU -> Draw : drawDocument(pixels,...)
loop for each root object
Draw -> Draw : Transform.compose(parent, local)
Draw -> Shapes : draw shape by object.kind
loop for each child
Draw -> Draw : recursive drawObject(child, world_transform)
end
end
Draw --> CPU : pixels rendered
CPU --> Canvas : texture
Canvas -> UI : texture for draw
else no redraw
Canvas -> UI : keep previous texture
end
@enduml

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
# Лабораторная работа 3
## Векторный редактор `Zivro`
## 1. Цель работы
### 1.1. Дидактическая цель
Овладеть навыками разработки программных модулей, реализующих алгоритмы растровой развёртки (растеризации) контуров фигур и сплошных областей на плоскости, а также получить опыт эмпирического анализа вычислительной трудоёмкости и точности алгоритмов растеризации.
### 1.2. Практическая цель
Разработать и исследовать программный модуль, выполняющий:
- растеризацию контура фигуры;
- растеризацию (закраску) сплошной области;
- формирование и отображение растрового изображения;
- сбор эмпирических данных по времени выполнения и различию результатов алгоритмов.
В рамках данной работы в качестве программной базы используется проект `Zivro`, разработанный автором специально для выполнения лабораторной работы, в который интегрированы и исследуются соответствующие алгоритмы.
## 2. Постановка задачи
В рамках лабораторной работы требуется реализовать и проверить функционал, соответствующий методическим указаниям ЛР №3:
1. Реализовать программный модуль растеризации контура и внутренней области фигуры на растровом изображении.
2. Обеспечить вывод результата в окне/холсте приложения с попиксельной визуализацией.
3. Реализовать не менее двух алгоритмов построения контура и не менее двух алгоритмов закраски (в соответствии с вариантом).
4. Добавить эталонный контурный алгоритм (например, Брезенхейм) для сравнения.
5. Реализовать сбор эмпирических данных:
- время выполнения алгоритмов;
- различие получаемых растровых развёрток (по отличающимся пикселям).
6. Подготовить и оформить отчёт по выполненной работе.
7. Сформировать приложение с исходными текстами автоматически в отдельный `.md` без изменения исходного файла отчёта.
## 3. Краткое описание проекта
Для выполнения ЛР в качестве основы используется проект `Zivro` - настольное графическое приложение для работы с векторными объектами (линия, эллипс, ломаная), с собственной моделью документа, иерархией объектов и CPU-рендерингом.
В контексте ЛР проект используется не как объект абстрактного обзора, а как программная платформа, где реализуются и исследуются алгоритмы растеризации контура и закраски.
Приложение использует:
- язык `Zig`;
- UI-библиотеку `dvui` с SDL3-бэкендом;
- модульную архитектуру (UI, модель данных, рендер, сериализация, инструменты).
Точка входа находится в `src/main.zig`: создаётся окно, инициализируется `WindowContext`, далее выполняется event/render loop и отрисовка растрового результата алгоритмов.
## 4. Структура проекта
Основные каталоги и файлы:
- `build.zig` - сценарий сборки и шагов `run`/`test`;
- `src/main.zig` - запуск приложения и главный цикл;
- `src/WindowContext.zig` - управление открытыми документами и активным документом;
- `src/Canvas.zig` - логика холста, масштабирование, вычисление видимой области, запуск рендера;
- `src/models/*` - модель документа, объектов и их свойств;
- `src/render/*` - CPU-рендер и конвейер отрисовки;
- `src/persistence/json_io.zig` - сохранение/загрузка документов в JSON;
- `src/ui/*` - интерфейсные панели и компоновка экрана;
- `src/tests.zig` - entry-point тестов.
## 5. Архитектура приложения
### 5.1. Высокоуровневая схема
Архитектурно проект разделён на три уровня:
1. **UI-уровень** (`src/ui`, `src/Canvas.zig`)
Обрабатывает пользовательские события, отображает панели и холст, передаёт действия в модель и рендер.
2. **Модель данных** (`src/models`)
Хранит документ, дерево объектов и свойства (позиция, угол, масштаб, цвет, точки, радиусы и др.).
3. **Рендер-уровень** (`src/render`)
Преобразует модель объектов в набор пикселей видимой области и формирует текстуру.
### 5.2. Управление документами
`WindowContext` хранит массив открытых документов (`OpenDocument`) и индекс активного документа.
Каждый `OpenDocument` содержит:
- `Document`;
- экземпляр `CpuRenderEngine`;
- `Canvas`;
- идентификатор выбранного объекта.
Это позволяет одновременно работать с несколькими документами во вкладках.
### 5.3. Рендер-конвейер
Ключевой путь рендера:
1. `Canvas.redraw()` вычисляет масштаб качества и видимую часть документа;
2. вызывается `RenderEngine.render(...)` (в текущей конфигурации CPU-вариант);
3. `CpuRenderEngine.renderDocument(...)` подготавливает пиксельный буфер;
4. `cpu/draw.zig` рекурсивно обходит объекты документа;
5. для каждого объекта применяется `Transform.compose(parent, local)`;
6. shape-специфичные модули рисуют примитивы в буфер;
7. буфер превращается в текстуру UI.
## 6. Математические алгоритмы проекта (подробное текстовое описание)
В данном разделе подробно рассмотрены алгоритмы, которые формируют геометрию и цвет в CPU-рендере.
### 6.1. Иерархические трансформации объектов
В основе рендера лежит сцена-дерево: каждый объект может иметь дочерние элементы.
Следовательно, координаты дочернего объекта заданы не в мировой системе, а в системе координат родителя.
В `Transform` хранятся:
- `position = (tx, ty)` - перенос;
- `angle = a` - поворот;
- `scale = (sx, sy)` - независимый масштаб по осям;
- `opacity = o` - накопленная непрозрачность.
Для перехода из локальных координат объекта к мировым используется аффинное преобразование:
$$
\begin{aligned}
x_w &= t_x + (x_l \cdot s_x)\cos a - (y_l \cdot s_y)\sin a, \\
y_w &= t_y + (x_l \cdot s_x)\sin a + (y_l \cdot s_y)\cos a.
\end{aligned}
$$
Обозначения:
- `x_l, y_l` - локальные координаты точки объекта;
- `x_w, y_w` - мировые координаты точки в документе;
- `t_x, t_y` - перенос (`position`) текущего transform;
- `s_x, s_y` - масштаб по осям (`scale_x`, `scale_y`);
- `a` - угол поворота объекта (в радианах).
При композиции `parent * local``Transform.compose`) выполняются шаги:
1. локальная позиция сначала масштабируется масштабом родителя;
2. результат поворачивается на угол родителя;
3. добавляется перенос родителя;
4. углы складываются: `a_world = a_parent + a_local`;
5. масштабы перемножаются покомпонентно: `sx_world = sx_parent * sx_local`, `sy_world = sy_parent * sy_local`;
6. прозрачности перемножаются: `o_world = o_parent * o_local`.
Почему это важно: такой порядок гарантирует, что при повороте/масштабе группы объектов дочерние элементы ведут себя как единая связанная конструкция.
### 6.2. Цепочка преобразований координат в рендере
Рендер использует 4 системы координат:
1. **локальная** (внутри shape);
2. **мировая** (внутри документа);
3. **координаты канвы** (после масштабирования документа под текущий размер);
4. **координаты буфера viewport** (видимая часть, начинающаяся с `(0,0)`).
Основные формулы:
- `canvas_x = world_x * scale_x`, `canvas_y = world_y * scale_y`;
- `buffer_x = canvas_x - visible_rect.x`, `buffer_y = canvas_y - visible_rect.y`.
Обратное преобразование также используется (например, в эллипсе):
- `canvas_x = buffer_x + visible_rect.x`, `canvas_y = buffer_y + visible_rect.y`;
- `world_x = canvas_x / scale_x`, `world_y = canvas_y / scale_y`.
Для вычисления локальных координат из мировых применяется обратный поворот и обратный масштаб:
$$
\begin{aligned}
dx &= x_w - t_x,\quad dy = y_w - t_y, \\
x_l &= \frac{dx\cos(-a)-dy\sin(-a)}{s_x}, \\
y_l &= \frac{dx\sin(-a)+dy\cos(-a)}{s_y}.
\end{aligned}
$$
Обозначения:
- `x_w, y_w` - мировые координаты точки;
- `x_l, y_l` - локальные координаты точки после обратного преобразования;
- `t_x, t_y` - перенос transform;
- `a` - угол поворота transform;
- `s_x, s_y` - масштаб transform;
- `dx, dy` - координаты точки после вычитания переноса.
Практический смысл: shape можно тестировать аналитически (по формулам) в "своей" удобной локальной системе, независимо от того, как он повернут и где расположен в документе.
### 6.3. Рисование линии: отсечение + дискретизация + толщина
Алгоритм в `line.zig` состоит из трёх частей.
#### 6.3.1. Отсечение Liang-Barsky
Перед рисованием линия отсекается расширенным прямоугольником буфера.
Параметрическая форма отрезка:
$$
P(t)=P_0+t(P_1-P_0),\quad t\in[0,1].
$$
Обозначения:
- `P_0, P_1` - начальная и конечная точки исходного отрезка;
- `P(t)` - точка на отрезке при параметре `t`;
- `t` - параметр интерполяции (`0` - начало, `1` - конец);
- `[t0, t1]` - допустимый интервал параметра после отсечения.
Для каждого ограничения (`x >= left`, `x <= right`, `y >= top`, `y <= bottom`) обновляется допустимый интервал `[t0, t1]`.
Если после обработки ограничений `t0 > t1`, отрезок полностью вне экрана и пропускается.
Преимущество: вместо "шагать по пикселям и каждый проверять границы" рендер сразу работает только с видимым отрезком.
#### 6.3.2. Дискретизация линии (Bresenham-подобный проход)
После отсечения используются целочисленные приращения:
- `dx = abs(x1 - x0)`, `dy = -abs(y1 - y0)`;
- `sx = sign(x1 - x0)`, `sy = sign(y1 - y0)`;
- ошибка `err = dx + dy`.
На каждом шаге:
- вычисляется `e2 = 2*err`;
- если `e2 >= dy`, двигаемся по `x`;
- если `e2 <= dx`, двигаемся по `y`.
Это классическая идея целочисленного интегрирования ошибки для аппроксимации идеального непрерывного отрезка на пиксельной сетке.
#### 6.3.3. Толщина и коррекция по углу
Если просто расширять линию равномерно, визуальная толщина может "плыть" при разных углах.
В проекте вычисляется поправка по длине проекций:
- `cos(theta) = |dx| / len`;
- `sin(theta) = |dy| / len`;
- выбирается базис (по X или Y), где ошибка толщины минимальна;
- итоговая толщина пересчитывается через деление на `max(sin, eps)` или `max(cos, eps)`.
Далее вокруг центрального пикселя проводится полоса ширины `thickness_corrected`.
Дополнительно есть режим `draw_when_outside`:
- внутри viewport рисуется полная толщина;
- за пределами viewport — только 1px, чтобы контур не "взрывался" по ширине за экраном.
### 6.4. Растрирование эллипса и дуги
Алгоритм `ellipse.zig` не использует инкрементальные midpoint-формулы, а работает через аналитическую проверку каждого пикселя в ограничивающем прямоугольнике.
#### 6.4.1. Нормализация координат
Для пикселя вычисляются локальные координаты `loc = (x_l, y_l)` и нормализуются:
$$
n_x = \frac{x_l}{r_x},\quad n_y = \frac{y_l}{r_y},\quad d=n_x^2+n_y^2.
$$
Обозначения:
- `x_l, y_l` - локальные координаты текущего пикселя (центра пикселя после обратного преобразования);
- `r_x, r_y` - полуоси эллипса по `x` и `y`;
- `n_x, n_y` - нормализованные координаты в системе эллипса;
- `d` - значение функции эллипса в нормализованной форме.
- `d = 1` соответствует идеальному контуру эллипса;
- `d < 1` внутри;
- `d > 1` снаружи.
#### 6.4.2. Полоса обводки заданной толщины
Толщина `thickness` переводится в нормированное пространство через меньшую полуось:
- `half_norm = thickness / (2*min(rx, ry))`;
- внутренний радиус: `inner = max(0, 1 - half_norm)`;
- внешний радиус: `outer = 1 + half_norm`.
Пиксель принадлежит обводке, если:
$$
inner^2 \le d \le outer^2.
$$
Обозначения:
- `d` - нормализованная квадратичная дистанция из предыдущей формулы;
- `inner` - внутренний радиус полосы обводки в нормализованном пространстве;
- `outer` - внешний радиус полосы обводки в нормализованном пространстве.
Это даёт геометрически корректную полосу вокруг эллипса при произвольном повороте/масштабе объекта.
#### 6.4.3. Дуга через угловой фильтр
Если `arc_percent < 100`, из полного эллипса берётся только часть:
- вычисляется длина дуги в радианах: `arc_len = 2*pi*arc_percent/100`;
- для пикселя находится угол через `atan2(ny, nx)` (с поправкой на экранную систему);
- точка принимается только если её угловая позиция не превышает `arc_len`.
Если `closed = true`, концы дуги соединяются с центром двумя радиальными отрезками (используется общий алгоритм линии).
### 6.5. Ломаная, выделение границы и заливка
В `broken.zig` ломаная строится как цепочка сегментов `P0->P1->...->Pn`, а при `closed` добавляется `Pn->P0`.
Для корректной заливки применяется трёхфазный алгоритм.
#### 6.5.1. Фаза 1: сбор пикселей границы
Во временном `FillCanvas` при каждом `blendPixelAtBuffer` сохраняется координата пикселя как граничная точка (`border_set`).
Смысл: сначала зафиксировать "жёсткий" контур, затем независимо заполнить внутренность.
#### 6.5.2. Фаза 2: поиск внутренних сегментов по строкам
Граничные пиксели сортируются по `(y, x)`. Для каждой строки:
1. выделяются последовательности рёбер;
2. строятся интервалы между соседними рёбрами;
3. из подходящих интервалов выбираются seed-точки (середина интервала).
Это снижает риск старта flood fill с внешней стороны фигуры.
#### 6.5.3. Фаза 3: flood fill (4-связность)
От каждого seed выполняется стековый обход соседей:
- влево, вправо, вверх, вниз;
- граничные пиксели не пересекаются;
- уже посещённые пиксели пропускаются.
Каждый найденный внутренний пиксель окрашивается в `fill_color`.
Почему используется отдельный буфер: при полупрозрачности иначе одна и та же область может смешаться несколько раз из-за пересечения сегментов.
### 6.6. Альфа-смешивание в Premultiplied Alpha (PMA)
Для каждого канала цвета применяется модель:
$$
C_{out} = C_{src} + (1-\alpha_{src})C_{dst},
\quad
\alpha_{out} = \alpha_{src} + (1-\alpha_{src})\alpha_{dst}.
$$
Обозначения:
- `C_src` - цвет источника в PMA (`r,g,b` уже домножены на альфу);
- `C_dst` - текущий цвет пикселя в буфере до смешивания;
- `C_out` - результат смешивания;
- `\alpha_src` - альфа источника (с учётом `transform.opacity`);
- `\alpha_dst` - альфа пикселя назначения;
- `\alpha_out` - итоговая альфа после композиции.
В коде `C_src` уже premultiplied (или домножается на opacity трансформа в момент смешивания).
Пошагово:
1. берётся альфа источника `a = src_a/255 * transform.opacity`;
2. вычисляется `inv_a = 1 - a`;
3. каналы `r,g,b` источника масштабируются на `transform.opacity`;
4. формируется новый `dst` по PMA-формуле.
`replace_mode = true` отключает смешивание и просто заменяет пиксель.
Этот режим используется во временных буферах shape-рендера, а затем результат один раз композится в целевой буфер.
### 6.7. Численная устойчивость и ограничения
В алгоритмах предусмотрены защиты от деградации вычислений:
- защита от деления на ноль в обратных преобразованиях (`scale == 0 -> 1.0`);
- использование `eps` в коррекции толщины линий;
- ограничение минимальных размеров рендер-буфера (`>= 1 px`);
- отсечение слишком больших выходов за viewport до начала растрирования;
- явное округление float->int в точках, где нужна стабильная пиксельная привязка.
Это снижает число визуальных артефактов при малых масштабах, сильных поворотах и частичной видимости объектов.
## 7. Работа с данными и сериализация
Модуль `src/persistence/json_io.zig` поддерживает:
- `saveToFile(...)` - сериализация в JSON (pretty-print);
- `loadFromFile(...)` - чтение JSON и восстановление структуры.
Для `Document` после парсинга выполняется клонирование, чтобы избежать проблем владения памятью (парсер выделяет память из арены).
## 8. Сборка, запуск и тестирование
### 8.1. Сборка и запуск
```bash
zig build
zig build run
```
### 8.2. Запуск тестов
```bash
zig build test
```
Файл `src/tests.zig` подключает модули с `test`-блоками, чтобы они выполнялись в составе общего тестового шага.
## 9. Автоматическое формирование приложения с исходным кодом
Чтобы не вставлять исходники вручную в конец отчёта, используется скрипт `Report/append_sources_to_report.py`.
Скрипт:
- читает исходный `.md` отчёт;
- добавляет раздел с кодом файлов проекта;
- перед каждым листингом вставляет путь файла;
- сохраняет результат в новый `.md` файл;
- исходный отчёт не изменяет.
Пример запуска:
```bash
python3 Report/append_sources_to_report.py \
--input Report/zivro-open-project-report.md \
--output Report/zivro-open-project-report-with-code.md \
--base .
```
## 10. PlantUML-диаграммы
Для отчёта подготовлены диаграммы в формате PlantUML (`.puml`) и сгенерированы их PNG-версии для прямой вставки в документ.
### 10.1. Архитектура проекта
`Report/uml/zivro-architecture-components.puml` - компонентная архитектура приложения.
![Компонентная архитектура Zivro](uml/zivro-architecture-components.png)
`Report/uml/zivro-render-sequence.puml` - последовательность рендера кадра.
![Последовательность рендера кадра](uml/zivro-render-sequence.png)
### 10.2. Управление холстом и viewport
`Report/uml/canvas-viewport-algorithm.puml` - вычисление видимой области, масштабирование по качеству, условия редроу.
![Алгоритм управления viewport и redraw](uml/canvas-viewport-algorithm.png)
### 10.3. Алгоритмы обработки данных и растризации
`Report/uml/transform-compose-algorithm.puml` - композиция трансформаций в иерархии объектов.
![Композиция трансформаций](uml/transform-compose-algorithm.png)
`Report/uml/coordinate-pipeline.puml` - конвейер преобразований координат.
![Конвейер преобразований координат](uml/coordinate-pipeline.png)
`Report/uml/line-rasterization-flow.puml` - полный алгоритм отрисовки линии.
![Полный алгоритм растрирования линии](uml/line-rasterization-flow.png)
`Report/uml/liang-barsky-clip.puml` - шаги отсечения отрезка методом Liang-Barsky.
![Алгоритм Liang-Barsky](uml/liang-barsky-clip.png)
`Report/uml/ellipse-arc-rasterization.puml` - растрирование эллипса/дуги.
![Алгоритм растрирования эллипса и дуги](uml/ellipse-arc-rasterization.png)
`Report/uml/polyline-fill-algorithm.puml` - построение и заливка замкнутой ломаной.
![Алгоритм ломаной и заливки](uml/polyline-fill-algorithm.png)
`Report/uml/pma-alpha-blending.puml` - PMA alpha-композиция пикселей.
![PMA alpha-композиция пикселя](uml/pma-alpha-blending.png)
### 10.4. Скрипт генерации PNG-диаграмм
Для повторной генерации PNG сохранён отдельный скрипт:
- `Report/render_uml_png.py`
Пример запуска в высоком качестве:
```bash
python3 Report/render_uml_png.py --input-dir Report/uml --dpi 360
```
## 11. Выводы
В ходе работы разработан и исследован проект `Zivro`, подготовлено структурированное описание его архитектуры. Проект реализует модульный подход: модель документа, иерархию объектов, CPU-рендер с преобразованиями координат и отдельные алгоритмы растеризации геометрии.
Ключевые математические части - композиция трансформаций, отсечение и растеризация линий, рендер эллипсов/дуг, а также алгоритм заливки замкнутых контуров.
Текстовый отчёт подготовлен без встроенных листингов кода; для генерации версии с приложением исходников создан отдельный автоматизированный скрипт.

0
assets/icons/.gitkeep Normal file
View File

View File

@@ -8,18 +8,19 @@ pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{ const exe = b.addExecutable(.{
.name = "Zivro", .name = "Zivro",
// .use_llvm = true,
// .use_lld = true,
.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,
.optimize = optimize, .optimize = optimize,
.imports = &.{ .imports = &.{
.{ .name = "dvui", .module = dvui_dep.module("dvui_sdl3") }, .{ .name = "dvui", .module = dvui_dep.module("dvui_sdl3") },
.{ .name = "sdl-backend", .module = dvui_dep.module("sdl3") }, .{ .name = "sdl-backend", .module = dvui_dep.module("sdl3") },
}, },
}), }),
}); });
exe.bundle_compiler_rt = true;
b.installArtifact(exe); b.installArtifact(exe);
const run_step = b.step("run", "Run the app"); const run_step = b.step("run", "Run the app");

View File

@@ -33,8 +33,8 @@
// internet connectivity. // internet connectivity.
.dependencies = .{ .dependencies = .{
.dvui = .{ .dvui = .{
.url = "https://github.com/david-vanderson/dvui/archive/main.tar.gz", .url = "git+https://github.com/david-vanderson/dvui#edb2d5a4cd2981fca74ee5f096277b91333c1316",
.hash = "dvui-0.4.0-dev-AQFJmev72QC6e0ojhnW8a_wRhZDgzWWLgeyoNuIPZc2m", .hash = "dvui-0.4.0-dev-AQFJmeGB3QAwun9qF76CDE5IopA4nUVRgD-IwwTsOo4H",
}, },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies. // See `zig fetch --save <url>` for a command-line interface for adding dependencies.

0
review.txt Normal file
View File

View File

@@ -3,15 +3,20 @@ const builtin = @import("builtin");
const dvui = @import("dvui"); const dvui = @import("dvui");
const Document = @import("models/Document.zig"); const Document = @import("models/Document.zig");
const RenderEngine = @import("render/RenderEngine.zig").RenderEngine; const RenderEngine = @import("render/RenderEngine.zig").RenderEngine;
const ImageRect = @import("models/basic_models.zig").ImageRect; const basic_models = @import("models/basic_models.zig");
const Point2 = @import("models/basic_models.zig").Point2; const Rect_i = basic_models.Rect_i;
const Size_i = basic_models.Size_i;
const Point2_f = @import("models/basic_models.zig").Point2_f;
const Color = dvui.Color; const Color = dvui.Color;
const tools = @import("toolbar/tools.zig");
const Toolbar = @import("toolbar/Toolbar.zig");
const random_document = @import("random_document.zig");
const Canvas = @This(); const Canvas = @This();
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
document: *Document, document: *Document,
render_engine: RenderEngine, render_engine: RenderEngine,
toolbar: Toolbar,
texture: ?dvui.Texture = null, texture: ?dvui.Texture = null,
pos: dvui.Point = dvui.Point{ .x = 400, .y = 400 }, pos: dvui.Point = dvui.Point{ .x = 400, .y = 400 },
scroll: dvui.ScrollInfo = .{ scroll: dvui.ScrollInfo = .{
@@ -19,34 +24,45 @@ scroll: dvui.ScrollInfo = .{
.horizontal = .auto, .horizontal = .auto,
}, },
native_scaling: bool = true, native_scaling: bool = true,
cursor_document_point: ?Point2_f = null,
draw_document: bool = true,
show_render_stats: bool = true,
/// Rect тулбара (из предыдущего кадра) для исключения кликов по нему из handleCanvasMouse.
toolbar_rect_scale: ?dvui.RectScale = null,
/// Rect панели свойств (из предыдущего кадра) для исключения кликов по нему из handleCanvasMouse.
properties_rect_scale: ?dvui.RectScale = null,
redraw_throttle_ms: u32 = 50, redraw_throttle_ms: u32 = 50,
_visible_rect: ?ImageRect = null, frame_index: u64 = 0,
_zoom: f32 = 1, _zoom: f32 = 1,
_rendering_quality: f32 = 100.0,
_last_redraw_time_ms: i64 = 0, // Метка последней перерисовки чтобы ограничить частоту
_visible_rect: ?Rect_i = null,
_redraw_pending: bool = false, _redraw_pending: bool = false,
_last_redraw_time_ms: i64 = 0,
cursor_document_point: ?Point2 = null,
pub fn init(allocator: std.mem.Allocator, document: *Document, engine: RenderEngine) Canvas { pub fn init(allocator: std.mem.Allocator, document: *Document, engine: RenderEngine) Canvas {
return .{ return .{
.allocator = allocator, .allocator = allocator,
.document = document, .document = document,
.render_engine = engine, .render_engine = engine,
.toolbar = Toolbar.init(&tools.default_tools),
}; };
} }
pub fn deinit(self: *Canvas) void { pub fn deinit(self: *Canvas) void {
self.toolbar.deinit();
if (self.texture) |texture| { if (self.texture) |texture| {
dvui.Texture.destroyLater(texture); dvui.Texture.destroyLater(texture);
self.texture = null; self.texture = null;
} }
} }
pub fn redrawExample(self: *Canvas) !void { fn redraw(self: *Canvas) !void {
const full = self.getZoomedImageSize(); const full = self.getZoomedImageSize();
const vis: ImageRect = self._visible_rect orelse ImageRect{ .x = 0, .y = 0, .w = 0, .h = 0 }; const vis_full: Rect_i = self._visible_rect orelse Rect_i{ .x = 0, .y = 0, .w = 0, .h = 0 };
if (vis.w == 0 or vis.h == 0) { if (vis_full.w == 0 or vis_full.h == 0) {
if (self.texture) |tex| { if (self.texture) |tex| {
dvui.Texture.destroyLater(tex); dvui.Texture.destroyLater(tex);
self.texture = null; self.texture = null;
@@ -54,7 +70,42 @@ pub fn redrawExample(self: *Canvas) !void {
return; return;
} }
const new_texture = self.render_engine.example(.{ .w = full.w, .h = full.h }, vis) catch null; // Качество рендеринга задаётся в процентах площади (1100),
// при этом фактически уменьшаем ширину/высоту холста на корень из этой доли.
const quality_percent: f32 = self.getRenderingQuality();
const quality_area: f32 = quality_percent / 100.0;
const quality_side: f32 = std.math.sqrt(quality_area);
const scale: f32 = std.math.clamp(quality_side, 0.01, 1.0);
const canvas_size: Size_i = .{
.w = @max(@as(u32, 1), @as(u32, @intFromFloat(@as(f32, @floatFromInt(full.w)) * scale))),
.h = @max(@as(u32, 1), @as(u32, @intFromFloat(@as(f32, @floatFromInt(full.h)) * scale))),
};
var vis_scaled = Rect_i{
.x = @as(u32, @intFromFloat(@as(f32, @floatFromInt(vis_full.x)) * scale)),
.y = @as(u32, @intFromFloat(@as(f32, @floatFromInt(vis_full.y)) * scale)),
.w = @max(@as(u32, 1), @as(u32, @intFromFloat(@as(f32, @floatFromInt(vis_full.w)) * scale))),
.h = @max(@as(u32, 1), @as(u32, @intFromFloat(@as(f32, @floatFromInt(vis_full.h)) * scale))),
};
if (vis_scaled.x >= canvas_size.w or vis_scaled.y >= canvas_size.h) {
if (self.texture) |tex| {
dvui.Texture.destroyLater(tex);
self.texture = null;
}
return;
}
const max_vis_w: u32 = canvas_size.w - vis_scaled.x;
const max_vis_h: u32 = canvas_size.h - vis_scaled.y;
if (vis_scaled.w > max_vis_w) vis_scaled.w = max_vis_w;
if (vis_scaled.h > max_vis_h) vis_scaled.h = max_vis_h;
const new_texture = if (self.draw_document)
self.render_engine.render(self.document, canvas_size, vis_scaled) catch null
else
self.render_engine.example(canvas_size, vis_scaled) catch null;
if (new_texture) |tex| { if (new_texture) |tex| {
if (self.texture) |old_tex| { if (self.texture) |old_tex| {
@@ -64,11 +115,18 @@ pub fn redrawExample(self: *Canvas) !void {
self.texture = tex; self.texture = tex;
} }
self._last_redraw_time_ms = std.time.milliTimestamp(); self._last_redraw_time_ms = std.time.milliTimestamp();
self.frame_index += 1;
self.redraw_throttle_ms = @max(1, @as(u32, @intCast(self.render_engine.getStats().render_time_ns / std.time.ns_per_ms / 3)));
} }
pub fn exampleReset(self: *Canvas) !void { pub fn exampleReset(self: *Canvas) !void {
self.render_engine.exampleReset(); self.render_engine.exampleReset();
try self.redrawExample(); try self.redraw();
}
pub fn addRandomShapes(self: *Canvas) !void {
try random_document.addRandomShapes(self.document, self.allocator, std.crypto.random);
self.requestRedraw();
} }
pub fn setZoom(self: *Canvas, value: f32) void { pub fn setZoom(self: *Canvas, value: f32) void {
@@ -80,6 +138,24 @@ pub fn addZoom(self: *Canvas, value: f32) void {
self._zoom = @max(self._zoom, 0.01); self._zoom = @max(self._zoom, 0.01);
} }
pub fn multZoom(self: *Canvas, value: f32) void {
self._zoom *= value;
self._zoom = @max(self._zoom, 0.01);
}
pub fn getZoom(self: Canvas) f32 {
return self._zoom;
}
pub fn setRenderingQuality(self: *Canvas, value: f32) void {
self._rendering_quality = std.math.clamp(value, 1.0, 100.0);
self.requestRedraw();
}
pub fn getRenderingQuality(self: Canvas) f32 {
return self._rendering_quality;
}
pub fn requestRedraw(self: *Canvas) void { pub fn requestRedraw(self: *Canvas) void {
self._redraw_pending = true; self._redraw_pending = true;
} }
@@ -88,43 +164,46 @@ pub fn processPendingRedraw(self: *Canvas) !void {
if (!self._redraw_pending) return; if (!self._redraw_pending) return;
if (self.redraw_throttle_ms == 0) { if (self.redraw_throttle_ms == 0) {
self._redraw_pending = false; self._redraw_pending = false;
try self.redrawExample(); try self.redraw();
return; return;
} }
const now_ms = std.time.milliTimestamp(); const now_ms = std.time.milliTimestamp();
const elapsed: i64 = if (self._last_redraw_time_ms == 0) self.redraw_throttle_ms else now_ms - self._last_redraw_time_ms; const elapsed: i64 = if (self._last_redraw_time_ms == 0) self.redraw_throttle_ms else now_ms - self._last_redraw_time_ms;
if (elapsed < @as(i64, @intCast(self.redraw_throttle_ms))) return; if (elapsed < @as(i64, @intCast(self.redraw_throttle_ms))) return;
self._redraw_pending = false; self._redraw_pending = false;
try self.redrawExample(); try self.redraw();
} }
pub fn getZoomedImageSize(self: Canvas) ImageRect { pub fn getZoomedImageSize(self: Canvas) Rect_i {
const doc = self.document; const doc = self.document;
return .{ return .{
.x = @intFromFloat(self.pos.x), .x = @intFromFloat(self.pos.x),
.y = @intFromFloat(self.pos.y), .y = @intFromFloat(self.pos.y),
.w = @intFromFloat(doc.size.width * self._zoom), .w = @intFromFloat(doc.size.w * self._zoom),
.h = @intFromFloat(doc.size.height * self._zoom), .h = @intFromFloat(doc.size.h * self._zoom),
}; };
} }
pub fn contentPointToDocument(self: Canvas, content_point: dvui.Point, natural_scale: f32) ?Point2 { /// Точка контента -> координаты документа.
pub fn contentPointToDocument(self: Canvas, content_point: dvui.Point, natural_scale: f32) Point2_f {
const img = self.getZoomedImageSize();
const px_x = content_point.x * natural_scale - @as(f32, @floatFromInt(img.x));
const px_y = content_point.y * natural_scale - @as(f32, @floatFromInt(img.y));
return .{
.x = px_x / self._zoom,
.y = px_y / self._zoom,
};
}
/// Точка контента внутри холста.
pub fn isContentPointOnDocument(self: Canvas, content_point: dvui.Point, natural_scale: f32) bool {
const img = self.getZoomedImageSize(); const img = self.getZoomedImageSize();
const left_n = @as(f32, @floatFromInt(img.x)) / natural_scale; const left_n = @as(f32, @floatFromInt(img.x)) / natural_scale;
const top_n = @as(f32, @floatFromInt(img.y)) / natural_scale; const top_n = @as(f32, @floatFromInt(img.y)) / natural_scale;
const right_n = @as(f32, @floatFromInt(img.x + img.w)) / natural_scale; const right_n = @as(f32, @floatFromInt(img.x + img.w)) / natural_scale;
const bottom_n = @as(f32, @floatFromInt(img.y + img.h)) / natural_scale; const bottom_n = @as(f32, @floatFromInt(img.y + img.h)) / natural_scale;
return content_point.x >= left_n and content_point.x < right_n and
if (content_point.x < left_n or content_point.x >= right_n or content_point.y >= top_n and content_point.y < bottom_n;
content_point.y < top_n or content_point.y >= bottom_n)
return null;
const px_x = content_point.x * natural_scale - @as(f32, @floatFromInt(img.x));
const px_y = content_point.y * natural_scale - @as(f32, @floatFromInt(img.y));
return Point2{
.x = px_x / self._zoom,
.y = px_y / self._zoom,
};
} }
pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) bool { pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) bool {
@@ -134,16 +213,13 @@ 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; changed |= next.x != vis.x or next.y != vis.y or next.w != vis.w or next.h != vis.h;
} }
self._visible_rect = next; 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) { if (changed or self.texture == null) {
return true; return true;
} }
return false; return false;
} }
fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) ImageRect { fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) Rect_i {
const image_rect = self.getZoomedImageSize(); const image_rect = self.getZoomedImageSize();
const img_w: u32 = image_rect.w; const img_w: u32 = image_rect.w;
@@ -158,7 +234,7 @@ fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvu
const vis_x: u32 = @intCast(std.math.clamp(raw_x, 0, @as(i64, img_w) - @as(i64, vis_w))); const vis_x: u32 = @intCast(std.math.clamp(raw_x, 0, @as(i64, img_w) - @as(i64, vis_w)));
const vis_y: u32 = @intCast(std.math.clamp(raw_y, 0, @as(i64, img_h) - @as(i64, vis_h))); const vis_y: u32 = @intCast(std.math.clamp(raw_y, 0, @as(i64, img_h) - @as(i64, vis_h)));
return ImageRect{ return Rect_i{
.x = vis_x, .x = vis_x,
.y = vis_y, .y = vis_y,
.w = vis_w, .w = vis_w,

View File

@@ -3,6 +3,7 @@ const Canvas = @import("Canvas.zig");
const CpuRenderEngine = @import("render/CpuRenderEngine.zig"); const CpuRenderEngine = @import("render/CpuRenderEngine.zig");
const RenderEngine = @import("render/RenderEngine.zig").RenderEngine; const RenderEngine = @import("render/RenderEngine.zig").RenderEngine;
const Document = @import("models/Document.zig"); const Document = @import("models/Document.zig");
const random_document = @import("random_document.zig");
const basic_models = @import("models/basic_models.zig"); const basic_models = @import("models/basic_models.zig");
const WindowContext = @This(); const WindowContext = @This();
@@ -11,32 +12,42 @@ pub const OpenDocument = struct {
document: Document, document: Document,
cpu_render: CpuRenderEngine, cpu_render: CpuRenderEngine,
canvas: Canvas, canvas: Canvas,
/// Выбранный объект в дереве (id объекта).
selected_object_id: ?u64 = 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{ .width = 800, .height = 600 }; initWithDocument(allocator, self, .init(.{
self.document = Document.init(allocator, default_size); .w = 800,
self.cpu_render = CpuRenderEngine.init(allocator, .Squares); .h = 600,
self.canvas = Canvas.init(allocator, &self.document, (&self.cpu_render).renderEngine()); }));
} }
pub fn deinit(self: *OpenDocument) void { pub fn initWithDocument(allocator: std.mem.Allocator, self: *OpenDocument, doc: Document) void {
self.document.deinit(); self.document = doc;
self.cpu_render = CpuRenderEngine.init(allocator, .Squares);
self.canvas = Canvas.init(
allocator,
&self.document,
(&self.cpu_render).renderEngine(),
);
self.selected_object_id = null;
}
pub fn deinit(self: *OpenDocument, allocator: std.mem.Allocator) void {
self.document.deinit(allocator);
self.canvas.deinit(); self.canvas.deinit();
} }
}; };
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
frame_index: u64,
documents: std.ArrayList(*OpenDocument), documents: std.ArrayList(*OpenDocument),
active_document_index: ?usize, active_document_index: ?usize,
pub fn init(allocator: std.mem.Allocator) !WindowContext { pub fn init(allocator: std.mem.Allocator) !WindowContext {
const frame_index: u64 = 0;
const documents = std.ArrayList(*OpenDocument).empty; const documents = std.ArrayList(*OpenDocument).empty;
const active_document_index: ?usize = null; const active_document_index: ?usize = null;
return .{ return .{
.allocator = allocator, .allocator = allocator,
.frame_index = frame_index,
.documents = documents, .documents = documents,
.active_document_index = active_document_index, .active_document_index = active_document_index,
}; };
@@ -44,7 +55,7 @@ pub fn init(allocator: std.mem.Allocator) !WindowContext {
pub fn deinit(self: *WindowContext) void { pub fn deinit(self: *WindowContext) void {
for (self.documents.items) |ptr| { for (self.documents.items) |ptr| {
ptr.deinit(); ptr.deinit(self.allocator);
self.allocator.destroy(ptr); self.allocator.destroy(ptr);
} }
self.documents.deinit(self.allocator); self.documents.deinit(self.allocator);
@@ -60,7 +71,17 @@ pub fn addNewDocument(self: *WindowContext) !void {
const ptr = try self.allocator.create(OpenDocument); const ptr = try self.allocator.create(OpenDocument);
errdefer self.allocator.destroy(ptr); errdefer self.allocator.destroy(ptr);
OpenDocument.init(self.allocator, ptr); OpenDocument.init(self.allocator, ptr);
try ptr.document.addRandomShapes(std.crypto.random); //try random_document.addRandomShapes(&ptr.document, std.crypto.random);
try self.documents.append(self.allocator, ptr);
self.active_document_index = self.documents.items.len - 1;
}
pub fn addDocument(self: *WindowContext, doc: Document) !void {
const ptr = try self.allocator.create(OpenDocument);
errdefer self.allocator.destroy(ptr);
var doc_mut = doc;
errdefer doc_mut.deinit(self.allocator);
OpenDocument.initWithDocument(self.allocator, ptr, doc_mut);
try self.documents.append(self.allocator, ptr); try self.documents.append(self.allocator, ptr);
self.active_document_index = self.documents.items.len - 1; self.active_document_index = self.documents.items.len - 1;
} }
@@ -74,7 +95,7 @@ pub fn setActiveDocument(self: *WindowContext, index: usize) void {
pub fn closeDocument(self: *WindowContext, index: usize) void { pub fn closeDocument(self: *WindowContext, index: usize) void {
if (index >= self.documents.items.len) return; if (index >= self.documents.items.len) return;
const open_doc = self.documents.items[index]; const open_doc = self.documents.items[index];
open_doc.deinit(); open_doc.deinit(self.allocator);
self.allocator.destroy(open_doc); self.allocator.destroy(open_doc);
_ = self.documents.orderedRemove(index); _ = self.documents.orderedRemove(index);

8
src/icons.zig Normal file
View File

@@ -0,0 +1,8 @@
const dvui = @import("dvui");
pub const line = dvui.entypo.flow_line;
pub const ellipse = dvui.entypo.circle;
pub const broken = dvui.entypo.line_graph;
pub const trash = dvui.entypo.trash;
pub const cross = dvui.entypo.cross;
pub const plus = dvui.entypo.plus;

View File

@@ -5,7 +5,10 @@ const WindowContext = @import("WindowContext.zig");
const ui = @import("ui/frame.zig"); const ui = @import("ui/frame.zig");
pub fn main() !void { pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; // std.heap.GeneralPurposeAllocator was renamed to DebugAllocator recently.
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator(); const allocator = gpa.allocator();
var backend = try SDLBackend.initWindow(.{ var backend = try SDLBackend.initWindow(.{

View File

@@ -1,78 +1,132 @@
const std = @import("std"); const std = @import("std");
const basic_models = @import("basic_models.zig"); const basic_models = @import("basic_models.zig");
const Size = basic_models.Size; const Size_f = basic_models.Size_f;
const Document = @This(); const Document = @This();
pub const Object = @import("Object.zig"); pub const Object = @import("Object.zig");
const shape = @import("shape/shape.zig"); const shape = @import("shape/shape.zig");
size: Size, size: Size_f,
allocator: std.mem.Allocator,
objects: std.ArrayList(Object), objects: std.ArrayList(Object),
next_object_id: u64,
pub fn init(allocator: std.mem.Allocator, size: Size) Document { pub fn init(size: Size_f) Document {
return .{ return .{
.size = size, .size = size,
.allocator = allocator,
.objects = std.ArrayList(Object).empty, .objects = std.ArrayList(Object).empty,
.next_object_id = 1,
}; };
} }
pub fn deinit(self: *Document) void { pub fn deinit(self: *Document, allocator: std.mem.Allocator) void {
for (self.objects.items) |*obj| obj.deinit(self.allocator); for (self.objects.items) |*obj| obj.deinit(allocator);
self.objects.deinit(self.allocator); self.objects.deinit(allocator);
} }
pub fn addObject(self: *Document, template: Object) !void { pub fn clone(self: *const Document, allocator: std.mem.Allocator) !Document {
const obj = try template.clone(self.allocator); var objects_list = std.ArrayList(Object).empty;
try self.objects.append(self.allocator, obj); errdefer {
for (objects_list.items) |*obj| obj.deinit(allocator);
objects_list.deinit(allocator);
}
var next_id = self.next_object_id;
for (self.objects.items) |obj| {
try objects_list.append(allocator, try obj.clone(allocator, &next_id));
}
return .{
.size = self.size,
.objects = objects_list,
.next_object_id = next_id,
};
} }
pub fn addShape(self: *Document, parent: ?*Object, shape_kind: Object.ShapeKind) !void { pub fn addObject(self: *Document, allocator: std.mem.Allocator, template: Object) !void {
const obj = try shape.createObject(self.allocator, shape_kind); const obj = try template.clone(allocator, &self.next_object_id);
try self.objects.append(allocator, obj);
}
/// Добавляет объект в документ: как ребёнка родителя (если id найден), иначе в корень.
pub fn addObjectUnderParentId(self: *Document, allocator: std.mem.Allocator, parent_id: ?u64, template: Object) !void {
if (parent_id) |id| {
if (self.findObjectById(id)) |parent| {
try parent.addChild(allocator, template, &self.next_object_id);
return;
}
}
try self.addObject(allocator, template);
}
pub fn addShape(self: *Document, allocator: std.mem.Allocator, parent: ?*Object, shape_kind: Object.ShapeKind) !void {
const obj = try shape.createObject(allocator, shape_kind);
if (parent) |p| { if (parent) |p| {
try p.addChild(self.allocator, obj); try p.addChild(allocator, obj, &self.next_object_id);
} else { } else {
try self.addObject(obj); try self.addObject(allocator, obj);
} }
} }
fn randomShapeKind(rng: std.Random) Object.ShapeKind { /// Удаляет объект из документа (из корня или из детей родителя). Возвращает true, если объект был найден и удалён.
const shapes_implemented = [_]Object.ShapeKind{ .line, .ellipse, .broken }; pub fn removeObject(self: *Document, allocator: std.mem.Allocator, obj: *Object) bool {
return shapes_implemented[rng.intRangeLessThan(usize, 0, shapes_implemented.len)]; for (self.objects.items, 0..) |*item, i| {
if (item == obj) {
var removed = self.objects.orderedRemove(i);
removed.deinit(allocator);
return true;
}
if (removeFromChildren(allocator, &item.children, obj)) return true;
}
return false;
} }
/// Создаёт случайное количество фигур в документе (в т.ч. вложенных). /// Удаляет объект по id. Возвращает true, если объект был найден и удалён.
/// Используются только реализованные типы: line, ellipse, broken. pub fn removeObjectById(self: *Document, allocator: std.mem.Allocator, obj_id: u64) bool {
/// Ограничение max_total предотвращает экспоненциальный рост и переполнение. for (self.objects.items, 0..) |*item, i| {
pub fn addRandomShapes(self: *Document, rng: std.Random) !void { if (item.id == obj_id) {
const max_total: usize = 80; var removed = self.objects.orderedRemove(i);
var total_count: usize = 0; removed.deinit(allocator);
return true;
const n_root = rng.intRangeLessThan(usize, 1, 5);
for (0..n_root) |_| {
if (total_count >= max_total) break;
try self.addShape(null, randomShapeKind(rng));
total_count += 1;
}
var stack = std.ArrayList(*Object).empty;
defer stack.deinit(self.allocator);
for (self.objects.items) |*obj| {
try stack.append(self.allocator, obj);
}
while (stack.pop()) |obj| {
if (total_count >= max_total) continue;
const n_children = rng.intRangeLessThan(usize, 0, 2);
const base_len = obj.children.items.len;
for (0..n_children) |_| {
if (total_count >= max_total) break;
try self.addShape(obj, randomShapeKind(rng));
total_count += 1;
}
// Пушим в стек только после всех append, чтобы не держать указатели при реаллокации obj.children
for (obj.children.items[base_len..]) |*child| {
try stack.append(self.allocator, child);
} }
if (removeFromChildrenById(allocator, &item.children, obj_id)) return true;
} }
return false;
}
pub fn findObjectById(self: *Document, obj_id: u64) ?*Object {
for (self.objects.items) |*item| {
if (item.id == obj_id) return item;
if (findInChildrenById(&item.children, obj_id)) |found| return found;
}
return null;
}
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;
}
fn removeFromChildrenById(allocator: std.mem.Allocator, children: *std.ArrayList(Object), obj_id: u64) bool {
for (children.items, 0..) |*item, i| {
if (item.id == obj_id) {
var removed = children.orderedRemove(i);
removed.deinit(allocator);
return true;
}
if (removeFromChildrenById(allocator, &item.children, obj_id)) return true;
}
return false;
}
fn findInChildrenById(children: *std.ArrayList(Object), obj_id: u64) ?*Object {
for (children.items) |*item| {
if (item.id == obj_id) return item;
if (findInChildrenById(&item.children, obj_id)) |found| return found;
}
return null;
} }

View File

@@ -6,38 +6,58 @@ const Object = @This();
pub const ShapeKind = enum { pub const ShapeKind = enum {
line, line,
ellipse, ellipse,
arc,
broken, broken,
}; };
const default_common_data = [_]PropertyData{
.{ .position = .{ .x = 0, .y = 0 } },
.{ .angle = 0 },
.{ .scale = .{ .scale_x = 1, .scale_y = 1 } },
.{ .visible = true },
.{ .opacity = 1.0 },
.{ .locked = false },
.{ .stroke_rgba = 0x000000FF }, // чёрный, полная непрозрачность
.{ .thickness = 2.0 },
};
pub const defaultCommonProperties: [default_common_data.len]Property = blk: {
var result: [default_common_data.len]Property = undefined;
for (default_common_data, &result) |d, *p| {
p.* = .{ .data = d };
}
break :blk result;
};
id: u64,
shape: ShapeKind, shape: ShapeKind,
properties: std.ArrayList(Property), properties: std.ArrayList(Property),
children: std.ArrayList(Object), children: std.ArrayList(Object),
pub fn getProperty(self: Object, tag: std.meta.Tag(PropertyData)) ?*const PropertyData { pub fn getProperty(self: Object, comptime tag: std.meta.Tag(PropertyData)) ?@FieldType(PropertyData, @tagName(tag)) {
for (self.properties.items) |*prop| { for (self.properties.items) |*prop| {
if (std.meta.activeTag(prop.data) == tag) return &prop.data; if (std.meta.activeTag(prop.data) == tag) return @field(prop.data, @tagName(tag));
} }
return null; return null;
} }
/// Забирает владение Property
pub fn setProperty(self: *Object, allocator: std.mem.Allocator, prop: Property) !void { pub fn setProperty(self: *Object, allocator: std.mem.Allocator, prop: Property) !void {
for (self.properties.items, 0..) |*p, i| { for (self.properties.items, 0..) |*p, i| {
if (std.meta.activeTag(p.data) == std.meta.activeTag(prop.data)) { if (std.meta.activeTag(p.data) == std.meta.activeTag(prop.data)) {
if (p.data == .points) p.data.points.deinit(allocator); if (p.data == .points) allocator.free(p.data.points);
self.properties.items[i] = prop; self.properties.items[i] = prop;
return; return;
} }
} }
try self.properties.append(allocator, prop); return error.PropertyNotFound;
} }
pub fn addChild(self: *Object, allocator: std.mem.Allocator, template: Object) !void { pub fn addChild(self: *Object, allocator: std.mem.Allocator, template: Object, next_id: *u64) !void {
const obj = try template.clone(allocator); const obj = try template.clone(allocator, next_id);
try self.children.append(allocator, obj); try self.children.append(allocator, obj);
} }
pub fn clone(self: Object, allocator: std.mem.Allocator) !Object { pub fn clone(self: Object, allocator: std.mem.Allocator, next_id: *u64) !Object {
var properties_list = std.ArrayList(Property).empty; var properties_list = std.ArrayList(Property).empty;
errdefer properties_list.deinit(allocator); errdefer properties_list.deinit(allocator);
for (self.properties.items) |prop| { for (self.properties.items) |prop| {
@@ -47,16 +67,23 @@ pub fn clone(self: Object, allocator: std.mem.Allocator) !Object {
var children_list = std.ArrayList(Object).empty; var children_list = std.ArrayList(Object).empty;
errdefer children_list.deinit(allocator); errdefer children_list.deinit(allocator);
for (self.children.items) |child| { for (self.children.items) |child| {
try children_list.append(allocator, try child.clone(allocator)); try children_list.append(allocator, try child.clone(allocator, next_id));
} }
return .{ return .{
.id = allocId(next_id),
.shape = self.shape, .shape = self.shape,
.properties = properties_list, .properties = properties_list,
.children = children_list, .children = children_list,
}; };
} }
fn allocId(next_id: *u64) u64 {
const id = next_id.*;
next_id.* += 1;
return id;
}
pub fn deinit(self: *Object, allocator: std.mem.Allocator) void { pub fn deinit(self: *Object, allocator: std.mem.Allocator) void {
for (self.children.items) |*child| child.deinit(allocator); for (self.children.items) |*child| child.deinit(allocator);
self.children.deinit(allocator); self.children.deinit(allocator);

View File

@@ -1,26 +1,38 @@
const std = @import("std"); const std = @import("std");
const basic_models = @import("basic_models.zig"); const basic_models = @import("basic_models.zig");
const Point2 = basic_models.Point2; const Point2_f = basic_models.Point2_f;
const Scale2 = basic_models.Scale2; const Scale2_f = basic_models.Scale2_f;
const Size = basic_models.Size; const Size_f = basic_models.Size_f;
const Radii = basic_models.Radii; const Radii_f = basic_models.Radii_f;
pub const Data = union(enum) { pub const Data = union(enum) {
position: Point2, position: Point2_f,
angle: f32, angle: f32,
scale: Scale2, scale: Scale2_f,
visible: bool, visible: bool,
opacity: f32, opacity: f32,
locked: bool, locked: bool,
size: Size, size: Size_f,
radii: Radii, radii: Radii_f,
end_point: Point2, /// Процент дуги эллипса: 100 — полный эллипс, 50 — полуэллипс (0..100).
arc_percent: f32,
end_point: Point2_f,
points: std.ArrayList(Point2), /// Владеет памятью; при deinit/clone — free/duplicate.
points: []const Point2_f,
/// Замкнутый контур (для ломаной: отрезок последняя–первая точка + заливка).
closed: bool,
/// Включена ли заливка.
filled: bool,
/// Цвет заливки, 0xRRGGBBAA.
fill_rgba: u32, fill_rgba: u32,
/// Цвет обводки, 0xRRGGBBAA.
stroke_rgba: u32, stroke_rgba: u32,
thickness: f32,
}; };
pub const Property = struct { pub const Property = struct {
@@ -28,7 +40,7 @@ pub const Property = struct {
pub fn deinit(self: *Property, allocator: std.mem.Allocator) void { pub fn deinit(self: *Property, allocator: std.mem.Allocator) void {
switch (self.data) { switch (self.data) {
.points => |*list| list.deinit(allocator), .points => |slice| allocator.free(slice),
else => {}, else => {},
} }
self.* = undefined; self.* = undefined;
@@ -36,42 +48,15 @@ pub const Property = struct {
pub fn clone(self: Property, allocator: std.mem.Allocator) !Property { pub fn clone(self: Property, allocator: std.mem.Allocator) !Property {
return switch (self.data) { return switch (self.data) {
.points => |list| .{ .points => |slice| .{
.data = .{ .data = .{
.points = try list.clone(allocator), .points = blk: {
const copy = try allocator.dupe(Point2_f, slice);
break :blk copy;
},
}, },
}, },
else => .{ .data = self.data }, else => .{ .data = self.data },
}; };
} }
}; };
const default_common_data = [_]Data{
.{ .position = .{ .x = 0, .y = 0 } },
.{ .angle = 0 },
.{ .scale = .{ .scale_x = 1, .scale_y = 1 } },
.{ .visible = true },
.{ .opacity = 1.0 },
.{ .locked = false },
};
pub const defaultCommonProperties: [default_common_data.len]Property = blk: {
var result: [default_common_data.len]Property = undefined;
for (default_common_data, &result) |d, *p| {
p.* = .{ .data = d };
}
break :blk result;
};
test "Property wrapper and Data" {
const p = Property{ .data = .{ .opacity = 0.5 } };
try std.testing.expect(p.data == .opacity);
try std.testing.expect(p.data.opacity == 0.5);
}
test "common properties" {
try std.testing.expect(defaultCommonProperties[0].data == .position);
try std.testing.expect(defaultCommonProperties[0].data.position.x == 0);
try std.testing.expect(defaultCommonProperties[3].data == .visible);
try std.testing.expect(defaultCommonProperties[3].data.visible == true);
}

View File

@@ -1,37 +1,41 @@
pub const ImageRect = struct { pub const Rect_i = struct {
x: u32, x: u32,
y: u32, y: u32,
w: u32, w: u32,
h: u32, h: u32,
}; };
pub const ImageSize = struct { pub const Size_i = struct {
w: u32, w: u32,
h: u32, h: u32,
}; };
pub const Size = struct { pub const Size_f = struct {
width: f32, w: f32,
height: f32, h: f32,
}; };
pub const Point2 = struct { pub const Point2_f = struct {
x: f32 = 0, x: f32 = 0,
y: f32 = 0, y: f32 = 0,
}; };
pub const Radii = struct { pub const Point2_i = struct {
x: i32 = 0,
y: i32 = 0,
};
pub const Radii_f = struct {
x: f32, x: f32,
y: f32, y: f32,
}; };
pub const Scale2 = struct { pub const Scale2_f = struct {
scale_x: f32 = 1, scale_x: f32 = 1,
scale_y: f32 = 1, scale_y: f32 = 1,
}; };
/// Прямоугольник в координатах документа (f32), например локальные границы объекта. pub const Rect_f = struct {
pub const Rect = struct {
x: f32 = 0, x: f32 = 0,
y: f32 = 0, y: f32 = 0,
w: f32 = 0, w: f32 = 0,

View File

@@ -1,23 +0,0 @@
const std = @import("std");
const Object = @import("../Object.zig");
const PropertyData = @import("../Property.zig").Data;
const Rect = @import("../basic_models.zig").Rect;
const shape_mod = @import("shape.zig");
/// Теги обязательных свойств (заглушка: arc пока не реализован).
pub fn getRequiredTags() []const std.meta.Tag(PropertyData) {
return &[_]std.meta.Tag(PropertyData){};
}
/// Добавляет свойства по умолчанию для дуги (заглушка).
pub fn appendDefaultShapeProperties(allocator: std.mem.Allocator, obj: *Object) !void {
_ = allocator;
_ = obj;
return error.ArcNotImplemented;
}
/// Локальные границы дуги (заглушка: пока не реализовано).
pub fn getLocalBounds(obj: *const Object) !Rect {
try shape_mod.ensure(obj, .arc);
return error.ArcNotImplemented;
}

View File

@@ -2,48 +2,19 @@ const std = @import("std");
const Object = @import("../Object.zig"); const Object = @import("../Object.zig");
const Property = @import("../Property.zig").Property; const Property = @import("../Property.zig").Property;
const PropertyData = @import("../Property.zig").Data; const PropertyData = @import("../Property.zig").Data;
const Point2 = @import("../basic_models.zig").Point2; const Point2_f = @import("../basic_models.zig").Point2_f;
const Rect = @import("../basic_models.zig").Rect; const Rect_f = @import("../basic_models.zig").Rect_f;
const shape_mod = @import("shape.zig"); const shape_mod = @import("shape.zig");
/// Точки ломаной по умолчанию (для создания). /// Свойства фигуры по умолчанию (добавляются к общим). points — слайс на статический массив.
pub const default_points = [_]Point2{ pub const default_shape_properties_points = [_]Point2_f{
.{ .x = 0, .y = 0 }, .{ .x = 0, .y = 0 },
.{ .x = 80, .y = 0 }, .{ .x = 80, .y = 0 },
.{ .x = 80, .y = 60 }, .{ .x = 80, .y = 60 },
}; };
pub const default_shape_properties = [_]Property{
/// Теги обязательных свойств (у ломаной нет const default_shape_properties, только default_points). .{ .data = .{ .points = &default_shape_properties_points } },
pub fn getRequiredTags() []const std.meta.Tag(PropertyData) { .{ .data = .{ .closed = false } },
return &[_]std.meta.Tag(PropertyData){.points}; .{ .data = .{ .filled = true } },
} .{ .data = .{ .fill_rgba = 0x000000FF } },
};
/// Добавляет к объекту свойства по умолчанию для ломаной (points из default_points).
pub fn appendDefaultShapeProperties(allocator: std.mem.Allocator, obj: *Object) !void {
var points = std.ArrayList(Point2).empty;
try points.appendSlice(allocator, &default_points);
try obj.properties.append(allocator, .{ .data = .{ .points = points } });
}
/// Локальные границы ломаной: AABB по всем точкам.
pub fn getLocalBounds(obj: *const Object) !Rect {
try shape_mod.ensure(obj, .broken);
const p = obj.getProperty(.points).?;
if (p.points.items.len == 0) return error.EmptyPoints;
var min_x: f32 = p.points.items[0].x;
var max_x: f32 = min_x;
var min_y: f32 = p.points.items[0].y;
var max_y: f32 = min_y;
for (p.points.items[1..]) |pt| {
min_x = @min(min_x, pt.x);
max_x = @max(max_x, pt.x);
min_y = @min(min_y, pt.y);
max_y = @max(max_y, pt.y);
}
return .{
.x = min_x,
.y = min_y,
.w = max_x - min_x,
.h = max_y - min_y,
};
}

View File

@@ -1,33 +1,14 @@
const std = @import("std"); const std = @import("std");
const Object = @import("../Object.zig"); const Object = @import("../Object.zig");
const Property = @import("../Property.zig").Property; const Property = @import("../Property.zig").Property;
const PropertyData = @import("../Property.zig").Data; const Rect_f = @import("../basic_models.zig").Rect_f;
const Rect = @import("../basic_models.zig").Rect;
const shape_mod = @import("shape.zig"); const shape_mod = @import("shape.zig");
/// Свойства фигуры по умолчанию (для создания и проверки типа). Теги для ensure выводятся отсюда. /// Свойства фигуры по умолчанию (добавляются к общим).
pub const default_shape_properties = [_]Property{ pub const default_shape_properties = [_]Property{
.{ .data = .{ .radii = .{ .x = 50, .y = 50 } } }, .{ .data = .{ .radii = .{ .x = 50, .y = 50 } } },
.{ .data = .{ .arc_percent = 100.0 } },
.{ .data = .{ .closed = true } },
.{ .data = .{ .filled = false } },
.{ .data = .{ .fill_rgba = 0x000000FF } },
}; };
/// Теги обязательных свойств (выводятся из default_shape_properties).
pub fn getRequiredTags() []const std.meta.Tag(PropertyData) {
return &([_]std.meta.Tag(PropertyData){std.meta.activeTag(default_shape_properties[0].data)});
}
/// Добавляет к объекту свойства по умолчанию для эллипса.
pub fn appendDefaultShapeProperties(allocator: std.mem.Allocator, obj: *Object) !void {
for (default_shape_properties) |prop| try obj.properties.append(allocator, prop);
}
/// Локальные границы эллипса: [-radii.x, -radii.y] .. [radii.x, radii.y].
pub fn getLocalBounds(obj: *const Object) !Rect {
try shape_mod.ensure(obj, .ellipse);
const r = obj.getProperty(.radii).?;
return .{
.x = -r.radii.x,
.y = -r.radii.y,
.w = 2 * r.radii.x,
.h = 2 * r.radii.y,
};
}

View File

@@ -1,37 +1,10 @@
const std = @import("std"); const std = @import("std");
const Object = @import("../Object.zig"); const Object = @import("../Object.zig");
const Property = @import("../Property.zig").Property; const Property = @import("../Property.zig").Property;
const PropertyData = @import("../Property.zig").Data; const Rect_f = @import("../basic_models.zig").Rect_f;
const Rect = @import("../basic_models.zig").Rect;
const shape_mod = @import("shape.zig"); const shape_mod = @import("shape.zig");
/// Свойства фигуры по умолчанию (для создания и проверки типа). Теги для ensure выводятся отсюда. /// Свойства фигуры по умолчанию (добавляются к общим).
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 } } },
}; };
/// Теги обязательных свойств (выводятся из default_shape_properties).
pub fn getRequiredTags() []const std.meta.Tag(PropertyData) {
return &([_]std.meta.Tag(PropertyData){std.meta.activeTag(default_shape_properties[0].data)});
}
/// Добавляет к объекту свойства по умолчанию для линии.
pub fn appendDefaultShapeProperties(allocator: std.mem.Allocator, obj: *Object) !void {
for (default_shape_properties) |prop| try obj.properties.append(allocator, prop);
}
/// Локальные границы линии: от (0,0) до end_point.
pub fn getLocalBounds(obj: *const Object) !Rect {
try shape_mod.ensure(obj, .line);
const ep = obj.getProperty(.end_point).?;
const min_x = @min(0, ep.end_point.x);
const max_x = @max(0, ep.end_point.x);
const min_y = @min(0, ep.end_point.y);
const max_y = @max(0, ep.end_point.y);
return .{
.x = min_x,
.y = min_y,
.w = max_x - min_x,
.h = max_y - min_y,
};
}

View File

@@ -2,24 +2,38 @@ const std = @import("std");
const Object = @import("../Object.zig"); const Object = @import("../Object.zig");
const Property = @import("../Property.zig").Property; const Property = @import("../Property.zig").Property;
const PropertyData = @import("../Property.zig").Data; const PropertyData = @import("../Property.zig").Data;
const defaultCommonProperties = @import("../Property.zig").defaultCommonProperties; const defaultCommonProperties = Object.defaultCommonProperties;
const basic_models = @import("../basic_models.zig"); const basic_models = @import("../basic_models.zig");
const Point2_f = basic_models.Point2_f;
const line = @import("line.zig"); const line = @import("line.zig");
const ellipse = @import("ellipse.zig"); const ellipse = @import("ellipse.zig");
const broken = @import("broken.zig"); const broken = @import("broken.zig");
const arc = @import("arc.zig"); pub const Rect = basic_models.Rect_f;
pub const Rect = basic_models.Rect; /// Добавляет к объекту список свойств фигуры. Для .points дублирует слайс (объект владеет).
fn appendShapeProperties(allocator: std.mem.Allocator, obj: *Object, props: []const Property) !void {
for (props) |prop| {
if (prop.data == .points) {
const pts = prop.data.points;
const copy = try allocator.dupe(Point2_f, pts);
try obj.properties.append(allocator, .{ .data = .{ .points = copy } });
} else {
try obj.properties.append(allocator, prop);
}
}
}
/// Создаёт объект с общими свойствами по умолчанию и специфичными для типа фигуры. /// Создаёт объект с дефолтными общими и фигурными свойствами.
pub fn createObject(allocator: std.mem.Allocator, shape_kind: Object.ShapeKind) !Object { pub fn createObject(allocator: std.mem.Allocator, shape_kind: Object.ShapeKind) !Object {
var obj = try createWithCommonProperties(allocator, shape_kind); var obj = try createWithCommonProperties(allocator, shape_kind);
errdefer obj.deinit(allocator); errdefer obj.deinit(allocator);
switch (shape_kind) { switch (shape_kind) {
.line => try line.appendDefaultShapeProperties(allocator, &obj), .line => try appendShapeProperties(allocator, &obj, &line.default_shape_properties),
.ellipse => try ellipse.appendDefaultShapeProperties(allocator, &obj), .ellipse => try appendShapeProperties(allocator, &obj, &ellipse.default_shape_properties),
.broken => try broken.appendDefaultShapeProperties(allocator, &obj), .broken => {
.arc => try arc.appendDefaultShapeProperties(allocator, &obj), try appendShapeProperties(allocator, &obj, &broken.default_shape_properties);
try obj.setProperty(allocator, .{ .data = .{ .fill_rgba = obj.getProperty(.stroke_rgba).? } });
},
} }
return obj; return obj;
} }
@@ -29,67 +43,9 @@ fn createWithCommonProperties(allocator: std.mem.Allocator, shape_kind: Object.S
errdefer properties_list.deinit(allocator); errdefer properties_list.deinit(allocator);
for (defaultCommonProperties) |prop| try properties_list.append(allocator, prop); for (defaultCommonProperties) |prop| try properties_list.append(allocator, prop);
return .{ return .{
.id = 0,
.shape = shape_kind, .shape = shape_kind,
.properties = properties_list, .properties = properties_list,
.children = std.ArrayList(Object).empty, .children = std.ArrayList(Object).empty,
}; };
} }
/// Проверяет, что объект имеет ожидаемый тип и все требуемые для этого типа свойства.
pub fn ensure(obj: *const Object, expected_kind: Object.ShapeKind) !void {
if (obj.shape != expected_kind) return error.WrongShapeKind;
const tags = requiredTagsFor(expected_kind);
for (tags) |tag| {
if (obj.getProperty(tag) == null) return error.MissingRequiredProperty;
}
}
fn requiredTagsFor(kind: Object.ShapeKind) []const std.meta.Tag(PropertyData) {
return switch (kind) {
.line => line.getRequiredTags(),
.ellipse => ellipse.getRequiredTags(),
.broken => broken.getRequiredTags(),
.arc => arc.getRequiredTags(),
};
}
/// Локальные границы объекта (AABB в своих координатах).
pub fn getLocalBounds(obj: *const Object) !Rect {
return switch (obj.shape) {
.line => line.getLocalBounds(obj),
.ellipse => ellipse.getLocalBounds(obj),
.broken => broken.getLocalBounds(obj),
.arc => arc.getLocalBounds(obj),
};
}
test "getLocalBounds" {
const shape = @This();
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var line_obj = try shape.createObject(allocator, .line);
defer line_obj.deinit(allocator);
const line_bounds = try getLocalBounds(&line_obj);
try std.testing.expect(line_bounds.x == 0);
try std.testing.expect(line_bounds.y == 0);
try std.testing.expect(line_bounds.w == 100);
try std.testing.expect(line_bounds.h == 0);
var ellipse_obj = try shape.createObject(allocator, .ellipse);
defer ellipse_obj.deinit(allocator);
const ellipse_bounds = try getLocalBounds(&ellipse_obj);
try std.testing.expect(ellipse_bounds.x == -50);
try std.testing.expect(ellipse_bounds.y == -50);
try std.testing.expect(ellipse_bounds.w == 100);
try std.testing.expect(ellipse_bounds.h == 100);
var broken_obj = try shape.createObject(allocator, .broken);
defer broken_obj.deinit(allocator);
const broken_bounds = try getLocalBounds(&broken_obj);
try std.testing.expect(broken_bounds.x == 0);
try std.testing.expect(broken_bounds.y == 0);
try std.testing.expect(broken_bounds.w == 80);
try std.testing.expect(broken_bounds.h == 60);
}

View File

@@ -0,0 +1,33 @@
const std = @import("std");
const Document = @import("../models/Document.zig");
/// Сохраняет значение произвольного типа T в JSON-файл.
pub fn saveToFile(comptime T: type, value: *const T, path: []const u8) !void {
var file = try std.fs.cwd().createFile(path, .{ .truncate = true });
defer file.close();
var buffer: [4096]u8 = undefined;
var writer = file.writer(&buffer);
try std.json.Stringify.value(value, .{ .whitespace = .indent_2 }, &writer.interface);
try writer.interface.flush();
}
/// Загружает значение типа T из JSON-файла.
/// Для Document после разбора делается клон через allocator, т.к. парсер выделяет память
/// из арены — при закрытии документа её нельзя освобождать нашим аллокатором.
pub fn loadFromFile(comptime T: type, allocator: std.mem.Allocator, path: []const u8) !T {
var file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const data = try file.readToEndAlloc(allocator, std.math.maxInt(usize));
defer allocator.free(data);
var parsed = try std.json.parseFromSlice(T, allocator, data, .{ .ignore_unknown_fields = true });
if (T == Document) {
defer parsed.deinit();
return try parsed.value.clone(allocator);
}
return parsed.value;
}

130
src/random_document.zig Normal file
View File

@@ -0,0 +1,130 @@
const std = @import("std");
const Document = @import("models/Document.zig");
const Object = Document.Object;
const shape = @import("models/shape/shape.zig");
const basic_models = @import("models/basic_models.zig");
const Size_f = basic_models.Size_f;
const Point2_f = basic_models.Point2_f;
const Scale2_f = basic_models.Scale2_f;
const Radii_f = basic_models.Radii_f;
fn randFloat(rng: std.Random, min: f32, max: f32) f32 {
return min + (max - min) * rng.float(f32);
}
fn randRgba(rng: std.Random) u32 {
const r = rng.int(u8);
const g = rng.int(u8);
const b = rng.int(u8);
const a: u8 = @intCast(rng.intRangeLessThan(usize, 128, 256));
return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | a;
}
fn randomShapeKind(rng: std.Random) Object.ShapeKind {
const shapes_implemented = [_]Object.ShapeKind{ .line, .ellipse, .broken };
return shapes_implemented[rng.intRangeLessThan(usize, 0, shapes_implemented.len)];
}
fn randomizeObjectProperties(allocator: std.mem.Allocator, doc_size: *const Size_f, obj: *Object, rng: std.Random) !void {
const margin: f32 = 8;
const max_x = @max(0, doc_size.w - margin);
const max_y = @max(0, doc_size.h - margin);
try obj.setProperty(allocator, .{ .data = .{
.position = .{
.x = randFloat(rng, margin, if (max_x > margin) max_x else margin),
.y = randFloat(rng, margin, if (max_y > margin) max_y else margin),
},
} });
try obj.setProperty(allocator, .{ .data = .{ .angle = randFloat(rng, 0, 2 * std.math.pi) } });
try obj.setProperty(allocator, .{ .data = .{
.scale = .{
.scale_x = randFloat(rng, 0.25, 2.0),
.scale_y = randFloat(rng, 0.25, 2.0),
},
} });
try obj.setProperty(allocator, .{ .data = .{ .visible = true } });
try obj.setProperty(allocator, .{ .data = .{ .opacity = randFloat(rng, 0.3, 1.0) } });
try obj.setProperty(allocator, .{ .data = .{ .locked = rng.boolean() } });
const stroke = randRgba(rng);
try obj.setProperty(allocator, .{ .data = .{ .stroke_rgba = stroke } });
obj.setProperty(allocator, .{ .data = .{ .fill_rgba = randRgba(rng) } }) catch {};
const thickness = randFloat(rng, max_x * 0.001, max_x * 0.01);
try obj.setProperty(allocator, .{ .data = .{ .thickness = thickness } });
switch (obj.shape) {
.line => {
const len = randFloat(rng, 20, @min(doc_size.w, doc_size.h) * 0.5);
const angle = randFloat(rng, 0, 2 * std.math.pi);
try obj.setProperty(allocator, .{ .data = .{
.end_point = .{
.x = std.math.cos(angle) * len,
.y = std.math.sin(angle) * len,
},
} });
},
.ellipse => {
const max_r = @min(120, @min(doc_size.w / 4, doc_size.h / 4));
try obj.setProperty(allocator, .{ .data = .{
.radii = .{
.x = randFloat(rng, 8, @max(8, max_r)),
.y = randFloat(rng, 8, @max(8, max_r)),
},
} });
},
.broken => {
var list = std.ArrayList(Point2_f).empty;
defer list.deinit(allocator);
const n = rng.intRangeLessThan(usize, 2, 9);
var x: f32 = 0;
var y: f32 = 0;
for (0..n) |_| {
try list.append(allocator, .{ .x = x, .y = y });
x += randFloat(rng, -40, 80);
y += randFloat(rng, -30, 60);
}
const slice = try allocator.dupe(Point2_f, list.items);
errdefer allocator.free(slice);
try obj.setProperty(allocator, .{ .data = .{ .points = slice } });
},
}
}
/// Создаёт в документе случайные фигуры (line, ellipse, broken).
pub fn addRandomShapes(doc: *Document, allocator: std.mem.Allocator, rng: std.Random) !void {
const max_total: usize = 80;
var total_count: usize = 0;
const n_root = rng.intRangeLessThan(usize, 6, 15);
for (0..n_root) |_| {
if (total_count >= max_total) break;
var obj = try shape.createObject(allocator, randomShapeKind(rng));
defer obj.deinit(allocator);
try randomizeObjectProperties(allocator, &doc.size, &obj, rng);
try doc.addObject(allocator, obj);
total_count += 1;
}
var stack = std.ArrayList(*Object).empty;
defer stack.deinit(allocator);
for (doc.objects.items) |*obj| {
try stack.append(allocator, obj);
}
while (stack.pop()) |obj| {
if (total_count >= max_total) continue;
const n_children = rng.intRangeLessThan(usize, 0, 2);
const base_len = obj.children.items.len;
for (0..n_children) |_| {
if (total_count >= max_total) break;
var child = try shape.createObject(allocator, randomShapeKind(rng));
defer child.deinit(allocator);
try randomizeObjectProperties(allocator, &doc.size, &child, rng);
try obj.addChild(allocator, child, &doc.next_object_id);
total_count += 1;
}
for (obj.children.items[base_len..]) |*child| {
try stack.append(allocator, child);
}
}
}

View File

@@ -2,9 +2,12 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const dvui = @import("dvui"); const dvui = @import("dvui");
const RenderEngine = @import("RenderEngine.zig").RenderEngine; const RenderEngine = @import("RenderEngine.zig").RenderEngine;
const Document = @import("../models/Document.zig");
const basic_models = @import("../models/basic_models.zig"); const basic_models = @import("../models/basic_models.zig");
const ImageSize = basic_models.ImageSize; const cpu_draw = @import("cpu/draw.zig");
const ImageRect = basic_models.ImageRect; const RenderStats = @import("RenderStats.zig");
const Size_i = basic_models.Size_i;
const Rect_i = basic_models.Rect_i;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Color = dvui.Color; const Color = dvui.Color;
@@ -15,14 +18,18 @@ const Type = enum {
}; };
type: Type, type: Type,
_allocator: Allocator,
gradient_start: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }, gradient_start: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 },
gradient_end: Color.PMA = .{ .r = 255, .g = 255, .b = 255, .a = 255 }, gradient_end: Color.PMA = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
_allocator: Allocator,
_renderStats: RenderStats,
pub fn init(allocator: Allocator, render_type: Type) CpuRenderEngine { pub fn init(allocator: Allocator, render_type: Type) CpuRenderEngine {
return .{ return .{
._allocator = allocator, ._allocator = allocator,
.type = render_type, .type = render_type,
._renderStats = .{
.render_time_ns = 0,
},
}; };
} }
@@ -33,7 +40,7 @@ pub fn exampleReset(self: *CpuRenderEngine) void {
self.gradient_end = Color.PMA{ .r = random.int(u8), .g = random.int(u8), .b = random.int(u8), .a = 255 }; self.gradient_end = Color.PMA{ .r = random.int(u8), .g = random.int(u8), .b = random.int(u8), .a = 255 };
} }
fn renderGradient(self: CpuRenderEngine, pixels: []Color.PMA, width: u32, height: u32, full_w: u32, full_h: u32, visible_rect: ImageRect) void { fn renderGradient(self: CpuRenderEngine, pixels: []Color.PMA, width: u32, height: u32, full_w: u32, full_h: u32, visible_rect: Rect_i) void {
var y: u32 = 0; var y: u32 = 0;
while (y < height) : (y += 1) { while (y < height) : (y += 1) {
var x: u32 = 0; var x: u32 = 0;
@@ -59,9 +66,7 @@ fn renderGradient(self: CpuRenderEngine, pixels: []Color.PMA, width: u32, height
} }
} }
fn renderSquares(self: CpuRenderEngine, pixels: []Color.PMA, canvas_size: ImageSize, visible_rect: ImageRect) void { fn renderSquares(pixels: []Color.PMA, canvas_size: Size_i, visible_rect: Rect_i) void {
_ = self;
const colors = [_]Color.PMA{ const colors = [_]Color.PMA{
.{ .r = 255, .g = 0, .b = 0, .a = 255 }, .{ .r = 255, .g = 0, .b = 0, .a = 255 },
.{ .r = 255, .g = 165, .b = 0, .a = 255 }, .{ .r = 255, .g = 165, .b = 0, .a = 255 },
@@ -150,7 +155,7 @@ fn renderSquares(self: CpuRenderEngine, pixels: []Color.PMA, canvas_size: ImageS
} }
} }
pub fn example(self: CpuRenderEngine, canvas_size: ImageSize, visible_rect: ImageRect) !?dvui.Texture { pub fn example(self: CpuRenderEngine, canvas_size: Size_i, visible_rect: Rect_i) !?dvui.Texture {
const full_w = canvas_size.w; const full_w = canvas_size.w;
const full_h = canvas_size.h; const full_h = canvas_size.h;
@@ -162,12 +167,31 @@ pub fn example(self: CpuRenderEngine, canvas_size: ImageSize, visible_rect: Imag
switch (self.type) { switch (self.type) {
.Gradient => self.renderGradient(pixels, width, height, full_w, full_h, visible_rect), .Gradient => self.renderGradient(pixels, width, height, full_w, full_h, visible_rect),
.Squares => self.renderSquares(pixels, canvas_size, visible_rect), .Squares => renderSquares(pixels, canvas_size, visible_rect),
} }
return try dvui.textureCreate(pixels, width, height, .nearest); return try dvui.textureCreate(pixels, width, height, .nearest, .abgr_8_8_8_8);
} }
pub fn renderEngine(self: *CpuRenderEngine) RenderEngine { pub fn renderEngine(self: *CpuRenderEngine) RenderEngine {
return .{ .cpu = self }; return .{ .cpu = self };
} }
/// Растеризует документ в текстуру (фон + фигуры через конвейер).
pub fn renderDocument(self: *CpuRenderEngine, document: *const Document, canvas_size: Size_i, visible_rect: Rect_i) !?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);
for (pixels) |*p| p.* = .{ .r = 255, .g = 255, .b = 255, .a = 255 };
var t = try std.time.Timer.start();
try cpu_draw.drawDocument(pixels, width, height, visible_rect, document, canvas_size, self._allocator);
self._renderStats.render_time_ns = t.read();
return try dvui.textureCreate(pixels, width, height, .nearest, .abgr_8_8_8_8);
}
pub fn getStats(self: CpuRenderEngine) RenderStats {
return self._renderStats;
}

View File

@@ -1,6 +1,8 @@
const dvui = @import("dvui"); const dvui = @import("dvui");
const CpuRenderEngine = @import("CpuRenderEngine.zig"); const CpuRenderEngine = @import("CpuRenderEngine.zig");
const Document = @import("../models/Document.zig");
const basic_models = @import("../models/basic_models.zig"); const basic_models = @import("../models/basic_models.zig");
const RenderStats = @import("RenderStats.zig");
pub const RenderEngine = union(enum) { pub const RenderEngine = union(enum) {
cpu: *CpuRenderEngine, cpu: *CpuRenderEngine,
@@ -11,9 +13,22 @@ pub const RenderEngine = union(enum) {
} }
} }
pub fn example(self: RenderEngine, canvas_size: basic_models.ImageSize, visible_rect: basic_models.ImageRect) !?dvui.Texture { pub fn example(self: RenderEngine, canvas_size: basic_models.Size_i, visible_rect: basic_models.Rect_i) !?dvui.Texture {
return switch (self) { return switch (self) {
.cpu => |cpu_r| cpu_r.example(canvas_size, visible_rect), .cpu => |cpu_r| cpu_r.example(canvas_size, visible_rect),
}; };
} }
pub fn getStats(self: RenderEngine) RenderStats {
return switch (self) {
.cpu => |cpu_r| cpu_r.getStats(),
};
}
/// Растеризует документ в текстуру.
pub fn render(self: RenderEngine, document: *const Document, canvas_size: basic_models.Size_i, visible_rect: basic_models.Rect_i) !?dvui.Texture {
return switch (self) {
.cpu => |cpu_r| cpu_r.renderDocument(document, canvas_size, visible_rect),
};
}
}; };

View File

@@ -0,0 +1,3 @@
const RenderStats = @This();
render_time_ns: u64, // Время рендера кадра в наносекундах

55
src/render/cpu/broken.zig Normal file
View File

@@ -0,0 +1,55 @@
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 };
const default_fill: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 0 };
const default_thickness: f32 = 2.0;
/// Ломаная по точкам, обводка stroke_rgba. При closed — отрезок последняя–первая точка и заливка fill_rgba.
pub fn draw(
ctx: *DrawContext,
obj: *const Object,
allocator: std.mem.Allocator,
) !void {
const pts = obj.getProperty(.points) orelse return;
if (pts.len < 2) return;
const stroke = if (obj.getProperty(.stroke_rgba)) |stroke_rgba| pipeline.rgbaToPma(stroke_rgba) else default_stroke;
const thickness = obj.getProperty(.thickness) orelse default_thickness;
const closed = obj.getProperty(.closed) orelse false;
const filled = obj.getProperty(.filled) orelse true;
const fill_color = if (obj.getProperty(.fill_rgba)) |fill_rgba| pipeline.rgbaToPma(fill_rgba) else default_fill;
const buffer = try allocator.alloc(Color.PMA, ctx.buf_width * ctx.buf_height);
@memset(buffer, .{ .r = 0, .g = 0, .b = 0, .a = 0 });
defer allocator.free(buffer);
var copy_ctx = ctx.*;
copy_ctx.pixels = buffer;
copy_ctx.replace_mode = true;
const do_fill = closed and filled;
if (do_fill) {
copy_ctx.startFill(allocator) catch return;
}
var i: usize = 0;
while (i + 1 < pts.len) : (i += 1) {
line.drawLine(&copy_ctx, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, stroke, thickness, true);
}
if (closed and pts.len >= 2) {
const last = pts.len - 1;
line.drawLine(&copy_ctx, pts[last].x, pts[last].y, pts[0].x, pts[0].y, stroke, thickness, true);
}
if (do_fill) {
copy_ctx.stopFill(allocator, fill_color);
}
ctx.compositeDrawerContext(&copy_ctx, copy_ctx.transform.opacity);
}

67
src/render/cpu/draw.zig Normal file
View File

@@ -0,0 +1,67 @@
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 basic_models = @import("../../models/basic_models.zig");
const Rect_i = basic_models.Rect_i;
const Size_i = basic_models.Size_i;
const Object = Document.Object;
const DrawContext = pipeline.DrawContext;
const Transform = pipeline.Transform;
fn isVisible(obj: *const Object) bool {
return obj.getProperty(.visible) orelse true;
}
fn drawObject(
ctx: *DrawContext,
obj: *const Object,
parent_transform: Transform,
allocator: std.mem.Allocator,
) !void {
if (!isVisible(obj)) return;
const local = Transform.init(obj);
const world = Transform.compose(parent_transform, local);
ctx.setTransform(world);
switch (obj.shape) {
.line => line.draw(ctx, obj),
.ellipse => try ellipse.draw(ctx, obj, allocator),
.broken => try broken.draw(ctx, obj, allocator),
}
for (obj.children.items) |*child| {
try drawObject(ctx, child, world, allocator);
}
}
/// Рекурсивно рисует документ в буфер (объекты и потомки по порядку).
/// allocator опционален; если передан, ломаные рисуются через слой (без двойного наложения при alpha < 1).
pub fn drawDocument(
pixels: []@import("dvui").Color.PMA,
buf_width: u32,
buf_height: u32,
visible_rect: Rect_i,
document: *const Document,
canvas_size: Size_i,
allocator: std.mem.Allocator,
) !void {
const scale_x: f32 = if (document.size.w > 0) @as(f32, @floatFromInt(canvas_size.w)) / document.size.w else 0;
const scale_y: f32 = if (document.size.h > 0) @as(f32, @floatFromInt(canvas_size.h)) / document.size.h 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| {
try drawObject(&ctx, obj, identity, allocator);
}
}

116
src/render/cpu/ellipse.zig Normal file
View File

@@ -0,0 +1,116 @@
const std = @import("std");
const std_math = std.math;
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 basic_models = @import("../../models/basic_models.zig");
const Point2_f = basic_models.Point2_f;
const Object = Document.Object;
const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 };
const default_fill: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 0 };
const default_thickness: f32 = 2.0;
/// Эллипс с центром (0,0) и полуосями radii. Обводка — полоса расстояния до контура (чёткая линия, не круги).
/// arc_percent: 100 — полный эллипс, иначе одна дуга; обход в коде от (0,ry) по квадрантам (визуально может казаться от низа против часовой из‑за экранной Y).
/// Отрисовка в отдельный буфер и один composite, чтобы при alpha<255 пиксели не накладывались несколько раз.
pub fn draw(ctx: *DrawContext, obj: *const Object, allocator: std.mem.Allocator) !void {
const radii = obj.getProperty(.radii) orelse return;
const rx = radii.x;
const ry = radii.y;
if (rx <= 0 or ry <= 0) return;
const stroke = if (obj.getProperty(.stroke_rgba)) |stroke_rgba| pipeline.rgbaToPma(stroke_rgba) else default_stroke;
const thickness = if (obj.getProperty(.thickness)) |thickness| thickness else default_thickness;
const arc_percent = std_math.clamp(if (obj.getProperty(.arc_percent)) |arc_percent| arc_percent else 100.0, 0.0, 100.0);
const closed = obj.getProperty(.closed) orelse true;
const filled = obj.getProperty(.filled) orelse false;
const fill_color = if (obj.getProperty(.fill_rgba)) |fill_rgba| pipeline.rgbaToPma(fill_rgba) else default_fill;
const do_fill = filled and (closed or arc_percent >= 100.0);
const t = &ctx.transform;
const min_r = @min(rx, ry);
const half_norm = thickness / (2.0 * min_r);
const inner = @max(0.0, 1.0 - half_norm);
const outer = 1.0 + half_norm;
const d_inner_sq = inner * inner;
const d_outer_sq = outer * outer;
const margin = 1.0 + half_norm;
const corners = [_]Point2_f{
.{ .x = -rx * margin, .y = -ry * margin },
.{ .x = rx * margin, .y = -ry * margin },
.{ .x = rx * margin, .y = ry * margin },
.{ .x = -rx * margin, .y = ry * margin },
};
var min_bx: f32 = std_math.inf(f32);
var min_by: f32 = std_math.inf(f32);
var max_bx: f32 = -std_math.inf(f32);
var max_by: f32 = -std_math.inf(f32);
for (corners) |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);
const buffer = try allocator.alloc(Color.PMA, ctx.buf_width * ctx.buf_height);
@memset(buffer, .{ .r = 0, .g = 0, .b = 0, .a = 0 });
defer allocator.free(buffer);
var stroke_ctx = ctx.*;
stroke_ctx.pixels = buffer;
stroke_ctx.replace_mode = true;
if (do_fill) {
stroke_ctx.startFill(allocator) catch return;
}
const inv_rx = 1.0 / rx;
const inv_ry = 1.0 / ry;
const arc_len = 2.0 * std_math.pi * arc_percent / 100.0;
var by: i32 = y0;
while (by < y1) : (by += 1) {
const buf_y = @as(f32, @floatFromInt(by)) + 0.5;
var bx: i32 = x0;
while (bx < x1) : (bx += 1) {
const buf_x = @as(f32, @floatFromInt(bx)) + 0.5;
const w = stroke_ctx.bufferToWorld(buf_x, buf_y);
const loc = stroke_ctx.worldToLocal(w.x, w.y);
const nx = loc.x * inv_rx;
const ny = loc.y * inv_ry;
const d = nx * nx + ny * ny;
if (d < d_inner_sq or d > d_outer_sq) continue;
if (arc_percent < 100.0) {
var diff = std_math.pi / 2.0 - std_math.atan2(ny, nx);
if (diff < 0) diff += 2.0 * std_math.pi;
if (diff > arc_len) continue;
}
stroke_ctx.blendPixelAtBuffer(bx, by, stroke);
}
}
if (closed and arc_percent < 100.0) {
const end_x = rx * std_math.sin(arc_len);
const end_y = ry * std_math.cos(arc_len);
line.drawLine(&stroke_ctx, 0, 0, 0, ry, stroke, thickness, false);
line.drawLine(&stroke_ctx, 0, 0, end_x, end_y, stroke, thickness, false);
}
if (do_fill) {
stroke_ctx.stopFill(allocator, fill_color);
}
ctx.compositeDrawerContext(&stroke_ctx, t.opacity);
}

192
src/render/cpu/line.zig Normal file
View File

@@ -0,0 +1,192 @@
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 base_models = @import("../../models/basic_models.zig");
const Point2_i = base_models.Point2_i;
const Object = Document.Object;
const default_stroke: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .a = 255 };
const default_thickness: f32 = 2.0;
/// Линия от (0,0) до end_point.
pub fn draw(ctx: *DrawContext, obj: *const Object) void {
const end_point = obj.getProperty(.end_point) orelse return;
const end_x = end_point.x;
const end_y = end_point.y;
const stroke = if (obj.getProperty(.stroke_rgba)) |stroke_rgba| pipeline.rgbaToPma(stroke_rgba) else default_stroke;
const thickness = obj.getProperty(.thickness) orelse default_thickness;
drawLine(ctx, 0, 0, end_x, end_y, stroke, thickness, false);
}
/// Рисует отрезок по локальным концам (перевод в буфер внутри).
/// draw_when_outside: если true, участки линии за экраном тоже рисуются (толщиной 1 px); в буфере — обычная толщина.
pub fn drawLine(ctx: *DrawContext, x0: f32, y0: f32, x1: f32, y1: f32, color: Color.PMA, thickness: f32, draw_when_outside: bool) void {
const w0 = ctx.localToWorld(x0, y0);
const w1 = ctx.localToWorld(x1, y1);
const b0 = ctx.worldToBuffer(w0.x, w0.y);
const b1 = ctx.worldToBuffer(w1.x, w1.y);
const t = &ctx.transform;
const scale = @sqrt(t.scale.scale_x * ctx.scale_x * t.scale.scale_y * ctx.scale_y);
const thickness_px: u32 = @as(u32, @intFromFloat(std.math.round(thickness * scale)));
if (thickness_px > 0)
drawLineInBuffer(ctx, b0.x, b0.y, b1.x, b1.y, color, thickness_px, draw_when_outside);
}
inline fn clip(p: f32, q: f32, t0: *f32, t1: *f32) bool {
if (p == 0) {
return q >= 0;
}
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].
/// Координаты концов модифицируются по месту. Возвращает false, если отрезок целиком вне прямоугольника.
fn liangBarskyClip(
x0: *i32,
y0: *i32,
x1: *i32,
y1: *i32,
left: i32,
top: i32,
right: i32,
bottom: i32,
) bool {
const fx0: f32 = @floatFromInt(x0.*);
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, draw_when_outside: bool) void {
// Коррекция толщины в зависимости от угла линии.
var thickness_corrected: u32 = thickness_px;
var use_vertical: bool = undefined;
const dx_f: f32 = @floatFromInt(bx1 - bx0);
const dy_f: f32 = @floatFromInt(by1 - by0);
const len: f32 = @sqrt(dx_f * dx_f + dy_f * dy_f);
if (len > 0) {
const cos_theta = @abs(dx_f) / len;
const sin_theta = @abs(dy_f) / len;
const desired: f32 = @floatFromInt(thickness_px);
const eps: f32 = 1e-3;
const vertical_based = desired / @max(sin_theta, eps);
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);
const thickness_corrected_i: i32 = @as(i32, @intCast(thickness_corrected));
var p0 = Point2_i{ .x = bx0, .y = by0 };
var p1 = Point2_i{ .x = bx1, .y = by1 };
// Отсечение только когда не рисуем вне viewport: иначе линия идёт целиком, толщина 1 px снаружи.
if (!draw_when_outside) {
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;
const buf_w_i: i32 = @intCast(ctx.buf_width);
const buf_h_i: i32 = @intCast(ctx.buf_height);
while (true) {
// Внутри viewport — полная толщина; снаружи при draw_when_outside — только 1 пиксель.
const in_viewport = x0 >= -thickness_corrected_i and x0 < buf_w_i + thickness_corrected_i and y0 >= -thickness_corrected_i and y0 < buf_h_i + thickness_corrected_i;
const effective_half: i32 = if (draw_when_outside and !in_viewport) 0 else half_thickness;
var thick: i32 = -effective_half;
while (thick <= effective_half) {
const x = if (use_vertical) x0 + thick else x0;
const y = if (use_vertical) y0 else y0 + thick;
ctx.blendPixelAtBuffer(x, y, color);
thick += 1;
}
if (x0 == ex and y0 == ey) break;
const e2: i32 = 2 * err;
if (e2 >= dy) {
err += dy;
x0 += sx;
}
if (e2 <= dx) {
err += dx;
y0 += sy;
}
}
}

349
src/render/cpu/pipeline.zig Normal file
View File

@@ -0,0 +1,349 @@
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;
}
};

51
src/toolbar/Tool.zig Normal file
View File

@@ -0,0 +1,51 @@
const basic_models = @import("../models/basic_models.zig");
const Point2_f = basic_models.Point2_f;
const Canvas = @import("../Canvas.zig");
const Document = @import("../models/Document.zig");
const pipeline = @import("../render/cpu/pipeline.zig");
const Transform = pipeline.Transform;
pub const ToolContext = struct {
canvas: *Canvas,
document_point: Point2_f,
selected_object_id: ?u64,
pub fn addObject(self: *const ToolContext, template: Document.Object) !void {
var obj = template;
const local_pos = self.computeLocalPosition();
try obj.setProperty(self.canvas.allocator, .{ .data = .{ .position = local_pos } });
try self.canvas.document.addObjectUnderParentId(self.canvas.allocator, self.selected_object_id, obj);
self.canvas.requestRedraw();
}
fn computeLocalPosition(self: *const ToolContext) Point2_f {
if (self.selected_object_id) |parent_id| {
if (findWorldTransformById(self.canvas.document, parent_id)) |parent_world| {
return pipeline.worldToLocalTransform(parent_world, self.document_point.x, self.document_point.y);
}
}
return self.document_point;
}
};
fn findWorldTransformById(doc: *Document, target_id: u64) ?Transform {
const identity = Transform{};
for (doc.objects.items) |*obj| {
if (findWorldTransformInTree(obj, identity, target_id)) |t| return t;
}
return null;
}
fn findWorldTransformInTree(obj: *const Document.Object, parent: Transform, target_id: u64) ?Transform {
const local = Transform.init(obj);
const world = Transform.compose(parent, local);
if (obj.id == target_id) return world;
for (obj.children.items) |*child| {
if (findWorldTransformInTree(child, world, target_id)) |t| return t;
}
return null;
}
pub const Tool = struct {
onCanvasClick: *const fn (*const ToolContext) anyerror!void,
};

43
src/toolbar/Toolbar.zig Normal file
View File

@@ -0,0 +1,43 @@
const Tool = @import("Tool.zig");
const Toolbar = @This();
pub const ToolDescriptor = struct {
name: []const u8,
icon_tvg: []const u8,
implementation: *const Tool.Tool,
};
tools: []const ToolDescriptor,
selected_index: ?usize,
pub fn init(tools_list: []const ToolDescriptor) Toolbar {
return .{
.tools = tools_list,
.selected_index = null,
};
}
pub fn deinit(_: *Toolbar) void {}
pub fn currentDescriptor(self: *const Toolbar) ?*const ToolDescriptor {
if (self.tools.len == 0) return null;
if (self.selected_index) |index| {
return &self.tools[index];
}
return null;
}
pub fn select(self: *Toolbar, index: ?usize) void {
if (index == self.selected_index) {
self.selected_index = null;
return;
}
if (index) |i| {
if (i < self.tools.len) {
self.selected_index = i;
}
} else {
self.selected_index = null;
}
}

23
src/toolbar/tools.zig Normal file
View File

@@ -0,0 +1,23 @@
const Toolbar = @import("Toolbar.zig");
const line = @import("tools/line.zig");
const ellipse = @import("tools/ellipse.zig");
const broken = @import("tools/broken.zig");
const icons = @import("../icons.zig");
pub const default_tools = [_]Toolbar.ToolDescriptor{
.{
.name = "Line",
.icon_tvg = icons.line,
.implementation = &line.tool,
},
.{
.name = "Ellipse",
.icon_tvg = icons.ellipse,
.implementation = &ellipse.tool,
},
.{
.name = "Broken line",
.icon_tvg = icons.broken,
.implementation = &broken.tool,
},
};

View File

@@ -0,0 +1,10 @@
const Tool = @import("../Tool.zig");
const shape = @import("../../models/shape/shape.zig");
fn onCanvasClick(ctx: *const Tool.ToolContext) !void {
const canvas = ctx.canvas;
var obj = shape.createObject(canvas.allocator, .broken) catch return;
defer obj.deinit(canvas.allocator);
try ctx.addObject(obj);
}
pub const tool = Tool.Tool{ .onCanvasClick = onCanvasClick };

View File

@@ -0,0 +1,10 @@
const Tool = @import("../Tool.zig");
const shape = @import("../../models/shape/shape.zig");
fn onCanvasClick(ctx: *const Tool.ToolContext) !void {
const canvas = ctx.canvas;
var obj = shape.createObject(canvas.allocator, .ellipse) catch return;
defer obj.deinit(canvas.allocator);
try ctx.addObject(obj);
}
pub const tool = Tool.Tool{ .onCanvasClick = onCanvasClick };

View File

@@ -0,0 +1,12 @@
const std = @import("std");
const Canvas = @import("../../Canvas.zig");
const Tool = @import("../Tool.zig");
const shape = @import("../../models/shape/shape.zig");
fn onCanvasClick(ctx: *const Tool.ToolContext) !void {
const canvas = ctx.canvas;
var obj = shape.createObject(canvas.allocator, .line) catch return;
defer obj.deinit(canvas.allocator);
try ctx.addObject(obj);
}
pub const tool = Tool.Tool{ .onCanvasClick = onCanvasClick };

View File

@@ -2,30 +2,114 @@ const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const dvui_ext = @import("dvui_ext.zig"); const dvui_ext = @import("dvui_ext.zig");
const Canvas = @import("../Canvas.zig"); const Canvas = @import("../Canvas.zig");
const ImageRect = @import("../models/basic_models.zig").ImageRect; const Document = @import("../models/Document.zig");
const Property = @import("../models/Property.zig").Property;
const PropertyData = @import("../models/Property.zig").Data;
const Rect_i = @import("../models/basic_models.zig").Rect_i;
const Point2_f = @import("../models/basic_models.zig").Point2_f;
const Tool = @import("../toolbar/Tool.zig");
const RenderStats = @import("../render/RenderStats.zig");
const icons = @import("../icons.zig");
pub fn canvasView(canvas: *Canvas, content_rect_scale: dvui.RectScale) void { pub fn canvasView(canvas: *Canvas, selected_object_id: ?u64, content_rect_scale: dvui.RectScale) void {
var textured = dvui_ext.texturedBox(content_rect_scale, dvui.Rect.all(20)); var textured = dvui_ext.texturedBox(content_rect_scale, dvui.Rect.all(20));
{ {
var overlay = dvui.overlay(@src(), .{ .expand = .both }); var overlay = dvui.overlay(@src(), .{ .expand = .both });
{ {
var scroll = dvui.scrollArea( const overlay_parent = dvui.parentGet();
@src(), const init_options: dvui.ScrollAreaWidget.InitOpts = .{
.{
.scroll_info = &canvas.scroll, .scroll_info = &canvas.scroll,
.vertical_bar = .auto, .vertical_bar = .auto,
.horizontal_bar = .auto, .horizontal_bar = .auto,
.process_events_after = false,
};
var scroll = dvui.scrollArea(
@src(),
init_options,
.{
.expand = .both,
.background = false,
}, },
.{ .expand = .both, .background = false },
); );
{ {
drawCanvasContent(canvas, scroll); drawCanvasContent(canvas, scroll);
handleCanvasZoom(canvas, scroll); handleCanvasZoom(canvas, scroll);
handleCanvasMouse(canvas, scroll); handleCanvasMouse(canvas, scroll, selected_object_id);
} }
scroll.deinit();
dvui.label(@src(), "Canvas", .{}, .{ .gravity_x = 0.5, .gravity_y = 0.0 }); const scroll_parent = dvui.parentGet();
dvui.parentSet(overlay_parent);
const vbar = scroll.vbar;
const hbar = scroll.hbar;
if (vbar != null) {
// std.debug.print("{any}", .{vbar.?.data()});
}
if (hbar != null) {
// std.debug.print("{any}", .{hbar.?.data()});
}
// Тулбар поверх scroll
var toolbar_box = dvui.box(
@src(),
.{ .dir = .horizontal },
.{},
);
{
drawToolbar(canvas);
// Сохраняем rect тулбара для следующего кадра — в handleCanvasMouse исключаем из него клики
canvas.toolbar_rect_scale = toolbar_box.data().contentRectScale();
}
toolbar_box.deinit();
// Панель свойств поверх scroll (правый верхний угол)
if (selected_object_id) |obj_id| {
if (canvas.document.findObjectById(obj_id)) |obj| {
var properties_box = dvui.box(
@src(),
.{ .dir = .horizontal },
.{
.gravity_x = 1.0,
.gravity_y = 0.0,
},
);
{
drawPropertiesPanel(canvas, obj);
// Сохраняем rect панели свойств для следующего кадра — в handleCanvasMouse исключаем из него клики
canvas.properties_rect_scale = properties_box.data().contentRectScale();
}
properties_box.deinit();
}
}
drawCanvasLabelPanel();
if (canvas.show_render_stats)
drawStatsPanel(canvas.render_engine.getStats(), canvas.frame_index);
if (canvas.properties_rect_scale) |prs| {
for (dvui.events()) |*e| {
if (e.handled) continue;
if (e.evt != .mouse) continue;
const mouse = &e.evt.mouse;
if (mouse.action != .wheel_x and mouse.action != .wheel_y) continue;
const pt = prs.pointFromPhysical(mouse.p);
const r = prs.r;
if (pt.x >= 0 and pt.x * prs.s < r.w and pt.y >= 0 and pt.y * prs.s < r.h) {
e.handled = true;
}
}
}
if (!init_options.process_events_after) {
if (scroll.scroll) |*sc| {
dvui.clipSet(sc.prevClip);
sc.processEventsAfter();
}
}
dvui.parentSet(scroll_parent);
scroll.deinit();
} }
overlay.deinit(); overlay.deinit();
} }
@@ -67,7 +151,7 @@ fn drawCanvasContent(canvas: *Canvas, scroll: anytype) void {
); );
{ {
if (canvas.texture) |tex| { if (canvas.texture) |tex| {
const vis = canvas._visible_rect orelse ImageRect{ .x = 0, .y = 0, .w = 0, .h = 0 }; const vis = canvas._visible_rect orelse Rect_i{ .x = 0, .y = 0, .w = 0, .h = 0 };
const left = @as(f32, @floatFromInt(img_size.x + vis.x)) / natural_scale; const left = @as(f32, @floatFromInt(img_size.x + vis.x)) / natural_scale;
const top = @as(f32, @floatFromInt(img_size.y + vis.y)) / natural_scale; const top = @as(f32, @floatFromInt(img_size.y + vis.y)) / natural_scale;
@@ -99,15 +183,34 @@ fn handleCanvasZoom(canvas: *Canvas, scroll: anytype) void {
const ctrl = dvui.currentWindow().modifiers.control(); const ctrl = dvui.currentWindow().modifiers.control();
if (!ctrl) return; if (!ctrl) return;
const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale();
for (dvui.events()) |*e| { for (dvui.events()) |*e| {
switch (e.evt) { switch (e.evt) {
.mouse => |mouse| { .mouse => |*mouse| {
const action = mouse.action; const action = mouse.action;
if (dvui.eventMatchSimple(e, scroll.data()) and (action == .wheel_x or action == .wheel_y)) { if (dvui.eventMatchSimple(e, scroll.data()) and (action == .wheel_x or action == .wheel_y)) {
switch (action) { switch (action) {
.wheel_y => |y| { .wheel_y => |y| {
canvas.addZoom(y / 1000); const viewport_pt = scroll.data().contentRectScale().pointFromPhysical(mouse.p);
const content_pt = dvui.Point{
.x = viewport_pt.x + canvas.scroll.viewport.x,
.y = viewport_pt.y + canvas.scroll.viewport.y,
};
const doc_pt = canvas.contentPointToDocument(content_pt, natural_scale);
// canvas.addZoom(y / 1000);
canvas.multZoom(1 + y / 2000);
canvas.requestRedraw(); canvas.requestRedraw();
const doc_pt_after = canvas.contentPointToDocument(content_pt, natural_scale);
const zoom = canvas.getZoom();
const dx = (doc_pt_after.x - doc_pt.x) * zoom / natural_scale;
const dy = (doc_pt_after.y - doc_pt.y) * zoom / natural_scale;
canvas.scroll.viewport.x -= dx;
canvas.scroll.viewport.y -= dy;
}, },
else => {}, else => {},
} }
@@ -119,25 +222,553 @@ fn handleCanvasZoom(canvas: *Canvas, scroll: anytype) void {
} }
} }
fn handleCanvasMouse(canvas: *Canvas, scroll: anytype) void { fn handleCanvasMouse(canvas: *Canvas, scroll: *dvui.ScrollAreaWidget, selected_object_id: ?u64) void {
const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale();
const scroll_data = scroll.data();
for (dvui.events()) |*e| { for (dvui.events()) |*e| {
switch (e.evt) { switch (e.evt) {
.mouse => |*mouse| { .mouse => |*mouse| {
if (mouse.action != .press or mouse.button != .left) continue; if (mouse.action != .press or mouse.button != .left) continue;
if (!dvui.eventMatchSimple(e, scroll.data())) continue; if (e.handled) continue;
if (!dvui.eventMatchSimple(e, scroll_data)) continue;
const viewport_pt = scroll.data().contentRectScale().pointFromPhysical(mouse.p); // Не обрабатывать клик, если он попал в область тулбара (rect с предыдущего кадра).
if (canvas.toolbar_rect_scale) |trs| {
const pt = trs.pointFromPhysical(mouse.p);
const r = trs.r;
if (pt.x >= 0 and pt.x * trs.s < r.w and pt.y >= 0 and pt.y * trs.s < r.h) continue;
}
// Не обрабатывать клик, если он попал в область панели свойств (rect с предыдущего кадра).
if (canvas.properties_rect_scale) |prs| {
const pt = prs.pointFromPhysical(mouse.p);
const r = prs.r;
if (pt.x >= 0 and pt.x * prs.s < r.w and pt.y >= 0 and pt.y * prs.s < r.h) continue;
}
const viewport_pt = scroll_data.contentRectScale().pointFromPhysical(mouse.p);
const content_pt = dvui.Point{ const content_pt = dvui.Point{
.x = viewport_pt.x + canvas.scroll.viewport.x, .x = viewport_pt.x + canvas.scroll.viewport.x,
.y = viewport_pt.y + canvas.scroll.viewport.y, .y = viewport_pt.y + canvas.scroll.viewport.y,
}; };
canvas.cursor_document_point = canvas.contentPointToDocument(content_pt, natural_scale); const doc_pt = canvas.contentPointToDocument(content_pt, natural_scale);
if (canvas.cursor_document_point) |point| canvas.cursor_document_point = if (canvas.isContentPointOnDocument(content_pt, natural_scale)) doc_pt else null;
std.debug.print("cursor_document_point: {}\n", .{point}); if (canvas.cursor_document_point) |point| {
if (canvas.toolbar.currentDescriptor()) |desc| {
var ctx = Tool.ToolContext{
.canvas = canvas,
.document_point = point,
.selected_object_id = selected_object_id,
};
desc.implementation.onCanvasClick(&ctx) catch |err| {
std.debug.print("onCanvasClick error: {}\n", .{err});
};
}
}
}, },
else => {}, else => {},
} }
} }
} }
fn drawToolbar(canvas: *Canvas) void {
const tools_list = canvas.toolbar.tools;
if (tools_list.len == 0) return;
var bar = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.gravity_x = 0.0,
.gravity_y = 0.0,
.padding = dvui.Rect.all(6),
.corner_radius = dvui.Rect.all(8),
.background = true,
.color_fill = dvui.Color.black.opacity(0.2),
.margin = dvui.Rect{ .x = 16, .y = 16 },
},
);
{
var to_select: ?usize = null;
for (tools_list, 0..) |*tool_desc, i| {
const is_selected = canvas.toolbar.selected_index == i;
const selected_fill = dvui.themeGet().focus;
const opts: dvui.Options = .{
.id_extra = i,
.color_fill = if (is_selected) selected_fill else null,
};
if (dvui.buttonIcon(@src(), tool_desc.name, tool_desc.icon_tvg, .{}, .{}, opts)) {
to_select = i;
}
}
if (to_select) |index| {
canvas.toolbar.select(index);
}
}
bar.deinit();
}
fn drawPropertiesPanel(canvas: *Canvas, selected_object: *Document.Object) void {
var panel = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.padding = dvui.Rect.all(8),
.corner_radius = dvui.Rect.all(8),
.background = true,
.color_fill = dvui.Color.black.opacity(0.2),
.min_size_content = .width(300),
.max_size_content = .width(300),
.margin = dvui.Rect{ .w = 32, .y = 16, .h = 100 },
},
);
{
dvui.label(@src(), "Properties", .{}, .{});
var scroll = dvui.scrollArea(@src(), .{
.horizontal = .none,
.vertical = .auto,
}, .{
.expand = .both,
});
{
for (selected_object.properties.items, 0..) |*prop, i| {
drawPropertyEditor(canvas, selected_object, prop, i);
}
}
scroll.deinit();
}
panel.deinit();
}
fn drawCanvasLabelPanel() void {
var panel = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.gravity_x = 0.5,
.gravity_y = 0.0,
.padding = dvui.Rect.all(8),
.corner_radius = dvui.Rect.all(8),
.background = true,
.color_fill = dvui.Color.black.opacity(0.2),
.margin = dvui.Rect{ .x = 16, .y = 16 },
},
);
{
dvui.label(@src(), "Canvas", .{}, .{});
}
panel.deinit();
}
fn drawStatsPanel(stats: RenderStats, frame_index: u64) void {
var panel = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.gravity_x = 0.0,
.gravity_y = 1.0,
.padding = dvui.Rect.all(8),
.corner_radius = dvui.Rect.all(8),
.background = true,
.color_fill = dvui.Color.black.opacity(0.2),
.margin = dvui.Rect{ .x = 16, .h = 16 },
},
);
{
dvui.label(@src(), "Frame time: {d:.2}ms", .{@as(f32, @floatFromInt(stats.render_time_ns)) / std.time.ns_per_ms}, .{});
dvui.label(@src(), "Frame index: {}", .{frame_index}, .{});
}
panel.deinit();
}
fn applyPropertyPatch(canvas: *Canvas, obj: *Document.Object, patch: Property) void {
obj.setProperty(canvas.allocator, patch) catch {};
canvas.requestRedraw();
}
fn drawPropertyEditor(canvas: *Canvas, obj: *Document.Object, prop: *const Property, row_index: usize) void {
const row_id: usize = row_index * 16;
const is_even = row_index % 2 == 0;
var row = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.id_extra = row_id,
.expand = .horizontal,
.padding = dvui.Rect{ .y = 2, .x = 4 },
.corner_radius = dvui.Rect.all(4),
.background = is_even,
.color_fill = if (is_even) dvui.Color.black.opacity(0.4) else .{},
},
);
{
const tag = std.meta.activeTag(prop.data);
dvui.labelNoFmt(@src(), propertyLabel(tag), .{}, .{});
switch (prop.data) {
.position => |pos| {
var next = pos;
var changed = false;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "x:", .{}, .{});
const T = @TypeOf(next.x);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.x }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "y:", .{}, .{});
const T = @TypeOf(next.y);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.y }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
if (changed) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .position = next } });
}
},
.angle => |angle| {
var next = angle;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "deg:", .{}, .{});
var degrees: f32 = next * 180.0 / std.math.pi;
const res = dvui.textEntryNumber(@src(), f32, .{ .value = &degrees }, .{ .expand = .horizontal });
subrow.deinit();
if (res.changed) {
next = degrees * std.math.pi / 180.0;
applyPropertyPatch(canvas, obj, .{ .data = .{ .angle = next } });
}
}
},
.scale => |scale| {
var next = scale;
var changed = false;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "x:", .{}, .{});
const T = @TypeOf(next.scale_x);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.scale_x, .min = @as(T, 0.0), .max = @as(T, 10.0) }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "y:", .{}, .{});
const T = @TypeOf(next.scale_y);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.scale_y, .min = @as(T, 0.0), .max = @as(T, 10.0) }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
if (changed) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .scale = next } });
}
},
.visible => |v| {
var next = v;
if (dvui.checkbox(@src(), &next, "Visible", .{})) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .visible = next } });
}
},
.opacity => |opacity| {
var next = opacity;
if (dvui.sliderEntry(@src(), "{d:0.2}", .{ .value = &next, .min = 0.0, .max = 1.0, .interval = 0.01 }, .{ .expand = .horizontal })) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .opacity = next } });
}
},
.locked => |v| {
var next = v;
if (dvui.checkbox(@src(), &next, "Locked", .{})) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .locked = next } });
}
},
.size => |size| {
var next = size;
var changed = false;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "w:", .{}, .{});
const T = @TypeOf(next.w);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.w, .min = @as(T, 0.0) }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "h:", .{}, .{});
const T = @TypeOf(next.h);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.h, .min = @as(T, 0.0) }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
if (changed) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .size = next } });
}
},
.radii => |radii| {
var next = radii;
var changed = false;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "x:", .{}, .{});
const T = @TypeOf(next.x);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.x, .min = @as(T, 0.0) }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "y:", .{}, .{});
const T = @TypeOf(next.y);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.y, .min = @as(T, 0.0) }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
if (changed) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .radii = next } });
}
},
.arc_percent => |pct| {
var next = pct;
if (dvui.sliderEntry(@src(), "{d:0.0}%", .{ .value = &next, .min = 0.0, .max = 100.0, .interval = 1.0 }, .{ .expand = .horizontal })) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .arc_percent = next } });
}
},
.end_point => |pt| {
var next = pt;
var changed = false;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "x:", .{}, .{});
const T = @TypeOf(next.x);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.x }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "y:", .{}, .{});
const T = @TypeOf(next.y);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next.y }, .{ .expand = .horizontal });
subrow.deinit();
changed = res.changed or changed;
}
if (changed) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .end_point = next } });
}
},
.points => |points| {
var list = std.ArrayList(Point2_f).empty;
list.appendSlice(canvas.allocator, points) catch {
dvui.label(@src(), "Points: {d}", .{points.len}, .{});
return;
};
defer list.deinit(canvas.allocator);
dvui.label(@src(), "Points: {d}", .{list.items.len}, .{});
var changed = false;
var to_delete: ?usize = null;
for (list.items, 0..) |*pt, i| {
// Одна строка: крестик удаления + paned с X/Y пополам
var subrow = dvui.box(
@src(),
.{ .dir = .horizontal },
.{
.expand = .horizontal,
.id_extra = i,
},
);
{
// Крестик удаления
if (dvui.buttonIcon(@src(), "Delete", icons.cross, .{}, .{}, .{
.id_extra = i,
.gravity_y = 0.5,
.margin = .{
.x = 8,
},
})) {
to_delete = i;
}
// Панель с X и Y, разделёнными пополам
var split_ratio: f32 = 0.5;
var paned = dvui.paned(
@src(),
.{
.direction = .horizontal,
.collapsed_size = 0.0,
.split_ratio = &split_ratio,
.handle_size = 0,
},
.{
.expand = .horizontal,
},
);
{
if (paned.showFirst()) {
var x_box = dvui.box(
@src(),
.{ .dir = .horizontal },
.{ .expand = .both },
);
{
dvui.labelNoFmt(@src(), "x:", .{}, .{
.gravity_y = 0.5,
});
const Tx = @TypeOf(pt.x);
const res_x = dvui.textEntryNumber(
@src(),
Tx,
.{ .value = &pt.x },
.{ .expand = .horizontal },
);
changed = res_x.changed or changed;
}
x_box.deinit();
}
if (paned.showSecond()) {
var y_box = dvui.box(
@src(),
.{ .dir = .horizontal },
.{ .expand = .both },
);
{
dvui.labelNoFmt(@src(), "y:", .{}, .{
.gravity_y = 0.5,
});
const Ty = @TypeOf(pt.y);
const res_y = dvui.textEntryNumber(
@src(),
Ty,
.{ .value = &pt.y },
.{ .expand = .horizontal },
);
changed = res_y.changed or changed;
}
y_box.deinit();
}
}
paned.deinit();
}
subrow.deinit();
}
// Удаление выбранной точки
if (to_delete) |idx| {
_ = list.orderedRemove(idx);
changed = true;
}
// Кнопка добавления новой точки (одна на весь список)
if (dvui.button(@src(), "Add point", .{}, .{})) {
const T = @TypeOf(list.items[0]);
const new_point: T = if (list.items.len > 0)
list.items[list.items.len - 1]
else
.{ .x = 0, .y = 0 };
list.append(canvas.allocator, new_point) catch {};
changed = true;
}
if (changed) {
const slice = canvas.allocator.dupe(Point2_f, list.items) catch return;
obj.setProperty(canvas.allocator, .{ .data = .{ .points = slice } }) catch {
canvas.allocator.free(slice);
return;
};
canvas.requestRedraw();
}
},
.fill_rgba => |rgba| {
drawColorEditor(canvas, obj, rgba, true);
},
.stroke_rgba => |rgba| {
drawColorEditor(canvas, obj, rgba, false);
},
.thickness => |t| {
var next = t;
{
var subrow = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.labelNoFmt(@src(), "thickness:", .{}, .{});
const T = @TypeOf(next);
const res = dvui.textEntryNumber(@src(), T, .{ .value = &next, .min = @as(T, 0.0), .max = @as(T, 100.0) }, .{ .expand = .horizontal });
subrow.deinit();
if (res.changed) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .thickness = next } });
}
}
},
.closed => |v| {
var next = v;
if (dvui.checkbox(@src(), &next, "Closed", .{})) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .closed = next } });
}
},
.filled => |v| {
var next = v;
if (dvui.checkbox(@src(), &next, "Filled", .{})) {
applyPropertyPatch(canvas, obj, .{ .data = .{ .filled = next } });
}
},
}
}
row.deinit();
}
fn drawColorEditor(canvas: *Canvas, obj: *Document.Object, rgba: u32, is_fill: bool) void {
var hsv = dvui.Color.HSV.fromColor(rgbaToColor(rgba));
if (dvui.colorPicker(
@src(),
.{ .hsv = &hsv, .dir = .horizontal, .sliders = .rgb, .alpha = true, .hex_text_entry = true },
.{ .expand = .horizontal },
)) {
const next = colorToRgba(hsv.toColor());
const patch: Property = if (is_fill)
.{ .data = .{ .fill_rgba = next } }
else
.{ .data = .{ .stroke_rgba = next } };
applyPropertyPatch(canvas, obj, patch);
}
}
fn propertyLabel(tag: std.meta.Tag(PropertyData)) []const u8 {
return switch (tag) {
.position => "Position",
.angle => "Angle",
.scale => "Scale",
.visible => "Visible",
.opacity => "Opacity",
.locked => "Locked",
.size => "Size",
.radii => "Radii",
.arc_percent => "Arc %",
.end_point => "End point",
.points => "Points",
.fill_rgba => "Fill color",
.stroke_rgba => "Stroke color",
.thickness => "Thickness",
.closed => "Closed",
.filled => "Filled",
};
}
fn rgbaToColor(rgba: u32) dvui.Color {
return .{
.r = @intCast((rgba >> 24) & 0xFF),
.g = @intCast((rgba >> 16) & 0xFF),
.b = @intCast((rgba >> 8) & 0xFF),
.a = @intCast((rgba >> 0) & 0xFF),
};
}
fn colorToRgba(color: dvui.Color) u32 {
return (@as(u32, color.r) << 24) |
(@as(u32, color.g) << 16) |
(@as(u32, color.b) << 8) |
(@as(u32, color.a) << 0);
}

View File

@@ -1,5 +1,6 @@
const dvui = @import("dvui"); const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");
const menu_bar = @import("menu_bar.zig");
const tab_bar = @import("tab_bar.zig"); const tab_bar = @import("tab_bar.zig");
const left_panel = @import("left_panel.zig"); const left_panel = @import("left_panel.zig");
const right_panel = @import("right_panel.zig"); const right_panel = @import("right_panel.zig");
@@ -16,6 +17,7 @@ pub fn guiFrame(ctx: *WindowContext) bool {
.{ .expand = .both, .background = true, .style = .window }, .{ .expand = .both, .background = true, .style = .window },
); );
{ {
menu_bar.menuBar(ctx);
tab_bar.tabBar(ctx); tab_bar.tabBar(ctx);
var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
@@ -28,6 +30,5 @@ pub fn guiFrame(ctx: *WindowContext) bool {
} }
root.deinit(); root.deinit();
ctx.frame_index += 1;
return true; return true;
} }

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,43 +10,122 @@ 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: u64,
delete: u64,
};
fn shapeLabel(shape: Object.ShapeKind) []const u8 { fn shapeLabel(shape: Object.ShapeKind) []const u8 {
return switch (shape) { return switch (shape) {
.line => "Line", .line => "Line",
.ellipse => "Ellipse", .ellipse => "Ellipse",
.arc => "Arc",
.broken => "Broken line", .broken => "Broken line",
}; };
} }
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_id == obj.id;
const row_id: usize = @intCast(obj.id);
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 == .release 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, .w = 6 },
.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.id };
select_row = false;
}
}
}
content.deinit();
if (select_row) {
object_callback.* = .{ .select = obj.id };
}
} }
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_id| {
if (open_doc.selected_object_id == obj_id) {
open_doc.selected_object_id = null;
} else {
open_doc.selected_object_id = obj_id;
}
},
.delete => |obj_id| {
_ = doc.removeObjectById(ctx.allocator, obj_id);
if (open_doc.selected_object_id == obj_id)
open_doc.selected_object_id = null;
open_doc.canvas.requestRedraw();
},
} }
} }
} else { } else {
@@ -60,37 +141,14 @@ 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,
}, },
); );
{ {
// Верхняя часть: дерево объектов
var tree_section = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.expand = .both,
.padding = dvui.Rect.all(panel_padding),
.corner_radius = dvui.Rect.all(panel_radius),
.color_fill = fill_color,
.background = true,
},
);
{
dvui.label(@src(), "Objects", .{}, .{});
var scroll = dvui.scrollArea(
@src(),
.{ .vertical = .auto },
.{ .expand = .vertical, .background = false },
);
{
objectTree(ctx);
}
scroll.deinit();
}
tree_section.deinit();
// Нижняя часть: настройки // Нижняя часть: настройки
var settings_section = dvui.box( var settings_section = dvui.box(
@@ -98,6 +156,7 @@ pub fn leftPanel(ctx: *WindowContext) void {
.{ .dir = .vertical }, .{ .dir = .vertical },
.{ .{
.expand = .horizontal, .expand = .horizontal,
.gravity_y = 1.0,
.margin = .{ .y = 5 }, .margin = .{ .y = 5 },
.padding = dvui.Rect.all(panel_padding), .padding = dvui.Rect.all(panel_padding),
.corner_radius = dvui.Rect.all(panel_radius), .corner_radius = dvui.Rect.all(panel_radius),
@@ -112,19 +171,75 @@ pub fn leftPanel(ctx: *WindowContext) void {
if (active_doc) |doc| { if (active_doc) |doc| {
const canvas = &doc.canvas; const canvas = &doc.canvas;
if (dvui.checkbox(@src(), &canvas.native_scaling, "Scaling", .{})) {} if (dvui.checkbox(@src(), &canvas.native_scaling, "Scaling", .{})) {}
if (dvui.checkbox(@src(), &canvas.draw_document, "Draw document", .{})) {
canvas.requestRedraw();
}
if (dvui.checkbox(@src(), &canvas.show_render_stats, "Show stats", .{})) {}
{
dvui.label(@src(), "Rendering quality", .{}, .{});
var quality = canvas.getRenderingQuality();
if (dvui.sliderEntry(
@src(),
"{d:0.0}%",
.{ .value = &quality, .min = 1.0, .max = 100.0, .interval = 1.0 },
.{ .expand = .horizontal },
)) {
canvas.setRenderingQuality(quality);
}
}
if (!canvas.draw_document) {
if (dvui.button(@src(), if (doc.cpu_render.type == .Gradient) "Gradient" else "Squares", .{}, .{})) { if (dvui.button(@src(), if (doc.cpu_render.type == .Gradient) "Gradient" else "Squares", .{}, .{})) {
if (doc.cpu_render.type == .Gradient) { if (doc.cpu_render.type == .Gradient) {
doc.cpu_render.type = .Squares; doc.cpu_render.type = .Squares;
} else { } else {
doc.cpu_render.type = .Gradient; doc.cpu_render.type = .Gradient;
} }
canvas.redrawExample() catch {}; canvas.requestRedraw();
}
}
if (dvui.button(@src(), "Add random shapes", .{}, .{})) {
canvas.addRandomShapes() catch {};
}
if (dvui.button(@src(), "Request redraw", .{}, .{})) {
canvas.requestRedraw();
} }
} else { } else {
dvui.label(@src(), "No document", .{}, .{}); dvui.label(@src(), "No document", .{}, .{});
} }
} }
settings_section.deinit(); settings_section.deinit();
// Верхняя часть: дерево объектов
var tree_section = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.expand = .both,
.padding = dvui.Rect.all(panel_padding),
.corner_radius = dvui.Rect.all(panel_radius),
.color_fill = fill_color,
.background = true,
},
);
{
dvui.label(@src(), "Objects", .{}, .{
.font = .{
.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(
@src(),
.{ .vertical = .auto, .horizontal = .auto },
.{ .expand = .both, .background = false },
);
{
objectTree(ctx);
}
scroll.deinit();
}
tree_section.deinit();
} }
panel.deinit(); panel.deinit();
} }

66
src/ui/menu_bar.zig Normal file
View File

@@ -0,0 +1,66 @@
const std = @import("std");
const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig");
const Document = @import("../models/Document.zig");
const json_io = @import("../persistence/json_io.zig");
pub fn menuBar(ctx: *WindowContext) void {
var m = dvui.menu(@src(), .horizontal, .{ .background = true, .expand = .horizontal });
defer m.deinit();
if (dvui.menuItemLabel(@src(), "File", .{ .submenu = true }, .{ .expand = .none })) |r| {
var fm = dvui.floatingMenu(@src(), .{ .from = r }, .{});
defer fm.deinit();
if (dvui.menuItemLabel(@src(), "Open", .{}, .{ .expand = .horizontal }) != null) {
m.close();
openDocumentDialog(ctx);
}
if (dvui.menuItemLabel(@src(), "Save As", .{}, .{ .expand = .horizontal }) != null) {
m.close();
saveAsDialog(ctx);
}
}
}
fn openDocumentDialog(ctx: *WindowContext) void {
const path_z = dvui.dialogNativeFileOpen(ctx.allocator, .{
.title = "Open",
.filters = &.{ "*.json" },
.filter_description = "JSON files",
}) catch |err| {
std.debug.print("Open dialog error: {}\n", .{err});
return;
} orelse return;
defer ctx.allocator.free(path_z);
const path = path_z[0..path_z.len];
const doc = json_io.loadFromFile(Document, ctx.allocator, path) catch |err| {
std.debug.print("Open file error: {}\n", .{err});
return;
};
ctx.addDocument(doc) catch |err| {
std.debug.print("Add document error: {}\n", .{err});
return;
};
}
fn saveAsDialog(ctx: *WindowContext) void {
const open_doc = ctx.activeDocument() orelse return;
const path_z = dvui.dialogNativeFileSave(ctx.allocator, .{
.title = "Save As",
.filters = &.{ "*.json" },
.filter_description = "JSON files",
}) catch |err| {
std.debug.print("Save dialog error: {}\n", .{err});
return;
} orelse return;
defer ctx.allocator.free(path_z);
const path_raw = path_z[0..path_z.len];
json_io.saveToFile(Document, &open_doc.document, path_raw) catch |err| {
std.debug.print("Save file error: {}\n", .{err});
return;
};
}

View File

@@ -26,7 +26,7 @@ pub fn rightPanel(ctx: *WindowContext) void {
const active_doc = ctx.activeDocument(); const active_doc = ctx.activeDocument();
if (active_doc) |doc| { if (active_doc) |doc| {
const content_rect_scale = panel.data().contentRectScale(); const content_rect_scale = panel.data().contentRectScale();
canvas_view.canvasView(&doc.canvas, content_rect_scale); canvas_view.canvasView(&doc.canvas, doc.selected_object_id, content_rect_scale);
} else { } else {
noDocView(ctx); noDocView(ctx);
} }

View File

@@ -1,22 +1,113 @@
const std = @import("std"); const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const icons = @import("../icons.zig");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");
const DocCallback = union(enum) {
select: usize,
close: usize,
};
fn documentTab(ctx: *WindowContext, index: usize, callback: *?DocCallback) void {
const row_id: usize = index;
const is_selected = if (ctx.active_document_index) |active| active == index else false;
const focus_color = dvui.themeGet().focus;
var row = dvui.box(
@src(),
.{ .dir = .horizontal },
.{
.id_extra = row_id,
.expand = .none,
.gravity_y = 0.5,
},
);
{
var hovered: bool = false;
var select_row: bool = false;
const row_data = row.data();
// Ручная обработка hover/click по строке без пометки события как handled,
// чтобы кнопка закрытия могла нормально получать свои события.
for (dvui.events()) |*e| {
switch (e.evt) {
.mouse => |*mouse| {
if (!dvui.eventMatchSimple(e, row_data)) continue;
hovered = true;
if (mouse.action == .release and mouse.button == .left) {
select_row = true;
}
},
else => {},
}
}
var overlay = dvui.overlay(@src(), .{
.margin = dvui.Rect{ .x = 4, .w = 4 },
.padding = dvui.Rect{ .x = 12, .y = 0, .w = 0, .h = 0 },
.background = is_selected or hovered,
.color_fill = if (is_selected) focus_color.opacity(0.35) else if (hovered) focus_color.opacity(0.18) else null,
.corner_radius = dvui.Rect.all(4),
});
{
var buf: [32]u8 = undefined;
const label = std.fmt.bufPrint(&buf, "Doc {d}", .{index + 1}) catch "Doc";
dvui.labelNoFmt(@src(), label, .{}, .{
.gravity_x = 0.5,
.gravity_y = 0.5,
.margin = .{ .w = 24 },
});
if (hovered) {
if (dvui.buttonIcon(@src(), "Close", icons.cross, .{}, .{}, .{
.id_extra = row_id +% 1,
.margin = dvui.Rect{ .x = 8, .w = 6 },
.padding = dvui.Rect.all(2),
.gravity_x = 1,
.gravity_y = 0.5,
})) {
callback.* = .{ .close = index };
select_row = false;
}
}
}
overlay.deinit();
if (select_row) {
callback.* = .{ .select = index };
}
}
row.deinit();
}
pub fn tabBar(ctx: *WindowContext) void { pub fn tabBar(ctx: *WindowContext) void {
var bar = dvui.box( var bar = dvui.box(
@src(), @src(),
.{ .dir = .horizontal }, .{ .dir = .horizontal },
.{ .expand = .horizontal, .min_size_content = .{ .h = 32 }, .background = true, .padding = dvui.Rect.all(4) }, .{
.expand = .horizontal,
.background = true,
.padding = .{
.x = 12,
.w = 12,
},
},
); );
{ {
var callback: ?DocCallback = null;
for (ctx.documents.items, 0..) |_, i| { for (ctx.documents.items, 0..) |_, i| {
var buf: [32]u8 = undefined; documentTab(ctx, i, &callback);
const label = std.fmt.bufPrint(&buf, "Doc {d}", .{i + 1}) catch "Doc"; }
if (dvui.button(@src(), label, .{}, .{ .id_extra = i })) { if (callback) |action| {
ctx.setActiveDocument(i); switch (action) {
.select => |index| ctx.setActiveDocument(index),
.close => |index| ctx.closeDocument(index),
} }
} }
if (dvui.button(@src(), "+", .{}, .{})) {
if (dvui.buttonIcon(@src(), "Create", icons.plus, .{}, .{}, .{
.gravity_y = 0,
})) {
ctx.addNewDocument() catch |err| { ctx.addNewDocument() catch |err| {
std.debug.print("addNewDocument error: {}\n", .{err}); std.debug.print("addNewDocument error: {}\n", .{err});
}; };