facett-graphview 0.1.8

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 L0 kernel adoption** (CONS-CORE Phase C, step 1) — route a graph's nodes
//! and straight edges through the shared [`facett_core::render`] `Canvas` seam
//! instead of graphview's bespoke `vello_cpu` scene.
//!
//! This is the **opt-in alternative** path. graphview's [`crate::cpu::render`] stays
//! the default + the pixel reference; this module lowers the same [`GraphModel`] /
//! [`Decorations`] into the L0 SDF instance vocabulary
//! ([`QuadInstance`](facett_core::render::QuadInstance) for node chips,
//! [`LineInstance`](facett_core::render::LineInstance) for straight edges) and hands
//! them to a core [`Renderer`](facett_core::render::Renderer) — the **same** kernel
//! the map skins draw through. The CPU lane ([`facett_core::render::CpuRenderer`])
//! is always available; with the `gpu` feature the L0 SDF wgpu pipeline can back the
//! same instances.
//!
//! Geometry note: the reference path draws **rounded-rect** chips + **cubic** edges.
//! The L0 vocabulary is SDF circles/markers + straight capsules, so this path is a
//! deliberately different (chip = rounded-square marker, edge = straight line)
//! lowering — it is **not** byte-parity with `cpu::render` and so does **not** feed
//! any golden. It exists to prove a graph routes through the core `Canvas` and
//! renders non-blank; the curved-edge / rounded-rect parity is a follow-up (the L0
//! kernel would need a rounded-rect-with-border SDF + a cubic tessellation to lines).

use facett_core::render::{
    Camera as CoreCamera, CircleInstance, CpuRenderer, Frame, LineInstance, MarkerInstance,
    QuadInstance, Renderer, prim::shape,
};

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

/// Straight `[r,g,b,a]` in `[0,1]` from a graphview [`Color`] — exactly what the L0
/// SDF instances + `color32_to_f32` expect.
#[inline]
fn col(c: Color) -> [f32; 4] {
    [c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0, c.a as f32 / 255.0]
}

/// Lower `model` (decorated, under `camera`) into the L0 SDF instance batches:
/// node chips → rounded-square [`MarkerInstance`]s (+ a ring [`CircleInstance`]
/// where a decoration ring is set), straight edges → [`LineInstance`]s. Returns
/// `(quads, lines)` in the core's screen-pixel contract.
///
/// Exposed (not just used by [`render_to_rgba_l0`]) so the adoption test can assert
/// on the lowered instances directly — the inject-assert oracle that "a graph
/// routes through the core vocabulary".
pub fn lower(
    model: &GraphModel,
    decorations: &Decorations,
    camera: &Camera,
) -> (Vec<QuadInstance>, Vec<LineInstance>) {
    let mut quads: Vec<QuadInstance> = Vec::with_capacity(model.nodes.len() * 2);
    let mut lines: Vec<LineInstance> = Vec::with_capacity(model.edges.len() + decorations.edges.len());

    // id -> screen centre, for edge endpoints (mirrors cpu::render's index).
    let idx: std::collections::HashMap<&str, (f32, f32)> =
        model.nodes.iter().map(|n| (n.id.as_str(), camera.project(n.pos))).collect();

    let half = BOX_W * 0.5 * camera.zoom;

    // Edges first (chips draw over), as straight capsules right-edge → left-edge.
    let mut push_edge = |e: &GraphEdge, w: f32| {
        if let (Some(&(ax, ay)), Some(&(bx, by))) =
            (idx.get(e.from.as_str()), idx.get(e.to.as_str()))
        {
            let a = [ax + half, ay];
            let b = [bx - half, by];
            lines.push(LineInstance::round(a, b, w * 0.5, 1.0, col(e.color)));
        }
    };
    for e in &model.edges {
        push_edge(e, (1.4 * camera.zoom).clamp(0.7, 3.0));
    }
    for e in &decorations.edges {
        push_edge(e, (2.6 * camera.zoom).clamp(0.7, 4.0));
    }

    // Node chips: a rounded-square marker sized to the chip's half-extent, scaled by
    // the caller's optional per-node `scale` decoration (domain-agnostic node sizing
    // — population, degree, …), plus a ring overlay where a decoration ring is set.
    let base_r = (BOX_H * 0.5 * camera.zoom).max(2.0);
    for n in &model.nodes {
        let (cx, cy) = camera.project(n.pos);
        let deco = decorations.nodes.get(&n.id);
        let scale = deco.and_then(|d| d.scale).unwrap_or(1.0).max(0.05);
        let r = (base_r * scale).max(2.0);
        let corner = (5.0 * camera.zoom).clamp(0.0, r);
        quads.push(
            MarkerInstance {
                center: [cx, cy],
                radius: r,
                corner,
                color: col(n.fill),
                aa: 1.0,
                shape: shape::SQUARE,
            }
            .lower(),
        );
        if let Some(ring) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
            // A thin status ring just outside the chip.
            quads.push(
                CircleInstance { center: [cx, cy], radius: r + 2.0, color: col(ring), aa: 1.2 }
                    .lower(),
            );
        }
    }

    (quads, lines)
}

/// Render `model` (decorated, under `camera`) to a straight-RGBA8 [`Frame`] through
/// the shared L0 [`Canvas`](facett_core::render::Canvas) — the CONS-CORE adoption
/// path. `backend` selects the core renderer; today both arms use the CPU
/// [`CpuRenderer`] (the GPU arm shares the same lowered instances — the live wgpu
/// readback is the follow-up, exactly as graphview's reference GPU arm).
pub fn render_to_rgba_l0(
    backend: Backend,
    model: &GraphModel,
    decorations: &Decorations,
    camera: &Camera,
    width: u32,
    height: u32,
    background: Color,
) -> Frame {
    let (quads, lines) = lower(model, decorations, camera);
    // The core camera is the seam type; the graph drives it in 2D pan/zoom mode.
    let core_cam = CoreCamera {
        pan_x: camera.pan_x,
        pan_y: camera.pan_y,
        zoom: camera.zoom,
        ..CoreCamera::default()
    };
    // ── L0 render-lane emit: this CONS-CORE adoption path RAN ─────────────────
    // Distinct from the legacy `cpu::render` lane and from the core renderer's own
    // `cpu_render` row — proves the L0 `Canvas` route was exercised + lowered real
    // instances (quads per node, lines per edge) for the chosen backend.
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-graphview::render_to_rgba_l0",
        "l0_render",
        !quads.is_empty() || !lines.is_empty() || (model.nodes.is_empty() && model.edges.is_empty()),
        &format!("backend={backend:?} quads={} lines={} nodes={} edges={}", quads.len(), lines.len(), model.nodes.len(), model.edges.len()),
    );
    let _ = backend; // both arms share the lowered instances; CPU raster is the readback.
    let mut r = CpuRenderer::new([background.r, background.g, background.b, background.a]);
    let canvas = r.begin(width, height, core_cam);
    canvas.push_lines(&lines);
    canvas.push_quads(&quads);
    r.present()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::{GpuProbe, decide};
    use crate::model::{GraphEdge, GraphNode, Pos};

    fn sample() -> GraphModel {
        GraphModel {
            nodes: vec![
                GraphNode { id: "a".into(), label: "a".into(), fill: Color::rgb(60, 90, 160), stroke: Color::WHITE, pos: Pos::new(0.0, 0.0) },
                GraphNode { id: "b".into(), label: "b".into(), fill: Color::rgb(60, 140, 90), stroke: Color::WHITE, pos: Pos::new(300.0, -80.0) },
                GraphNode { id: "c".into(), label: "c".into(), fill: Color::rgb(160, 90, 60), stroke: Color::WHITE, pos: Pos::new(300.0, 80.0) },
            ],
            edges: vec![
                GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(200, 200, 220), dashed: false, label: None },
                GraphEdge { from: "a".into(), to: "c".into(), color: Color::rgb(200, 200, 220), dashed: true, label: None },
            ],
        }
    }

    /// INJECT-ASSERT: the model lowers into the L0 SDF vocabulary — one quad per
    /// node (rounded-square markers), one line per edge — carrying the real fill
    /// colours and screen positions. Proves graphview routes through the core
    /// primitive vocabulary, not just "didn't crash".
    #[test]
    fn model_lowers_into_core_sdf_instances() {
        let model = sample();
        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
        let (quads, lines) = lower(&model, &Decorations::default(), &cam);
        assert_eq!(quads.len(), 3, "one rounded-square marker per node");
        assert_eq!(lines.len(), 2, "one capsule per straight edge");
        // The first node's fill flows through to its quad colour.
        assert_eq!(quads[0].shape, shape::SQUARE);
        let want = col(model.nodes[0].fill);
        assert!((quads[0].color[2] - want[2]).abs() < 1e-6, "node fill colour carried into the L0 quad");
        // Every instance sits inside the viewport (camera projected to screen px).
        for q in &quads {
            assert!(q.center[0] >= 0.0 && q.center[0] <= 640.0 && q.center[1] >= 0.0 && q.center[1] <= 480.0);
        }
    }

    /// INJECT-ASSERT: the L0 adoption path renders a **non-blank** frame through the
    /// shared core `Canvas` (the same oracle as the reference
    /// `cpu_render_produces_nonblank_frame`, applied to the L0 lane).
    #[test]
    fn l0_path_routes_through_core_canvas_and_renders_nonblank() {
        let model = sample();
        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
        let backend = decide(GpuProbe::cpu_only());
        assert_eq!(backend, Backend::CpuVello);
        let frame: Frame = render_to_rgba_l0(
            backend,
            &model,
            &Decorations::default(),
            &cam,
            640,
            480,
            Color::rgb(18, 20, 28),
        );
        assert_eq!(frame.rgba.len(), 640 * 480 * 4);
        assert!(frame.lit_px() > 0, "L0 lane drew lit pixels");
        // More than the background colour present (chips + edges over bg).
        let mut seen = std::collections::HashSet::new();
        for px in frame.rgba.chunks_exact(4) {
            seen.insert([px[0], px[1], px[2]]);
            if seen.len() > 3 {
                break;
            }
        }
        assert!(seen.len() > 3, "L0 Canvas drew a real graph, not a flat pane");
    }

    /// INJECT-ASSERT (node-sizing engine extension): a per-node `scale` decoration
    /// makes that node's L0 marker quad bigger — domain-agnostic node sizing the
    /// caller drives (population, degree, …) without the engine learning the metric.
    #[test]
    fn node_scale_decoration_enlarges_the_marker() {
        use crate::model::NodeDecoration;
        let model = sample();
        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);

        // Baseline: every marker is the default size.
        let (base_quads, _) = lower(&model, &Decorations::default(), &cam);
        let base_r = base_quads[0].radius;

        // Scale node "a" up 3×; its marker radius must grow ~3×, the others unchanged.
        let mut deco = Decorations::default();
        deco.nodes.insert("a".into(), NodeDecoration { scale: Some(3.0), ..Default::default() });
        let (quads, _) = lower(&model, &deco, &cam);
        // Quad order mirrors node order (a, b, c) since no rings were added.
        assert!((quads[0].radius - base_r * 3.0).abs() < 0.5, "node 'a' marker scaled 3× ({} vs {})", quads[0].radius, base_r * 3.0);
        assert!((quads[1].radius - base_r).abs() < 0.5, "node 'b' marker unchanged at default size");
    }
}