From 6386928552e132c377a3e6e286750469c52814ff Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 22 Jul 2026 19:42:02 -0700 Subject: [PATCH] Add Vec2 support to the scalar math operator nodes (#4360) * Add Vec2 support to the scalar math operator nodes * Support the scalar-with-Vec2 operand order in the Exponent, Root, Logarithm, Min, Max, and Clamp nodes * Detect an f32 base of e in the Logarithm node despite inexact widening to f64 --- node-graph/nodes/math/src/lib.rs | 460 +++++++++++++++++++++++++------ 1 file changed, 369 insertions(+), 91 deletions(-) diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index ac34c9a920..3797c273f0 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -9,7 +9,6 @@ use log::warn; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; use math_parser::value::{Number, Value}; -use num_traits::Pow; use rand::{Rng, SeedableRng}; use std::ops::{Add, Mul, Rem, Sub}; use vector_types::Gradient; @@ -180,17 +179,36 @@ fn divide, B>( numerator.safe_divide(denominator) } +trait Componentwise { + fn componentwise(self, f: impl Fn(f64) -> f64) -> Self; +} +impl Componentwise for f64 { + fn componentwise(self, f: impl Fn(f64) -> f64) -> Self { + f(self) + } +} +impl Componentwise for f32 { + fn componentwise(self, f: impl Fn(f64) -> f64) -> Self { + f(self as f64) as f32 + } +} +impl Componentwise for DVec2 { + fn componentwise(self, f: impl Fn(f64) -> f64) -> Self { + DVec2::new(f(self.x), f(self.y)) + } +} + /// The reciprocal operation (`1/x`) calculates the multiplicative inverse of a number. /// -/// Produces 0 if the input is 0. +/// Produces 0 if the input is 0. With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Arithmetic"))] -fn reciprocal( +fn reciprocal( _: impl Ctx, /// The number for which the reciprocal is calculated. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] value: T, ) -> T { - if value == T::from(0.).unwrap() { T::from(0.).unwrap() } else { T::from(1.).unwrap() / value } + value.componentwise(|value| if value == 0. { 0. } else { 1. / value }) } /// The modulo operation (`%`) calculates the remainder from the division of two scalar numbers or vectors. @@ -213,108 +231,243 @@ fn modulo>>, B: Copy if always_positive { (numerator % modulus + modulus) % modulus } else { numerator % modulus } } +pub trait Exponent { + type Output; + fn power(self, power: Rhs) -> Self::Output; +} +impl Exponent for f64 { + type Output = f64; + fn power(self, power: f64) -> f64 { + self.powf(power) + } +} +impl Exponent for f32 { + type Output = f32; + fn power(self, power: f32) -> f32 { + self.powf(power) + } +} +impl Exponent for u32 { + type Output = u32; + fn power(self, power: u32) -> u32 { + self.pow(power) + } +} +impl Exponent for DVec2 { + type Output = DVec2; + fn power(self, power: DVec2) -> DVec2 { + DVec2::new(self.x.powf(power.x), self.y.powf(power.y)) + } +} +impl Exponent for DVec2 { + type Output = DVec2; + fn power(self, power: f64) -> DVec2 { + DVec2::new(self.x.powf(power), self.y.powf(power)) + } +} +impl Exponent for f64 { + type Output = DVec2; + fn power(self, power: DVec2) -> DVec2 { + DVec2::new(self.powf(power.x), self.powf(power.y)) + } +} + /// The exponent operation (`^`) calculates the result of raising a number to a power. +/// +/// With vec2 inputs, this applies separately to the X and Y components. #[node_macro::node(category("Math: Arithmetic"))] -fn exponent>( +fn exponent, B>( _: impl Ctx, /// The base number that is raised to the power. - #[implementations(f64, f32, u32)] - base: T, + #[implementations(f64, f32, u32, DVec2, DVec2, f64)] + base: A, /// The power to which the base number is raised. - #[implementations(f64, f32, u32)] + #[implementations(f64, f32, u32, DVec2, f64, DVec2)] #[default(2.)] - power: T, -) -> >::Output { - base.pow(power) + power: B, +) -> >::Output { + base.power(power) +} + +fn scalar_nth_root(radicand: f64, degree: f64) -> f64 { + if degree == 2. { + radicand.sqrt() + } else if degree == 3. { + radicand.cbrt() + } else if degree <= 0. { + 0. + } else { + radicand.powf(1. / degree) + } +} + +pub trait NthRoot { + type Output; + fn nth_root(self, degree: Degree) -> Self::Output; +} +impl NthRoot for f64 { + type Output = f64; + fn nth_root(self, degree: f64) -> f64 { + scalar_nth_root(self, degree) + } +} +impl NthRoot for f32 { + type Output = f32; + fn nth_root(self, degree: f32) -> f32 { + scalar_nth_root(self as f64, degree as f64) as f32 + } +} +impl NthRoot for DVec2 { + type Output = DVec2; + fn nth_root(self, degree: DVec2) -> DVec2 { + DVec2::new(scalar_nth_root(self.x, degree.x), scalar_nth_root(self.y, degree.y)) + } +} +impl NthRoot for DVec2 { + type Output = DVec2; + fn nth_root(self, degree: f64) -> DVec2 { + DVec2::new(scalar_nth_root(self.x, degree), scalar_nth_root(self.y, degree)) + } +} +impl NthRoot for f64 { + type Output = DVec2; + fn nth_root(self, degree: DVec2) -> DVec2 { + DVec2::new(scalar_nth_root(self, degree.x), scalar_nth_root(self, degree.y)) + } } /// The `n`th root operation (`√`) calculates the inverse of exponentiation. Square root inverts squaring, cube root inverts cubing, and so on. /// -/// This is equivalent to raising the number to the power of `1/n`. +/// This is equivalent to raising the number to the power of `1/n`. With vec2 inputs, this applies separately to the X and Y components. #[node_macro::node(category("Math: Arithmetic"))] -fn root( +fn root, B>( _: impl Ctx, /// The number inside the radical for which the `n`th root is calculated. #[default(2.)] - #[implementations(f64, f32)] - radicand: T, + #[implementations(f64, f32, DVec2, DVec2, f64)] + radicand: A, /// The degree of the root to be calculated. Square root is 2, cube root is 3, and so on. /// Degrees 0 or less are invalid and will produce an output of 0. #[default(2.)] - #[implementations(f64, f32)] - degree: T, -) -> T { - if degree == T::from(2.).unwrap() { - radicand.sqrt() - } else if degree == T::from(3.).unwrap() { - radicand.cbrt() - } else if degree <= T::from(0.).unwrap() { - T::from(0.).unwrap() - } else { - radicand.powf(T::from(1.).unwrap() / degree) - } + #[implementations(f64, f32, f64, DVec2, DVec2)] + degree: B, +) -> >::Output { + radicand.nth_root(degree) } -/// The logarithmic function (`log`) calculates the logarithm of a number with a specified base. If the natural logarithm function (`ln`) is desired, set the base to "e". -#[node_macro::node(category("Math: Arithmetic"))] -fn logarithm( - _: impl Ctx, - /// The number for which the logarithm is calculated. - #[implementations(f64, f32)] - value: T, - /// The base of the logarithm, such as 2 (binary), 10 (decimal), and e (natural logarithm). - #[default(2.)] - #[implementations(f64, f32)] - base: T, -) -> T { - if base == T::from(2.).unwrap() { +fn scalar_logarithm(value: f64, base: f64) -> f64 { + if base == 2. { value.log2() - } else if base == T::from(10.).unwrap() { + } else if base == 10. { value.log10() - } else if (base - T::from(std::f64::consts::E).unwrap()).abs() < T::epsilon() * T::from(1e6).unwrap() { + } else if (base - std::f64::consts::E).abs() < f64::EPSILON * 1e6 { value.ln() } else { value.log(base) } } +pub trait Logarithm { + type Output; + fn logarithm(self, base: Base) -> Self::Output; +} +impl Logarithm for f64 { + type Output = f64; + fn logarithm(self, base: f64) -> f64 { + scalar_logarithm(self, base) + } +} +impl Logarithm for f32 { + type Output = f32; + fn logarithm(self, base: f32) -> f32 { + // The f32 representation of e widens inexactly, so match it against e at f32 precision and substitute the exact f64 e + let base = if (base - std::f32::consts::E).abs() < f32::EPSILON * 10. { + std::f64::consts::E + } else { + base as f64 + }; + scalar_logarithm(self as f64, base) as f32 + } +} +impl Logarithm for DVec2 { + type Output = DVec2; + fn logarithm(self, base: DVec2) -> DVec2 { + DVec2::new(scalar_logarithm(self.x, base.x), scalar_logarithm(self.y, base.y)) + } +} +impl Logarithm for DVec2 { + type Output = DVec2; + fn logarithm(self, base: f64) -> DVec2 { + DVec2::new(scalar_logarithm(self.x, base), scalar_logarithm(self.y, base)) + } +} +impl Logarithm for f64 { + type Output = DVec2; + fn logarithm(self, base: DVec2) -> DVec2 { + DVec2::new(scalar_logarithm(self, base.x), scalar_logarithm(self, base.y)) + } +} + +/// The logarithmic function (`log`) calculates the logarithm of a number with a specified base. If the natural logarithm function (`ln`) is desired, set the base to "e". +/// +/// With vec2 inputs, this applies separately to the X and Y components. +#[node_macro::node(category("Math: Arithmetic"))] +fn logarithm, B>( + _: impl Ctx, + /// The number for which the logarithm is calculated. + #[implementations(f64, f32, DVec2, DVec2, f64)] + value: A, + /// The base of the logarithm, such as 2 (binary), 10 (decimal), and e (natural logarithm). + #[default(2.)] + #[implementations(f64, f32, f64, DVec2, DVec2)] + base: B, +) -> >::Output { + value.logarithm(base) +} + /// The sine trigonometric function (`sin`) calculates the ratio of the angle's opposite side length to its hypotenuse length. +/// +/// With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Trig"))] -fn sine( +fn sine( _: impl Ctx, /// The given angle. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] theta: T, /// Whether the given angle should be interpreted as radians instead of degrees. radians: bool, ) -> T { - if radians { theta.sin() } else { theta.to_radians().sin() } + theta.componentwise(|theta| if radians { theta.sin() } else { theta.to_radians().sin() }) } /// The cosine trigonometric function (`cos`) calculates the ratio of the angle's adjacent side length to its hypotenuse length. +/// +/// With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Trig"))] -fn cosine( +fn cosine( _: impl Ctx, /// The given angle. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] theta: T, /// Whether the given angle should be interpreted as radians instead of degrees. radians: bool, ) -> T { - if radians { theta.cos() } else { theta.to_radians().cos() } + theta.componentwise(|theta| if radians { theta.cos() } else { theta.to_radians().cos() }) } /// The tangent trigonometric function (`tan`) calculates the ratio of the angle's opposite side length to its adjacent side length. +/// +/// With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Trig"))] -fn tangent( +fn tangent( _: impl Ctx, /// The given angle. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] theta: T, /// Whether the given angle should be interpreted as radians instead of degrees. radians: bool, ) -> T { - if radians { theta.tan() } else { theta.to_radians().tan() } + theta.componentwise(|theta| if radians { theta.tan() } else { theta.to_radians().tan() }) } /// The inverse sine trigonometric function (`asin`) calculates the angle whose sine is the input value. @@ -476,36 +629,42 @@ fn as_f64(_: impl Ctx, value: f64) -> f64 { } /// The rounding function (`round`) maps an input value to its nearest whole number. Halfway values are rounded away from zero. +/// +/// With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Numeric"))] -fn round( +fn round( _: impl Ctx, /// The number to be rounded to the nearest whole number. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] value: T, ) -> T { - value.round() + value.componentwise(f64::round) } /// The floor function (`floor`) rounds down an input value to the nearest whole number, unless the input number is already whole. +/// +/// With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Numeric"))] -fn floor( +fn floor( _: impl Ctx, /// The number to be rounded down. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] value: T, ) -> T { - value.floor() + value.componentwise(f64::floor) } /// The ceiling function (`ceil`) rounds up an input value to the nearest whole number, unless the input number is already whole. +/// +/// With a vec2 input, this applies separately to the X and Y components. #[node_macro::node(category("Math: Numeric"))] -fn ceiling( +fn ceiling( _: impl Ctx, /// The number to be rounded up. - #[implementations(f64, f32)] + #[implementations(f64, f32, DVec2)] value: T, ) -> T { - value.ceil() + value.componentwise(f64::ceil) } trait AbsoluteValue { @@ -538,6 +697,8 @@ impl AbsoluteValue for i64 { } /// The absolute value function (`abs`) removes the negative sign from an input value, if present. +/// +/// With a vec2 input, this applies separately to the X and Y components. For the overall length of a vec2, see the "Magnitude" node instead. #[node_macro::node(category("Math: Numeric"))] fn absolute_value( _: impl Ctx, @@ -548,57 +709,129 @@ fn absolute_value( value.abs() } +pub trait MinMax { + type Output; + fn minimum(self, other: Rhs) -> Self::Output; + fn maximum(self, other: Rhs) -> Self::Output; +} +impl MinMax for f64 { + type Output = f64; + fn minimum(self, other: f64) -> f64 { + if self < other { self } else { other } + } + fn maximum(self, other: f64) -> f64 { + if self > other { self } else { other } + } +} +impl MinMax for f32 { + type Output = f32; + fn minimum(self, other: f32) -> f32 { + if self < other { self } else { other } + } + fn maximum(self, other: f32) -> f32 { + if self > other { self } else { other } + } +} +impl MinMax for u32 { + type Output = u32; + fn minimum(self, other: u32) -> u32 { + if self < other { self } else { other } + } + fn maximum(self, other: u32) -> u32 { + if self > other { self } else { other } + } +} +impl MinMax for String { + type Output = String; + fn minimum(self, other: Self) -> String { + if self < other { self } else { other } + } + fn maximum(self, other: Self) -> String { + if self > other { self } else { other } + } +} +impl MinMax for DVec2 { + type Output = DVec2; + fn minimum(self, other: DVec2) -> DVec2 { + self.min(other) + } + fn maximum(self, other: DVec2) -> DVec2 { + self.max(other) + } +} +impl MinMax for DVec2 { + type Output = DVec2; + fn minimum(self, other: f64) -> DVec2 { + self.min(DVec2::splat(other)) + } + fn maximum(self, other: f64) -> DVec2 { + self.max(DVec2::splat(other)) + } +} +impl MinMax for f64 { + type Output = DVec2; + fn minimum(self, other: DVec2) -> DVec2 { + DVec2::splat(self).min(other) + } + fn maximum(self, other: DVec2) -> DVec2 { + DVec2::splat(self).max(other) + } +} + /// The minimum function (`min`) picks the smaller of two numbers. +/// +/// With vec2 inputs, this applies separately to the X and Y components. #[node_macro::node(category("Math: Numeric"))] -fn min( +fn min, B>( _: impl Ctx, /// One of the two numbers, of which the lesser is returned. - #[implementations(f64, f32, u32, String)] - value: T, + #[implementations(f64, f32, u32, String, DVec2, DVec2, f64)] + value: A, /// The other of the two numbers, of which the lesser is returned. - #[implementations(f64, f32, u32, String)] - other_value: T, -) -> T { - if value < other_value { value } else { other_value } + #[implementations(f64, f32, u32, String, DVec2, f64, DVec2)] + other_value: B, +) -> >::Output { + value.minimum(other_value) } /// The maximum function (`max`) picks the larger of two numbers. +/// +/// With vec2 inputs, this applies separately to the X and Y components. #[node_macro::node(category("Math: Numeric"))] -fn max( +fn max, B>( _: impl Ctx, /// One of the two numbers, of which the greater is returned. - #[implementations(f64, f32, u32, String)] - value: T, + #[implementations(f64, f32, u32, String, DVec2, DVec2, f64)] + value: A, /// The other of the two numbers, of which the greater is returned. - #[implementations(f64, f32, u32, String)] - other_value: T, -) -> T { - if value > other_value { value } else { other_value } + #[implementations(f64, f32, u32, String, DVec2, f64, DVec2)] + other_value: B, +) -> >::Output { + value.maximum(other_value) } /// The clamp function (`clamp`) restricts a number to a specified range between a minimum and maximum value. The minimum and maximum values are automatically swapped if they are reversed. +/// +/// With vec2 inputs, this applies separately to the X and Y components. #[node_macro::node(category("Math: Numeric"))] -fn clamp( +fn clamp, B: MinMax + Clone>( _: impl Ctx, /// The number to be clamped, which is restricted to the range between the minimum and maximum values. - #[implementations(f64, f32, u32, String)] - value: T, + #[implementations(f64, f32, u32, String, DVec2, DVec2, f64)] + value: A, /// The left (smaller) side of the range. The output is never less than this number. - #[implementations(f64, f32, u32, String)] - min: T, + #[implementations(f64, f32, u32, String, DVec2, f64, DVec2)] + min: B, /// The right (greater) side of the range. The output is never greater than this number. - #[implementations(f64, f32, u32, String)] + #[implementations(f64, f32, u32, String, DVec2, f64, DVec2)] #[default(1)] - max: T, -) -> T { - let (min, max) = if min < max { (min, max) } else { (max, min) }; - if value < min { - min - } else if value > max { - max - } else { - value - } + max: B, +) -> >::Output +where + >::Output: MinMax>::Output>, +{ + let (min, max) = (min.clone().minimum(max.clone()), min.maximum(max)); + value.maximum(min).minimum(max) } /// The greatest common divisor (GCD) calculates the largest positive integer that divides both of the two input numbers without leaving a remainder. @@ -1048,6 +1281,51 @@ mod test { assert_eq!(magnitude(&(), vector), 5.); } + #[test] + pub fn clamp_vec2_within_swapped_bounds() { + let vec2 = |x, y| DVec2::new(x, y); + assert_eq!(clamp(&(), vec2(-5., 5.), vec2(1., 1.), vec2(0., 2.)), DVec2::new(0., 2.)); + } + + #[test] + pub fn min_max_vec2_with_scalar() { + let vec2 = |x, y| DVec2::new(x, y); + assert_eq!(super::min(&(), vec2(-5., 5.), 0_f64), DVec2::new(-5., 0.)); + assert_eq!(super::max(&(), vec2(-5., 5.), 0_f64), DVec2::new(0., 5.)); + } + + #[test] + pub fn scalar_with_vec2_operand_orders() { + let vec2 = |x, y| DVec2::new(x, y); + assert_eq!(super::min(&(), 0_f64, vec2(-5., 5.)), DVec2::new(-5., 0.)); + assert_eq!(super::max(&(), 0_f64, vec2(-5., 5.)), DVec2::new(0., 5.)); + assert_eq!(exponent(&(), 2_f64, vec2(2., 3.)), DVec2::new(4., 8.)); + assert_eq!(root(&(), 64_f64, vec2(2., 3.)), DVec2::new(8., 4.)); + assert_eq!(logarithm(&(), 8_f64, vec2(2., 10.)), DVec2::new(3., 8_f64.log10())); + assert_eq!(clamp(&(), 5_f64, vec2(0., 6.), vec2(1., 10.)), DVec2::new(1., 6.)); + } + + #[test] + pub fn vec2_degrees_and_bases() { + let vec2 = |x, y| DVec2::new(x, y); + assert_eq!(root(&(), vec2(64., 27.), vec2(2., 3.)), DVec2::new(8., 3.)); + assert_eq!(logarithm(&(), vec2(8., 100.), vec2(2., 10.)), DVec2::new(3., 2.)); + } + + #[test] + pub fn logarithm_f32_base_e_and_near_e() { + assert_eq!(logarithm(&(), 8_f32, std::f32::consts::E), 8_f64.ln() as f32); + assert_eq!(logarithm(&(), 8_f32, 2.7_f32), 8_f64.log(2.7_f32 as f64) as f32); + } + + #[test] + pub fn round_floor_ceiling_vec2() { + let vec2 = |x, y| DVec2::new(x, y); + assert_eq!(round(&(), vec2(1.5, -1.4)), DVec2::new(2., -1.)); + assert_eq!(floor(&(), vec2(1.9, -1.1)), DVec2::new(1., -2.)); + assert_eq!(ceiling(&(), vec2(1.1, -1.9)), DVec2::new(2., -1.)); + } + #[test] fn test_basic_expression() { let result = math(&(), 0., "2 + 2".to_string(), 0.);