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

nordisk/facett

facett-graphgen/src/universe.rs

//! **The `Universe` tab** — the whole graph as one GPU star-field, 100 000 nodes by
//! default and up to 1 000 000: facett's twin of dwarves' warehouse-nav "universe"
//! mode, built from facett parts only (facett must not depend on dwarves).
//!
//! - **data** — by default the repo → crate → function world
//!   ([`facett_graphview::world::WorldGraph::synthetic`]) on its O(n) **galaxy
//!   placement** ([`facett_graphview::world::hier_placement`], the lane dwarves calls
//!   `galaxy`); the size + generator control ([`crate::GenControl`]) offers the
//!   other generators on their own O(n) placements, and the population.
//! - **paint** — the instanced GPU cloud's RESIDENT lane
//!   (`graphcloud::cloud::draw_cloud_model`): the model is uploaded once per generation
//!   and each frame sends only the 48-byte camera, so pan and zoom cost the CPU nothing
//!   per node. Labels are off (a label per star at 100k is noise).
//! - **no GPU** — said plainly on the pane and in `state_json` (`lane: "no GPU"`). There
//!   is no CPU painter behind this tab (owner ruling 2026-09-28: GPU-able work goes to
//!   WebGPU/wgpu, no new CPU fallbacks).
//!
//! The model is generated on the first frame the tab is drawn, never in
//! `DemoState::new`, and again only when the control changes.

use facett_core::Facet;
use facett_core::FacetCaps;
use facett_graphview::generate::GenKind;

use crate::{self as graphgen, GenControl, GenGraph, Source};

/// The stable tab title (deck / LEGEND / `#Universe` deep-link key).
pub const TITLE: &str = "Universe";

/// The size the tab opens at.
pub const DEFAULT_NODES: usize = 100_000;

/// The ceiling the size selector offers (the resident lane's tested reach).
pub const MAX_NODES: usize = 1_000_000;

/// What the pane says when no renderer is installed.
pub const NO_GPU: &str = "no GPU — the universe is drawn on the GPU only (no WebGPU/wgpu adapter reached this pane), and there is no CPU painter behind it";

/// The generated universe, CPU side: the placement (for picking) and the GPU model
/// (uploaded when the generation moves).
struct Built {
    gg: GenGraph,
    #[cfg(feature = "cloud")]
    points: Vec<facett_core::render::gpu::graphcloud::CloudPoint>,
    #[cfg(feature = "cloud")]
    edges: Vec<facett_core::render::gpu::graphcloud::CloudEdge>,
    generation: u64,
}

pub struct UniverseTab {
    control: GenControl,
    built: Option<Built>,
    generations: u64,
    /// Which generation the resident lane holds for THIS pane.
    #[cfg_attr(not(feature = "cloud"), allow(dead_code))]
    uploaded: std::cell::Cell<u64>,
    /// Camera: screen-space pan (points) and zoom (1 = the box fills the pane).
    pan: egui::Vec2,
    zoom: f32,
    edges_on: bool,
    selected: Option<usize>,
    /// The canvas size the last frame allocated (points).
    canvas_px: [f32; 2],
    /// `"gpu"`, `"no GPU"` or `"not drawn"` (before the first frame).
    lane: &'static str,
    timing: graphgen::FrameTiming,
    /// ⊙ fit was asked (the button, or `{"fit": true}`): the next frame starts the flight.
    fit_asked: bool,
    /// ⊙ fit under way on the shared smooth path ([`facett_core::fly`]), with the canvas
    /// width it was started for; a drag or a scroll ends it.
    flight: Option<(facett_core::fly::Flight, f32)>,
}

impl Default for UniverseTab {
    fn default() -> Self {
        Self::new()
    }
}

impl UniverseTab {
    #[must_use]
    pub fn new() -> Self {
        Self {
            control: GenControl::new(MAX_NODES).starting_at(Source::Gen(GenKind::Crates), DEFAULT_NODES).with_sources(&SOURCES),
            built: None,
            generations: 0,
            uploaded: std::cell::Cell::new(0),
            pan: egui::Vec2::ZERO,
            zoom: 1.0,
            edges_on: true,
            selected: None,
            canvas_px: [0.0, 0.0],
            lane: "not drawn",
            timing: graphgen::FrameTiming::default(),
            fit_asked: false,
            flight: None,
        }
    }

    /// **⊙ fit as a flight**: from where the camera is at `now` (mid-flight included) to
    /// the whole universe (pan 0, zoom 1) on the shared smooth zoom-and-pan path, for a
    /// canvas `viewport_w` wide.
    pub fn fly_fit(&mut self, now: f64, viewport_w: f32) {
        use facett_core::fly;
        let vw = viewport_w.max(1.0);
        let (c0, w0) = match self.flight {
            Some((f, fvw)) => {
                let (c, w) = f.at(now);
                let (p, z) = fly::to_pan_zoom(c, w, fvw);
                fly::from_pan_zoom(p, z, vw)
            }
            None => fly::from_pan_zoom([self.pan.x, self.pan.y], self.zoom, vw),
        };
        let (c1, w1) = fly::from_pan_zoom([0.0, 0.0], 1.0, vw);
        self.flight = Some((fly::Flight::new(now, c0, w0, c1, w1), vw));
    }

    /// Advance a flight to `now`; `true` while it still flies.
    fn fly_step(&mut self, now: f64) -> bool {
        let Some((f, vw)) = self.flight else { return false };
        let (c, w) = f.at(now);
        let (p, z) = facett_core::fly::to_pan_zoom(c, w, vw);
        (self.pan, self.zoom) = (egui::vec2(p[0], p[1]), z);
        if f.done(now) {
            self.flight = None;
        }
        self.flight.is_some()
    }

    /// A universe that does not generate until a size is picked (the empty state).
    #[must_use]
    pub fn unbuilt() -> Self {
        let mut u = Self::new();
        let _ = u.control.take_request();
        u
    }

    /// A universe built NOW from a generator request (facet-wrapped's `{"generate": …}`
    /// seam). `None` for a source the universe does not offer (`demo data`).
    #[must_use]
    pub fn from_request(req: crate::GenRequest) -> Option<Self> {
        if !SOURCES.contains(&req.source) {
            return None;
        }
        // `new()` is already pending (crates at the default size), so a request equal to
        // the default still builds; any other request moves the control first.
        let mut u = Self::new();
        u.select(req.source, req.size, req.seed);
        u.built.is_some().then_some(u)
    }

    /// The sources the universe offers.
    #[must_use]
    pub fn sources() -> &'static [Source] {
        &SOURCES
    }

    /// The control (size / generator / seed).
    #[must_use]
    pub fn control(&self) -> &GenControl {
        &self.control
    }

    /// Nodes in the built universe (0 before the first build).
    #[must_use]
    pub fn node_count(&self) -> usize {
        self.built.as_ref().map_or(0, |b| b.gg.g.n)
    }

    /// Select `(source, size, seed)` and build NOW (the headless path).
    pub fn select(&mut self, source: Source, size: usize, seed: u64) -> bool {
        let changed = self.control.set(source, size, seed);
        self.build_if_requested();
        changed
    }

    /// Build now if the control asked (once per change).
    pub fn build_if_requested(&mut self) {
        if !self.control.take_request() {
            return;
        }
        let t0 = graphgen::now_ms();
        let Some(gg) = graphgen::generate(self.control.source, self.control.size, self.control.seed) else { return };
        let t1 = graphgen::now_ms();
        self.generations += 1;
        // A generation number no other pane uses: high bit set, our counter below.
        let generation = (1u64 << 63) | (u64::from(fnv(TITLE)) << 24) | self.generations;
        #[cfg(feature = "cloud")]
        let (points, edges) = gpu_model(&gg);
        self.built = Some(Built {
            gg,
            #[cfg(feature = "cloud")]
            points,
            #[cfg(feature = "cloud")]
            edges,
            generation,
        });
        let t2 = graphgen::now_ms();
        let b = self.built.as_ref().expect("just built");
        self.control.stats = graphgen::GenStats {
            nodes: b.gg.g.n,
            edges: b.gg.g.edges.len(),
            gen_ms: t1 - t0,
            build_ms: t2 - t1,
            builds: self.control.stats.builds + 1,
        };
        self.pan = egui::Vec2::ZERO;
        self.zoom = 1.0;
        self.selected = None;
        self.timing = graphgen::FrameTiming::start(t2);
    }

    /// Screen position of node `i` under the current camera.
    fn screen(&self, rect: egui::Rect, p: [f32; 2]) -> egui::Pos2 {
        let s = self.scale(rect);
        rect.center() + self.pan + egui::vec2(p[0], p[1]) * s
    }

    /// Points per placement unit.
    fn scale(&self, rect: egui::Rect) -> f32 {
        rect.width().min(rect.height()) * 0.48 * self.zoom
    }
}

/// The sources the universe offers (every generator on its own placement; `demo data`
/// is the default world, so it is not listed twice).
const SOURCES: [Source; 8] = [
    Source::Gen(GenKind::Crates),
    Source::Population,
    Source::Gen(GenKind::Community),
    Source::Gen(GenKind::Sbm),
    Source::Gen(GenKind::Random),
    Source::Gen(GenKind::ScaleFree),
    Source::Gen(GenKind::Grid),
    Source::Gen(GenKind::Tree),
];

/// Is the instanced cloud renderer installed in this process (a GPU adapter reached the
/// app and `main` installed the lane)? `false` in a build without the GPU lane.
#[must_use]
pub fn renderer_installed() -> bool {
    #[cfg(feature = "cloud")]
    {
        facett_core::render::gpu::graphcloud::cloud::cloud_renderer_installed()
    }
    #[cfg(not(feature = "cloud"))]
    {
        false
    }
}

fn fnv(s: &str) -> u32 {
    let mut h: u32 = 0x811c_9dc5;
    for b in s.bytes() {
        h = (h ^ u32::from(b)).wrapping_mul(0x0100_0193);
    }
    h & 0x00ff_ffff
}

/// The resident model: one point per node (y negated: the cloud projects
/// `centre + (x, −y)·scale`), one index pair per edge.
#[cfg(feature = "cloud")]
fn gpu_model(
    gg: &GenGraph,
) -> (Vec<facett_core::render::gpu::graphcloud::CloudPoint>, Vec<facett_core::render::gpu::graphcloud::CloudEdge>) {
    use facett_core::render::gpu::graphcloud::CloudPoint;
    let points = (0..gg.g.n)
        .map(|i| {
            let [r, g, b] = gg.rgb(i);
            let p = gg.g.pos[i];
            CloudPoint::new([p[0], -p[1], 0.0], egui::Color32::from_rgb(r, g, b))
        })
        .collect();
    let edges = gg.g.edges.iter().map(|&(a, b)| [a, b]).collect();
    (points, edges)
}

impl Facet for UniverseTab {
    fn title(&self) -> &str {
        TITLE
    }

    fn component(&self) -> &'static str {
        // The id it had in the demo, kept across the move to this crate (2026-10-02):
        // it is an identity a host may key on, not a module path.
        "facett-demo::universe_tab"
    }

    fn kind(&self) -> &'static str {
        "universe"
    }

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

    fn ui(&mut self, ui: &mut egui::Ui) {
        let was_pending = self.control.pending();
        self.build_if_requested();
        let th = facett_core::theme(ui);
        if self.control.ui(ui) || was_pending {
            ui.ctx().request_repaint();
        }
        ui.horizontal_wrapped(|ui| {
            ui.checkbox(&mut self.edges_on, "edges");
            if ui.button("⊙ fit").clicked() {
                self.fit_asked = true;
            }
            let fps = self.timing.fps();
            let first = self.timing.first_frame_ms.map_or("—".into(), |v| format!("{v:.0} ms"));
            ui.label(
                egui::RichText::new(format!(
                    "lane: {} · labels off · zoom {:.2}× · first frame {first} · {fps:.0} fps",
                    self.lane, self.zoom
                ))
                .color(th.text_dim),
            );
            if let (Some(i), Some(b)) = (self.selected, self.built.as_ref()) {
                ui.label(egui::RichText::new(format!("· node {i} ({})", b.gg.label(i))).color(th.text));
            }
        });
        ui.separator();

        // A floor, as the navigator has: a deck pane can hand a canvas zero height, and a
        // zero-pixel pane is a pane the GPU lane refuses (it would read as "no GPU").
        let (resp, painter) =
            ui.allocate_painter(ui.available_size().max(egui::vec2(360.0, 300.0)), egui::Sense::click_and_drag());
        self.canvas_px = [resp.rect.width(), resp.rect.height()];
        let rect = resp.rect;
        painter.rect_filled(rect, 4.0, egui::Color32::from_rgb(5, 6, 8));
        let now = ui.input(|i| i.time);
        if std::mem::take(&mut self.fit_asked) {
            self.fly_fit(now, rect.width());
        }
        let scroll = if resp.hovered() { ui.input(|i| i.smooth_scroll_delta.y) } else { 0.0 };
        if resp.dragged() || scroll != 0.0 {
            // the viewer takes the camera from wherever the flight had it
            self.flight = None;
        } else if self.fly_step(now) {
            ui.ctx().request_repaint();
        }
        if resp.dragged() {
            self.pan += resp.drag_delta();
        }
        if resp.hovered() {
            if scroll != 0.0 {
                // Zoom about the pointer: the point under it stays under it.
                let z0 = self.zoom;
                let z1 = (z0 * (1.0 + scroll * 0.002)).clamp(0.2, 2_000.0);
                if let Some(p) = resp.hover_pos() {
                    let rel = p - rect.center();
                    self.pan = rel - (rel - self.pan) * (z1 / z0);
                }
                self.zoom = z1;
            }
        }
        if let (Some(click), Some(b)) = (resp.clicked().then(|| resp.interact_pointer_pos()).flatten(), self.built.as_ref()) {
            // One O(n) scan per CLICK (never per frame).
            let mut best = (f32::MAX, None);
            for (i, p) in b.gg.g.pos.iter().enumerate() {
                let d = self.screen(rect, *p).distance_sq(click);
                if d < best.0 {
                    best = (d, Some(i));
                }
            }
            self.selected = best.1.filter(|_| best.0 <= 64.0);
        }

        let drew = self.paint_gpu(ui, rect);
        if drew {
            self.lane = "gpu";
            if let Some(i) = self.selected
                && let Some(b) = self.built.as_ref()
            {
                painter.circle_stroke(self.screen(rect, b.gg.g.pos[i]), 6.0, egui::Stroke::new(2.0, th.accent));
            }
            self.timing.frame(graphgen::now_ms());
            ui.ctx().request_repaint();
        } else if self.built.is_some() && !renderer_installed() {
            self.lane = "no GPU";
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, NO_GPU, egui::FontId::proportional(14.0), th.text_dim);
        } else if self.built.is_some() {
            // A renderer exists but this frame drew nothing (a degenerate rect): say so
            // rather than claim there is no GPU.
            self.lane = "not drawn";
        } else {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "nothing generated — pick a data source and a size", egui::FontId::proportional(14.0), th.text_dim);
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let b = self.built.as_ref();
        serde_json::json!({
            "title": TITLE,
            "nodes": b.map_or(0, |b| b.gg.g.n),
            "edges": b.map_or(0, |b| b.gg.g.edges.len()),
            "groups": b.map_or(0, |b| b.gg.g.groups),
            "placement": b.map(|b| match b.gg.source {
                Source::Gen(GenKind::Crates) => "galaxy",
                Source::Population => "kommun",
                _ => "generator",
            }),
            "lane": self.lane,
            "gpu_cloud": self.lane == "gpu",
            "renderer_installed": renderer_installed(),
            "canvas": self.canvas_px,
            "labels": "off",
            "edges_on": self.edges_on,
            "zoom": self.zoom,
            "pan": [self.pan.x, self.pan.y],
            "flying": self.flight.is_some(),
            "selected": self.selected,
            "generation": b.map(|b| b.generation),
            "frames": self.timing.frames,
            "first_frame_ms": self.timing.first_frame_ms,
            "settle_ms": self.timing.settle_ms,
            "frame_ms": self.timing.ema_ms,
            "fps": self.timing.fps(),
            "gen": self.control.state_json(),
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        // the headless twin of the ⊙ fit button: the next frame flies
        if v.get("fit").and_then(serde_json::Value::as_bool) == Some(true) {
            self.fit_asked = true;
        }
        if let Some(g) = v.get("gen")
            && self.control.apply_json(g)
        {
            self.build_if_requested();
        }
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
}

impl UniverseTab {
    /// Push the resident cloud for `rect`; `false` when no renderer is installed (or
    /// nothing is built yet, or this build has no GPU lane at all).
    #[cfg(feature = "cloud")]
    fn paint_gpu(&self, ui: &egui::Ui, rect: egui::Rect) -> bool {
        use facett_core::render::gpu::graphcloud::{cloud, CloudCamera};
        let Some(b) = self.built.as_ref() else { return false };
        if !cloud::cloud_renderer_installed() {
            return false;
        }
        // Edges off is its own model (the generation says so), not a per-frame filter.
        let generation = b.generation ^ u64::from(!self.edges_on) << 40;
        let upload = self.uploaded.get() != generation || cloud::resident_generation() != generation;
        let model = upload.then(|| (b.points.clone(), if self.edges_on { b.edges.clone() } else { Vec::new() }));
        let ppp = ui.ctx().pixels_per_point();
        let c = (rect.center() - rect.min + self.pan) * ppp;
        let scale = self.scale(rect) * ppp;
        // Disc radius: ~1.2 px at the fit, growing gently with zoom (over the shader's
        // 5.5 px base).
        let radius = (1.2 * self.zoom.sqrt()).clamp(0.8, 6.0);
        let cam = CloudCamera {
            viewport: [rect.width() * ppp, rect.height() * ppp, c.x, c.y],
            cam: [0.0, scale, 2.5, radius * ppp / 5.5],
            edge: [0.5 * ppp, 0.35, 0.0, 0.0],
        };
        let drew = cloud::draw_cloud_model(ui, rect, generation, model, cam);
        if drew && upload {
            self.uploaded.set(generation);
        }
        drew
    }

    #[cfg(not(feature = "cloud"))]
    fn paint_gpu(&self, _ui: &egui::Ui, _rect: egui::Rect) -> bool {
        false
    }
}

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

    #[test]
    fn opens_on_the_galaxy_at_100k_and_builds_only_when_asked() {
        let mut u = UniverseTab::new();
        assert_eq!(u.node_count(), 0, "nothing is generated at construction");
        assert!(u.control().pending(), "the first draw builds");
        u.build_if_requested();
        assert!(u.node_count().abs_diff(DEFAULT_NODES) <= DEFAULT_NODES / 10, "{} nodes", u.node_count());
        let s = u.state_json();
        assert_eq!(s["placement"], "galaxy");
        assert_eq!(s["labels"], "off");
        assert_eq!(s["gen"]["builds"], 1);
        // No second build without a change.
        u.build_if_requested();
        assert_eq!(u.state_json()["gen"]["builds"], 1);
        assert_eq!(u.control().sizes().last(), Some(MAX_NODES), "1M is offered");
        // A change rebuilds once, deterministically.
        assert!(u.select(Source::Gen(GenKind::Sbm), 10_000, 4));
        let a = u.built.as_ref().unwrap().gg.g.fingerprint();
        u.select(Source::Gen(GenKind::Grid), 1_000, 4);
        u.select(Source::Gen(GenKind::Sbm), 10_000, 4);
        assert_eq!(u.built.as_ref().unwrap().gg.g.fingerprint(), a, "same (source, size, seed) → same universe");
        assert_eq!(u.state_json()["gen"]["builds"], 4);
    }

    /// **⊙ fit flies** on the shared smooth path, like every other view: a quarter second
    /// after it the camera is ON THE WAY from where the viewer left it, and it lands
    /// exactly on the whole universe (pan 0, zoom 1).
    #[test]
    fn fit_flies_to_the_whole_universe() {
        let mut u = UniverseTab::new();
        u.select(Source::Gen(GenKind::Grid), 400, 1);
        let ctx = egui::Context::default();
        let mut frame = |u: &mut UniverseTab, t: f64| {
            let input = egui::RawInput {
                time: Some(t),
                screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))),
                ..Default::default()
            };
            let mut out = ctx.run_ui(input, |ui| u.ui(ui));
            out.textures_delta.clear();
        };
        frame(&mut u, 0.0);
        (u.pan, u.zoom) = (egui::vec2(300.0, -200.0), 3.0);
        u.update_json(r#"{"fit":true}"#);
        frame(&mut u, 1.0);
        frame(&mut u, 1.25);
        let (pan, zoom) = (u.pan, u.zoom);
        assert!(pan.length() > 1.0 && (pan - egui::vec2(300.0, -200.0)).length() > 1.0, "on the way, not jumped and not left: {pan:?}");
        assert!((zoom - 3.0).abs() > 1e-3, "zooming out on the way: {zoom}");
        assert_eq!(u.state_json()["flying"], true);
        frame(&mut u, 3.0);
        assert!(u.pan.length() < 1e-3 && (u.zoom - 1.0).abs() < 1e-4, "landed on the fit: {:?} {}", u.pan, u.zoom);
        assert_eq!(u.state_json()["flying"], false);
    }
}