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

nordisk/facett

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: &centres,
                    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)
}