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};
#[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]
}
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());
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;
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));
}
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) {
quads.push(
CircleInstance { center: [cx, cy], radius: r + 2.0, color: col(ring), aa: 1.2 }
.lower(),
);
}
}
(quads, lines)
}
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);
let core_cam = CoreCamera {
pan_x: camera.pan_x,
pan_y: camera.pan_y,
zoom: camera.zoom,
..CoreCamera::default()
};
#[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; 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 },
],
}
}
#[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");
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");
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);
}
}
#[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");
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");
}
#[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);
let (base_quads, _) = lower(&model, &Decorations::default(), &cam);
let base_r = base_quads[0].radius;
let mut deco = Decorations::default();
deco.nodes.insert("a".into(), NodeDecoration { scale: Some(3.0), ..Default::default() });
let (quads, _) = lower(&model, &deco, &cam);
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");
}
}