facett-graphpan/src/resident.rs
//! **The RESIDENT GPU cloud lane for the canvas at scale** (above
//! [`cloud::RESIDENT_ABOVE`] nodes, `gpu_cloud` on, a renderer installed).
//!
//! The per-frame lane ([`crate::legible::draw_gpu_cloud`]) rebuilds an id → index
//! `HashMap` and one instance per node and edge every frame and re-sends them — at
//! 100 000 nodes / 310 000 edges that loop, not the GPU, set the frame rate (14.8 fps on
//! the t14s Radeon 890M, lane D 2026-10-01). Here the points and edge index pairs are
//! uploaded ONCE per model ([`ResidentSlot`]) and a frame sends the 48-byte camera. The
//! CPU keeps what is per frame and small: the selection ring, decoration rings and
//! badges, decoration (cut) edges, the lit downstream trace, and labels for the chips
//! that are on screen.
//!
//! What identifies "the model" ([`ModelKey`]): a [`crate::GraphPanView`] numbers its own
//! models ([`ModelKey::Owned`], set in `with_model`), so it hashes nothing. The free
//! [`crate::paint_scene_opts`] is handed a model by reference every frame and cannot
//! know whether it changed, so it FINGERPRINTS it ([`fingerprint`]): every node's
//! position and fill, every edge's colour, and every id string's address and length —
//! O(nodes + edges) integer work, no string hashing, and no allocation.
//!
//! The cloud is the model's fills; a selection does not dim it (neither did the per-frame
//! lane) — the trace is drawn over it instead, so a selection change uploads nothing.
use std::cell::RefCell;
use std::collections::{BTreeSet, HashMap};
use egui::{Align2, Color32, FontId, Pos2, Rect, Stroke, Vec2};
use facett_core::legibility::{plan_frame as core_plan_frame, FrameInput, FrameOpts};
use facett_core::render::gpu::graphcloud::cloud::{self, ResidentSlot};
use facett_core::render::gpu::graphcloud::{CloudCamera, CloudEdge, CloudPoint};
use crate::{downstream_of, Decorations, GraphModel, GraphView, LegibilityReport, PaintOpts, BOX_H, BOX_W};
/// Most lit-trace edges drawn over the cloud for a selection (the rest are in the cloud).
pub const LIT_EDGE_CAP: usize = 4_096;
/// Most labels painted in one frame on this lane (the on-screen ones, in model order).
pub const LABEL_CAP: usize = 2_000;
/// What the uploaded cloud is a picture of.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelKey {
/// A generation the owning view assigned when its model was set.
Owned(u64),
/// [`fingerprint`] of a model handed in by reference.
Fingerprint(u64),
}
/// The per-model work, done once per model (and the lit set once per selection).
#[derive(Default)]
pub(crate) struct ResidentCache {
slot: ResidentSlot,
key: Option<ModelKey>,
generation: u64,
idx: HashMap<String, u32>,
/// The selection `lit` / `lit_edges` were computed for (`None` = not computed yet).
lit_for: Option<Option<String>>,
lit: BTreeSet<String>,
lit_edges: Vec<u32>,
/// Model changes seen (each one is a fresh generation).
models: u64,
}
impl ResidentCache {
pub(crate) fn uploads(&self) -> u64 {
self.slot.uploads()
}
/// Bring the per-model and per-selection caches up to `key` / `selected`.
fn sync(&mut self, key: ModelKey, model: &GraphModel, selected: Option<&String>) {
if self.key != Some(key) {
self.key = Some(key);
self.generation = cloud::fresh_generation();
self.idx = model.nodes.iter().enumerate().map(|(i, n)| (n.id.clone(), i as u32)).collect();
self.lit_for = None;
self.models += 1;
}
if self.lit_for.as_ref().map(Option::as_ref) != Some(selected) {
self.lit = selected.map(|s| downstream_of(model, s)).unwrap_or_default();
self.lit_edges = if self.lit.is_empty() {
Vec::new()
} else {
model
.edges
.iter()
.enumerate()
.filter(|(_, e)| self.lit.contains(&e.from) && self.lit.contains(&e.to))
.map(|(i, _)| i as u32)
.collect()
};
self.lit_for = Some(selected.cloned());
}
}
}
thread_local! {
/// The cache the free [`crate::paint_scene_opts`] uses (it has no pane to hold one).
static FREE: RefCell<ResidentCache> = RefCell::new(ResidentCache::default());
}
/// Run `f` on the free function's cache.
pub(crate) fn with_free<R>(f: impl FnOnce(&mut ResidentCache) -> R) -> R {
FREE.with(|c| f(&mut c.borrow_mut()))
}
/// The uploads the free [`crate::paint_scene_opts`] lane has made on this thread.
#[must_use]
pub fn free_uploads() -> u64 {
with_free(|c| c.uploads())
}
/// **What a borrowed model looks like, in one integer** — positions, fills, edge colours
/// and dash flags by value, id strings by address and length. A host that rebuilds an
/// identical model into fresh allocations gets a new fingerprint (one re-upload); one that
/// keeps its model gets the same fingerprint every frame (no upload).
#[must_use]
pub fn fingerprint(model: &GraphModel) -> u64 {
const K: u64 = 0x9E37_79B9_7F4A_7C15;
let mut h: u64 = K ^ model.nodes.len() as u64;
let mut w = |x: u64| h = (h.rotate_left(5) ^ x).wrapping_mul(K);
let s = |t: &str| (t.as_ptr() as u64) ^ ((t.len() as u64) << 48);
w(model.edges.len() as u64);
for n in &model.nodes {
w(u64::from(n.pos.x.to_bits()) | (u64::from(n.pos.y.to_bits()) << 32));
w(u64::from(u32::from_le_bytes(n.fill.to_array())));
w(s(&n.id));
}
for e in &model.edges {
w(s(&e.from));
w(s(&e.to) ^ (u64::from(e.dashed) << 63));
w(u64::from(u32::from_le_bytes(e.color.to_array())));
}
h
}
/// Paint one frame on the resident lane. `None` when the lane cannot draw (no renderer,
/// a zero-pixel rect) — the caller then takes the per-frame / CPU road unchanged.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(crate) fn paint(
cache: &mut ResidentCache,
key: impl FnOnce() -> ModelKey,
painter: &egui::Painter,
model: &GraphModel,
decorations: &Decorations,
view: &GraphView,
origin: Pos2,
text: Color32,
selection_ring: Color32,
text_dim: Color32,
opts: PaintOpts,
ui: &egui::Ui,
rect: Rect,
) -> Option<LegibilityReport> {
if !cloud::cloud_renderer_installed() {
return None;
}
let zoom = view.zoom;
cache.sync(key(), model, view.selected.as_ref());
// ── the cloud: the camera every frame, the model once ─────────────────────────
let ppp = ui.ctx().pixels_per_point();
let o = (origin - rect.min) * ppp;
let radius = view.dot_px.map_or((BOX_H * 0.5 * zoom * ppp).max(1.0), |r| r * ppp);
let cam = CloudCamera::plane([rect.width() * ppp, rect.height() * ppp], [o.x, o.y], zoom * ppp, radius, 1.5, 2.5);
let idx = &cache.idx;
let build = || {
let pts: Vec<CloudPoint> = model.nodes.iter().map(|n| CloudPoint::new([n.pos.x, -n.pos.y, 0.0], n.fill)).collect();
let eds: Vec<CloudEdge> = model
.edges
.iter()
.filter_map(|e| Some([*idx.get(&e.from)?, *idx.get(&e.to)?]))
.collect();
(pts, eds)
};
if !cache.slot.draw(ui, rect, cache.generation, build, cam) {
return None;
}
let project = |p: Pos2| origin + p.to_vec2() * zoom;
let at = |id: &str| cache.idx.get(id).map(|&i| i as usize);
let highlighting = view.selected.is_some();
let dimc = |c: Color32| c.linear_multiply(0.22);
let mut report = LegibilityReport { gpu_cloud: true, resident: true, ..LegibilityReport::default() };
// ── the lit downstream trace over the cloud ─────────────────────────────────
for &ei in cache.lit_edges.iter().take(LIT_EDGE_CAP) {
let e = &model.edges[ei as usize];
if let (Some(a), Some(b)) = (at(&e.from), at(&e.to)) {
painter.line_segment([project(model.nodes[a].pos), project(model.nodes[b].pos)], Stroke::new(1.6, selection_ring));
}
}
// ── decoration (cut) edges: never thinned, never in the cloud ─────────────────
for e in &decorations.edges {
if let (Some(a), Some(b)) = (at(&e.from), at(&e.to)) {
crate::stroke_edge(painter, project(model.nodes[a].pos), project(model.nodes[b].pos), e, zoom, view.dot_px, false, false, text_dim, true);
}
}
// ── rings + badges: the selected node and the decorated ones, not every node ──
let mut special: Vec<usize> = decorations.nodes.keys().filter_map(|id| at(id)).collect();
if let Some(s) = view.selected.as_deref().and_then(at) {
special.push(s);
}
special.sort_unstable();
special.dedup();
for &i in &special {
let n = &model.nodes[i];
let c = project(n.pos);
let on_trace = highlighting && cache.lit.contains(&n.id);
let selected = view.selected.as_deref() == Some(n.id.as_str());
let deco = decorations.nodes.get(&n.id);
if let Some(r) = view.dot_px {
if selected {
painter.circle_stroke(c, r + 2.0, Stroke::new(2.0, selection_ring));
} else if let Some(rc) = deco.and_then(|d| d.ring) {
painter.circle_stroke(c, r + 2.0, Stroke::new(1.8, if highlighting && !on_trace { dimc(rc) } else { rc }));
}
continue;
}
let chip = Rect::from_center_size(c, Vec2::new(BOX_W, BOX_H) * zoom);
let ring = if selected {
Stroke::new(3.0_f32, selection_ring)
} else if let Some(rc) = deco.and_then(|d| d.ring) {
Stroke::new(2.4_f32, if highlighting && !on_trace { dimc(rc) } else { rc })
} else {
Stroke::new(1.4_f32, if highlighting && !on_trace { dimc(n.stroke) } else { n.stroke })
};
painter.rect_stroke(chip, 5.0 * zoom, ring, egui::epaint::StrokeKind::Outside);
if zoom > 0.45
&& let Some(badge) = deco.and_then(|d| d.badge.as_deref())
{
let col = deco.and_then(|d| d.badge_color).unwrap_or(text);
painter.text(
chip.right_top() + Vec2::new(-2.0, 1.0),
Align2::RIGHT_TOP,
badge,
FontId::proportional(12.0 * zoom.clamp(0.7, 1.4)),
if highlighting && !on_trace { dimc(col) } else { col },
);
}
}
// ── labels: the chips on screen only (DOT mode has none, as on the CPU lane) ──
if zoom > 0.45 && view.dot_px.is_none() {
let viewport = painter.clip_rect();
let half = Vec2::new(BOX_W, BOX_H) * 0.5 * zoom;
let on: Vec<usize> = (0..model.nodes.len())
.filter(|&i| viewport.intersects(Rect::from_center_size(project(model.nodes[i].pos), half * 2.0)))
.take(LABEL_CAP)
.collect();
let font = FontId::proportional(11.0 * zoom.clamp(0.7, 1.4));
report.labels_considered = on.len();
if opts.thin_labels {
let char_px = 6.0 * zoom.clamp(0.7, 1.4);
let centres: Vec<Pos2> = on.iter().map(|&i| project(model.nodes[i].pos)).collect();
let size: Vec<Vec2> = on
.iter()
.map(|&i| Vec2::new((model.nodes[i].label.chars().count() as f32 * char_px).max(4.0), 11.0 * zoom.clamp(0.7, 1.4)))
.collect();
let prio: Vec<f32> = on
.iter()
.map(|&i| {
let n = &model.nodes[i];
(if cache.lit.contains(&n.id) { 1000.0 } else { 0.0 }) + n.label.chars().count() as f32
})
.collect();
let pinned: Vec<bool> = on.iter().map(|&i| view.selected.as_deref() == Some(model.nodes[i].id.as_str())).collect();
let plan = core_plan_frame(
&FrameInput {
centres: ¢res,
label_size: &size,
label_priority: &prio,
label_pinned: &pinned,
edges: &[],
edge_always: &[],
edge_weight: &[],
viewport,
},
FrameOpts { thin_labels: true, thin_edges: false, label_pad: opts.label_pad, ..FrameOpts::default() },
);
if let Some(p) = &plan.labels {
for pl in &p.placed {
painter.text(pl.rect.center(), Align2::CENTER_CENTER, &model.nodes[on[pl.index]].label, font.clone(), text);
}
report.labels_placed = p.placed.len();
report.labels_suppressed = p.suppressed;
}
} else {
for &i in &on {
painter.text(project(model.nodes[i].pos), Align2::CENTER_CENTER, &model.nodes[i].label, font.clone(), text);
}
report.labels_placed = on.len();
}
}
report.edges_drawn = model.edges.len();
report.resident_uploads = cache.slot.uploads();
Some(report)
}