diff --git a/Cargo.lock b/Cargo.lock index 8fe39fed0966..1ec696388972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6231,11 +6231,11 @@ name = "re_space_view_graph" version = "0.21.0-alpha.1+dev" dependencies = [ "ahash", - "bytemuck", "egui", "fjadra", "nohash-hasher", "re_chunk", + "re_entity_db", "re_format", "re_log_types", "re_query", diff --git a/crates/viewer/re_space_view_graph/Cargo.toml b/crates/viewer/re_space_view_graph/Cargo.toml index d049ad1e1285..107ebf1da830 100644 --- a/crates/viewer/re_space_view_graph/Cargo.toml +++ b/crates/viewer/re_space_view_graph/Cargo.toml @@ -20,6 +20,7 @@ all-features = true [dependencies] re_chunk.workspace = true +re_entity_db.workspace = true re_format.workspace = true re_log_types.workspace = true re_query.workspace = true @@ -32,7 +33,6 @@ re_viewer_context.workspace = true re_viewport_blueprint.workspace = true ahash.workspace = true -bytemuck.workspace = true egui.workspace = true fjadra.workspace = true nohash-hasher.workspace = true diff --git a/crates/viewer/re_space_view_graph/src/graph/index.rs b/crates/viewer/re_space_view_graph/src/graph/index.rs index 10db2dcf7d0d..257c7ca389a9 100644 --- a/crates/viewer/re_space_view_graph/src/graph/index.rs +++ b/crates/viewer/re_space_view_graph/src/graph/index.rs @@ -3,25 +3,24 @@ use re_types::components; use super::GraphNodeHash; -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct NodeIndex { +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct NodeId { pub entity_hash: EntityPathHash, pub node_hash: GraphNodeHash, } -impl nohash_hasher::IsEnabled for NodeIndex {} +impl nohash_hasher::IsEnabled for NodeId {} // We implement `Hash` manually, because `nohash_hasher` requires a single call to `state.write_*`. // More info: https://crates.io/crates/nohash-hasher -impl std::hash::Hash for NodeIndex { +impl std::hash::Hash for NodeId { fn hash(&self, state: &mut H) { - // TODO(grtlr): Consider using `write_usize` here, to further decrease the risk of collision. - let combined = self.entity_hash.hash64() << 32 | self.node_hash.hash64(); + let combined = self.entity_hash.hash64() ^ self.node_hash.hash64(); state.write_u64(combined); } } -impl NodeIndex { +impl NodeId { pub fn from_entity_node(entity_path: &EntityPath, node: &components::GraphNode) -> Self { Self { entity_hash: entity_path.hash(), @@ -30,7 +29,7 @@ impl NodeIndex { } } -impl std::fmt::Debug for NodeIndex { +impl std::fmt::Debug for NodeId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "NodeIndex({:?}@{:?})", self.node_hash, self.entity_hash) } diff --git a/crates/viewer/re_space_view_graph/src/graph/mod.rs b/crates/viewer/re_space_view_graph/src/graph/mod.rs index faf9a80f0d97..947216daf86c 100644 --- a/crates/viewer/re_space_view_graph/src/graph/mod.rs +++ b/crates/viewer/re_space_view_graph/src/graph/mod.rs @@ -1,87 +1,164 @@ mod hash; + +use egui::{Pos2, Vec2}; pub(crate) use hash::GraphNodeHash; mod index; -pub(crate) use index::NodeIndex; +pub(crate) use index::NodeId; + +use re_chunk::EntityPath; +use re_types::components::{self, GraphType}; -use re_types::components::{GraphNode, GraphType}; +use crate::{ + ui::DrawableLabel, + visualizers::{EdgeData, NodeData, NodeInstance}, +}; -use crate::visualizers::{EdgeData, EdgeInstance, NodeData, NodeInstance}; +/// Describes the different kind of nodes that we can have in a graph. +pub enum Node { + /// An explicit node is a node that was provided via [`re_types::archetypes::GraphNodes`]. + /// + /// It therefore has an instance, as well as all properties that can be added via that archetype. + Explicit { + instance: NodeInstance, + label: DrawableLabel, + }, -pub struct NodeInstanceImplicit { - pub node: GraphNode, - pub index: NodeIndex, + /// An implicit node is a node that was provided via [`re_types::archetypes::GraphEdges`], but does not have a corresponding [`re_types::components::GraphNode`] in an [`re_types::archetypes::GraphNodes`] archetype. + /// + /// Because it was never specified directly, it also does not have many of the properties that an [`Node::Explicit`] has. + Implicit { + id: NodeId, + graph_node: components::GraphNode, + label: DrawableLabel, + }, } -impl std::hash::Hash for NodeInstanceImplicit { - fn hash(&self, state: &mut H) { - self.index.hash(state); +impl Node { + pub fn id(&self) -> NodeId { + match self { + Self::Explicit { instance, .. } => instance.id, + Self::Implicit { id, .. } => *id, + } + } + + /// The original [`components::GraphNode`] id that was logged by the user. + pub fn graph_node(&self) -> &components::GraphNode { + match self { + Self::Explicit { instance, .. } => &instance.graph_node, + Self::Implicit { graph_node, .. } => graph_node, + } + } + + pub fn label(&self) -> &DrawableLabel { + match self { + Self::Explicit { label, .. } | Self::Implicit { label, .. } => label, + } + } + + pub fn size(&self) -> Vec2 { + self.label().size() + } + + pub fn position(&self) -> Option { + match self { + Self::Explicit { + instance: NodeInstance { position, .. }, + .. + } => *position, + Self::Implicit { .. } => None, + } } } -#[derive(Hash)] -pub struct Graph<'a> { - explicit: &'a [NodeInstance], - implicit: Vec, - edges: &'a [EdgeInstance], +pub struct Edge { + pub from: NodeId, + pub to: NodeId, + pub arrow: bool, +} + +pub struct Graph { + entity: EntityPath, + nodes: Vec, + edges: Vec, kind: GraphType, } -impl<'a> Graph<'a> { - pub fn new(node_data: Option<&'a NodeData>, edge_data: Option<&'a EdgeData>) -> Self { +impl Graph { + pub fn new<'a>( + ui: &egui::Ui, + entity: EntityPath, + node_data: Option<&'a NodeData>, + edge_data: Option<&'a EdgeData>, + ) -> Self { // We keep track of the nodes to find implicit nodes. let mut seen = ahash::HashSet::default(); - let explicit = if let Some(data) = node_data { - seen.extend(data.nodes.iter().map(|n| n.index)); - data.nodes.as_slice() + let mut nodes: Vec = if let Some(data) = node_data { + seen.extend(data.nodes.iter().map(|n| n.id)); + // TODO(grtlr): We should see if we can get rid of some of the cloning here. + data.nodes + .iter() + .map(|n| Node::Explicit { + instance: n.clone(), + label: DrawableLabel::from_label(ui, &n.label), + }) + .collect() } else { - &[][..] + Vec::new() }; - let (edges, implicit, kind) = if let Some(data) = edge_data { - let mut implicit = Vec::new(); + let (edges, kind) = if let Some(data) = edge_data { for edge in &data.edges { if !seen.contains(&edge.source_index) { - implicit.push(NodeInstanceImplicit { - node: edge.source.clone(), - index: edge.source_index, + nodes.push(Node::Implicit { + id: edge.source_index, + graph_node: edge.source.clone(), + label: DrawableLabel::implicit_circle(), }); seen.insert(edge.source_index); } if !seen.contains(&edge.target_index) { - implicit.push(NodeInstanceImplicit { - node: edge.target.clone(), - index: edge.target_index, + nodes.push(Node::Implicit { + id: edge.target_index, + graph_node: edge.target.clone(), + label: DrawableLabel::implicit_circle(), }); seen.insert(edge.target_index); } } - (data.edges.as_slice(), implicit, Some(data.graph_type)) + + let es = data.edges.iter().map(|e| Edge { + from: e.source_index, + to: e.target_index, + arrow: data.graph_type == GraphType::Directed, + }); + + (es.collect(), data.graph_type) } else { - (&[][..], Vec::new(), None) + (Vec::new(), GraphType::default()) }; Self { - explicit, - implicit, + entity, + nodes, edges, - kind: kind.unwrap_or_default(), + kind, } } - pub fn nodes_explicit(&self) -> impl Iterator { - self.explicit.iter() - } - - pub fn nodes_implicit(&self) -> impl Iterator + '_ { - self.implicit.iter() + pub fn nodes(&self) -> &[Node] { + &self.nodes } - pub fn edges(&self) -> impl Iterator { - self.edges.iter() + pub fn edges(&self) -> &[Edge] { + &self.edges } pub fn kind(&self) -> GraphType { self.kind } + + pub fn entity(&self) -> &EntityPath { + &self.entity + } } diff --git a/crates/viewer/re_space_view_graph/src/layout/mod.rs b/crates/viewer/re_space_view_graph/src/layout/mod.rs index 0b4a994c329c..8c280d81c782 100644 --- a/crates/viewer/re_space_view_graph/src/layout/mod.rs +++ b/crates/viewer/re_space_view_graph/src/layout/mod.rs @@ -1,129 +1,7 @@ -use egui::{Pos2, Rect, Vec2}; -use fjadra as fj; +mod provider; +mod request; +mod result; -use crate::{ - graph::{Graph, NodeIndex}, - ui::bounding_rect_from_iter, - visualizers::NodeInstance, -}; - -#[derive(Debug, PartialEq, Eq)] -pub struct Layout { - extents: ahash::HashMap, -} - -impl Layout { - pub fn bounding_rect(&self) -> Rect { - bounding_rect_from_iter(self.extents.values().copied()) - } - - /// Gets the position and size of a node in the layout. - pub fn get(&self, node: &NodeIndex) -> Option { - self.extents.get(node).copied() - } - - /// Updates the size and position of a node, for example after size changes. - /// Returns `true` if the node changed its size. - pub fn update(&mut self, node: &NodeIndex, rect: Rect) -> bool { - debug_assert!( - self.extents.contains_key(node), - "node should exist in the layout" - ); - if let Some(extent) = self.extents.get_mut(node) { - let size_changed = (extent.size() - rect.size()).length_sq() > 0.01; - *extent = rect; - return size_changed; - } - false - } -} - -impl<'a> From<&'a NodeInstance> for fj::Node { - fn from(instance: &'a NodeInstance) -> Self { - let mut node = Self::default(); - if let Some(pos) = instance.position { - node = node.fixed_position(pos.x as f64, pos.y as f64); - } - node - } -} - -pub struct ForceLayout { - simulation: fj::Simulation, - node_index: ahash::HashMap, -} - -impl ForceLayout { - pub fn new<'a>(graphs: impl Iterator> + Clone) -> Self { - let explicit = graphs - .clone() - .flat_map(|g| g.nodes_explicit().map(|n| (n.index, fj::Node::from(n)))); - let implicit = graphs - .clone() - .flat_map(|g| g.nodes_implicit().map(|n| (n.index, fj::Node::default()))); - - let mut node_index = ahash::HashMap::default(); - let all_nodes: Vec = explicit - .chain(implicit) - .enumerate() - .map(|(i, n)| { - node_index.insert(n.0, i); - n.1 - }) - .collect(); - - let all_edges = graphs.flat_map(|g| { - g.edges() - .map(|e| (node_index[&e.source_index], node_index[&e.target_index])) - }); - - // TODO(grtlr): Currently we guesstimate good forces. Eventually these should be exposed as blueprints. - let simulation = fj::SimulationBuilder::default() - .with_alpha_decay(0.01) // TODO(grtlr): slows down the simulation for demo - .build(all_nodes) - .add_force( - "link", - fj::Link::new(all_edges).distance(50.0).iterations(2), - ) - .add_force("charge", fj::ManyBody::new()) - // TODO(grtlr): This is a small stop-gap until we have blueprints to prevent nodes from flying away. - .add_force("x", fj::PositionX::new().strength(0.01)) - .add_force("y", fj::PositionY::new().strength(0.01)); - - Self { - simulation, - node_index, - } - } - - pub fn init(&self) -> Layout { - let positions = self.simulation.positions().collect::>(); - let mut extents = ahash::HashMap::default(); - - for (node, i) in &self.node_index { - let [x, y] = positions[*i]; - let pos = Pos2::new(x as f32, y as f32); - let size = Vec2::ZERO; - let rect = Rect::from_min_size(pos, size); - extents.insert(*node, rect); - } - - Layout { extents } - } - - /// Returns `true` if finished. - pub fn tick(&mut self, layout: &mut Layout) -> bool { - self.simulation.tick(1); - - let positions = self.simulation.positions().collect::>(); - - for (node, extent) in &mut layout.extents { - let i = self.node_index[node]; - let [x, y] = positions[i]; - let pos = Pos2::new(x as f32, y as f32); - extent.set_center(pos); - } - - self.simulation.finished() - } -} +pub use provider::ForceLayoutProvider; +pub use request::LayoutRequest; +pub use result::Layout; diff --git a/crates/viewer/re_space_view_graph/src/layout/provider.rs b/crates/viewer/re_space_view_graph/src/layout/provider.rs new file mode 100644 index 000000000000..d3f845f45457 --- /dev/null +++ b/crates/viewer/re_space_view_graph/src/layout/provider.rs @@ -0,0 +1,208 @@ +use egui::{Pos2, Rect, Vec2}; +use fjadra as fj; + +use crate::graph::NodeId; + +use super::{request::NodeTemplate, Layout, LayoutRequest}; + +impl<'a> From<&'a NodeTemplate> for fj::Node { + fn from(node: &'a NodeTemplate) -> Self { + match node.fixed_position { + Some(pos) => Self::default().fixed_position(pos.x as f64, pos.y as f64), + _ => Self::default(), + } + } +} + +pub struct ForceLayoutProvider { + simulation: fj::Simulation, + node_index: ahash::HashMap, + edges: Vec<(NodeId, NodeId)>, +} + +impl ForceLayoutProvider { + pub fn new(request: &LayoutRequest) -> Self { + let nodes = request.graphs.iter().flat_map(|(_, graph_template)| { + graph_template + .nodes + .iter() + .map(|n| (n.0, fj::Node::from(n.1))) + }); + + let mut node_index = ahash::HashMap::default(); + let all_nodes: Vec = nodes + .enumerate() + .map(|(i, n)| { + node_index.insert(*n.0, i); + n.1 + }) + .collect(); + + let all_edges_iter = request + .graphs + .iter() + .flat_map(|(_, graph_template)| graph_template.edges.iter()); + + let all_edges = all_edges_iter + .clone() + .map(|(a, b)| (node_index[a], node_index[b])); + + // TODO(grtlr): Currently we guesstimate good forces. Eventually these should be exposed as blueprints. + let simulation = fj::SimulationBuilder::default() + .with_alpha_decay(0.01) // TODO(grtlr): slows down the simulation for demo + .build(all_nodes) + .add_force( + "link", + fj::Link::new(all_edges).distance(50.0).iterations(2), + ) + .add_force("charge", fj::ManyBody::new()) + // TODO(grtlr): This is a small stop-gap until we have blueprints to prevent nodes from flying away. + .add_force("x", fj::PositionX::new().strength(0.01)) + .add_force("y", fj::PositionY::new().strength(0.01)); + + Self { + simulation, + node_index, + edges: all_edges_iter.copied().collect(), + } + } + + pub fn init(&self, request: &LayoutRequest) -> Layout { + let positions = self.simulation.positions().collect::>(); + let mut extents = ahash::HashMap::default(); + + for graph in request.graphs.values() { + for (id, node) in &graph.nodes { + let i = self.node_index[id]; + let [x, y] = positions[i]; + let pos = Pos2::new(x as f32, y as f32); + extents.insert(*id, Rect::from_center_size(pos, node.size)); + } + } + + Layout { + nodes: extents, + // Without any real node positions, we probably don't want to draw edges either. + edges: ahash::HashMap::default(), + } + } + + /// Returns `true` if finished. + pub fn tick(&mut self, layout: &mut Layout) -> bool { + self.simulation.tick(1); + + let positions = self.simulation.positions().collect::>(); + + for (node, extent) in &mut layout.nodes { + let i = self.node_index[node]; + let [x, y] = positions[i]; + let pos = Pos2::new(x as f32, y as f32); + extent.set_center(pos); + } + + for (from, to) in &self.edges { + layout.edges.insert( + (*from, *to), + line_segment(layout.nodes[from], layout.nodes[to]), + ); + } + + self.simulation.finished() + } +} + +/// Helper function to calculate the line segment between two rectangles. +fn line_segment(source: Rect, target: Rect) -> [Pos2; 2] { + let source_center = source.center(); + let target_center = target.center(); + + // Calculate direction vector from source to target + let direction = (target_center - source_center).normalized(); + + // Find the border points on both rectangles + let source_point = intersects_ray_from_center(source, direction); + let target_point = intersects_ray_from_center(target, -direction); // Reverse direction for target + + [source_point, target_point] +} + +/// Helper function to find the point where the line intersects the border of a rectangle +fn intersects_ray_from_center(rect: Rect, direction: Vec2) -> Pos2 { + let mut tmin = f32::NEG_INFINITY; + let mut tmax = f32::INFINITY; + + for i in 0..2 { + let inv_d = 1.0 / -direction[i]; + let mut t0 = (rect.min[i] - rect.center()[i]) * inv_d; + let mut t1 = (rect.max[i] - rect.center()[i]) * inv_d; + + if inv_d < 0.0 { + std::mem::swap(&mut t0, &mut t1); + } + + tmin = tmin.max(t0); + tmax = tmax.min(t1); + } + + let t = tmax.min(tmin); // Pick the first intersection + rect.center() + t * -direction +} + +#[cfg(test)] +mod test { + use super::*; + use egui::pos2; + + #[test] + fn test_ray_intersection() { + let rect = Rect::from_min_max(pos2(1.0, 1.0), pos2(3.0, 3.0)); + + assert_eq!( + intersects_ray_from_center(rect, Vec2::RIGHT), + pos2(3.0, 2.0), + "rightward ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, Vec2::UP), + pos2(2.0, 1.0), + "upward ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, Vec2::LEFT), + pos2(1.0, 2.0), + "leftward ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, Vec2::DOWN), + pos2(2.0, 3.0), + "downward ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, (Vec2::LEFT + Vec2::DOWN).normalized()), + pos2(1.0, 3.0), + "bottom-left corner ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, (Vec2::LEFT + Vec2::UP).normalized()), + pos2(1.0, 1.0), + "top-left corner ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, (Vec2::RIGHT + Vec2::DOWN).normalized()), + pos2(3.0, 3.0), + "bottom-right corner ray" + ); + + assert_eq!( + intersects_ray_from_center(rect, (Vec2::RIGHT + Vec2::UP).normalized()), + pos2(3.0, 1.0), + "top-right corner ray" + ); + } +} diff --git a/crates/viewer/re_space_view_graph/src/layout/request.rs b/crates/viewer/re_space_view_graph/src/layout/request.rs new file mode 100644 index 000000000000..be17a02e4395 --- /dev/null +++ b/crates/viewer/re_space_view_graph/src/layout/request.rs @@ -0,0 +1,52 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use egui::{Pos2, Vec2}; +use re_chunk::EntityPath; + +use crate::graph::{Graph, NodeId}; + +#[derive(PartialEq)] +pub(super) struct NodeTemplate { + pub(super) size: Vec2, + pub(super) fixed_position: Option, +} + +#[derive(Default, PartialEq)] +pub(super) struct GraphTemplate { + pub(super) nodes: BTreeMap, + pub(super) edges: BTreeSet<(NodeId, NodeId)>, +} + +/// A [`LayoutRequest`] encapsulates all the information that is considered when computing a layout. +/// +/// It implements [`PartialEq`] to check if a layout is up-to-date, or if it needs to be recomputed. +#[derive(PartialEq)] +pub struct LayoutRequest { + pub(super) graphs: BTreeMap, +} + +impl LayoutRequest { + pub fn from_graphs<'a>(graphs: impl IntoIterator) -> Self { + let mut request = Self { + graphs: BTreeMap::new(), + }; + + for graph in graphs { + let entity = request.graphs.entry(graph.entity().clone()).or_default(); + + for node in graph.nodes() { + let shape = NodeTemplate { + size: node.size(), + fixed_position: node.position(), + }; + entity.nodes.insert(node.id(), shape); + } + + for edge in graph.edges() { + entity.edges.insert((edge.from, edge.to)); + } + } + + request + } +} diff --git a/crates/viewer/re_space_view_graph/src/layout/result.rs b/crates/viewer/re_space_view_graph/src/layout/result.rs new file mode 100644 index 000000000000..78734514ee13 --- /dev/null +++ b/crates/viewer/re_space_view_graph/src/layout/result.rs @@ -0,0 +1,35 @@ +use egui::{Pos2, Rect}; + +use crate::graph::NodeId; + +pub type LineSegment = [Pos2; 2]; + +#[derive(Debug, PartialEq, Eq)] +pub struct Layout { + pub(super) nodes: ahash::HashMap, + pub(super) edges: ahash::HashMap<(NodeId, NodeId), LineSegment>, + // TODO(grtlr): Consider adding the entity rects here too. +} + +fn bounding_rect_from_iter(rectangles: impl Iterator) -> egui::Rect { + rectangles.fold(egui::Rect::NOTHING, |acc, rect| acc.union(rect)) +} + +impl Layout { + /// Returns the bounding rectangle of the layout. + pub fn bounding_rect(&self) -> Rect { + // TODO(grtlr): We mostly use this for debugging, but we should probably + // take all elements of the layout into account, once we have entity rects too. + bounding_rect_from_iter(self.nodes.values().copied()) + } + + /// Gets the final position and size of a node in the layout. + pub fn get_node(&self, node: &NodeId) -> Option { + self.nodes.get(node).copied() + } + + /// Gets the shape of an edge in the final layout. + pub fn get_edge(&self, from: NodeId, to: NodeId) -> Option { + self.edges.get(&(from, to)).copied() + } +} diff --git a/crates/viewer/re_space_view_graph/src/ui/draw.rs b/crates/viewer/re_space_view_graph/src/ui/draw.rs new file mode 100644 index 000000000000..ff094f1c0045 --- /dev/null +++ b/crates/viewer/re_space_view_graph/src/ui/draw.rs @@ -0,0 +1,312 @@ +use std::sync::Arc; + +use egui::{ + Align2, Color32, FontId, FontSelection, Frame, Galley, Painter, Pos2, Rect, Response, RichText, + Sense, Shape, Stroke, TextWrapMode, Ui, UiBuilder, Vec2, WidgetText, +}; +use re_chunk::EntityPath; +use re_entity_db::InstancePath; +use re_types::ArrowString; +use re_viewer_context::{ + HoverHighlight, InteractionHighlight, Item, SelectionHighlight, SpaceViewHighlights, ViewQuery, + ViewerContext, +}; + +use crate::{ + graph::{Graph, Node}, + layout::Layout, + visualizers::Label, +}; + +// Sorry for the pun, could not resist 😎. +// On a serious note, is there no other way to create a `Sense` that does nothing? +const NON_SENSE: Sense = Sense { + click: false, + drag: false, + focusable: false, +}; + +pub enum DrawableLabel { + Circle(CircleLabel), + Text(TextLabel), +} + +impl DrawableLabel { + pub fn from_label(ui: &Ui, label: &Label) -> Self { + match label { + &Label::Circle { radius, color } => Self::circle(radius, color), + Label::Text { text, color } => Self::text(ui, text, *color), + } + } +} + +pub struct TextLabel { + frame: Frame, + galley: Arc, +} + +pub struct CircleLabel { + radius: f32, + color: Option, +} + +impl DrawableLabel { + pub fn size(&self) -> Vec2 { + match self { + Self::Circle(CircleLabel { radius, .. }) => Vec2::splat(radius * 2.0), + Self::Text(TextLabel { galley, frame }) => { + frame.inner_margin.sum() + galley.size() + Vec2::splat(frame.stroke.width * 2.0) + } + } + } + + pub fn circle(radius: f32, color: Option) -> Self { + Self::Circle(CircleLabel { radius, color }) + } + + pub fn implicit_circle() -> Self { + Self::Circle(CircleLabel { + radius: 4.0, + color: None, + }) + } + + pub fn text(ui: &Ui, text: &ArrowString, color: Option) -> Self { + let galley = WidgetText::from( + RichText::new(text.to_string()) + .color(color.unwrap_or_else(|| ui.style().visuals.text_color())), + ) + .into_galley( + ui, + Some(TextWrapMode::Extend), + f32::INFINITY, + FontSelection::Default, + ); + + let frame = Frame::default() + .inner_margin(Vec2::new(6.0, 4.0)) + .fill(ui.style().visuals.widgets.noninteractive.bg_fill) + .stroke(Stroke::new(1.0, ui.style().visuals.text_color())); + + Self::Text(TextLabel { frame, galley }) + } +} + +fn draw_circle_label( + ui: &mut Ui, + label: &CircleLabel, + _highlight: InteractionHighlight, +) -> Response { + let &CircleLabel { radius, color } = label; + let (resp, painter) = ui.allocate_painter(Vec2::splat(radius * 2.0), Sense::click()); + painter.circle( + resp.rect.center(), + radius, + color.unwrap_or_else(|| ui.style().visuals.text_color()), + Stroke::NONE, + ); + resp +} + +fn draw_text_label(ui: &mut Ui, label: &TextLabel, highlight: InteractionHighlight) -> Response { + let TextLabel { galley, frame } = label; + let visuals = &ui.style().visuals; + + let bg = match highlight.hover { + HoverHighlight::None => visuals.widgets.noninteractive.bg_fill, + HoverHighlight::Hovered => visuals.widgets.hovered.bg_fill, + }; + + let stroke = match highlight.selection { + SelectionHighlight::Selection => visuals.selection.stroke, + _ => Stroke::new(1.0, visuals.text_color()), + }; + + frame + .stroke(stroke) + .fill(bg) + .show(ui, |ui| { + ui.add( + egui::Label::new(galley.clone()) + .selectable(false) + .sense(Sense::click()), + ) + }) + .inner +} + +/// Draws a node at the given position. +fn draw_node( + ui: &mut Ui, + center: Pos2, + node: &DrawableLabel, + highlight: InteractionHighlight, +) -> Response { + let builder = UiBuilder::new().max_rect(Rect::from_center_size(center, node.size())); + let mut node_ui = ui.new_child(builder); + + // TODO(grtlr): handle highlights + + match node { + DrawableLabel::Circle(label) => draw_circle_label(&mut node_ui, label, highlight), + DrawableLabel::Text(label) => draw_text_label(&mut node_ui, label, highlight), + } +} + +/// Draws a bounding box, as well as a basic coordinate system. +pub fn draw_debug(ui: &Ui, world_bounding_rect: Rect) { + let painter = ui.painter(); + + // Paint coordinate system at the world origin + let origin = Pos2::new(0.0, 0.0); + let x_axis = Pos2::new(100.0, 0.0); + let y_axis = Pos2::new(0.0, 100.0); + + painter.line_segment([origin, x_axis], Stroke::new(1.0, Color32::RED)); + painter.line_segment([origin, y_axis], Stroke::new(1.0, Color32::GREEN)); + + if world_bounding_rect.is_positive() { + painter.rect( + world_bounding_rect, + 0.0, + Color32::from_rgba_unmultiplied(255, 0, 255, 8), + Stroke::new(1.0, Color32::from_rgb(255, 0, 255)), + ); + } +} + +/// Helper function to draw an arrow at the end of the edge +fn draw_arrow(painter: &Painter, tip: Pos2, direction: Vec2, color: Color32) { + let arrow_size = 10.0; // Adjust size as needed + let perpendicular = Vec2::new(-direction.y, direction.x) * 0.5 * arrow_size; + + let p1 = tip - direction * arrow_size + perpendicular; + let p2 = tip - direction * arrow_size - perpendicular; + + // Draw a filled triangle for the arrow + painter.add(Shape::convex_polygon( + vec![tip, p1, p2], + color, + Stroke::NONE, + )); +} + +/// Draws an edge between two points, optionally with an arrow at the target point. +fn draw_edge(ui: &mut Ui, points: [Pos2; 2], show_arrow: bool) -> Response { + let fg = ui.style().visuals.text_color(); + + let rect = Rect::from_points(&points); + let painter = ui.painter(); + painter.line_segment(points, Stroke::new(1.0, fg)); + + // Calculate direction vector from source to target + let direction = (points[1] - points[0]).normalized(); + + // Conditionally draw an arrow at the target point + if show_arrow { + draw_arrow(painter, points[1], direction, fg); + } + + // We can add interactions in the future, for now we simply allocate the + // rect, so that bounding boxes are computed correctly. + ui.allocate_rect(rect, NON_SENSE) +} + +pub fn draw_entity_rect( + ui: &mut Ui, + rect: Rect, + entity_path: &EntityPath, + highlights: &SpaceViewHighlights, +) -> Response { + let color = if highlights + .entity_outline_mask(entity_path.hash()) + .overall + .is_some() + { + ui.ctx().style().visuals.text_color() + } else { + ui.ctx() + .style() + .visuals + .gray_out(ui.ctx().style().visuals.text_color()) + }; + + let padded = rect.expand(10.0); + + ui.painter() + .rect(padded, 0.0, Color32::TRANSPARENT, Stroke::new(1.0, color)); + + ui.painter().text( + padded.left_top(), + Align2::LEFT_BOTTOM, + entity_path.to_string(), + FontId { + size: 12.0, + family: Default::default(), + }, + color, + ); + + ui.allocate_rect(rect, Sense::click()) +} + +/// Draws the graph using the layout. +#[must_use] +pub fn draw_graph( + ui: &mut Ui, + ctx: &ViewerContext<'_>, + graph: &Graph, + layout: &Layout, + query: &ViewQuery<'_>, +) -> Rect { + let entity_path = graph.entity(); + let entity_highlights = query.highlights.entity_highlight(entity_path.hash()); + + // For now we compute the entity rectangles on the fly. + let mut current_rect = egui::Rect::NOTHING; + + for node in graph.nodes() { + let center = layout.get_node(&node.id()).unwrap_or(Rect::ZERO).center(); + + let response = match node { + Node::Explicit { instance, .. } => { + let highlight = entity_highlights.index_highlight(instance.instance_index); + let response = draw_node(ui, center, node.label(), highlight); + + let response = if let Label::Text { text, .. } = &instance.label { + response.on_hover_text(format!( + "Graph Node: {}\nLabel: {text}", + instance.graph_node.as_str(), + )) + } else { + response.on_hover_text(format!("Graph Node: {}", instance.graph_node.as_str(),)) + }; + + let instance_path = + InstancePath::instance(entity_path.clone(), instance.instance_index); + ctx.select_hovered_on_click( + &response, + vec![(Item::DataResult(query.space_view_id, instance_path), None)].into_iter(), + ); + + response + } + Node::Implicit { graph_node, .. } => { + draw_node(ui, center, node.label(), Default::default()) + .on_hover_text(format!("Implicit Graph Node: {}", graph_node.as_str(),)) + } + }; + + current_rect = current_rect.union(response.rect); + } + + for edge in graph.edges() { + let points = layout + .get_edge(edge.from, edge.to) + .unwrap_or([Pos2::ZERO, Pos2::ZERO]); + let resp = draw_edge(ui, points, edge.arrow); + current_rect = current_rect.union(resp.rect); + } + + current_rect +} diff --git a/crates/viewer/re_space_view_graph/src/ui/draw/edge.rs b/crates/viewer/re_space_view_graph/src/ui/draw/edge.rs deleted file mode 100644 index 501614fe7655..000000000000 --- a/crates/viewer/re_space_view_graph/src/ui/draw/edge.rs +++ /dev/null @@ -1,67 +0,0 @@ -use egui::{Color32, Frame, Painter, Pos2, Rect, Response, Shape, Stroke, Ui, Vec2}; - -/// Draws an edge with an optional arrow mark at the target end. -pub fn draw_edge(ui: &mut Ui, source: Rect, target: Rect, show_arrow: bool) -> Response { - let fg = ui.style().visuals.text_color(); - - Frame::default() - .show(ui, |ui| { - let source_center = source.center(); - let target_center = target.center(); - - // Calculate direction vector from source to target - let direction = (target_center - source_center).normalized(); - - // Find the border points on both rectangles - let source_point = find_border_point(source, -direction); // Reverse direction for target - let target_point = find_border_point(target, direction); - - let painter = ui.painter(); - - painter.line_segment([source_point, target_point], Stroke::new(1.0, fg)); - - // Conditionally draw an arrow at the target point - if show_arrow { - draw_arrow(painter, target_point, direction, fg); - } - }) - .response -} - -/// Helper function to find the point where the line intersects the border of a rectangle -fn find_border_point(rect: Rect, direction: Vec2) -> Pos2 { - let mut t_min = f32::NEG_INFINITY; - let mut t_max = f32::INFINITY; - - for i in 0..2 { - let inv_d = 1.0 / direction[i]; - let mut t0 = (rect.min[i] - rect.center()[i]) * inv_d; - let mut t1 = (rect.max[i] - rect.center()[i]) * inv_d; - - if inv_d < 0.0 { - std::mem::swap(&mut t0, &mut t1); - } - - t_min = t_min.max(t0); - t_max = t_max.min(t1); - } - - let t = t_max.min(t_min); // Pick the first intersection - rect.center() + t * direction -} - -/// Helper function to draw an arrow at the end of the edge -fn draw_arrow(painter: &Painter, tip: Pos2, direction: Vec2, color: Color32) { - let arrow_size = 10.0; // Adjust size as needed - let perpendicular = Vec2::new(-direction.y, direction.x) * 0.5 * arrow_size; - - let p1 = tip - direction * arrow_size + perpendicular; - let p2 = tip - direction * arrow_size - perpendicular; - - // Draw a filled triangle for the arrow - painter.add(Shape::convex_polygon( - vec![tip, p1, p2], - color, - Stroke::NONE, - )); -} diff --git a/crates/viewer/re_space_view_graph/src/ui/draw/entity.rs b/crates/viewer/re_space_view_graph/src/ui/draw/entity.rs deleted file mode 100644 index 91a97e4b5972..000000000000 --- a/crates/viewer/re_space_view_graph/src/ui/draw/entity.rs +++ /dev/null @@ -1,44 +0,0 @@ -use egui::{Align2, Color32, FontId, Rect, Response, Sense, Stroke, Ui}; - -use re_log_types::EntityPath; -use re_viewer_context::SpaceViewHighlights; - -pub fn draw_entity( - ui: &mut Ui, - rect: Rect, - entity_path: &EntityPath, - highlights: &SpaceViewHighlights, -) -> Response { - let (rect, response) = ui.allocate_at_least(rect.size(), Sense::drag()); - - let color = if highlights - .entity_outline_mask(entity_path.hash()) - .overall - .is_some() - { - ui.ctx().style().visuals.text_color() - } else { - ui.ctx() - .style() - .visuals - .gray_out(ui.ctx().style().visuals.text_color()) - }; - - let padded = rect.expand(10.0); - - ui.painter() - .rect(padded, 0.0, Color32::TRANSPARENT, Stroke::new(1.0, color)); - - ui.painter().text( - padded.left_top(), - Align2::LEFT_BOTTOM, - entity_path.to_string(), - FontId { - size: 12.0, - family: Default::default(), - }, - color, - ); - - response -} diff --git a/crates/viewer/re_space_view_graph/src/ui/draw/mod.rs b/crates/viewer/re_space_view_graph/src/ui/draw/mod.rs deleted file mode 100644 index 4bf84020d259..000000000000 --- a/crates/viewer/re_space_view_graph/src/ui/draw/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod edge; -mod entity; -mod node; - -pub use edge::draw_edge; -pub use entity::draw_entity; -pub use node::{draw_explicit, draw_implicit}; diff --git a/crates/viewer/re_space_view_graph/src/ui/draw/node.rs b/crates/viewer/re_space_view_graph/src/ui/draw/node.rs deleted file mode 100644 index 0ede98194065..000000000000 --- a/crates/viewer/re_space_view_graph/src/ui/draw/node.rs +++ /dev/null @@ -1,83 +0,0 @@ -use egui::{Frame, Label, Response, RichText, Sense, Stroke, TextWrapMode, Ui, Vec2}; -use re_viewer_context::{InteractionHighlight, SelectionHighlight}; - -use crate::{graph::NodeInstanceImplicit, ui::scene::SceneContext, visualizers::NodeInstance}; - -/// The `world_to_ui_scale` parameter is used to convert between world and ui coordinates. -pub fn draw_explicit( - ui: &mut Ui, - ctx: &SceneContext, - node: &NodeInstance, - highlight: InteractionHighlight, -) -> Response { - let visuals = &ui.style().visuals; - - let fg = node.color.unwrap_or_else(|| visuals.text_color()); - - let response = if let (Some(label), true) = (node.label.as_ref(), node.show_label) { - // Draw a text node. - - let bg = visuals.widgets.noninteractive.bg_fill; - let stroke = match highlight.selection { - SelectionHighlight::Selection => visuals.selection.stroke, - _ => Stroke::new(1.0, visuals.text_color()), - }; - - let text = RichText::new(label.as_str()).color(fg); - let label = Label::new(text).wrap_mode(TextWrapMode::Extend); - - Frame::default() - .rounding(4.0) - .stroke(stroke) - .inner_margin(Vec2::new(6.0, 4.0)) - .fill(bg) - .show(ui, |ui| ui.add(label)) - .response - } else { - // Draw a circle node. - let r = node.radius.map(|r| ctx.radius_to_world(r)).unwrap_or(4.0); - debug_assert!(r.is_sign_positive(), "radius must be greater than zero"); - - Frame::default() - .show(ui, |ui| { - ui.add(|ui: &mut Ui| { - let (rect, response) = ui.allocate_at_least( - Vec2::splat(2.0 * r), - Sense::hover(), // Change this to allow dragging. - ); // Frame size - ui.painter().circle(rect.center(), r, fg, Stroke::NONE); - response - }) - }) - .response - }; - - if let Some(label) = node.label.as_ref() { - response.on_hover_text(format!( - "Graph Node: {}\nLabel: {label}", - node.node.as_str(), - )) - } else { - response.on_hover_text(format!("Graph Node: {}", node.node.as_str(),)) - } -} - -/// Draws an implicit node instance (dummy node). -pub fn draw_implicit(ui: &mut egui::Ui, node: &NodeInstanceImplicit) -> Response { - let fg = ui.style().visuals.gray_out(ui.style().visuals.text_color()); - let r = 4.0; - - Frame::default() - .show(ui, |ui| { - ui.add(|ui: &mut Ui| { - let (rect, response) = ui.allocate_at_least( - Vec2::splat(2.0 * r), - Sense::hover(), // Change this to allow dragging. - ); // Frame size - ui.painter().circle(rect.center(), r, fg, Stroke::NONE); - response - }) - }) - .response - .on_hover_text(format!("Implicit Node: `{}`", node.node.as_str(),)) -} diff --git a/crates/viewer/re_space_view_graph/src/ui/mod.rs b/crates/viewer/re_space_view_graph/src/ui/mod.rs index aa7590ecd9d7..e387d8568276 100644 --- a/crates/viewer/re_space_view_graph/src/ui/mod.rs +++ b/crates/viewer/re_space_view_graph/src/ui/mod.rs @@ -1,10 +1,5 @@ mod draw; mod state; -pub mod scene; - -pub use state::{Discriminator, GraphSpaceViewState}; - -pub fn bounding_rect_from_iter(rectangles: impl Iterator) -> egui::Rect { - rectangles.fold(egui::Rect::NOTHING, |acc, rect| acc.union(rect)) -} +pub use draw::{draw_debug, draw_entity_rect, draw_graph, DrawableLabel}; +pub use state::GraphSpaceViewState; diff --git a/crates/viewer/re_space_view_graph/src/ui/scene.rs b/crates/viewer/re_space_view_graph/src/ui/scene.rs deleted file mode 100644 index 3637c3a7f05d..000000000000 --- a/crates/viewer/re_space_view_graph/src/ui/scene.rs +++ /dev/null @@ -1,276 +0,0 @@ -use egui::{ - emath::TSTransform, Area, Color32, Id, LayerId, Order, Painter, Pos2, Rect, Response, Sense, - Stroke, Ui, Vec2, -}; -use re_chunk::EntityPath; -use re_types::{components::Radius, datatypes::Float32}; -use re_viewer_context::{InteractionHighlight, SpaceViewHighlights}; -use std::hash::Hash; - -use crate::{ - graph::NodeInstanceImplicit, - visualizers::{EdgeInstance, NodeInstance}, -}; - -use super::draw::{draw_edge, draw_entity, draw_explicit, draw_implicit}; - -fn fit_to_world_rect(clip_rect_window: Rect, world_rect: Rect) -> TSTransform { - let available_size = clip_rect_window.size(); - - // Compute the scale factor to fit the bounding rectangle into the available screen size. - let scale_x = available_size.x / world_rect.width(); - let scale_y = available_size.y / world_rect.height(); - - // Use the smaller of the two scales to ensure the whole rectangle fits on the screen. - let scale = scale_x.min(scale_y).min(1.0); - - // Compute the translation to center the bounding rect in the screen. - let center_screen = Pos2::new(available_size.x / 2.0, available_size.y / 2.0); - let center_world = world_rect.center().to_vec2(); - - // Set the transformation to scale and then translate to center. - - TSTransform::from_translation(center_screen.to_vec2() - center_world * scale) - * TSTransform::from_scaling(scale) -} - -pub struct SceneBuilder { - show_debug: bool, - world_bounds: Rect, - bounding_rect: Rect, - children_drag_delta: Vec2, - children_hovered: bool, -} - -impl SceneBuilder { - pub fn from_world_bounds(world_bounds: impl Into) -> Self { - Self { - world_bounds: world_bounds.into(), - show_debug: false, - bounding_rect: Rect::NOTHING, - children_drag_delta: Vec2::ZERO, - children_hovered: false, - } - } - - pub fn show_debug(&mut self) { - self.show_debug = true; - } - - /// Return the clip rect of the scene in window coordinates. - pub fn add(mut self, ui: &mut Ui, add_scene_contents: F) -> (Rect, Response) - where - F: for<'b> FnOnce(Scene<'b>), - { - re_tracing::profile_function!(); - - let (id, clip_rect_window) = ui.allocate_space(ui.available_size()); - let response = ui.interact(clip_rect_window, id, Sense::click_and_drag()); - - let mut world_to_view = fit_to_world_rect(clip_rect_window, self.world_bounds); - - let view_to_window = TSTransform::from_translation(ui.min_rect().left_top().to_vec2()); - let world_to_window = view_to_window * world_to_view; - let clip_rect_world = world_to_window.inverse() * clip_rect_window; - - let window_layer = ui.layer_id(); - - add_scene_contents(Scene { - ui, - id, - window_layer, - context: SceneContext { - clip_rect_world, - world_to_window, - }, - bounding_rect: &mut self.bounding_rect, - children_drag_delta: &mut self.children_drag_delta, - children_hovered: &mut self.children_hovered, - }); - - // :warning: Currently, `children_drag_delta` and `children_hovered` only report events from the entity rectangles. - // TODO(grtlr): Would it makes sense to move the creation of the `Response` here and let `Canvas` only be a thin wrapper? - // That way we would avoid the duplication between those objects and we would have single-responsibility. - - world_to_view.translation += self.children_drag_delta; - if response.dragged() { - world_to_view.translation += response.drag_delta(); - } - - if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) { - // Note: Catch zooming / panning either in the container, or on the entities. - if response.hovered() || self.children_hovered { - let pointer_in_world = world_to_window.inverse() * pointer; - let zoom_delta = ui.ctx().input(|i| i.zoom_delta()); - let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta); - - // Zoom in on pointer, but only if we are not zoomed out too far. - if zoom_delta < 1.0 || world_to_view.scaling < 1.0 { - world_to_view = world_to_view - * TSTransform::from_translation(pointer_in_world.to_vec2()) - * TSTransform::from_scaling(zoom_delta) - * TSTransform::from_translation(-pointer_in_world.to_vec2()); - } - - // Pan: - world_to_view = TSTransform::from_translation(pan_delta) * world_to_view; - } - } - - // We need to draw the debug information after the rest to ensure that we have the correct bounding box. - if self.show_debug { - let debug_id = LayerId::new(Order::Debug, id.with("debug_layer")); - ui.ctx().set_transform_layer(debug_id, world_to_window); - - // Paint the coordinate system. - let painter = Painter::new(ui.ctx().clone(), debug_id, clip_rect_world); - - // paint coordinate system at the world origin - let origin = Pos2::new(0.0, 0.0); - let x_axis = Pos2::new(100.0, 0.0); - let y_axis = Pos2::new(0.0, 100.0); - - painter.line_segment([origin, x_axis], Stroke::new(1.0, Color32::RED)); - painter.line_segment([origin, y_axis], Stroke::new(2.0, Color32::GREEN)); - - if self.bounding_rect.is_positive() { - painter.rect( - self.bounding_rect, - 0.0, - Color32::from_rgba_unmultiplied(255, 0, 255, 8), - Stroke::new(1.0, Color32::from_rgb(255, 0, 255)), - ); - } - } - - ( - (view_to_window * world_to_view).inverse() * clip_rect_window, - response, - ) - } -} - -pub struct SceneContext { - clip_rect_world: Rect, - world_to_window: TSTransform, -} - -impl SceneContext { - pub fn distance_to_world(&self, distance: f32) -> f32 { - distance / self.world_to_window.scaling - } - - /// If the radius is negative, we need to convert it from ui to world coordinates. - pub fn radius_to_world(&self, radius: Radius) -> f32 { - match radius { - Radius(Float32(r)) if r.is_sign_positive() => r, - Radius(Float32(r)) => self.distance_to_world(r.abs()), - } - } -} - -pub struct Scene<'a> { - ui: &'a mut Ui, - id: Id, - window_layer: LayerId, - context: SceneContext, - bounding_rect: &'a mut Rect, - children_drag_delta: &'a mut Vec2, - children_hovered: &'a mut bool, -} - -impl Scene<'_> { - /// Draws a regular node, i.e. an explicit node instance. - pub fn explicit_node( - &mut self, - pos: Pos2, - node: &NodeInstance, - highlights: InteractionHighlight, - ) -> Response { - self.node_wrapper(node.index, pos, |ui, world_to_ui| { - draw_explicit(ui, world_to_ui, node, highlights) - }) - } - - pub fn implicit_node(&mut self, pos: Pos2, node: &NodeInstanceImplicit) -> Response { - self.node_wrapper(node.index, pos, |ui, _| draw_implicit(ui, node)) - } - - /// `pos` is the top-left position of the node in world coordinates. - fn node_wrapper(&mut self, id: impl Hash, pos: Pos2, add_node_contents: F) -> Response - where - F: for<'b> FnOnce(&'b mut Ui, &'b SceneContext) -> Response, - { - let response = Area::new(self.id.with(id)) - .fixed_pos(pos) - .order(Order::Middle) - .constrain(false) - .show(self.ui.ctx(), |ui| { - ui.set_clip_rect(self.context.clip_rect_world); - add_node_contents(ui, &self.context) - }) - .response; - - let id = response.layer_id; - self.ui - .ctx() - .set_transform_layer(id, self.context.world_to_window); - self.ui.ctx().set_sublayer(self.window_layer, id); - - *self.bounding_rect = self.bounding_rect.union(response.rect); - - response - } - - pub fn entity( - &mut self, - entity: &EntityPath, - rect: Rect, - highlights: &SpaceViewHighlights, - ) -> Response { - let response = Area::new(self.id.with(entity)) - .fixed_pos(rect.min) - .order(Order::Background) - .constrain(false) - .show(self.ui.ctx(), |ui| { - ui.set_clip_rect(self.context.clip_rect_world); - draw_entity(ui, rect, entity, highlights) - }) - .inner; - - if response.dragged() { - *self.children_drag_delta += response.drag_delta(); - } - - if response.hovered() { - *self.children_hovered = true; - } - - let id = response.layer_id; - self.ui - .ctx() - .set_transform_layer(id, self.context.world_to_window); - self.ui.ctx().set_sublayer(self.window_layer, id); - - response - } - - pub fn edge(&self, from: Rect, to: Rect, edge: &EdgeInstance, show_arrow: bool) -> Response { - let response = Area::new(self.id.with(((edge.source_index, edge.target_index),))) - .order(Order::Middle) - .constrain(false) - .show(self.ui.ctx(), |ui| { - ui.set_clip_rect(self.context.clip_rect_world); - draw_edge(ui, from, to, show_arrow) - }) - .response; - - let id = response.layer_id; - self.ui - .ctx() - .set_transform_layer(id, self.context.world_to_window); - self.ui.ctx().set_sublayer(self.window_layer, id); - - response - } -} diff --git a/crates/viewer/re_space_view_graph/src/ui/state.rs b/crates/viewer/re_space_view_graph/src/ui/state.rs index 21cbe3dabce1..ccf77707ebf4 100644 --- a/crates/viewer/re_space_view_graph/src/ui/state.rs +++ b/crates/viewer/re_space_view_graph/src/ui/state.rs @@ -4,10 +4,7 @@ use re_types::blueprint::components::VisualBounds2D; use re_ui::UiExt; use re_viewer_context::SpaceViewState; -use crate::{ - graph::Graph, - layout::{ForceLayout, Layout}, -}; +use crate::layout::{ForceLayoutProvider, Layout, LayoutRequest}; /// Space view state for the custom space view. /// @@ -18,7 +15,7 @@ pub struct GraphSpaceViewState { pub show_debug: bool, - pub world_bounds: Option, + pub visual_bounds: Option, } impl GraphSpaceViewState { @@ -60,18 +57,6 @@ impl SpaceViewState for GraphSpaceViewState { } } -/// Used to determine if a layout is up-to-date or outdated. For now we use a -/// hash of the data the comes from the visualizers. -#[derive(Debug, PartialEq, Eq)] -#[repr(transparent)] -pub struct Discriminator(u64); - -impl Discriminator { - pub fn new(hash: u64) -> Self { - Self(hash) - } -} - /// The following is a simple state machine that keeps track of the different /// layouts and if they need to be recomputed. It also holds the state of the /// force-based simulation. @@ -80,14 +65,14 @@ pub enum LayoutState { #[default] None, InProgress { - discriminator: Discriminator, + request: LayoutRequest, layout: Layout, - provider: ForceLayout, + provider: ForceLayoutProvider, }, Finished { - discriminator: Discriminator, + request: LayoutRequest, layout: Layout, - _provider: ForceLayout, + _provider: ForceLayoutProvider, }, } @@ -114,54 +99,46 @@ impl LayoutState { } /// A simple state machine that keeps track of the different stages and if the layout needs to be recomputed. - fn update<'a>( - self, - requested: Discriminator, - graphs: impl Iterator> + Clone, - ) -> Self { + fn update(self, new_request: LayoutRequest) -> Self { match self { // Layout is up to date, nothing to do here. - Self::Finished { - ref discriminator, .. - } if discriminator == &requested => { + Self::Finished { ref request, .. } if request == &new_request => { self // no op } // We need to recompute the layout. Self::None | Self::Finished { .. } => { - let provider = ForceLayout::new(graphs); - let layout = provider.init(); + let provider = ForceLayoutProvider::new(&new_request); + let layout = provider.init(&new_request); Self::InProgress { - discriminator: requested, + request: new_request, layout, provider, } } - Self::InProgress { - ref discriminator, .. - } if discriminator != &requested => { - let provider = ForceLayout::new(graphs); - let layout = provider.init(); + Self::InProgress { request, .. } if request != new_request => { + let provider = ForceLayoutProvider::new(&new_request); + let layout = provider.init(&new_request); Self::InProgress { - discriminator: requested, + request: new_request, layout, provider, } } // We keep iterating on the layout until it is stable. Self::InProgress { - discriminator, + request, mut layout, mut provider, } => match provider.tick(&mut layout) { true => Self::Finished { - discriminator, + request, layout, _provider: provider, }, false => Self::InProgress { - discriminator, + request, layout, provider, }, @@ -170,12 +147,8 @@ impl LayoutState { } /// This method is lazy. A new layout is only computed if the current timestamp requires it. - pub fn get<'a>( - &'a mut self, - hash: Discriminator, - graphs: impl Iterator> + Clone, - ) -> &'a mut Layout { - *self = std::mem::take(self).update(hash, graphs); + pub fn get(&mut self, request: LayoutRequest) -> &mut Layout { + *self = std::mem::take(self).update(request); match self { Self::Finished { layout, .. } | Self::InProgress { layout, .. } => layout, diff --git a/crates/viewer/re_space_view_graph/src/view.rs b/crates/viewer/re_space_view_graph/src/view.rs index e0d5362a0209..cef909294e39 100644 --- a/crates/viewer/re_space_view_graph/src/view.rs +++ b/crates/viewer/re_space_view_graph/src/view.rs @@ -1,5 +1,4 @@ -use std::hash::{Hash as _, Hasher as _}; - +use re_entity_db::InstancePath; use re_log_types::EntityPath; use re_space_view::{ controls::{DRAG_PAN2D_BUTTON, ZOOM_SCROLL_MODIFIER}, @@ -7,12 +6,16 @@ use re_space_view::{ }; use re_types::{ blueprint::{self, archetypes::VisualBounds2D}, - components, SpaceViewClassIdentifier, + SpaceViewClassIdentifier, +}; +use re_ui::{ + self, + zoom_pan_area::{fit_to_rect_in_scene, zoom_pan_area}, + ModifiersMarkdown, MouseButtonMarkdown, UiExt as _, }; -use re_ui::{self, ModifiersMarkdown, MouseButtonMarkdown, UiExt as _}; use re_viewer_context::{ - external::re_entity_db::InstancePath, IdentifiedViewSystem as _, Item, RecommendedSpaceView, - SpaceViewClass, SpaceViewClassLayoutPriority, SpaceViewClassRegistryError, SpaceViewId, + IdentifiedViewSystem as _, Item, RecommendedSpaceView, SpaceViewClass, + SpaceViewClassLayoutPriority, SpaceViewClassRegistryError, SpaceViewId, SpaceViewSpawnHeuristics, SpaceViewState, SpaceViewStateExt as _, SpaceViewSystemExecutionError, SpaceViewSystemRegistrator, SystemExecutionOutput, ViewQuery, ViewerContext, @@ -21,7 +24,8 @@ use re_viewport_blueprint::ViewProperty; use crate::{ graph::Graph, - ui::{scene::SceneBuilder, Discriminator, GraphSpaceViewState}, + layout::LayoutRequest, + ui::{draw_debug, draw_entity_rect, draw_graph, GraphSpaceViewState}, visualizers::{merge, EdgesVisualizer, NodeVisualizer}, }; @@ -74,7 +78,7 @@ Display a graph of nodes and edges. fn preferred_tile_aspect_ratio(&self, state: &dyn SpaceViewState) -> Option { let state = state.downcast_ref::().ok()?; - if let Some(bounds) = state.world_bounds { + if let Some(bounds) = state.visual_bounds { let width = bounds.x_range.abs_len() as f32; let height = bounds.y_range.abs_len() as f32; return Some(width / height); @@ -143,21 +147,15 @@ Display a graph of nodes and edges. query: &ViewQuery<'_>, system_output: SystemExecutionOutput, ) -> Result<(), SpaceViewSystemExecutionError> { + re_tracing::profile_function!(); + let node_data = &system_output.view_systems.get::()?.data; let edge_data = &system_output.view_systems.get::()?.data; let graphs = merge(node_data, edge_data) - .map(|(ent, nodes, edges)| (ent, Graph::new(nodes, edges))) + .map(|(ent, nodes, edges)| Graph::new(ui, ent.clone(), nodes, edges)) .collect::>(); - // We could move this computation to the visualizers to improve - // performance if needed. - let discriminator = { - let mut hasher = ahash::AHasher::default(); - graphs.hash(&mut hasher); - Discriminator::new(hasher.finish()) - }; - let state = state.downcast_mut::()?; let bounds_property = ViewProperty::from_archetype::( @@ -165,103 +163,57 @@ Display a graph of nodes and edges. ctx.blueprint_query, query.space_view_id, ); - - let bounds: blueprint::components::VisualBounds2D = + let rect_in_scene: blueprint::components::VisualBounds2D = bounds_property.component_or_fallback(ctx, self, state)?; - let layout_was_empty = state.layout_state.is_none(); - let layout = state - .layout_state - .get(discriminator, graphs.iter().map(|(_, graph)| graph)); - - let mut needs_remeasure = false; - - state.world_bounds = Some(bounds); - let bounds_rect: egui::Rect = bounds.into(); - - let mut scene_builder = SceneBuilder::from_world_bounds(bounds_rect); - - if state.show_debug { - scene_builder.show_debug(); - } - - let (new_world_bounds, response) = scene_builder.add(ui, |mut scene| { - for (entity, graph) in &graphs { - // We use the following to keep track of the bounding box over nodes in an entity. - let mut entity_rect = egui::Rect::NOTHING; + let rect_in_ui = ui.max_rect(); - let ent_highlights = query.highlights.entity_highlight((*entity).hash()); - - // Draw explicit nodes. - for node in graph.nodes_explicit() { - let pos = layout.get(&node.index).unwrap_or(egui::Rect::ZERO); - - let response = scene.explicit_node( - pos.min, - node, - ent_highlights.index_highlight(node.instance), - ); + let request = LayoutRequest::from_graphs(graphs.iter()); + let layout_was_empty = state.layout_state.is_none(); + let layout = state.layout_state.get(request); - if response.clicked() { - let instance_path = - InstancePath::instance((*entity).clone(), node.instance); - ctx.select_hovered_on_click( - &response, - vec![(Item::DataResult(query.space_view_id, instance_path), None)] - .into_iter(), - ); - } - - entity_rect = entity_rect.union(response.rect); - needs_remeasure |= layout.update(&node.index, response.rect); - } + let mut ui_from_world = fit_to_rect_in_scene(rect_in_ui, rect_in_scene.into()); - // Draw implicit nodes. - for node in graph.nodes_implicit() { - let current = layout.get(&node.index).unwrap_or(egui::Rect::ZERO); - let response = scene.implicit_node(current.min, node); - entity_rect = entity_rect.union(response.rect); - needs_remeasure |= layout.update(&node.index, response.rect); - } + let resp = zoom_pan_area(ui, rect_in_ui, &mut ui_from_world, |ui| { + let mut world_bounding_rect = egui::Rect::NOTHING; - // Draw edges. - for edge in graph.edges() { - if let (Some(from), Some(to)) = ( - layout.get(&edge.source_index), - layout.get(&edge.target_index), - ) { - let show_arrow = graph.kind() == components::GraphType::Directed; - scene.edge(from, to, edge, show_arrow); - } - } + for graph in &graphs { + let mut current_rect = draw_graph(ui, ctx, graph, layout, query); - // Draw entity rect. - if graphs.len() > 1 && entity_rect.is_positive() { - let response = scene.entity(entity, entity_rect, &query.highlights); + // We only show entity rects if there are multiple entities. + // For now, these entity rects are not part of the layout, but rather tracked on the fly. + if graphs.len() > 1 { + let resp = + draw_entity_rect(ui, current_rect, graph.entity(), &query.highlights); - let instance_path = InstancePath::entity_all((*entity).clone()); + let instance_path = InstancePath::entity_all(graph.entity().clone()); ctx.select_hovered_on_click( - &response, + &resp, vec![(Item::DataResult(query.space_view_id, instance_path), None)] .into_iter(), ); + current_rect = current_rect.union(resp.rect); } + + world_bounding_rect = world_bounding_rect.union(current_rect); + } + + // We need to draw the debug information after the rest to ensure that we have the correct bounding box. + if state.show_debug { + draw_debug(ui, world_bounding_rect); } }); // Update blueprint if changed - let updated_bounds: blueprint::components::VisualBounds2D = new_world_bounds.into(); - if response.double_clicked() || layout_was_empty { + let updated_rect_in_scene = + blueprint::components::VisualBounds2D::from(ui_from_world.inverse() * rect_in_ui); + if resp.double_clicked() || layout_was_empty { bounds_property.reset_blueprint_component::(ctx); - } else if bounds != updated_bounds { - bounds_property.save_blueprint_component(ctx, &updated_bounds); + } else if rect_in_scene != updated_rect_in_scene { + bounds_property.save_blueprint_component(ctx, &updated_rect_in_scene); } // Update stored bounds on the state, so visualizers see an up-to-date value. - state.world_bounds = Some(bounds); - - if needs_remeasure { - ui.ctx().request_discard("layout needed a remeasure"); - } + state.visual_bounds = Some(updated_rect_in_scene); if state.layout_state.is_in_progress() { ui.ctx().request_repaint(); diff --git a/crates/viewer/re_space_view_graph/src/visualizers/edges.rs b/crates/viewer/re_space_view_graph/src/visualizers/edges.rs index e291cd193be6..2753ad20d3cc 100644 --- a/crates/viewer/re_space_view_graph/src/visualizers/edges.rs +++ b/crates/viewer/re_space_view_graph/src/visualizers/edges.rs @@ -11,7 +11,7 @@ use re_viewer_context::{ ViewQuery, ViewSystemIdentifier, VisualizerQueryInfo, VisualizerSystem, }; -use crate::graph::NodeIndex; +use crate::graph::NodeId; #[derive(Default)] pub struct EdgesVisualizer { @@ -21,8 +21,8 @@ pub struct EdgesVisualizer { pub struct EdgeInstance { pub source: GraphNode, pub target: GraphNode, - pub source_index: NodeIndex, - pub target_index: NodeIndex, + pub source_index: NodeId, + pub target_index: NodeId, } impl std::hash::Hash for EdgeInstance { @@ -86,8 +86,8 @@ impl VisualizerSystem for EdgesVisualizer { let target = GraphNode::from(edge.second.clone()); let entity_path = &data_result.entity_path; - let source_index = NodeIndex::from_entity_node(entity_path, &source); - let target_index = NodeIndex::from_entity_node(entity_path, &target); + let source_index = NodeId::from_entity_node(entity_path, &source); + let target_index = NodeId::from_entity_node(entity_path, &target); EdgeInstance { source, diff --git a/crates/viewer/re_space_view_graph/src/visualizers/mod.rs b/crates/viewer/re_space_view_graph/src/visualizers/mod.rs index 0f91ee66492e..0b0a2ab86ce5 100644 --- a/crates/viewer/re_space_view_graph/src/visualizers/mod.rs +++ b/crates/viewer/re_space_view_graph/src/visualizers/mod.rs @@ -3,8 +3,8 @@ mod nodes; use std::collections::BTreeSet; -pub use edges::{EdgeData, EdgeInstance, EdgesVisualizer}; -pub use nodes::{NodeData, NodeInstance, NodeVisualizer}; +pub use edges::{EdgeData, EdgesVisualizer}; +pub use nodes::{Label, NodeData, NodeInstance, NodeVisualizer}; use re_chunk::EntityPath; diff --git a/crates/viewer/re_space_view_graph/src/visualizers/nodes.rs b/crates/viewer/re_space_view_graph/src/visualizers/nodes.rs index 2c5896b94610..eb02530150a1 100644 --- a/crates/viewer/re_space_view_graph/src/visualizers/nodes.rs +++ b/crates/viewer/re_space_view_graph/src/visualizers/nodes.rs @@ -1,3 +1,4 @@ +use egui::Color32; use re_chunk::LatestAtQuery; use re_log_types::{EntityPath, Instance}; use re_query::{clamped_zip_2x4, range_zip_1x4}; @@ -15,53 +16,37 @@ use re_viewer_context::{ ViewSystemIdentifier, VisualizerQueryInfo, VisualizerSystem, }; -use crate::graph::NodeIndex; +use crate::graph::NodeId; #[derive(Default)] pub struct NodeVisualizer { pub data: ahash::HashMap, } -pub struct NodeInstance { - pub node: components::GraphNode, - pub instance: Instance, - pub index: NodeIndex, - pub label: Option, - pub show_label: bool, - pub color: Option, - pub position: Option, - pub radius: Option, +/// The label information of a [`re_types::archetypes::GraphNodes`]. +#[derive(Clone)] +pub enum Label { + Circle { + /// Radius of the circle in world coordinates. + radius: f32, + color: Option, + }, + Text { + text: ArrowString, + color: Option, + }, } -impl std::hash::Hash for NodeInstance { - fn hash(&self, state: &mut H) { - // We use the more verbose destructring here, to make sure that we - // exhaustively consider all fields when hashing (we get a compiler - // warning when we forget a field). - let Self { - // The index already uniquely identifies a node, so we don't need to - // hash the node itself. - node: _, - instance, - index, - label, - show_label, - color, - position, - radius, - } = self; - instance.hash(state); - index.hash(state); - label.hash(state); - show_label.hash(state); - color.hash(state); - // The following fields don't implement `Hash`. - position.as_ref().map(bytemuck::bytes_of).hash(state); - radius.as_ref().map(bytemuck::bytes_of).hash(state); - } +/// A [`NodeInstance`] is the output of the [`NodeVisualizer`] and represents a single node in the graph. +#[derive(Clone)] +pub struct NodeInstance { + pub graph_node: components::GraphNode, + pub instance_index: Instance, + pub id: NodeId, + pub position: Option, + pub label: Label, } -#[derive(Hash)] pub struct NodeData { pub nodes: Vec, } @@ -107,7 +92,7 @@ impl VisualizerSystem for NodeVisualizer { all_colors.component::(), all_positions.primitive_array::<2, f32>(), all_labels.string(), - all_radii.component::(), + all_radii.primitive::(), ); for (_index, nodes, colors, positions, labels, radii) in data { @@ -122,23 +107,32 @@ impl VisualizerSystem for NodeVisualizer { .copied() .map(Option::Some), Option::<[f32; 2]>::default, - labels.unwrap_or_default().iter().map(Option::Some), - Option::<&ArrowString>::default, - radii.unwrap_or_default().iter().map(Option::Some), - Option::<&components::Radius>::default, + labels.unwrap_or_default().iter().cloned().map(Option::Some), + Option::::default, + radii.unwrap_or_default().iter().copied().map(Option::Some), + Option::::default, ) - .map( - |(node, instance, color, position, label, radius)| NodeInstance { - node: node.clone(), - instance, - index: NodeIndex::from_entity_node(&data_result.entity_path, node), - color: color.map(|&Color(color)| color.into()), + .map(|(node, instance, color, position, label, radius)| { + let color = color.map(|&c| egui::Color32::from(c)); + let label = match (label, show_label) { + (Some(label), true) => Label::Text { + text: label.clone(), + color, + }, + _ => Label::Circle { + radius: radius.unwrap_or(4.0), + color, + }, + }; + + NodeInstance { + graph_node: node.clone(), + instance_index: instance, + id: NodeId::from_entity_node(&data_result.entity_path, node), position: position.map(|[x, y]| egui::Pos2::new(x, y)), - label: label.cloned(), - show_label, - radius: radius.copied(), - }, - ) + label, + } + }) .collect::>(); self.data diff --git a/crates/viewer/re_ui/src/lib.rs b/crates/viewer/re_ui/src/lib.rs index efb27a0a1c59..fd1d14c9ec93 100644 --- a/crates/viewer/re_ui/src/lib.rs +++ b/crates/viewer/re_ui/src/lib.rs @@ -14,6 +14,7 @@ pub mod modal; mod section_collapsing_header; pub mod toasts; mod ui_ext; +pub mod zoom_pan_area; use egui::NumExt as _; diff --git a/crates/viewer/re_ui/src/zoom_pan_area.rs b/crates/viewer/re_ui/src/zoom_pan_area.rs new file mode 100644 index 000000000000..e3c6114e99d2 --- /dev/null +++ b/crates/viewer/re_ui/src/zoom_pan_area.rs @@ -0,0 +1,102 @@ +//! A small, self-container pan-and-zoom area for [`egui`]. +//! +//! Throughout this module, we use the following conventions or naming the different spaces: +//! * `ui`-space: The _global_ `egui` space. +//! * `view`-space: The space where the pan-and-zoom area is drawn. +//! * `scene`-space: The space where the actual content is drawn. + +use egui::{emath::TSTransform, Area, Order, Rect, Response, Ui, UiKind}; + +/// Helper function to handle pan and zoom interactions on a response. +fn register_pan_and_zoom(ui: &Ui, resp: &Response, ui_from_scene: &mut TSTransform) { + if resp.dragged() { + ui_from_scene.translation += ui_from_scene.scaling * resp.drag_delta(); + } + + if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) { + if resp.contains_pointer() { + let pointer_in_scene = ui_from_scene.inverse() * mouse_pos; + let zoom_delta = ui.ctx().input(|i| i.zoom_delta()); + let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta); + + // Zoom in on pointer, but only if we are not zoomed out too far. + if zoom_delta < 1.0 || ui_from_scene.scaling < 1.0 { + *ui_from_scene = *ui_from_scene + * TSTransform::from_translation(pointer_in_scene.to_vec2()) + * TSTransform::from_scaling(zoom_delta) + * TSTransform::from_translation(-pointer_in_scene.to_vec2()); + } + + // We clamp the resulting scaling to avoid zooming out too far. + ui_from_scene.scaling = ui_from_scene.scaling.min(1.0); + + // Pan: + *ui_from_scene = TSTransform::from_translation(pan_delta) * *ui_from_scene; + } + } +} + +/// Creates a transformation that fits a given scene rectangle into the available screen size. +pub fn fit_to_rect_in_scene(rect_in_ui: Rect, rect_in_scene: Rect) -> TSTransform { + let available_size_in_ui = rect_in_ui.size(); + + // Compute the scale factor to fit the bounding rectangle into the available screen size. + let scale_x = available_size_in_ui.x / rect_in_scene.width(); + let scale_y = available_size_in_ui.y / rect_in_scene.height(); + + // Use the smaller of the two scales to ensure the whole rectangle fits on the screen. + let scale = scale_x.min(scale_y).min(1.0); + + // Compute the translation to center the bounding rect in the screen. + let center_screen = rect_in_ui.center(); + let center_scene = rect_in_scene.center().to_vec2(); + + // Set the transformation to scale and then translate to center. + TSTransform::from_translation(center_screen.to_vec2() - center_scene * scale) + * TSTransform::from_scaling(scale) +} + +/// Provides a zoom-pan area for a given view. +pub fn zoom_pan_area( + ui: &Ui, + view_bounds_in_ui: Rect, + ui_from_scene: &mut TSTransform, + draw_contents: impl FnOnce(&mut Ui), +) -> Response { + let area_resp = Area::new(ui.id().with("zoom_pan_area")) + .constrain_to(view_bounds_in_ui) + .order(Order::Middle) + .kind(UiKind::GenericArea) + .show(ui.ctx(), |ui| { + // Transform to the scene space: + let visible_rect_in_scene = ui_from_scene.inverse() * view_bounds_in_ui; + + // set proper clip-rect so we can interact with the background. + ui.set_clip_rect(visible_rect_in_scene); + + // A Ui for sensing drag-to-pan, scroll-to-zoom, etc + let mut drag_sense_ui = ui.new_child( + egui::UiBuilder::new() + .sense(egui::Sense::click_and_drag()) + .max_rect(visible_rect_in_scene), + ); + drag_sense_ui.set_min_size(visible_rect_in_scene.size()); + let pan_response = drag_sense_ui.response(); + + // Update the transform based on the interactions: + register_pan_and_zoom(ui, &pan_response, ui_from_scene); + + // Update the clip-rect with the new transform, to avoid frame-delays + ui.set_clip_rect(ui_from_scene.inverse() * view_bounds_in_ui); + + // Add the actual contents to the area: + draw_contents(ui); + + pan_response + }); + + ui.ctx() + .set_transform_layer(area_resp.response.layer_id, *ui_from_scene); + + area_resp.inner +} diff --git a/tests/python/release_checklist/check_graph_view.py b/tests/python/release_checklist/check_graph_view.py index 5e6f60bdfc09..dc0b5edeeaf7 100644 --- a/tests/python/release_checklist/check_graph_view.py +++ b/tests/python/release_checklist/check_graph_view.py @@ -29,7 +29,7 @@ def log_graphs() -> None: ("C", None), (None, ("A", "B")), (None, ("B", "C")), - (None, ("A", "C")), + (None, ("C", "A")), ] nodes = []