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,
)
}