Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-graphview/src/scene.rs

//! The **shared, backend-neutral scene builder** — the single geometry source both
//! rasterizer lanes paint from, so the CPU path ([`crate::cpu`], `vello_cpu`) and the
//! live GPU path ([`crate::gpu`], `vello` on wgpu) draw *the same* chips, edges,
//! arrowheads, dashes and rings. TODO(#17) mandated "share the geometry builder …
//! do not fork geometry": this module is that shared core.
//!
//! Geometry is expressed in **screen pixels as plain `f64`** (a small display list of
//! [`Prim`]s) so it carries no `kurbo`/`peniko`/`vello` type dependency — each backend
//! lowers a [`Prim`] into its own paint API. Draw order matches the historical
//! `cpu::render`: base edges, then emphasis (decoration) edges, then node chips (fill,
//! then an optional status ring). The page background is NOT a `Prim`: each backend
//! paints it its own way (`vello_cpu` fills a rect; `vello` uses `RenderParams.base_color`),
//! but with the same colour, so the composited frames match.

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

/// One path segment in screen space (`f64` px). Mirrors the `kurbo::PathEl` cases the
/// two backends actually use (move / line / cubic), kept crate-local so the display
/// list stays dependency-free.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Seg {
    MoveTo(f64, f64),
    LineTo(f64, f64),
    /// Cubic Bézier: two control points then the endpoint.
    CurveTo(f64, f64, f64, f64, f64, f64),
}

/// One backend-neutral paint primitive, in screen pixels. The backends lower these
/// identically, so the only pixel difference between the two lanes is the rasterizer's
/// own anti-aliasing (vello_cpu analytic AA vs vello MSAA), never the geometry.
#[derive(Clone, Debug, PartialEq)]
pub enum Prim {
    /// A filled rounded rectangle (a node chip). Corners `(x0,y0)`–`(x1,y1)`, corner
    /// `radius`, solid `color`.
    FillRoundRect { x0: f64, y0: f64, x1: f64, y1: f64, radius: f64, color: Color },
    /// A stroked rounded rectangle (a node status ring). Round joins.
    StrokeRoundRect { x0: f64, y0: f64, x1: f64, y1: f64, radius: f64, width: f64, color: Color },
    /// A stroked open path (an edge cubic, an arrowhead, or dash spans). Round caps +
    /// round joins, `width` px, solid `color`.
    StrokePath { segs: Vec<Seg>, width: f64, color: Color },
}

/// Build the backend-neutral display list for `model` (decorated, under `camera`).
/// The single geometry builder both [`crate::cpu`] and [`crate::gpu`] consume.
pub fn build(model: &GraphModel, decorations: &Decorations, camera: &Camera) -> Vec<Prim> {
    let mut prims = Vec::with_capacity(model.edges.len() * 2 + model.nodes.len() * 2);

    // id -> centre (screen px), for edge endpoints (matches cpu::render's `idx`).
    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). Base edges at width 1.4, then emphasis edges a
    // touch thicker at 2.6 — the exact widths the CPU reference used.
    for e in &model.edges {
        push_edge(&mut prims, &idx, e, camera.zoom, 1.4);
    }
    for e in &decorations.edges {
        push_edge(&mut prims, &idx, e, camera.zoom, 2.6);
    }

    // Node chips: fill, then ONLY-if-status a single ring (mirrors cpu::render — no
    // kind-stroke ring on plain nodes).
    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);
        prims.push(Prim::FillRoundRect {
            x0: cx - hw,
            y0: cy - hh,
            x1: cx + hw,
            y1: cy + hh,
            radius,
            color: n.fill,
        });
        if let Some(rc) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
            prims.push(Prim::StrokeRoundRect {
                x0: cx - hw,
                y0: cy - hh,
                x1: cx + hw,
                y1: cy + hh,
                radius,
                width: (2.0 * camera.zoom) as f64,
                color: rc,
            });
        }
    }

    prims
}

/// Push one PCB-style cubic edge (with a mid break + a small arrowhead) — the exact
/// geometry `cpu::draw_edge_styled` painted. Dashing is the same 20-sample alternate-span
/// approximation. `base_w` is 1.4 for base edges, 2.6 for emphasis edges.
fn push_edge(
    prims: &mut Vec<Prim>,
    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 (ax, ay) = (ax0 + half, ay);
    let (bx, by) = (bx0 - half, by);
    let midx = (ax + bx) * 0.5;

    let w = (base_w * zoom.clamp(0.5, 2.0)) as f64;

    if e.dashed {
        // Cheap dash: sample the cubic at 21 points and stroke alternate spans.
        let mut segs = Vec::new();
        let mut on = true;
        let mut last = (ax, ay);
        for i in 0..=20 {
            let t = i as f64 / 20.0;
            let p = cubic_point((ax, ay), (midx, ay), (midx, by), (bx, by), t);
            if on {
                segs.push(Seg::MoveTo(last.0, last.1));
                segs.push(Seg::LineTo(p.0, p.1));
            }
            last = p;
            on = !on;
        }
        prims.push(Prim::StrokePath { segs, width: w, color: e.color });
    } else {
        prims.push(Prim::StrokePath {
            segs: vec![Seg::MoveTo(ax, ay), Seg::CurveTo(midx, ay, midx, by, bx, by)],
            width: w,
            color: e.color,
        });
    }

    // Arrowhead at the callee, along the cubic's (horizontal) exit tangent. Same width +
    // colour as the edge stroke, exactly as the CPU reference reused its stroke state.
    let dir = {
        let (dx, dy) = (bx - 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;
    prims.push(Prim::StrokePath {
        segs: vec![
            Seg::MoveTo(bx, by),
            Seg::LineTo(bx - dir.0 * head + perp.0 * head * 0.5, by - dir.1 * head + perp.1 * head * 0.5),
            Seg::MoveTo(bx, by),
            Seg::LineTo(bx - dir.0 * head - perp.0 * head * 0.5, by - dir.1 * head - perp.1 * head * 0.5),
        ],
        width: w,
        color: e.color,
    });
}

/// A point on a cubic Bézier at parameter `t` (`(x,y)` px).
fn cubic_point(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
    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;
    (
        a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
        a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
    )
}