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);
}
}