#![allow(clippy::use_self)] use egui::{Color32, Id, Ui}; use egui_snarl::{ InPin, NodeId, OutPin, Snarl, ui::{NodeLayout, PinInfo, PinPlacement, SnarlStyle, SnarlViewer, SnarlWidget}, }; use lazy_static::lazy_static; use log::*; use std::{collections::HashMap, path::PathBuf}; mod library; mod node; mod preview; mod snarl_ext; mod types; #[cfg(target_arch = "wasm32")] mod wasm; use library::NodeIndex; use node::{AnyNode, ConcreteNode}; use snarl_ext::{Compilable, GraphError, WithSignatures}; use types::{Dimension, FloatPrecision, ScalarType, Type}; use crate::node::MAX_INPUTS; impl From for PinInfo { fn from(val: Type) -> Self { match val.scalar() { ScalarType::Float(FloatPrecision::Single) => Self::circle(), ScalarType::Float(FloatPrecision::Double) => Self::triangle(), ScalarType::Int => Self::circle(), ScalarType::UInt => Self::triangle(), ScalarType::Bool => Self::square(), } .with_fill(match val { Type::Scalar(_) => Color32::from_rgb(0xb0, 0x00, 0x00), //red Type::Vector(_, Dimension::D2) => Color32::from_rgb(0x00, 0xb0, 0x00), //green Type::Vector(_, Dimension::D3) => Color32::from_rgb(0x00, 0x00, 0xb0), //blue Type::Vector(_, Dimension::D4) => Color32::from_rgb(0x00, 0xb0, 0xb0), //cyan Type::Matrix(_, _, _) => Color32::from_rgb(0xb0, 0xb0, 0x00), //yellow }) } } static COLOR_ERROR: Color32 = Color32::from_rgb(0xc0, 0x00, 0x00); static COLOR_INACTIVE: Color32 = Color32::from_rgb(0x90, 0x90, 0x90); pub struct Viewer { dirty: bool, error: Option, } impl Default for Viewer { fn default() -> Self { Self { dirty: true, error: None, } } } impl SnarlViewer for Viewer { #[inline] fn connect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl) { if let Ok(from_signature) = snarl.get_node_signature(from.id.node) { let mut overrides: [Option; _] = [None; MAX_INPUTS]; overrides[to.id.input] = Some(from_signature.outputs[from.id.output]); if snarl .get_node_signature_with_overrides(to.id.node, &overrides) .is_err() { return; } for &remote in &to.remotes { snarl.disconnect(remote, to.id); } snarl.connect(from.id, to.id); self.dirty = true; } } fn disconnect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl) { snarl.disconnect(from.id, to.id); self.dirty = true; } fn drop_outputs(&mut self, pin: &OutPin, snarl: &mut Snarl) { snarl.drop_outputs(pin.id); } fn drop_inputs(&mut self, pin: &InPin, snarl: &mut Snarl) { snarl.drop_inputs(pin.id); } fn title(&mut self, node: &AnyNode) -> String { format!("{node}") } fn inputs(&mut self, id: NodeId, snarl: &Snarl) -> usize { snarl.get_visible_inputs(id).len() } fn outputs(&mut self, id: NodeId, snarl: &Snarl) -> usize { snarl.get_node_signature(id).map_or(1, |s| s.outputs.len()) } #[allow(clippy::too_many_lines)] #[allow(refining_impl_trait)] fn show_input(&mut self, pin: &InPin, ui: &mut Ui, snarl: &mut Snarl) -> PinInfo { _ = ui; let typ = snarl.get_visible_inputs(pin.id.node)[pin.id.input]; #[cfg(feature = "debug-ui")] if let Some(label) = typ { ui.label(format!("{label}")); } match typ { None => PinInfo::circle() .with_fill(COLOR_INACTIVE) .with_stroke(egui::Stroke::NONE), Some(t) => t.into(), } } #[allow(refining_impl_trait)] fn show_output(&mut self, pin: &OutPin, ui: &mut Ui, snarl: &mut Snarl) -> PinInfo { match snarl.get_node_signature(pin.id.node) { Ok(signature) => { let typ = signature.outputs[pin.id.output]; ui.label(format!("{typ}")); typ.into() } Err(_) => { ui.label("error"); PinInfo::star().with_fill(COLOR_ERROR) } } } fn has_body(&mut self, _node: &AnyNode) -> bool { true } fn show_body( &mut self, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut Ui, snarl: &mut Snarl, ) { if snarl[node].show_body(ui).changed() { self.dirty = true; } } #[cfg(feature = "debug-ui")] fn has_footer(&mut self, _node: &AnyNode) -> bool { true } #[cfg(feature = "debug-ui")] fn show_footer( &mut self, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut Ui, _snarl: &mut Snarl, ) { ui.label(format!("node ID: {node:?}")); } fn node_frame( &mut self, mut default: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], _snarl: &Snarl, ) -> egui::Frame { if Some(node) == self.error.and_then(|e| e.1) { default.stroke = egui::Stroke { width: 2.0, color: COLOR_ERROR, }; } default } fn has_graph_menu(&mut self, _pos: egui::Pos2, _snarl: &mut Snarl) -> bool { true } fn show_graph_menu(&mut self, pos: egui::Pos2, ui: &mut Ui, snarl: &mut Snarl) { if let Some(node) = AnyNode::pick_node(ui) { snarl.insert_node(pos, node); self.dirty = true; ui.close(); } } fn has_node_menu(&mut self, _node: &AnyNode) -> bool { true } fn show_node_menu( &mut self, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut Ui, snarl: &mut Snarl, ) { ui.label("Node menu"); if ui.button("Remove").clicked() { snarl.remove_node(node); self.dirty = true; ui.close(); } } } pub struct App { snarl: Snarl, filename: Option, viewer: Viewer, preview: preview::Preview, show_about: bool, #[cfg(target_arch = "wasm32")] save_as_dialog: Option, } lazy_static! { static ref EXAMPLES: HashMap<&'static str, &'static str> = { let mut m = HashMap::new(); m.insert("UV Ramp", include_str!("./examples/uv_ramp.nt.json")); m.insert("Circle", include_str!("./examples/circle.nt.json")); m.insert( "Shadertoy Plasma", include_str!("./examples/shadertoy_plasma.nt.json"), ); m.insert("Circle Grid", include_str!("./examples/grid.nt.json")); m }; } const fn default_style() -> SnarlStyle { SnarlStyle { node_layout: Some(NodeLayout::coil()), pin_placement: Some(PinPlacement::Edge), pin_size: Some(7.0), node_frame: Some(egui::Frame { inner_margin: egui::Margin::same(8), outer_margin: egui::Margin { left: 0, right: 0, top: 0, bottom: 4, }, corner_radius: egui::CornerRadius::same(8), fill: egui::Color32::from_gray(30), stroke: egui::Stroke::NONE, shadow: egui::Shadow::NONE, }), bg_frame: Some(egui::Frame { inner_margin: egui::Margin::ZERO, outer_margin: egui::Margin::same(2), corner_radius: egui::CornerRadius::ZERO, fill: egui::Color32::from_gray(40), stroke: egui::Stroke::NONE, shadow: egui::Shadow::NONE, }), ..SnarlStyle::new() } } impl App { pub fn new(cx: &eframe::CreationContext) -> Self { cx.egui_ctx.style_mut(|style| style.animation_time *= 10.0); let snarl: Snarl = cx .storage .and_then(|storage| storage.get_string("snarl")) .or(EXAMPLES.get("Shadertoy Plasma").map(|s| s.to_string())) .and_then(|data| serde_json::from_str(&data).ok()) .unwrap_or_default(); let show_about = cx .storage .and_then(|storage| storage.get_string("about_closed")) .map(|_| false) .unwrap_or(true); Self { snarl, filename: None, viewer: Default::default(), preview: preview::Preview::new(cx).expect("Failed to init preview"), show_about, #[cfg(target_arch = "wasm32")] save_as_dialog: None, } } fn about_modal(ui: &mut egui::Ui) { ui.heading("About Nodetoy"); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label("Nodetoy is a node-based Fragment Shader editor inspired by "); ui.hyperlink_to("Shadertoy", "https://shadertoy.com/"); ui.label("."); }); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label("Unlike most node-based shader editors, it's not intended as a high-level tool for \ authoring materials in standard graphics rendering pipelines, but rather follows the \ shading language GLSL closely to allow learning from technical resources like "); ui.hyperlink_to("the Book of Shaders", "https://thebookofshaders.com/"); ui.label(" or "); ui.hyperlink_to("Inigo Quilez' articles","https://iquilezles.org/articles/"); ui.label(" on Signed Distance Fields and Raymarching without needing to write in the textual \ syntax of the language."); }); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label( "Nodetoy is (MIT licensed) open-source software. The source code is available ", ); ui.hyperlink_to("here", "https://git.s-ol.nu/nodetoy/"); ui.label("."); }); ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { ui.set_opacity(0.7); ui.spacing_mut().item_spacing.y = 0.0; ui.label("© 2025 s-ol bekic"); ui.hyperlink_to("https://s-ol.nu", "https://s-ol.nu"); ui.hyperlink_to("@s_ol@merveilles.town", "https://merveilles.town/@s_ol"); }); ui.separator(); egui::Sides::new().show( ui, |_ui| {}, |ui| { if ui.button("Close").clicked() { ui.close(); } }, ); } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::MenuBar::new().ui(ui, |ui| { egui::widgets::global_theme_preference_switch(ui); let mut do_save = false; if ui.button("About").clicked() { self.show_about = true; } ui.menu_button("File", |ui| { if ui.button("Open").clicked() { #[cfg(not(target_arch = "wasm32"))] if let Some(path) = rfd::FileDialog::new() .add_filter("Nodetoy shader", &["nt.json"]) .pick_file() { let file = std::fs::File::open(path).unwrap(); self.snarl = serde_json::from_reader::<_, Snarl>(file).unwrap(); self.viewer.dirty = true; } #[cfg(target_arch = "wasm32")] web_sys::window() .unwrap() .alert_with_message( "To open an .nt.json file, please drag it onto the canvas.", ) .unwrap(); } ui.menu_button("Load Example", |ui| { for label in EXAMPLES.keys() { if ui.button(*label).clicked() { self.snarl = serde_json::from_str(EXAMPLES.get(label).unwrap()).unwrap(); self.filename = Some(PathBuf::from(label)); self.viewer.dirty = true; } } }); ui.separator(); if ui .add_enabled(self.filename.is_some(), egui::Button::new("Save")) .clicked() { do_save = true; } if ui.button("Save as").clicked() { #[cfg(not(target_arch = "wasm32"))] { if let Some(path) = rfd::FileDialog::new() .add_filter("Nodetoy shader", &["nt.json"]) .save_file() { self.filename = Some(path); do_save = true; } } #[cfg(target_arch = "wasm32")] { self.save_as_dialog = self .filename .as_ref() .map(|p| p.as_os_str().to_string_lossy().to_string()) .or(Some("Untitled".to_string())); } } ui.separator(); if ui.button("Clear All").clicked() { self.snarl = Snarl::default(); self.filename = None; } #[cfg(not(target_arch = "wasm32"))] if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); #[cfg(target_arch = "wasm32")] if let Some(buffer) = &mut self.save_as_dialog { let modal = egui::Modal::new(egui::Id::new("save")).show(ui.ctx(), |ui| { ui.set_width(250.0); ui.heading("Save File"); ui.label("Name:"); ui.text_edit_singleline(buffer); egui::Sides::new().show( ui, |_ui| {}, |ui| { if ui.button("Save").clicked() { if buffer.is_empty() { *buffer = "Unnamed.nt.json".to_string(); } if !buffer.ends_with(".json") { *buffer += ".nt.json"; } self.filename = Some(PathBuf::from(&buffer)); do_save = true; ui.close(); } if ui.button("Cancel").clicked() { ui.close(); } }, ); }); if modal.should_close() { self.save_as_dialog = None; } } if do_save { let path = self.filename.as_ref().unwrap(); #[cfg(not(target_arch = "wasm32"))] let file = std::fs::File::create(path).unwrap(); #[cfg(target_arch = "wasm32")] let file = wasm::DownloadFile::new(path.clone(), "application/json"); serde_json::to_writer(file, &self.snarl).unwrap(); } }); }); let shader = if self.viewer.dirty { let mut buf = String::new(); match self.snarl.compile(&mut buf) { Ok(()) => { info!("{}", buf); self.viewer.error = None; self.preview.reset(); Some(buf) } Err(x) => { error!("Error compiling: {:?}", x); self.viewer.dirty = false; self.viewer.error = Some(x); None } } } else { None }; egui::Window::new("Preview").show(ctx, |ui| { #[cfg(not(feature = "debug-ui"))] egui::Frame::canvas(ui.style()).show(ui, |ui| { if self.preview.update(ui, shader) { self.viewer.dirty = false; } }); if let Some(err) = &self.viewer.error { let message = if let Some(node) = err.1 { format!("Error in node {}: {:?}", node.0, err.0) } else { format!("Error while compiling: {:?}", err.0) }; ui.colored_label(Color32::from_rgb(0xff, 0, 0), message); } }); egui::CentralPanel::default().show(ctx, |ui| { SnarlWidget::new() .id(Id::new("snarl-demo")) .style(default_style()) .show(&mut self.snarl, &mut self.viewer, ui); // let _ = response.changed(); ui.input(|i| { if let Some(data) = i.raw.dropped_files.first().and_then(|f| f.bytes.as_ref()) { self.snarl = serde_json::from_slice(data).unwrap(); self.viewer.dirty = true; } }); }); if self.show_about { let id = egui::Id::new("about"); let modal = egui::Modal::new(id).show(ctx, Self::about_modal); if modal.should_close() { self.show_about = false; frame .storage_mut() .map(|storage| storage.set_string("about_closed", "true".to_string())); } } } fn save(&mut self, storage: &mut dyn eframe::Storage) { let snarl = serde_json::to_string(&self.snarl).unwrap(); storage.set_string("snarl", snarl); } } // When compiling natively: #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result<()> { let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([400.0, 300.0]) .with_min_inner_size([300.0, 220.0]), renderer: eframe::Renderer::Wgpu, ..Default::default() }; eframe::run_native( "nodetoy", native_options, Box::new(|cx| Ok(Box::new(App::new(cx)))), ) } // When compiling to web using trunk: #[cfg(target_arch = "wasm32")] fn main() { eframe::WebLogger::init(log::LevelFilter::Debug).ok(); let canvas = wasm::get_canvas_element().expect("failed to find canvas"); let web_options = eframe::WebOptions::default(); wasm_bindgen_futures::spawn_local(async { eframe::WebRunner::new() .start( canvas, web_options, Box::new(|cx| Ok(Box::new(App::new(cx)))), ) .await .expect("failed to start eframe"); }); }