from typing import Any, Callable import webbrowser import bl_operators import bpy import blf from bpy.props import BoolProperty, CollectionProperty, StringProperty import lnx.logicnode.lnx_nodes as lnx_nodes import lnx.logicnode.replacement import lnx.logicnode.tree_variables import lnx.logicnode.lnx_node_group import lnx.logicnode import lnx.props_traits import lnx.ui_icons as ui_icons import lnx.utils if lnx.is_reload(__name__): lnx_nodes = lnx.reload_module(lnx_nodes) lnx.logicnode.replacement = lnx.reload_module(lnx.logicnode.replacement) lnx.logicnode.tree_variables = lnx.reload_module(lnx.logicnode.tree_variables) lnx.logicnode = lnx.reload_module(lnx.logicnode) lnx.props_traits = lnx.reload_module(lnx.props_traits) ui_icons = lnx.reload_module(ui_icons) lnx.utils = lnx.reload_module(lnx.utils) else: lnx.enable_reload(__name__) INTERNAL_GROUPS_MENU_ID = 'LNX_INTERNAL_GROUPS' internal_groups_menu_class: bpy.types.Menu registered_nodes = [] registered_categories = [] class LnxLogicTree(bpy.types.NodeTree): """Logic nodes""" bl_idname = 'LnxLogicTreeType' bl_label = 'Logic Node Editor' bl_icon = 'NODETREE' def update(self): pass class LNX_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_menu: bpy.types.Menu = None overridden_draw: Callable = None def draw(self, context): if context.space_data.tree_type == 'LnxLogicTreeType': layout = self.layout # Invoke the search layout.operator_context = "INVOKE_DEFAULT" layout.operator('lnx.node_search', icon="VIEWZOOM") for category_section in lnx_nodes.category_items.values(): layout.separator() for category in category_section: safe_category_name = lnx.utils.safesrc(category.name.lower()) layout.menu(f'LNX_MT_{safe_category_name}_menu', text=category.name, icon=category.icon) if lnx.logicnode.lnx_node_group.LnxGroupTree.has_linkable_group_trees(): layout.separator() layout.menu(f'LNX_MT_{INTERNAL_GROUPS_MENU_ID}_menu', text=internal_groups_menu_class.bl_label, icon='OUTLINER_OB_GROUP_INSTANCE') else: LNX_MT_NodeAddOverride.overridden_draw(self, context) class LNX_OT_AddNodeOverride(bpy.types.Operator): bl_idname = "lnx.add_node_override" bl_label = "Add Node" bl_property = "type" bl_options = {'INTERNAL'} type: StringProperty(name="NodeItem type") use_transform: BoolProperty(name="Use Transform") settings: CollectionProperty( name="Settings", description="Settings to be applied on the newly created node", type=bl_operators.node.NodeSetting, options={'SKIP_SAVE'}, ) def invoke(self, context, event): # Passing collection properties as operator parameters only # works via raw sequences of dicts: # https://blender.stackexchange.com/a/298977/58208 # https://github.com/blender/blender/blob/cf1e1ed46b7ec80edb0f43cb514d3601a1696ec1/source/blender/python/intern/bpy_rna.c#L2033-L2043 setting_dicts = [] for setting in self.settings.values(): setting_dicts.append({ "name": setting.name, "value": setting.value, "array_index": setting.array_index }) bpy.ops.node.add_node('INVOKE_DEFAULT', type=self.type, use_transform=self.use_transform, settings=setting_dicts) 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 = lnx.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].strip() @classmethod def poll(cls, context): return context.space_data.tree_type == 'LnxLogicTreeType' and context.space_data.edit_tree def get_category_draw_func(category: lnx_nodes.LnxNodeCategory): 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("lnx.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, internal_groups_menu_class # Re-register all nodes for now.. if len(registered_nodes) > 0 or len(registered_categories) > 0: unregister_nodes() lnx.logicnode.init_nodes(subpackages_only=True) for node_type in lnx_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 lnx_nodes.add_node(bpy.types.NodeReroute, 'Layout') lnx_nodes.add_node(bpy.types.NodeFrame, 'Layout') # Generate and register category menus for category_section in lnx_nodes.category_items.values(): for category in category_section: category.sort_nodes() safe_category_name = lnx.utils.safesrc(category.name.lower()) assert(safe_category_name != INTERNAL_GROUPS_MENU_ID) # see below menu_class = type(f'LNX_MT_{safe_category_name}Menu', (bpy.types.Menu, ), { 'bl_space_type': 'NODE_EDITOR', 'bl_idname': f'LNX_MT_{safe_category_name}_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) # Generate and register group menu def draw_nodegroups_menu(self, context): layout = self.layout tree: lnx.logicnode.lnx_node_group.LnxGroupTree for tree in lnx.logicnode.lnx_node_group.LnxGroupTree.get_linkable_group_trees(): op = layout.operator('lnx.add_node_override', text=tree.name) op.type = 'LNCallGroupNode' op.use_transform = True item = op.settings.add() item.name = "group_tree" item.value = f'bpy.data.node_groups["{tree.name}"]' # Don't name categories like the content of the INTERNAL_GROUPS_MENU_ID variable! menu_class = type(f'LNX_MT_{INTERNAL_GROUPS_MENU_ID}Menu', (bpy.types.Menu,), { 'bl_space_type': 'NODE_EDITOR', 'bl_idname': f'LNX_MT_{INTERNAL_GROUPS_MENU_ID}_menu', 'bl_label': 'Node Groups', 'bl_description': 'List of node groups that can be added to the current tree', 'draw': draw_nodegroups_menu }) internal_groups_menu_class = menu_class bpy.utils.register_class(menu_class) def unregister_nodes(): global registered_nodes, registered_categories, internal_groups_menu_class for n in registered_nodes: if issubclass(n, lnx_nodes.LnxLogicTreeNode): n.on_unregister() bpy.utils.unregister_class(n) registered_nodes = [] for c in registered_categories: bpy.utils.unregister_class(c) registered_categories = [] if internal_groups_menu_class is not None: bpy.utils.unregister_class(internal_groups_menu_class) internal_groups_menu_class = None class LNX_PT_LogicNodePanel(bpy.types.Panel): bl_label = 'Leenkx Logic Node' bl_idname = 'LNX_PT_LogicNodePanel' bl_space_type = 'NODE_EDITOR' bl_region_type = 'UI' bl_category = 'Leenkx' @classmethod def poll(cls, context): return context.space_data.tree_type == 'LnxLogicTreeType' 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 is not None and context.active_node.bl_idname.startswith('LN'): layout.prop(context.active_node, 'lnx_watch') layout.separator() layout.operator('lnx.open_node_documentation', icon='HELP') column = layout.column(align=True) column.operator('lnx.open_node_python_source', icon='FILE_SCRIPT') column.operator('lnx.open_node_haxe_source', icon_value=ui_icons.get_id("haxe")) class LnxOpenNodeHaxeSource(bpy.types.Operator): """Expose Haxe source""" bl_idname = 'lnx.open_node_haxe_source' bl_label = 'Open Node Haxe 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 = lnx.utils.get_last_commit() if version == '': version = 'main' webbrowser.open(f'https://github.com/leenkx3d/leenkx/blob/{version}/leenkx/Sources/leenkx/logicnode/{name}.hx') return{'FINISHED'} class LnxOpenNodePythonSource(bpy.types.Operator): """Expose Python source""" bl_idname = 'lnx.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.lnx_version is not None: version = lnx.utils.get_last_commit() if version == '': version = 'main' rel_path = node.__module__.replace('.', '/') webbrowser.open(f'https://github.com/leenkx3d/leenkx/blob/{version}/leenkx/blender/{rel_path}.py') return{'FINISHED'} class LnxOpenNodeWikiEntry(bpy.types.Operator): """Open the logic node's documentation in the Leenkx wiki""" bl_idname = 'lnx.open_node_documentation' bl_label = 'Open Node Documentation' 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.lnx_version is not None: anchor = node.bl_label.lower().replace(" ", "-") category = lnx_nodes.eval_node_category(node) category_section = lnx_nodes.get_category(category).category_section webbrowser.open(f'https://github.com/leenkx3d/leenkx/wiki/reference_{category_section}#{anchor}') return {'FINISHED'} class LNX_PT_NodeDevelopment(bpy.types.Panel): """Sidebar panel to ease development of logic nodes.""" bl_label = 'Node Development' bl_idname = 'LNX_PT_NodeDevelopment' bl_space_type = 'NODE_EDITOR' bl_region_type = 'UI' bl_category = 'Leenkx' @classmethod def poll(cls, context): return context.space_data.tree_type == 'LnxLogicTreeType' and context.space_data.edit_tree def draw(self, context): layout = self.layout layout.use_property_split = True layout.use_property_decorate = False node = context.active_node if node is not None and node.bl_idname.startswith('LN'): box = layout.box() box.label(text='Selected Node') col = box.column(align=True) self._draw_row(col, 'bl_idname', node.bl_idname) self._draw_row(col, 'Category', lnx_nodes.eval_node_category(node)) self._draw_row(col, 'Section', node.lnx_section) self._draw_row(col, 'Specific Version', node.lnx_version) self._draw_row(col, 'Class Version', node.__class__.lnx_version) self._draw_row(col, 'Is Deprecated', node.lnx_is_obsolete) is_var_node = isinstance(node, lnx_nodes.LnxLogicVariableNodeMixin) self._draw_row(col, 'Is Variable Node', is_var_node) self._draw_row(col, 'Logic ID', node.lnx_logic_id) if is_var_node: self._draw_row(col, 'Is Master Node', node.is_master_node) layout.separator() layout.operator('lnx.node_replace_all') @staticmethod def _draw_row(col: bpy.types.UILayout, text: str, val: Any): split = col.split(factor=0.4) split.label(text=text) split.label(text=str(val)) class LNX_OT_ReplaceNodesOperator(bpy.types.Operator): bl_idname = "lnx.node_replace_all" bl_label = "Replace Deprecated Nodes" bl_description = "Replace all deprecated nodes in the active node tree" bl_options = {'REGISTER'} def execute(self, context): lnx.logicnode.replacement.replace_all() return {'FINISHED'} @classmethod def poll(cls, context): return context.space_data is not None and context.space_data.type == 'NODE_EDITOR' class LNX_UL_InterfaceSockets(bpy.types.UIList): """UI List of input and output sockets""" def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): socket = item color = socket.draw_color(context, context.active_node) if self.layout_type in {'DEFAULT', 'COMPACT'}: row = layout.row(align=True) row.template_node_socket(color=color) row.prop(socket, "display_label", text="", emboss=False, icon_value=icon) elif self.layout_type == 'GRID': layout.alignment = 'CENTER' layout.template_node_socket(color=color) class DrawNodeBreadCrumbs(): """A class to draw node tree breadcrumbs or context path""" draw_handler = None @classmethod def convert_array_to_string(cls, arr): return ' > '.join(arr) @classmethod def draw(cls, context): if context.space_data.edit_tree and context.space_data.node_tree.bl_idname == "LnxLogicTreeType": height = context.area.height path_data = [path.node_tree.name for path in context.space_data.path] str = cls.convert_array_to_string(path_data) blf.position(0, 20, height-60, 0) if bpy.app.version < (4, 1, 0): blf.size(0, 15, 72) else: blf.size(15, 72) blf.draw(0, str) @classmethod def register_draw(cls): if cls.draw_handler is not None: cls.unregister_draw() cls.draw_handler = bpy.types.SpaceNodeEditor.draw_handler_add(cls.draw, tuple([bpy.context]), 'WINDOW', 'POST_PIXEL') @classmethod def unregister_draw(cls): if cls.draw_handler is not None: bpy.types.SpaceNodeEditor.draw_handler_remove(cls.draw_handler, 'WINDOW') cls.draw_handler = None __REG_CLASSES = ( LnxLogicTree, LnxOpenNodeHaxeSource, LnxOpenNodePythonSource, LnxOpenNodeWikiEntry, LNX_OT_ReplaceNodesOperator, LNX_MT_NodeAddOverride, LNX_OT_AddNodeOverride, LNX_UL_InterfaceSockets, LNX_PT_LogicNodePanel, LNX_PT_NodeDevelopment ) __reg_classes, __unreg_classes = bpy.utils.register_classes_factory(__REG_CLASSES) def register(): lnx.logicnode.lnx_nodes.register() lnx.logicnode.lnx_sockets.register() lnx.logicnode.lnx_node_group.register() lnx.logicnode.tree_variables.register() LNX_MT_NodeAddOverride.overridden_menu = bpy.types.NODE_MT_add LNX_MT_NodeAddOverride.overridden_draw = bpy.types.NODE_MT_add.draw __reg_classes() lnx.logicnode.init_categories() DrawNodeBreadCrumbs.register_draw() register_nodes() def unregister(): unregister_nodes() DrawNodeBreadCrumbs.unregister_draw() # Ensure that globals are reset if the addon is enabled again in the same Blender session lnx_nodes.reset_globals() __unreg_classes() bpy.utils.register_class(LNX_MT_NodeAddOverride.overridden_menu) lnx.logicnode.tree_variables.unregister() lnx.logicnode.lnx_node_group.unregister() lnx.logicnode.lnx_sockets.unregister() lnx.logicnode.lnx_nodes.unregister()