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

nordisk/facett

facett-graph/src/scale.rs

//! **[`GraphView`](crate::GraphView) at scale** — above [`SCALE_ABOVE`] nodes.
//!
//! The fixture painter makes every scene node its own AccessKit button and (up to 60
//! nodes) a label, every frame, and lays the scene out in the paint rect. At 100 000
//! nodes that is 100 000 `ui.interact`s per frame on top of a per-frame CPU cloud. Here:
//!
//! * the layout is computed ONCE per scene (in a fixed reference square, so a resize is
//!   a camera change, not a relayout) and the pane gets a pan/zoom camera (drag pans,
//!   scroll zooms about the pointer) — at 100 000 nodes on one circle a node is not
//!   reachable without one;
//! * the node/edge cloud is RESIDENT on the GPU (feature `wgpu` + an installed renderer:
//!   `cloud::ResidentSlot`, the model uploaded once per scene, the camera per frame);
//!   without one the egui painter draws, as below the threshold;
//! * **FC-4 stays honest, with a stated budget**: the AccessKit buttons are the nodes ON
//!   SCREEN, in scene order, at most [`A11Y_BUDGET`] (the selected node always), and the
//!   canvas itself carries the budget in its label ("… N of M nodes addressable …").
//!   `state_json()["a11y"]` publishes the same numbers and `node_targets` lists exactly
//!   the labels the tree holds this frame. [`Msg::SelectNode`](crate::Msg::SelectNode)
//!   reaches every node, on screen or not.
//! * labels: the on-screen ones, when at most 60 are on screen (the fixture's own cut).
//! * `Layout::Force` at scale is a non-blocking GPU job on the host's device (feature
//!   `gpu-layout`; the grid kernel above `GRID_MIN_NODES`), the circle shown until it
//!   lands (one more upload then). With no device the existing core layout runs.

/// Above this many nodes a [`GraphView`](crate::GraphView) takes the scale path. The
/// same cut the resident 2-D panes make (`graphcloud::cloud::RESIDENT_ABOVE`).
pub const SCALE_ABOVE: usize = 5_000;

/// Most per-node AccessKit buttons one frame emits at scale (lane E's label cap).
pub const A11Y_BUDGET: usize = 2_000;

/// Most on-screen nodes for which labels are painted (the fixture painter's cut).
pub const LABELS_ON_SCREEN: usize = 60;

/// Force-layout steps of the GPU job (the navigator's `FORCE_ITERS_GPU`).
#[cfg(feature = "gpu-layout")]
const FORCE_ITERS_GPU: usize = 80;

/// The reference square the scale path lays the scene out in (px at zoom 1 in a
/// square pane of this size — the fixture painter's own geometry).
pub(crate) const REF: f32 = 1_000.0;

/// What the last scale frame put in the a11y tree.
#[derive(Clone, Debug, Default)]
pub(crate) struct A11yFrame {
    pub on_screen: usize,
    pub emitted: Vec<usize>,
    pub labels: usize,
}

/// The per-scene work of the scale path (ephemeral, FC-7: rebuilt from the scene).
#[derive(Default)]
pub(crate) struct ScaleCache {
    key: Option<u64>,
    /// Node positions in the reference square, centred on the origin.
    pub pos: Vec<egui::Pos2>,
    /// The resident generation of this scene (process-unique).
    pub generation: u64,
    /// Scenes laid out on this path (the "model change" counter a test reads).
    pub layouts: u64,
    #[cfg(feature = "wgpu")]
    pub slot: facett_core::render::gpu::graphcloud::cloud::ResidentSlot,
    pub last: A11yFrame,
    /// Which lane produced `pos`: `"circular"`, `"cpu"` (the core force layout),
    /// `"gpu-job"` (pending or landed — see `pending`), or `"circular (gpu job failed)"`.
    pub lane: &'static str,
    /// The GPU force job while it runs.
    #[cfg(feature = "gpu-layout")]
    job: Option<Box<facett_graphview::gpu_layout::LayoutJob>>,
}

impl ScaleCache {
    /// Bring the positions up to `scene` under `layout`: a no-op unless the scene's
    /// fingerprint or the layout changed.
    pub fn sync(&mut self, layout: facett_core::Layout, scene: &facett_core::Scene) {
        self.poll_job();
        let key = fingerprint(layout, scene);
        if self.key == Some(key) {
            return;
        }
        let r = egui::Rect::from_center_size(egui::Pos2::ZERO, egui::vec2(REF, REF));
        self.key = Some(key);
        if matches!(layout, facett_core::Layout::Force) && self.start_job(scene) {
            // The circle until the GPU layout lands.
            self.pos = facett_core::layout_positions(facett_core::Layout::Circular, scene, r);
            self.lane = "gpu-job";
        } else {
            self.pos = facett_core::layout_positions(layout, scene, r);
            self.lane = if matches!(layout, facett_core::Layout::Force) { "cpu" } else { "circular" };
        }
        self.bump();
    }

    /// A new layout: count it and give it a fresh resident generation (one upload).
    fn bump(&mut self) {
        self.layouts += 1;
        #[cfg(feature = "wgpu")]
        {
            self.generation = facett_core::render::gpu::graphcloud::cloud::fresh_generation();
        }
        #[cfg(not(feature = "wgpu"))]
        {
            self.generation = self.layouts;
        }
    }

    /// Is a GPU force layout in flight (the host keeps repainting)?
    #[must_use]
    pub fn pending(&self) -> bool {
        #[cfg(feature = "gpu-layout")]
        {
            self.job.is_some()
        }
        #[cfg(not(feature = "gpu-layout"))]
        {
            false
        }
    }

    /// Start the GPU force job for `scene` on the host's device; `false` (no job) when
    /// the feature is off or no host lent a device.
    #[allow(unused_variables, clippy::unused_self)]
    fn start_job(&mut self, scene: &facett_core::Scene) -> bool {
        #[cfg(feature = "gpu-layout")]
        {
            use facett_graphview::gpu_layout::{host_device_installed, with_host_device, LayoutJob};
            self.job = None;
            if !host_device_installed() {
                return false;
            }
            let n = scene.nodes.len();
            let edges: Vec<(usize, usize)> = scene.edges.iter().map(|e| (e.src, e.dst)).collect();
            if let Some(job) = with_host_device(|d, _| LayoutJob::start(d, n, &edges, FORCE_ITERS_GPU)) {
                self.job = Some(Box::new(job));
                return true;
            }
            false
        }
        #[cfg(not(feature = "gpu-layout"))]
        {
            false
        }
    }

    /// Advance the GPU job one bounded slice; when it lands, swap the positions in
    /// (scaled onto the circle's radius) — one more upload.
    fn poll_job(&mut self) {
        #[cfg(feature = "gpu-layout")]
        {
            use facett_graphview::gpu_layout::{with_host_device, JobState};
            let Some(job) = self.job.as_mut() else { return };
            let state = with_host_device(|d, q| job.poll(d, q)).unwrap_or(JobState::Failed("host device cleared"));
            match state {
                JobState::Pending => {}
                JobState::Ready(p) => {
                    self.job = None;
                    // `[-0.9, 0.9]²` → the circular layout's radius (0.42 · REF).
                    let k = 0.42 * REF / 0.9;
                    self.pos = p.iter().map(|q| egui::pos2(q[0] * k, q[1] * k)).collect();
                    self.bump();
                }
                JobState::Failed(_) => {
                    self.job = None;
                    self.lane = "circular (gpu job failed)";
                }
            }
        }
    }
}

/// **A scene in one integer** — node colours and label addresses, edge endpoints, and
/// the layout, by value; no allocation. A host that edits `scene` in place changes it;
/// one that leaves it alone gets the same key every frame (no relayout, no upload).
#[must_use]
pub fn fingerprint(layout: facett_core::Layout, scene: &facett_core::Scene) -> u64 {
    const K: u64 = 0x9E37_79B9_7F4A_7C15;
    let mut h: u64 = K ^ scene.nodes.len() as u64 ^ (u64::from(matches!(layout, facett_core::Layout::Force)) << 63);
    let mut w = |x: u64| h = (h.rotate_left(5) ^ x).wrapping_mul(K);
    w(scene.edges.len() as u64);
    for n in &scene.nodes {
        w(u64::from(u32::from_le_bytes(n.color.to_array())));
        w((n.label.as_ptr() as u64) ^ ((n.label.len() as u64) << 48));
    }
    for e in &scene.edges {
        w((e.src as u64) | ((e.dst as u64) << 32));
    }
    h
}

/// The pan/zoom camera of the scale path: screen = `centre + pan + p · zoom · fit`,
/// where `fit` maps the reference square onto the pane (`min(w, h) / REF`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScaleCamera {
    pub zoom: f32,
    pub pan: egui::Vec2,
}

impl Default for ScaleCamera {
    fn default() -> Self {
        Self { zoom: 1.0, pan: egui::Vec2::ZERO }
    }
}

impl ScaleCamera {
    /// Pixels per reference unit in `rect`.
    #[must_use]
    pub fn scale(&self, rect: egui::Rect) -> f32 {
        self.zoom * rect.size().min_elem().max(1.0) / REF
    }

    /// Where reference point `p` lands in `rect`.
    #[must_use]
    pub fn project(&self, rect: egui::Rect, p: egui::Pos2) -> egui::Pos2 {
        rect.center() + self.pan + p.to_vec2() * self.scale(rect)
    }

    /// Zoom by `factor` keeping the reference point under `pointer` still.
    pub fn zoom_about(&mut self, rect: egui::Rect, pointer: egui::Pos2, factor: f32) {
        let old = self.scale(rect);
        let z = (self.zoom * factor).clamp(0.05, 10_000.0);
        let new_scale = z * rect.size().min_elem().max(1.0) / REF;
        // world under the pointer before == after
        let world = (pointer - rect.center() - self.pan) / old;
        self.zoom = z;
        self.pan = pointer - rect.center() - world * new_scale;
    }
}