Skip to main content

facett_graphview/
l0.rs

1//! **The L0 kernel adoption** (CONS-CORE Phase C, step 1) — route a graph's nodes
2//! and straight edges through the shared [`facett_core::render`] `Canvas` seam
3//! instead of graphview's bespoke `vello_cpu` scene.
4//!
5//! This is the **opt-in alternative** path. graphview's [`crate::cpu::render`] stays
6//! the default + the pixel reference; this module lowers the same [`GraphModel`] /
7//! [`Decorations`] into the L0 SDF instance vocabulary
8//! ([`QuadInstance`](facett_core::render::QuadInstance) for node chips,
9//! [`LineInstance`](facett_core::render::LineInstance) for straight edges) and hands
10//! them to a core [`Renderer`](facett_core::render::Renderer) — the **same** kernel
11//! the map skins draw through. The CPU lane ([`facett_core::render::CpuRenderer`])
12//! is always available; with the `gpu` feature the L0 SDF wgpu pipeline can back the
13//! same instances.
14//!
15//! Geometry note: the reference path draws **rounded-rect** chips + **cubic** edges.
16//! The L0 vocabulary is SDF circles/markers + straight capsules, so this path is a
17//! deliberately different (chip = rounded-square marker, edge = straight line)
18//! lowering — it is **not** byte-parity with `cpu::render` and so does **not** feed
19//! any golden. It exists to prove a graph routes through the core `Canvas` and
20//! renders non-blank; the curved-edge / rounded-rect parity is a follow-up (the L0
21//! kernel would need a rounded-rect-with-border SDF + a cubic tessellation to lines).
22
23use facett_core::render::{
24    Camera as CoreCamera, CircleInstance, CpuRenderer, Frame, LineInstance, MarkerInstance,
25    QuadInstance, Renderer, prim::shape,
26};
27
28use crate::backend::Backend;
29use crate::model::{BOX_H, BOX_W, Camera, Color, Decorations, GraphEdge, GraphModel};
30
31/// Straight `[r,g,b,a]` in `[0,1]` from a graphview [`Color`] — exactly what the L0
32/// SDF instances + `color32_to_f32` expect.
33#[inline]
34fn col(c: Color) -> [f32; 4] {
35    [c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0, c.a as f32 / 255.0]
36}
37
38/// Lower `model` (decorated, under `camera`) into the L0 SDF instance batches:
39/// node chips → rounded-square [`MarkerInstance`]s (+ a ring [`CircleInstance`]
40/// where a decoration ring is set), straight edges → [`LineInstance`]s. Returns
41/// `(quads, lines)` in the core's screen-pixel contract.
42///
43/// Exposed (not just used by [`render_to_rgba_l0`]) so the adoption test can assert
44/// on the lowered instances directly — the inject-assert oracle that "a graph
45/// routes through the core vocabulary".
46pub fn lower(
47    model: &GraphModel,
48    decorations: &Decorations,
49    camera: &Camera,
50) -> (Vec<QuadInstance>, Vec<LineInstance>) {
51    let mut quads: Vec<QuadInstance> = Vec::with_capacity(model.nodes.len() * 2);
52    let mut lines: Vec<LineInstance> = Vec::with_capacity(model.edges.len() + decorations.edges.len());
53
54    // id -> screen centre, for edge endpoints (mirrors cpu::render's index).
55    let idx: std::collections::HashMap<&str, (f32, f32)> =
56        model.nodes.iter().map(|n| (n.id.as_str(), camera.project(n.pos))).collect();
57
58    let half = BOX_W * 0.5 * camera.zoom;
59
60    // Edges first (chips draw over), as straight capsules right-edge → left-edge.
61    let mut push_edge = |e: &GraphEdge, w: f32| {
62        if let (Some(&(ax, ay)), Some(&(bx, by))) =
63            (idx.get(e.from.as_str()), idx.get(e.to.as_str()))
64        {
65            let a = [ax + half, ay];
66            let b = [bx - half, by];
67            lines.push(LineInstance::round(a, b, w * 0.5, 1.0, col(e.color)));
68        }
69    };
70    for e in &model.edges {
71        push_edge(e, (1.4 * camera.zoom).clamp(0.7, 3.0));
72    }
73    for e in &decorations.edges {
74        push_edge(e, (2.6 * camera.zoom).clamp(0.7, 4.0));
75    }
76
77    // Node chips: a rounded-square marker sized to the chip's half-extent, scaled by
78    // the caller's optional per-node `scale` decoration (domain-agnostic node sizing
79    // — population, degree, …), plus a ring overlay where a decoration ring is set.
80    let base_r = (BOX_H * 0.5 * camera.zoom).max(2.0);
81    for n in &model.nodes {
82        let (cx, cy) = camera.project(n.pos);
83        let deco = decorations.nodes.get(&n.id);
84        let scale = deco.and_then(|d| d.scale).unwrap_or(1.0).max(0.05);
85        let r = (base_r * scale).max(2.0);
86        let corner = (5.0 * camera.zoom).clamp(0.0, r);
87        quads.push(
88            MarkerInstance {
89                center: [cx, cy],
90                radius: r,
91                corner,
92                color: col(n.fill),
93                aa: 1.0,
94                shape: shape::SQUARE,
95            }
96            .lower(),
97        );
98        if let Some(ring) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
99            // A thin status ring just outside the chip.
100            quads.push(
101                CircleInstance { center: [cx, cy], radius: r + 2.0, color: col(ring), aa: 1.2 }
102                    .lower(),
103            );
104        }
105    }
106
107    (quads, lines)
108}
109
110/// Render `model` (decorated, under `camera`) to a straight-RGBA8 [`Frame`] through
111/// the shared L0 [`Canvas`](facett_core::render::Canvas) — the CONS-CORE adoption
112/// path. `backend` selects the core renderer; today both arms use the CPU
113/// [`CpuRenderer`] (the GPU arm shares the same lowered instances — the live wgpu
114/// readback is the follow-up, exactly as graphview's reference GPU arm).
115pub fn render_to_rgba_l0(
116    backend: Backend,
117    model: &GraphModel,
118    decorations: &Decorations,
119    camera: &Camera,
120    width: u32,
121    height: u32,
122    background: Color,
123) -> Frame {
124    let (quads, lines) = lower(model, decorations, camera);
125    // The core camera is the seam type; the graph drives it in 2D pan/zoom mode.
126    let core_cam = CoreCamera {
127        pan_x: camera.pan_x,
128        pan_y: camera.pan_y,
129        zoom: camera.zoom,
130        ..CoreCamera::default()
131    };
132    // ── L0 render-lane emit: this CONS-CORE adoption path RAN ─────────────────
133    // Distinct from the legacy `cpu::render` lane and from the core renderer's own
134    // `cpu_render` row — proves the L0 `Canvas` route was exercised + lowered real
135    // instances (quads per node, lines per edge) for the chosen backend.
136    #[cfg(feature = "testmatrix")]
137    facett_core::testmatrix::emit(
138        "facett-graphview::render_to_rgba_l0",
139        "l0_render",
140        !quads.is_empty() || !lines.is_empty() || (model.nodes.is_empty() && model.edges.is_empty()),
141        &format!("backend={backend:?} quads={} lines={} nodes={} edges={}", quads.len(), lines.len(), model.nodes.len(), model.edges.len()),
142    );
143    let _ = backend; // both arms share the lowered instances; CPU raster is the readback.
144    let mut r = CpuRenderer::new([background.r, background.g, background.b, background.a]);
145    let canvas = r.begin(width, height, core_cam);
146    canvas.push_lines(&lines);
147    canvas.push_quads(&quads);
148    r.present()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::backend::{GpuProbe, decide};
155    use crate::model::{GraphEdge, GraphNode, Pos};
156
157    fn sample() -> GraphModel {
158        GraphModel {
159            nodes: vec![
160                GraphNode { id: "a".into(), label: "a".into(), fill: Color::rgb(60, 90, 160), stroke: Color::WHITE, pos: Pos::new(0.0, 0.0) },
161                GraphNode { id: "b".into(), label: "b".into(), fill: Color::rgb(60, 140, 90), stroke: Color::WHITE, pos: Pos::new(300.0, -80.0) },
162                GraphNode { id: "c".into(), label: "c".into(), fill: Color::rgb(160, 90, 60), stroke: Color::WHITE, pos: Pos::new(300.0, 80.0) },
163            ],
164            edges: vec![
165                GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(200, 200, 220), dashed: false, label: None },
166                GraphEdge { from: "a".into(), to: "c".into(), color: Color::rgb(200, 200, 220), dashed: true, label: None },
167            ],
168        }
169    }
170
171    /// INJECT-ASSERT: the model lowers into the L0 SDF vocabulary — one quad per
172    /// node (rounded-square markers), one line per edge — carrying the real fill
173    /// colours and screen positions. Proves graphview routes through the core
174    /// primitive vocabulary, not just "didn't crash".
175    #[test]
176    fn model_lowers_into_core_sdf_instances() {
177        let model = sample();
178        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
179        let (quads, lines) = lower(&model, &Decorations::default(), &cam);
180        assert_eq!(quads.len(), 3, "one rounded-square marker per node");
181        assert_eq!(lines.len(), 2, "one capsule per straight edge");
182        // The first node's fill flows through to its quad colour.
183        assert_eq!(quads[0].shape, shape::SQUARE);
184        let want = col(model.nodes[0].fill);
185        assert!((quads[0].color[2] - want[2]).abs() < 1e-6, "node fill colour carried into the L0 quad");
186        // Every instance sits inside the viewport (camera projected to screen px).
187        for q in &quads {
188            assert!(q.center[0] >= 0.0 && q.center[0] <= 640.0 && q.center[1] >= 0.0 && q.center[1] <= 480.0);
189        }
190    }
191
192    /// INJECT-ASSERT: the L0 adoption path renders a **non-blank** frame through the
193    /// shared core `Canvas` (the same oracle as the reference
194    /// `cpu_render_produces_nonblank_frame`, applied to the L0 lane).
195    #[test]
196    fn l0_path_routes_through_core_canvas_and_renders_nonblank() {
197        let model = sample();
198        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
199        let backend = decide(GpuProbe::cpu_only());
200        assert_eq!(backend, Backend::CpuVello);
201        let frame: Frame = render_to_rgba_l0(
202            backend,
203            &model,
204            &Decorations::default(),
205            &cam,
206            640,
207            480,
208            Color::rgb(18, 20, 28),
209        );
210        assert_eq!(frame.rgba.len(), 640 * 480 * 4);
211        assert!(frame.lit_px() > 0, "L0 lane drew lit pixels");
212        // More than the background colour present (chips + edges over bg).
213        let mut seen = std::collections::HashSet::new();
214        for px in frame.rgba.chunks_exact(4) {
215            seen.insert([px[0], px[1], px[2]]);
216            if seen.len() > 3 {
217                break;
218            }
219        }
220        assert!(seen.len() > 3, "L0 Canvas drew a real graph, not a flat pane");
221    }
222
223    /// INJECT-ASSERT (node-sizing engine extension): a per-node `scale` decoration
224    /// makes that node's L0 marker quad bigger — domain-agnostic node sizing the
225    /// caller drives (population, degree, …) without the engine learning the metric.
226    #[test]
227    fn node_scale_decoration_enlarges_the_marker() {
228        use crate::model::NodeDecoration;
229        let model = sample();
230        let cam = Camera::fit(&model, 640.0, 480.0, 40.0);
231
232        // Baseline: every marker is the default size.
233        let (base_quads, _) = lower(&model, &Decorations::default(), &cam);
234        let base_r = base_quads[0].radius;
235
236        // Scale node "a" up 3×; its marker radius must grow ~3×, the others unchanged.
237        let mut deco = Decorations::default();
238        deco.nodes.insert("a".into(), NodeDecoration { scale: Some(3.0), ..Default::default() });
239        let (quads, _) = lower(&model, &deco, &cam);
240        // Quad order mirrors node order (a, b, c) since no rings were added.
241        assert!((quads[0].radius - base_r * 3.0).abs() < 0.5, "node 'a' marker scaled 3× ({} vs {})", quads[0].radius, base_r * 3.0);
242        assert!((quads[1].radius - base_r).abs() < 0.5, "node 'b' marker unchanged at default size");
243    }
244}