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

nordisk/facett

facett-graphview/tests/legibility.rs

//! **Legibility + the GPU fallback for the graph render engine.**
//!
//! Two things are pinned here, both of which were prose before:
//!
//! 1. `clip::pick` scans every node in the model on every probe. [`PickIndex`] answers
//!    from a screen-space neighbourhood instead — and must return the **identical**
//!    node, including where chips overlap and the tiebreak decides. A fast wrong answer
//!    is worse than a slow right one, so the two are swept against each other.
//! 2. Opting into the shared instanced GPU cloud on a box with **no renderer
//!    installed** must render the CPU frame vertex for vertex. That is the exact shape
//!    that made `Graph3D`'s cloud paint a blank pane: proven by tests, installed by no
//!    host, CPU painter skipped anyway.

use facett_core::harness::headless_render;
use facett_graphview::clip::{pick, WidgetRect};
use facett_graphview::model::{Camera, Color, GraphEdge, GraphModel, GraphNode, Pos};
use facett_graphview::{DecoratedGraphView, PickIndex};

fn model(n: usize) -> GraphModel {
    let side = (n as f32).sqrt().ceil() as usize;
    let nodes = (0..n)
        .map(|i| {
            let (r, c) = (i / side, i % side);
            GraphNode {
                id: format!("n{i}"),
                label: format!("node-{i}"),
                fill: Color::rgb(60, 90, 160),
                stroke: Color::WHITE,
                pos: Pos::new(c as f32 * 70.0, r as f32 * 45.0),
            }
        })
        .collect();
    let edges = (0..n.saturating_sub(1))
        .map(|i| GraphEdge {
            from: format!("n{i}"),
            to: format!("n{}", i + 1),
            color: Color::rgb(200, 200, 220),
            dashed: false,
            label: None,
        })
        .collect();
    GraphModel { nodes, edges }
}

/// **The index and the scan must agree, probe for probe** — including the overlapping
/// chips where the tiebreak is the whole question.
#[test]
fn indexed_picking_is_identical_to_the_linear_scan() {
    let m = model(900);
    let cam = Camera { pan_x: 20.0, pan_y: 15.0, zoom: 0.4 };
    let rect = WidgetRect::new(0.0, 0.0, 900.0, 700.0);
    let erect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(900.0, 700.0));

    let mut idx = PickIndex::default();
    idx.ensure(&m, &cam);

    let (mut hits, mut misses) = (0usize, 0usize);
    for k in 0..800 {
        let p = egui::pos2(((k * 11) % 900) as f32, ((k * 17) % 700) as f32);
        let want = pick(&m, &cam, rect, p.x, p.y);
        let got = idx.pick(&cam, erect, p).map(|i| m.nodes[i].id.as_str());
        assert_eq!(got, want, "probe {p:?}: index {got:?} vs scan {want:?}");
        if want.is_some() {
            hits += 1;
        } else {
            misses += 1;
        }
    }
    assert!(hits > 50, "the sweep must hit real chips ({hits})");
    assert!(misses > 20, "and must miss ({misses})");
}

/// **THE claim, as data.** One pick touches a neighbourhood; the scan it replaced
/// touched the whole model. Asserted over a 100× dataset growth so the number cannot
/// quietly start tracking `n` again.
#[test]
fn pick_cost_tracks_the_neighbourhood_not_the_model() {
    let mut seen = Vec::new();
    for n in [900usize, 10_000, 90_000] {
        let m = model(n);
        let cam = Camera { pan_x: 0.0, pan_y: 0.0, zoom: 1.0 };
        let erect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100_000.0, 100_000.0));
        let mut idx = PickIndex::default();
        idx.ensure(&m, &cam);
        // A probe on a real chip, well inside the lattice.
        let (cx, cy) = cam.project(m.nodes[n / 2].pos);
        assert!(idx.pick(&cam, erect, egui::pos2(cx, cy)).is_some(), "n={n}: the probe must hit");
        assert!(
            idx.last_candidates <= 128,
            "n={n}: one pick distance-tested {} nodes",
            idx.last_candidates
        );
        seen.push((n, idx.last_candidates));
    }
    let (n0, c0) = seen[0];
    let (n1, c1) = *seen.last().unwrap();
    assert!(
        c1 <= c0 * 2,
        "model {n0} -> {n1} (×{}) but pick cost {c0} -> {c1}",
        n1 / n0
    );
}

/// GPU opt-in with **no installed renderer** paints the CPU frame vertex for vertex.
#[test]
fn gpu_optin_without_a_renderer_falls_back_vertex_for_vertex() {
    let m = model(100);
    let mut cpu = DecoratedGraphView::new("cpu").with_model(m.clone());
    let mut gpu = DecoratedGraphView::new("gpu").with_model(m).with_gpu(true);
    let c = headless_render(&mut cpu);
    let g = headless_render(&mut gpu);
    assert!(c.drew(), "the control frame draws");
    assert_eq!(
        g.vertices, c.vertices,
        "no renderer installed ⇒ the CPU frame, vertex for vertex ({} vs {})",
        g.vertices, c.vertices
    );
    assert_eq!(
        g.state["legibility"]["gpu_cloud"],
        serde_json::json!(false),
        "and the pane says so"
    );
    // Not two blanks agreeing: an empty pane is visibly smaller.
    let mut empty = DecoratedGraphView::new("empty");
    assert!(g.vertices > headless_render(&mut empty).vertices * 3);
}

/// Legibility ON dissolves a label pile and publishes the numbers; OFF is the
/// historical frame.
#[test]
fn legibility_on_suppresses_overplot_and_off_is_unchanged() {
    // 600 chips crammed together so their labels cannot all be readable.
    let mut m = model(600);
    for (i, n) in m.nodes.iter_mut().enumerate() {
        n.pos = Pos::new(((i * 17) % 300) as f32, ((i * 29) % 220) as f32);
    }

    let mut plain = DecoratedGraphView::new("plain").with_model(m.clone());
    let plain_r = headless_render(&mut plain);
    let mut plain2 = DecoratedGraphView::new("plain2").with_model(m.clone());
    assert_eq!(
        headless_render(&mut plain2).vertices,
        plain_r.vertices,
        "the default pane is deterministic"
    );

    let mut legible = DecoratedGraphView::new("legible").with_model(m).with_legibility(true);
    let r = headless_render(&mut legible);
    let lg = &r.state["legibility"];
    assert!(r.drew());
    assert!(
        lg["labels_suppressed"].as_u64().unwrap() > 0,
        "a 600-label pile must suppress losers: {lg}"
    );
    assert!(lg["labels_placed"].as_u64().unwrap() > 0, "and keep winners: {lg}");
    assert!(
        r.vertices < plain_r.vertices,
        "fewer labels drawn ⇒ fewer vertices ({} vs {})",
        r.vertices,
        plain_r.vertices
    );
}