facett-graph3d/tests/nav_focus.rs
//! **3D 📌 Stay / 🎯 Focus**, rendered: real egui frames, a real click on a node's
//! projected spot, the UI clock at 60 fps.
//!
//! * Focus: the camera target glides to the node — after the flight its projection is
//! the canvas centre (±2 px) — on a continuous path, the view direction (yaw) kept.
//! * Stay: a click never moves the 3D camera.
use egui::{Pos2, Rect, Vec2};
use facett_core::fly::NavMode;
use facett_core::Facet;
use facett_graph3d::{DecorPolicy, Graph3D, Layout3D, Msg};
const SCREEN: Vec2 = Vec2::new(1200.0, 800.0);
const DT: f64 = 1.0 / 60.0;
fn graph(mode: NavMode) -> Graph3D {
let nodes: Vec<(String, egui::Color32)> = (0..5).map(|i| (format!("n{i}"), egui::Color32::from_rgb(90, 160, 255))).collect();
let pos = vec![[0.0, 0.0, 0.0], [0.8, 0.6, -0.3], [0.95, 0.4, 0.1], [-0.7, -0.5, 0.4], [0.6, 0.75, -0.5]];
let mut g = Graph3D::new(nodes, vec![(0, 1), (1, 2), (1, 4), (0, 3)], Layout3D::Given)
.with_positions(pos)
.with_decor(DecorPolicy::Reduced);
let _ = g.update(Msg::SetSpin(false));
let _ = g.update(Msg::SetNavMode(mode));
g
}
struct Rig {
ctx: egui::Context,
t: f64,
canvas: Rect,
}
impl Rig {
fn new() -> Self {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(1.0);
Self { ctx, t: 1.0, canvas: Rect::NOTHING }
}
fn frame(&mut self, g: &mut Graph3D, events: Vec<egui::Event>) {
self.t += DT;
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(self.t), events, ..Default::default() };
let mut canvas = Rect::NOTHING;
let mut out = self.ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| {
canvas = ui.available_rect_before_wrap();
g.ui(ui);
});
});
out.textures_delta.clear();
self.canvas = canvas;
}
fn click(&mut self, g: &mut Graph3D, p: Pos2) {
self.frame(g, vec![egui::Event::PointerMoved(p)]);
let b = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
self.frame(g, vec![b(true)]);
self.frame(g, vec![b(false)]);
}
fn land(&mut self, g: &mut Graph3D) -> usize {
let mut frames = 0;
for _ in 0..120 {
self.frame(g, Vec::new());
frames += 1;
if !g.flying() {
break;
}
}
frames
}
fn screen_of(&self, g: &Graph3D, i: usize) -> Pos2 {
let scale = self.canvas.size().min_elem() * 0.40;
g.project_all(self.canvas.center(), scale, g.render_yaw())[i].0
}
}
/// 🎯 Focus: node 1 glides to the centre, the yaw is kept, the path is continuous.
#[test]
fn focus_flies_the_clicked_node_to_the_centre() {
let mut rig = Rig::new();
let mut g = graph(NavMode::Focus);
rig.frame(&mut g, Vec::new());
let yaw = g.render_yaw();
let start = rig.screen_of(&g, 1);
rig.click(&mut g, start);
assert!(g.flying(), "the click started a flight");
let mut path = Vec::new();
for _ in 0..120 {
rig.frame(&mut g, Vec::new());
path.push(rig.screen_of(&g, 1));
if !g.flying() {
break;
}
}
assert!(path.len() >= 20, "a flight, not a jump: {} frames", path.len());
let end = rig.screen_of(&g, 1);
let c = rig.canvas.center();
assert!((end - c).length() <= 2.0, "node 1 lands at the canvas centre: {end:?} vs {c:?}");
assert!((g.render_yaw() - yaw).abs() < 1e-6, "the view direction is kept (no spin)");
assert!(g.state().zoom > 1.0, "zoomed in to frame it and its neighbours: {}", g.state().zoom);
let total = (c - start).length();
let mut prev = start;
for p in &path {
assert!((*p - prev).length() <= 0.25 * total.max(1.0), "a frame jumped {} of {total}", (*p - prev).length());
prev = *p;
}
// the neighbours stay on the canvas
for j in [0, 2, 4] {
assert!(rig.canvas.contains(rig.screen_of(&g, j)), "neighbour {j} framed");
}
}
/// 📌 Stay: the click selects, the 3D camera does not move.
#[test]
fn stay_a_click_never_moves_the_3d_camera() {
let mut rig = Rig::new();
let mut g = graph(NavMode::Stay);
rig.frame(&mut g, Vec::new());
let start = rig.screen_of(&g, 1);
let cam = (g.state().focus, g.state().zoom, g.render_yaw());
rig.click(&mut g, start);
rig.land(&mut g);
assert_eq!(g.state().selected, Some(1), "the click selected node 1");
assert_eq!((g.state().focus, g.state().zoom, g.render_yaw()), cam, "the camera did not move");
assert_eq!(rig.screen_of(&g, 1), start);
}
/// ⊙ fit flies back to the whole graph, and the remembered mode reaches a new pane.
#[test]
fn fit_flies_back_and_the_choice_is_remembered() {
let mut rig = Rig::new();
let mut g = graph(NavMode::Focus);
rig.frame(&mut g, Vec::new());
let p = rig.screen_of(&g, 2);
rig.click(&mut g, p);
rig.land(&mut g);
let whole = g.frame_camera(rig.canvas.width(), rig.canvas.height());
assert_ne!(g.state().zoom, whole.1);
let _ = g.update(Msg::FitFly(rig.t));
rig.land(&mut g);
assert_eq!((g.state().focus, g.state().zoom), whole, "back to the whole graph");
NavMode::Focus.remember(&rig.ctx);
let nodes: Vec<(String, egui::Color32)> = (0..3).map(|i| (format!("m{i}"), egui::Color32::WHITE)).collect();
let mut h = Graph3D::new(nodes, vec![(0, 1)], Layout3D::Sphere);
rig.frame(&mut h, Vec::new());
assert_eq!(h.state().nav_mode, NavMode::Focus, "a new 3D pane starts in the viewer's mode");
}
/// **The wheel zooms, toward the cursor** (the 🏛 3D room: "I can't zoom"): a real
/// mouse-wheel event over node 1 zooms in and node 1 stays under the cursor; ± on the
/// keyboard zooms too; and the range reaches a close-up of one node. RED before: the
/// pane had no zoom at all — the wheel did nothing.
#[test]
fn the_wheel_zooms_toward_the_cursor_and_reaches_a_close_up() {
let mut rig = Rig::new();
let mut g = graph(NavMode::Stay);
rig.frame(&mut g, Vec::new());
let p = rig.screen_of(&g, 1);
let z0 = g.state().zoom;
let wheel = |dy: f32| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: Vec2::new(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Default::default() };
rig.frame(&mut g, vec![egui::Event::PointerMoved(p)]);
rig.frame(&mut g, vec![wheel(120.0)]);
for _ in 0..30 {
rig.frame(&mut g, Vec::new());
}
let z1 = g.state().zoom;
assert!(z1 > z0 * 1.1, "the wheel zoomed in: {z0} → {z1}");
let q = rig.screen_of(&g, 1);
assert!((q - p).length() <= 1.0, "the node under the cursor stayed under it: {p:?} → {q:?}");
rig.frame(&mut g, vec![egui::Event::Key { key: egui::Key::Minus, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
rig.frame(&mut g, Vec::new());
assert!(g.state().zoom < z1, "− zooms out: {}", g.state().zoom);
for _ in 0..40 {
rig.frame(&mut g, vec![wheel(400.0)]);
}
for _ in 0..30 {
rig.frame(&mut g, Vec::new());
}
assert!(g.state().zoom >= 50.0, "a close-up of one node is reachable: zoom {}", g.state().zoom);
}
/// **A host-placed cloud opens fitted and centred** — positions far from the origin
/// (x in 3..5), in a canvas inside an over-wide layout: after the first frames the
/// projected box of the nodes is centred (±2 px) in the visible canvas and wholly inside
/// it. RED before: the camera looked at world (0, 0, 0), the cloud sat off to the side
/// (and past the window), at a zoom made for `[-1, 1]³`.
#[test]
fn a_host_placed_cloud_opens_centred_in_the_visible_canvas() {
let nodes: Vec<(String, egui::Color32)> = (0..6).map(|i| (format!("p{i}"), egui::Color32::WHITE)).collect();
let pos = vec![[3.0, 0.2, 0.0], [5.0, 0.9, 0.1], [4.2, -0.6, 0.3], [3.6, 1.4, -0.2], [4.8, -1.1, 0.0], [3.3, 0.0, 0.4]];
let mut g = Graph3D::new(nodes, vec![(0, 1), (1, 2)], Layout3D::Given).with_positions(pos);
let _ = g.update(Msg::SetSpin(false));
let ctx = egui::Context::default();
ctx.set_pixels_per_point(1.0);
let mut canvas = Rect::NOTHING;
for i in 0..4 {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(1.0 + i as f64 / 60.0), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| {
let wide = Rect::from_min_size(ui.max_rect().min, Vec2::new(6000.0, ui.max_rect().height()));
ui.scope_builder(egui::UiBuilder::new().max_rect(wide), |ui| {
canvas = Rect::from_min_size(ui.cursor().min, facett_core::nav::canvas_size(ui));
g.ui(ui);
});
});
});
out.textures_delta.clear();
}
let seen = canvas.intersect(Rect::from_min_size(Pos2::ZERO, SCREEN));
let scale = canvas.size().min_elem() * 0.40;
let pts: Vec<Pos2> = g.project_all(canvas.center(), scale, g.render_yaw()).into_iter().map(|(p, _)| p).collect();
let (min, max) = pts.iter().fold((Pos2::new(f32::MAX, f32::MAX), Pos2::new(f32::MIN, f32::MIN)), |(a, b), p| (a.min(*p), b.max(*p)));
let b = Rect::from_min_max(min, max);
assert!(canvas.width() <= SCREEN.x, "the canvas is the visible width: {canvas:?}");
assert!((b.center() - seen.center()).length() <= 2.0, "centred: box {b:?} in {seen:?}");
assert!(seen.contains_rect(b), "wholly visible: box {b:?} in {seen:?}");
}