facett-graphview 0.1.7

facett — a vello-backed, domain-agnostic scalable 2D graph render engine. Runtime-selects vello (GPU/wgpu) when a usable GPU exists, vello_cpu (multithreaded SIMD) as the no-GPU fallback. The eventual home for every graph surface (dep/arch/release dashboards, korp, graph-DB browsing).
Documentation
//! The **CPU rasterizer** (`vello_cpu`) — the reference render path. Builds a
//! vello_cpu scene from a [`GraphModel`] + [`Decorations`] under a [`Camera`] and
//! rasterizes it to a straight-RGBA8 pixel buffer (ready for a PNG or an egui
//! texture). vello_cpu is multithreaded-SIMD software rendering: the proof that
//! "vello" is not GPU-only.
//!
//! Geometry mirrors nornir's `draw_graph`: nodes are rounded rects with a kind
//! fill + a ring stroke; edges are PCB-style cubics (mid-break) with a little
//! arrowhead; decorations add ring colours / emphasis edges.

use vello_cpu::color::{AlphaColor, Srgb};
use vello_cpu::kurbo::{BezPath, Cap, Join, PathEl, Point, RoundedRect, Shape, Stroke};
use vello_cpu::{Pixmap, RenderContext};

use crate::model::{BOX_H, BOX_W, Camera, Color, Decorations, GraphEdge, GraphModel};

/// A rendered frame: straight (un-premultiplied) RGBA8, row-major, `w × h`.
pub struct Rendered {
    pub width: u32,
    pub height: u32,
    /// `width * height * 4` bytes, `[r, g, b, a]` per pixel, NOT premultiplied.
    pub rgba: Vec<u8>,
}

fn to_alpha(c: Color) -> AlphaColor<Srgb> {
    AlphaColor::from_rgba8(c.r, c.g, c.b, c.a)
}

/// Build + rasterize `model` (decorated, under `camera`) into a `w × h` frame on
/// the CPU. The single entry point the `CpuVello` backend drives.
pub fn render(
    model: &GraphModel,
    decorations: &Decorations,
    camera: &Camera,
    width: u32,
    height: u32,
    background: Color,
) -> Rendered {
    let mut ctx = RenderContext::new(width as u16, height as u16);

    // Background.
    ctx.set_paint(to_alpha(background));
    let full = vello_cpu::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64);
    ctx.fill_rect(&full);

    // id -> centre (screen px), for edge endpoints.
    let idx: std::collections::HashMap<&str, (f64, f64)> = model
        .nodes
        .iter()
        .map(|n| {
            let (x, y) = camera.project(n.pos);
            (n.id.as_str(), (x as f64, y as f64))
        })
        .collect();

    // Edges first (chips draw on top).
    for e in &model.edges {
        draw_edge(&mut ctx, &idx, e, camera.zoom);
    }
    for e in &decorations.edges {
        // Emphasis edges: a touch thicker.
        draw_edge_styled(&mut ctx, &idx, e, camera.zoom, 2.6);
    }

    // Node chips.
    let hw = (BOX_W * 0.5 * camera.zoom) as f64;
    let hh = (BOX_H * 0.5 * camera.zoom) as f64;
    let radius = (5.0 * camera.zoom) as f64;
    for n in &model.nodes {
        let (cx, cy) = camera.project(n.pos);
        let (cx, cy) = (cx as f64, cy as f64);
        let rect = RoundedRect::new(cx - hw, cy - hh, cx + hw, cy + hh, radius);

        // Fill.
        ctx.set_paint(to_alpha(n.fill));
        ctx.fill_path(&rect.to_path(0.1));

        // Ring: a decoration ring wins over the kind stroke.
        let deco = decorations.nodes.get(&n.id);
        let (ring_col, ring_w) = match deco.and_then(|d| d.ring) {
            Some(rc) => (rc, 2.4_f32 * camera.zoom),
            None => (n.stroke, 1.4_f32 * camera.zoom),
        };
        ctx.set_stroke(Stroke::new(ring_w as f64).with_join(Join::Round));
        ctx.set_paint(to_alpha(ring_col));
        ctx.stroke_path(&rect.to_path(0.1));
    }

    let mut pixmap = Pixmap::new(width as u16, height as u16);
    ctx.flush();
    ctx.render_to_pixmap(&mut pixmap);

    let rgba: Vec<u8> = pixmap
        .take_unpremultiplied()
        .into_iter()
        .flat_map(|p| [p.r, p.g, p.b, p.a])
        .collect();

    Rendered { width, height, rgba }
}

fn draw_edge(
    ctx: &mut RenderContext,
    idx: &std::collections::HashMap<&str, (f64, f64)>,
    e: &GraphEdge,
    zoom: f32,
) {
    draw_edge_styled(ctx, idx, e, zoom, 1.4);
}

/// One PCB-style cubic from the right edge of `from` to the left edge of `to`,
/// with a mid break + a small arrowhead — the same shape nornir's `draw_edge`
/// paints. (Dashing is approximated by skipping the stroke for `dashed` edges in
/// this spike — see the LOD/edge-styling stub.)
fn draw_edge_styled(
    ctx: &mut RenderContext,
    idx: &std::collections::HashMap<&str, (f64, f64)>,
    e: &GraphEdge,
    zoom: f32,
    base_w: f32,
) {
    let (Some(&(ax0, ay)), Some(&(bx0, by))) = (idx.get(e.from.as_str()), idx.get(e.to.as_str()))
    else {
        return;
    };
    let half = (BOX_W * 0.5 * zoom) as f64;
    let a = Point::new(ax0 + half, ay);
    let b = Point::new(bx0 - half, by);
    let midx = (a.x + b.x) * 0.5;

    let mut path = BezPath::new();
    path.push(PathEl::MoveTo(a));
    path.push(PathEl::CurveTo(Point::new(midx, a.y), Point::new(midx, b.y), b));

    let w = (base_w * zoom.clamp(0.5, 2.0)) as f64;
    ctx.set_stroke(Stroke::new(w).with_caps(Cap::Round).with_join(Join::Round));
    ctx.set_paint(to_alpha(e.color));
    if e.dashed {
        // Cheap dash: sample the cubic and stroke alternate spans (mirrors the
        // egui dashed-cubic). Kept simple for the spike.
        let mut dash = BezPath::new();
        let seg = 0..=20;
        let mut on = true;
        let mut last = a;
        for i in seg {
            let t = i as f64 / 20.0;
            let p = cubic_point(a, Point::new(midx, a.y), Point::new(midx, b.y), b, t);
            if on {
                dash.push(PathEl::MoveTo(last));
                dash.push(PathEl::LineTo(p));
            }
            last = p;
            on = !on;
        }
        ctx.stroke_path(&dash);
    } else {
        ctx.stroke_path(&path);
    }

    // Arrowhead at the callee, along the cubic's exit tangent. The last control
    // point is (midx, b.y), so the curve enters `b` travelling from there → a
    // horizontal approach toward the node (dy = 0).
    let dir = {
        let (dx, dy) = (b.x - midx, 0.0_f64);
        let len = (dx * dx + dy * dy).sqrt().max(1e-3);
        (dx / len, dy / len)
    };
    let perp = (-dir.1, dir.0);
    let head = (6.0 * zoom.clamp(0.6, 1.6)) as f64;
    let mut ah = BezPath::new();
    ah.push(PathEl::MoveTo(b));
    ah.push(PathEl::LineTo(Point::new(
        b.x - dir.0 * head + perp.0 * head * 0.5,
        b.y - dir.1 * head + perp.1 * head * 0.5,
    )));
    ah.push(PathEl::MoveTo(b));
    ah.push(PathEl::LineTo(Point::new(
        b.x - dir.0 * head - perp.0 * head * 0.5,
        b.y - dir.1 * head - perp.1 * head * 0.5,
    )));
    ctx.stroke_path(&ah);
}

/// A point on a cubic Bézier at parameter `t`.
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
    let mt = 1.0 - t;
    let a = mt * mt * mt;
    let b = 3.0 * mt * mt * t;
    let c = 3.0 * mt * t * t;
    let d = t * t * t;
    Point::new(
        a * p0.x + b * p1.x + c * p2.x + d * p3.x,
        a * p0.y + b * p1.y + c * p2.y + d * p3.y,
    )
}