Skip to main content

facett_graphview/
lib.rs

1//! **facett-graphview** — a domain-agnostic, scalable 2D **graph render engine**,
2//! vello-backed, that **runtime-selects its backend by hardware**: `vello`
3//! (GPU/wgpu compute) when a usable GPU exists, `vello_cpu` (multithreaded SIMD)
4//! as the no-GPU fallback. The eventual single home for every graph surface —
5//! nornir's arch/dep/release dashboards, korp, graph-DB browsing — aiming to
6//! beat Graphviz on interactivity + scale.
7//!
8//! # The shared API (a future drop-in for nornir's `draw_graph`)
9//! The model — [`GraphModel`] (nodes id/label/pos/fill/stroke + edges
10//! from/to/color/dashed/label) and the caller-supplied [`Decorations`] overlay
11//! (per-node ring/badge + emphasis edges) — mirrors nornir's
12//! `src/viz/graph_render.rs` SHARED API exactly, so that egui routine can swap
13//! its painter for this engine without changing its callers. Decorations stay
14//! caller-supplied and domain-agnostic: facett never learns what a "release
15//! gate" is.
16//!
17//! # Backends (vello is NOT GPU-only)
18//! [`Backend`] {`GpuVello`, `CpuVello`}; [`decide`] turns a [`GpuProbe`] into a
19//! pick (probe → choose, mirroring nornir's embedder runtime-select). The CPU
20//! path ([`cpu::render`]) is the proven reference — `vello_cpu` already ships
21//! transitively inside epaint 0.34, so it always builds here. The GPU path is a
22//! real, version-aligned seam (`vello` 0.9 ↔ wgpu 29 ↔ egui-wgpu 0.34), wired
23//! behind the `gpu` cargo feature; see [`gpu`].
24//!
25//! # Render entry point
26//! [`render_to_rgba`] dispatches on the chosen [`Backend`] and returns straight
27//! RGBA8 pixels (PNG / egui-texture ready). Today both backends route through
28//! the CPU rasterizer (the GPU readback path is the #17 follow-up); the dispatch
29//! seam is in place.
30//!
31//! # What is STUBBED (documented, not built — see `.nornir/design.md`)
32//! LOD / viewport-culling, clustering / aggregation, edge-bundling, streaming
33//! layout, and the data-source **adapters** (nornir dep graph / FalkorDB /
34//! pipeline). Clear TODOs live at each seam.
35
36pub mod backend;
37pub mod cpu;
38pub mod model;
39
40#[cfg(feature = "gpu")]
41pub mod gpu;
42
43pub use backend::{Backend, GpuProbe, decide};
44pub use cpu::{Rendered, render as render_cpu};
45pub use model::{
46    BOX_H, BOX_W, Camera, Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos,
47};
48
49/// Render `model` (decorated, under `camera`) to a `w × h` straight-RGBA8 frame,
50/// dispatching on the runtime-chosen `backend`. The one call a host makes after
51/// [`decide`].
52///
53/// Both [`Backend`] arms currently rasterize through [`cpu::render`]: the
54/// `GpuVello` arm builds its vello GPU scene (when the `gpu` feature is on) for
55/// the seam, then falls through to the CPU rasterizer for the readback — the GPU
56/// texture readback is the #17 follow-up. The point of this spike is the
57/// architecture + the proven CPU path + the version-aligned GPU seam, not a live
58/// GPU framebuffer on a headless box.
59pub fn render_to_rgba(
60    backend: Backend,
61    model: &GraphModel,
62    decorations: &Decorations,
63    camera: &Camera,
64    width: u32,
65    height: u32,
66    background: Color,
67) -> Rendered {
68    match backend {
69        Backend::GpuVello => {
70            #[cfg(feature = "gpu")]
71            {
72                // Build the GPU scene (proves the vello-0.9 seam compiles); the
73                // texture render+readback is the #17 follow-up, so we hand back
74                // the CPU raster for now to keep one pixel contract.
75                let _scene = gpu::build_scene(model, decorations, camera);
76            }
77            cpu::render(model, decorations, camera, width, height, background)
78        }
79        Backend::CpuVello => cpu::render(model, decorations, camera, width, height, background),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    fn sample_graph() -> GraphModel {
88        let nodes = vec![
89            GraphNode {
90                id: "a".into(),
91                label: "alpha".into(),
92                fill: Color::rgb(60, 90, 160),
93                stroke: Color::WHITE,
94                pos: Pos::new(0.0, 0.0),
95            },
96            GraphNode {
97                id: "b".into(),
98                label: "beta".into(),
99                fill: Color::rgb(60, 140, 90),
100                stroke: Color::WHITE,
101                pos: Pos::new(300.0, -80.0),
102            },
103            GraphNode {
104                id: "c".into(),
105                label: "gamma".into(),
106                fill: Color::rgb(160, 90, 60),
107                stroke: Color::WHITE,
108                pos: Pos::new(300.0, 80.0),
109            },
110        ];
111        let edges = vec![
112            GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(200, 200, 220), dashed: false, label: None },
113            GraphEdge { from: "a".into(), to: "c".into(), color: Color::rgb(200, 200, 220), dashed: true, label: None },
114        ];
115        GraphModel { nodes, edges }
116    }
117
118    #[test]
119    fn cpu_render_produces_nonblank_frame() {
120        let model = sample_graph();
121        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
122        let backend = decide(GpuProbe::cpu_only());
123        assert_eq!(backend, Backend::CpuVello);
124        let frame = render_to_rgba(
125            backend,
126            &model,
127            &Decorations::default(),
128            &cam,
129            640,
130            480,
131            Color::rgb(18, 20, 28),
132        );
133        assert_eq!(frame.rgba.len(), 640 * 480 * 4);
134        // Non-blank: more than one distinct colour drew (chips + edges over bg).
135        let mut seen = std::collections::HashSet::new();
136        for px in frame.rgba.chunks_exact(4) {
137            seen.insert([px[0], px[1], px[2]]);
138            if seen.len() > 3 {
139                break;
140            }
141        }
142        assert!(seen.len() > 3, "vello_cpu drew a real graph, not a flat pane");
143    }
144
145    #[test]
146    fn camera_fit_centers_world() {
147        let model = sample_graph();
148        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
149        // The world-bounds centre should land near the viewport centre.
150        let (min_x, min_y, max_x, max_y) = model.world_bounds().unwrap();
151        let (wcx, wcy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5);
152        let (sx, sy) = cam.project(Pos::new(wcx, wcy));
153        assert!((sx - 320.0).abs() < 1.0, "x centered, got {sx}");
154        assert!((sy - 240.0).abs() < 1.0, "y centered, got {sy}");
155    }
156}