facett-flow/src/lib.rs
//! Source-agnostic, host-clock driven flow graph. Observable state follows Facett's
//! Elm contract. Derived spatial / cluster caches are outside the serializable model.
//! GPU resources use facett-core's install, buffer, offscreen and pass-clock seams.
#[cfg(any(feature = "boxes", feature = "status", feature = "rate"))]
pub mod attr;
#[cfg(feature = "host")]
mod curve;
#[cfg(feature = "wgpu")]
pub mod gpu;
#[cfg(feature = "host")]
pub mod host;
mod scene;
#[cfg(feature = "host")]
pub use host::{
Animation, CpuStyle, FlowPalette, FlowScene, Hit, OnScreen, PaintOpts, Painted, View, paint,
};
#[cfg(feature = "layered")]
pub use scene::LayeredOpts;
#[cfg(feature = "labels")]
mod labels;
mod view;
use facett_core::{Elm, FacetCaps};
pub use scene::{Edge, Graph, Node};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Camera {
/// World position at the centre of the pane. Zoom is relative to fit-to-pane.
pub centre: [f32; 2],
pub zoom: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Model {
pub id: String,
pub title: String,
pub graph: Arc<Graph>,
pub camera: Camera,
pub seconds: f64,
pub running: bool,
pub reduced_motion: bool,
/// Explicit clusters; raw mode always submits every original node and edge.
pub clustered: bool,
pub selected: Option<String>,
#[cfg(feature = "status")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Arc<attr::StatusTable>>,
#[cfg(feature = "rate")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate: Option<Arc<attr::Rates>>,
#[cfg(feature = "boxes")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boxes: Option<Arc<attr::Sizes>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Msg {
Tick(f64),
Camera(Camera),
Running(bool),
ReducedMotion(bool),
Clustered(bool),
Select(Option<String>),
Replace(Arc<Graph>),
Fit,
#[cfg(feature = "status")]
Status(Arc<attr::StatusTable>),
#[cfg(feature = "rate")]
Rates(Arc<attr::Rates>),
#[cfg(feature = "boxes")]
Sizes(Arc<attr::Sizes>),
}
pub enum Effect {}
pub struct FlowGraph {
model: Model,
pub(crate) scene: scene::Scene,
#[cfg(feature = "host")]
pub(crate) host_scene: Option<host::FlowScene>,
#[cfg(feature = "labels")]
pub(crate) labels: bool,
#[cfg(feature = "compact-state")]
compact: bool,
#[cfg(feature = "compact-state")]
graph_digest: u64,
}
impl FlowGraph {
pub fn new(id: impl Into<String>, graph: Graph) -> Result<Self, String> {
let id = id.into();
if id.trim().is_empty() {
return Err("Facet ID must be nonempty".into());
}
graph.validate()?;
let scene = scene::Scene::new(&graph);
let camera = Camera {
centre: scene.centre(),
zoom: 1.0,
};
#[cfg(feature = "compact-state")]
let graph_digest = scene::digest(&graph, 0);
Ok(Self {
#[cfg(feature = "host")]
host_scene: None,
#[cfg(feature = "labels")]
labels: false,
#[cfg(feature = "compact-state")]
compact: false,
#[cfg(feature = "compact-state")]
graph_digest,
model: Model {
id,
title: "Flow graph".into(),
graph: Arc::new(graph),
camera,
seconds: 0.0,
running: true,
reduced_motion: false,
clustered: false,
selected: None,
#[cfg(feature = "status")]
status: None,
#[cfg(feature = "rate")]
rate: None,
#[cfg(feature = "boxes")]
boxes: None,
},
scene,
})
}
#[allow(unused_mut)] // optional attribute setters rebuild the derived host scene
pub fn from_model(model: Model) -> Result<Self, String> {
model.graph.validate()?;
if model.id.trim().is_empty()
|| !valid_camera(&model.camera)
|| !model.seconds.is_finite()
|| model.seconds < 0.0
|| model
.selected
.as_ref()
.is_some_and(|id| !model.graph.nodes.iter().any(|n| &n.id == id))
{
return Err("Invalid flow state".into());
}
let scene = scene::Scene::new(&model.graph);
#[cfg(feature = "compact-state")]
let graph_digest = scene::digest(&model.graph, 0);
let mut out = Self {
model,
scene,
#[cfg(feature = "host")]
host_scene: None,
#[cfg(feature = "labels")]
labels: false,
#[cfg(feature = "compact-state")]
compact: false,
#[cfg(feature = "compact-state")]
graph_digest,
};
#[cfg(feature = "status")]
if let Some(s) = out.model.status.clone() {
out.ensure_host()?.set_status((*s).clone())?;
}
#[cfg(feature = "rate")]
if let Some(s) = out.model.rate.clone() {
out.ensure_host()?.set_rates((*s).clone())?;
}
#[cfg(feature = "boxes")]
if let Some(s) = out.model.boxes.clone() {
out.ensure_host()?.set_sizes((*s).clone())?;
}
Ok(out)
}
/// Derived bounds for host camera scripts; does not mutate the model.
pub fn bounds(&self) -> egui::Rect {
#[cfg(feature = "host")]
if let Some(scene) = &self.host_scene {
return scene.bounds();
}
self.scene.bounds
}
/// Exact submitted geometry counts for the currently selected render mode.
pub fn render_counts(&self) -> (usize, usize) {
let geometry = if self.model.clustered {
&self.scene.clusters
} else {
&self.scene.raw
};
(geometry.points.len(), geometry.links.len())
}
#[cfg(any(feature = "boxes", feature = "status", feature = "rate"))]
fn ensure_host(&mut self) -> Result<&mut host::FlowScene, String> {
if self.host_scene.is_none() {
self.host_scene = Some(host::FlowScene::new((*self.model.graph).clone())?);
}
Ok(self.host_scene.as_mut().unwrap())
}
#[cfg(feature = "labels")]
pub fn set_labels(&mut self, enabled: bool) {
self.labels = enabled;
}
#[cfg(feature = "compact-state")]
pub fn set_compact_state(&mut self, enabled: bool) {
self.compact = enabled;
}
#[cfg(feature = "compact-state")]
pub fn compact_state_json(&self) -> serde_json::Value {
serde_json::json!({"node_count":self.model.graph.nodes.len(),"edge_count":self.model.graph.edges.len(),"graph_digest":self.graph_digest,"camera":self.model.camera,"seconds":self.model.seconds,"running":self.model.running,"reduced_motion":self.model.reduced_motion,"clustered":self.model.clustered,"selected":self.model.selected,"render_counts":self.render_counts(),"lane":if cfg!(feature="wgpu"){"wgpu-capable"}else{"cpu"}})
}
pub fn local() -> Self {
let mut flow = Self::new("flow-hub", Graph::hub_demo(10_000)).expect("valid fixture");
Elm::update(&mut flow, Msg::Clustered(true));
flow
}
}
fn valid_camera(c: &Camera) -> bool {
c.centre.iter().all(|v| v.is_finite() && v.abs() <= 1.0e8)
&& c.zoom.is_finite()
&& (0.1..=100.0).contains(&c.zoom)
}
impl Elm for FlowGraph {
type Model = Model;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.model.title
}
fn state(&self) -> &Model {
&self.model
}
#[allow(clippy::collapsible_match)] // Keep fallible mutating scene setters out of match guards.
fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Tick(dt)
if self.model.running
&& !self.model.reduced_motion
&& dt.is_finite()
&& dt >= 0.0 =>
{
self.model.seconds = (self.model.seconds.rem_euclid(3600.0)
+ dt.rem_euclid(3600.0))
.rem_euclid(3600.0);
}
Msg::Camera(camera) if valid_camera(&camera) => self.model.camera = camera,
Msg::Running(value) => self.model.running = value,
Msg::ReducedMotion(value) => self.model.reduced_motion = value,
Msg::Clustered(value) => self.model.clustered = value,
Msg::Select(id) if id.as_ref().is_none_or(|id| self.scene.ids.contains_key(id)) => {
self.model.selected = id
}
Msg::Replace(graph) if graph.validate().is_ok() => {
self.scene = scene::Scene::new(&graph);
#[cfg(feature = "compact-state")]
{
self.graph_digest = scene::digest(&graph, 0);
}
#[cfg(feature = "host")]
{
self.host_scene = None;
}
#[cfg(feature = "status")]
{
self.model.status = None;
}
#[cfg(feature = "rate")]
{
self.model.rate = None;
}
#[cfg(feature = "boxes")]
{
self.model.boxes = None;
}
if self
.model
.selected
.as_ref()
.is_some_and(|id| !self.scene.ids.contains_key(id))
{
self.model.selected = None;
}
self.model.graph = graph;
self.model.camera = Camera {
centre: self.scene.centre(),
zoom: 1.0,
};
}
#[cfg(feature = "status")]
Msg::Status(value) => {
if self
.ensure_host()
.and_then(|s| s.set_status((*value).clone()))
.is_ok()
{
self.model.status = Some(value);
}
}
#[cfg(feature = "rate")]
Msg::Rates(value) => {
if self
.ensure_host()
.and_then(|s| s.set_rates((*value).clone()))
.is_ok()
{
self.model.rate = Some(value);
}
}
#[cfg(feature = "boxes")]
Msg::Sizes(value) => {
if self
.ensure_host()
.and_then(|s| s.set_sizes((*value).clone()))
.is_ok()
{
self.model.boxes = Some(value);
}
}
Msg::Fit => {
self.model.camera = Camera {
centre: self.bounds().center().into(),
zoom: 1.0,
}
}
_ => {}
}
Vec::new()
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
ui.push_id(("flow-scale", &self.model.id), |ui| view::show(self, ui))
.inner
}
}
facett_core::impl_facet_via_elm!(FlowGraph, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
#[cfg(feature = "compact-state")]
if self.compact {
return self.compact_state_json();
}
serde_json::to_value(&self.model).unwrap_or(serde_json::Value::Null)
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable()
}
fn selection_json(&self) -> serde_json::Value {
serde_json::json!(self.model.selected)
}
fn update_json(&mut self, json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(json) {
let _ = Elm::update(self, msg);
}
}
});
/// Features compiled into this binary, for reproducible benchmark reports.
pub fn compiled_features() -> Vec<&'static str> {
let mut result = Vec::new();
if cfg!(feature = "wgpu") {
result.push("wgpu");
}
if cfg!(feature = "host") {
result.push("host");
}
if cfg!(feature = "labels") {
result.push("labels");
}
if cfg!(feature = "boxes") {
result.push("boxes");
}
if cfg!(feature = "arrows") {
result.push("arrows");
}
if cfg!(feature = "status") {
result.push("status");
}
if cfg!(feature = "rate") {
result.push("rate");
}
if cfg!(feature = "layered") {
result.push("layered");
}
if cfg!(feature = "compact-state") {
result.push("compact-state");
}
if cfg!(feature = "hit-test") {
result.push("hit-test");
}
if cfg!(feature = "lod") {
result.push("lod");
}
result
}