facett-graphview/src/l0.rs
//! **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, CpuRenderer, Frame, LineInstance, MarkerInstance, QuadInstance,
Renderer, RingInstance, 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) {
// ONE clean status ring, only for a node that HAS a status — a thin
// ANNULUS hugging the chip, NOT a filled disc over it (the old
// `CircleInstance` at r+2 painted a solid circle that buried the chip —
// the "double ring / covered node" ugliness). Plain nodes get no ring.
quads.push(
RingInstance { center: [cx, cy], radius: r + 2.0, inner: r + 0.4, 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::VelloCpu);
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.as_chunks::<4>().0 {
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");
}
}