From c6896e7c7768e640b37ce5972be0fd78476f79ab Mon Sep 17 00:00:00 2001 From: "Ilya V. Portnov" Date: Sat, 21 Sep 2019 12:46:45 +0500 Subject: [PATCH] Port Formula mk3 to 2.80 (#2546) * Port formula3 node * Support for easy migration from formula2 to formula3. * Update node to 2.80 standards. --- docs/nodes/number/formula3.rst | 88 +++++++++ index.md | 1 + nodes/number/formula2.py | 2 + nodes/number/formula3.py | 342 +++++++++++++++++++++++++++++++++ 4 files changed, 433 insertions(+) create mode 100644 docs/nodes/number/formula3.rst create mode 100644 nodes/number/formula3.py diff --git a/docs/nodes/number/formula3.rst b/docs/nodes/number/formula3.rst new file mode 100644 index 0000000000..e05762e4d0 --- /dev/null +++ b/docs/nodes/number/formula3.rst @@ -0,0 +1,88 @@ +Formula Node Mk3 +================ + +Functionality +------------- + +This node allows one to evaluate (almost) arbitrary Python expressions, using inputs as variables. +It is possible to calculate numeric values, construct lists, tuples, vertices and matrices. + +The node allows to evaluate up to 4 formulas for each set of input values. + +Expression syntax +----------------- + +Syntax being used for formulas is standard Python's syntax for expressions. +For exact syntax definition, please refer to https://docs.python.org/3/reference/expressions.html. + +In short, you can use usual mathematical operations (`+`, `-`, `*`, `/`, `**` for power), numbers, variables, parenthesis, and function call, such as `sin(x)`. + +One difference with Python's syntax is that you can call only restricted number of Python's functions. Allowed are: + +- Functions from math module: + - acos, acosh, asin, asinh, atan, atan2, + atanh, ceil, copysign, cos, cosh, degrees, + erf, erfc, exp, expm1, fabs, factorial, floor, + fmod, frexp, fsum, gamma, hypot, isfinite, isinf, + isnan, ldexp, lgamma, log, log10, log1p, log2, modf, + pow, radians, sin, sinh, sqrt, tan, tanh, trunc; +- Constants from math module: pi, e; +- Additional functions: abs, sign; +- From mathutlis module: Vector, Matrix; +- Python type conversions: tuple, list. + +This restriction is for security reasons. However, Python's ecosystem does not guarantee that noone can call some unsafe operations by using some sort of language-level hacks. So, please be warned that usage of this node with JSON definition obtained from unknown or untrusted source can potentially harm your system or data. + +Examples of valid expressions are: + +* 1.0 +* x +* x+1 +* 0.75*X + 0.25*Y +* R * sin(phi) + +Inputs +------ + +Set of inputs for this node depends on used formulas. Each variable used in formula becomes one input. If there are no variables used in formula, then this node will have no inputs. + +Parameters +---------- + +This node has the following parameters: + +- **Dimensions**. This parameter is available in the N panel only. It defines how many formulas the node will allow to specify and evaluate. Default value is 1. Maximum value is 4. +- **Formula 1** to **Formula 4** input boxes. Formulas theirselve. If no formula is specified, then nothing will be calculated for this dimension. Number of formula input boxes is defined by **Dimensions** parameter. +- **Separate**. If the flag is set, then for each combination of input values, list of values calculated by formula is enclosed in separate list. Usually you will want to uncheck this if you are using only one formula. Usually you will want to check this if you are using more than one formula. Other combinations can be of use in specific cases. Unchecked by default. +- **Wrap**. If checked, then the whole output of the node will be enclosed in additional brackets. Checked by default. + +For example, let's consider the following setup: + +.. image:: https://user-images.githubusercontent.com/284644/53962080-00c78700-410c-11e9-9563-855fca16537a.png + +Then the following combinations of flags are possible: + ++-----------+-----------+--------------------+ +| Separate | Wrap | Result | ++===========+===========+====================+ +| Checked | Checked | [[[1, 3], [2, 4]]] | ++-----------+-----------+--------------------+ +| Checked | Unchecked | [[1, 3], [2, 4]] | ++-----------+-----------+--------------------+ +| Unchecked | Checked | [[1, 3, 2, 4]] | ++-----------+-----------+--------------------+ +| Unchecked | Unchecked | [1, 3, 2, 4] | ++-----------+-----------+--------------------+ + +Outputs +------- + +**Result** - what we got as result. + +Usage examples +-------------- + +.. image:: https://user-images.githubusercontent.com/284644/53965898-dbd71200-4113-11e9-83c7-cb3c7ced8c1e.png + +.. image:: https://user-images.githubusercontent.com/284644/53967764-9f0d1a00-4117-11e9-92e3-a047dbd2981b.png + diff --git a/index.md b/index.md index dd211049b9..a1566d7b40 100644 --- a/index.md +++ b/index.md @@ -196,6 +196,7 @@ SvEasingNode SvMixNumbersNode Formula2Node + SvFormulaNodeMk3 --- SvGenFibonacci SvGenExponential diff --git a/nodes/number/formula2.py b/nodes/number/formula2.py index 2ecc28c9cf..9b80a8db26 100644 --- a/nodes/number/formula2.py +++ b/nodes/number/formula2.py @@ -49,6 +49,8 @@ class Formula2Node(bpy.types.Node, SverchCustomTreeNode): base_name = 'n' multi_socket_type = 'SvStringsSocket' + replacement_nodes = [('SvFormulaNodeMk3', None, None)] + def draw_buttons(self, context, layout): layout.prop(self, "formula", text="") diff --git a/nodes/number/formula3.py b/nodes/number/formula3.py new file mode 100644 index 0000000000..60f6336bbe --- /dev/null +++ b/nodes/number/formula3.py @@ -0,0 +1,342 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import ast +from math import * + +import bpy +from bpy.props import BoolProperty, StringProperty, EnumProperty, FloatVectorProperty, IntProperty +from mathutils import Vector, Matrix +import json +import io + +from sverchok.node_tree import SverchCustomTreeNode +from sverchok.data_structure import fullList, updateNode, dataCorrect, match_long_repeat +from sverchok.utils import logging + +def make_functions_dict(*functions): + return dict([(function.__name__, function) for function in functions]) + +# Standard functions which for some reasons are not in the math module +def sign(x): + if x < 0: + return -1 + elif x > 0: + return 1 + else: + return 0 + +# Functions +safe_names = make_functions_dict( + # From math module + acos, acosh, asin, asinh, atan, atan2, + atanh, ceil, copysign, cos, cosh, degrees, + erf, erfc, exp, expm1, fabs, factorial, floor, + fmod, frexp, fsum, gamma, hypot, isfinite, isinf, + isnan, ldexp, lgamma, log, log10, log1p, log2, modf, + pow, radians, sin, sinh, sqrt, tan, tanh, trunc, + # Additional functions + abs, sign, + # From mathutlis module + Vector, Matrix, + # Python type conversions + tuple, list, str + ) +# Constants +safe_names['e'] = e +safe_names['pi'] = pi + +# Blender modules +# Consider this not safe for now +# safe_names["bpy"] = bpy + +class VariableCollector(ast.NodeVisitor): + """ + Visitor class to collect free variable names from the expression. + The problem is that one doesn't just select all names from expression: + there can be local-only variables. + + For example, in + + [g*g for g in lst] + + only "lst" should be considered as a free variable, "g" should be not, + as it is bound by list comprehension scope. + + This implementation is not exactly complete (at least, dictionary comprehensions + are not supported yet). But it works for most cases. + + Please refer to ast.NodeVisitor class documentation for general reference. + """ + def __init__(self): + self.variables = set() + # Stack of local variables + # It is not enough to track just a plain set of names, + # since one name can be re-introduced in the nested scope + self.local_vars = [] + + def push(self, local_vars): + self.local_vars.append(local_vars) + + def pop(self): + return self.local_vars.pop() + + def is_local(self, name): + """ + Check if name is local variable + """ + + for stack_frame in self.local_vars: + if name in stack_frame: + return True + return False + + def visit_SetComp(self, node): + local_vars = set() + for generator in node.generators: + if isinstance(generator.target, ast.Name): + local_vars.add(generator.target.id) + self.push(local_vars) + self.generic_visit(node) + self.pop() + + def visit_ListComp(self, node): + local_vars = set() + for generator in node.generators: + if isinstance(generator.target, ast.Name): + local_vars.add(generator.target.id) + self.push(local_vars) + self.generic_visit(node) + self.pop() + + def visit_Lambda(self, node): + local_vars = set() + arguments = node.args + for arg in arguments.args: + local_vars.add(arg.id) + if arguments.vararg: + local_vars.add(arguments.vararg.arg) + self.push(local_vars) + self.generic_visit(node) + self.pop() + + def visit_Name(self, node): + name = node.id + if not self.is_local(name): + self.variables.add(name) + + self.generic_visit(node) + +def get_variables(string): + """ + Get set of free variables used by formula + """ + string = string.strip() + if not len(string): + return set() + root = ast.parse(string, mode='eval') + visitor = VariableCollector() + visitor.visit(root) + result = visitor.variables + return result.difference(safe_names.keys()) + +# It could be safer... +def safe_eval(string, variables): + """ + Evaluate expression, allowing only functions known to be "safe" + to be used. + """ + try: + env = dict() + env.update(safe_names) + env.update(variables) + env["__builtins__"] = {} + root = ast.parse(string, mode='eval') + return eval(compile(root, "", 'eval'), env) + except SyntaxError as e: + logging.exception(e) + raise Exception("Invalid expression syntax: " + str(e)) + +class SvFormulaNodeMk3(bpy.types.Node, SverchCustomTreeNode): + """ + Triggers: Formula + Tooltip: Calculate by custom formula. + """ + bl_idname = 'SvFormulaNodeMk3' + bl_label = 'Formula Mk3' + bl_icon = 'OUTLINER_OB_EMPTY' + + def on_update(self, context): + self.adjust_sockets() + updateNode(self, context) + + def on_update_dims(self, context): + if self.dimensions < 4: + self.formula4 = "" + if self.dimensions < 3: + self.formula3 = "" + if self.dimensions < 2: + self.formula2 = "" + + self.adjust_sockets() + updateNode(self, context) + + dimensions : IntProperty(name="Dimensions", default=1, min=1, max=4, update=on_update_dims) + + formula1 : StringProperty(default = "x+y", update=on_update) + formula2 : StringProperty(update=on_update) + formula3 : StringProperty(update=on_update) + formula4 : StringProperty(update=on_update) + + separate : BoolProperty(name="Separate", default=False, update=updateNode) + wrap : BoolProperty(name="Wrap", default=False, update=updateNode) + + def formulas(self): + return [self.formula1, self.formula2, self.formula3, self.formula4] + + def formula(self, k): + return self.formulas()[k] + + def draw_buttons(self, context, layout): + layout.prop(self, "formula1", text="") + if self.dimensions > 1: + layout.prop(self, "formula2", text="") + if self.dimensions > 2: + layout.prop(self, "formula3", text="") + if self.dimensions > 3: + layout.prop(self, "formula4", text="") + row = layout.row() + row.prop(self, "separate") + row.prop(self, "wrap") + + def draw_buttons_ext(self, context, layout): + layout.prop(self, "dimensions") + self.draw_buttons(context, layout) + + def sv_init(self, context): + self.inputs.new('SvStringsSocket', "x") + + self.outputs.new('SvStringsSocket', "Result") + + def get_variables(self): + variables = set() + + for formula in self.formulas(): + vs = get_variables(formula) + variables.update(vs) + + return list(sorted(list(variables))) + + def adjust_sockets(self): + variables = self.get_variables() + #self.debug("adjust_sockets:" + str(variables)) + #self.debug("inputs:" + str(self.inputs.keys())) + for key in self.inputs.keys(): + if key not in variables: + self.debug("Input {} not in variables {}, remove it".format(key, str(variables))) + self.inputs.remove(self.inputs[key]) + for v in variables: + if v not in self.inputs: + self.debug("Variable {} not in inputs {}, add it".format(v, str(self.inputs.keys()))) + self.inputs.new('SvStringsSocket', v) + + def update(self): + ''' + update analyzes the state of the node and returns if the criteria to start processing + are not met. + ''' + + if not any(len(formula) for formula in self.formulas()): + return + + self.adjust_sockets() + + def get_input(self): + variables = self.get_variables() + result = {} + + for var in variables: + if var in self.inputs and self.inputs[var].is_linked: + result[var] = self.inputs[var].sv_get()[0] + #self.debug("get_input: {} => {}".format(var, result[var])) + return result + + def migrate_from(self, old_node): + if old_node.bl_idname == 'Formula2Node': + formula = old_node.formula + # Older formula node allowed only fixed set of + # variables, with names "x", "n[0]" .. "n[100]". + # Other names could not be considered valid. + k = -1 + for socket in old_node.inputs: + name = socket.name + if k == -1: # First socket name was "x" + new_name = name + else: # Other names was "n[k]", which is syntactically not + # a valid python variable name. + # So we replace all occurences of "n[0]" in formula + # with "n0", and so on. + new_name = "n" + str(k) + + logging.info("Replacing %s with %s", name, new_name) + formula = formula.replace(name, new_name) + k += 1 + + self.formula1 = formula + self.wrap = True + + def process(self): + + if not self.outputs[0].is_linked: + return + + var_names = self.get_variables() + inputs = self.get_input() + + results = [] + + if var_names: + input_values = [inputs.get(name, []) for name in var_names] + parameters = match_long_repeat(input_values) + else: + parameters = [[[]]] + for values in zip(*parameters): + variables = dict(zip(var_names, values)) + vector = [] + for formula in self.formulas(): + if formula: + value = safe_eval(formula, variables) + vector.append(value) + if self.separate: + results.append(vector) + else: + results.extend(vector) + + if self.wrap: + results = [results] + + self.outputs['Result'].sv_set(results) + + +def register(): + bpy.utils.register_class(SvFormulaNodeMk3) + + +def unregister(): + bpy.utils.unregister_class(SvFormulaNodeMk3) +