# Some parts of this code is reused from project Sverchok. # https://github.com/nortikin/sverchok/blob/master/core/node_group.py # # SPDX-License-Identifier: GPL3 # License-Filename: LICENSE from functools import reduce from typing import Iterator, List, Set, Dict import bpy from bpy.props import * import bpy.types from mathutils import Vector import lnx import lnx.logicnode.lnx_nodes as lnx_nodes from lnx.logicnode.lnx_nodes import LnxLogicTreeNode import lnx.utils import lnx.props_ui if lnx.is_reload(__name__): lnx_nodes = lnx.reload_module(lnx_nodes) from lnx.logicnode.lnx_nodes import LnxLogicTreeNode lnx.utils = lnx.reload_module(lnx.utils) lnx.props_ui = lnx.reload_module(lnx.props_ui) else: lnx.enable_reload(__name__) array_nodes = lnx.logicnode.lnx_nodes.array_nodes class LnxGroupTree(bpy.types.NodeTree): """Separate tree class for subtrees""" bl_idname = 'LnxGroupTree' bl_icon = 'NODETREE' bl_label = 'Group Tree' # should be updated by "Go to edit group tree" operator group_node_name: bpy.props.StringProperty(options={'SKIP_SAVE'}) @classmethod def poll(cls, context): return False # only for internal usage def upstream_trees(self) -> List['LnxGroupTree']: """ Returns all the tree subtrees (in case if there are group nodes) and subtrees of subtrees and so on The method can help predict if linking new subtree can lead to cyclic linking """ next_group_nodes = [node for node in self.nodes if node.bl_idname == 'LNCallGroupNode'] trees = [self] safe_counter = 0 while next_group_nodes: next_node = next_group_nodes.pop() if next_node.group_tree: trees.append(next_node.group_tree) next_group_nodes.extend([ node for node in next_node.group_tree.nodes if node.bl_idname == 'LNCallGroupNode']) safe_counter += 1 if safe_counter > 1000: raise RecursionError(f'Looks like group tree "{self}" has links to itself from other groups') return trees def can_be_linked(self) -> bool: """Try to avoid creating loops of group trees with each other""" # upstream trees of tested treed should nad share trees with downstream trees of current tree tested_tree_upstream_trees = {t.name for t in self.upstream_trees()} current_tree_downstream_trees = {p.node_tree.name for p in bpy.context.space_data.path} shared_trees = tested_tree_upstream_trees & current_tree_downstream_trees return not shared_trees def update(self): pass @classmethod def get_linkable_group_trees(cls) -> Iterator['LnxGroupTree']: return filter(lambda tree: isinstance(tree, LnxGroupTree) and tree.can_be_linked(), bpy.data.node_groups) @classmethod def has_linkable_group_trees(cls) -> bool: try: _ = next(cls.get_linkable_group_trees()) except StopIteration: return False return True class LnxEditGroupTree(bpy.types.Operator): """Go into subtree to edit""" bl_idname = 'lnx.edit_group_tree' bl_label = 'Edit Group Tree' node_index: StringProperty(name='Node Index', default='') def custom_poll(self, context): if not self.node_index == '': return True if context.space_data.type == 'NODE_EDITOR': if context.active_node and hasattr(context.active_node, 'group_tree'): if context.active_node.group_tree is not None: return True return False def execute(self, context): if self.custom_poll(context): global array_nodes if not self.node_index == '': group_node = array_nodes[self.node_index] else: group_node = context.active_node sub_tree: LnxLogicTree = group_node.group_tree context.space_data.path.append(sub_tree, node=group_node) sub_tree.group_node_name = group_node.name self.node_index = '' return {'FINISHED'} return {'CANCELLED'} class LnxCopyGroupTree(bpy.types.Operator): """Create a copy of this group tree and use it""" bl_idname = 'lnx.copy_group_tree' bl_label = 'Copy Group Tree' node_index: StringProperty(name='Node Index', default='') def execute(self, context): global array_nodes group_node = array_nodes[self.node_index] group_tree = group_node.group_tree [setattr(n, 'copy_override', True) for n in group_tree.nodes if n.bl_idname in {'LNGroupInputsNode', 'LNGroupOutputsNode'}] new_group_tree = group_node.group_tree.copy() [setattr(n, 'copy_override', False) for n in group_tree.nodes if n.bl_idname in {'LNGroupInputsNode', 'LNGroupOutputsNode'}] group_node.group_tree = new_group_tree return {'FINISHED'} class LnxUnlinkGroupTree(bpy.types.Operator): """Unlink node-group (Shift + Click to set users to zero, data will then not be saved)""" bl_idname = 'lnx.unlink_group_tree' bl_label = 'Unlink Group Tree' node_index: StringProperty(name='Node Index', default='') def invoke(self, context, event): self.clear = False if event.shift: self.clear = True self.execute(context) return {'FINISHED'} def execute(self, context): global array_nodes group_node = array_nodes[self.node_index] group_tree = group_node.group_tree group_node.group_tree = None if self.clear: group_tree.user_clear() return {'FINISHED'} class LnxSearchGroupTree(bpy.types.Operator): """Browse group trees to be linked""" bl_idname = 'lnx.search_group_tree' bl_label = 'Search Group Tree' bl_property = 'tree_name' node_index: StringProperty(name='Node Index', default='') def available_trees(self, context): linkable_trees = filter(lambda t: hasattr(t, 'can_be_linked') and t.can_be_linked(), bpy.data.node_groups) return [(t.name, ('0 ' if t.users == 0 else 'F ' if t.use_fake_user else '') + t.name, '') for t in linkable_trees] tree_name: bpy.props.EnumProperty(items=available_trees) def execute(self, context): global array_nodes tree_to_link = bpy.data.node_groups[self.tree_name] group_node = array_nodes[self.node_index] group_node.group_tree = tree_to_link return {'FINISHED'} def invoke(self, context, event): context.window_manager.invoke_search_popup(self) return {'FINISHED'} class LnxAddGroupTree(bpy.types.Operator): """Create empty subtree for group node""" bl_idname = "lnx.add_group_tree" bl_label = "Add Group Tree" node_index: StringProperty(name='Node Index', default='') @classmethod def poll(cls, context): path = getattr(context.space_data, 'path', []) if len(path): if path[-1].node_tree.bl_idname in {'LnxLogicTreeType', 'LnxGroupTree'}: return True return False def execute(self, context): """Link new subtree to group node, create input and output nodes in subtree and go to edit one""" global array_nodes sub_tree = bpy.data.node_groups.new('Leenkx group', 'LnxGroupTree') # creating subtree sub_tree.use_fake_user = True group_node = array_nodes[self.node_index] group_node.group_tree = sub_tree # link subtree to group node sub_tree.nodes.new('LNGroupInputsNode').location = (-250, 0) # create node for putting data into subtree sub_tree.nodes.new('LNGroupOutputsNode').location = (250, 0) # create node for getting data from subtree context.space_data.path.append(sub_tree, node=group_node) sub_tree.group_node_name = group_node.name return {'FINISHED'} class LnxAddGroupTreeFromSelected(bpy.types.Operator): """Select nodes group node and placing them into subtree""" bl_idname = "lnx.add_group_tree_from_selected" bl_label = "Create Group Tree from Selected" @classmethod def poll(cls, context): path = getattr(context.space_data, 'path', []) if len(path): if path[-1].node_tree.bl_idname in {'LnxLogicTreeType', 'LnxGroupTree'}: return bool(cls.filter_selected_nodes(path[-1].node_tree)) return False def execute(self, context): """ Add group tree from selected: 01. Deselect group Input and Output nodes 02. Copy nodes into clipboard 03. Create group tree and move into one 04. Past nodes from clipboard 05. Move nodes into tree center 06. Add group "input" and "output" outside of bounding box of the nodes 07. TODO: Connect "input" and "output" sockets with group nodes 08. Add Group tree node in center of selected node in initial tree 09. TODO: Link the node with appropriate sockets 10. Cleaning """ base_tree = context.space_data.path[-1].node_tree sub_tree: LnxGroupTree = bpy.data.node_groups.new('Leenkx group', 'LnxGroupTree') # deselect group nodes if selected [setattr(n, 'select', False) for n in base_tree.nodes if n.select and n.bl_idname in {'LNGroupInputsNode', 'LNGroupOutputsNode'}] # Frames can't be just copied because they do not have absolute location, but they can be recreated frame_names = {n.name for n in base_tree.nodes if n.select and n.bl_idname == 'NodeFrame'} [setattr(n, 'select', False) for n in base_tree.nodes if n.bl_idname == 'NodeFrame'] # copy and past nodes into group tree bpy.ops.node.clipboard_copy() context.space_data.path.append(sub_tree) bpy.ops.node.clipboard_paste() context.space_data.path.pop() # will enter later via operator # move nodes in tree center sub_tree_nodes = self.filter_selected_nodes(sub_tree) center = reduce(lambda v1, v2: v1 + v2, [n.location for n in sub_tree_nodes]) / len(sub_tree_nodes) [setattr(n, 'location', n.location - center) for n in sub_tree_nodes] # recreate frames node_name_mapping = {n.name: n.name for n in sub_tree.nodes} # all nodes have the same name as in base tree self.recreate_frames(base_tree, sub_tree, frame_names, node_name_mapping) # add group input and output nodes min_x = min(n.location[0] for n in sub_tree_nodes) max_x = max(n.location[0] for n in sub_tree_nodes) input_node = sub_tree.nodes.new('LNGroupInputsNode') input_node.location = (min_x - 250, 0) output_node = sub_tree.nodes.new('LNGroupOutputsNode') output_node.location = (max_x + 250, 0) # add group tree node initial_nodes = self.filter_selected_nodes(base_tree) center = reduce(lambda v1, v2: v1 + v2, [Vector(LnxLogicTreeNode.absolute_location(n)) for n in initial_nodes]) / len(initial_nodes) group_node = base_tree.nodes.new('LNCallGroupNode') group_node.select = False group_node.group_tree = sub_tree group_node.location = center sub_tree.group_node_name = group_node.name # delete selected nodes and copied frames without children [base_tree.nodes.remove(n) for n in self.filter_selected_nodes(base_tree)] with_children_frames = {n.parent.name for n in base_tree.nodes if n.parent} [base_tree.nodes.remove(n) for n in base_tree.nodes if n.name in frame_names and n.name not in with_children_frames] # enter the group tree bpy.ops.lnx.edit_group_tree(node_index=group_node.get_id_str()) return {'FINISHED'} @staticmethod def filter_selected_nodes(tree) -> list: """Avoiding selecting nodes which should not be copied into subtree""" return [n for n in tree.nodes if n.select and n.bl_idname not in {'LNGroupInputsNode', 'LNGroupOutputsNode'}] @staticmethod def recreate_frames(from_tree: bpy.types.NodeTree, to_tree: bpy.types.NodeTree, frame_names: Set[str], from_to_node_names: Dict[str, str]): """ Copy frames from one tree to another from_to_node_names - mapping of node names between two trees """ new_frame_names = {n: to_tree.nodes.new('NodeFrame').name for n in frame_names} frame_attributes = ['label', 'use_custom_color', 'color', 'label_size', 'text'] for frame_name in frame_names: old_frame = from_tree.nodes[frame_name] new_frame = to_tree.nodes[new_frame_names[frame_name]] for attr in frame_attributes: setattr(new_frame, attr, getattr(old_frame, attr)) for from_node in from_tree.nodes: if from_node.name not in from_to_node_names: continue if from_node.parent and from_node.parent.name in new_frame_names: if from_node.bl_idname == 'NodeFrame': to_node = to_tree.nodes[new_frame_names[from_node.name]] else: to_node = to_tree.nodes[from_to_node_names[from_node.name]] to_node.parent = to_tree.nodes[new_frame_names[from_node.parent.name]] class TreeVarNameConflictItem(bpy.types.PropertyGroup): """Represents two conflicting tree variables with the same name""" name: StringProperty( description='The name of the conflicting tree variables' ) action: EnumProperty( name='Conflict Resolution Action', description='How to resolve the tree variable conflict', default='rename', items=[ ('rename', 'Rename', 'Automatically rename the group\'s tree variable'), ('merge', 'Merge', 'Merge the conflicting tree variables'), ] ) needs_rename: BoolProperty( description='If true, the conflict needs to be resolved by renaming' ) class LnxUngroupGroupTree(bpy.types.Operator): """Put sub nodes into current layout and delete current group node""" bl_idname = 'lnx.ungroup_group_tree' bl_label = "Ungroup Group Tree" bl_options = {'UNDO'} # Required to "un-rename" node's lnx_logic_id in case of tree variable conflicts conflicts: CollectionProperty(type=TreeVarNameConflictItem) @classmethod def poll(cls, context): if context.space_data.type == 'NODE_EDITOR': if context.active_node and hasattr(context.active_node, 'group_tree'): if context.active_node.group_tree is not None: return True return False def invoke(self, context, event): group_node = context.active_node group_tree = group_node.group_tree dest_tree = group_node.get_tree() # name -> type group_tree_variables = {} dest_tree_variables = {} for var in group_tree.lnx_treevariableslist: group_tree_variables[var.name] = var.node_type for var in dest_tree.lnx_treevariableslist: dest_tree_variables[var.name] = var.node_type # Check for conflicting tree variables self.conflicts.clear() # Might still contain values from previous invocation conflicting_var_names = group_tree_variables.keys() & dest_tree_variables.keys() user_can_choose = False for conflicting_var_name in conflicting_var_names: conflict_item = self.conflicts.add() conflict_item.name = conflicting_var_name # Tree variable types differ, cannot merge conflict_item.needs_rename = group_tree_variables[conflicting_var_name] != dest_tree_variables[conflicting_var_name] user_can_choose |= not conflict_item.needs_rename # If there are no conflicts or all conflicts _must_ be resolved # via renaming there's no reason to ask the user if user_can_choose: wm = context.window_manager return wm.invoke_props_dialog(self, width=400) return self.execute(context) def draw(self, context): layout = self.layout lnx.props_ui.draw_multiline_with_icon( layout, layout_width_px=400, icon='ERROR', text=( 'The group\'s logic tree contains tree variables whose names' ' are identical to tree variable names in the enclosing tree.' ) ) layout.label(icon='BLANK1', text='Please choose how to resolve the naming conflicts (press ESC to cancel):') layout.separator() conflict_item: TreeVarNameConflictItem for conflict_item in self.conflicts: split = layout.split(factor=0.6) split.alignment = 'RIGHT' split.label(text=conflict_item.name) if conflict_item.needs_rename: row = split.row() row.label(text="Needs rename") else: row = split.row() row.prop(conflict_item, "action", expand=True) layout.separator() # Add space above Blender's "OK" button def execute(self, context): """Similar to AddGroupTreeFromSelected operator but in backward direction (from subtree to tree)""" # go to subtree, select all except input and output groups and mark nodes to be copied group_node = context.active_node sub_tree = group_node.group_tree if len(self.conflicts) > 0: self._resolve_conflicts(sub_tree, group_node.get_tree()) bpy.ops.lnx.edit_group_tree(node_index=group_node.get_id_str()) [setattr(n, 'select', False) for n in sub_tree.nodes] group_nodes_filter = filter(lambda n: n.bl_idname not in {'LNGroupInputsNode', 'LNGroupOutputsNode'}, sub_tree.nodes) for node in group_nodes_filter: node.select = True node['sub_node_name'] = node.name # this will be copied within the nodes # the attribute should be empty in destination tree tree = context.space_data.path[-2].node_tree for node in tree.nodes: if 'sub_node_name' in node: del node['sub_node_name'] # Frames can't be just copied because they do not have absolute location, but they can be recreated frame_names = {n.name for n in sub_tree.nodes if n.select and n.bl_idname == 'NodeFrame'} [setattr(n, 'select', False) for n in sub_tree.nodes if n.bl_idname == 'NodeFrame'] if any(n for n in sub_tree.nodes if n.select): # if no selection copy operator will raise error # copy and past nodes into group tree bpy.ops.node.clipboard_copy() context.space_data.path.pop() bpy.ops.node.clipboard_paste() # this will deselect all and select only pasted nodes # move nodes in group node center tree_select_nodes = [n for n in tree.nodes if n.select] center = reduce(lambda v1, v2: v1 + v2, [Vector(LnxLogicTreeNode.absolute_location(n)) for n in tree_select_nodes]) / len(tree_select_nodes) [setattr(n, 'location', n.location - (center - group_node.location)) for n in tree_select_nodes] # recreate frames node_name_mapping = {n['sub_node_name']: n.name for n in tree.nodes if 'sub_node_name' in n} LnxAddGroupTreeFromSelected.recreate_frames(sub_tree, tree, frame_names, node_name_mapping) else: context.space_data.path.pop() # should exit from subtree anywhere # delete group node tree.nodes.remove(group_node) for node in tree.nodes: if 'sub_node_name' in node: del node['sub_node_name'] tree.update() return {'FINISHED'} def _resolve_conflicts(self, group_tree: bpy.types.NodeTree, dest_tree: bpy.types.NodeTree): rename_conflict_names = {} # old variable name -> new variable name for conflict_item in self.conflicts: if conflict_item.needs_rename or conflict_item.action == 'rename': # Initialize as empty, will be set further below rename_conflict_names[conflict_item.name] = '' for var_item in group_tree.lnx_treevariableslist: if var_item.name in rename_conflict_names: # Create a renamed variable in the destination tree and ensure # its name doesn't conflict with either tree new_name = group_tree.name + '.' + var_item.name dest_var = dest_tree.lnx_treevariableslist.add() dest_varname = lnx.utils.unique_name_in_lists( item_lists=[group_tree.lnx_treevariableslist, dest_tree.lnx_treevariableslist], name_attr='name', wanted_name=new_name, ignore_item=dest_var ) dest_var['_name'] = dest_varname rename_conflict_names[var_item.name] = dest_varname dest_var.node_type = var_item.node_type dest_var.color = var_item.color # Update the logic ids so that copying the nodes to the new tree # pastes them with references to the newly created dest_var for node in group_tree.nodes: node.lnx_logic_id = rename_conflict_names.get(node.lnx_logic_id, node.lnx_logic_id) class LnxAddCallGroupNode(bpy.types.Operator): """Create A Call Group Node""" bl_idname = 'lnx.add_call_group_node' bl_label = "Add 'Call Node Group' Node" node_ref = None @classmethod def poll(cls, context): if context.space_data.type == 'NODE_EDITOR': return context.space_data.edit_tree and context.space_data.tree_type == 'LnxLogicTreeType' return False 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.node_ref.location = context.space_data.cursor_location elif event.type == 'LEFTMOUSE': # Confirm return {'FINISHED'} return {'RUNNING_MODAL'} def execute(self, context): tree = context.space_data.path[-1].node_tree self.node_ref = tree.nodes.new('LNCallGroupNode') self.node_ref.location = context.space_data.cursor_location return {'FINISHED'} class LNX_PT_LogicGroupPanel(bpy.types.Panel): bl_label = 'Leenkx Logic Group' bl_idname = 'LNX_PT_LogicGroupPanel' 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 has_active_node(self, context): if context.active_node and hasattr(context.active_node, 'group_tree'): if context.active_node.group_tree is not None: return True return False def draw(self, context): layout = self.layout layout.operator('lnx.add_call_group_node', icon='ADD') layout.operator('lnx.add_group_tree_from_selected', icon='NODETREE') layout.operator('lnx.ungroup_group_tree', icon='NODETREE') row = layout.row() row.enabled = self.has_active_node(context) row.operator('lnx.edit_group_tree', icon='FULLSCREEN_ENTER', text='Edit Tree') __REG_CLASSES = ( LnxGroupTree, LnxEditGroupTree, LnxCopyGroupTree, LnxUnlinkGroupTree, LnxSearchGroupTree, LnxAddGroupTree, LnxAddGroupTreeFromSelected, TreeVarNameConflictItem, LnxUngroupGroupTree, LnxAddCallGroupNode, LNX_PT_LogicGroupPanel ) register, unregister = bpy.utils.register_classes_factory(__REG_CLASSES)