From 3423c8ec1339ba895f046476b904c290370a13e8 Mon Sep 17 00:00:00 2001 From: Jacin Yan <66163750+jacinyan@users.noreply.github.com> Date: Tue, 17 Dec 2024 18:08:14 +1100 Subject: [PATCH] New node: Math (#2121) * 2115 IP * Initial implementation of Expression node * Register Expression Node * Add Expression DocumentNode Definition * DocumentNodeImplementation::Expresssion in guess_type_from_node * Move expression.rs to graphene-core * WIP: Investigating 'exposed' & 'value_source' params for Expression property * Node graph render debug IP * Single input can change node properties; complex debug IP * Fix epsilon in test * Handle invalid expressions in expression_node by returning 0.0 * Run cargo fmt * Set the default expression to "1 + 1" * Hardcode the A and B inputs at Keavon's request * Rename and clean up UX * Move into ops.rs --------- Co-authored-by: hypercube <0hypercube@gmail.com> Co-authored-by: Keavon Chambers --- Cargo.lock | 1 + .../node_graph/document_node_definitions.rs | 1 + .../document/node_graph/node_properties.rs | 49 ++++++++ libraries/math-parser/src/lib.rs | 2 +- node-graph/gcore/Cargo.toml | 1 + node-graph/gcore/src/ops.rs | 106 ++++++++++++++++-- 6 files changed, 152 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e1bfb0d..706ba1cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2386,6 +2386,7 @@ dependencies = [ "js-sys", "kurbo", "log", + "math-parser", "node-macro", "num-derive", "num-traits", diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 9c1e61dc..a5d8ef5c 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -2530,6 +2530,7 @@ fn static_nodes() -> Vec { "graphene_core::raster::adjustments::SelectiveColorNode", &node_properties::selective_color_properties as PropertiesLayout, ), + ("graphene_core::ops::MathNode", &node_properties::math_properties as PropertiesLayout), ("graphene_core::raster::ExposureNode", &node_properties::exposure_properties as PropertiesLayout), ("graphene_core::vector::generator_nodes::RectangleNode", &node_properties::rectangle_properties as PropertiesLayout), ("graphene_core::vector::AssignColorsNode", &node_properties::assign_colors_properties as PropertiesLayout), diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 822d8997..1ca81ace 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2616,3 +2616,52 @@ pub(crate) fn artboard_properties(document_node: &DocumentNode, node_id: NodeId, vec![location, dimensions, background, clip_row] } + +pub fn math_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { + let expression_index = 1; + let operation_b_index = 2; + + let expression = (|| { + let mut widgets = start_widgets(document_node, node_id, expression_index, "Expression", FrontendGraphDataType::General, true); + + let Some(input) = document_node.inputs.get(expression_index) else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return vec![]; + }; + if let Some(TaggedValue::String(x)) = &input.as_non_exposed_value() { + widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + TextInput::new(x.clone()) + .centered(true) + .on_update(update_value( + |x: &TextInput| { + TaggedValue::String({ + let mut expression = x.value.trim().to_string(); + + if ["+", "-", "*", "/", "^", "%"].iter().any(|&infix| infix == expression) { + expression = format!("A {} B", expression); + } else if expression == "^" { + expression = String::from("A^B"); + } + + expression + }) + }, + node_id, + expression_index, + )) + .on_commit(commit_value) + .widget_holder(), + ]) + } + widgets + })(); + let operand_b = number_widget(document_node, node_id, operation_b_index, "Operand B", NumberInput::default(), true); + let operand_a_hint = vec![TextLabel::new("(Operand A is the primary input)").widget_holder()]; + + vec![ + LayoutGroup::Row { widgets: expression }.with_tooltip(r#"A math expression that may incorporate "A" and/or "B", such as "sqrt(A + B) - B^2""#), + LayoutGroup::Row { widgets: operand_b }.with_tooltip(r#"The value of "B" when calculating the expression"#), + LayoutGroup::Row { widgets: operand_a_hint }.with_tooltip(r#""A" is fed by the value from the previous node in the primary data flow, or it is 0 if disconnected"#), + ] +} diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index 584ea3f8..e164de21 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -27,7 +27,7 @@ mod tests { use super::*; - const EPSILON: f64 = 1e10_f64; + const EPSILON: f64 = 1e-10_f64; macro_rules! test_end_to_end{ ($($name:ident: $input:expr => ($expected_value:expr, $expected_unit:expr)),* $(,)?) => { diff --git a/node-graph/gcore/Cargo.toml b/node-graph/gcore/Cargo.toml index 5271a7db..ac4465bd 100644 --- a/node-graph/gcore/Cargo.toml +++ b/node-graph/gcore/Cargo.toml @@ -78,6 +78,7 @@ web-sys = { workspace = true, optional = true, features = [ image = { workspace = true, optional = true, default-features = false, features = [ "png", ] } +math-parser = { path = "../../libraries/math-parser" } [dev-dependencies] # Workspace dependencies diff --git a/node-graph/gcore/src/ops.rs b/node-graph/gcore/src/ops.rs index 80715666..241bf4bc 100644 --- a/node-graph/gcore/src/ops.rs +++ b/node-graph/gcore/src/ops.rs @@ -4,6 +4,10 @@ use crate::registry::types::Percentage; use crate::vector::style::GradientStops; use crate::{Color, Node}; +use math_parser::ast; +use math_parser::context::{EvalContext, NothingMap, ValueProvider}; +use math_parser::value::{Number, Value}; + use core::marker::PhantomData; use core::ops::{Add, Div, Mul, Rem, Sub}; use glam::DVec2; @@ -13,6 +17,70 @@ use rand::{Rng, SeedableRng}; #[cfg(target_arch = "spirv")] use spirv_std::num_traits::float::Float; +/// The struct that stores the context for the maths parser. +/// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. +struct MathNodeContext { + a: f64, + b: f64, +} + +impl ValueProvider for MathNodeContext { + fn get_value(&self, name: &str) -> Option { + if name.eq_ignore_ascii_case("a") { + Some(Value::from_f64(self.a)) + } else if name.eq_ignore_ascii_case("b") { + Some(Value::from_f64(self.b)) + } else { + None + } + } +} + +/// Calculates a mathematical expression with input values "A" and "B" +#[node_macro::node(category("Math"))] +fn math( + _: (), + /// The value of "A" when calculating the expression + #[implementations(f64, f32)] + operand_a: U, + /// A math expression that may incorporate "A" and/or "B", such as "sqrt(A + B) - B^2" + #[default(A + B)] + expression: String, + /// The value of "B" when calculating the expression + #[implementations(f64, f32)] + #[default(1.)] + operand_b: U, +) -> U { + let (node, _unit) = match ast::Node::try_parse_from_str(&expression) { + Ok(expr) => expr, + Err(e) => { + warn!("Invalid expression: `{expression}`\n{e:?}"); + return U::from(0.).unwrap(); + } + }; + let context = EvalContext::new( + MathNodeContext { + a: operand_a.to_f64().unwrap(), + b: operand_b.to_f64().unwrap(), + }, + NothingMap, + ); + + let value = match node.eval(&context) { + Ok(value) => value, + Err(e) => { + warn!("Expression evaluation error: {e:?}"); + return U::from(0.).unwrap(); + } + }; + + let Value::Number(num) = value; + match num { + Number::Real(val) => U::from(val).unwrap(), + Number::Complex(c) => U::from(c.re).unwrap(), + } +} + /// The addition operation (+) calculates the sum of two numbers. #[node_macro::node(category("Math: Arithmetic"))] fn add, T>( @@ -471,6 +539,37 @@ mod test { use super::*; use crate::{generic::*, structural::*, value::*}; + #[test] + pub fn dot_product_function() { + let vector_a = glam::DVec2::new(1., 2.); + let vector_b = glam::DVec2::new(3., 4.); + assert_eq!(dot_product(vector_a, vector_b), 11.); + } + + #[test] + fn test_basic_expression() { + let result = math((), 0., "2 + 2".to_string(), 0.); + assert_eq!(result, 4.); + } + + #[test] + fn test_complex_expression() { + let result = math((), 0., "(5 * 3) + (10 / 2)".to_string(), 0.); + assert_eq!(result, 20.); + } + + #[test] + fn test_default_expression() { + let result = math((), 0., "0".to_string(), 0.); + assert_eq!(result, 0.); + } + + #[test] + fn test_invalid_expression() { + let result = math((), 0., "invalid".to_string(), 0.); + assert_eq!(result, 0.); + } + #[test] pub fn identity_node() { let value = ValueNode(4u32).then(IdentityNode::new()); @@ -482,11 +581,4 @@ mod test { let fnn = FnNode::new(|(a, b)| (b, a)); assert_eq!(fnn.eval((1u32, 2u32)), (2, 1)); } - - #[test] - pub fn dot_product_function() { - let vector_a = glam::DVec2::new(1., 2.); - let vector_b = glam::DVec2::new(3., 4.); - assert_eq!(dot_product(vector_a, vector_b), 11.); - } }