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 <keavon@keavon.com>
This commit is contained in:
Jacin Yan 2024-12-17 18:08:14 +11:00 committed by GitHub
parent 79b4f4df7b
commit 3423c8ec13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 152 additions and 8 deletions

1
Cargo.lock generated
View file

@ -2386,6 +2386,7 @@ dependencies = [
"js-sys",
"kurbo",
"log",
"math-parser",
"node-macro",
"num-derive",
"num-traits",

View file

@ -2530,6 +2530,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
"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),

View file

@ -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<LayoutGroup> {
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"#),
]
}

View file

@ -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)),* $(,)?) => {

View file

@ -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

View file

@ -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<Value> {
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<U: num_traits::float::Float>(
_: (),
/// 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<U: Add<T>, 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.);
}
}