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

nordisk/facett

facett-demo/src/graphview_tab.rs

//! **The `graphview` tab** — a real graph tab driven by **facett-graphview**, the
//! consolidated graph render ENGINE, rendering through the shared
//! `facett_core::render` **L0 Canvas** (the `render_to_rgba_l0` path).
//!
//! Where the existing `graph` tab is a `facett-graph` `Scene` (the circular
//! layout skin) and `depgraph` is the layered-DAG skin, THIS tab exercises the
//! *render kernel* directly: it lowers a [`GraphModel`] into the L0 SDF instance
//! vocabulary (rounded-square node markers + straight-capsule edges) and
//! rasterizes it with the core [`CpuRenderer`](facett_core::render) — the exact
//! same kernel the map skins draw through (see `.nornir/render-engine.md` §"ONE
//! render kernel, TWO domain skins"). The frame is uploaded as an egui texture.
//!
//! ## Why a wrapper here (not in `facett-graphview`)
//! `facett-graphview` is a render engine: it produces an RGBA frame, it is **not**
//! an egui `Facet` widget (its model mirrors nornir's `draw_graph` SHARED API so
//! nornir/korp can swap their painter for it). The demo owns the thin egui-`Facet`
//! adapter so the leaf engine crate stays UI-framework-free — the same split the
//! OSM tabs use (`osm_tabs` owns the data load; the viewer crates stay lean).
//!
//! ## At scale: the RESIDENT GPU cloud
//! The L0 kernel rasterises on the CPU on every camera move — fine for a mesh, a
//! wall at 100 000 nodes. Above `cloud::RESIDENT_ABOVE` (5 000) nodes, with the shared
//! instanced cloud renderer installed, the tab paints the SAME model (fills, world
//! positions, the same chip radius and edge width the L0 lowering uses) on the
//! resident lane instead: points + edge index pairs uploaded ONCE per model
//! (`cloud::ResidentSlot`), each frame the 48-byte camera alone. With no renderer the
//! L0 CPU lane draws as before and `state_json` says `no GPU`.
//!
//! ## Navigable
//! Drag pans, scroll zooms (the shared pan/zoom contract — `caps().navigable`).
//! The live camera + the lowered instance counts + a lit-pixel proof ride in
//! `state_json`, so the robot matrix drives it and asserts a real, non-blank
//! render (EMIT-DOCTRINE: the frame's `lit_px` is the can't-see-from-outside
//! "did it actually draw" oracle, asserted as data).

use facett::Facet;
use facett::core::FacetCaps;
use facett_graphview::{
    Backend, Camera, Color, Decorations, GpuProbe, GraphEdge, GraphModel, GraphNode, Pos, decide,
    render_to_rgba_l0,
};

/// The stable tab title (the deck/discovery-contract key + the legend key).
pub const TITLE: &str = "graphview";

/// A `graphview`-engine-backed graph tab — a canonical [`Facet`] that renders a
/// synthetic layered service-mesh graph through the consolidated L0 kernel.
pub struct GraphviewTab {
    title: &'static str,
    model: GraphModel,
    /// The runtime-chosen backend (CPU here; the GPU L0 lane shares the same
    /// lowered instances and is opt-in behind the engine's `gpu` feature).
    backend: Backend,
    /// The live 2D pan/zoom camera (the navigable contract). Re-fit on first draw
    /// to the actual widget rect; user drag/scroll then drives it.
    camera: Camera,
    /// Whether the camera has been fit to a real widget rect yet.
    fitted: bool,
    /// The cached uploaded frame texture (re-rendered when the camera/rect moves).
    tex: Option<egui::TextureHandle>,
    /// The last (w, h) the frame was rendered at — re-render on rect change.
    last_size: (u32, u32),
    /// The last frame's lit-pixel count — the non-blank render oracle.
    lit_px: usize,
    /// Which lane drew the last frame: `"l0-cpu"`, `"resident"`, or `""` (not drawn).
    lane: &'static str,
    /// The resident lane's upload bookkeeping (one model per generation).
    #[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
    resident: facett::core::render::gpu::graphcloud::cloud::ResidentSlot,
    /// This model's resident generation (process-unique; the model never changes in
    /// place — a new graph is a new tab value).
    #[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
    generation: u64,
}

impl GraphviewTab {
    /// Build the tab over a synthetic **layered service-mesh** graph: `LAYERS`
    /// columns of `PER_LAYER` nodes, fully forward-connected layer→layer, so the
    /// engine draws a real fan-in/fan-out mesh (not a 3-node toy). The node fill
    /// is a per-layer hue so the columns read as bands.
    pub fn new(title: &'static str) -> Self {
        Self::from_model(title, synthetic_mesh())
    }

    /// The tab over a caller's `model` (the size + generator control's graph).
    pub fn from_model(title: &'static str, model: GraphModel) -> Self {
        let backend = decide(GpuProbe::cpu_only());
        // A provisional fit (re-fit on the first real draw to the widget rect).
        let camera = Camera::fit(&model, 900.0, 560.0, 48.0);
        Self {
            title,
            model,
            backend,
            camera,
            fitted: false,
            tex: None,
            last_size: (0, 0),
            lit_px: 0,
            lane: "",
            #[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
            resident: Default::default(),
            #[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
            generation: facett::core::render::gpu::graphcloud::cloud::fresh_generation(),
        }
    }

    /// Does this model take the resident lane when a renderer is installed?
    #[must_use]
    pub fn at_scale(&self) -> bool {
        self.model.nodes.len() > RESIDENT_ABOVE
    }

    /// Which lane drew the last frame (`"l0-cpu"` / `"resident"` / `""`).
    #[must_use]
    pub fn lane(&self) -> &'static str {
        self.lane
    }

    /// Models the resident lane has uploaded (`0` on the L0 lane).
    #[must_use]
    pub fn resident_uploads(&self) -> u64 {
        #[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
        {
            self.resident.uploads()
        }
        #[cfg(not(any(feature = "gpu", feature = "graph-wgpu")))]
        {
            0
        }
    }

    /// Is the shared cloud renderer installed in this process?
    fn renderer_installed() -> bool {
        crate::universe_tab::renderer_installed()
    }

    /// The camera `(zoom, pan_x, pan_y)`.
    #[must_use]
    pub fn camera(&self) -> (f32, f32, f32) {
        (self.camera.zoom, self.camera.pan_x, self.camera.pan_y)
    }

    /// Pan the camera by `(dx, dy)` px — what a drag does.
    pub fn pan_by(&mut self, dx: f32, dy: f32) {
        self.camera.pan_x += dx;
        self.camera.pan_y += dy;
    }

    /// The lane readout at scale (no per-node data): the summary a `GenTab` publishes.
    #[must_use]
    pub fn summary_json(&self) -> serde_json::Value {
        serde_json::json!({
            "engine": "facett-graphview",
            "nodes": self.node_count(),
            "edges": self.edge_count(),
            "navigable": true,
            "zoom": self.camera.zoom,
            "pan": [self.camera.pan_x, self.camera.pan_y],
            "lane": self.lane,
            "gpu": if Self::renderer_installed() { "gpu" } else { "no GPU" },
            "resident_uploads": self.resident_uploads(),
            "lit_px": self.lit_px,
        })
    }

    /// **The resident frame**: the camera every frame, the model only when this slot's
    /// generation is not the resident one. `false` when the lane cannot draw (no
    /// renderer, a zero-pixel rect, or a build without the GPU cloud).
    #[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
    fn paint_resident(&self, ui: &egui::Ui, rect: egui::Rect) -> bool {
        use facett::core::render::gpu::graphcloud::{CloudCamera, CloudPoint};
        use facett_graphview::model::BOX_H;
        let ppp = ui.ctx().pixels_per_point();
        let zoom = self.camera.zoom;
        // The L0 projection is `p · zoom + pan` from the frame's top-left.
        let cam = CloudCamera::plane(
            [rect.width() * ppp, rect.height() * ppp],
            [self.camera.pan_x * ppp, self.camera.pan_y * ppp],
            zoom * ppp,
            (BOX_H * 0.5 * zoom).max(2.0) * ppp,
            (1.4 * zoom).clamp(0.7, 3.0) * 0.5 * ppp,
            2.5,
        );
        let model = &self.model;
        let build = || {
            let idx: std::collections::HashMap<&str, u32> =
                model.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i as u32)).collect();
            let pts = model
                .nodes
                .iter()
                .map(|n| {
                    let c = n.fill;
                    CloudPoint::new([n.pos.x, -n.pos.y, 0.0], egui::Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a))
                })
                .collect();
            let eds = model
                .edges
                .iter()
                .filter_map(|e| Some([*idx.get(e.from.as_str())?, *idx.get(e.to.as_str())?]))
                .collect();
            (pts, eds)
        };
        self.resident.draw(ui, rect, self.generation, build, cam)
    }

    #[cfg(not(any(feature = "gpu", feature = "graph-wgpu")))]
    fn paint_resident(&self, _ui: &egui::Ui, _rect: egui::Rect) -> bool {
        false
    }

    /// The number of nodes / edges in the driven graph (for tests + state).
    pub fn node_count(&self) -> usize {
        self.model.nodes.len()
    }
    pub fn edge_count(&self) -> usize {
        self.model.edges.len()
    }

    /// Render the current model+camera to an RGBA frame through the **consolidated
    /// L0 kernel** (`render_to_rgba_l0`) and upload it as an egui texture. Records
    /// the lowered instance counts + lit-pixel proof. The single render path shared
    /// by `ui` and the headless test.
    fn render_into_texture(&mut self, ctx: &egui::Context, w: u32, h: u32) {
        let bg = Color::rgb(16, 18, 26);
        // The L0 lane: lower the model into core SDF instances + rasterize on the
        // shared `facett_core::render::CpuRenderer` (the same kernel the maps use).
        let frame = render_to_rgba_l0(
            self.backend,
            &self.model,
            &Decorations::default(),
            &self.camera,
            w,
            h,
            bg,
        );
        self.lit_px = frame.lit_px();
        let color = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &frame.rgba);
        let tex = ctx.load_texture(format!("{}-frame", self.title), color, egui::TextureOptions::LINEAR);
        self.tex = Some(tex);
        self.last_size = (w, h);
    }
}

impl Facet for GraphviewTab {
    fn title(&self) -> &str {
        self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // The viewport: take the available area (min a sane size so the headless
        // harness, which can hand a zero rect on the first frame, still renders).
        let avail = ui.available_size();
        let w = (avail.x.max(64.0)) as u32;
        let h = avail.y.clamp(64.0, 900.0) as u32;

        // Fit to the real rect the first time we know it.
        if !self.fitted {
            self.camera = Camera::fit(&self.model, w as f32, h as f32, 48.0);
            self.fitted = true;
        }

        let (rect, resp) =
            ui.allocate_exact_size(egui::vec2(w as f32, h as f32), egui::Sense::click_and_drag());

        // ── Navigable: drag pans, scroll zooms (the shared pan/zoom contract). ──
        let mut moved = false;
        if resp.dragged() {
            let d = resp.drag_delta();
            self.camera.pan_x += d.x;
            self.camera.pan_y += d.y;
            moved = true;
        }
        let scroll = ui.input(|i| i.smooth_scroll_delta.y);
        if resp.hovered() && scroll.abs() > 0.0 {
            let factor = (scroll * 0.0025).exp();
            self.camera.zoom = (self.camera.zoom * factor).clamp(0.05, 12.0);
            moved = true;
        }

        // At scale with a renderer: the resident cloud (camera-only frames upload
        // nothing). Otherwise the L0 kernel, re-rendered when the rect changed or the
        // camera moved (else the texture is reused).
        let resident = self.at_scale() && {
            let th = facett::core::theme(ui);
            ui.painter().rect_filled(rect, 0.0, th.bg);
            self.paint_resident(ui, rect)
        };
        if resident {
            self.lane = "resident";
            self.tex = None;
            self.last_size = (0, 0);
        } else {
            self.lane = "l0-cpu";
            if moved || self.tex.is_none() || self.last_size != (w, h) {
                let ctx = ui.ctx().clone();
                self.render_into_texture(&ctx, w, h);
            }
            if let Some(tex) = &self.tex {
                ui.painter().image(
                    tex.id(),
                    rect,
                    egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
                    egui::Color32::WHITE,
                );
            }
        }
        // A small label so the AccessKit tree carries the tab name for the robot.
        let lane_txt = if resident {
            "resident GPU cloud"
        } else if self.at_scale() {
            "L0 kernel (CPU — no GPU)"
        } else {
            "L0 kernel"
        };
        ui.painter().text(
            rect.left_top() + egui::vec2(8.0, 8.0),
            egui::Align2::LEFT_TOP,
            format!("graphview · {lane_txt} · {} nodes · {} edges", self.node_count(), self.edge_count()),
            egui::FontId::proportional(13.0),
            egui::Color32::from_gray(200),
        );

        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        {
            let (quads, lines) = facett_graphview::lower_to_l0(&self.model, &Decorations::default(), &self.camera);
            let (n_quads, n_lines) = (quads.len(), lines.len());
            facett::core::testmatrix::emit(
                "facett-demo::GraphviewTab::ui",
                "ui_render",
                // OK = the model lowered into the L0 SDF vocabulary and rasterized:
                // a non-empty graph yields ≥1 node-marker quad and lit pixels, and
                // the lowered counts never exceed the model (rendered ≤ input).
                n_quads <= self.node_count()
                    && n_lines <= self.edge_count()
                    && (self.node_count() == 0 || (n_quads > 0 && self.lit_px > 0)),
                &format!(
                    "nodes={} edges={} l0_quads={} l0_lines={} lit_px={}",
                    self.node_count(),
                    self.edge_count(),
                    n_quads,
                    n_lines,
                    self.lit_px,
                ),
            );
        }
    }

    fn state_json(&self) -> serde_json::Value {
        // Lower the model into the L0 SDF vocabulary HERE (cheap, deterministic) so
        // the instance-count proof is available even before the first `ui()` draw
        // (the headless deck self-check reads state without drawing). The cached
        // `self.lowered` from the last real draw matches this exactly.
        let (quads, lines) = facett_graphview::lower_to_l0(&self.model, &Decorations::default(), &self.camera);
        serde_json::json!({
            "engine": "facett-graphview",
            "render_path": "facett_core::render L0 Canvas (render_to_rgba_l0)",
            "backend": format!("{:?}", self.backend),
            "nodes": self.node_count(),
            "edges": self.edge_count(),
            "navigable": true,
            "zoom": self.camera.zoom,
            "pan": [self.camera.pan_x, self.camera.pan_y],
            // The lowered L0 instance counts — proof the model routed through the
            // shared SDF vocabulary (quads = node markers, lines = edges).
            "l0_quads": quads.len(),
            "l0_lines": lines.len(),
            // The non-blank render oracle (EMIT-DOCTRINE: the can't-see-from-outside
            // "did it actually draw" signal, surfaced as data). 0 until first draw.
            "lit_px": self.lit_px,
            // Which lane drew the last frame + whether the GPU cloud is there at all.
            "lane": self.lane,
            "gpu": if Self::renderer_installed() { "gpu" } else { "no GPU" },
            "resident_uploads": self.resident_uploads(),
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.navigable().themeable().resizable()
    }
}

/// Above this many nodes the tab paints on the resident GPU cloud (when installed) —
/// the shared cut every resident 2-D pane makes.
#[cfg(any(feature = "gpu", feature = "graph-wgpu"))]
pub use facett::core::render::gpu::graphcloud::cloud::RESIDENT_ABOVE;
/// See the GPU build's `RESIDENT_ABOVE` (this build has no resident lane).
#[cfg(not(any(feature = "gpu", feature = "graph-wgpu")))]
pub const RESIDENT_ABOVE: usize = 5_000;

/// The number of layers in the synthetic mesh.
pub const LAYERS: usize = 5;
/// Nodes per layer.
pub const PER_LAYER: usize = 6;

/// Build the synthetic **layered service-mesh** graph the tab draws: `LAYERS`
/// columns × `PER_LAYER` nodes, fully forward-connected (every node in layer `i`
/// feeds every node in layer `i+1`) — a real fan-in/fan-out mesh that shows the
/// engine drawing dozens of nodes + scores of edges, not a 3-node toy. Per-layer
/// hue so the columns read as bands.
pub fn synthetic_mesh() -> GraphModel {
    let layer_hue = |l: usize| -> Color {
        // Walk the hue wheel across the layers (cheap HSV-ish ramp).
        let palette = [
            Color::rgb(70, 110, 190),
            Color::rgb(80, 170, 120),
            Color::rgb(200, 160, 70),
            Color::rgb(190, 90, 120),
            Color::rgb(140, 110, 200),
        ];
        palette[l % palette.len()]
    };

    let mut nodes: Vec<GraphNode> = Vec::with_capacity(LAYERS * PER_LAYER);
    for l in 0..LAYERS {
        for r in 0..PER_LAYER {
            let id = format!("n{l}_{r}");
            // World-space layout: columns left→right, rows top→bottom (the caller
            // owns layout, per the engine's model contract).
            let x = l as f32 * 260.0;
            let y = r as f32 * 90.0;
            nodes.push(GraphNode {
                id,
                label: format!("svc {l}.{r}"),
                fill: layer_hue(l),
                stroke: Color::WHITE,
                pos: Pos::new(x, y),
            });
        }
    }

    let mut edges: Vec<GraphEdge> = Vec::new();
    for l in 0..LAYERS - 1 {
        for r in 0..PER_LAYER {
            for r2 in 0..PER_LAYER {
                // Thin the mesh a touch so it reads as a mesh, not a solid block:
                // connect to a rotating window of the next layer's nodes.
                if (r + r2) % 2 == 0 {
                    edges.push(GraphEdge {
                        from: format!("n{l}_{r}"),
                        to: format!("n{}_{}", l + 1, r2),
                        color: Color::rgba(170, 180, 205, 200),
                        dashed: (r2 % 3) == 0,
                        label: None,
                    });
                }
            }
        }
    }

    GraphModel { nodes, edges }
}

#[cfg(test)]
mod tests {
    use super::*;
    use facett::core::harness;

    /// INJECT-ASSERT: the synthetic mesh is a real multi-layer fan-in/fan-out
    /// graph (dozens of nodes, scores of edges), the tab is a navigable Facet, and
    /// a headless render routes through the L0 kernel and draws a non-blank frame —
    /// the lowered instance counts + lit pixels prove the consolidated path, not
    /// "didn't panic".
    #[test]
    fn graphview_tab_renders_through_l0_kernel_nonblank() {
        let model = synthetic_mesh();
        assert_eq!(model.nodes.len(), LAYERS * PER_LAYER, "the mesh has LAYERS×PER_LAYER nodes");
        assert!(model.edges.len() > model.nodes.len(), "real fan-in/fan-out edges, not a chain");

        let mut tab = GraphviewTab::new(TITLE);
        assert!(tab.caps().navigable, "the graphview tab is navigable");
        assert!(tab.caps().themeable, "the graphview tab is themeable");

        // Drive a real headless render frame.
        let r = harness::headless_render(&mut tab);
        assert!(r.drew(), "the graphview tab tessellated the egui texture image");

        let s = tab.state_json();
        assert_eq!(s["engine"], "facett-graphview");
        assert!(s["render_path"].as_str().unwrap().contains("L0 Canvas"));
        assert_eq!(s["nodes"].as_u64().unwrap(), (LAYERS * PER_LAYER) as u64);
        assert!(s["edges"].as_u64().unwrap() > (LAYERS * PER_LAYER) as u64);
        assert_eq!(s["navigable"], true);
        // The L0 vocabulary carried the model: one quad per node (markers; rings
        // only with decorations, none here) + one line per edge.
        assert_eq!(s["l0_quads"].as_u64().unwrap(), (LAYERS * PER_LAYER) as u64, "one L0 marker quad per node");
        assert_eq!(s["l0_lines"].as_u64().unwrap(), model.edges.len() as u64, "one L0 line per edge");
        // The render oracle: the consolidated kernel actually lit pixels.
        assert!(s["lit_px"].as_u64().unwrap() > 0, "the L0 kernel drew a non-blank frame");
    }

    /// INJECT-ASSERT: the navigable contract responds — zoom changes after a
    /// scroll-driven camera move, asserted as a real state value (not "didn't
    /// panic"). Mirrors the OSM tabs' navigable assertion.
    #[test]
    fn graphview_tab_camera_is_drivable() {
        let mut tab = GraphviewTab::new(TITLE);
        let z0 = tab.camera.zoom;
        // Simulate a scroll-zoom step the way `ui` does.
        tab.camera.zoom = (tab.camera.zoom * 1.2).clamp(0.05, 12.0);
        assert!((tab.camera.zoom - z0).abs() > 1e-3, "the camera zoom moved");
        // Re-render at the new zoom still lights pixels (the move didn't blank it).
        let ctx = egui::Context::default();
        tab.render_into_texture(&ctx, 800, 500);
        assert!(tab.lit_px > 0, "re-render after a camera move stays non-blank");
    }
}