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