From 6e5565d45c2ad31fb545d72a839431fc06f8ae39 Mon Sep 17 00:00:00 2001 From: hodasemi Date: Sun, 30 Jan 2022 10:53:40 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + .vscode/launch.json | 26 +++ .vscode/settings.json | 7 + Cargo.toml | 19 +++ src/color_hex_utils.rs | 93 ++++++++++ src/editor_state.rs | 69 ++++++++ src/graph_editor_egui.rs | 160 +++++++++++++++++ src/graph_impls.rs | 203 ++++++++++++++++++++++ src/graph_node_ui.rs | 359 +++++++++++++++++++++++++++++++++++++++ src/graph_types.rs | 141 +++++++++++++++ src/id_types.rs | 24 +++ src/index_impls.rs | 36 ++++ src/main.rs | 41 +++++ src/node_finder.rs | 68 ++++++++ src/node_types.rs | 210 +++++++++++++++++++++++ src/param_ui.rs | 89 ++++++++++ 16 files changed, 1548 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Cargo.toml create mode 100644 src/color_hex_utils.rs create mode 100644 src/editor_state.rs create mode 100644 src/graph_editor_egui.rs create mode 100644 src/graph_impls.rs create mode 100644 src/graph_node_ui.rs create mode 100644 src/graph_types.rs create mode 100644 src/id_types.rs create mode 100644 src/index_impls.rs create mode 100644 src/main.rs create mode 100644 src/node_finder.rs create mode 100644 src/node_types.rs create mode 100644 src/param_ui.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fba7613 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target + +Cargo.lock \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7714498 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'virtualdevicecreator'", + "cargo": { + "args": [ + "build", + "--bin=virtualdevicecreator", + "--package=virtualdevicecreator" + ], + "filter": { + "name": "virtualdevicecreator", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f0bdf72 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#063147", + "titleBar.activeBackground": "#094563", + "titleBar.activeForeground": "#F6FBFE" + } +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..20e2468 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "virtualdevicecreator" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +egui = { version = "*", features = ["persistence"] } +eframe = "*" +epi = "*" +slotmap = { version = "*", features = ["serde"] } +smallvec = { version = "*", features = ["serde"] } +strum = "*" +strum_macros = "*" +serde = { version = "*", features = ["derive"] } +anyhow = "*" +nalgebra = { version = "*", features = ["serde-serialize"] } +rfd = "*" \ No newline at end of file diff --git a/src/color_hex_utils.rs b/src/color_hex_utils.rs new file mode 100644 index 0000000..769ca1d --- /dev/null +++ b/src/color_hex_utils.rs @@ -0,0 +1,93 @@ +use egui::Color32; + +/// Converts a hex string with a leading '#' into a egui::Color32. +/// - The first three channels are interpreted as R, G, B. +/// - The fourth channel, if present, is used as the alpha value. +/// - Both upper and lowercase characters can be used for the hex values. +/// +/// *Adapted from: https://docs.rs/raster/0.1.0/src/raster/lib.rs.html#425-725. +/// Credit goes to original authors.* +pub fn color_from_hex(hex: &str) -> Result { + // Convert a hex string to decimal. Eg. "00" -> 0. "FF" -> 255. + fn _hex_dec(hex_string: &str) -> Result { + match u8::from_str_radix(hex_string, 16) { + Ok(o) => Ok(o), + Err(e) => Err(format!("Error parsing hex: {}", e)), + } + } + + if hex.len() == 9 && hex.starts_with('#') { + // #FFFFFFFF (Red Green Blue Alpha) + return Ok(Color32::from_rgba_premultiplied( + _hex_dec(&hex[1..3])?, + _hex_dec(&hex[3..5])?, + _hex_dec(&hex[5..7])?, + _hex_dec(&hex[7..9])?, + )); + } else if hex.len() == 7 && hex.starts_with('#') { + // #FFFFFF (Red Green Blue) + return Ok(Color32::from_rgb( + _hex_dec(&hex[1..3])?, + _hex_dec(&hex[3..5])?, + _hex_dec(&hex[5..7])?, + )); + } + + Err(format!( + "Error parsing hex: {}. Example of valid formats: #FFFFFF or #ffffffff", + hex + )) +} + +/// Converts a Color32 into its canonical hexadecimal representation. +/// - The color string will be preceded by '#'. +/// - If the alpha channel is completely opaque, it will be ommitted. +/// - Characters from 'a' to 'f' will be written in lowercase. +pub fn color_to_hex(color: Color32) -> String { + if color.a() < 255 { + format!( + "#{:02x?}{:02x?}{:02x?}{:02x?}", + color.r(), + color.g(), + color.b(), + color.a() + ) + } else { + format!("#{:02x?}{:02x?}{:02x?}", color.r(), color.g(), color.b()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn test_color_from_and_to_hex() { + assert_eq!( + color_from_hex("#00ff00").unwrap(), + Color32::from_rgb(0, 255, 0) + ); + assert_eq!( + color_from_hex("#5577AA").unwrap(), + Color32::from_rgb(85, 119, 170) + ); + assert_eq!( + color_from_hex("#E2e2e277").unwrap(), + Color32::from_rgba_premultiplied(226, 226, 226, 119) + ); + assert!(color_from_hex("abcdefgh").is_err()); + + assert_eq!( + color_to_hex(Color32::from_rgb(0, 255, 0)), + "#00ff00".to_string() + ); + assert_eq!( + color_to_hex(Color32::from_rgb(85, 119, 170)), + "#5577aa".to_string() + ); + assert_eq!( + color_to_hex(Color32::from_rgba_premultiplied(226, 226, 226, 119)), + "e2e2e277".to_string() + ); + } +} diff --git a/src/editor_state.rs b/src/editor_state.rs new file mode 100644 index 0000000..efb340a --- /dev/null +++ b/src/editor_state.rs @@ -0,0 +1,69 @@ +use crate::graph_types::*; +use crate::id_types::*; +use crate::node_finder::NodeFinder; + +use std::collections::HashMap; + +#[derive(Copy, Clone, serde::Serialize, serde::Deserialize)] +pub struct PanZoom { + pub pan: egui::Vec2, + pub zoom: f32, +} + +pub struct GraphEditorState { + pub graph: Graph, + /// An ongoing connection interaction: The mouse has dragged away from a + /// port and the user is holding the click + pub connection_in_progress: Option<(NodeId, AnyParameterId)>, + /// The currently active node. A program will be compiled to compute the + /// result of this node and constantly updated in real-time. + pub active_node: Option, + /// The position of each node. + pub node_positions: HashMap, + /// The node finder is used to create new nodes. + pub node_finder: Option, + /// When this option is set by the UI, the side effect encoded by the node + /// will be executed at the start of the next frame. + pub run_side_effect: Option, + /// The panning of the graph viewport. + pub pan_zoom: PanZoom, +} + +impl GraphEditorState { + pub fn new() -> Self { + Self { + graph: Graph::new(), + connection_in_progress: None, + active_node: None, + run_side_effect: None, + node_positions: HashMap::new(), + node_finder: None, + pan_zoom: PanZoom { + pan: egui::Vec2::ZERO, + zoom: 1.0, + }, + } + } +} + +impl Default for GraphEditorState { + fn default() -> Self { + Self::new() + } +} + +impl PanZoom { + pub fn adjust_zoom( + &mut self, + zoom_delta: f32, + point: egui::Vec2, + zoom_min: f32, + zoom_max: f32, + ) { + let zoom_clamped = (self.zoom + zoom_delta).clamp(zoom_min, zoom_max); + let zoom_delta = zoom_clamped - self.zoom; + + self.zoom += zoom_delta; + self.pan += point * zoom_delta; + } +} diff --git a/src/graph_editor_egui.rs b/src/graph_editor_egui.rs new file mode 100644 index 0000000..ecbc102 --- /dev/null +++ b/src/graph_editor_egui.rs @@ -0,0 +1,160 @@ +use egui::*; + +use crate::color_hex_utils::*; +use crate::editor_state::GraphEditorState; +use crate::graph_node_ui::*; +use crate::id_types::*; +use crate::node_finder::NodeFinder; + +pub fn draw_graph_editor(ctx: &CtxRef, state: &mut GraphEditorState) { + let mouse = &ctx.input().pointer; + let cursor_pos = mouse.hover_pos().unwrap_or(Pos2::ZERO); + + // Gets filled with the port locations as nodes are drawn + let mut port_locations = PortLocations::new(); + + // The responses returned from node drawing have side effects that are best + // executed at the end of this function. + let mut delayed_responses: Vec = vec![]; + + CentralPanel::default().show(ctx, |ui| { + /* Draw nodes */ + let nodes = state.graph.iter_nodes().collect::>(); // avoid borrow checker + for node_id in nodes { + let response = GraphNodeWidget { + position: state.node_positions.get_mut(&node_id).unwrap(), + graph: &mut state.graph, + port_locations: &mut port_locations, + node_id, + ongoing_drag: state.connection_in_progress, + active: state + .active_node + .map(|active| active == node_id) + .unwrap_or(false), + pan: state.pan_zoom.pan, + } + .show(ui); + + if let Some(response) = response { + delayed_responses.push(response); + } + } + }); + + /* Draw the node finder, if open */ + let mut should_close_node_finder = false; + if let Some(ref mut node_finder) = state.node_finder { + let mut node_finder_area = Area::new("node_finder"); + if let Some(pos) = node_finder.position { + node_finder_area = node_finder_area.current_pos(pos); + } + node_finder_area.show(ctx, |ui| { + if let Some(node_archetype) = node_finder.show(ui) { + let new_node = state.graph.add_node(node_archetype.to_descriptor()); + state + .node_positions + .insert(new_node, cursor_pos - state.pan_zoom.pan); + should_close_node_finder = true; + } + }); + } + if should_close_node_finder { + state.node_finder = None; + } + + /* Draw connections */ + let connection_stroke = egui::Stroke { + width: 5.0, + color: color_from_hex("#efefef").unwrap(), + }; + + if let Some((_, ref locator)) = state.connection_in_progress { + let painter = ctx.layer_painter(LayerId::background()); + let start_pos = port_locations[locator]; + painter.line_segment([start_pos, cursor_pos], connection_stroke) + } + + for (input, output) in state.graph.iter_connections() { + let painter = ctx.layer_painter(LayerId::background()); + let src_pos = port_locations[&AnyParameterId::Output(output)]; + let dst_pos = port_locations[&AnyParameterId::Input(input)]; + painter.line_segment([src_pos, dst_pos], connection_stroke); + } + + /* Handle responses from drawing nodes */ + + for response in delayed_responses { + match response { + DrawGraphNodeResponse::ConnectEventStarted(node_id, port) => { + state.connection_in_progress = Some((node_id, port)); + } + DrawGraphNodeResponse::ConnectEventEnded(locator) => { + let in_out = match ( + state + .connection_in_progress + .map(|(_node, param)| param) + .take() + .expect("Cannot end drag without in-progress connection."), + locator, + ) { + (AnyParameterId::Input(input), AnyParameterId::Output(output)) + | (AnyParameterId::Output(output), AnyParameterId::Input(input)) => { + Some((input, output)) + } + _ => None, + }; + + if let Some((input, output)) = in_out { + state.graph.add_connection(output, input) + } + } + DrawGraphNodeResponse::SetActiveNode(node_id) => { + state.active_node = Some(node_id); + } + DrawGraphNodeResponse::ClearActiveNode => { + state.active_node = None; + } + DrawGraphNodeResponse::RunNodeSideEffect(node_id) => { + state.run_side_effect = Some(node_id); + } + DrawGraphNodeResponse::DeleteNode(node_id) => { + state.graph.remove_node(node_id); + state.node_positions.remove(&node_id); + // Make sure to not leave references to old nodes hanging + if state.active_node.map(|x| x == node_id).unwrap_or(false) { + state.active_node = None; + } + if state.run_side_effect.map(|x| x == node_id).unwrap_or(false) { + state.run_side_effect = None; + } + } + DrawGraphNodeResponse::DisconnectEvent(input_id) => { + let corresp_output = state + .graph + .connection(input_id) + .expect("Connection data should be valid"); + let other_node = state.graph.get_input(input_id).node(); + state.graph.remove_connection(input_id); + state.connection_in_progress = + Some((other_node, AnyParameterId::Output(corresp_output))); + } + } + } + + /* Mouse input handling */ + + if mouse.any_released() && state.connection_in_progress.is_some() { + state.connection_in_progress = None; + } + + if mouse.button_down(PointerButton::Secondary) { + state.node_finder = Some(NodeFinder::new_at(cursor_pos)); + } + if ctx.input().key_pressed(Key::Escape) { + state.node_finder = None; + } + + if ctx.input().pointer.middle_down() { + state.pan_zoom.pan += ctx.input().pointer.delta(); + } +} diff --git a/src/graph_impls.rs b/src/graph_impls.rs new file mode 100644 index 0000000..c326161 --- /dev/null +++ b/src/graph_impls.rs @@ -0,0 +1,203 @@ +use anyhow::{anyhow, Result}; +use smallvec::smallvec; + +use crate::graph_types::*; +use crate::id_types::*; +use crate::SVec; + +impl Graph { + pub fn new() -> Self { + Self::default() + } + + pub fn add_node(&mut self, d: NodeDescriptor) -> NodeId { + let node_id = self.nodes.insert_with_key(|node_id| { + Node { + id: node_id, + label: d.label, + op_name: d.op_name, + // These will get filled in later + inputs: Vec::default(), + outputs: Vec::default(), + is_executable: d.is_executable, + } + }); + + use InputParamKind::*; + let inputs: Vec<(String, InputId)> = d + .inputs + .into_iter() + .map(|(input_name, input)| { + let input_id = self.inputs.insert_with_key(|id| match input { + InputDescriptor::Vector { default } => InputParam { + id, + typ: DataType::Vector, + value: InputParamValue::Vector(default), + metadata: smallvec![], + kind: ConnectionOrConstant, + node: node_id, + }, + InputDescriptor::Mesh => InputParam { + id, + typ: DataType::Mesh, + value: InputParamValue::None, + metadata: smallvec![], + kind: ConnectionOnly, + node: node_id, + }, + InputDescriptor::Selection => InputParam { + id, + typ: DataType::Selection, + value: InputParamValue::Selection { + text: "".into(), + selection: Some(vec![]), + }, + metadata: smallvec![], + kind: ConnectionOrConstant, + node: node_id, + }, + InputDescriptor::Scalar { default, min, max } => InputParam { + id, + typ: DataType::Scalar, + value: InputParamValue::Scalar(default), + metadata: smallvec![InputParamMetadata::MinMaxScalar { min, max }], + kind: ConnectionOrConstant, + node: node_id, + }, + InputDescriptor::Enum { values } => InputParam { + id, + typ: DataType::Enum, + value: InputParamValue::Enum { + values, + selection: None, + }, + metadata: smallvec![], + kind: ConstantOnly, + node: node_id, + }, + InputDescriptor::NewFile => InputParam { + id, + typ: DataType::NewFile, + value: InputParamValue::NewFile { path: None }, + metadata: smallvec![], + kind: ConstantOnly, + node: node_id, + }, + }); + (input_name, input_id) + }) + .collect(); + + let outputs: Vec<(String, OutputId)> = d + .outputs + .into_iter() + .map(|(output_name, output)| { + let output_id = self.outputs.insert_with_key(|id| OutputParam { + node: node_id, + id, + typ: output.0, + }); + (output_name, output_id) + }) + .collect(); + + self[node_id].inputs = inputs; + self[node_id].outputs = outputs; + node_id + } + + pub fn remove_node(&mut self, node_id: NodeId) { + self.connections + .retain(|i, o| !(self.outputs[*o].node == node_id || self.inputs[*i].node == node_id)); + let inputs: SVec<_> = self[node_id].input_ids().collect(); + for input in inputs { + self.inputs.remove(input); + } + let outputs: SVec<_> = self[node_id].output_ids().collect(); + for output in outputs { + self.outputs.remove(output); + } + self.nodes.remove(node_id); + } + + pub fn remove_connection(&mut self, input_id: InputId) -> Option { + self.connections.remove(&input_id) + } + + pub fn iter_nodes(&self) -> impl Iterator + '_ { + self.nodes.iter().map(|(id, _)| id) + } + + pub fn add_connection(&mut self, output: OutputId, input: InputId) { + self.connections.insert(input, output); + } + + pub fn iter_connections(&self) -> impl Iterator + '_ { + self.connections.iter().map(|(o, i)| (*o, *i)) + } + + pub fn connection(&self, input: InputId) -> Option { + self.connections.get(&input).copied() + } + + pub fn any_param_type(&self, param: AnyParameterId) -> Result { + match param { + AnyParameterId::Input(input) => self.inputs.get(input).map(|x| x.typ), + AnyParameterId::Output(output) => self.outputs.get(output).map(|x| x.typ), + } + .ok_or_else(|| anyhow!("Invalid parameter id: {:?}", param)) + } + + pub fn get_input(&self, input: InputId) -> &InputParam { + &self.inputs[input] + } + + pub fn get_output(&self, output: OutputId) -> &OutputParam { + &self.outputs[output] + } +} + +impl Node { + pub fn inputs<'a>(&'a self, graph: &'a Graph) -> impl Iterator + 'a { + self.input_ids().map(|id| graph.get_input(id)) + } + + pub fn outputs<'a>(&'a self, graph: &'a Graph) -> impl Iterator + 'a { + self.output_ids().map(|id| graph.get_output(id)) + } + + pub fn input_ids(&self) -> impl Iterator + '_ { + self.inputs.iter().map(|(_name, id)| *id) + } + + pub fn output_ids(&self) -> impl Iterator + '_ { + self.outputs.iter().map(|(_name, id)| *id) + } + + pub fn get_input(&self, name: &str) -> Result { + self.inputs + .iter() + .find(|(param_name, _id)| param_name == name) + .map(|x| x.1) + .ok_or_else(|| anyhow!("Node {:?} has no parameter named {}", self.id, name)) + } + + pub fn get_output(&self, name: &str) -> Result { + self.outputs + .iter() + .find(|(param_name, _id)| param_name == name) + .map(|x| x.1) + .ok_or_else(|| anyhow!("Node {:?} has no parameter named {}", self.id, name)) + } + + /// Can this node be enabled on the UI? I.e. does it output a mesh? + pub fn can_be_enabled(&self, graph: &Graph) -> bool { + self.outputs(graph) + .any(|output| output.typ == DataType::Mesh) + } + + /// Executable nodes are used to produce side effects, like exporting files. + pub fn is_executable(&self) -> bool { + self.is_executable + } +} diff --git a/src/graph_node_ui.rs b/src/graph_node_ui.rs new file mode 100644 index 0000000..4d7f31e --- /dev/null +++ b/src/graph_node_ui.rs @@ -0,0 +1,359 @@ +use egui::*; +use epaint::*; + +use crate::color_hex_utils::*; +use crate::graph_types::*; +use crate::id_types::*; + +pub type PortLocations = std::collections::HashMap; + +pub enum DrawGraphNodeResponse { + ConnectEventStarted(NodeId, AnyParameterId), + ConnectEventEnded(AnyParameterId), + SetActiveNode(NodeId), + RunNodeSideEffect(NodeId), + ClearActiveNode, + DeleteNode(NodeId), + DisconnectEvent(InputId), +} + +pub struct GraphNodeWidget<'a> { + pub position: &'a mut Pos2, + pub graph: &'a mut Graph, + pub port_locations: &'a mut PortLocations, + pub node_id: NodeId, + pub ongoing_drag: Option<(NodeId, AnyParameterId)>, + pub active: bool, + pub pan: egui::Vec2, +} + +impl<'a> GraphNodeWidget<'a> { + pub const MAX_NODE_SIZE: [f32; 2] = [200.0, 200.0]; + + pub fn show(self, ui: &mut Ui) -> Option { + let mut child_ui = ui.child_ui( + Rect::from_min_size(*self.position + self.pan, Self::MAX_NODE_SIZE.into()), + Layout::default(), + ); + + let node_resp = Self::show_graph_node( + self.graph, + self.node_id, + &mut child_ui, + self.port_locations, + self.ongoing_drag, + self.active, + ); + + let resp = ui.allocate_rect(child_ui.min_rect(), Sense::drag()); + *self.position += resp.drag_delta(); + + node_resp + } + + /// Draws this node. Also fills in the list of port locations with all of its ports. + /// Returns a response showing whether a drag event was started. + /// Parameters: + /// - **ongoing_drag**: Is there a port drag event currently going on? + fn show_graph_node( + graph: &mut Graph, + node_id: NodeId, + ui: &mut Ui, + port_locations: &mut PortLocations, + ongoing_drag: Option<(NodeId, AnyParameterId)>, + active: bool, + ) -> Option { + let margin = egui::vec2(15.0, 5.0); + let _field_separation = 5.0; + let mut response: Option = None; + + let background_color = color_from_hex("#3f3f3f").unwrap(); + let titlebar_color = background_color.linear_multiply(0.8); + let text_color = color_from_hex("#fefefe").unwrap(); + + ui.visuals_mut().widgets.noninteractive.fg_stroke = Stroke::new(2.0, text_color); + + // Preallocate shapes to paint below contents + let background_shape = ui.painter().add(Shape::Noop); + + let outer_rect_bounds = ui.available_rect_before_wrap(); + let mut inner_rect = outer_rect_bounds.shrink2(margin); + + // Make sure we don't shrink to the negative: + inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x); + inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y); + + let mut child_ui = ui.child_ui(inner_rect, *ui.layout()); + let mut title_height = 0.0; + + let mut input_port_heights = vec![]; + let mut output_port_heights = vec![]; + + child_ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.add(Label::new( + RichText::new(&graph[node_id].label) + .text_style(TextStyle::Button) + .color(color_from_hex("#fefefe").unwrap()), + )); + }); + ui.add_space(margin.y); + title_height = ui.min_size().y; + + // First pass: Draw the inner fields. Compute port heights + let inputs = graph[node_id].inputs.clone(); + for (param_name, param) in inputs { + let height_before = ui.min_rect().bottom(); + if graph.connection(param).is_some() { + ui.label(param_name); + } else { + graph[param].value_widget(¶m_name, ui); + } + let height_after = ui.min_rect().bottom(); + input_port_heights.push((height_before + height_after) / 2.0); + } + + let outputs = graph[node_id].outputs.clone(); + for (param_name, _param) in outputs { + let height_before = ui.min_rect().bottom(); + ui.label(¶m_name); + let height_after = ui.min_rect().bottom(); + output_port_heights.push((height_before + height_after) / 2.0); + } + + // Button row + ui.horizontal(|ui| { + // Show 'Enable' button for nodes that output a mesh + if graph[node_id].can_be_enabled(graph) { + ui.horizontal(|ui| { + if !active { + if ui.button("👁 Set active").clicked() { + response = Some(DrawGraphNodeResponse::SetActiveNode(node_id)); + } + } else { + let button = egui::Button::new( + RichText::new("👁 Active").color(egui::Color32::BLACK), + ) + .fill(egui::Color32::GOLD); + if ui.add(button).clicked() { + response = Some(DrawGraphNodeResponse::ClearActiveNode); + } + } + }); + } + // Show 'Run' button for executable nodes + if graph[node_id].is_executable() && ui.button("⛭ Run").clicked() { + response = Some(DrawGraphNodeResponse::RunNodeSideEffect(node_id)); + } + }) + }); + + // Second pass, iterate again to draw the ports. This happens outside + // the child_ui because we want ports to overflow the node background. + + let outer_rect = child_ui.min_rect().expand2(margin); + let port_left = outer_rect.left(); + let port_right = outer_rect.right(); + + #[allow(clippy::too_many_arguments)] + fn draw_port( + ui: &mut Ui, + graph: &Graph, + node_id: NodeId, + port_pos: Pos2, + response: &mut Option, + param_id: AnyParameterId, + port_locations: &mut PortLocations, + ongoing_drag: Option<(NodeId, AnyParameterId)>, + is_connected_input: bool, + ) { + let port_type = graph.any_param_type(param_id).unwrap(); + + let port_rect = Rect::from_center_size(port_pos, egui::vec2(10.0, 10.0)); + + let sense = if ongoing_drag.is_some() { + Sense::hover() + } else { + Sense::click_and_drag() + }; + + let resp = ui.allocate_rect(port_rect, sense); + let port_color = if resp.hovered() { + Color32::WHITE + } else { + GraphNodeWidget::data_type_color(port_type) + }; + ui.painter() + .circle(port_rect.center(), 5.0, port_color, Stroke::none()); + + if resp.drag_started() { + if is_connected_input { + *response = Some(DrawGraphNodeResponse::DisconnectEvent( + param_id.assume_input(), + )); + } else { + *response = Some(DrawGraphNodeResponse::ConnectEventStarted( + node_id, param_id, + )); + } + } + + if let Some((origin_node, origin_param)) = ongoing_drag { + if origin_node != node_id { + // Don't allow self-loops + if graph.any_param_type(origin_param).unwrap() == port_type + && resp.hovered() + && ui.input().pointer.any_released() + { + *response = Some(DrawGraphNodeResponse::ConnectEventEnded(param_id)); + } + } + } + + port_locations.insert(param_id, port_rect.center()); + } + + // Input ports + for ((_, param), port_height) in graph[node_id] + .inputs + .iter() + .zip(input_port_heights.into_iter()) + { + let should_draw = match graph[*param].kind() { + InputParamKind::ConnectionOnly => true, + InputParamKind::ConstantOnly => false, + InputParamKind::ConnectionOrConstant => true, + }; + + if should_draw { + let pos_left = pos2(port_left, port_height); + draw_port( + ui, + graph, + node_id, + pos_left, + &mut response, + AnyParameterId::Input(*param), + port_locations, + ongoing_drag, + graph.connection(*param).is_some(), + ); + } + } + + // Output ports + for ((_, param), port_height) in graph[node_id] + .outputs + .iter() + .zip(output_port_heights.into_iter()) + { + let pos_right = pos2(port_right, port_height); + draw_port( + ui, + graph, + node_id, + pos_right, + &mut response, + AnyParameterId::Output(*param), + port_locations, + ongoing_drag, + false, + ); + } + + // Draw the background shape. + // NOTE: This code is a bit more involve than it needs to be because egui + // does not support drawing rectangles with asymmetrical round corners. + + let shape = { + let corner_radius = 4.0; + + let titlebar_height = title_height + margin.y; + let titlebar_rect = + Rect::from_min_size(outer_rect.min, vec2(outer_rect.width(), titlebar_height)); + let titlebar = Shape::Rect(RectShape { + rect: titlebar_rect, + corner_radius, + fill: titlebar_color, + stroke: Stroke::none(), + }); + + let body_rect = Rect::from_min_size( + outer_rect.min + vec2(0.0, titlebar_height - corner_radius), + vec2(outer_rect.width(), outer_rect.height() - titlebar_height), + ); + let body = Shape::Rect(RectShape { + rect: body_rect, + corner_radius: 0.0, + fill: background_color, + stroke: Stroke::none(), + }); + + let bottom_body_rect = Rect::from_min_size( + body_rect.min + vec2(0.0, body_rect.height() - titlebar_height * 0.5), + vec2(outer_rect.width(), titlebar_height), + ); + let bottom_body = Shape::Rect(RectShape { + rect: bottom_body_rect, + corner_radius, + fill: background_color, + stroke: Stroke::none(), + }); + + Shape::Vec(vec![titlebar, body, bottom_body]) + }; + + ui.painter().set(background_shape, shape); + ui.allocate_rect(outer_rect, Sense::hover()); + + // Titlebar buttons + if Self::close_button(ui, outer_rect).clicked() { + response = Some(DrawGraphNodeResponse::DeleteNode(node_id)); + }; + + response + } + + fn close_button(ui: &mut Ui, node_rect: Rect) -> Response { + // Measurements + let margin = 8.0; + let size = 10.0; + let stroke_width = 2.0; + let offs = margin + size / 2.0; + + let position = pos2(node_rect.right() - offs, node_rect.top() + offs); + let rect = Rect::from_center_size(position, vec2(size, size)); + let resp = ui.allocate_rect(rect, Sense::click()); + + let color = if resp.clicked() { + color_from_hex("#ffffff").unwrap() + } else if resp.hovered() { + color_from_hex("#dddddd").unwrap() + } else { + color_from_hex("#aaaaaa").unwrap() + }; + let stroke = Stroke { + width: stroke_width, + color, + }; + + ui.painter() + .line_segment([rect.left_top(), rect.right_bottom()], stroke); + ui.painter() + .line_segment([rect.right_top(), rect.left_bottom()], stroke); + + resp + } + + /// The port colors for all the data types + fn data_type_color(data_type: DataType) -> egui::Color32 { + match data_type { + DataType::Mesh => color_from_hex("#266dd3").unwrap(), + DataType::Vector => color_from_hex("#eecf6d").unwrap(), + DataType::Scalar => color_from_hex("#eb9fef").unwrap(), + DataType::Selection => color_from_hex("#4b7f52").unwrap(), + DataType::Enum => color_from_hex("#ff0000").unwrap(), // Should never be in a port, so highlight in red + DataType::NewFile => color_from_hex("#ff0000").unwrap(), // Should never be in a port, so highlight in red + } + } +} diff --git a/src/graph_types.rs b/src/graph_types.rs new file mode 100644 index 0000000..6da37c9 --- /dev/null +++ b/src/graph_types.rs @@ -0,0 +1,141 @@ +use nalgebra::Vector3 as Vec3; +use serde::{Deserialize, Serialize}; +use slotmap::SlotMap; +use std::collections::HashMap; + +use crate::id_types::*; +use crate::SVec; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataType { + Vector, + Scalar, + Selection, + Mesh, + Enum, + // The path to a (possibly new) file where export contents will be saved to + NewFile, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum InputParamMetadata { + MinMaxScalar { min: f32, max: f32 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum InputParamValue { + Vector(Vec3), + Scalar(f32), + Selection { + text: String, + selection: Option>, + }, + /// Used for parameters that can't have a value because they only accept + /// connections. + None, + Enum { + values: Vec, + selection: Option, + }, + NewFile { + path: Option, + }, +} + +/// There are three kinds of input params +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum InputParamKind { + /// No constant value can be set. Only incoming connections can produce it + ConnectionOnly, + /// Only a constant value can be set. No incoming connections accepted. + ConstantOnly, + /// Both incoming connections and constants are accepted. Connections take + /// precedence over the constant values. + ConnectionOrConstant, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputParam { + pub id: InputId, + /// The data type of this node. Used to determine incoming connections. This + /// should always match the type of the InputParamValue, but the property is + /// not actually enforced. + pub typ: DataType, + /// The constant value stored in this parameter. + pub value: InputParamValue, + /// A list of metadata fields, specifying things like bounds or limits. + /// Metadata values that don't make sense for a type are ignored. + pub metadata: SVec, + /// The input kind. See [InputParamKind] + pub kind: InputParamKind, + /// Back-reference to the node containing this parameter. + pub node: NodeId, +} + +impl InputParam { + pub fn value(&self) -> InputParamValue { + self.value.clone() + } + + pub fn kind(&self) -> InputParamKind { + self.kind + } + + pub fn node(&self) -> NodeId { + self.node + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputParam { + pub id: OutputId, + /// Back-reference to the node containing this parameter. + pub node: NodeId, + pub typ: DataType, +} + +impl OutputParam { + pub fn node(&self) -> NodeId { + self.node + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: NodeId, + pub label: String, + pub op_name: String, + pub inputs: Vec<(String, InputId)>, + pub outputs: Vec<(String, OutputId)>, + /// Executable nodes will run some code when their "Run" button is clicked + pub is_executable: bool, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct Graph { + pub nodes: SlotMap, + pub inputs: SlotMap, + pub outputs: SlotMap, + // Connects the input of a node, to the output of its predecessor that + // produces it + pub connections: HashMap, +} + +pub enum InputDescriptor { + Vector { default: Vec3 }, + Mesh, + Selection, + Scalar { default: f32, min: f32, max: f32 }, + Enum { values: Vec }, + NewFile, +} + +pub struct OutputDescriptor(pub DataType); + +pub struct NodeDescriptor { + pub op_name: String, + pub label: String, + pub inputs: Vec<(String, InputDescriptor)>, + pub outputs: Vec<(String, OutputDescriptor)>, + pub is_executable: bool, +} diff --git a/src/id_types.rs b/src/id_types.rs new file mode 100644 index 0000000..3ed325e --- /dev/null +++ b/src/id_types.rs @@ -0,0 +1,24 @@ +slotmap::new_key_type! { pub struct NodeId; } +slotmap::new_key_type! { pub struct InputId; } +slotmap::new_key_type! { pub struct OutputId; } + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum AnyParameterId { + Input(InputId), + Output(OutputId), +} + +impl AnyParameterId { + pub fn assume_input(&self) -> InputId { + match self { + AnyParameterId::Input(input) => *input, + AnyParameterId::Output(output) => panic!("{:?} is not an InputId", output), + } + } + pub fn assume_output(&self) -> OutputId { + match self { + AnyParameterId::Output(output) => *output, + AnyParameterId::Input(input) => panic!("{:?} is not an OutputId", input), + } + } +} diff --git a/src/index_impls.rs b/src/index_impls.rs new file mode 100644 index 0000000..ca2a181 --- /dev/null +++ b/src/index_impls.rs @@ -0,0 +1,36 @@ +use crate::graph_types::*; +use crate::id_types::*; + +macro_rules! impl_index_traits { + ($id_type:ty, $output_type:ty, $arena:ident) => { + impl std::ops::Index<$id_type> for Graph { + type Output = $output_type; + + fn index(&self, index: $id_type) -> &Self::Output { + self.$arena.get(index).unwrap_or_else(|| { + panic!( + "{} index error for {:?}. Has the value been deleted?", + stringify!($id_type), + index + ) + }) + } + } + + impl std::ops::IndexMut<$id_type> for Graph { + fn index_mut(&mut self, index: $id_type) -> &mut Self::Output { + self.$arena.get_mut(index).unwrap_or_else(|| { + panic!( + "{} index error for {:?}. Has the value been deleted?", + stringify!($id_type), + index + ) + }) + } + } + }; +} + +impl_index_traits!(NodeId, Node, nodes); +impl_index_traits!(InputId, InputParam, inputs); +impl_index_traits!(OutputId, OutputParam, outputs); diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6ca1c0c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,41 @@ +use editor_state::GraphEditorState; + +mod graph_node_ui; + +mod color_hex_utils; +mod editor_state; +mod graph_editor_egui; +mod graph_impls; +mod graph_types; +mod id_types; +mod index_impls; +mod node_finder; +mod node_types; +mod param_ui; + +pub type SVec = smallvec::SmallVec<[T; 4]>; + +#[derive(Default)] +struct App { + pub state: GraphEditorState, +} + +impl epi::App for App { + fn update(&mut self, ctx: &egui::CtxRef, frame: &epi::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + graph_editor_egui::draw_graph_editor(&ctx, &mut self.state); + }); + } + + fn name(&self) -> &str { + "Device Node Mapper" + } +} + +fn main() { + let options = eframe::NativeOptions { + ..Default::default() + }; + + eframe::run_native(Box::new(App::default()), options); +} diff --git a/src/node_finder.rs b/src/node_finder.rs new file mode 100644 index 0000000..28e3c07 --- /dev/null +++ b/src/node_finder.rs @@ -0,0 +1,68 @@ +use egui::*; + +use crate::color_hex_utils::*; +use crate::node_types::GraphNodeType; + +#[derive(Default)] +pub struct NodeFinder { + query: String, + /// Reset every frame. When set, the node finder will be moved at that position + pub position: Option, + pub just_spawned: bool, +} + +impl NodeFinder { + pub fn new_at(pos: Pos2) -> Self { + NodeFinder { + position: Some(pos), + just_spawned: true, + ..Default::default() + } + } + + /// Shows the node selector panel with a search bar. Returns whether a node + /// archetype was selected and, in that case, the finder should be hidden on + /// the next frame. + pub fn show(&mut self, ui: &mut Ui) -> Option { + let background_color = color_from_hex("#3f3f3f").unwrap(); + let _titlebar_color = background_color.linear_multiply(0.8); + let text_color = color_from_hex("#fefefe").unwrap(); + + ui.visuals_mut().widgets.noninteractive.fg_stroke = Stroke::new(2.0, text_color); + + let frame = Frame::dark_canvas(ui.style()) + .fill(background_color) + .margin(vec2(5.0, 5.0)); + + // The archetype that will be returned. + let mut submitted_archetype = None; + frame.show(ui, |ui| { + ui.vertical(|ui| { + let resp = ui.text_edit_singleline(&mut self.query); + if self.just_spawned { + resp.request_focus(); + self.just_spawned = false; + } + + let mut query_submit = resp.lost_focus() && ui.input().key_down(Key::Enter); + + Frame::default().margin(vec2(10.0, 10.0)).show(ui, |ui| { + for archetype in GraphNodeType::all_types() { + let archetype_name = archetype.type_label(); + if archetype_name.contains(self.query.as_str()) { + if query_submit { + submitted_archetype = Some(archetype); + query_submit = false; + } + if ui.selectable_label(false, archetype_name).clicked() { + submitted_archetype = Some(archetype); + } + } + } + }); + }); + }); + + submitted_archetype + } +} diff --git a/src/node_types.rs b/src/node_types.rs new file mode 100644 index 0000000..d0d0bb2 --- /dev/null +++ b/src/node_types.rs @@ -0,0 +1,210 @@ +use nalgebra::Vector3 as Vec3; +use strum::IntoEnumIterator; + +use crate::graph_types::*; + +const ONE: Vec3 = Vec3::new(1.0, 1.0, 1.0); +const ZERO: Vec3 = Vec3::new(0.0, 0.0, 0.0); +const X: Vec3 = Vec3::new(1.0, 0.0, 0.0); +const Y: Vec3 = Vec3::new(0.0, 1.0, 0.0); + +#[derive(Clone, Copy, strum_macros::EnumIter)] +pub enum GraphNodeType { + MakeBox, + MakeQuad, + BevelEdges, + ExtrudeFaces, + ChamferVertices, + MakeVector, + VectorMath, + MergeMeshes, + ExportObj, +} + +macro_rules! in_vector { + ($name:expr, $default:expr) => { + ( + $name.to_owned(), + InputDescriptor::Vector { default: $default }, + ) + }; +} + +macro_rules! in_scalar { + ($name:expr, $default:expr, $min:expr, $max:expr) => { + ( + $name.to_owned(), + InputDescriptor::Scalar { + default: $default, + max: $max, + min: $min, + }, + ) + }; + ($name:expr) => { + in_scalar!($name, 0.0, -1.0, 2.0) + }; +} + +macro_rules! in_mesh { + ($name:expr) => { + ($name.to_owned(), InputDescriptor::Mesh) + }; +} + +macro_rules! out_mesh { + ($name:expr) => { + ($name.to_owned(), OutputDescriptor(DataType::Mesh)) + }; +} + +macro_rules! out_vector { + ($name:expr) => { + ($name.to_owned(), OutputDescriptor(DataType::Vector)) + }; +} + +macro_rules! in_selection { + ($name:expr) => { + ($name.to_owned(), InputDescriptor::Selection) + }; +} + +macro_rules! in_file { + ($name:expr) => { + ($name.to_owned(), InputDescriptor::NewFile) + }; +} + +macro_rules! in_enum { + ($name:expr, $( $values:expr ),+) => { + ($name.to_owned(), InputDescriptor::Enum { values: vec![$( $values.to_owned() ),+] }) + }; +} + +impl GraphNodeType { + pub fn to_descriptor(&self) -> NodeDescriptor { + let label = self.type_label().into(); + let op_name = self.op_name().into(); + match self { + GraphNodeType::MakeBox => NodeDescriptor { + op_name, + label, + inputs: vec![in_vector!("origin", ZERO), in_vector!("size", ONE)], + outputs: vec![out_mesh!("out_mesh")], + is_executable: false, + }, + GraphNodeType::MakeQuad => NodeDescriptor { + op_name, + label, + inputs: vec![ + in_vector!("center", ZERO), + in_vector!("normal", Y), + in_vector!("right", X), + in_vector!("size", ONE), + ], + outputs: vec![out_mesh!("out_mesh")], + is_executable: false, + }, + GraphNodeType::BevelEdges => NodeDescriptor { + op_name, + label, + inputs: vec![ + in_mesh!("in_mesh"), + in_selection!("edges"), + in_scalar!("amount", 0.0, 0.0, 1.0), + ], + outputs: vec![out_mesh!("out_mesh")], + is_executable: false, + }, + GraphNodeType::ExtrudeFaces => NodeDescriptor { + op_name, + label, + inputs: vec![ + in_mesh!("in_mesh"), + in_selection!("faces"), + in_scalar!("amount", 0.0, 0.0, 1.0), + ], + outputs: vec![out_mesh!("out_mesh")], + is_executable: false, + }, + GraphNodeType::ChamferVertices => NodeDescriptor { + op_name, + label, + inputs: vec![ + in_mesh!("in_mesh"), + in_selection!("vertices"), + in_scalar!("amount", 0.0, 0.0, 1.0), + ], + outputs: vec![out_mesh!("out_mesh")], + is_executable: false, + }, + GraphNodeType::MakeVector => NodeDescriptor { + op_name, + label, + inputs: vec![in_scalar!("x"), in_scalar!("y"), in_scalar!("z")], + outputs: vec![out_vector!("out_vec")], + is_executable: false, + }, + GraphNodeType::VectorMath => NodeDescriptor { + op_name, + label, + inputs: vec![ + in_enum!("vec_op", "ADD", "SUB"), + in_vector!("A", ZERO), + in_vector!("B", ZERO), + ], + outputs: vec![out_vector!("out_vec")], + is_executable: false, + }, + GraphNodeType::MergeMeshes => NodeDescriptor { + op_name, + label, + inputs: vec![in_mesh!("A"), in_mesh!("B")], + outputs: vec![out_mesh!("out_mesh")], + is_executable: false, + }, + GraphNodeType::ExportObj => NodeDescriptor { + op_name, + label, + inputs: vec![in_mesh!("mesh"), in_file!("export_path")], + outputs: vec![], + is_executable: true, + }, + } + } + + pub fn all_types() -> impl Iterator { + GraphNodeType::iter() + } + + pub fn type_label(&self) -> &'static str { + match self { + GraphNodeType::MakeBox => "Box", + GraphNodeType::MakeQuad => "Quad", + GraphNodeType::BevelEdges => "Bevel edges", + GraphNodeType::ExtrudeFaces => "Extrude faces", + GraphNodeType::ChamferVertices => "Chamfer vertices", + GraphNodeType::MakeVector => "Vector", + GraphNodeType::VectorMath => "Vector math", + GraphNodeType::MergeMeshes => "Merge meshes", + GraphNodeType::ExportObj => "OBJ Export", + } + } + + /// The op_name is used by the graph compiler in graph_compiler.rs to select + /// which PolyASM instructions to emit. + pub fn op_name(&self) -> &'static str { + match self { + GraphNodeType::MakeBox => "MakeBox", + GraphNodeType::MakeQuad => "MakeQuad", + GraphNodeType::BevelEdges => "BevelEdges", + GraphNodeType::ExtrudeFaces => "ExtrudeFaces", + GraphNodeType::ChamferVertices => "ChamferVertices", + GraphNodeType::MakeVector => "MakeVector", + GraphNodeType::VectorMath => "VectorMath", + GraphNodeType::MergeMeshes => "MergeMeshes", + GraphNodeType::ExportObj => "ExportObj", + } + } +} diff --git a/src/param_ui.rs b/src/param_ui.rs new file mode 100644 index 0000000..afa6ea1 --- /dev/null +++ b/src/param_ui.rs @@ -0,0 +1,89 @@ +use anyhow::Result; +use egui::*; + +use crate::graph_types::*; + +impl InputParam { + pub fn value_widget(&mut self, name: &str, ui: &mut Ui) { + match &mut self.value { + InputParamValue::Vector(vector) => { + ui.label(name); + + ui.horizontal(|ui| { + ui.label("x"); + ui.add(egui::DragValue::new(&mut vector.x).speed(0.1)); + ui.label("y"); + ui.add(egui::DragValue::new(&mut vector.y).speed(0.1)); + ui.label("z"); + ui.add(egui::DragValue::new(&mut vector.z).speed(0.1)); + }); + } + InputParamValue::Scalar(scalar) => { + let mut min = f32::NEG_INFINITY; + let mut max = f32::INFINITY; + for metadata in &self.metadata { + match metadata { + InputParamMetadata::MinMaxScalar { + min: min_val, + max: max_val, + } => { + min = *min_val; + max = *max_val; + } + } + } + ui.horizontal(|ui| { + ui.label(name); + ui.add(Slider::new(scalar, min..=max)); + }); + } + InputParamValue::Selection { text, selection } => { + if ui.text_edit_singleline(text).changed() { + *selection = text + .split(',') + .map(|x| { + x.parse::() + .map_err(|_| anyhow::anyhow!("Cannot parse number")) + }) + .collect::>>() + .ok(); + } + } + InputParamValue::None => { + ui.label(name); + } + InputParamValue::Enum { values, selection } => { + let selected = if let Some(selection) = selection { + values[*selection as usize].clone() + } else { + "".to_owned() + }; + ComboBox::from_label(name) + .selected_text(selected) + .show_ui(ui, |ui| { + for (idx, value) in values.iter().enumerate() { + ui.selectable_value(selection, Some(idx as u32), value); + } + }); + } + InputParamValue::NewFile { path } => { + ui.label(name); + ui.horizontal(|ui| { + if ui.button("Select").clicked() { + *path = rfd::FileDialog::new().save_file(); + } + if let Some(ref path) = path { + ui.label( + path.clone() + .into_os_string() + .into_string() + .unwrap_or_else(|_| "".to_owned()), + ); + } else { + ui.label("No file selected"); + } + }); + } + } + } +}