""" This module contains the functionality to replace nodes by other nodes in order to keep files from older Armory versions compatible with newer versions. Nodes can define custom update procedures which describe how the replacement should look like. Original author: @niacdoial """ import os.path import time import traceback from typing import Dict, List, Optional, Tuple import bpy.props import arm.log as log import arm.logicnode.arm_nodes as arm_nodes import arm.logicnode.arm_sockets # List of errors that occurred during the replacement # Format: (error identifier, node.bl_idname (or None), tree name, exception traceback (optional)) replacement_errors: List[Tuple[str, Optional[str], str, Optional[str]]] = [] class NodeReplacement: """ Represents a simple replacement rule, this can replace nodes of one type to nodes of a second type. However, it is fairly limited. For instance, it assumes there are no changes in the type of the inputs or outputs Second, it also assumes that node properties (especially EnumProperties) keep the same possible values. - from_node, from_node_version: the type of node to be removed, and its version number - to_node, to_node_version: the type of node which takes from_node's place, and its version number - *_socket_mapping: a map which defines how the sockets of the old node shall be connected to the new node {1: 2} means that anything connected to the socket with index 1 on the original node will be connected to the socket with index 2 on the new node - property_mapping: the mapping used to transfer the values of the old node's properties to the new node's properties. {"property0": "property1"} mean that the value of the new node's property1 should be the old node's property0's value. - input_defaults: a mapping used to give default values to the inputs which aren't overridden otherwise. - property_defaults: a mapping used to define the value of the new node's properties, when they aren't overridden otherwise. """ def __init__(self, from_node: str, from_node_version: int, to_node: str, to_node_version: int, in_socket_mapping: Dict[int, int], out_socket_mapping: Dict[int, int], property_mapping: Optional[Dict[str, str]] = None, input_defaults: Optional[Dict[int, any]] = None, property_defaults: Optional[Dict[str, any]] = None): self.from_node = from_node self.to_node = to_node self.from_node_version = from_node_version self.to_node_version = to_node_version self.in_socket_mapping = in_socket_mapping self.out_socket_mapping = out_socket_mapping self.property_mapping = {} if property_mapping is None else property_mapping self.input_defaults = {} if input_defaults is None else input_defaults self.property_defaults = {} if property_defaults is None else property_defaults @classmethod def Identity(cls, node: 'ArmLogicTreeNode'): """Returns a NodeReplacement that does nothing, while operating on a given node. WARNING: it assumes that all node properties have names that start with "property" """ in_socks = {i: i for i in range(len(node.inputs))} out_socks = {i: i for i in range(len(node.outputs))} props = {} i = 0 # finding all the properties fo a node is not possible in a clean way for now. # so, I'll assume their names start with "property", and list all the node's attributes that fulfill that condition. # next, to check that those are indeed properties (in the blender sense), we need to check the class's type annotations. # those annotations are not even instances of bpy.types.Property, but tuples, with the first element being a function accessible at bpy.props.XXXProperty property_types = [] for possible_prop_type in dir(bpy.props): if possible_prop_type.endswith('Property'): property_types.append(getattr(bpy.props, possible_prop_type)) possible_properties = [] for attrname in dir(node): if attrname.startswith('property'): possible_properties.append(attrname) for attrname in possible_properties: if attrname not in node.__annotations__: continue if not isinstance(node.__annotations__[attrname], tuple): continue if node.__annotations__[attrname][0] in property_types: props[attrname] = attrname return NodeReplacement( node.bl_idname, node.arm_version, node.bl_idname, type(node).arm_version, in_socket_mapping=in_socks, out_socket_mapping=out_socks, property_mapping=props ) def chain_with(self, other): """Modify the current NodeReplacement by "adding" a second replacement after it""" if self.to_node != other.from_node or self.to_node_version != other.from_node_version: raise TypeError('the given NodeReplacement-s could not be chained') self.to_node = other.to_node self.to_node_version = other.to_node_version for i1, i2 in self.in_socket_mapping.items(): i3 = other.in_socket_mapping[i2] self.in_socket_mapping[i1] = i3 for i1, i2 in self.out_socket_mapping.items(): i3 = other.out_socket_mapping[i2] self.out_socket_mapping[i1] = i3 for p1, p2 in self.property_mapping.items(): p3 = other.property_mapping[p2] self.property_mapping[p1] = p3 old_input_defaults = self.input_defaults self.input_defaults = other.input_defaults.copy() for i, x in old_input_defaults.items(): self.input_defaults[ other.in_socket_mapping[i] ] = x old_property_defaults = self.property_defaults self.property_defaults = other.property_defaults.copy() for p, x in old_property_defaults.items(): self.property_defaults[ other.property_mapping[p] ] = x def replace(tree: bpy.types.NodeTree, node: 'ArmLogicTreeNode'): """Replaces the given node with its replacement.""" # the node can either return a NodeReplacement object (for simple replacements) # or a brand new node, for more complex stuff. response = node.get_replacement_node(tree) if isinstance(response, arm_nodes.ArmLogicTreeNode): newnode = response # some misc. properties newnode.parent = node.parent newnode.location = node.location newnode.select = node.select elif isinstance(response, list): # a list of nodes: for newnode in response: newnode.parent = node.parent newnode.location = node.location newnode.select = node.select elif isinstance(response, NodeReplacement): replacement = response # if the returned object is a NodeReplacement, check that it corresponds to the node (also, create the new node) if node.bl_idname != replacement.from_node or node.arm_version != replacement.from_node_version: raise LookupError("The provided NodeReplacement doesn't seem to correspond to the node needing replacement") # Create the replacement node newnode = tree.nodes.new(response.to_node) if newnode.arm_version != replacement.to_node_version: tree.nodes.remove(newnode) raise LookupError("The provided NodeReplacement doesn't seem to correspond to the node needing replacement") # some misc. properties newnode.parent = node.parent newnode.location = node.location newnode.select = node.select # now, use the `replacement` to hook up the new node correctly # start by applying defaults for prop_name, prop_value in replacement.property_defaults.items(): setattr(newnode, prop_name, prop_value) for input_id, input_value in replacement.input_defaults.items(): input_socket = newnode.inputs[input_id] if isinstance(input_socket, arm.logicnode.arm_sockets.ArmCustomSocket): if input_socket.arm_socket_type != 'NONE': input_socket.default_value_raw = input_value elif input_socket.type != 'SHADER': # note: shader-type sockets don't have a default value... input_socket.default_value = input_value # map properties for src_prop_name, dest_prop_name in replacement.property_mapping.items(): setattr(newnode, dest_prop_name, getattr(node, src_prop_name)) # map inputs for src_socket_id, dest_socket_id in replacement.in_socket_mapping.items(): src_socket = node.inputs[src_socket_id] dest_socket = newnode.inputs[dest_socket_id] if src_socket.is_linked: # an input socket only has one link datasource_socket = src_socket.links[0].from_socket tree.links.new(datasource_socket, dest_socket) else: if isinstance(dest_socket, arm.logicnode.arm_sockets.ArmCustomSocket): if dest_socket.arm_socket_type != 'NONE': dest_socket.default_value_raw = src_socket.default_value_raw elif dest_socket.type != 'SHADER': # note: shader-type sockets don't have a default value... dest_socket.default_value = src_socket.default_value # map outputs for src_socket_id, dest_socket_id in replacement.out_socket_mapping.items(): dest_socket = newnode.outputs[dest_socket_id] for link in node.outputs[src_socket_id].links: tree.links.new(dest_socket, link.to_socket) else: print(response) tree.nodes.remove(node) def replace_all(): """Iterate through all logic node trees in the file and check for node updates/replacements to execute.""" global replacement_errors replacement_errors.clear() for tree in bpy.data.node_groups: if tree.bl_idname == "ArmLogicTreeType": # Use list() to make a "static" copy. It's possible to iterate over it because nodes which get removed # from the tree leave python objects in the list for node in list(tree.nodes): # Blender nodes (layout) if not isinstance(node, arm_nodes.ArmLogicTreeNode): continue # That node has been removed from the tree without replace() being called on it somehow elif node.type == '': continue # Node type deleted. That's unusual. Or it has been replaced for a looong time elif not node.is_registered_node_type(): replacement_errors.append(('unregistered', None, tree.name, None)) # Invalid version number elif not isinstance(type(node).arm_version, int): replacement_errors.append(('bad version', node.bl_idname, tree.name, None)) # Actual replacement elif node.arm_version < type(node).arm_version: try: replace(tree, node) except LookupError as err: replacement_errors.append(('update failed', node.bl_idname, tree.name, traceback.format_exc())) except Exception as err: replacement_errors.append(('misc.', node.bl_idname, tree.name, traceback.format_exc())) # Node version is newer than supported by the class elif node.arm_version > type(node).arm_version: replacement_errors.append(('future version', node.bl_idname, tree.name, None)) # If possible, make a popup about the errors and write an error report into the .blend file's folder if len(replacement_errors) > 0: basedir = os.path.dirname(bpy.data.filepath) reportfile = os.path.join( basedir, 'node_update_failure.{:s}.txt'.format( time.strftime("%Y-%m-%dT%H-%M-%S%z") ) ) with open(reportfile, 'w') as reportf: for error_type, node_class, tree_name, tb in replacement_errors: if error_type == 'unregistered': print(f"A node whose class doesn't exist was found in node tree \"{tree_name}\"", file=reportf) elif error_type == 'update failed': print(f"A node of type {node_class} in tree \"{tree_name}\" failed to be updated, " f"because there is no (longer?) an update routine for this version of the node.", file=reportf) elif error_type == 'future version': print(f"A node of type {node_class} in tree \"{tree_name}\" seemingly comes from a future version of armory. " f"Please check whether your version of armory is up to date", file=reportf) elif error_type == 'bad version': print(f"A node of type {node_class} in tree \"{tree_name}\" doesn't have version information attached to it. " f"If so, please check that the nodes in the file are compatible with the in-code node classes. " f"If this nodes comes from an add-on, please check that it is compatible with this version of armory.", file=reportf) elif error_type == 'misc.': print(f"A node of type {node_class} in tree \"{tree_name}\" failed to be updated, " f"because the node's update procedure itself failed. Original exception:" "\n" + tb + "\n", file=reportf) else: print(f"Whoops, we don't know what this error type (\"{error_type}\") means. You might want to report a bug here. " f"All we know is that it comes form a node of class {node_class} in the node tree called \"{tree_name}\".", file=reportf) log.error(f'There were errors in the node update procedure, a detailed report has been written to {reportfile}') bpy.ops.arm.show_node_update_errors()