Merge branch 'node-versioning' into logic-nodes

This commit is contained in:
niacdoial 2020-09-12 18:22:21 +02:00
commit cd730249af
8 changed files with 564 additions and 167 deletions

View file

@ -1,6 +1,6 @@
import itertools
from collections import OrderedDict
from typing import Any, Generator, List, Optional, Type
from typing import Any, Generator, List, Optional, Type, Dict
from typing import OrderedDict as ODict # Prevent naming conflicts
import bpy.types
@ -19,10 +19,41 @@ array_nodes = dict()
class ArmLogicTreeNode(bpy.types.Node):
def init(self, context):
# make sure a given node knows the version of the NodeClass from when it was created
if isinstance(type(self).arm_version, int):
self.arm_version = type(self).arm_version
else:
self.arm_version = 1
@classmethod
def poll(cls, ntree):
return ntree.bl_idname == 'ArmLogicTreeType'
def get_replacement_node(self, node_tree: bpy.types.NodeTree):
# needs to be overridden by individual node classes with arm_version>1
"""(only called if the node's version is inferior to the node class's version)
Help with the node replacement process, by explaining how a node (`self`) should be replaced.
This method can either return a NodeReplacement object (see `nodes_logic.py`), or a brand new node.
If a new node is returned, then the following needs to be already set:
- the node's links to the other nodes
- the node's properties
- the node inputs's default values
If more than one node need to be created (for example, if an input needs a type conversion after update),
please return all the nodes in a list.
please raise a LookupError specifically when the node's version isn't handled by the function.
note that the lowest 'defined' version should be 1. if the node's version is 0, it means that it has been saved before versioning was a thing.
NODES OF VERSION 1 AND VERSION 0 SHOULD HAVE THE SAME CONTENTS
"""
if self.arm_version==0 and type(self).arm_version == 1:
return NodeReplacement.Identity(self) # in case someone doesn't implement this function, but the node has version 0.
else:
raise LookupError(f"the current node class, {repr(type(self)):s}, does not implement the getReplacementNode method, even though it has updated")
def add_input(self, socket_type: str, socket_name: str, default_value: Any = None, is_var: bool = False) -> bpy.types.NodeSocket:
"""Adds a new input socket to the node.
@ -58,6 +89,82 @@ class ArmLogicTreeNode(bpy.types.Node):
return socket
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: Dict[str, str] = {},
input_defaults: Dict[int, any] = {}, property_defaults: Dict[str, any]={}):
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 = property_mapping
self.input_defaults = input_defaults
self.property_defaults = 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 are called "property0", "property1", etc...
"""
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
while hasattr(node, f'property{i:d}'):
props[f'property{i:d}'] = f'property{i:d}'
i +=1
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
class ArmNodeAddInputButton(bpy.types.Operator):
"""Add new input"""
bl_idname = 'arm.node_add_input'
@ -317,7 +424,7 @@ def add_category(category: str, section: str = 'default', icon: str = 'BLANK1',
return None
def add_node(node_type: Type[bpy.types.Node], category: str, section: str = 'default') -> None:
def add_node(node_type: Type[bpy.types.Node], category: str, section: str = 'default', is_obselete: bool = False) -> None:
"""
Registers a node to the given category. If no section is given, the
node is put into the default section that does always exist.
@ -330,6 +437,11 @@ def add_node(node_type: Type[bpy.types.Node], category: str, section: str = 'def
nodes.append(node_type)
node_category = get_category(category)
if is_obselete:
# We need the obselete nodes to be registered in order to have them replaced,
# but do not add them to the menu.
return
if node_category is None:
node_category = add_category(category)

View file

@ -5,78 +5,102 @@ from bpy.types import NodeSocket
import arm.utils
class ArmActionSocket(NodeSocket):
bl_idname = 'ArmNodeSocketAction'
bl_label = 'Action Socket'
def draw(self, context, layout, node, text):
layout.label(text=self.name)
def draw_color(self, context, node):
return 0.8, 0.3, 0.3, 1
class ArmAnimActionSocket(NodeSocket):
bl_idname = 'ArmNodeSocketAnimAction'
bl_label = 'Action Socket'
default_value_get: PointerProperty(name='Action', type=bpy.types.Action)
def get_default_value(self):
if self.default_value_get is None:
return ''
if self.default_value_get.name not in bpy.data.actions:
return self.default_value_get.name
name = arm.utils.asset_name(bpy.data.actions[self.default_value_get.name])
return arm.utils.safestr(name)
def draw(self, context, layout, node, text):
if self.is_output:
layout.label(text=self.name)
elif self.is_linked:
layout.label(text=self.name)
else:
layout.prop_search(self, 'default_value_get', bpy.data, 'actions', icon='NONE', text='')
def draw_color(self, context, node):
return 0.8, 0.8, 0.8, 1
class ArmArraySocket(NodeSocket):
bl_idname = 'ArmNodeSocketArray'
bl_label = 'Array Socket'
def draw(self, context, layout, node, text):
layout.label(text=self.name)
def draw_color(self, context, node):
return 0.8, 0.4, 0.0, 1
class ArmCustomSocket(NodeSocket):
"""
A custom socket that can be used to define more socket types for
logic node packs. Do not use this type directly (it is not
registered)!
"""
bl_idname = 'ArmCustomSocket'
bl_label = 'Custom Socket'
# note: trying to use the `type` property will fail. All custom nodes will have "VALUE" as a type, because it is the default.
arm_socket_type = 'NONE'
# please also declare a property named "default_value_raw" of arm_socket_type isn't "NONE"
def get_default_value(self):
"""Override this for values of unconnected input sockets."""
return None
class ArmObjectSocket(NodeSocket):
bl_idname = 'ArmNodeSocketObject'
bl_label = 'Object Socket'
default_value_get: PointerProperty(name='Object', type=bpy.types.Object)
class ArmActionSocket(ArmCustomSocket):
bl_idname = 'ArmNodeSocketAction'
bl_label = 'Action Socket'
arm_socket_type = 'NONE'
def draw(self, context, layout, node, text):
layout.label(text=self.name)
def draw_color(self, context, node):
return 0.8, 0.3, 0.3, 1
class ArmAnimActionSocket(ArmCustomSocket):
bl_idname = 'ArmNodeSocketAnimAction'
bl_label = 'Action Socket'
arm_socket_type = 'STRING'
default_value_get: PointerProperty(name='Action', type=bpy.types.Action) # legacy version of the line after this one
default_value_raw: PointerProperty(name='Action', type=bpy.types.Action)
def __init__(self):
super().__init__()
if self.default_value_get is not None:
self.default_value_raw = self.default_value_get
self.default_value_get = None
def get_default_value(self):
if self.default_value_get is None:
if self.default_value_raw is None:
return ''
if self.default_value_get.name not in bpy.data.objects:
return self.default_value_get.name
return arm.utils.asset_name(bpy.data.objects[self.default_value_get.name])
if self.default_value_raw.name not in bpy.data.actions:
return self.default_value_raw.name
name = arm.utils.asset_name(bpy.data.actions[self.default_value_raw.name])
return arm.utils.safestr(name)
def draw(self, context, layout, node, text):
if self.is_output:
layout.label(text=self.name)
elif self.is_linked:
layout.label(text=self.name)
else:
layout.prop_search(self, 'default_value_raw', bpy.data, 'actions', icon='NONE', text='')
def draw_color(self, context, node):
return 0.8, 0.8, 0.8, 1
class ArmArraySocket(ArmCustomSocket):
bl_idname = 'ArmNodeSocketArray'
bl_label = 'Array Socket'
arm_socket_type = 'NONE'
def draw(self, context, layout, node, text):
layout.label(text=self.name)
def draw_color(self, context, node):
return 0.8, 0.4, 0.0, 1
class ArmObjectSocket(ArmCustomSocket):
bl_idname = 'ArmNodeSocketObject'
bl_label = 'Object Socket'
arm_socket_type = 'OBJECT'
default_value_get: PointerProperty(name='Object', type=bpy.types.Object) # legacy version of the line after this one
default_value_raw: PointerProperty(name='Object', type=bpy.types.Object)
def __init__(self):
super().__init__()
if self.default_value_get is not None:
self.default_value_raw = self.default_value_get
self.default_value_get = None
def get_default_value(self):
if self.default_value_raw is None:
return ''
if self.default_value_raw.name not in bpy.data.objects:
return self.default_value_raw.name
return arm.utils.asset_name(bpy.data.objects[self.default_value_raw.name])
def draw(self, context, layout, node, text):
if self.is_output:
@ -85,7 +109,7 @@ class ArmObjectSocket(NodeSocket):
layout.label(text=self.name)
else:
row = layout.row(align=True)
row.prop_search(self, 'default_value_get', context.scene, 'objects', icon='NONE', text=self.name)
row.prop_search(self, 'default_value_raw', context.scene, 'objects', icon='NONE', text=self.name)
def draw_color(self, context, node):
return 0.15, 0.55, 0.75, 1

View file

@ -4,8 +4,10 @@ class RotateObjectNode(ArmLogicTreeNode):
"""Rotate object node"""
bl_idname = 'LNRotateObjectNode'
bl_label = 'Rotate Object'
arm_version = 1
def init(self, context):
super().init(context)
self.add_input('ArmNodeSocketAction', 'In')
self.add_input('ArmNodeSocketObject', 'Object')
self.add_input('NodeSocketVector', 'Euler Angles')

View file

@ -6,16 +6,30 @@ class RotateObjectAroundAxisNode(ArmLogicTreeNode):
bl_label = 'Rotate Object Around Axis'
bl_description = 'Rotate Object Around Axis (Depreciated: use "Rotate Object")'
bl_icon = 'ERROR'
arm_version=2
def init(self, context):
super().init(context)
print("init() getting called")
self.add_input('ArmNodeSocketAction', 'In')
self.add_input('ArmNodeSocketObject', 'Object')
self.add_input('NodeSocketVector', 'Axis', default_value=[0, 0, 1])
self.add_input('NodeSocketFloat', 'Angle')
self.add_output('ArmNodeSocketAction', 'Out')
def get_replacement_node(self, node_tree: bpy.types.NodeTree):
if self.arm_version not in (0, 1):
raise LookupError()
return NodeReplacement(
'LNRotateObjectAroundAxisNode', self.arm_version, 'LNRotateObjectNode', 1,
in_socket_mapping = {0:0, 1:1, 2:2, 3:3}, out_socket_mapping={0:0},
property_defaults={'property0': "Angle Axies (Radians)"}
)
def draw_buttons(self, context, layout):
row = layout.row(align=True)
row.label(text='Depreciated. Consider using "Rotate Object"')
add_node(RotateObjectAroundAxisNode, category=PKG_AS_CATEGORY, section='rotation')
add_node(RotateObjectAroundAxisNode, category=PKG_AS_CATEGORY, section='rotation', is_obselete=True)

View file

@ -246,40 +246,39 @@ def get_root_nodes(node_group):
def build_default_node(inp: bpy.types.NodeSocket):
"""Creates a new node to give a not connected input socket a value"""
null_node = 'new armory.logicnode.NullNode(this)'
if isinstance(inp, arm.logicnode.arm_sockets.ArmCustomSocket):
is_custom_socket = isinstance(inp, arm.logicnode.arm_sockets.ArmCustomSocket)
if is_custom_socket:
# ArmCustomSockets need to implement get_default_value()
default_value = inp.get_default_value()
if default_value is None:
return null_node
if isinstance(default_value, str):
default_value = f'"{default_value}"'
default_value = '"{:s}"'.format( default_value.replace('"', '\\"') )
inp_type = inp.arm_socket_type # any custom socket's `type` is "VALUE". might as well have valuable type information for custom nodes as well.
else:
default_value = inp.default_value
inp_type = inp.type
if inp_type == 'VECTOR':
return f'new armory.logicnode.VectorNode(this, {default_value[0]}, {default_value[1]}, {default_value[2]})'
elif inp_type == 'RGBA':
return f'new armory.logicnode.ColorNode(this, {default_value[0]}, {default_value[1]}, {default_value[2]}, {default_value[3]})'
elif inp_type == 'RGB':
return f'new armory.logicnode.ColorNode(this, {default_value[0]}, {default_value[1]}, {default_value[2]})'
elif inp_type == 'VALUE':
return f'new armory.logicnode.FloatNode(this, {default_value})'
elif inp_type == 'INT':
return f'new armory.logicnode.IntegerNode(this, {default_value})'
elif inp_type == 'BOOLEAN':
return f'new armory.logicnode.BooleanNode(this, {str(default_value).lower()})'
elif inp_type == 'STRING':
return f'new armory.logicnode.StringNode(this, {default_value})'
elif inp_type == 'NONE':
return 'new armory.logicnode.NullNode(this)'
elif inp_type == 'OBJECT':
return f'new armory.logicnode.ObjectNode(this, {default_value})'
elif is_custom_socket:
return f'new armory.logicnode.DynamicNode(this, {default_value})'
if inp.bl_idname == 'ArmNodeSocketAction' or inp.bl_idname == 'ArmNodeSocketArray':
return null_node
if inp.bl_idname == 'ArmNodeSocketObject':
return f'new armory.logicnode.ObjectNode(this, "{inp.get_default_value()}")'
if inp.bl_idname == 'ArmNodeSocketAnimAction':
# Backslashes are not allowed in f-strings so we need this variable
default_value = inp.get_default_value().replace("\"", "\\\"")
return f'new armory.logicnode.StringNode(this, "{default_value}")'
if inp.type == 'VECTOR':
return f'new armory.logicnode.VectorNode(this, {inp.default_value[0]}, {inp.default_value[1]}, {inp.default_value[2]})'
elif inp.type == 'RGBA':
return f'new armory.logicnode.ColorNode(this, {inp.default_value[0]}, {inp.default_value[1]}, {inp.default_value[2]}, {inp.default_value[3]})'
elif inp.type == 'RGB':
return f'new armory.logicnode.ColorNode(this, {inp.default_value[0]}, {inp.default_value[1]}, {inp.default_value[2]})'
elif inp.type == 'VALUE':
return f'new armory.logicnode.FloatNode(this, {inp.default_value})'
elif inp.type == 'INT':
return f'new armory.logicnode.IntegerNode(this, {inp.default_value})'
elif inp.type == 'BOOLEAN':
return f'new armory.logicnode.BooleanNode(this, {str(inp.default_value).lower()})'
elif inp.type == 'STRING':
default_value = inp.default_value.replace("\"", "\\\"")
return f'new armory.logicnode.StringNode(this, "{default_value}")'
return null_node
else:
return 'new armory.logicnode.NullNode(this)'

View file

@ -1,21 +1,23 @@
from typing import Callable
import os.path
import time
import webbrowser
import bpy
from bpy.props import BoolProperty, StringProperty
from bpy.types import NodeTree
#from bpy.types import NodeTree, Node
import nodeitems_utils
from arm.logicnode import *
from arm.logicnode import arm_nodes
from arm.logicnode.arm_nodes import ArmNodeCategory
from arm.logicnode import arm_sockets
from arm.logicnode import *
registered_nodes = []
registered_categories = []
class ArmLogicTree(NodeTree):
class ArmLogicTree(bpy.types.NodeTree):
"""Logic nodes"""
bl_idname = 'ArmLogicTreeType'
bl_label = 'Logic Node Editor'
@ -278,89 +280,138 @@ class ARMAddSetVarNode(bpy.types.Operator):
self.setNodeRef = setNode
return({'FINISHED'})
# node replacement code
replacements = {}
def add_replacement(item):
replacements[item.from_node] = item
def replace(tree: bpy.types.NodeTree, node: bpy.types.Node):
"""Replaces the given node with its replacement."""
def get_replaced_nodes():
return replacements.keys()
def get_replacement_for_node(node):
return replacements[node.bl_idname]
class Replacement:
# represents a single replacement rule, this can replace exactly one node with another
#
# from_node: the node type to be removed
# to_node: the node type which takes from_node's place
# *SocketMapping: 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
def __init__(self, from_node, to_node, in_socket_mapping, out_socket_mapping, property_mapping):
self.from_node = from_node
self.to_node = to_node
self.in_socket_mapping = in_socket_mapping
self.out_socket_mapping = out_socket_mapping
self.property_mapping = property_mapping
# actual replacement code
def replace(tree, node):
replacement = get_replacement_for_node(node)
newnode = tree.nodes.new(replacement.to_node)
newnode.location = node.location
newnode.parent = node.parent
parent = node.parent
while parent is not None:
newnode.location[0] += parent.location[0]
newnode.location[1] += parent.location[1]
parent = parent.parent
# 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)
# map properties
for prop in replacement.property_mapping.keys():
setattr(newnode, replacement.property_mapping.get(prop), getattr(node, prop))
if isinstance(response, bpy.types.Node):
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 node in response:
newnode.parent = node.parent
newnode.location = node.location
newnode.select = node.select
elif isinstance(response, arm_nodes.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")
newnode = tree.nodes.new(response.to_node)
if newnode.arm_version != replacement.to_node_version:
raise LookupError("the provided NodeReplacement doesn't seem to correspond to the node needing replacement")
# map unconnected inputs
for in_socket in replacement.in_socket_mapping.keys():
if not node.inputs[in_socket].is_linked:
newnode.inputs[replacement.in_socket_mapping.get(in_socket)].default_value = node.inputs[in_socket].default_value
# some misc. properties
newnode.parent = node.parent
newnode.location = node.location
newnode.select = node.select
# map connected inputs
for link in tree.links:
if link.from_node == node:
# this is an output link
for i in range(0, len(node.outputs)):
# check the outputs
# i represents the socket index
# do we want to remap it & is it the one referenced in the current link
if i in replacement.out_socket_mapping.keys() and node.outputs[i] == link.from_socket:
tree.links.new(newnode.outputs[replacement.out_socket_mapping.get(i)], link.to_socket)
if link.to_node == node:
# this is an input link
for i in range(0, len(node.inputs)):
# check the inputs
# i represents the socket index
# do we want to remap it & is it the one referenced socket in the current link
if i in replacement.in_socket_mapping.keys() and node.inputs[i] == link.to_socket:
tree.links.new(newnode.inputs[replacement.in_socket_mapping.get(i)], link.from_socket)
# 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_sockets.ArmCustomSocket):
if input_socket.arm_socket_type != 'NONE':
input_socket.default_value_raw = input_value
else:
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_sockets.ArmCustomSocket):
if dest_socket.arm_socket_type != 'NONE':
dest_socket.default_value_raw = src_socket.default_value_raw
else:
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 replaceAll():
global replacement_errors
list_of_errors = set()
for tree in bpy.data.node_groups:
if tree.bl_idname == "ArmLogicTreeType":
for node in tree.nodes:
if node.bl_idname in get_replaced_nodes():
print("Replacing "+ node.bl_idname+ " in Tree "+tree.name)
replace(tree, node)
if not isinstance(type(node).arm_version, int):
continue # TODO: that's a line to remove when all node classes will have their own version set.
if not node.is_registered_node_type():
# node type deleted. That's unusual. Or it has been replaced for a looong time.
list_of_errors.add( ('unregistered', None, tree.name) )
if node.arm_version < type(node).arm_version:
try:
replace(tree, node)
except LookupError as err:
list_of_errors.add( ('update failed', node.bl_idname, tree.name) )
except Exception as err:
list_of_errors.add( ('misc.', node.bl_idname, tree.name) )
elif node.arm_version > type(node).arm_version:
list_of_errors.add( ('future version', node.bl_idname, tree.name) )
# if possible, make a popup about the errors.
# also write an error report.
if len(list_of_errors) > 0:
print('there were errors in node replacement')
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")
)
)
reportf = open(reportfile, 'w')
for error_type, node_class, tree_name in list_of_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 update isn't implemented (anymore?) 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 == 'misc.':
print(f"", 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)
reportf.close()
replacement_errors = list_of_errors
bpy.ops.arm.show_node_update_errors()
replacement_errors = None
class ReplaceNodesOperator(bpy.types.Operator):
'''Automatically replaces deprecated nodes.'''
"""Automatically replaces deprecated nodes."""
bl_idname = "node.replace"
bl_label = "Replace Nodes"
bl_description = "Replace deprecated nodes"
def execute(self, context):
replaceAll()
@ -368,11 +419,12 @@ class ReplaceNodesOperator(bpy.types.Operator):
@classmethod
def poll(cls, context):
return context.space_data != None and context.space_data.type == 'NODE_EDITOR'
return context.space_data is not None and context.space_data.type == 'NODE_EDITOR'
# TODO: deprecated
# Input Replacement Rules
# add_replacement(Replacement("LNOnGamepadNode", "LNMergedGamepadNode", {0: 0}, {0: 0}, {"property0": "property0", "property1": "property1"}))
# Node Replacement Rules (TODO port them)
#add_replacement(NodeReplacement("LNOnGamepadNode", "LNMergedGamepadNode", {0: 0}, {0: 0}, {"property0": "property0", "property1": "property1"}))
#add_replacement(NodeReplacement("LNOnKeyboardNode", "LNMergedKeyboardNode", {}, {0: 0}, {"property0": "property0", "property1": "property1"}))
def register():
arm_sockets.register()

View file

@ -286,6 +286,7 @@ def init_properties():
bpy.types.Node.arm_material_param = BoolProperty(name="Parameter", description="Control this node from script", default=False)
bpy.types.Node.arm_logic_id = StringProperty(name="ID", description="Nodes with equal identifier will share data", default='')
bpy.types.Node.arm_watch = BoolProperty(name="Watch", description="Watch value of this node in debug console", default=False)
bpy.types.Node.arm_version = IntProperty(name="Node Version", description="The version of an instanced node", default=0)
# Particles
bpy.types.ParticleSettings.arm_count_mult = FloatProperty(name="Multiply Count", description="Multiply particle count when rendering in Armory", default=1.0)
bpy.types.ParticleSettings.arm_loop = BoolProperty(name="Loop", description="Loop this particle system", default=False)

View file

@ -1,4 +1,5 @@
import os
import time
import bpy
@ -9,6 +10,7 @@ import arm.make as make
import arm.make_state as state
import arm.props as props
import arm.props_properties
import arm.nodes_logic
import arm.proxy
import arm.utils
@ -1773,6 +1775,191 @@ class ARM_PT_MaterialNodePanel(bpy.types.Panel):
if n != None and (n.bl_idname == 'ShaderNodeRGB' or n.bl_idname == 'ShaderNodeValue' or n.bl_idname == 'ShaderNodeTexImage'):
layout.prop(context.active_node, 'arm_material_param')
class ARM_OT_ShowFileVersionInfo(bpy.types.Operator):
bl_label = 'Show old file version info'
bl_idname = 'arm.show_old_file_version_info'
bl_description = ('Displays an info panel that warns about opening a file'
'which was created in a previous version of Armory')
# bl_options = {'INTERNAL'}
wrd = None
def draw_message_box(self, context):
file_version = ARM_OT_ShowFileVersionInfo.wrd.arm_version
current_version = props.arm_version
layout = self.layout
layout = layout.column(align=True)
layout.alignment = 'EXPAND'
if current_version == file_version:
layout.label('This file was saved in', icon='INFO')
layout.label('the current Armory version', icon='BLANK1')
layout.separator()
layout.label(f'(version: {current_version}')
row = layout.row(align=True)
row.active_default = True
row.operator('arm.discard_popup', text='Ok')
# this will help order versions better, somewhat.
# note: this is NOT complete
current_version = tuple( current_version.split('.') )
file_version = tuple( file_version.split('.') )
if current_version > file_version:
layout.label(text='Warning: This file was saved in a', icon='ERROR')
layout.label(text='previous version of Armory!', icon='BLANK1')
layout.separator()
layout.label(text='Please inform yourself about breaking changes!', icon='BLANK1')
layout.label(text=f'File saved in: {file_version}', icon='BLANK1')
layout.label(text=f'Current version: {current_version}', icon='BLANK1')
layout.separator()
layout.separator()
layout.label(text='Should Armory try to automatically update', icon='BLANK1')
layout.label(text='the file to the current SDK version?', icon='BLANK1')
layout.separator()
row = layout.row(align=True)
row.active_default = True
row.operator('arm.update_file_sdk', text='Yes')
row.active_default = False
row.operator('arm.discard_popup', text='No')
else:
layout.label(text='Warning: This file was saved in a', icon='ERROR')
layout.label(text='future version of Armory!', icon='BLANK1')
layout.separator()
layout.label(text='It is impossible to downgrade a file,', icon='BLANK1')
layout.label(text='Something will probably be broken here.', icon='BLANK1')
layout.label(text=f'File saved in: {file_version}', icon='BLANK1')
layout.label(text=f'Current version: {current_version}', icon='BLANK1')
layout.separator()
layout.separator()
layout.label(text='Please check how this file was created', icon='BLANK1')
layout.separator()
row = layout.row(align=True)
row.active_default = True
row.operator('arm.discard_popup', text='Ok')
def execute(self, context):
ARM_OT_ShowFileVersionInfo.wrd = bpy.data.worlds['Arm']
context.window_manager.popover(ARM_OT_ShowFileVersionInfo.draw_message_box, ui_units_x=16)
return {"FINISHED"}
class ARM_OT_ShowNodeUpdateErrors(bpy.types.Operator):
bl_label = 'Show upgrade failure details'
bl_idname = 'arm.show_node_update_errors'
bl_description = ('Displays an info panel that shows the different errors that occurred when upgrading nodes')
wrd = None # a helper internal variable
def draw_message_box(self, context):
list_of_errors = arm.nodes_logic.replacement_errors.copy()
# note: list_of_errors is a set of tuples: `(error_type, node_class, tree_name)`
# where `error_type` can be "unregistered", "update failed", "future version" or "misc."
file_version = ARM_OT_ShowNodeUpdateErrors.wrd.arm_version
current_version = props.arm_version
# this will help order versions better, somewhat.
# note: this is NOT complete
current_version_2 = tuple( current_version.split('.') )
file_version_2 = tuple( file_version.split('.') )
is_armory_upgrade = (current_version_2 > file_version_2)
error_types = set()
errored_trees = set()
errored_nodes = set()
for error_entry in list_of_errors:
error_types.add(error_entry[0])
errored_nodes.add(error_entry[1])
errored_trees.add(error_entry[2])
layout = self.layout
layout = layout.column(align=True)
layout.alignment = 'EXPAND'
layout.label(text="Some nodes failed to be updated to the current armory version", icon="ERROR")
if current_version==file_version:
layout.label(text="(This might be because you are using a development snapshot, or a homemade version ;) )", icon='BLANK1')
elif not is_armory_upgrade:
layout.label(text="(Please note that it is not possible do downgrade nodes to a previous version either.", icon='BLANK1')
layout.label(text="This might be the cause of your problem.)", icon='BLANK1')
layout.label(text=f'File saved in: {file_version}', icon='BLANK1')
layout.label(text=f'Current version: {current_version}', icon='BLANK1')
layout.separator()
if 'update failed' in error_types:
layout.label(text="Some nodes do not have an update procedure to deal with the version saved in this file.", icon='BLANK1')
if current_version==file_version:
layout.label(text="(if you are a developer, this might be because you didn't implement it yet.)", icon='BLANK1')
if 'unregistered' in error_types:
if is_armory_upgrade:
layout.label(text='Some nodes seem to be too old to be understood by armory anymore', icon='BLANK1')
else:
layout.label(text="Some nodes are unknown to armory, either because they are too new or too old.", icon='BLANK1')
if 'future version' in error_types:
if is_armory_upgrade:
layout.label(text='Somehow, some nodes seem to have been created with a future version of armory.', icon='BLANK1')
else:
layout.label(text='Some nodes seem to have been created with a future version of armory.', icon='BLANK1')
if 'misc.' in error_types:
layout.label(text="Some nodes' update procedure failed to complete")
layout.separator()
layout.label(text='the nodes impacted are the following:', icon='BLANK1')
for node in errored_nodes:
layout.label(text=f' {node}', icon='BLANK1')
layout.separator()
layout.label(text='the node trees impacted are the following:', icon='BLANK1')
for tree in errored_trees:
layout.label(text=f' "{tree}"', icon='BLANK1')
layout.separator()
layout.label(text="A detailed error report has been saved next to the blender file.", icon='BLANK1')
layout.label(text="the file name is \"node_update_failure\", followed by the current time.", icon='BLANK1')
layout.separator()
row = layout.row(align=True)
row.active_default = False
row.operator('arm.discard_popup', text='Ok')
def execute(self, context):
ARM_OT_ShowNodeUpdateErrors.wrd = bpy.data.worlds['Arm']
context.window_manager.popover(ARM_OT_ShowNodeUpdateErrors.draw_message_box, ui_units_x=32)
return {"FINISHED"}
class ARM_OT_UpdateFileSDK(bpy.types.Operator):
bl_idname = 'arm.update_file_sdk'
bl_label = 'Update file to current SDK version'
bl_description = bl_label
bl_options = {'INTERNAL'}
def execute(self, context):
wrd = bpy.data.worlds['Arm']
# This allows for seamless migration from ealier versions of Armory
for rp in wrd.arm_rplist: # TODO: deprecated
if rp.rp_gi != 'Off':
rp.rp_gi = 'Off'
rp.rp_voxelao = True
# Replace deprecated nodes
arm.nodes_logic.replaceAll()
wrd.arm_version = props.arm_version
wrd.arm_commit = props.arm_commit
arm.make.clean()
print(f'Project updated to SDK {props.arm_version}. Please save the .blend file.')
return {'FINISHED'}
class ARM_OT_DiscardPopup(bpy.types.Operator):
"""Empty operator for discarding dialogs."""
@ -1833,6 +2020,9 @@ def register():
bpy.utils.register_class(ArmSyncProxyButton)
bpy.utils.register_class(ArmPrintTraitsButton)
bpy.utils.register_class(ARM_PT_MaterialNodePanel)
bpy.utils.register_class(ARM_OT_UpdateFileSDK)
bpy.utils.register_class(ARM_OT_ShowFileVersionInfo)
bpy.utils.register_class(ARM_OT_ShowNodeUpdateErrors)
bpy.utils.register_class(ARM_OT_DiscardPopup)
bpy.types.VIEW3D_HT_header.append(draw_view3d_header)
@ -1844,6 +2034,9 @@ def unregister():
bpy.types.VIEW3D_HT_header.remove(draw_view3d_header)
bpy.utils.unregister_class(ARM_OT_DiscardPopup)
bpy.utils.unregister_class(ARM_OT_ShowNodeUpdateErrors)
bpy.utils.unregister_class(ARM_OT_ShowFileVersionInfo)
bpy.utils.unregister_class(ARM_OT_UpdateFileSDK)
bpy.utils.unregister_class(ARM_PT_ObjectPropsPanel)
bpy.utils.unregister_class(ARM_PT_ModifiersPropsPanel)
bpy.utils.unregister_class(ARM_PT_ParticlesPropsPanel)
@ -1890,4 +2083,4 @@ def unregister():
bpy.utils.unregister_class(ArmProxyApplyAllButton)
bpy.utils.unregister_class(ArmSyncProxyButton)
bpy.utils.unregister_class(ArmPrintTraitsButton)
bpy.utils.unregister_class(ARM_PT_MaterialNodePanel)
bpy.utils.unregister_class(ARM_PT_MaterialNodePanel)