facett-flow/src/host.rs
//! Caller-owned scenes and transforms. Keep the scene between frames: only setters
//! and graph replacement rebuild derived data, never `paint`.
use crate::{Camera, Graph, scene::Scene};
use egui::{Color32, Pos2, Rect, Stroke, Vec2, vec2};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
pub scale: f32,
pub offset: Vec2,
}
impl View {
/// Navigable's offset is already in screen coordinates; rect is the viewport.
pub fn from_navigable(nav: &facett_core::nav::Navigable, _rect: Rect) -> Self {
Self {
scale: nav.scale,
offset: vec2(nav.offset[0], nav.offset[1]),
}
}
pub fn screen(&self, p: Pos2) -> Pos2 {
(p.to_vec2() * self.scale + self.offset).to_pos2()
}
pub fn world(&self, p: Pos2) -> Pos2 {
((p.to_vec2() - self.offset) / self.scale).to_pos2()
}
}
impl Camera {
pub fn view(&self, rect: Rect, bounds: Rect) -> View {
let fit = ((rect.width() - 64.0).max(1.0) / bounds.width().max(1.0))
.min((rect.height() - 64.0).max(1.0) / bounds.height().max(1.0));
let scale = fit * self.zoom;
View {
scale,
offset: rect.center().to_vec2() - vec2(self.centre[0], self.centre[1]) * scale,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CpuStyle {
Preview,
Full { max_edges: usize },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Animation {
Flow,
External,
None,
}
#[derive(Clone, Copy, Debug)]
pub struct FlowPalette {
pub input: Color32,
pub middle: Color32,
pub output: Color32,
pub edge: Color32,
pub pulse: Color32,
pub background: Color32,
#[cfg(feature = "status")]
pub status: crate::attr::StatusPalette,
}
impl FlowPalette {
pub fn from_theme(t: &facett_core::Theme) -> Self {
Self {
input: t.point,
middle: t.accent,
output: t.glow,
edge: t.edge,
pulse: t.accent,
background: t.bg,
#[cfg(feature = "status")]
status: crate::attr::StatusPalette::from_theme(t),
}
}
fn node(&self, kind: f32) -> Color32 {
if kind < 0.5 {
self.input
} else if kind > 1.5 {
self.output
} else {
self.middle
}
}
}
#[derive(Clone, Debug)]
pub struct PaintOpts {
pub seconds: f64,
pub moving: bool,
pub clustered: bool,
pub palette: Option<FlowPalette>,
pub cpu: CpuStyle,
pub animation: Animation,
pub label_budget: usize,
pub min_px: f32,
/// Hide packets/trails on edges shorter than this many logical screen pixels.
#[cfg(feature = "lod")]
pub min_packet_px: f32,
}
impl Default for PaintOpts {
fn default() -> Self {
Self {
seconds: 0.0,
moving: true,
clustered: false,
palette: None,
cpu: CpuStyle::Preview,
animation: Animation::Flow,
label_budget: 2000,
min_px: 0.0,
#[cfg(feature = "lod")]
min_packet_px: 0.0,
}
}
}
#[derive(Clone, Debug)]
pub struct OnScreen {
pub node: usize,
pub rect: Rect,
pub px_per_world: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hit {
Node(usize),
Edge(usize),
Group(usize),
}
#[derive(Default)]
pub struct Painted {
/// Budgeted label candidates, in graph order.
pub on_screen: Vec<OnScreen>,
/// All visible node rectangles; never constrained by the label budget.
pub visible_nodes: Vec<OnScreen>,
pub truncated: bool,
pub gpu: bool,
pub hover: Option<Hit>,
}
#[derive(Clone)]
#[cfg_attr(not(feature = "wgpu"), allow(dead_code))]
pub(crate) struct Attributes {
pub generation: u64,
pub nodes: Vec<[f32; 4]>,
pub edges: Vec<[f32; 4]>,
pub arrows: bool,
}
#[derive(Clone)]
pub struct FlowScene {
graph: Graph,
pub(crate) scene: Scene,
key: u64,
digest: u64,
bounds: Rect,
node_rects: Vec<Rect>,
max_half_extent: Vec2,
#[cfg(feature = "boxes")]
sizes: Option<crate::attr::Sizes>,
#[cfg(feature = "status")]
status: Option<crate::attr::StatusTable>,
#[cfg(feature = "rate")]
rates: Option<crate::attr::Rates>,
#[cfg(feature = "rate")]
plan: Vec<facett_flowsim::flow::EdgeParam>,
arrows: bool,
pub(crate) attributes: Option<Arc<Attributes>>,
#[cfg(feature = "lod")]
#[cfg_attr(not(feature = "wgpu"), allow(dead_code))] // neutral resident table is GPU-only
lod_attributes: std::sync::OnceLock<Arc<Attributes>>,
}
impl FlowScene {
pub fn new(graph: Graph) -> Result<Self, String> {
Self::with_key(graph, 0)
}
pub fn with_key(graph: Graph, key: u64) -> Result<Self, String> {
graph.validate()?;
let digest = crate::scene::digest(&graph, key);
let mut scene = Scene::new(&graph);
// Distinct namespace for raw and clustered, including every geometry input.
Arc::get_mut(&mut scene.raw).unwrap().generation = digest;
Arc::get_mut(&mut scene.clusters).unwrap().generation =
digest.rotate_left(29) ^ 0xa52f_6138_be76_d091;
let bounds = scene.bounds;
let mut out = Self {
graph,
scene,
key,
digest,
bounds,
node_rects: Vec::new(),
max_half_extent: Vec2::ZERO,
arrows: false,
attributes: None,
#[cfg(feature = "lod")]
lod_attributes: std::sync::OnceLock::new(),
#[cfg(feature = "boxes")]
sizes: None,
#[cfg(feature = "status")]
status: None,
#[cfg(feature = "rate")]
rates: None,
#[cfg(feature = "rate")]
plan: Vec::new(),
};
out.reindex();
Ok(out)
}
#[cfg(feature = "wgpu")]
fn paint_attributes(&self, _opts: &PaintOpts) -> Option<Arc<Attributes>> {
#[cfg(feature = "lod")]
if _opts.min_packet_px > 0.0 && self.attributes.is_none() {
return Some(
self.lod_attributes
.get_or_init(|| {
Arc::new(Attributes {
generation: self.digest ^ 0x67ab201d,
nodes: vec![[0.0; 4]; self.graph.nodes.len()],
edges: vec![[0.18, 1.0, 0.0, 0.0]; self.graph.edges.len()],
arrows: false,
})
})
.clone(),
);
}
self.attributes.clone()
}
pub fn graph(&self) -> &Graph {
&self.graph
}
pub fn bounds(&self) -> Rect {
self.bounds
}
pub fn generation(&self) -> u64 {
self.digest
}
/// Graph replacement clears all ordering-dependent tables, even at equal length.
/// An exactly unchanged graph is a no-op, retaining geometry and attributes.
pub fn replace(&mut self, graph: Graph) -> Result<(), String> {
if self.graph == graph {
return Ok(());
}
*self = Self::with_key(graph, self.key)?;
Ok(())
}
pub fn node_rect(&self, i: usize) -> Option<Rect> {
self.node_rects.get(i).copied()
}
#[cfg(feature = "status")]
pub fn status(&self) -> Option<&crate::attr::StatusTable> {
self.status.as_ref()
}
#[cfg(feature = "rate")]
pub fn rates(&self) -> Option<&crate::attr::Rates> {
self.rates.as_ref()
}
fn visible_nodes(&self, rect: Rect) -> Vec<usize> {
let query = rect.expand2(self.max_half_extent);
// Reuse Scene's existing 64-unit node-centre buckets. Its close-view
// query deliberately refuses huge grids; an overview scans the nodes.
let cells = ((query.width() / 64.0).ceil() + 2.0) * ((query.height() / 64.0).ceil() + 2.0);
let mut ids = if cells > 200_000.0 {
(0..self.node_rects.len()).collect()
} else {
self.scene.visible(query, usize::MAX)
};
ids.retain(|&i| self.node_rects[i].intersects(rect));
ids.sort_unstable();
ids
}
fn visible_edges(&self, rect: Rect) -> Vec<usize> {
(0..self.graph.edges.len())
.filter(|&i| {
let (a, b) = self.ends(i);
Rect::from_two_pos(a, b).intersects(rect)
})
.collect()
}
pub fn attribute_generation(&self) -> Option<u64> {
self.attributes.as_ref().map(|a| a.generation)
}
#[cfg(feature = "boxes")]
pub fn set_sizes(&mut self, sizes: crate::attr::Sizes) -> Result<(), String> {
if sizes.nodes.len() != self.graph.nodes.len()
|| sizes
.nodes
.iter()
.flatten()
.any(|v| !v.is_finite() || *v <= 0.0 || *v > 2.0e7)
{
return Err("Sizes require one finite positive bounded extent per node".into());
}
if self.sizes.as_ref() == Some(&sizes) {
return Ok(());
}
self.sizes = Some(sizes);
self.reindex();
self.rebuild_attributes();
Ok(())
}
#[cfg(feature = "status")]
pub fn set_status(&mut self, status: crate::attr::StatusTable) -> Result<(), String> {
if status.nodes.len() != self.graph.nodes.len()
|| status.edges.len() != self.graph.edges.len()
{
return Err("Status table length differs from graph ordering".into());
}
if self.status.as_ref() == Some(&status) {
return Ok(());
}
self.status = Some(status);
self.rebuild_attributes();
Ok(())
}
#[cfg(feature = "rate")]
pub fn set_rates(&mut self, rates: crate::attr::Rates) -> Result<(), String> {
if rates.edges.len() != self.graph.edges.len()
|| rates
.edges
.iter()
.flatten()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err("Rates require one finite nonnegative optional value per edge".into());
}
if self.rates.as_ref() == Some(&rates) {
return Ok(());
}
self.plan = facett_flowsim::flow::plan_edges(
&rates
.edges
.iter()
.map(|v| v.unwrap_or(0.0))
.collect::<Vec<_>>(),
&[],
&facett_flowsim::flow::FlowParams::default(),
);
self.rates = Some(rates);
self.rebuild_attributes();
Ok(())
}
#[cfg(feature = "arrows")]
pub fn set_arrows(&mut self, value: bool) {
if self.arrows != value {
self.arrows = value;
self.rebuild_attributes();
}
}
fn reindex(&mut self) {
let rects = self
.scene
.raw
.points
.iter()
.enumerate()
.map(|(_i, p)| {
#[cfg(feature = "boxes")]
if let Some(s) = &self.sizes {
return Rect::from_center_size(Pos2::from(p.pos), Vec2::from(s.nodes[_i]));
}
Rect::from_center_size(Pos2::from(p.pos), Vec2::splat(p.radius * 2.0))
})
.collect::<Vec<_>>();
self.bounds = self.scene.bounds;
#[cfg(feature = "boxes")]
if self.sizes.is_some() {
self.bounds = rects.iter().fold(self.bounds, |r, v| r.union(*v));
}
if rects.is_empty() {
self.bounds = self.scene.bounds;
}
self.max_half_extent = rects.iter().fold(Vec2::ZERO, |v, r| v.max(r.size() * 0.5));
self.node_rects = rects;
}
pub(crate) fn ends(&self, i: usize) -> (Pos2, Pos2) {
let e = &self.scene.raw.links[i];
let a = Pos2::from(self.scene.raw.points[e.from as usize].pos);
let b = Pos2::from(self.scene.raw.points[e.to as usize].pos);
#[cfg(feature = "boxes")]
if let Some(s) = &self.sizes {
let vertical = (b.y - a.y).abs() > (b.x - a.x).abs();
let axis = if vertical {
vec2(0.0, (b.y - a.y).signum())
} else {
vec2((b.x - a.x).signum(), 0.0)
};
let k = usize::from(vertical);
return (
a + axis * s.nodes[e.from as usize][k] * 0.5,
b - axis * s.nodes[e.to as usize][k] * 0.5,
);
}
(a, b)
}
pub fn edge_point(&self, i: usize, t: f32) -> Option<Pos2> {
if i >= self.graph.edges.len() || !t.is_finite() {
return None;
}
let (a, b) = self.ends(i);
Some(crate::curve::point(a, b, t.clamp(0.0, 1.0)))
}
pub fn hit(&self, world: Pos2, tol: f32) -> Option<Hit> {
self.hit_with_candidates(world, tol).0
}
/// Candidate count is observable for large-scene performance checks.
pub fn hit_with_candidates(&self, world: Pos2, tol: f32) -> (Option<Hit>, usize) {
if !world.x.is_finite() || !world.y.is_finite() || !tol.is_finite() || tol < 0.0 {
return (None, 0);
}
let r = Rect::from_center_size(world, Vec2::splat(tol * 2.0));
let nodes = self.visible_nodes(r);
let mut count = nodes.len();
if let Some(&i) = nodes.last() {
return (Some(Hit::Node(i)), count);
}
for i in self.visible_edges(r) {
count += 1;
let (a, b) = self.ends(i);
let mut p = a;
for s in 1..=16 {
let q = crate::curve::point(a, b, s as f32 / 16.0);
if crate::curve::distance(world, p, q) <= tol {
return (Some(Hit::Edge(i)), count);
}
p = q;
}
}
(None, count)
}
pub fn hits_in(&self, rect: Rect) -> Vec<Hit> {
let mut hits: Vec<_> = self
.visible_nodes(rect)
.into_iter()
.map(Hit::Node)
.collect();
for i in self.visible_edges(rect) {
let (a, b) = self.ends(i);
let mut p = a;
for s in 1..=16 {
let q = crate::curve::point(a, b, s as f32 / 16.0);
if segment_intersects(rect, p, q) {
hits.push(Hit::Edge(i));
break;
}
p = q;
}
}
hits
}
#[cfg(any(
feature = "boxes",
feature = "status",
feature = "rate",
feature = "arrows"
))]
#[allow(unused_mut)] // each table is independently feature-gated
fn rebuild_attributes(&mut self) {
let mut nodes: Vec<[f32; 4]> = vec![[0.0; 4]; self.graph.nodes.len()];
let mut edges: Vec<[f32; 4]> = vec![[0.18, 1.0, 0.0, 0.0]; self.graph.edges.len()];
#[cfg(feature = "boxes")]
if let Some(s) = &self.sizes {
for (n, s) in nodes.iter_mut().zip(&s.nodes) {
n[0] = s[0];
n[1] = s[1];
}
}
#[cfg(feature = "status")]
if let Some(s) = &self.status {
let code = |s: Option<crate::attr::Status>| match s {
None => 0.0,
Some(crate::attr::Status::Ok) => 1.0,
Some(crate::attr::Status::Slow) => 2.0,
Some(crate::attr::Status::Error) => 3.0,
};
for (n, s) in nodes.iter_mut().zip(&s.nodes) {
n[2] = code(*s);
}
for (e, s) in edges.iter_mut().zip(&s.edges) {
e[2] = code(*s);
}
}
#[cfg(feature = "rate")]
if let Some(r) = &self.rates {
for ((e, rate), p) in edges.iter_mut().zip(&r.edges).zip(&self.plan) {
if rate.is_some() {
e[0] = p.speed;
e[1] = p.count as f32;
e[3] = 1.0;
}
}
}
if !self.arrows
&& nodes.iter().all(|n| *n == [0.0; 4])
&& edges.iter().all(|e| *e == [0.18, 1.0, 0.0, 0.0])
{
self.attributes = None;
return;
}
let mut generation = self.digest;
for v in nodes.iter().chain(&edges).flatten() {
crate::scene::hash_bytes(&mut generation, &v.to_bits().to_le_bytes());
}
crate::scene::hash_bytes(&mut generation, &[u8::from(self.arrows)]);
self.attributes = Some(Arc::new(Attributes {
generation,
nodes,
edges,
arrows: self.arrows,
}));
}
}
fn segment_intersects(r: Rect, a: Pos2, b: Pos2) -> bool {
let d = b - a;
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
for (origin, delta, min, max) in [(a.x, d.x, r.min.x, r.max.x), (a.y, d.y, r.min.y, r.max.y)] {
if delta.abs() < 1e-12 {
if origin < min || origin > max {
return false;
}
} else {
let x = (min - origin) / delta;
let y = (max - origin) / delta;
lo = lo.max(x.min(y));
hi = hi.min(x.max(y));
if lo > hi {
return false;
}
}
}
true
}
#[allow(unused_mut)] // optional status/rate tables override the plain defaults
pub fn paint(
ui: &egui::Ui,
pane: egui::Id,
rect: Rect,
scene: &FlowScene,
view: &View,
opts: &PaintOpts,
) -> Painted {
let mut result = Painted::default();
if !view.scale.is_finite()
|| view.scale <= 0.0
|| !view.offset.x.is_finite()
|| !view.offset.y.is_finite()
|| !rect.is_finite()
{
return result;
}
let palette = opts
.palette
.unwrap_or_else(|| FlowPalette::from_theme(&facett_core::theme(ui)));
let world = Rect::from_min_max(view.world(rect.min), view.world(rect.max));
let geometry = if opts.clustered {
&scene.scene.clusters
} else {
&scene.scene.raw
};
if !opts.clustered {
result.visible_nodes = scene
.visible_nodes(world)
.into_iter()
.map(|i| OnScreen {
node: i,
rect: Rect::from_min_max(
view.screen(scene.node_rects[i].min),
view.screen(scene.node_rects[i].max),
),
px_per_world: view.scale,
})
.collect();
} else {
result.visible_nodes = geometry
.points
.iter()
.enumerate()
.filter_map(|(i, p)| {
let r = Rect::from_center_size(
view.screen(Pos2::from(p.pos)),
Vec2::splat((p.radius * view.scale).clamp(1.2, 14.0) * 2.0),
);
r.intersects(rect).then_some(OnScreen {
node: i,
rect: r,
px_per_world: view.scale,
})
})
.collect();
}
for n in &result.visible_nodes {
if n.rect.width().min(n.rect.height()) < opts.min_px {
continue;
}
if result.on_screen.len() == opts.label_budget {
result.truncated = true;
break;
}
result.on_screen.push(n.clone());
}
result.hover = ui
.input(|i| i.pointer.hover_pos())
.filter(|p| rect.contains(*p))
.and_then(|p| {
if opts.clustered {
result
.visible_nodes
.iter()
.rev()
.find(|n| n.rect.expand(4.0).contains(p))
.map(|n| Hit::Group(n.node))
} else {
scene.hit(view.world(p), 4.0 / view.scale)
}
});
let moving = opts.moving && opts.animation == Animation::Flow;
#[cfg(feature = "wgpu")]
{
result.gpu = crate::gpu::draw_host(
ui,
pane,
rect,
geometry.clone(),
view,
opts,
&palette,
if opts.clustered {
None
} else {
scene.paint_attributes(opts)
},
);
}
#[cfg(not(feature = "wgpu"))]
let _ = pane;
if !result.gpu {
let painter = ui.painter().with_clip_rect(rect);
let max_edges = match opts.cpu {
CpuStyle::Preview => 4000,
CpuStyle::Full { max_edges } => max_edges,
};
let edge_ids = if opts.clustered {
(0..geometry.links.len()).collect()
} else {
scene.visible_edges(world)
};
for i in edge_ids.into_iter().take(max_edges) {
let edge = &geometry.links[i];
let (a, b) = if opts.clustered {
(
Pos2::from(geometry.points[edge.from as usize].pos),
Pos2::from(geometry.points[edge.to as usize].pos),
)
} else {
scene.ends(i)
};
let a = view.screen(a);
let b = view.screen(b);
let mut col = palette.edge;
#[cfg(feature = "status")]
if !opts.clustered
&& let Some(s) = &scene.status
{
col = palette.status.color(s.edges[i], col);
}
let samples = (0..=16)
.map(|s| crate::curve::point(a, b, s as f32 / 16.0))
.collect();
painter.add(egui::Shape::line(
samples,
Stroke::new(1.3, col.gamma_multiply(0.4)),
));
if scene.arrows && !opts.clustered {
let d = (b - crate::curve::point(a, b, 0.95)).normalized();
let n = vec2(-d.y, d.x);
painter.add(egui::Shape::convex_polygon(
vec![b, b - d * 8.0 + n * 4.0, b - d * 8.0 - n * 4.0],
col,
Stroke::NONE,
));
}
let packets_visible = moving;
#[cfg(feature = "lod")]
let packets_visible = packets_visible && a.distance(b) >= opts.min_packet_px;
if packets_visible {
let mut speed = 0.18;
let mut count = 1;
let mut phase = edge.phase;
#[cfg(feature = "rate")]
if !opts.clustered
&& let Some(r) = &scene.rates
&& r.edges[i].is_some()
{
speed = scene.plan[i].speed;
count = scene.plan[i].count;
phase = 0.0;
}
for j in 0..count {
#[cfg(feature = "rate")]
let t =
(facett_flowsim::flow::particle_s(j, count, speed, opts.seconds as f32)
+ phase)
.fract();
#[cfg(not(feature = "rate"))]
let t = (j as f32 / count.max(1) as f32 + speed * opts.seconds as f32 + phase)
.fract();
painter.circle_filled(crate::curve::point(a, b, t), 2.8, palette.pulse);
}
}
}
let cap = if opts.cpu == CpuStyle::Preview {
2000
} else {
usize::MAX
};
for n in result.visible_nodes.iter().take(cap) {
let mut col = palette.node(geometry.points[n.node].kind);
#[cfg(feature = "status")]
if !opts.clustered
&& let Some(s) = &scene.status
{
col = palette.status.color(s.nodes[n.node], col);
}
#[cfg(feature = "boxes")]
if !opts.clustered && scene.sizes.is_some() {
painter.rect_filled(n.rect, 4.0, col);
continue;
}
if geometry.points[n.node].kind < 2.5 {
painter.circle_filled(
n.rect.center(),
(geometry.points[n.node].radius * view.scale).clamp(1.2, 14.0),
col,
);
}
}
}
result
}
#[cfg(all(test, feature = "rate"))]
mod rate_tests {
use super::*;
#[test]
fn rate_table_uses_flowsim_plan_and_busiest_is_fastest() {
let mut scene = FlowScene::new(Graph::demo(10)).unwrap();
let mut values = vec![Some(0.0); scene.graph.edges.len()];
values[1] = Some(10.0);
values[2] = Some(100.0);
scene
.set_rates(crate::attr::Rates {
edges: values.clone(),
})
.unwrap();
let expected = facett_flowsim::flow::plan_edges(
&values.iter().map(|v| v.unwrap()).collect::<Vec<_>>(),
&[],
&Default::default(),
);
assert_eq!(scene.plan, expected);
assert_eq!(scene.plan[0].count, 0);
assert!(scene.plan[2].speed > scene.plan[1].speed);
let attrs = scene.attributes.as_ref().unwrap();
assert_eq!(attrs.edges[0][1], 0.0);
assert_eq!(attrs.edges[2][0], expected[2].speed);
}
}