546 lines
22 KiB
Python
Executable file
546 lines
22 KiB
Python
Executable file
import os.path
|
|
import time
|
|
from typing import Callable
|
|
import webbrowser
|
|
|
|
import bpy
|
|
from bpy.props import BoolProperty, StringProperty
|
|
|
|
import arm.logicnode.arm_nodes as arm_nodes
|
|
import arm.logicnode
|
|
import arm.utils
|
|
|
|
registered_nodes = []
|
|
registered_categories = []
|
|
|
|
|
|
class ArmLogicTree(bpy.types.NodeTree):
|
|
"""Logic nodes"""
|
|
bl_idname = 'ArmLogicTreeType'
|
|
bl_label = 'Logic Node Editor'
|
|
bl_icon = 'DECORATE'
|
|
|
|
|
|
class ARM_MT_NodeAddOverride(bpy.types.Menu):
|
|
"""
|
|
Overrides the `Add node` menu. If called from the logic node
|
|
editor, the custom menu is drawn, otherwise the default one is drawn.
|
|
|
|
Todo: Find a better solution to custom menus, this will conflict
|
|
with other add-ons overriding this menu.
|
|
"""
|
|
bl_idname = "NODE_MT_add"
|
|
bl_label = "Add"
|
|
bl_translation_context = bpy.app.translations.contexts.operator_default
|
|
|
|
overridden_draw: Callable = None
|
|
|
|
def draw(self, context):
|
|
if context.space_data.tree_type == 'ArmLogicTreeType':
|
|
layout = self.layout
|
|
|
|
# Invoke the search
|
|
layout.operator_context = "INVOKE_DEFAULT"
|
|
layout.operator('arm.node_search', icon="VIEWZOOM")
|
|
|
|
for category_section in arm_nodes.category_items.values():
|
|
layout.separator()
|
|
|
|
for category in category_section:
|
|
layout.menu(f'ARM_MT_{category.name.lower()}_menu', text=category.name, icon=category.icon)
|
|
|
|
else:
|
|
ARM_MT_NodeAddOverride.overridden_draw(self, context)
|
|
|
|
|
|
class ARM_OT_AddNodeOverride(bpy.types.Operator):
|
|
bl_idname = "arm.add_node_override"
|
|
bl_label = "Add Node"
|
|
bl_property = "type"
|
|
|
|
type: StringProperty(name="NodeItem type")
|
|
use_transform: BoolProperty(name="Use Transform")
|
|
|
|
def invoke(self, context, event):
|
|
bpy.ops.node.add_node('INVOKE_DEFAULT', type=self.type, use_transform=self.use_transform)
|
|
return {"FINISHED"}
|
|
|
|
@classmethod
|
|
def description(cls, context, properties):
|
|
"""Show the node's bl_description attribute as a tooltip or, if
|
|
it doesn't exist, its docstring."""
|
|
nodetype = arm.utils.type_name_to_type(properties.type)
|
|
|
|
if hasattr(nodetype, 'bl_description'):
|
|
return nodetype.bl_description.split('.')[0]
|
|
|
|
if nodetype.__doc__ is None:
|
|
return ""
|
|
|
|
return nodetype.__doc__.split('.')[0]
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data.tree_type == 'ArmLogicTreeType' and context.space_data.edit_tree
|
|
|
|
|
|
def get_category_draw_func(category: arm_nodes.ArmNodeCategory):
|
|
def draw_category_menu(self, context):
|
|
layout = self.layout
|
|
|
|
for index, node_section in enumerate(category.node_sections.values()):
|
|
if index != 0:
|
|
layout.separator()
|
|
|
|
for node_item in node_section:
|
|
op = layout.operator("arm.add_node_override", text=node_item.label)
|
|
op.type = node_item.nodetype
|
|
op.use_transform = True
|
|
|
|
return draw_category_menu
|
|
|
|
|
|
def register_nodes():
|
|
global registered_nodes
|
|
|
|
# Re-register all nodes for now..
|
|
if len(registered_nodes) > 0 or len(registered_categories) > 0:
|
|
unregister_nodes()
|
|
|
|
arm.logicnode.init_nodes()
|
|
|
|
for node_type in arm_nodes.nodes:
|
|
# Don't register internal nodes, they are already registered
|
|
if not issubclass(node_type, bpy.types.NodeInternal):
|
|
registered_nodes.append(node_type)
|
|
bpy.utils.register_class(node_type)
|
|
|
|
# Also add Blender's layout nodes
|
|
arm_nodes.add_node(bpy.types.NodeReroute, 'Layout')
|
|
arm_nodes.add_node(bpy.types.NodeFrame, 'Layout')
|
|
|
|
# Generate and register category menus
|
|
for category_section in arm_nodes.category_items.values():
|
|
for category in category_section:
|
|
category.sort_nodes()
|
|
menu_class = type(f'ARM_MT_{category.name}Menu', (bpy.types.Menu, ), {
|
|
'bl_space_type': 'NODE_EDITOR',
|
|
'bl_idname': f'ARM_MT_{category.name.lower()}_menu',
|
|
'bl_label': category.name,
|
|
'bl_description': category.description,
|
|
'draw': get_category_draw_func(category)
|
|
})
|
|
registered_categories.append(menu_class)
|
|
|
|
bpy.utils.register_class(menu_class)
|
|
|
|
|
|
def unregister_nodes():
|
|
global registered_nodes, registered_categories
|
|
|
|
for n in registered_nodes:
|
|
if issubclass(n, arm_nodes.ArmLogicTreeNode):
|
|
n.on_unregister()
|
|
bpy.utils.unregister_class(n)
|
|
for c in registered_categories:
|
|
bpy.utils.unregister_class(c)
|
|
|
|
registered_nodes = []
|
|
registered_categories = []
|
|
|
|
|
|
class ARM_PT_LogicNodePanel(bpy.types.Panel):
|
|
bl_label = 'Armory Logic Node'
|
|
bl_idname = 'ARM_PT_LogicNodePanel'
|
|
bl_space_type = 'NODE_EDITOR'
|
|
bl_region_type = 'UI'
|
|
bl_category = 'Node'
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data.tree_type == 'ArmLogicTreeType' and context.space_data.edit_tree
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
layout.use_property_split = True
|
|
layout.use_property_decorate = False
|
|
if context.active_node != None and context.active_node.bl_idname.startswith('LN'):
|
|
layout.prop(context.active_node, 'arm_logic_id')
|
|
layout.prop(context.active_node, 'arm_watch')
|
|
layout.operator('arm.open_node_source')
|
|
|
|
class ArmOpenNodeSource(bpy.types.Operator):
|
|
"""Expose Haxe source"""
|
|
bl_idname = 'arm.open_node_source'
|
|
bl_label = 'Open Node Source'
|
|
|
|
def execute(self, context):
|
|
if context.selected_nodes is not None:
|
|
if len(context.selected_nodes) == 1:
|
|
if context.selected_nodes[0].bl_idname.startswith('LN'):
|
|
name = context.selected_nodes[0].bl_idname[2:]
|
|
version = arm.utils.get_last_commit()
|
|
if version == '':
|
|
version = 'master'
|
|
webbrowser.open(f'https://github.com/armory3d/armory/tree/{version}/Sources/armory/logicnode/{name}.hx')
|
|
return{'FINISHED'}
|
|
|
|
class ArmOpenNodePythonSource(bpy.types.Operator):
|
|
"""Expose Python source"""
|
|
bl_idname = 'arm.open_node_python_source'
|
|
bl_label = 'Open Node Python Source'
|
|
|
|
def execute(self, context):
|
|
if context.selected_nodes is not None:
|
|
if len(context.selected_nodes) == 1:
|
|
node = context.selected_nodes[0]
|
|
if node.bl_idname.startswith('LN') and node.arm_version is not None:
|
|
version = arm.utils.get_last_commit()
|
|
if version == '':
|
|
version = 'master'
|
|
rel_path = node.__module__.replace('.', '/')
|
|
webbrowser.open(f'https://github.com/armory3d/armory/tree/{version}/blender/{rel_path}.py')
|
|
return{'FINISHED'}
|
|
|
|
class ArmOpenNodeWikiEntry(bpy.types.Operator):
|
|
"""Expose Python source"""
|
|
bl_idname = 'arm.open_node_documentation'
|
|
bl_label = 'Open Node Documentation'
|
|
|
|
def to_wiki_id(self, node_name):
|
|
"""convert from the conventional node name to its wiki counterpart's anchor or id
|
|
expected node_name format: LN_[a-z_]+
|
|
"""
|
|
return node_name.replace('_','-')[3:]
|
|
|
|
def execute(self, context):
|
|
if context.selected_nodes is not None:
|
|
if len(context.selected_nodes) == 1:
|
|
node = context.selected_nodes[0]
|
|
if node.bl_idname.startswith('LN') and node.arm_version is not None:
|
|
wiki_id = self.to_wiki_id(node.__module__.rsplit('.', 2).pop())
|
|
webbrowser.open(f'https://github.com/armory3d/armory/wiki/reference#{wiki_id}')
|
|
return{'FINISHED'}
|
|
|
|
|
|
class ARM_PT_Variables(bpy.types.Panel):
|
|
bl_label = 'Armory Node Variables'
|
|
bl_idname = 'ARM_PT_Variables'
|
|
bl_space_type = 'NODE_EDITOR'
|
|
bl_region_type = 'UI'
|
|
bl_category = 'Node'
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data.tree_type == 'ArmLogicTreeType' and context.space_data.edit_tree
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
nodes = list(filter(lambda node: node.arm_logic_id != "", list(context.space_data.node_tree.nodes)))
|
|
|
|
IDs = []
|
|
for n in nodes:
|
|
if not n.arm_logic_id in IDs:
|
|
IDs.append(n.arm_logic_id)
|
|
|
|
for ID in IDs:
|
|
row = layout.row(align=True)
|
|
row.alignment = 'EXPAND'
|
|
row.label(text = ID)
|
|
getN = row.operator(operator = 'arm.add_var_node')
|
|
getN.ntype = ID
|
|
setN = row.operator('arm.add_setvar_node')
|
|
setN.ntype = ID
|
|
|
|
class ARMAddVarNode(bpy.types.Operator):
|
|
'''Add a linked node of that Variable'''
|
|
bl_idname = 'arm.add_var_node'
|
|
bl_label = 'Add Get'
|
|
bl_options = {'GRAB_CURSOR', 'BLOCKING'}
|
|
|
|
ntype: bpy.props.StringProperty()
|
|
nodeRef = None
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.modal_handler_add(self)
|
|
self.execute(context)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def modal(self, context, event):
|
|
if event.type == 'MOUSEMOVE':
|
|
self.nodeRef.location = context.space_data.cursor_location
|
|
elif event.type == 'LEFTMOUSE': # Confirm
|
|
return {'FINISHED'}
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def execute(self, context):
|
|
nodes = context.space_data.node_tree.nodes
|
|
node = nodes.new("LNDynamicNode")
|
|
print(context.space_data.backdrop_offset[0])
|
|
node.location = context.space_data.cursor_location
|
|
node.arm_logic_id = self.ntype
|
|
node.label = "GET " + self.ntype
|
|
node.use_custom_color = True
|
|
node.color = (0.22, 0.89, 0.5)
|
|
#node.width = 5
|
|
global nodeRef
|
|
self.nodeRef = node
|
|
return({'FINISHED'})
|
|
|
|
class ARMAddSetVarNode(bpy.types.Operator):
|
|
'''Add a node to set this Variable'''
|
|
bl_idname = 'arm.add_setvar_node'
|
|
bl_label = 'Add Set'
|
|
bl_options = {'GRAB_CURSOR', 'BLOCKING'}
|
|
|
|
ntype: bpy.props.StringProperty()
|
|
nodeRef = None
|
|
setNodeRef = None
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.modal_handler_add(self)
|
|
self.execute(context)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def modal(self, context, event):
|
|
if event.type == 'MOUSEMOVE':
|
|
self.setNodeRef.location = context.space_data.cursor_location
|
|
self.nodeRef.location[0] = context.space_data.cursor_location[0]+10
|
|
self.nodeRef.location[1] = context.space_data.cursor_location[1]-10
|
|
elif event.type == 'LEFTMOUSE': # Confirm
|
|
return {'FINISHED'}
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def execute(self, context):
|
|
nodes = context.space_data.node_tree.nodes
|
|
node = nodes.new("LNDynamicNode")
|
|
print(context.space_data.backdrop_offset[0])
|
|
node.location = context.space_data.cursor_location
|
|
node.arm_logic_id = self.ntype
|
|
node.label = "GET " + self.ntype
|
|
node.use_custom_color = True
|
|
node.color = (0.32, 0.65, 0.89)
|
|
node.bl_width_min = 3
|
|
node.width = 5
|
|
node.bl_width_min = 100
|
|
setNode = nodes.new("LNSetVariableNode")
|
|
setNode.label = "SET " + self.ntype
|
|
setNode.location = context.space_data.cursor_location
|
|
setNode.use_custom_color = True
|
|
setNode.color = (0.49, 0.2, 1.0)
|
|
links = context.space_data.node_tree.links
|
|
links.new(node.outputs[0], setNode.inputs[1])
|
|
global nodeRef
|
|
self.nodeRef = node
|
|
global setNodeRef
|
|
self.setNodeRef = setNode
|
|
return({'FINISHED'})
|
|
|
|
|
|
def replace(tree: bpy.types.NodeTree, node: bpy.types.Node):
|
|
"""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, 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 newnode 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")
|
|
|
|
# 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 replaceAll():
|
|
global replacement_errors
|
|
list_of_errors = set()
|
|
for tree in bpy.data.node_groups:
|
|
if tree.bl_idname == "ArmLogicTreeType":
|
|
for node in list(tree.nodes):
|
|
# add the list() to make a "static" copy
|
|
# (note: one can iterate it, because and nodes which get removed from the tree leave python objects in the list)
|
|
if isinstance(node, (bpy.types.NodeFrame, bpy.types.NodeReroute) ):
|
|
pass
|
|
elif node.type=='':
|
|
pass # that node has been removed from the tree without replace() being called on it somehow.
|
|
elif 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) )
|
|
elif not isinstance(type(node).arm_version, int):
|
|
list_of_errors.add( ('bad version', node.bl_idname, tree.name) )
|
|
elif 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 == '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.", 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."""
|
|
bl_idname = "node.replace"
|
|
bl_label = "Replace Nodes"
|
|
bl_description = "Replace deprecated nodes"
|
|
|
|
def execute(self, context):
|
|
replaceAll()
|
|
return {'FINISHED'}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.space_data is not None and context.space_data.type == 'NODE_EDITOR'
|
|
|
|
|
|
|
|
# https://blender.stackexchange.com/questions/150101/python-how-to-add-items-in-context-menu-in-2-8
|
|
def draw_custom_logicnode_menu(self, context):
|
|
if context.space_data.tree_type == 'ArmLogicTreeType' \
|
|
and context.selected_nodes is not None:
|
|
if len(context.selected_nodes) == 1:
|
|
if context.selected_nodes[0].bl_idname.startswith('LN'):
|
|
layout = self.layout
|
|
layout.separator()
|
|
layout.operator("arm.open_node_documentation", text="Show documentation for this node")
|
|
layout.operator("arm.open_node_source", text="Open .hx source in the browser")
|
|
layout.operator("arm.open_node_python_source", text="Open .py source in the browser")
|
|
|
|
def register():
|
|
arm.logicnode.arm_sockets.register()
|
|
|
|
bpy.utils.register_class(ArmLogicTree)
|
|
bpy.utils.register_class(ARM_PT_LogicNodePanel)
|
|
bpy.utils.register_class(ArmOpenNodeSource)
|
|
bpy.utils.register_class(ArmOpenNodePythonSource)
|
|
bpy.utils.register_class(ArmOpenNodeWikiEntry)
|
|
bpy.utils.register_class(ReplaceNodesOperator)
|
|
bpy.utils.register_class(ARM_PT_Variables)
|
|
bpy.utils.register_class(ARMAddVarNode)
|
|
bpy.utils.register_class(ARMAddSetVarNode)
|
|
ARM_MT_NodeAddOverride.overridden_draw = bpy.types.NODE_MT_add.draw
|
|
bpy.utils.register_class(ARM_MT_NodeAddOverride)
|
|
bpy.utils.register_class(ARM_OT_AddNodeOverride)
|
|
|
|
bpy.types.NODE_MT_context_menu.append(draw_custom_logicnode_menu)
|
|
|
|
register_nodes()
|
|
|
|
|
|
def unregister():
|
|
unregister_nodes()
|
|
|
|
bpy.utils.unregister_class(ReplaceNodesOperator)
|
|
bpy.utils.unregister_class(ArmLogicTree)
|
|
bpy.utils.unregister_class(ARM_PT_LogicNodePanel)
|
|
bpy.utils.unregister_class(ArmOpenNodeSource)
|
|
bpy.utils.unregister_class(ArmOpenNodePythonSource)
|
|
bpy.utils.unregister_class(ArmOpenNodeWikiEntry)
|
|
bpy.utils.unregister_class(ARM_PT_Variables)
|
|
bpy.utils.unregister_class(ARMAddVarNode)
|
|
bpy.utils.unregister_class(ARMAddSetVarNode)
|
|
bpy.utils.unregister_class(ARM_OT_AddNodeOverride)
|
|
bpy.utils.unregister_class(ARM_MT_NodeAddOverride)
|
|
|
|
bpy.types.NODE_MT_context_menu.remove(draw_custom_logicnode_menu)
|
|
|
|
arm.logicnode.arm_sockets.unregister()
|