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
36/// The pure-algorithm **graph analysis** toolbox (centrality / community /
37/// pathfinding / cycles / components / k-core / similarity) — the compute core of the
38/// graph-DB IDE. Index-based `(n, edges)`; no egui, no GPU.
39pub mod analysis;
40pub mod backend;
41pub mod clip;
42pub mod community;
43pub mod cpu;
44pub mod decorated_view;
45pub mod depgraph_layout;
46pub mod fdeb;
47pub mod gpu_layout;
48pub mod l0;
49pub mod metro;
50pub mod model;
51
52#[cfg(feature = "gpu")]
53pub mod gpu;
54
55pub use backend::{Backend, GpuProbe, decide, probe_from_env};
56pub use clip::{GraphClipReport, WidgetRect, pick, render_graph_clipped};
57pub use cpu::{Rendered, render as render_cpu};
58pub use decorated_view::{DecoratedGraphView, downstream_of};
59pub use depgraph_layout::{DepEdge, DepGraphLayout, EdgeClass, LaidEdge, LaidNode};
60// The elite GPU-compute layout engine (#1): CSR streamed from Arrow → force-directed
61// positions that iterate in VRAM (feature `gpu`), with the deterministic CPU fallback
62// always built. See [`gpu_layout`] + `.nornir/elite-graph-engine.md`.
63pub use gpu_layout::{relax_cpu, step_cpu, GraphCsr, LayoutParams, LayoutState};
64// Force-directed edge bundling (#2): the hairball cure — compatible edges attract
65// into shared "information highways". See [`fdeb`].
66pub use fdeb::{bundle, occupancy, BundleParams, BundledEdge};
67// Topological semantic zoom (#3): Louvain communities collapse into meta-nodes when
68// zoomed out and fracture open (injected-clock easing) when zoomed in. See [`community`].
69pub use community::{
70 fracture_t, fractured_positions, louvain, meta_graph, visible_count, Communities, MetaGraph, MetaNode,
71};
72// CONS-CORE Phase C: the opt-in L0 kernel adoption (routes nodes/edges through the
73// shared `facett_core::render` Canvas). The default render path
74// ([`render_to_rgba`]) is UNCHANGED — this is the alternative L0 lane.
75pub use l0::{render_to_rgba_l0, lower as lower_to_l0};
76pub use metro::{MetroBranch, MetroLine, MetroMap, MetroStation, MetroView, StationKind};
77pub use model::{
78 BOX_H, BOX_W, Camera, Color, Decorations, GraphEdge, GraphModel, GraphNode, NodeDecoration, Pos,
79};
80
81/// Render `model` (decorated, under `camera`) to a `w × h` straight-RGBA8 frame,
82/// dispatching on the runtime-chosen `backend`. The one call a host makes after
83/// [`decide`].
84///
85/// Both [`Backend`] arms currently rasterize through [`cpu::render`]: the
86/// `GpuVello` arm builds its vello GPU scene (when the `gpu` feature is on) for
87/// the seam, then falls through to the CPU rasterizer for the readback — the GPU
88/// texture readback is the #17 follow-up. The point of this spike is the
89/// architecture + the proven CPU path + the version-aligned GPU seam, not a live
90/// GPU framebuffer on a headless box.
91pub fn render_to_rgba(
92 backend: Backend,
93 model: &GraphModel,
94 decorations: &Decorations,
95 camera: &Camera,
96 width: u32,
97 height: u32,
98 background: Color,
99) -> Rendered {
100 // ── render-lane dispatch emit: record WHICH backend arm this call took ─────
101 // The recurring smear hid in an uninstrumented branch; emit the chosen arm so
102 // the matrix proves both the GpuVello and CpuVello dispatch arms get exercised.
103 #[cfg(feature = "testmatrix")]
104 facett_core::testmatrix::emit(
105 "facett-graphview::render_to_rgba",
106 "render_dispatch",
107 true,
108 &format!("backend={backend:?} nodes={} edges={}", model.nodes.len(), model.edges.len()),
109 );
110 match backend {
111 Backend::GpuVello => {
112 #[cfg(feature = "gpu")]
113 {
114 // Build the GPU scene (proves the vello-0.9 seam compiles); the
115 // texture render+readback is the #17 follow-up, so we hand back
116 // the CPU raster for now to keep one pixel contract.
117 let _scene = gpu::build_scene(model, decorations, camera);
118 }
119 cpu::render(model, decorations, camera, width, height, background)
120 }
121 Backend::CpuVello => cpu::render(model, decorations, camera, width, height, background),
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn sample_graph() -> GraphModel {
130 let nodes = vec![
131 GraphNode {
132 id: "a".into(),
133 label: "alpha".into(),
134 fill: Color::rgb(60, 90, 160),
135 stroke: Color::WHITE,
136 pos: Pos::new(0.0, 0.0),
137 },
138 GraphNode {
139 id: "b".into(),
140 label: "beta".into(),
141 fill: Color::rgb(60, 140, 90),
142 stroke: Color::WHITE,
143 pos: Pos::new(300.0, -80.0),
144 },
145 GraphNode {
146 id: "c".into(),
147 label: "gamma".into(),
148 fill: Color::rgb(160, 90, 60),
149 stroke: Color::WHITE,
150 pos: Pos::new(300.0, 80.0),
151 },
152 ];
153 let edges = vec![
154 GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(200, 200, 220), dashed: false, label: None },
155 GraphEdge { from: "a".into(), to: "c".into(), color: Color::rgb(200, 200, 220), dashed: true, label: None },
156 ];
157 GraphModel { nodes, edges }
158 }
159
160 #[test]
161 fn cpu_render_produces_nonblank_frame() {
162 let model = sample_graph();
163 let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
164 let backend = decide(GpuProbe::cpu_only());
165 assert_eq!(backend, Backend::CpuVello);
166 let frame = render_to_rgba(
167 backend,
168 &model,
169 &Decorations::default(),
170 &cam,
171 640,
172 480,
173 Color::rgb(18, 20, 28),
174 );
175 assert_eq!(frame.rgba.len(), 640 * 480 * 4);
176 // Non-blank: more than one distinct colour drew (chips + edges over bg).
177 let mut seen = std::collections::HashSet::new();
178 for px in frame.rgba.chunks_exact(4) {
179 seen.insert([px[0], px[1], px[2]]);
180 if seen.len() > 3 {
181 break;
182 }
183 }
184 assert!(seen.len() > 3, "vello_cpu drew a real graph, not a flat pane");
185 }
186
187 #[test]
188 fn camera_fit_centers_world() {
189 let model = sample_graph();
190 let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
191 // The world-bounds centre should land near the viewport centre.
192 let (min_x, min_y, max_x, max_y) = model.world_bounds().unwrap();
193 let (wcx, wcy) = ((min_x + max_x) * 0.5, (min_y + max_y) * 0.5);
194 let (sx, sy) = cam.project(Pos::new(wcx, wcy));
195 assert!((sx - 320.0).abs() < 1.0, "x centered, got {sx}");
196 assert!((sy - 240.0).abs() < 1.0, "y centered, got {sy}");
197 }
198}