diff --git a/node_wrangler/interface.py b/node_wrangler/interface.py index f2ac3e8da..83560b14f 100644 --- a/node_wrangler/interface.py +++ b/node_wrangler/interface.py @@ -63,7 +63,15 @@ def drawlayout(context, layout, mode='non-panel'): col.separator() col = layout.column(align=True) - col.operator(operators.NWAlignNodes.bl_idname, icon='CENTER_ONLY') + col.operator(operators.NWAlignNodes.bl_idname, text='Auto-Align Nodes', icon='CENTER_ONLY').mode = 'AUTOMATIC' + if mode == 'panel': + row = col.row(align=True) + row.operator(operators.NWAlignNodes.bl_idname, text='Align X').mode = 'HORIZONTAL' + row.operator(operators.NWAlignNodes.bl_idname, text='Align Y').mode = 'VERTICAL' + else: + col.operator(operators.NWAlignNodes.bl_idname, text='Align X').mode = 'HORIZONTAL' + col.operator(operators.NWAlignNodes.bl_idname, text='Align Y').mode = 'VERTICAL' + col.separator() col = layout.column(align=True) diff --git a/node_wrangler/operators.py b/node_wrangler/operators.py index 6bd7de7ee..5d5924009 100644 --- a/node_wrangler/operators.py +++ b/node_wrangler/operators.py @@ -12,6 +12,7 @@ from bpy.props import ( IntProperty, StringProperty, FloatVectorProperty, + IntVectorProperty, CollectionProperty, ) from bpy_extras.io_utils import ImportHelper, ExportHelper @@ -29,7 +30,8 @@ from .utils.draw import draw_callback_nodeoutline from .utils.paths import match_files_to_socket_names, split_into_components from .utils.nodes import (node_mid_pt, autolink, node_at_pos, get_nodes_links, is_viewer_socket, is_viewer_link, get_group_output_node, get_output_location, force_update, get_internal_socket, nw_check, - nw_check_space_type, NWBase, get_first_enabled_output, is_visible_socket, viewer_socket_name) + nw_check_space_type, NWBase, get_first_enabled_output, is_visible_socket, temporary_unframe, + viewer_socket_name) class NWLazyMix(Operator, NWBase): """Add a Mix RGB/Shader node by interactively drawing lines between nodes""" @@ -2142,69 +2144,137 @@ class NWAlignNodes(Operator, NWBase): bl_idname = "node.nw_align_nodes" bl_label = "Align Nodes" bl_options = {'REGISTER', 'UNDO'} - margin: IntProperty(name='Margin', default=50, description='The amount of space between nodes') + + mode: EnumProperty( + name='Align Mode', + default='AUTOMATIC', + items=( + ('AUTOMATIC', 'Auto-Align', ''), + ('HORIZONTAL', 'Align X', ''), + ('VERTICAL', 'Align Y', ''), + ) + ) + + @classmethod + def description(cls, context, props): + if props.mode == 'AUTOMATIC': + return "Aligns nodes horizontally/vertically based on which direction takes more space" + elif props.mode == 'HORIZONTAL': + return "Aligns nodes in a row from left to right" + elif props.mode == 'VERTICAL': + return "Aligns nodes in a column from top to bottom" + + @classmethod + def poll(cls, context): + selection = (node for node in context.selected_nodes if node.type != 'FRAME') + for index, _ in enumerate(selection): + if index >= 1: + return nw_check(context) + + return False + + @staticmethod + def get_midpoint(node, axis): + reroute_width = 10 + weird_offset = 10 + + if axis == 'X': + width = reroute_width if (node.type == 'REROUTE') else node.dimensions.x + return node.location.x + (0.5 * width) + + elif axis == 'Y': + if (node.type == 'REROUTE'): + return node.location.y - (0.5 * reroute_width) + elif node.hide: + return node.location.y - weird_offset + else: + return node.location.y - (0.5 * node.dimensions.y) def execute(self, context): - nodes, links = get_nodes_links(context) - margin = self.margin + selection = [node for node in context.selected_nodes if node.type != 'FRAME'] + active_node = context.active_node + prefs = context.preferences.addons[__package__].preferences + margin_x, margin_y = prefs.align_nodes_margin - selection = [] - for node in nodes: - if node.select and node.type != 'FRAME': - selection.append(node) + # Somehow hidden nodes would come out 10 units higher that non-hidden nodes when aligned, so this offset has to exist + weird_offset = 10 - # If no nodes are selected, align all nodes - active_loc = None - if not selection: - selection = nodes - elif nodes.active in selection: - active_loc = copy(nodes.active.location) # make a copy, not a reference + # node.dimensions for reroutes indicate (16.0, 16.0) but using that in calculations puts reroutes off-center + # At least on a purely visual basis, the dimensions of a reroute node seem to be closer to 10 units. (At least for 1.0 unit scale) + reroute_width = 10 - # Check if nodes should be laid out horizontally or vertically - # use dimension to get center of node, not corner - x_locs = [n.location.x + (n.dimensions.x / 2) for n in selection] - y_locs = [n.location.y - (n.dimensions.y / 2) for n in selection] - x_range = max(x_locs) - min(x_locs) - y_range = max(y_locs) - min(y_locs) - mid_x = (max(x_locs) + min(x_locs)) / 2 - mid_y = (max(y_locs) + min(y_locs)) / 2 - horizontal = x_range > y_range + with temporary_unframe(nodes=selection): + active_loc = None + if active_node in selection: + active_loc = copy(active_node.location) # make a copy, not a reference - # Sort selection by location of node mid-point - if horizontal: - selection = sorted(selection, key=lambda n: n.location.x + (n.dimensions.x / 2)) - else: - selection = sorted(selection, key=lambda n: n.location.y - (n.dimensions.y / 2), reverse=True) + x_locs = [self.get_midpoint(n, axis='X') for n in selection] + y_locs = [self.get_midpoint(n, axis='Y') for n in selection] - # Alignment - current_pos = 0 - for node in selection: - current_margin = margin - current_margin = current_margin * 0.5 if node.hide else current_margin # use a smaller margin for hidden nodes + x_range = max(x_locs) - min(x_locs) + y_range = max(y_locs) - min(y_locs) + horizontal = x_range > y_range + + mid_x = 0.5 * (max(x_locs) + min(x_locs)) + mid_y = 0.5 * (max(y_locs) + min(y_locs)) + + # Sort selection by location of node mid-point + if horizontal: + selection.sort(key=lambda n: n.location.x + (n.dimensions.x / 2)) + else: + selection.sort(key=lambda n: n.location.y - (n.dimensions.y / 2), reverse=True) + + if self.mode != 'AUTOMATIC': + horizontal = (self.mode == 'HORIZONTAL') + + # Alignment + current_pos = 0 if horizontal: - node.location.x = current_pos - current_pos += current_margin + node.dimensions.x - node.location.y = mid_y + (node.dimensions.y / 2) - else: - node.location.y = current_pos - current_pos -= (current_margin * 0.3) + node.dimensions.y # use half-margin for vertical alignment - node.location.x = mid_x - (node.dimensions.x / 2) + for node in selection: + if node.type != 'REROUTE': + node.location.x = current_pos + node.location.y = (mid_y + weird_offset) if node.hide else mid_y + (0.5 * node.dimensions.y) - # If active node is selected, center nodes around it - if active_loc is not None: - active_loc_diff = active_loc - nodes.active.location - for node in selection: - node.location += active_loc_diff - else: # Position nodes centered around where they used to be - locs = ([n.location.x + (n.dimensions.x / 2) for n in selection] - ) if horizontal else ([n.location.y - (n.dimensions.y / 2) for n in selection]) - new_mid = (max(locs) + min(locs)) / 2 - for node in selection: - if horizontal: - node.location.x += (mid_x - new_mid) - else: - node.location.y += (mid_y - new_mid) + current_pos += margin_x + node.dimensions.x + else: + node.location.x = current_pos + (0.5 * reroute_width) + node.location.y = mid_y + + current_pos += margin_x + reroute_width + + else: + for node in selection: + if node.type != 'REROUTE': + node.location.x = mid_x - (0.5 * node.dimensions.x) + node.location.y = (current_pos - (0.5 * node.dimensions.y) + + weird_offset) if node.hide else current_pos + + current_pos -= margin_y + node.dimensions.y + else: + node.location.x = mid_x + node.location.y = current_pos - (0.5 * reroute_width) + + current_pos -= margin_y + reroute_width + + # If active node is selected, center nodes around it + if active_loc is not None: + active_loc_diff = (active_loc - active_node.location) + for node in selection: + node.location += active_loc_diff + else: + new_x_locs = [self.get_midpoint(n, axis='X') for n in selection] + new_y_locs = [self.get_midpoint(n, axis='Y') for n in selection] + + new_x_mid = 0.5 * (max(new_x_locs) + min(new_x_locs)) + new_y_mid = 0.5 * (max(new_y_locs) + min(new_y_locs)) + + x_diff = mid_x - new_x_mid + y_diff = mid_y - new_y_mid + + for node in selection: + node.location.x += x_diff + node.location.y += y_diff return {'FINISHED'} diff --git a/node_wrangler/preferences.py b/node_wrangler/preferences.py index 6ce4c57bc..ba286b3e1 100644 --- a/node_wrangler/preferences.py +++ b/node_wrangler/preferences.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later import bpy -from bpy.props import EnumProperty, BoolProperty, StringProperty +from bpy.props import EnumProperty, BoolProperty, StringProperty, IntVectorProperty from nodeitems_utils import node_categories_iter from . import operators @@ -102,11 +102,25 @@ class NWNodeWrangler(bpy.types.AddonPreferences): default=False, description="Expand this box into a list of all naming tags for principled texture setup" ) + align_nodes_margin: IntVectorProperty( + name="Margin", + default=(50, 15), + subtype="XYZ", + size=2, + min=0, + soft_min=0, + soft_max=200, + description='The amount of space between nodes during when the Align Nodes operator is called' + ) principled_tags: bpy.props.PointerProperty(type=NWPrincipledPreferences) def draw(self, context): layout = self.layout - col = layout.column() + col = layout.column(heading="Margin (Align Nodes):") + col.prop(self, "align_nodes_margin", text="") + col.separator() + + col.label(text="Merge Node Options:") col.prop(self, "merge_position") col.prop(self, "merge_hide") diff --git a/node_wrangler/utils/nodes.py b/node_wrangler/utils/nodes.py index fd2782af5..fca9da1ac 100644 --- a/node_wrangler/utils/nodes.py +++ b/node_wrangler/utils/nodes.py @@ -229,6 +229,22 @@ def is_visible_socket(socket): return not socket.hide and socket.enabled and socket.type != 'CUSTOM' +class temporary_unframe(): # Context manager for temporarily unparenting nodes from their frames + def __init__(self, nodes): + self.parent_dict = {} + for node in nodes: + if node.parent is not None: + self.parent_dict[node] = node.parent + node.parent = None + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + for node, parent in self.parent_dict.items(): + node.parent = parent + + class NWBase: @classmethod def poll(cls, context):