diff --git a/Report/append_sources_to_report.py b/Report/append_sources_to_report.py new file mode 100644 index 0000000..f84a100 --- /dev/null +++ b/Report/append_sources_to_report.py @@ -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()) diff --git a/Report/header.tex b/Report/header.tex new file mode 100644 index 0000000..7b55a5e --- /dev/null +++ b/Report/header.tex @@ -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} diff --git a/Report/render_uml_png.py b/Report/render_uml_png.py new file mode 100644 index 0000000..aa071a3 --- /dev/null +++ b/Report/render_uml_png.py @@ -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 `. + - 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()) diff --git a/Report/sample_1.pdf b/Report/sample_1.pdf new file mode 100644 index 0000000..a731378 Binary files /dev/null and b/Report/sample_1.pdf differ diff --git a/Report/uml/canvas-viewport-algorithm.png b/Report/uml/canvas-viewport-algorithm.png new file mode 100644 index 0000000..2b3d718 Binary files /dev/null and b/Report/uml/canvas-viewport-algorithm.png differ diff --git a/Report/uml/canvas-viewport-algorithm.puml b/Report/uml/canvas-viewport-algorithm.puml new file mode 100644 index 0000000..ad9a175 --- /dev/null +++ b/Report/uml/canvas-viewport-algorithm.puml @@ -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 diff --git a/Report/uml/coordinate-pipeline.png b/Report/uml/coordinate-pipeline.png new file mode 100644 index 0000000..e30258c Binary files /dev/null and b/Report/uml/coordinate-pipeline.png differ diff --git a/Report/uml/coordinate-pipeline.puml b/Report/uml/coordinate-pipeline.puml new file mode 100644 index 0000000..a60ae87 --- /dev/null +++ b/Report/uml/coordinate-pipeline.puml @@ -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 diff --git a/Report/uml/ellipse-arc-rasterization.png b/Report/uml/ellipse-arc-rasterization.png new file mode 100644 index 0000000..6b617fe Binary files /dev/null and b/Report/uml/ellipse-arc-rasterization.png differ diff --git a/Report/uml/ellipse-arc-rasterization.puml b/Report/uml/ellipse-arc-rasterization.puml new file mode 100644 index 0000000..0770591 --- /dev/null +++ b/Report/uml/ellipse-arc-rasterization.puml @@ -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 diff --git a/Report/uml/liang-barsky-clip.png b/Report/uml/liang-barsky-clip.png new file mode 100644 index 0000000..4cb5615 Binary files /dev/null and b/Report/uml/liang-barsky-clip.png differ diff --git a/Report/uml/liang-barsky-clip.puml b/Report/uml/liang-barsky-clip.puml new file mode 100644 index 0000000..6bfd0da --- /dev/null +++ b/Report/uml/liang-barsky-clip.puml @@ -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 diff --git a/Report/uml/line-rasterization-flow.png b/Report/uml/line-rasterization-flow.png new file mode 100644 index 0000000..d480123 Binary files /dev/null and b/Report/uml/line-rasterization-flow.png differ diff --git a/Report/uml/line-rasterization-flow.puml b/Report/uml/line-rasterization-flow.puml new file mode 100644 index 0000000..3bbe7b2 --- /dev/null +++ b/Report/uml/line-rasterization-flow.puml @@ -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 diff --git a/Report/uml/pma-alpha-blending.png b/Report/uml/pma-alpha-blending.png new file mode 100644 index 0000000..bd5da09 Binary files /dev/null and b/Report/uml/pma-alpha-blending.png differ diff --git a/Report/uml/pma-alpha-blending.puml b/Report/uml/pma-alpha-blending.puml new file mode 100644 index 0000000..66fd2ed --- /dev/null +++ b/Report/uml/pma-alpha-blending.puml @@ -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 diff --git a/Report/uml/polyline-fill-algorithm.png b/Report/uml/polyline-fill-algorithm.png new file mode 100644 index 0000000..902761c Binary files /dev/null and b/Report/uml/polyline-fill-algorithm.png differ diff --git a/Report/uml/polyline-fill-algorithm.puml b/Report/uml/polyline-fill-algorithm.puml new file mode 100644 index 0000000..9d25719 --- /dev/null +++ b/Report/uml/polyline-fill-algorithm.puml @@ -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 diff --git a/Report/uml/transform-compose-algorithm.png b/Report/uml/transform-compose-algorithm.png new file mode 100644 index 0000000..6f84b4f Binary files /dev/null and b/Report/uml/transform-compose-algorithm.png differ diff --git a/Report/uml/transform-compose-algorithm.puml b/Report/uml/transform-compose-algorithm.puml new file mode 100644 index 0000000..335bfbd --- /dev/null +++ b/Report/uml/transform-compose-algorithm.puml @@ -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 diff --git a/Report/uml/zivro-architecture-components.png b/Report/uml/zivro-architecture-components.png new file mode 100644 index 0000000..5a2bc22 Binary files /dev/null and b/Report/uml/zivro-architecture-components.png differ diff --git a/Report/uml/zivro-architecture-components.puml b/Report/uml/zivro-architecture-components.puml new file mode 100644 index 0000000..92cd926 --- /dev/null +++ b/Report/uml/zivro-architecture-components.puml @@ -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 diff --git a/Report/uml/zivro-render-sequence.png b/Report/uml/zivro-render-sequence.png new file mode 100644 index 0000000..d3f9d14 Binary files /dev/null and b/Report/uml/zivro-render-sequence.png differ diff --git a/Report/uml/zivro-render-sequence.puml b/Report/uml/zivro-render-sequence.puml new file mode 100644 index 0000000..5fbd426 --- /dev/null +++ b/Report/uml/zivro-render-sequence.puml @@ -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 diff --git a/Report/zivro-open-project-report-with-code.md b/Report/zivro-open-project-report-with-code.md new file mode 100644 index 0000000..2a5763b --- /dev/null +++ b/Report/zivro-open-project-report-with-code.md @@ -0,0 +1,4292 @@ +# Лабораторная работа 3 +## Анализ и описание открытого проекта `Zivro` + +## 1. Цель работы + +Изучить архитектуру и ключевые алгоритмы открытого графического проекта, реализованного на языке Zig, и подготовить технический отчёт по его устройству, процессу сборки и принципам работы основных подсистем. + +## 2. Постановка задачи + +В рамках лабораторной работы требуется: + +- выбрать открытый проект и изучить его структуру; +- выделить основные модули и описать их взаимодействие; +- разобрать математические алгоритмы, используемые в проекте; +- описать процесс сборки, запуска и тестирования; +- подготовить отчёт в формате Markdown; +- сформировать приложение с исходным кодом автоматически, без ручной вставки кода в текст отчёта. + +## 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-рендере. +Диаграммы PlantUML будут добавлены отдельным шагом, после фиксации текстовой части. + +### 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} +$$ + +При композиции `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} +$$ + +Практический смысл: shape можно тестировать аналитически (по формулам) в "своей" удобной локальной системе, независимо от того, как он повернут и где расположен в документе. + +### 6.3. Рисование линии: отсечение + дискретизация + толщина + +Алгоритм в `line.zig` состоит из трёх частей. + +#### 6.3.1. Отсечение Liang-Barsky + +Перед рисованием линия отсекается расширенным прямоугольником буфера. +Параметрическая форма отрезка: + +$$ +P(t)=P_0+t(P_1-P_0),\quad t\in[0,1]. +$$ + +Для каждого ограничения (`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. +$$ + +- `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. +$$ + +Это даёт геометрически корректную полосу вокруг эллипса при произвольном повороте/масштабе объекта. + +#### 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` уже 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-рендер с преобразованиями координат и отдельные алгоритмы растеризации геометрии. + +Ключевые математические части - композиция трансформаций, отсечение и растеризация линий, рендер эллипсов/дуг, а также алгоритм заливки замкнутых контуров. +Текстовый отчёт подготовлен без встроенных листингов кода; для генерации версии с приложением исходников создан отдельный автоматизированный скрипт. + +--- + +## Приложение A. Исходные тексты + +Сформировано автоматически скриптом `Report/append_sources_to_report.py` (файлов: 39). + +### A.1. `build.zig` + +```zig +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const dvui_dep = b.dependency("dvui", .{ .target = target, .optimize = optimize, .backend = .sdl3 }); + + const exe = b.addExecutable(.{ + .name = "Zivro", + // .use_llvm = true, + // .use_lld = true, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "dvui", .module = dvui_dep.module("dvui_sdl3") }, + .{ .name = "sdl-backend", .module = dvui_dep.module("sdl3") }, + }, + }), + }); + exe.bundle_compiler_rt = true; + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const exe_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests.zig"), + .target = target, + .optimize = optimize, + + .imports = &.{ + .{ .name = "dvui", .module = dvui_dep.module("dvui_sdl3") }, + .{ .name = "sdl-backend", .module = dvui_dep.module("sdl3") }, + }, + }), + }); + + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_exe_tests.step); +} +``` + +### A.2. `build.zig.zon` + +``` +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .Zivro, + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0x62d7eba14686811e, // Changing this has security and trust implications. + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.15.2", + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + .dvui = .{ + .url = "git+https://github.com/david-vanderson/dvui#edb2d5a4cd2981fca74ee5f096277b91333c1316", + .hash = "dvui-0.4.0-dev-AQFJmeGB3QAwun9qF76CDE5IopA4nUVRgD-IwwTsOo4H", + }, + + // See `zig fetch --save ` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. If the contents of a URL change this will result in a hash mismatch + // // which will prevent zig from using it. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + // + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. Only files listed here will remain on disk + // when using the zig package manager. As a rule of thumb, one should list + // files required for compilation plus any license(s). + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} +``` + +### A.3. `src/Canvas.zig` + +```zig +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const Document = @import("models/Document.zig"); +const RenderEngine = @import("render/RenderEngine.zig").RenderEngine; +const basic_models = @import("models/basic_models.zig"); +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 tools = @import("toolbar/tools.zig"); +const Toolbar = @import("toolbar/Toolbar.zig"); +const random_document = @import("random_document.zig"); +const Canvas = @This(); + +allocator: std.mem.Allocator, +document: *Document, +render_engine: RenderEngine, +toolbar: Toolbar, +texture: ?dvui.Texture = null, +pos: dvui.Point = dvui.Point{ .x = 400, .y = 400 }, +scroll: dvui.ScrollInfo = .{ + .vertical = .auto, + .horizontal = .auto, +}, +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, +frame_index: u64 = 0, + +_zoom: f32 = 1, +_rendering_quality: f32 = 100.0, +_last_redraw_time_ms: i64 = 0, // Метка последней перерисовки чтобы ограничить частоту +_visible_rect: ?Rect_i = null, +_redraw_pending: bool = false, + +pub fn init(allocator: std.mem.Allocator, document: *Document, engine: RenderEngine) Canvas { + return .{ + .allocator = allocator, + .document = document, + .render_engine = engine, + .toolbar = Toolbar.init(&tools.default_tools), + }; +} + +pub fn deinit(self: *Canvas) void { + self.toolbar.deinit(); + if (self.texture) |texture| { + dvui.Texture.destroyLater(texture); + self.texture = null; + } +} + +fn redraw(self: *Canvas) !void { + const full = self.getZoomedImageSize(); + + const vis_full: Rect_i = self._visible_rect orelse Rect_i{ .x = 0, .y = 0, .w = 0, .h = 0 }; + + if (vis_full.w == 0 or vis_full.h == 0) { + if (self.texture) |tex| { + dvui.Texture.destroyLater(tex); + self.texture = null; + } + return; + } + + // Качество рендеринга задаётся в процентах площади (1–100), + // при этом фактически уменьшаем ширину/высоту холста на корень из этой доли. + 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 (self.texture) |old_tex| { + dvui.Texture.destroyLater(old_tex); + } + + self.texture = tex; + } + 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 { + self.render_engine.exampleReset(); + 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 { + self._zoom = @max(value, 0.01); +} + +pub fn addZoom(self: *Canvas, value: f32) void { + self._zoom += value; + 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 { + self._redraw_pending = true; +} + +pub fn processPendingRedraw(self: *Canvas) !void { + if (!self._redraw_pending) return; + if (self.redraw_throttle_ms == 0) { + self._redraw_pending = false; + try self.redraw(); + return; + } + 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; + if (elapsed < @as(i64, @intCast(self.redraw_throttle_ms))) return; + self._redraw_pending = false; + try self.redraw(); +} + +pub fn getZoomedImageSize(self: Canvas) Rect_i { + const doc = self.document; + return .{ + .x = @intFromFloat(self.pos.x), + .y = @intFromFloat(self.pos.y), + .w = @intFromFloat(doc.size.w * self._zoom), + .h = @intFromFloat(doc.size.h * self._zoom), + }; +} + +/// Точка контента -> координаты документа. +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 left_n = @as(f32, @floatFromInt(img.x)) / 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 bottom_n = @as(f32, @floatFromInt(img.y + img.h)) / natural_scale; + return content_point.x >= left_n and content_point.x < right_n and + content_point.y >= top_n and content_point.y < bottom_n; +} + +pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) bool { + const next = computeVisibleImageRect(self.*, viewport, scroll_offset); + var changed = false; + if (self._visible_rect) |vis| { + changed |= next.x != vis.x or next.y != vis.y or next.w != vis.w or next.h != vis.h; + } + self._visible_rect = next; + if (changed or self.texture == null) { + return true; + } + return false; +} + +fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) Rect_i { + const image_rect = self.getZoomedImageSize(); + + const img_w: u32 = image_rect.w; + const img_h: u32 = image_rect.h; + + const vis_w: u32 = @min(@as(u32, @intFromFloat(viewport.w)), img_w); + const vis_h: u32 = @min(@as(u32, @intFromFloat(viewport.h)), img_h); + + const raw_x: i64 = @intFromFloat(scroll_offset.x - @as(f32, @floatFromInt(image_rect.x))); + const raw_y: i64 = @intFromFloat(scroll_offset.y - @as(f32, @floatFromInt(image_rect.y))); + + 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))); + + return Rect_i{ + .x = vis_x, + .y = vis_y, + .w = vis_w, + .h = vis_h, + }; +} +``` + +### A.4. `src/WindowContext.zig` + +```zig +const std = @import("std"); +const Canvas = @import("Canvas.zig"); +const CpuRenderEngine = @import("render/CpuRenderEngine.zig"); +const RenderEngine = @import("render/RenderEngine.zig").RenderEngine; +const Document = @import("models/Document.zig"); +const random_document = @import("random_document.zig"); +const basic_models = @import("models/basic_models.zig"); + +const WindowContext = @This(); + +pub const OpenDocument = struct { + document: Document, + cpu_render: CpuRenderEngine, + canvas: Canvas, + /// Выбранный объект в дереве (id объекта). + selected_object_id: ?u64 = null, + + pub fn init(allocator: std.mem.Allocator, self: *OpenDocument) void { + initWithDocument(allocator, self, .init(.{ + .w = 800, + .h = 600, + })); + } + + pub fn initWithDocument(allocator: std.mem.Allocator, self: *OpenDocument, doc: Document) void { + 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(); + } +}; + +allocator: std.mem.Allocator, +documents: std.ArrayList(*OpenDocument), +active_document_index: ?usize, + +pub fn init(allocator: std.mem.Allocator) !WindowContext { + const documents = std.ArrayList(*OpenDocument).empty; + const active_document_index: ?usize = null; + return .{ + .allocator = allocator, + .documents = documents, + .active_document_index = active_document_index, + }; +} + +pub fn deinit(self: *WindowContext) void { + for (self.documents.items) |ptr| { + ptr.deinit(self.allocator); + self.allocator.destroy(ptr); + } + self.documents.deinit(self.allocator); +} + +pub fn activeDocument(self: *WindowContext) ?*OpenDocument { + const i = self.active_document_index orelse return null; + if (i >= self.documents.items.len) return null; + return self.documents.items[i]; +} + +pub fn addNewDocument(self: *WindowContext) !void { + const ptr = try self.allocator.create(OpenDocument); + errdefer self.allocator.destroy(ptr); + OpenDocument.init(self.allocator, ptr); + //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); + self.active_document_index = self.documents.items.len - 1; +} + +pub fn setActiveDocument(self: *WindowContext, index: usize) void { + if (index < self.documents.items.len) { + self.active_document_index = index; + } +} + +pub fn closeDocument(self: *WindowContext, index: usize) void { + if (index >= self.documents.items.len) return; + const open_doc = self.documents.items[index]; + open_doc.deinit(self.allocator); + self.allocator.destroy(open_doc); + _ = self.documents.orderedRemove(index); + + if (self.active_document_index) |*active| { + if (index < active.*) { + active.* -= 1; + } else if (index == active.*) { + if (self.documents.items.len > 0) { + active.* = @min(index, self.documents.items.len - 1); + } else { + self.active_document_index = null; + } + } + } +} +``` + +### A.5. `src/icons.zig` + +```zig +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; +``` + +### A.6. `src/main.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const SDLBackend = @import("sdl-backend"); +const WindowContext = @import("WindowContext.zig"); +const ui = @import("ui/frame.zig"); + +pub fn main() !void { + // std.heap.GeneralPurposeAllocator was renamed to DebugAllocator recently. + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + + const allocator = gpa.allocator(); + + var backend = try SDLBackend.initWindow(.{ + .allocator = allocator, + .size = .{ .w = 800.0, .h = 600.0 }, + .title = "My DVUI App", + .vsync = true, + }); + defer backend.deinit(); + + var win = try dvui.Window.init(@src(), allocator, backend.backend(), .{ + .theme = switch (backend.preferredColorScheme() orelse .light) { + .light => dvui.Theme.builtin.adwaita_light, + .dark => dvui.Theme.builtin.adwaita_dark, + }, + }); + defer win.deinit(); + + var ctx = try WindowContext.init(allocator); + defer ctx.deinit(); + + var interrupted = false; + main_loop: while (true) { + const nstime = win.beginWait(interrupted); + try win.begin(nstime); + try backend.addAllEvents(&win); + + _ = SDLBackend.c.SDL_SetRenderDrawColor(backend.renderer, 0, 0, 0, 255); + _ = SDLBackend.c.SDL_RenderClear(backend.renderer); + + if (!ui.guiFrame(&ctx)) break :main_loop; + + const end_micros = try win.end(.{}); + try backend.setCursor(win.cursorRequested()); + try backend.textInputRect(win.textInputRequested()); + try backend.renderPresent(); + + const wait_event_micros = win.waitTime(end_micros); + interrupted = try backend.waitEventTimeout(wait_event_micros); + } +} +``` + +### A.7. `src/models/Document.zig` + +```zig +const std = @import("std"); +const basic_models = @import("basic_models.zig"); +const Size_f = basic_models.Size_f; +const Document = @This(); + +pub const Object = @import("Object.zig"); +const shape = @import("shape/shape.zig"); + +size: Size_f, +objects: std.ArrayList(Object), +next_object_id: u64, + +pub fn init(size: Size_f) Document { + return .{ + .size = size, + .objects = std.ArrayList(Object).empty, + .next_object_id = 1, + }; +} + +pub fn deinit(self: *Document, allocator: std.mem.Allocator) void { + for (self.objects.items) |*obj| obj.deinit(allocator); + self.objects.deinit(allocator); +} + +pub fn clone(self: *const Document, allocator: std.mem.Allocator) !Document { + var objects_list = std.ArrayList(Object).empty; + 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 addObject(self: *Document, allocator: std.mem.Allocator, template: Object) !void { + 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| { + try p.addChild(allocator, obj, &self.next_object_id); + } else { + try self.addObject(allocator, obj); + } +} + +/// Удаляет объект из документа (из корня или из детей родителя). Возвращает true, если объект был найден и удалён. +pub fn removeObject(self: *Document, allocator: std.mem.Allocator, obj: *Object) bool { + 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, если объект был найден и удалён. +pub fn removeObjectById(self: *Document, allocator: std.mem.Allocator, obj_id: u64) bool { + for (self.objects.items, 0..) |*item, i| { + if (item.id == obj_id) { + var removed = self.objects.orderedRemove(i); + removed.deinit(allocator); + return true; + } + 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; +} +``` + +### A.8. `src/models/Object.zig` + +```zig +const std = @import("std"); +const Property = @import("Property.zig").Property; +const PropertyData = @import("Property.zig").Data; +const Object = @This(); + +pub const ShapeKind = enum { + line, + ellipse, + 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, +properties: std.ArrayList(Property), +children: std.ArrayList(Object), + +pub fn getProperty(self: Object, comptime tag: std.meta.Tag(PropertyData)) ?@FieldType(PropertyData, @tagName(tag)) { + for (self.properties.items) |*prop| { + if (std.meta.activeTag(prop.data) == tag) return @field(prop.data, @tagName(tag)); + } + return null; +} + +/// Забирает владение Property +pub fn setProperty(self: *Object, allocator: std.mem.Allocator, prop: Property) !void { + for (self.properties.items, 0..) |*p, i| { + if (std.meta.activeTag(p.data) == std.meta.activeTag(prop.data)) { + if (p.data == .points) allocator.free(p.data.points); + self.properties.items[i] = prop; + return; + } + } + return error.PropertyNotFound; +} + +pub fn addChild(self: *Object, allocator: std.mem.Allocator, template: Object, next_id: *u64) !void { + const obj = try template.clone(allocator, next_id); + try self.children.append(allocator, obj); +} + +pub fn clone(self: Object, allocator: std.mem.Allocator, next_id: *u64) !Object { + var properties_list = std.ArrayList(Property).empty; + errdefer properties_list.deinit(allocator); + for (self.properties.items) |prop| { + try properties_list.append(allocator, try prop.clone(allocator)); + } + + var children_list = std.ArrayList(Object).empty; + errdefer children_list.deinit(allocator); + for (self.children.items) |child| { + try children_list.append(allocator, try child.clone(allocator, next_id)); + } + + return .{ + .id = allocId(next_id), + .shape = self.shape, + .properties = properties_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 { + for (self.children.items) |*child| child.deinit(allocator); + self.children.deinit(allocator); + for (self.properties.items) |*prop| prop.deinit(allocator); + self.properties.deinit(allocator); + self.* = undefined; +} +``` + +### A.9. `src/models/Property.zig` + +```zig +const std = @import("std"); +const basic_models = @import("basic_models.zig"); +const Point2_f = basic_models.Point2_f; +const Scale2_f = basic_models.Scale2_f; +const Size_f = basic_models.Size_f; +const Radii_f = basic_models.Radii_f; + +pub const Data = union(enum) { + position: Point2_f, + angle: f32, + scale: Scale2_f, + visible: bool, + opacity: f32, + locked: bool, + + size: Size_f, + radii: Radii_f, + /// Процент дуги эллипса: 100 — полный эллипс, 50 — полуэллипс (0..100). + arc_percent: f32, + end_point: Point2_f, + + /// Владеет памятью; при deinit/clone — free/duplicate. + points: []const Point2_f, + /// Замкнутый контур (для ломаной: отрезок последняя–первая точка + заливка). + closed: bool, + + /// Включена ли заливка. + filled: bool, + + /// Цвет заливки, 0xRRGGBBAA. + fill_rgba: u32, + /// Цвет обводки, 0xRRGGBBAA. + stroke_rgba: u32, + + thickness: f32, +}; + +pub const Property = struct { + data: Data, + + pub fn deinit(self: *Property, allocator: std.mem.Allocator) void { + switch (self.data) { + .points => |slice| allocator.free(slice), + else => {}, + } + self.* = undefined; + } + + pub fn clone(self: Property, allocator: std.mem.Allocator) !Property { + return switch (self.data) { + .points => |slice| .{ + .data = .{ + .points = blk: { + const copy = try allocator.dupe(Point2_f, slice); + break :blk copy; + }, + }, + }, + else => .{ .data = self.data }, + }; + } +}; +``` + +### A.10. `src/models/basic_models.zig` + +```zig +pub const Rect_i = struct { + x: u32, + y: u32, + w: u32, + h: u32, +}; + +pub const Size_i = struct { + w: u32, + h: u32, +}; + +pub const Size_f = struct { + w: f32, + h: f32, +}; + +pub const Point2_f = struct { + x: f32 = 0, + y: f32 = 0, +}; + +pub const Point2_i = struct { + x: i32 = 0, + y: i32 = 0, +}; + +pub const Radii_f = struct { + x: f32, + y: f32, +}; + +pub const Scale2_f = struct { + scale_x: f32 = 1, + scale_y: f32 = 1, +}; + +pub const Rect_f = struct { + x: f32 = 0, + y: f32 = 0, + w: f32 = 0, + h: f32 = 0, +}; +``` + +### A.11. `src/models/shape/broken.zig` + +```zig +const std = @import("std"); +const Object = @import("../Object.zig"); +const Property = @import("../Property.zig").Property; +const PropertyData = @import("../Property.zig").Data; +const Point2_f = @import("../basic_models.zig").Point2_f; +const Rect_f = @import("../basic_models.zig").Rect_f; +const shape_mod = @import("shape.zig"); + +/// Свойства фигуры по умолчанию (добавляются к общим). points — слайс на статический массив. +pub const default_shape_properties_points = [_]Point2_f{ + .{ .x = 0, .y = 0 }, + .{ .x = 80, .y = 0 }, + .{ .x = 80, .y = 60 }, +}; +pub const default_shape_properties = [_]Property{ + .{ .data = .{ .points = &default_shape_properties_points } }, + .{ .data = .{ .closed = false } }, + .{ .data = .{ .filled = true } }, + .{ .data = .{ .fill_rgba = 0x000000FF } }, +}; +``` + +### A.12. `src/models/shape/ellipse.zig` + +```zig +const std = @import("std"); +const Object = @import("../Object.zig"); +const Property = @import("../Property.zig").Property; +const Rect_f = @import("../basic_models.zig").Rect_f; +const shape_mod = @import("shape.zig"); + +/// Свойства фигуры по умолчанию (добавляются к общим). +pub const default_shape_properties = [_]Property{ + .{ .data = .{ .radii = .{ .x = 50, .y = 50 } } }, + .{ .data = .{ .arc_percent = 100.0 } }, + .{ .data = .{ .closed = true } }, + .{ .data = .{ .filled = false } }, + .{ .data = .{ .fill_rgba = 0x000000FF } }, +}; +``` + +### A.13. `src/models/shape/line.zig` + +```zig +const std = @import("std"); +const Object = @import("../Object.zig"); +const Property = @import("../Property.zig").Property; +const Rect_f = @import("../basic_models.zig").Rect_f; +const shape_mod = @import("shape.zig"); + +/// Свойства фигуры по умолчанию (добавляются к общим). +pub const default_shape_properties = [_]Property{ + .{ .data = .{ .end_point = .{ .x = 100, .y = 200 } } }, +}; +``` + +### A.14. `src/models/shape/shape.zig` + +```zig +const std = @import("std"); +const Object = @import("../Object.zig"); +const Property = @import("../Property.zig").Property; +const PropertyData = @import("../Property.zig").Data; +const defaultCommonProperties = Object.defaultCommonProperties; +const basic_models = @import("../basic_models.zig"); +const Point2_f = basic_models.Point2_f; +const line = @import("line.zig"); +const ellipse = @import("ellipse.zig"); +const broken = @import("broken.zig"); +pub const Rect = basic_models.Rect_f; + +/// Добавляет к объекту список свойств фигуры. Для .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 { + var obj = try createWithCommonProperties(allocator, shape_kind); + errdefer obj.deinit(allocator); + switch (shape_kind) { + .line => try appendShapeProperties(allocator, &obj, &line.default_shape_properties), + .ellipse => try appendShapeProperties(allocator, &obj, &ellipse.default_shape_properties), + .broken => { + try appendShapeProperties(allocator, &obj, &broken.default_shape_properties); + try obj.setProperty(allocator, .{ .data = .{ .fill_rgba = obj.getProperty(.stroke_rgba).? } }); + }, + } + return obj; +} + +fn createWithCommonProperties(allocator: std.mem.Allocator, shape_kind: Object.ShapeKind) !Object { + var properties_list = std.ArrayList(Property).empty; + errdefer properties_list.deinit(allocator); + for (defaultCommonProperties) |prop| try properties_list.append(allocator, prop); + return .{ + .id = 0, + .shape = shape_kind, + .properties = properties_list, + .children = std.ArrayList(Object).empty, + }; +} +``` + +### A.15. `src/persistence/json_io.zig` + +```zig +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; +} +``` + +### A.16. `src/random_document.zig` + +```zig +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); + } + } +} +``` + +### A.17. `src/render/CpuRenderEngine.zig` + +```zig +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const RenderEngine = @import("RenderEngine.zig").RenderEngine; +const Document = @import("../models/Document.zig"); +const basic_models = @import("../models/basic_models.zig"); +const cpu_draw = @import("cpu/draw.zig"); +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 Color = dvui.Color; + +const CpuRenderEngine = @This(); +const Type = enum { + Gradient, + Squares, +}; + +type: Type, +gradient_start: Color.PMA = .{ .r = 0, .g = 0, .b = 0, .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 { + return .{ + ._allocator = allocator, + .type = render_type, + ._renderStats = .{ + .render_time_ns = 0, + }, + }; +} + +pub fn exampleReset(self: *CpuRenderEngine) void { + var prng = std.Random.DefaultPrng.init(@intCast(std.time.microTimestamp())); + const random = prng.random(); + self.gradient_start = 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: Rect_i) void { + var y: u32 = 0; + while (y < height) : (y += 1) { + var x: u32 = 0; + while (x < width) : (x += 1) { + const gx: u32 = visible_rect.x + x; + const gy: u32 = visible_rect.y + y; + + const denom_x: f32 = if (full_w > 1) @as(f32, @floatFromInt(full_w - 1)) else 1; + const denom_y: f32 = if (full_h > 1) @as(f32, @floatFromInt(full_h - 1)) else 1; + const fx: f32 = @as(f32, @floatFromInt(gx)) / denom_x; + const fy: f32 = @as(f32, @floatFromInt(gy)) / denom_y; + const factor: f32 = std.math.clamp((fx + fy) / 2, 0, 1); + + const r_f: f32 = @as(f32, @floatFromInt(self.gradient_start.r)) + factor * (@as(f32, @floatFromInt(self.gradient_end.r)) - @as(f32, @floatFromInt(self.gradient_start.r))); + const g_f: f32 = @as(f32, @floatFromInt(self.gradient_start.g)) + factor * (@as(f32, @floatFromInt(self.gradient_end.g)) - @as(f32, @floatFromInt(self.gradient_start.g))); + const b_f: f32 = @as(f32, @floatFromInt(self.gradient_start.b)) + factor * (@as(f32, @floatFromInt(self.gradient_end.b)) - @as(f32, @floatFromInt(self.gradient_start.b))); + + const r: u8 = @intFromFloat(std.math.clamp(r_f, 0, 255)); + const g: u8 = @intFromFloat(std.math.clamp(g_f, 0, 255)); + const b: u8 = @intFromFloat(std.math.clamp(b_f, 0, 255)); + pixels[y * width + x] = .{ .r = r, .g = g, .b = b, .a = 255 }; + } + } +} + +fn renderSquares(pixels: []Color.PMA, canvas_size: Size_i, visible_rect: Rect_i) void { + const colors = [_]Color.PMA{ + .{ .r = 255, .g = 0, .b = 0, .a = 255 }, + .{ .r = 255, .g = 165, .b = 0, .a = 255 }, + .{ .r = 255, .g = 255, .b = 0, .a = 255 }, + .{ .r = 0, .g = 255, .b = 0, .a = 255 }, + .{ .r = 0, .g = 255, .b = 255, .a = 255 }, + .{ .r = 0, .g = 0, .b = 255, .a = 255 }, + }; + + const squares_num = 5; + var thikness: u32 = @intFromFloat(@as(f32, @floatFromInt(canvas_size.w + canvas_size.h)) / 2 * 0.03); + if (thikness == 0) thikness = 1; + + const squares_sum_w = canvas_size.w - thikness * (squares_num + 1); + const base_w = squares_sum_w / squares_num; + const extra_w = squares_sum_w % squares_num; + const squares_sum_h = canvas_size.h - thikness * (squares_num + 1); + const base_h = squares_sum_h / squares_num; + const extra_h = squares_sum_h % squares_num; + + var x_pos: [6]u32 = undefined; + x_pos[0] = 0; + for (1..squares_num + 1) |i| { + const w = base_w + if (i - 1 < extra_w) @as(u32, 1) else 0; + x_pos[i] = x_pos[i - 1] + thikness + w; + } + + var y_pos: [6]u32 = undefined; + y_pos[0] = 0; + for (1..squares_num + 1) |i| { + const h = base_h + if (i - 1 < extra_h) @as(u32, 1) else 0; + y_pos[i] = y_pos[i - 1] + thikness + h; + } + + var y: u32 = 0; + while (y < visible_rect.h) : (y += 1) { + const canvas_y = y + visible_rect.y; + if (canvas_y >= canvas_size.h) continue; + var x: u32 = 0; + while (x < visible_rect.w) : (x += 1) { + const canvas_x = x + visible_rect.x; + if (canvas_x >= canvas_size.w) continue; + + var vertical_index: ?u32 = null; + for (0..x_pos.len) |i| { + if (canvas_x >= x_pos[i] and canvas_x < x_pos[i] + thikness) { + vertical_index = @intCast(i); + break; + } + } + + var horizontal_index: ?u32 = null; + for (0..y_pos.len) |i| { + if (canvas_y >= y_pos[i] and canvas_y < y_pos[i] + thikness) { + horizontal_index = @intCast(i); + break; + } + } + + if (vertical_index) |idx| { + pixels[y * visible_rect.w + x] = colors[idx]; + } else if (horizontal_index) |idx| { + pixels[y * visible_rect.w + x] = colors[idx]; + } else { + var square_x: u32 = 0; + for (0..squares_num) |i| { + if (canvas_x >= x_pos[i] + thikness and canvas_x < x_pos[i + 1]) { + square_x = @intCast(i); + break; + } + } + var square_y: u32 = 0; + for (0..squares_num) |i| { + if (canvas_y >= y_pos[i] + thikness and canvas_y < y_pos[i + 1]) { + square_y = @intCast(i); + break; + } + } + if (square_x % 2 == square_y % 2) { + pixels[y * visible_rect.w + x] = .{ .r = 255, .g = 255, .b = 255, .a = 255 }; + } else { + pixels[y * visible_rect.w + x] = .{ .r = 0, .g = 0, .b = 0, .a = 255 }; + } + } + } + } +} + +pub fn example(self: CpuRenderEngine, canvas_size: Size_i, visible_rect: Rect_i) !?dvui.Texture { + const full_w = canvas_size.w; + const full_h = canvas_size.h; + + 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); + + switch (self.type) { + .Gradient => self.renderGradient(pixels, width, height, full_w, full_h, visible_rect), + .Squares => renderSquares(pixels, canvas_size, visible_rect), + } + + return try dvui.textureCreate(pixels, width, height, .nearest, .rgba_8_8_8_8); +} + +pub fn renderEngine(self: *CpuRenderEngine) RenderEngine { + 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, .rgba_8_8_8_8); +} + +pub fn getStats(self: CpuRenderEngine) RenderStats { + return self._renderStats; +} +``` + +### A.18. `src/render/RenderEngine.zig` + +```zig +const dvui = @import("dvui"); +const CpuRenderEngine = @import("CpuRenderEngine.zig"); +const Document = @import("../models/Document.zig"); +const basic_models = @import("../models/basic_models.zig"); +const RenderStats = @import("RenderStats.zig"); + +pub const RenderEngine = union(enum) { + cpu: *CpuRenderEngine, + + pub fn exampleReset(self: RenderEngine) void { + switch (self) { + .cpu => |cpu_r| cpu_r.exampleReset(), + } + } + + pub fn example(self: RenderEngine, canvas_size: basic_models.Size_i, visible_rect: basic_models.Rect_i) !?dvui.Texture { + return switch (self) { + .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), + }; + } +}; +``` + +### A.19. `src/render/RenderStats.zig` + +```zig +const RenderStats = @This(); + +render_time_ns: u64, // Время рендера кадра в микросекундах +``` + +### A.20. `src/render/cpu/broken.zig` + +```zig +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(©_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(©_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(©_ctx, copy_ctx.transform.opacity); +} +``` + +### A.21. `src/render/cpu/draw.zig` + +```zig +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); + } +} +``` + +### A.22. `src/render/cpu/ellipse.zig` + +```zig +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); +} +``` + +### A.23. `src/render/cpu/line.zig` + +```zig +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; +} + +/// Liang–Barsky отсечение отрезка (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; + } + } +} +``` + +### A.24. `src/render/cpu/pipeline.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const basic_models = @import("../../models/basic_models.zig"); +const Document = @import("../../models/Document.zig"); +const Point2_f = basic_models.Point2_f; +const Point2_i = basic_models.Point2_i; +const Scale2_f = basic_models.Scale2_f; +const Rect_i = basic_models.Rect_i; +const Color = dvui.Color; + +/// Трансформ объекта: позиция, угол, масштаб, непрозрачность. +pub const Transform = struct { + position: Point2_f = .{}, + angle: f32 = 0, + scale: Scale2_f = .{}, + opacity: f32 = 1.0, + + pub fn init(obj: *const Document.Object) Transform { + const pos = obj.getProperty(.position) orelse Point2_f{ .x = 0, .y = 0 }; + const angle = obj.getProperty(.angle) orelse 0; + const scale = obj.getProperty(.scale) orelse Scale2_f{ .scale_x = 1, .scale_y = 1 }; + const opacity = obj.getProperty(.opacity) orelse 1.0; + return .{ + .position = pos, + .angle = angle, + .scale = scale, + .opacity = opacity, + }; + } + + /// Композиция: world = parent * local. + pub fn compose(parent: Transform, local: Transform) Transform { + const cos_a = std.math.cos(parent.angle); + const sin_a = std.math.sin(parent.angle); + const sx = parent.scale.scale_x * local.scale.scale_x; + const sy = parent.scale.scale_y * local.scale.scale_y; + const local_px = local.position.x * parent.scale.scale_x; + const local_py = local.position.y * parent.scale.scale_y; + const rx = cos_a * local_px - sin_a * local_py; + const ry = sin_a * local_px + cos_a * local_py; + return .{ + .position = .{ + .x = parent.position.x + rx, + .y = parent.position.y + ry, + }, + .angle = parent.angle + local.angle, + .scale = .{ .scale_x = sx, .scale_y = sy }, + .opacity = parent.opacity * local.opacity, + }; + } +}; + +/// Мировые -> локальные для заданного трансформа. +pub fn worldToLocalTransform(t: Transform, wx: f32, wy: f32) Point2_f { + const dx = wx - t.position.x; + const dy = wy - t.position.y; + const cos_a = std.math.cos(-t.angle); + const sin_a = std.math.sin(-t.angle); + const sx = if (t.scale.scale_x != 0) t.scale.scale_x else 1.0; + const sy = if (t.scale.scale_y != 0) t.scale.scale_y else 1.0; + return .{ + .x = (dx * cos_a - dy * sin_a) / sx, + .y = (dx * sin_a + dy * cos_a) / sy, + }; +} + +/// Конвейер отрисовки: локальные координаты -> трансформ -> буфер. +pub const DrawContext = struct { + pixels: []Color.PMA, + buf_width: u32, + buf_height: u32, + visible_rect: Rect_i, + scale_x: f32, + scale_y: f32, + transform: Transform = .{}, + /// Если true, blendPixelAtBuffer перезаписывает пиксель без бленда + replace_mode: bool = false, + _fill_canvas: ?*FillCanvas = null, + + pub fn setTransform(self: *DrawContext, t: Transform) void { + self.transform = t; + } + + /// Локальные -> мировые. + pub fn localToWorld(self: *const DrawContext, local_x: f32, local_y: f32) Point2_f { + const t = &self.transform; + const cos_a = std.math.cos(t.angle); + const sin_a = std.math.sin(t.angle); + return .{ + .x = t.position.x + (local_x * t.scale.scale_x) * cos_a - (local_y * t.scale.scale_y) * sin_a, + .y = t.position.y + (local_x * t.scale.scale_x) * sin_a + (local_y * t.scale.scale_y) * cos_a, + }; + } + + /// Мировые -> буфер (float). + pub fn worldToBufferF(self: *const DrawContext, wx: f32, wy: f32) Point2_f { + const canvas_x = wx * self.scale_x; + const canvas_y = wy * self.scale_y; + const vx = @as(f32, @floatFromInt(self.visible_rect.x)); + const vy = @as(f32, @floatFromInt(self.visible_rect.y)); + return .{ + .x = canvas_x - vx, + .y = canvas_y - vy, + }; + } + + /// Мировые -> буфер (целые). + pub fn worldToBuffer(self: *const DrawContext, wx: f32, wy: f32) Point2_i { + const b = self.worldToBufferF(wx, wy); + return .{ + .x = @intFromFloat(std.math.round(b.x)), + .y = @intFromFloat(std.math.round(b.y)), + }; + } + + /// Буфер -> мировые. + pub fn bufferToWorld(self: *const DrawContext, buf_x: f32, buf_y: f32) Point2_f { + const vx = @as(f32, @floatFromInt(self.visible_rect.x)); + const vy = @as(f32, @floatFromInt(self.visible_rect.y)); + const canvas_x = buf_x + vx; + const canvas_y = buf_y + vy; + const sx = if (self.scale_x != 0) self.scale_x else 1.0; + const sy = if (self.scale_y != 0) self.scale_y else 1.0; + return .{ + .x = canvas_x / sx, + .y = canvas_y / sy, + }; + } + + /// Мировые -> локальные. + pub fn worldToLocal(self: *const DrawContext, wx: f32, wy: f32) Point2_f { + return worldToLocalTransform(self.transform, wx, wy); + } + + /// Смешивает цвет в пикселе буфера с учётом opacity трансформа. В replace_mode просто перезаписывает пиксель. + /// Если активен fill canvas, каждый записанный пиксель помечается как граница для заливки. + pub fn blendPixelAtBuffer(self: *DrawContext, bx_i32: i32, by_i32: i32, color: Color.PMA) void { + if (self._fill_canvas) |fc| fc.setBorder(bx_i32, by_i32); + + if (bx_i32 < 0 or by_i32 < 0 or bx_i32 >= self.buf_width or by_i32 >= self.buf_height) return; + const bx: u32 = @intCast(bx_i32); + const by: u32 = @intCast(by_i32); + const idx = by * self.buf_width + bx; + const dst = &self.pixels[idx]; + if (self.replace_mode) { + dst.* = color; + return; + } + const t = &self.transform; + const a = @as(f32, @floatFromInt(color.a)) / 255.0 * t.opacity; + const src_r = @as(f32, @floatFromInt(color.r)) * t.opacity; + const src_g = @as(f32, @floatFromInt(color.g)) * t.opacity; + const src_b = @as(f32, @floatFromInt(color.b)) * t.opacity; + const inv_a = 1.0 - a; + dst.r = @intFromFloat(std.math.clamp(src_r + inv_a * @as(f32, @floatFromInt(dst.r)), 0, 255)); + dst.g = @intFromFloat(std.math.clamp(src_g + inv_a * @as(f32, @floatFromInt(dst.g)), 0, 255)); + dst.b = @intFromFloat(std.math.clamp(src_b + inv_a * @as(f32, @floatFromInt(dst.b)), 0, 255)); + dst.a = @intFromFloat(std.math.clamp(a * 255 + inv_a * @as(f32, @floatFromInt(dst.a)), 0, 255)); + } + + /// Накладывает буфер другого контекста на этот с заданной прозрачностью (один бленд на пиксель). Размеры буферов должны совпадать. + pub fn compositeDrawerContext(self: *DrawContext, other: *const DrawContext, opacity: f32) void { + if (self.buf_width != other.buf_width or self.buf_height != other.buf_height) return; + const n = self.buf_width * self.buf_height; + for (0..n) |i| { + const src = other.pixels[i]; + if (src.a == 0) continue; + const dst = &self.pixels[i]; + const a = @as(f32, @floatFromInt(src.a)) / 255.0 * opacity; + const src_r = @as(f32, @floatFromInt(src.r)) * opacity; + const src_g = @as(f32, @floatFromInt(src.g)) * opacity; + const src_b = @as(f32, @floatFromInt(src.b)) * opacity; + const inv_a = 1.0 - a; + dst.r = @intFromFloat(std.math.clamp(src_r + inv_a * @as(f32, @floatFromInt(dst.r)), 0, 255)); + dst.g = @intFromFloat(std.math.clamp(src_g + inv_a * @as(f32, @floatFromInt(dst.g)), 0, 255)); + dst.b = @intFromFloat(std.math.clamp(src_b + inv_a * @as(f32, @floatFromInt(dst.b)), 0, 255)); + dst.a = @intFromFloat(std.math.clamp(a * 255 + inv_a * @as(f32, @floatFromInt(dst.a)), 0, 255)); + } + } + + /// Пиксель в локальных координатах (трансформ + PMA). + pub fn blendPixelLocal(self: *DrawContext, local_x: f32, local_y: f32, color: Color.PMA) void { + const w = self.localToWorld(local_x, local_y); + const b = self.worldToBufferF(w.x, w.y); + const bx: i32 = @intFromFloat(b.x); + const by: i32 = @intFromFloat(b.y); + const vw = @as(i32, @intCast(self.visible_rect.w)); + const vh = @as(i32, @intCast(self.visible_rect.h)); + if (bx < 0 or bx >= vw or by < 0 or by >= vh) return; + self.blendPixelAtBuffer(bx, by, color); + } + + /// Начинает сбор границ для заливки: создаёт FillCanvas и при последующих вызовах blendPixelAtBuffer помечает пиксели как границу. + pub fn startFill(self: *DrawContext, allocator: std.mem.Allocator) !void { + const fc = try FillCanvas.init(allocator, self.buf_width, self.buf_height); + const ptr = try allocator.create(FillCanvas); + ptr.* = fc; + self._fill_canvas = ptr; + } + + /// Рисует заливку по собранным границам цветом color, освобождает FillCanvas и сбрасывает режим. + pub fn stopFill(self: *DrawContext, allocator: std.mem.Allocator, color: Color.PMA) void { + const fc = self._fill_canvas orelse return; + self._fill_canvas = null; + fc.fillColor(self, allocator, color); + fc.deinit(); + allocator.destroy(fc); + } +}; + +/// Конвертирует u32 0xRRGGBBAA в Color.PMA. +pub fn rgbaToPma(rgba: u32) Color.PMA { + const r: u8 = @intCast((rgba >> 24) & 0xFF); + const g: u8 = @intCast((rgba >> 16) & 0xFF); + const b: u8 = @intCast((rgba >> 8) & 0xFF); + const a: u8 = @intCast((rgba >> 0) & 0xFF); + if (a == 0) return .{ .r = 0, .g = 0, .b = 0, .a = 0 }; + const af: f32 = @as(f32, @floatFromInt(a)) / 255.0; + return .{ + .r = @intFromFloat(@as(f32, @floatFromInt(r)) * af), + .g = @intFromFloat(@as(f32, @floatFromInt(g)) * af), + .b = @intFromFloat(@as(f32, @floatFromInt(b)) * af), + .a = a, + }; +} + +/// Контекст для заполнения фигур цветом. Границы хранятся в set — по x и y можно добавлять произвольные точки. +const FillCanvas = struct { + /// Множество пикселей границы (x, y) — без ограничения по размеру буфера. + border_set: std.AutoHashMap(Point2_i, void), + buf_width: u32, + buf_height: u32, + + pub fn init(allocator: std.mem.Allocator, width: u32, height: u32) !FillCanvas { + const border_set = std.AutoHashMap(Point2_i, void).init(allocator); + return .{ + .border_set = border_set, + .buf_width = width, + .buf_height = height, + }; + } + + pub fn deinit(self: *FillCanvas) void { + self.border_set.deinit(); + } + + /// Добавляет точку границы; координаты x, y могут быть любыми (условно бесконечное поле). + pub fn setBorder(self: *FillCanvas, x: i32, y: i32) void { + self.border_set.put(.{ .x = x, .y = y }, {}) catch {}; + } + + /// Заливка четырёхсвязным стековым алгоритмом от первой найденной внутренней точки. + pub fn fillColor(self: *FillCanvas, draw_ctx: *DrawContext, allocator: std.mem.Allocator, color: Color.PMA) void { + const n = self.border_set.count(); + if (n == 0) return; + + const buf_w_i: i32 = @intCast(self.buf_width); + const buf_h_i: i32 = @intCast(self.buf_height); + + // Ключи один раз по (y, x) — по строкам x уже будут отсортированы. + var keys_buf = std.ArrayList(Point2_i).empty; + defer keys_buf.deinit(allocator); + keys_buf.ensureTotalCapacity(allocator, n) catch return; + var iter = self.border_set.keyIterator(); + while (iter.next()) |k| { + keys_buf.appendAssumeCapacity(k.*); + } + std.mem.sort(Point2_i, keys_buf.items, {}, struct { + fn lessThan(_: void, a: Point2_i, b: Point2_i) bool { + if (a.y != b.y) return a.y < b.y; + return a.x < b.x; + } + }.lessThan); + + // Семена: по строкам находим сегменты (пары x), пересекаем с окном буфера, берём середину сегмента. + var seeds = findFillSeeds(keys_buf.items, buf_w_i, buf_h_i, allocator) catch return; + defer seeds.deinit(allocator); + + var stack = std.ArrayList(Point2_i).empty; + defer stack.deinit(allocator); + var filled = std.AutoHashMap(Point2_i, void).init(allocator); + defer filled.deinit(); + + for (seeds.items) |s| { + if (self.border_set.contains(s)) continue; + if (filled.contains(s)) continue; + stack.clearRetainingCapacity(); + stack.append(allocator, s) catch return; + while (stack.pop()) |cell| { + if (self.border_set.contains(cell)) continue; + const gop = filled.getOrPut(cell) catch return; + if (gop.found_existing) continue; + + if (cell.x >= 0 and cell.x < buf_w_i and cell.y >= 0 and cell.y < buf_h_i) { + draw_ctx.blendPixelAtBuffer(cell.x, cell.y, color); + } + if (cell.x > 0) stack.append(allocator, .{ .x = cell.x - 1, .y = cell.y }) catch return; + if (cell.x < buf_w_i - 1) stack.append(allocator, .{ .x = cell.x + 1, .y = cell.y }) catch return; + if (cell.y > 0) stack.append(allocator, .{ .x = cell.x, .y = cell.y - 1 }) catch return; + if (cell.y < buf_h_i - 1) stack.append(allocator, .{ .x = cell.x, .y = cell.y + 1 }) catch return; + } + } + } + + /// По строкам: рёбра (подряд идущие x) → сегменты между ними. Семена — середины чётных сегментов (при чётном числе границ). + fn findFillSeeds( + keys: []const Point2_i, + buf_w_i: i32, + buf_h_i: i32, + allocator: std.mem.Allocator, + ) !std.ArrayList(Point2_i) { + var list = std.ArrayList(Point2_i).empty; + errdefer list.deinit(allocator); + var segments = std.ArrayList(struct { left: i32, right: i32 }).empty; + defer segments.deinit(allocator); + + var i: usize = 0; + while (i < keys.len) { + const y = keys[i].y; + const row_start = i; + while (i < keys.len and keys[i].y == y) : (i += 1) {} + const row = keys[row_start..i]; + if (row.len < 2 or y < 0 or y >= buf_h_i) continue; + + segments.clearRetainingCapacity(); + var run_end_x: i32 = row[0].x; + for (row[1..]) |p| { + if (p.x != run_end_x + 1) { + try segments.append(allocator, .{ .left = run_end_x + 1, .right = p.x - 1 }); + run_end_x = p.x; + } else { + run_end_x = p.x; + } + } + // Семена только при чётном числе границ + if ((segments.items.len + 1) % 2 != 0) continue; + + for (segments.items, 0..) |seg, gi| { + if (gi % 2 != 0 or seg.left > seg.right) continue; + const left = @max(seg.left, 0); + const right = @min(seg.right, buf_w_i - 1); + if (left <= right) { + try list.append(allocator, .{ .x = left + @divTrunc(right - left, 2), .y = y }); + } + } + } + return list; + } +}; +``` + +### A.25. `src/tests.zig` + +```zig +// Корень для `zig build test`. Тесты из импортированных здесь модулей выполняются (в Zig не подтягиваются из транзитивных импортов). +// Добавляй сюда _ = @import("path/to/module.zig"); для каждого модуля с test-блоками. +// Чтобы увидеть список всех тестов: после `zig build test` выполни `./zig-out/bin/test`. +test "discover tests" { + _ = @import("main.zig"); + _ = @import("models/Property.zig"); + _ = @import("models/shape/shape.zig"); +} + +// Убедиться, что выполнились все ожидаемые тесты: этот тест пройдёт только если до него дошли (т.е. все предыдущие прошли). +test "all module tests completed" { + const std = @import("std"); + std.debug.print("\n (все тесты модулей выполнены)\n", .{}); +} +``` + +### A.26. `src/toolbar/Tool.zig` + +```zig +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, +}; +``` + +### A.27. `src/toolbar/Toolbar.zig` + +```zig +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; + } +} +``` + +### A.28. `src/toolbar/tools.zig` + +```zig +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, + }, +}; +``` + +### A.29. `src/toolbar/tools/broken.zig` + +```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, .broken) catch return; + defer obj.deinit(canvas.allocator); + try ctx.addObject(obj); +} +pub const tool = Tool.Tool{ .onCanvasClick = onCanvasClick }; +``` + +### A.30. `src/toolbar/tools/ellipse.zig` + +```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, .ellipse) catch return; + defer obj.deinit(canvas.allocator); + try ctx.addObject(obj); +} +pub const tool = Tool.Tool{ .onCanvasClick = onCanvasClick }; +``` + +### A.31. `src/toolbar/tools/line.zig` + +```zig +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 }; +``` + +### A.32. `src/ui/canvas_view.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const dvui_ext = @import("dvui_ext.zig"); +const Canvas = @import("../Canvas.zig"); +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, selected_object_id: ?u64, content_rect_scale: dvui.RectScale) void { + var textured = dvui_ext.texturedBox(content_rect_scale, dvui.Rect.all(20)); + { + var overlay = dvui.overlay(@src(), .{ .expand = .both }); + { + const overlay_parent = dvui.parentGet(); + const init_options: dvui.ScrollAreaWidget.InitOpts = .{ + .scroll_info = &canvas.scroll, + .vertical_bar = .auto, + .horizontal_bar = .auto, + .process_events_after = false, + }; + var scroll = dvui.scrollArea( + @src(), + init_options, + .{ + .expand = .both, + .background = false, + }, + ); + { + drawCanvasContent(canvas, scroll); + handleCanvasZoom(canvas, scroll); + handleCanvasMouse(canvas, scroll, selected_object_id); + } + + 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(); + } + textured.deinit(); +} + +fn drawCanvasContent(canvas: *Canvas, scroll: anytype) void { + const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); + const img_size = canvas.getZoomedImageSize(); + const viewport_rect = scroll.data().contentRect(); + const scroll_current = dvui.Point{ .x = canvas.scroll.viewport.x, .y = canvas.scroll.viewport.y }; + + const viewport_px = dvui.Rect{ + .x = viewport_rect.x * natural_scale, + .y = viewport_rect.y * natural_scale, + .w = viewport_rect.w * natural_scale, + .h = viewport_rect.h * natural_scale, + }; + const scroll_px = dvui.Point{ + .x = scroll_current.x * natural_scale, + .y = scroll_current.y * natural_scale, + }; + + const changed = canvas.updateVisibleImageRect(viewport_px, scroll_px); + if (changed) + canvas.requestRedraw(); + canvas.processPendingRedraw() catch |err| { + std.debug.print("processPendingRedraw error: {}\n", .{err}); + }; + + const content_w_px: u32 = img_size.x + img_size.w; + const content_h_px: u32 = img_size.y + img_size.h; + const content_w = @as(f32, @floatFromInt(content_w_px)) / natural_scale; + const content_h = @as(f32, @floatFromInt(content_h_px)) / natural_scale; + + var canvas_layer = dvui.overlay( + @src(), + .{ .min_size_content = .{ .w = content_w, .h = content_h }, .background = false }, + ); + { + if (canvas.texture) |tex| { + 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 top = @as(f32, @floatFromInt(img_size.y + vis.y)) / natural_scale; + + _ = dvui.image( + @src(), + .{ .source = .{ .texture = tex } }, + .{ + .background = false, + .expand = .none, + .gravity_x = 0.0, + .gravity_y = 0.0, + .margin = .{ .x = left, .y = top, .w = canvas.pos.x, .h = canvas.pos.y }, + .min_size_content = .{ + .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, + .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, + }, + .max_size_content = .{ + .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, + .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, + }, + }, + ); + } + } + canvas_layer.deinit(); +} + +fn handleCanvasZoom(canvas: *Canvas, scroll: anytype) void { + const ctrl = dvui.currentWindow().modifiers.control(); + if (!ctrl) return; + + const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); + + for (dvui.events()) |*e| { + switch (e.evt) { + .mouse => |*mouse| { + const action = mouse.action; + if (dvui.eventMatchSimple(e, scroll.data()) and (action == .wheel_x or action == .wheel_y)) { + switch (action) { + .wheel_y => |y| { + 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(); + + 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 => {}, + } + e.handled = true; + } + }, + else => {}, + } + } +} + +fn handleCanvasMouse(canvas: *Canvas, scroll: *dvui.ScrollAreaWidget, selected_object_id: ?u64) void { + const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); + const scroll_data = scroll.data(); + + for (dvui.events()) |*e| { + switch (e.evt) { + .mouse => |*mouse| { + if (mouse.action != .press or mouse.button != .left) continue; + if (e.handled) continue; + if (!dvui.eventMatchSimple(e, scroll_data)) continue; + + // Не обрабатывать клик, если он попал в область тулбара (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{ + .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.cursor_document_point = if (canvas.isContentPointOnDocument(content_pt, natural_scale)) doc_pt else null; + 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 => {}, + } + } +} + +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 = °rees }, .{ .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); +} +``` + +### A.33. `src/ui/dvui_ext.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const TexturedBox = @import("./types/TexturedBox.zig"); + +pub fn texturedBox(rs: dvui.RectScale, corner_radius: dvui.Rect) TexturedBox { + return TexturedBox.init(rs, corner_radius); +} +``` + +### A.34. `src/ui/frame.zig` + +```zig +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); +const menu_bar = @import("menu_bar.zig"); +const tab_bar = @import("tab_bar.zig"); +const left_panel = @import("left_panel.zig"); +const right_panel = @import("right_panel.zig"); + +pub fn guiFrame(ctx: *WindowContext) bool { + for (dvui.events()) |*e| { + if (e.evt == .window and e.evt.window.action == .close) return false; + if (e.evt == .app and e.evt.app.action == .quit) return false; + } + + var root = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ .expand = .both, .background = true, .style = .window }, + ); + { + menu_bar.menuBar(ctx); + tab_bar.tabBar(ctx); + + var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + { + left_panel.leftPanel(ctx); + + right_panel.rightPanel(ctx); + } + content_row.deinit(); + } + root.deinit(); + + return true; +} +``` + +### A.35. `src/ui/left_panel.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); +const Document = @import("../models/Document.zig"); +const icons = @import("../icons.zig"); +const Object = Document.Object; + +const panel_gap: f32 = 12; +const panel_padding: f32 = 5; +const panel_radius: f32 = 24; +const fill_color = dvui.Color.black.opacity(0.2); + +const ObjectTreeCallback = union(enum) { + select: u64, + delete: u64, +}; + +fn shapeLabel(shape: Object.ShapeKind) []const u8 { + return switch (shape) { + .line => "Line", + .ellipse => "Ellipse", + .broken => "Broken line", + }; +} + +fn objectTreeRow(open_doc: *WindowContext.OpenDocument, obj: *Object, depth: u32, object_callback: *?ObjectTreeCallback) void { + 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( + @src(), + .{ .dir = .horizontal }, + .{ + .id_extra = row_id, + .expand = .horizontal, + }, + ); + { + 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(); + for (obj.children.items) |*child| { + objectTreeRow(open_doc, child, depth + 1, object_callback); + } +} + +fn objectTree(ctx: *WindowContext) void { + const active_doc = ctx.activeDocument(); + var object_callback: ?ObjectTreeCallback = null; + if (active_doc) |open_doc| { + const doc = &open_doc.document; + if (doc.objects.items.len == 0) { + dvui.label(@src(), "No objects", .{}, .{}); + } else { + for (doc.objects.items) |*obj| { + 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 { + dvui.label(@src(), "No document", .{}, .{}); + } +} + +pub fn leftPanel(ctx: *WindowContext) void { + var padding = dvui.Rect.all(panel_gap); + padding.w = 0; + var panel = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ + .expand = .vertical, + // Фиксированная ширина левой панели + .min_size_content = .{ .w = 220 }, + .max_size_content = .{ .h = undefined, .w = 220 }, + .background = true, + .padding = padding, + }, + ); + { + + // Нижняя часть: настройки + var settings_section = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ + .expand = .horizontal, + .gravity_y = 1.0, + .margin = .{ .y = 5 }, + .padding = dvui.Rect.all(panel_padding), + .corner_radius = dvui.Rect.all(panel_radius), + .color_fill = fill_color, + .background = true, + }, + ); + { + dvui.label(@src(), "Settings", .{}, .{}); + + const active_doc = ctx.activeDocument(); + if (active_doc) |doc| { + const canvas = &doc.canvas; + 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 (doc.cpu_render.type == .Gradient) { + doc.cpu_render.type = .Squares; + } else { + doc.cpu_render.type = .Gradient; + } + canvas.requestRedraw(); + } + } + if (dvui.button(@src(), "Add random shapes", .{}, .{})) { + canvas.addRandomShapes() catch {}; + } + if (dvui.button(@src(), "Request redraw", .{}, .{})) { + canvas.requestRedraw(); + } + } else { + dvui.label(@src(), "No document", .{}, .{}); + } + } + 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(); +} +``` + +### A.36. `src/ui/menu_bar.zig` + +```zig +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; + }; +} +``` + +### A.37. `src/ui/right_panel.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); +const canvas_view = @import("canvas_view.zig"); + +pub fn rightPanel(ctx: *WindowContext) void { + const fill_color = dvui.Color.black.opacity(0.25); + var back = dvui.box( + @src(), + .{ .dir = .horizontal }, + .{ .expand = .both, .padding = dvui.Rect.all(12), .background = true }, + ); + { + var panel = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ + .expand = .both, + .background = true, + .padding = dvui.Rect.all(5), + .corner_radius = dvui.Rect.all(24), + .color_fill = fill_color, + }, + ); + { + const active_doc = ctx.activeDocument(); + if (active_doc) |doc| { + const content_rect_scale = panel.data().contentRectScale(); + canvas_view.canvasView(&doc.canvas, doc.selected_object_id, content_rect_scale); + } else { + noDocView(ctx); + } + } + panel.deinit(); + } + back.deinit(); +} + +fn noDocView(ctx: *WindowContext) void { + var center = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ + .expand = .both, + .padding = dvui.Rect.all(20), + }, + ); + { + var box = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ + .gravity_x = 0.5, + .gravity_y = 0.5, + }, + ); + { + dvui.label(@src(), "No document open", .{}, .{}); + if (dvui.button(@src(), "New document", .{}, .{})) { + ctx.addNewDocument() catch |err| { + std.debug.print("addNewDocument error: {}\n", .{err}); + }; + } + } + box.deinit(); + } + center.deinit(); +} +``` + +### A.38. `src/ui/tab_bar.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("../icons.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 { + var bar = dvui.box( + @src(), + .{ .dir = .horizontal }, + .{ + .expand = .horizontal, + .background = true, + .padding = .{ + .x = 12, + .w = 12, + }, + }, + ); + { + var callback: ?DocCallback = null; + for (ctx.documents.items, 0..) |_, i| { + documentTab(ctx, i, &callback); + } + if (callback) |action| { + switch (action) { + .select => |index| ctx.setActiveDocument(index), + .close => |index| ctx.closeDocument(index), + } + } + + if (dvui.buttonIcon(@src(), "Create", icons.plus, .{}, .{}, .{ + .gravity_y = 0, + })) { + ctx.addNewDocument() catch |err| { + std.debug.print("addNewDocument error: {}\n", .{err}); + }; + } + } + bar.deinit(); +} +``` + +### A.39. `src/ui/types/TexturedBox.zig` + +```zig +const std = @import("std"); +const dvui = @import("dvui"); +const TexturedBox = @This(); + +parent: dvui.Widget, +rs: dvui.RectScale, +pic: ?dvui.Picture, +corner_radius: dvui.Rect, + +pub fn init(rs: dvui.RectScale, corner_radius: dvui.Rect) TexturedBox { + const parent = dvui.parentGet(); + const pic = dvui.Picture.start(rs.r); + return .{ + .parent = parent, + .corner_radius = corner_radius, + .rs = rs, + .pic = pic, + }; +} + +pub fn deinit(self: *TexturedBox) void { + if (self.pic) |*picture| { + picture.stop(); + + const tex = dvui.textureFromTarget(picture.texture) catch null; + if (tex) |t| { + dvui.Texture.destroyLater(t); + dvui.renderTexture(t, self.rs, .{ + .corner_radius = self.corner_radius, + }) catch {}; + } + } +} +``` diff --git a/Report/zivro-open-project-report.md b/Report/zivro-open-project-report.md new file mode 100644 index 0000000..38b61da --- /dev/null +++ b/Report/zivro-open-project-report.md @@ -0,0 +1,427 @@ +# Лабораторная работа 3 +## Анализ и описание открытого проекта `Zivro` + +## 1. Цель работы + +Изучить архитектуру и ключевые алгоритмы открытого графического проекта, реализованного на языке Zig, и подготовить технический отчёт по его устройству, процессу сборки и принципам работы основных подсистем. + +## 2. Постановка задачи + +В рамках лабораторной работы требуется: + +- выбрать открытый проект и изучить его структуру; +- выделить основные модули и описать их взаимодействие; +- разобрать математические алгоритмы, используемые в проекте; +- описать процесс сборки, запуска и тестирования; +- подготовить отчёт в формате Markdown; +- сформировать приложение с исходным кодом автоматически, без ручной вставки кода в текст отчёта. + +## 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-рендере. +Диаграммы PlantUML будут добавлены отдельным шагом, после фиксации текстовой части. + +### 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} +$$ + +При композиции `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} +$$ + +Практический смысл: shape можно тестировать аналитически (по формулам) в "своей" удобной локальной системе, независимо от того, как он повернут и где расположен в документе. + +### 6.3. Рисование линии: отсечение + дискретизация + толщина + +Алгоритм в `line.zig` состоит из трёх частей. + +#### 6.3.1. Отсечение Liang-Barsky + +Перед рисованием линия отсекается расширенным прямоугольником буфера. +Параметрическая форма отрезка: + +$$ +P(t)=P_0+t(P_1-P_0),\quad t\in[0,1]. +$$ + +Для каждого ограничения (`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. +$$ + +- `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. +$$ + +Это даёт геометрически корректную полосу вокруг эллипса при произвольном повороте/масштабе объекта. + +#### 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` уже 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-рендер с преобразованиями координат и отдельные алгоритмы растеризации геометрии. + +Ключевые математические части - композиция трансформаций, отсечение и растеризация линий, рендер эллипсов/дуг, а также алгоритм заливки замкнутых контуров. +Текстовый отчёт подготовлен без встроенных листингов кода; для генерации версии с приложением исходников создан отдельный автоматизированный скрипт.