diff --git a/node_calculator/base_functions.py b/node_calculator/base_functions.py index a4249d7..81069a4 100644 --- a/node_calculator/base_functions.py +++ b/node_calculator/base_functions.py @@ -1,9 +1,10 @@ -"""Basic NodeCalculator functions.""" -# This is an extension that is loaded by default. +"""Basic NodeCalculator functions. -# The main difference to the base_operators is that functions rely on operators! -# They combine existing operators to create more complex setups. +This is an extension that is loaded by default. +The main difference to the base_operators is that functions rely on operators! +They combine existing operators to create more complex setups. +""" from node_calculator.core import noca_op from node_calculator.core import Op @@ -16,18 +17,18 @@ @noca_op -def soft_approach(in_value, fade_in_range=0.5, target_value=1): - """Follow in_value, but approach the target_value slowly. +def soft_approach(attr_a, fade_in_range=0.5, target_value=1): + """Follow attr_a, but approach the target_value slowly. Note: Only works for 1D inputs! Args: - in_value (NcNode or NcAttrs or str or int or float): Value or attr + attr_a (NcNode or NcAttrs or str or int or float): Value or attr fade_in_range (NcNode or NcAttrs or str or int or float): Value or attr. This defines a range over which the target_value will be - approached. Before the in_value is within this range the output - of this and the in_value will be equal. Defaults to 0.5. + approached. Before the attr_a is within this range the output + of this and the attr_a will be equal. Defaults to 0.5. target_value (NcNode or NcAttrs or str or int or float): Value or attr. This is the value that will be approached slowly. Defaults to 1. @@ -45,19 +46,242 @@ def soft_approach(in_value, fade_in_range=0.5, target_value=1): """ start_val = target_value - fade_in_range - exponent = ((start_val) - in_value) / fade_in_range + exponent = ((start_val) - attr_a) / fade_in_range soft_approach_value = target_value - fade_in_range * Op.exp(exponent) is_range_valid_condition = Op.condition( fade_in_range > 0, soft_approach_value, - target_value + target_value, ) is_in_range_condition = Op.condition( - in_value > start_val, + attr_a > start_val, is_range_valid_condition, - in_value + attr_a, ) return is_in_range_condition + + +@noca_op +def sin(attr_a): + """Sine of attr_a. + + Note: + Only works for 1D inputs! + + The idea how to set this up with native Maya nodes is from Chad Vernon: + https://www.chadvernon.com/blog/trig-maya/ + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + in_attr = Node("pCube.tx") + Op.sin(in_attr) + """ + sin = Op.euler_to_quat(attr_a * 2).outputQuatX + return sin + + +@noca_op +def cos(attr_a): + """Cosine of attr_a. + + Note: + Only works for 1D inputs! + + The idea how to set this up with native Maya nodes is from Chad Vernon: + https://www.chadvernon.com/blog/trig-maya/ + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + in_attr = Node("pCube.tx") + Op.cos(in_attr) + """ + cos = Op.euler_to_quat(attr_a * 2).outputQuatW + return cos + + +@noca_op +def tan(attr_a): + """Tangent of attr_a. + + Note: + Only works for 1D inputs! + + The idea how to set this up with native Maya nodes is from Chad Vernon: + https://www.chadvernon.com/blog/trig-maya/ + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + in_attr = Node("pCube.tx") + Op.tan(in_attr) + """ + sin = Op.sin(attr_a) + cos = Op.cos(attr_a) + tan = sin / cos + divide_by_zero_safety = Op.condition(cos == 0, 0, tan) + return divide_by_zero_safety + + +@noca_op +def asin(attr_a): + """Arcsine of attr_a. + + Note: + Only works for 1D inputs! + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + in_attr = Node("pCube.tx") + Op.asin(in_attr) + """ + x_vector_component = Op.sqrt(1 - attr_a ** 2) + angle_between = Op.angle_between( + (x_vector_component, 0, 0), + (x_vector_component, attr_a, 0), + ) + right_angle_cond = Op.condition(x_vector_component == 0, 90, angle_between) + sign_cond = Op.condition(attr_a >= 0, right_angle_cond, -right_angle_cond) + return sign_cond + + +@noca_op +def acos(attr_a): + """Arccosine of attr_a. + + Note: + Only works for 1D inputs! + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + in_attr = Node("pCube.tx") + Op.acos(in_attr) + """ + y_vector_component = Op.sqrt(1 - attr_a ** 2) + angle_between = Op.angle_between( + (attr_a, 0, 0), + (attr_a, y_vector_component, 0), + ) + right_angle_cond = Op.condition(attr_a == 0, 90, angle_between) + flip_cond = Op.condition( + attr_a < 0, 180 - right_angle_cond, right_angle_cond + ) + return flip_cond + + +@noca_op +def atan(attr_a): + """Arctangent of attr_a, which calculates only quadrant 1 and 4. + + Note: + Only works for 1D inputs! + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + in_attr = Node("pCube.tx") + Op.atan(in_attr) + """ + angle_between = Op.angle_between( + (1, 0, 0), + (1, attr_a, 0), + ) + sign_cond = Op.condition(attr_a < 0, -angle_between, angle_between) + return sign_cond + + +@noca_op +def atan2(attr_a, attr_b): + """Arctangent2 of attr_b/attr_a, which calculates all four quadrants. + + Note: + The arguments mimic the behaviour of math.atan2(y, x)! Make sure you + pass them in the right order. + + Args: + attr_a (NcNode or NcAttrs or str or int or float): Value or attr + attr_b (NcNode or NcAttrs or str or int or float): Value or attr + + Returns: + NcNode: Instance with node and output-attr. + + Example: + :: + + x = Node("pCube.tx") + y = Node("pCube.ty") + Op.atan2(y, x) + """ + # Measure the angle between a vector constructed out of given values. + angle_between = Op.angle_between( + (attr_b, 0, 0), + (attr_b, attr_a, 0), + ) + + # Change the angle, depending on the quadrant it lies in. + quadrant_1_and_4_cond = Op.condition( + attr_a > 0, + angle_between, + -angle_between, + ) + quadrant_2_and_3_cond = Op.condition( + attr_a > 0, + 180 - angle_between, + -180 + angle_between, + ) + quadrant_cond = Op.condition( + attr_b > 0, + quadrant_1_and_4_cond, + quadrant_2_and_3_cond, + ) + + # Take care of special case where attr_b is zero & would result in angle=0. + right_angle_cond = Op.condition( + attr_b == 0, + Op.condition(attr_a < 0, -90, 90), + quadrant_cond, + ) + + return right_angle_cond diff --git a/node_calculator/base_operators.py b/node_calculator/base_operators.py index cb5bf63..4756611 100644 --- a/node_calculator/base_operators.py +++ b/node_calculator/base_operators.py @@ -1,9 +1,10 @@ -"""Basic NodeCalculator operators.""" -# This is an extension that is loaded by default. +"""Basic NodeCalculator operators. -# The main difference to the base_functions is that operators are stand-alone -# functions that create a Maya node. +This is an extension that is loaded by default. +The main difference to the base_functions is that operators are stand-alone +functions that create a Maya node. +""" import math from maya import cmds diff --git a/tests/test_base_functions.py b/tests/test_base_functions.py new file mode 100644 index 0000000..fc06905 --- /dev/null +++ b/tests/test_base_functions.py @@ -0,0 +1,184 @@ +"""Unit tests for noca.Op (from base_functions.py)""" +import math + +from base import BaseTestCase +import node_calculator.core as noca + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# GLOBALS +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +REGULAR_TEST_ANGLES = range(-360, 360, 45) +CUSTOM_TEST_ANGLES = [-361, -123, -12, 12, 123, 361, 3.1, 271.5, -35.1, -404.1] +TRIGONOMETRY_ANGLE_TEST_VALUES = REGULAR_TEST_ANGLES + CUSTOM_TEST_ANGLES + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# TESTS +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +class TestFunctions(BaseTestCase): + """Test base functions of the Op class.""" + + def setUp(self): + super(TestFunctions, self).setUp() + + # Create standard nodes on which to perform the operations on. + # Transform test nodes + self.tf_out_a = noca.create_node("transform", name="transform_out_A") + self.tf_out_b = noca.create_node("transform", name="transform_out_B") + self.tf_in_a = noca.create_node("transform", name="transform_in_A") + self.tf_in_b = noca.create_node("transform", name="transform_in_B") + + # Matrix test nodes + self.mat_out_a = noca.create_node("holdMatrix", name="matrix_out_A") + self.mat_out_b = noca.create_node("holdMatrix", name="matrix_out_B") + self.mat_in_a = noca.create_node("holdMatrix", name="matrix_in_A") + + # Quaternion test nodes + self.quat_out_a = noca.create_node("eulerToQuat", name="quaternion_out_A") + self.quat_out_b = noca.create_node("eulerToQuat", name="quaternion_out_B") + self.quat_in_a = noca.create_node("quatNormalize", name="quaternion_in_A") + + # Mesh, curve and nurbsSurface test nodes + self.mesh_out_a = noca.create_node("mesh", name="mesh_out_A") + self.mesh_shape_out_a = noca.Node("mesh_out_AShape") + self.curve_out_a = noca.create_node("nurbsCurve", name="curve_out_A") + self.curve_shape_out_a = noca.Node("curve_out_AShape") + self.surface_out_a = noca.create_node("nurbsSurface", name="surface_out_A") + self.surface_shape_out_a = noca.Node("surface_out_AShape") + + def test_smooth_step(self): + """ """ + fade_in_range = 0.5 + target_value = 1 + self.tf_in_a.tx = noca.Op.soft_approach( + self.tf_out_a.tx, fade_in_range=fade_in_range, target_value=target_value + ) + + # Check whether value matches when out of range. + self.tf_out_a.tx = -1 + self.assertEqual(-1, self.tf_in_a.tx.get()) + + self.tf_out_a.tx = 0.5 + self.assertEqual(0.5, self.tf_in_a.tx.get()) + + # Check whether value slowly approaches target value when in range. + self.tf_out_a.tx = 0.501 + self.assertLess(self.tf_in_a.tx.get(), 0.501) + + self.tf_out_a.tx = 1 + self.assertLess(self.tf_in_a.tx.get(), 1) + + self.tf_out_a.tx = 100 + self.assertAlmostEqual(1, self.tf_in_a.tx.get()) + + def test_sin(self): + """ """ + # Due to potential unit conversion nodes: Test scalar and angle plugs. + self.tf_in_a.ty = noca.Op.sin(self.tf_out_a.tx) + self.tf_in_a.rz = noca.Op.sin(self.tf_out_a.ry) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + desired_value = math.sin(math.radians(test_angle)) + + self.tf_out_a.tx = test_angle + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=6) + + self.tf_out_a.ry = test_angle + self.assertAlmostEqual(desired_value, self.tf_in_a.rz.get(), places=6) + + def test_cos(self): + """ """ + # Due to potential unit conversion nodes: Test scalar and angle plugs. + self.tf_in_a.ty = noca.Op.cos(self.tf_out_a.tx) + self.tf_in_a.rz = noca.Op.cos(self.tf_out_a.ry) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + desired_value = math.cos(math.radians(test_angle)) + + self.tf_out_a.tx = test_angle + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=6) + + self.tf_out_a.ry = test_angle + self.assertAlmostEqual(desired_value, self.tf_in_a.rz.get(), places=6) + + def test_tan(self): + """ """ + # Due to potential unit conversion nodes: Test scalar and angle plugs. + self.tf_in_a.ty = noca.Op.tan(self.tf_out_a.tx) + self.tf_in_a.rz = noca.Op.tan(self.tf_out_a.ry) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + desired_value = math.tan(math.radians(test_angle)) + # NoCa uses 0 in the undefined areas that go towards +/-infinity. + if abs(desired_value) > 1e15: + desired_value = 0 + + self.tf_out_a.tx = test_angle + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=4) + + self.tf_out_a.ry = test_angle + self.assertAlmostEqual(desired_value, self.tf_in_a.rz.get(), places=4) + + def test_asin(self): + """ """ + # Due to potential unit conversion nodes: Test scalar and angle plugs. + self.tf_in_a.ty = noca.Op.asin(self.tf_out_a.tx) + self.tf_in_a.rz = noca.Op.asin(self.tf_out_a.ry) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + test_scalar = math.sin(math.radians(test_angle)) + desired_value = math.degrees(math.asin(test_scalar)) + + self.tf_out_a.tx = test_scalar + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=4) + + self.tf_out_a.ry = test_scalar + self.assertAlmostEqual(desired_value, self.tf_in_a.rz.get(), places=4) + + def test_acos(self): + """ """ + # Due to potential unit conversion nodes: Test scalar and angle plugs. + self.tf_in_a.ty = noca.Op.acos(self.tf_out_a.tx) + self.tf_in_a.rz = noca.Op.acos(self.tf_out_a.ry) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + test_scalar = math.cos(math.radians(test_angle)) + desired_value = math.degrees(math.acos(test_scalar)) + + self.tf_out_a.tx = test_scalar + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=4) + + self.tf_out_a.ry = test_scalar + self.assertAlmostEqual(desired_value, self.tf_in_a.rz.get(), places=4) + + def test_atan(self): + """ """ + # Due to potential unit conversion nodes: Test scalar and angle plugs. + self.tf_in_a.ty = noca.Op.atan(self.tf_out_a.tx) + self.tf_in_a.rz = noca.Op.atan(self.tf_out_a.ry) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + test_scalar = math.tan(math.radians(test_angle)) + desired_value = math.degrees(math.atan(test_scalar)) + + self.tf_out_a.tx = test_scalar + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=5) + + self.tf_out_a.ry = test_scalar + self.assertAlmostEqual(desired_value, self.tf_in_a.rz.get(), places=5) + + def test_atan2(self): + """ """ + self.tf_in_a.ty = noca.Op.atan2(self.tf_out_a.ty, self.tf_out_a.tx) + + for test_angle in TRIGONOMETRY_ANGLE_TEST_VALUES: + test_scalar_x = math.cos(math.radians(test_angle)) + test_scalar_y = math.sin(math.radians(test_angle)) + desired_value = math.degrees(math.atan2(test_scalar_y, test_scalar_x)) + + self.tf_out_a.tx = test_scalar_x + self.tf_out_a.ty = test_scalar_y + self.assertAlmostEqual(desired_value, self.tf_in_a.ty.get(), places=5) diff --git a/tests/test_op.py b/tests/test_base_operators.py similarity index 98% rename from tests/test_op.py rename to tests/test_base_operators.py index a8a0805..6ac435e 100644 --- a/tests/test_op.py +++ b/tests/test_base_operators.py @@ -1,16 +1,6 @@ -""" -Unit tests for noca.Op -""" - -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# IMPORTS -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# Python imports - -# Third party imports +"""Unit tests for noca.Op (from base_operators.py)""" from maya import cmds -# Local imports from base import BaseTestCase import node_calculator.core as noca from node_calculator import om_util