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