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

nordisk/facett

facett-graphpan/tests/dot_edge_anchor.rs

//! **In DOT mode an edge ends ON its dot** — the arrow tip touches the dot it points at and
//! the tail leaves the dot it comes from.
//!
//! The defect (owner, 2026-10-13, the warehouse navigator's world graph): "the DOT is
//! disconnected from the arrow, displaced by 100 pixels?". The edge painter anchored every
//! edge at the CHIP's left/right rim — `centre ± BOX_W/2 · zoom`, 92 px at zoom 1 — also
//! when the node is drawn as a small dot ([`GraphView::dot_px`]). So every arrow stopped
//! `92 · zoom` px short of its dot and every edge started `92 · zoom` px right of its dot:
//! a constant horizontal gap, the same everywhere on the pane, growing with the zoom.
//!
//! These tests read the frame's SHAPES (what the painter really emitted) and assert
//! geometry in PHYSICAL px: for every edge, the painted tail and arrow tip sit on the rim
//! of the dot they belong to, within 1 px — for two pane insets (the app's chrome above
//! and beside the canvas), two `pixels_per_point`, and two zooms. The chip look is pinned
//! unchanged beside it.
//!
//! No device is needed for the CPU painter. The GPU-cloud half (feature `wgpu`) opens the
//! renderer gate without a device (`cloud-test-gate`): the cloud is a paint callback whose
//! rect is in the shapes, and the CPU overlays (rings, decoration edges) are plain shapes.

use egui::{pos2, vec2, Color32, Pos2, Rect, Vec2};
use facett_graphpan::{draw_graph, Decorations, GraphEdge, GraphModel, GraphNode, GraphView, BOX_H, BOX_W};

/// The navigator's largest world dot (`dot_px` is clamped to 1.8..6.0 there).
const DOT: f32 = 6.0;
/// The app's chrome around the canvas, in points: `(top bar, left rail)`.
const INSETS: [(f32, f32); 2] = [(96.0, 220.0), (40.0, 0.0)];
const PPPS: [f32; 2] = [1.0, 1.25];
const ZOOMS: [f32; 2] = [1.0, 0.3];

const BG: Color32 = Color32::from_gray(14);
const TEXT: Color32 = Color32::from_gray(235);
const DIM: Color32 = Color32::from_gray(120);
const RING: Color32 = Color32::from_rgb(10, 250, 20);

fn node(id: &str, x: f32, y: f32, fill: Color32) -> GraphNode {
    GraphNode { id: id.into(), label: id.into(), fill, stroke: Color32::WHITE, pos: pos2(x, y) }
}

fn edge(from: &str, to: &str) -> GraphEdge {
    GraphEdge { from: from.into(), to: to.into(), color: Color32::GRAY, dashed: false, label: None }
}

/// Four nodes, one fill each (the fill is how a painted dot is matched to its node), and
/// edges in every direction: rightwards, leftwards, up, down.
fn model() -> GraphModel {
    GraphModel {
        nodes: vec![
            node("a", -300.0, -120.0, Color32::from_rgb(200, 40, 40)),
            node("b", 260.0, 40.0, Color32::from_rgb(40, 200, 40)),
            node("c", -80.0, 200.0, Color32::from_rgb(40, 40, 200)),
            node("d", 340.0, -200.0, Color32::from_rgb(200, 200, 40)),
        ],
        edges: vec![edge("a", "b"), edge("b", "c"), edge("c", "d"), edge("a", "d"), edge("d", "a")],
    }
}

/// One frame at a 1920×1080-px window with the canvas inset by `top` / `left` points of
/// chrome. Returns the frame's shapes, flattened.
fn frame(ppp: f32, (top, left): (f32, f32), mut canvas: impl FnMut(&mut egui::Ui)) -> Vec<egui::Shape> {
    let ctx = egui::Context::default();
    ctx.set_pixels_per_point(ppp);
    let input = egui::RawInput {
        screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1920.0 / ppp, 1080.0 / ppp))),
        ..Default::default()
    };
    let mut out = ctx.run_ui(input, |ui| {
        egui::Panel::top("chrome_top").exact_size(top).show(ui, |ui| {
            ui.label("tab bar");
        });
        if left > 0.0 {
            egui::Panel::left("chrome_rail").exact_size(left).show(ui, |ui| {
                ui.label("rail");
            });
        }
        egui::CentralPanel::default().show(ui, |ui| canvas(ui));
    });
    out.textures_delta.clear();
    let mut flat = Vec::new();
    for s in out.shapes {
        flatten(s.shape, &mut flat);
    }
    flat
}

fn flatten(s: egui::Shape, out: &mut Vec<egui::Shape>) {
    match s {
        egui::Shape::Vec(v) => v.into_iter().for_each(|s| flatten(s, out)),
        s => out.push(s),
    }
}

/// The edge curves of a frame, in paint order: `(tail, tip)` of each cubic.
fn curves(shapes: &[egui::Shape]) -> Vec<(Pos2, Pos2)> {
    shapes
        .iter()
        .filter_map(|s| match s {
            egui::Shape::CubicBezier(c) => Some((c.points[0], c.points[3])),
            _ => None,
        })
        .collect()
}

/// The arrowhead barbs of a frame: every line segment's START (both barbs start at the tip).
fn barb_starts(shapes: &[egui::Shape]) -> Vec<Pos2> {
    shapes
        .iter()
        .filter_map(|s| match s {
            egui::Shape::LineSegment { points, .. } => Some(points[0]),
            _ => None,
        })
        .collect()
}

/// Where the painter put node `n`'s DOT: the filled circle of radius `DOT` in its fill.
fn dot_centre(shapes: &[egui::Shape], n: &GraphNode) -> Pos2 {
    let hits: Vec<Pos2> = shapes
        .iter()
        .filter_map(|s| match s {
            egui::Shape::Circle(c) if c.fill == n.fill && (c.radius - DOT).abs() < 1e-3 => Some(c.center),
            _ => None,
        })
        .collect();
    assert_eq!(hits.len(), 1, "node {} is painted as exactly one dot", n.id);
    hits[0]
}

/// How far `p` is from the RIM of the dot centred at `c`, in physical px (0 = on the rim).
fn off_rim_px(p: Pos2, c: Pos2, ppp: f32) -> f32 {
    ((p - c).length() - DOT).abs() * ppp
}

/// **THE test (CPU painter — the lane the navigator's world graph draws through).**
#[test]
fn in_dot_mode_every_edge_starts_and_ends_on_its_dot() {
    let m = model();
    let at = |id: &str| m.nodes.iter().find(|n| n.id == id).expect("node");
    let mut worst = 0.0_f32;
    for ppp in PPPS {
        for inset in INSETS {
            for zoom in ZOOMS {
                let mut view = GraphView { pan: Vec2::new(37.0, -21.0), zoom, selected: None, dot_px: Some(DOT) };
                let shapes = frame(ppp, inset, |ui| {
                    let _ = draw_graph(ui, &m, &Decorations::default(), &mut view, BG, TEXT, RING, DIM);
                });
                let cs = curves(&shapes);
                assert_eq!(cs.len(), m.edges.len(), "one curve per edge");
                let barbs = barb_starts(&shapes);
                for (e, &(tail, tip)) in m.edges.iter().zip(&cs) {
                    let (from, to) = (dot_centre(&shapes, at(&e.from)), dot_centre(&shapes, at(&e.to)));
                    let (d_tail, d_tip) = (off_rim_px(tail, from, ppp), off_rim_px(tip, to, ppp));
                    worst = worst.max(d_tail).max(d_tip);
                    let ctx = format!("edge {}→{} · ppp {ppp} · inset {inset:?} · zoom {zoom}", e.from, e.to);
                    assert!(
                        d_tip <= 1.0,
                        "{ctx}: the arrow tip is {d_tip:.1} px off the rim of the dot it points at \
                         (tip {tip:?}, dot centre {to:?}, dx {:.1} px, dy {:.1} px)",
                        (tip.x - to.x) * ppp,
                        (tip.y - to.y) * ppp,
                    );
                    assert!(
                        d_tail <= 1.0,
                        "{ctx}: the edge starts {d_tail:.1} px off the rim of the dot it leaves \
                         (tail {tail:?}, dot centre {from:?}, dx {:.1} px, dy {:.1} px)",
                        (tail.x - from.x) * ppp,
                        (tail.y - from.y) * ppp,
                    );
                    // The arrowhead is drawn AT the curve's end: two barbs start on the tip
                    // for every edge that lands on this dot.
                    let into = m.edges.iter().filter(|x| x.to == e.to).count();
                    assert_eq!(
                        barbs.iter().filter(|b| (**b - tip).length() * ppp <= 0.01).count(),
                        2 * into,
                        "{ctx}: both arrowhead barbs start at the tip"
                    );
                }
            }
        }
    }
    eprintln!("[dot-edge] CPU painter: worst edge-end distance from its dot's rim = {worst:.3} px");
}

/// **The chip look is unchanged**: with `dot_px == None` an edge still leaves the chip's
/// right rim and lands on the next chip's left rim (`centre ± BOX_W/2 · zoom`).
#[test]
fn in_chip_mode_edges_still_run_rim_to_rim_of_the_chips() {
    let m = model();
    for ppp in PPPS {
        for inset in INSETS {
            for zoom in ZOOMS {
                let mut view = GraphView { pan: Vec2::new(37.0, -21.0), zoom, selected: None, dot_px: None };
                let shapes = frame(ppp, inset, |ui| {
                    let _ = draw_graph(ui, &m, &Decorations::default(), &mut view, BG, TEXT, RING, DIM);
                });
                // A chip is the filled rect in the node's fill.
                let chip = |n: &GraphNode| -> Rect {
                    shapes
                        .iter()
                        .find_map(|s| match s {
                            egui::Shape::Rect(r) if r.fill == n.fill => Some(r.rect),
                            _ => None,
                        })
                        .expect("chip rect")
                };
                let at = |id: &str| m.nodes.iter().find(|n| n.id == id).expect("node");
                let cs = curves(&shapes);
                assert_eq!(cs.len(), m.edges.len());
                for (e, &(tail, tip)) in m.edges.iter().zip(&cs) {
                    let (from, to) = (chip(at(&e.from)), chip(at(&e.to)));
                    assert!((from.width() - BOX_W * zoom).abs() < 0.01 && (from.height() - BOX_H * zoom).abs() < 0.01);
                    assert!(
                        (tail - from.right_center()).length() * ppp <= 1.0,
                        "edge {}→{} leaves the chip's right rim (tail {tail:?}, rim {:?})",
                        e.from,
                        e.to,
                        from.right_center()
                    );
                    assert!(
                        (tip - to.left_center()).length() * ppp <= 1.0,
                        "edge {}→{} lands on the chip's left rim (tip {tip:?}, rim {:?})",
                        e.from,
                        e.to,
                        to.left_center()
                    );
                }
            }
        }
    }
}

/// **The GPU-cloud lanes** (per-frame ≤ `RESIDENT_ABOVE` nodes, resident above it): the
/// dots are GPU instances, the rings and the decoration (cut) edges are CPU overlays.
#[cfg(feature = "wgpu")]
mod gpu_cloud {
    use super::*;
    use facett_core::render::gpu::graphcloud::cloud;
    use facett_graphpan::{draw_graph_opts, NodeDecoration, PaintOpts, RESIDENT_ABOVE};
    use std::sync::Mutex;

    /// The resident model is process-global: one scenario at a time.
    static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());

    /// The cloud's paint-callback rects in a frame.
    fn cloud_rects(shapes: &[egui::Shape]) -> Vec<Rect> {
        shapes
            .iter()
            .filter_map(|s| match s {
                egui::Shape::Callback(c) => Some(c.rect),
                _ => None,
            })
            .collect()
    }

    /// The CPU ring overlay of a decorated node in DOT mode: the stroked circle of radius
    /// `DOT + 2` in `RING`. Its centre is the CPU overlay's point for that node.
    fn ring_centres(shapes: &[egui::Shape]) -> Vec<Pos2> {
        shapes
            .iter()
            .filter_map(|s| match s {
                egui::Shape::Circle(c) if c.stroke.color == RING && (c.radius - (DOT + 2.0)).abs() < 1e-3 => {
                    Some(c.center)
                }
                _ => None,
            })
            .collect()
    }

    /// `n` nodes on a grid around the world origin.
    fn grid(n: usize) -> GraphModel {
        let cols = (n as f32).sqrt().ceil() as usize;
        let nodes = (0..n)
            .map(|i| {
                let p = pos2((i % cols) as f32 * 40.0 - 600.0, (i / cols) as f32 * 30.0 - 300.0);
                node(&format!("g{i}"), p.x, p.y, Color32::from_rgb(60, 90, 160))
            })
            .collect();
        GraphModel { nodes, edges: Vec::new() }
    }

    /// For both lanes × two insets × two ppp: the cloud callback covers exactly the rect
    /// the CPU overlay projects against (so a GPU instance at pane-local `(p − rect.min) ·
    /// ppp` lands on the CPU point `p`), and the decoration edge between two ringed dots
    /// starts and ends on those dots.
    #[test]
    fn the_cloud_shares_the_overlay_frame_and_cut_edges_end_on_their_dots() {
        let _one = ONE_AT_A_TIME.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        cloud::assume_renderer_installed_for_tests();
        let mut worst = 0.0_f32;
        for (lane, n) in [("per-frame", 40_usize), ("resident", RESIDENT_ABOVE + 200)] {
            let m = grid(n);
            let (ia, ib) = (3_usize, n - 5);
            let mut deco = Decorations::default();
            for i in [ia, ib] {
                deco.nodes.insert(format!("g{i}"), NodeDecoration { ring: Some(RING), badge: None, badge_color: None });
            }
            deco.edges.push(edge(&format!("g{ia}"), &format!("g{ib}")));
            for ppp in PPPS {
                for inset in INSETS {
                    let zoom = 0.6;
                    let pan = Vec2::new(-52.0, 33.0);
                    let mut view = GraphView { pan, zoom, selected: None, dot_px: Some(DOT) };
                    let mut report = None;
                    let shapes = frame(ppp, inset, |ui| {
                        let (_, r) = draw_graph_opts(
                            ui,
                            &m,
                            &deco,
                            &mut view,
                            BG,
                            TEXT,
                            RING,
                            DIM,
                            PaintOpts::default().with_gpu(true),
                        );
                        report = Some(r);
                    });
                    let report = report.expect("the canvas painted");
                    let ctx = format!("{lane} lane · ppp {ppp} · inset {inset:?}");
                    assert!(report.gpu_cloud, "{ctx}: the cloud drew on the GPU lane");
                    assert_eq!(report.resident, lane == "resident", "{ctx}: the lane under test");

                    // The cloud's frame == the CPU overlay's frame. The CPU projects
                    // `rect.center() + pan + p · zoom`; the lane hands the GPU
                    // `(that − rect.min) · ppp` inside a callback mapped onto `rect`.
                    let rects = cloud_rects(&shapes);
                    assert_eq!(rects.len(), 1, "{ctx}: one cloud callback");
                    let rect = rects[0];
                    let rings = ring_centres(&shapes);
                    assert_eq!(rings.len(), 2, "{ctx}: both decorated dots are ringed");
                    for (i, &ring) in [ia, ib].iter().zip(&rings) {
                        // GPU instance centre in pane-local physical px, mapped back to screen points.
                        let world = m.nodes[*i].pos;
                        let inst_px = ((rect.center() + pan + world.to_vec2() * zoom) - rect.min) * ppp;
                        let on_screen = rect.min + inst_px / ppp;
                        let d = (on_screen - ring).length() * ppp;
                        worst = worst.max(d);
                        assert!(
                            d <= 1.0,
                            "{ctx}: node g{i}'s GPU instance lands {d:.2} px from its CPU ring \
                             (instance {on_screen:?}, ring {ring:?}, cloud rect {rect:?})"
                        );
                    }

                    // The decoration (cut) edge runs from dot rim to dot rim.
                    let cs = curves(&shapes);
                    assert_eq!(cs.len(), 1, "{ctx}: the one decoration edge");
                    let (tail, tip) = cs[0];
                    let (d_tail, d_tip) = (off_rim_px(tail, rings[0], ppp), off_rim_px(tip, rings[1], ppp));
                    worst = worst.max(d_tail).max(d_tip);
                    assert!(
                        d_tail <= 1.0 && d_tip <= 1.0,
                        "{ctx}: the cut edge misses its dots — tail {d_tail:.1} px, tip {d_tip:.1} px off the rim \
                         (tail {tail:?} vs dot {:?}, tip {tip:?} vs dot {:?})",
                        rings[0],
                        rings[1],
                    );
                }
            }
        }
        eprintln!("[dot-edge] GPU-cloud lanes: worst overlay/instance/edge-end error = {worst:.3} px");
    }
}